Hexagonal Architecture(Ports & Adapters)의 핵심은 의존성 방향 제어다. 비즈니스 로직이 프레임워크나 DB에 종속되지 않도록, 모든 외부 의존성을 인터페이스(Port) 뒤로 격리한다.

Go에서는 암묵적 인터페이스와 패키지 구조 덕분에 이 패턴이 자연스럽게 구현된다.

Hexagonal Architecture

Alistair Cockburn이 제안한 이 패턴은 애플리케이션을 세 영역으로 나눈다.

Domain. 비즈니스 규칙을 담은 핵심 계층이다. 외부 기술에 의존하지 않는다.

Port. 애플리케이션과 외부 세계 사이의 인터페이스다. 두 종류가 있다.

  • Driving port(inbound): 외부에서 애플리케이션으로 들어오는 진입점. 애플리케이션이 제공하는 기능을 정의한다.
  • Driven port(outbound): 애플리케이션이 외부 시스템에 요청하는 인터페이스. 애플리케이션이 필요로 하는 것을 정의한다.

Adapter. Port의 구현체다. Driving adapter는 HTTP handler, gRPC handler처럼 외부 요청을 받아 port를 호출한다. Driven adapter는 DB repository, 메시지 브로커처럼 port 인터페이스를 구현해서 외부 시스템과 통신한다.

의존성 방향은 항상 안쪽을 향한다. Adapter → Port → Domain. Domain은 Port의 존재를 모르고, Port는 Adapter의 존재를 모른다.

flowchart LR
    subgraph Adapter["Adapter"]
        DA["Driving Adapter
REST, gRPC"] DRA["Driven Adapter
DB, Kafka"] end subgraph Port["Port"] DP["Driving Port"] DRP["Driven Port"] end subgraph Core["Domain + Application"] D["Entity"] A["UseCase / Service"] end DA -->|호출| DP DP -.->|정의| A A -->|사용| DRP DRP -.->|구현| DRA A -->|포함| D

Go 디렉토리 구조

Go에서 Hexagonal Architecture를 적용할 때 사용할 수 있는 디렉토리 구조다.

internal/
├── domain/
│   ├── entity/        # 비즈니스 엔티티
│   └── service/       # 도메인 서비스
├── port/
│   ├── driving/       # inbound 인터페이스
│   └── driven/        # outbound 인터페이스
├── application/
│   ├── usecase/       # 비즈니스 동작 단위
│   ├── dto/           # 계층 간 데이터 전달 객체
│   └── mapper/        # entity ↔ dto 변환
└── adapter/
    ├── driving/       # REST handler, gRPC handler
    └── driven/        # DB repository, 메시지 브로커

internal/ 패키지를 사용하면 외부 모듈에서 직접 접근할 수 없다. 애플리케이션의 내부 구현이 자연스럽게 캡슐화된다.

Port

Port는 Go 인터페이스로 정의한다.

Driving Port

외부에서 애플리케이션으로 들어오는 진입점이다. UseCase 단위로 정의하면 각 인터페이스가 단일 책임을 가진다.

// port/driving/messenger.go
type JoinRoomUseCase interface {
    Handle(ctx context.Context, req dto.JoinRequest) error
}

type SendMessageUseCase interface {
    Handle(ctx context.Context, req dto.SendRequest) error
}

Driven Port

애플리케이션이 외부 시스템에 요청하는 인터페이스다.

// port/driven/message.go
type MessageRepository interface {
    Create(ctx context.Context, message entity.Message) error
    FindByRoom(ctx context.Context, roomID string, cursor string, limit int) ([]entity.Message, error)
}

type MessageBroker interface {
    Publish(ctx context.Context, message entity.Message) error
    Subscribe(subscriber MessageSubscriber)
}

암묵적 인터페이스

Go의 인터페이스는 암묵적으로 구현된다. Adapter가 Port 인터페이스의 메서드를 가지고 있으면 별도 선언 없이 해당 인터페이스를 만족한다. Java의 implements 키워드가 필요 없다.

이 특성은 Hexagonal Architecture에 적합하다. Driven adapter가 driven port를 구현할 때, adapter 코드에 port 패키지를 import하지 않아도 된다. 의존성이 코드 수준에서도 분리된다.

단, 컴파일 타임에 인터페이스 구현을 보장하려면 다음과 같은 관례를 사용한다.

var _ driven.MessageRepository = (*MongoMessageRepository)(nil)

이 한 줄이 MongoMessageRepositorydriven.MessageRepository를 만족하는지 컴파일 타임에 검증한다.

Adapter

Driving Adapter

HTTP handler가 대표적인 driving adapter다. 외부 요청을 받아서 driving port(usecase)를 호출한다.

// adapter/driving/rest/handler.go
type Handler struct {
    sendUseCase driving.SendMessageUseCase
}

func NewHandler(uc driving.SendMessageUseCase) *Handler {
    return &Handler{sendUseCase: uc}
}

func (h *Handler) Send(c *gin.Context) {
    var req dto.SendRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    if err := h.sendUseCase.Handle(c.Request.Context(), req); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }
    c.Status(http.StatusOK)
}

