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

육각형 안이 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.go 는 outbound 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)다.
답글 남기기