[아키텍처 #2] Hexagonal(Ports & Adapters)와 MessageQueue — Consumer·Publisher는 어느 레이어에?

Go 아키텍처 시리즈

  1. #1 Clean Architecture
  2. #2 Hexagonal & MessageQueue — 지금 이 글
  3. #3 Event-Driven Architecture
  4. #4 Microservices(MSA)

1편의 동심원을 옆으로 눕혀 육각형으로 그리면 Hexagonal Architecture(= Ports & Adapters)가 된다. 개념은 같다 — 안쪽(Application)은 바깥(기술)을 모른다. 다만 Hexagonal은 “안팎을 잇는 경계 = 포트(interface), 그 포트에 꽂히는 구현 = 어댑터”라는 어휘를 준다. 이 어휘 덕분에 이 글의 진짜 질문에 깔끔히 답할 수 있다: 메시지큐를 쓸 때 Consumer와 Publisher는 어느 레이어에 정의하나?

1. 한 장으로 보는 그림

Hexagonal — 왼쪽 구동 어댑터 / 오른쪽 피구동 어댑터, MQ의 위치 (원본 제작)
Hexagonal — 왼쪽 구동 어댑터 / 오른쪽 피구동 어댑터, MQ의 위치 (원본 제작)

육각형 안이 Application(UseCase + Domain). 왼쪽은 애플리케이션을 호출하는 쪽(구동, Driving/Primary), 오른쪽은 애플리케이션이 불러 쓰는 쪽(피구동, Driven/Secondary)이다. 이 좌우 구분이 MQ의 위치를 가른다.

2. 두 종류의 포트/어댑터

구분 구동(Primary/Driving) 피구동(Secondary/Driven)
누가 누구를 어댑터 → Application 호출 Application → 어댑터 호출
포트 종류 inbound port outbound port
포트를 소유 UseCase가 제공(구현) UseCase가 정의, 바깥이 구현
예시 HTTP 핸들러, MQ Consumer, CLI Repository, MQ Publisher, 외부 API

3. 그래서 답 — MQ는 좌우로 쪼개서 본다

“메시지큐”를 한 덩어리로 보면 헷갈린다. Consumer와 Publisher는 방향이 정반대라서 서로 다른 레이어에 산다.

3-1. Consumer = 구동(Primary) 어댑터

Consumer는 브로커에서 메시지를 받아 UseCase를 호출한다. 즉 HTTP 핸들러와 완전히 같은 자리다 — 트랜스포트만 HTTP에서 Kafka로 바뀌었을 뿐. internal/adapter/messaging에 두고, 안쪽의 inbound port를 호출한다.

// internal/adapter/messaging/order_consumer.go  (Primary Adapter)
package messaging

type OrderConsumer struct { uc usecase.OrderUseCase }   // inbound port 호출

func (c *OrderConsumer) Handle(ctx context.Context, m kafka.Message) error {
    var evt OrderPlacedEvent
    if err := json.Unmarshal(m.Value, &evt); err != nil { return err }
    return c.uc.Fulfill(ctx, evt.OrderID)   // HTTP 핸들러와 똑같이 UseCase만 부른다
}

3-2. Publisher = outbound port(안) + 피구동 어댑터(바깥)

Publisher는 두 조각으로 나뉜다. 인터페이스는 UseCase 패키지(안쪽)에 정의하고, 구현은 어댑터(바깥)에 둔다. UseCase는 브로커를 모른 채 인터페이스만 호출한다.

// internal/usecase/event_publisher.go  ← outbound port, UseCase가 소유
package usecase

type EventPublisher interface {                 // 안쪽에 "정의"
    Publish(ctx context.Context, topic string, payload any) error
}

// internal/usecase/order_usecase.go
func (u *orderUC) Place(ctx context.Context, id string) error {
    // ... 도메인 처리 ...
    return u.pub.Publish(ctx, "order.created", OrderCreated{ID: id})  // Kafka를 모른다
}
// internal/adapter/messaging/kafka_publisher.go  ← 구현, 여기서만 SDK import
package messaging

type KafkaPublisher struct { w *kafka.Writer }
func (p *KafkaPublisher) Publish(ctx context.Context, topic string, payload any) error {
    b, _ := json.Marshal(payload)
    return p.w.WriteMessages(ctx, kafka.Message{Topic: topic, Value: b})
}

4. 폴더 구조 — 좌우(구동/피구동)를 디렉터리로

육각형의 왼쪽(구동)과 오른쪽(피구동)을 폴더로 옮기면, 앞서 나눈 Consumer·Publisher 의 자리가 눈에 보인다. 포트는 전부 안쪽(usecase/)에 모으고, 어댑터는 방향에 따라 가른다.

project/
├─ cmd/server/main.go            # 조립: 어댑터를 포트에 꽂는다
└─ internal/
   ├─ domain/                    # 육각형 안 — 순수 도메인
   │  └─ order.go
   ├─ usecase/                   # 육각형 안 — Application + 모든 포트를 소유
   │  ├─ order_usecase.go
   │  ├─ order_repository.go     # outbound port (피구동)
   │  └─ event_publisher.go      # ★ outbound port — Publisher 인터페이스는 안쪽
   └─ adapter/                   # 육각형 바깥 — 좌우로 나뉜다
      ├─ http/                   # ← 구동(Primary): 들어오는 쪽
      │  └─ order_handler.go
      ├─ messaging/              #   MQ 한 폴더, 그러나 파일은 좌우로 갈린다
      │  ├─ order_consumer.go    # ← 구동(Primary): inbound port 호출
      │  └─ kafka_publisher.go   # → 피구동(Secondary): outbound port 구현
      └─ repository/             # → 피구동(Secondary): 나가는 쪽
         └─ order_repo.go
  • 왼쪽(구동·들어오는)http/messaging/order_consumer.go. 애플리케이션의 inbound port 를 호출한다.
  • 오른쪽(피구동·나가는)repository/messaging/kafka_publisher.go. usecase/ 가 정의한 outbound port 를 구현한다.

가장 눈여겨볼 지점은 messaging/ 폴더는 하나지만 그 안의 파일은 좌우로 갈린다는 것이다. order_consumer.go 는 구동 어댑터(HTTP 핸들러와 같은 자리), kafka_publisher.go 는 피구동 어댑터다. §3에서 “MQ 는 좌우로 쪼갠다”고 한 말이 폴더에서 이렇게 모습을 드러낸다. 덕분에 브로커 SDK 에 대한 import 는 오직 adapter/messaging/ 안에만 존재한다.

4-1. outbound port 다시 보기 — repo 인터페이스(안) vs repo 구현(밖)

Repository 도 Publisher(§3-2)와 정확히 같은 구조다. usecase/order_repository.gooutbound port(인터페이스)로 육각형 에 있고, adapter/repository/order_repo.go 는 그 포트를 구현한 피구동 어댑터로 육각형 에 있다.

그래서 “usecase 의 repo 가 DB 쿼리를 담당하나?”의 답은 아니오다. 실제 쿼리는 바깥의 adapter repo 가 실행하고, 안쪽 usecase repo 는 무엇이 필요한지만 선언하는 계약이다. 흐름(제어)은 UseCase → Repository 로 나가지만, 의존성(import)은 Adapter → UseCase 로 역전된다 — 이 역전이 육각형의 핵심이다.

5. 정리 — 한 문장으로

Consumer는 “들어오는 쪽” 어댑터라 HTTP 핸들러와 같은 레이어(구동 어댑터 → inbound port),
Publisher는 인터페이스를 UseCase가 소유(outbound port)하고 구현만 어댑터에 둔다.

이렇게 하면 브로커 SDK(kafka-go, amqp)에 대한 import는 오직 adapter/messaging 한 곳에만 존재한다. UseCase·Domain은 여전히 순수하고, 테스트에서는 EventPublisher를 fake로 갈아 끼우면 된다. 브로커를 Kafka→RabbitMQ로 바꿔도 UseCase는 그대로다.

다음 편은 이 Publisher/Consumer가 서비스 사이로 확장됐을 때의 그림, Event-Driven Architecture(EDA)다.

참고

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다