
핵심 요약: Hexagonal Architecture는 비즈니스 로직(Domain)을 외부 의존성(DB, API)으로부터 완전히 격리하여, 테스트와 확장이 쉬운 구조를 만드는 아키텍처 패턴입니다.
비즈니스 로직을 외부 의존성으로부터 격리하고 싶다면? Hexagonal Architecture를 고려해보세요.
목차
- Hexagonal Architecture란?
- 핵심 구성요소
- 의존성 방향
- 의존성 주입
- 확장이 쉬운 이유
- 테스트가 쉬운 이유
- Best Practices
- 마이크로서비스 전환
Hexagonal Architecture란?
Alistair Cockburn이 제안한 아키텍처 패턴으로, Ports and Adapters 패턴이라고도 불립니다. 핵심 아이디어는 단순합니다:
비즈니스 로직(Domain)을 외부 세계(Database, API, UI)로부터 격리한다.

왜 "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
- Port 먼저 정의 - 인터페이스부터 설계하고 구현은 나중에
- Domain 순수성 유지 - http, sql 같은 외부 패키지 import 금지
- 작은 인터페이스 - 필요한 메서드만 정의 (Interface Segregation)
- Context 전파 - 모든 Port 메서드에 context.Context 포함
DON'T
- Domain에서 Adapter import 금지
- 과도한 추상화 금지 - 확장 가능성이 명확할 때만 추상화
- God Object 생성 금지 - 거대한 Service 클래스 지양
마이크로서비스 전환
Hexagonal Architecture로 설계하면 MSA 전환이 수월합니다:

Port를 gRPC/REST로 교체하고, 각 Adapter를 별도 서비스로 분리하면 됩니다. Domain 코드는 그대로 유지됩니다.
결론
Hexagonal Architecture의 핵심은 비즈니스 로직의 격리입니다:
- 외부 의존성 변경이 Domain에 영향을 주지 않음
- 테스트가 쉬움 (Mock 주입)
- 확장이 쉬움 (새 Adapter 추가)
- MSA 전환 준비
처음에는 보일러플레이트 코드가 많아 보이지만, 프로젝트가 커질수록 그 가치를 체감하게 됩니다.
댓글