Handler는 driving port 인터페이스에만 의존한다. 그 뒤에 어떤 구현체가 있는지 모른다.

Driven Adapter

DB repository가 대표적인 driven adapter다. Driven port 인터페이스를 구현한다.

// adapter/driven/persistence/repository.go
type MongoMessageRepository struct {
    collection *mongo.Collection
}

func NewMongoMessageRepository(db *mongo.Database) *MongoMessageRepository {
    return &MongoMessageRepository{
        collection: db.Collection("messages"),
    }
}

func (r *MongoMessageRepository) Create(ctx context.Context, message entity.Message) error {
    doc := orm.FromMessage(message)
    _, err := r.collection.InsertOne(ctx, doc)
    if err != nil {
        return fmt.Errorf("insert message: %w", err)
    }
    return nil
}

ORM 모델과 domain entity는 별도 구조체로 분리한다. orm.FromMessage()ToDomain() 메서드로 변환한다. Domain entity가 DB 구조에 종속되지 않기 위함이다.

Domain과 Application

Entity

Domain entity는 비즈니스 규칙을 포함한다. 필드를 unexported(소문자)로 선언하고 getter 메서드를 제공한다.

// domain/entity/message.go
type Message struct {
    id     string
    roomID string
    userID string
    body   string
    sentAt time.Time
}

func NewMessage(roomID, userID, body string) Message {
    return Message{
        id:     uuid.New().String(),
        roomID: roomID,
        userID: userID,
        body:   body,
        sentAt: time.Now(),
    }
}

func (m Message) ID() string     { return m.id }
func (m Message) RoomID() string { return m.roomID }
func (m Message) Body() string   { return m.body }

필드가 unexported이므로 외부에서 직접 수정할 수 없다. 생성은 NewMessage 생성자를 통해서만 가능하다. 도메인 불변 조건(invariant)을 보호한다.

UseCase

UseCase는 하나의 비즈니스 동작을 담당한다. Driving port를 구현하며, driven port에 의존한다.

// application/usecase/send.go
type SendUseCase struct {
    repo   driven.MessageRepository
    broker driven.MessageBroker
}

func NewSendUseCase(repo driven.MessageRepository, broker driven.MessageBroker) *SendUseCase {
    return &SendUseCase{repo: repo, broker: broker}
}

func (uc *SendUseCase) Handle(ctx context.Context, req dto.SendRequest) error {
    message := entity.NewMessage(req.RoomID, req.UserID, req.Body)

    if err := uc.repo.Create(ctx, message); err != nil {
        return fmt.Errorf("save message: %w", err)
    }
    if err := uc.broker.Publish(ctx, message); err != nil {
        return fmt.Errorf("publish message: %w", err)
    }
    return nil
}

UseCase는 driven port 인터페이스에만 의존한다. MongoDB든 PostgreSQL이든 MessageRepository 인터페이스를 구현하면 교체할 수 있다.

의존성 주입

Go에서는 DI 프레임워크 없이 main 함수에서 직접 의존성을 조립하는 것이 일반적이다.

func main() {
    // driven adapter
    db := mongodb.Connect(os.Getenv("MONGO_URI"))
    messageRepo := repository.NewMongoMessageRepository(db)
    broker := messaging.NewKafkaBroker(kafkaConfig)

    // usecase (driven port 주입)
    sendUseCase := usecase.NewSendUseCase(messageRepo, broker)

    // driving adapter (driving port 주입)
    handler := rest.NewHandler(sendUseCase)

    // 서버 시작
    server := rest.NewServer(handler)
    server.Run(":8080")
}

의존성 그래프가 한 곳에서 명시적으로 드러난다. 어떤 구현체가 어떤 인터페이스에 주입되는지 코드를 따라가면 바로 확인할 수 있다.

Java/Spring에서는 @Component@Autowired로 프레임워크가 의존성을 자동 주입한다. Go에서는 이 과정이 수동이지만 의존성 흐름이 명시적이고 추적이 쉽다.

정리

Hexagonal Architecture의 구현은 언어마다 관용적 방식이 다르다. Go에서는 암묵적 인터페이스, internal 패키지, 수동 DI가 이 패턴과 잘 맞는다. Port를 인터페이스로 정의하고, Adapter가 이를 구현하고, main에서 조립한다. 프레임워크 없이도 의존성 방향이 코드 구조에 그대로 드러난다.