본문 바로가기
개발의 기록/Server

Go에서 Hexagonal Architecture 적용하기

by prographer J 2025. 12. 17.
728x90

핵심 요약: Hexagonal Architecture는 비즈니스 로직(Domain)을 외부 의존성(DB, API)으로부터 완전히 격리하여, 테스트와 확장이 쉬운 구조를 만드는 아키텍처 패턴입니다.

 

비즈니스 로직을 외부 의존성으로부터 격리하고 싶다면? Hexagonal Architecture를 고려해보세요.

목차

  1. Hexagonal Architecture란?
  2. 핵심 구성요소
  3. 의존성 방향
  4. 의존성 주입
  5. 확장이 쉬운 이유
  6. 테스트가 쉬운 이유
  7. Best Practices
  8. 마이크로서비스 전환

Hexagonal Architecture란?

Alistair Cockburn이 제안한 아키텍처 패턴으로, Ports and Adapters 패턴이라고도 불립니다. 핵심 아이디어는 단순합니다:

비즈니스 로직(Domain)을 외부 세계(Database, API, UI)로부터 격리한다.

 

Hexagonal Architecture

왜 "Hexagonal"인가?

육각형 모양 자체는 중요하지 않습니다. 여러 방향에서 도메인에 접근할 수 있다는 의미입니다. CLI로든, API로든, 테스트로든 동일한 비즈니스 로직을 사용할 수 있습니다.

핵심 구성요소

1. Domain (Core)

순수한 비즈니스 로직만 포함합니다. 외부 라이브러리 의존성이 없어야 합니다.

// Entity: 순수 도메인 객체
type Order struct {
    ID        string
    Items     []OrderItem
    Status    OrderStatus
    CreatedAt time.Time
}

// Domain Service: 비즈니스 규칙
func (o *Order) CanBeCancelled() bool {
    return o.Status == OrderStatusPending
}

func (o *Order) TotalAmount() int {
    total := 0
    for _, item := range o.Items {
        total += item.Price * item.Quantity
    }
    return total
}

2. Ports (Interfaces)

도메인과 외부 세계의 계약을 정의합니다.

// Input Port: 외부 → 도메인
type OrderUseCase interface {
    CreateOrder(ctx context.Context, req CreateOrderRequest) (*Order, error)
    CancelOrder(ctx context.Context, orderID string) error
}

// Output Port: 도메인 → 외부
type OrderRepository interface {
    Save(ctx context.Context, order *Order) error
    FindByID(ctx context.Context, id string) (*Order, error)
}

type PaymentGateway interface {
    Charge(ctx context.Context, amount int) error
}

3. Adapters

Port 인터페이스의 구현체입니다.

// Input Adapter: HTTP Handler
type OrderHandler struct {
    orderUseCase OrderUseCase
}

func (h *OrderHandler) HandleCreateOrder(w http.ResponseWriter, r *http.Request) {
    var req CreateOrderRequest
    json.NewDecoder(r.Body).Decode(&req)

    order, err := h.orderUseCase.CreateOrder(r.Context(), req)
    // ...
}

// Output Adapter: PostgreSQL Repository
type PostgresOrderRepository struct {
    db *sql.DB
}

func (r *PostgresOrderRepository) Save(ctx context.Context, order *Order) error {
    _, err := r.db.ExecContext(ctx,
        "INSERT INTO orders (id, status) VALUES ($1, $2)",
        order.ID, order.Status)
    return err
}

의존성 방향

핵심 규칙:

  • Domain은 Ports에만 의존
  • Ports는 Domain 타입에만 의존
  • Adapters는 Ports를 구현 (의존성 역전)

의존성 주입

모든 연결은 애플리케이션 진입점(main.go)에서 수행합니다.

func main() {
    // 1. Output Adapters 생성
    db := connectDB()
    orderRepo := postgres.NewOrderRepository(db)
    paymentGateway := stripe.NewPaymentGateway(apiKey)

    // 2. Domain Service 생성 (Ports 주입)
    orderService := service.NewOrderService(orderRepo, paymentGateway)

    // 3. Input Adapters 생성 (Domain Service 주입)
    orderHandler := api.NewOrderHandler(orderService)

    // 4. 서버 시작
    http.ListenAndServe(":8080", orderHandler)
}

확장이 쉬운 이유

새로운 데이터베이스 추가

Domain 코드 변경 없이 Adapter만 추가하면 됩니다:

// MongoDB Adapter 추가
type MongoOrderRepository struct {
    collection *mongo.Collection
}

func (r *MongoOrderRepository) Save(ctx context.Context, order *Order) error {
    _, err := r.collection.InsertOne(ctx, order)
    return err
}

// main.go에서 교체
orderRepo := mongodb.NewOrderRepository(mongoClient)
orderService := service.NewOrderService(orderRepo, paymentGateway)

새로운 입력 방식 추가

동일한 Domain Service를 재사용합니다:

// gRPC Handler 추가
type GRPCOrderHandler struct {
    orderUseCase OrderUseCase
}

func (h *GRPCOrderHandler) CreateOrder(ctx context.Context, req *pb.CreateOrderRequest) (*pb.Order, error) {
    order, err := h.orderUseCase.CreateOrder(ctx, toCreateOrderRequest(req))
    // ...
}

테스트가 쉬운 이유

Domain 테스트

외부 의존성 없이 순수하게 테스트:

func TestOrder_CanBeCancelled(t *testing.T) {
    order := &Order{Status: OrderStatusPending}

    assert.True(t, order.CanBeCancelled())

    order.Status = OrderStatusShipped
    assert.False(t, order.CanBeCancelled())
}

Service 테스트

Mock을 사용한 통합 테스트:

func TestOrderService_CreateOrder(t *testing.T) {
    mockRepo := &MockOrderRepository{}
    mockPayment := &MockPaymentGateway{}

    service := NewOrderService(mockRepo, mockPayment)

    order, err := service.CreateOrder(ctx, CreateOrderRequest{
        Items: []OrderItem{{ProductID: "123", Quantity: 2}},
    })

    assert.NoError(t, err)
    assert.Equal(t, OrderStatusPending, order.Status)
}

Best Practices

DO

  1. Port 먼저 정의 - 인터페이스부터 설계하고 구현은 나중에
  2. Domain 순수성 유지 - http, sql 같은 외부 패키지 import 금지
  3. 작은 인터페이스 - 필요한 메서드만 정의 (Interface Segregation)
  4. Context 전파 - 모든 Port 메서드에 context.Context 포함

DON'T

  1. Domain에서 Adapter import 금지
  2. 과도한 추상화 금지 - 확장 가능성이 명확할 때만 추상화
  3. God Object 생성 금지 - 거대한 Service 클래스 지양

마이크로서비스 전환

Hexagonal Architecture로 설계하면 MSA 전환이 수월합니다:

Port를 gRPC/REST로 교체하고, 각 Adapter를 별도 서비스로 분리하면 됩니다. Domain 코드는 그대로 유지됩니다.

결론

Hexagonal Architecture의 핵심은 비즈니스 로직의 격리입니다:

  • 외부 의존성 변경이 Domain에 영향을 주지 않음
  • 테스트가 쉬움 (Mock 주입)
  • 확장이 쉬움 (새 Adapter 추가)
  • MSA 전환 준비

처음에는 보일러플레이트 코드가 많아 보이지만, 프로젝트가 커질수록 그 가치를 체감하게 됩니다.

반응형

댓글