# Go 백엔드에 헥사고날 아키텍처 적용하기 - 포트와 어댑터

플랫폼 백엔드를 만들다 보면 서비스 로직 안에 Kubernetes 클라이언트, Helm 호출, DB 쿼리가 뒤섞이는 시점이 옵니다. 이 상태에서는 단위 테스트를 쓰려면 클러스터가 필요하고, 저장소를 인메모리에서 DB로 바꾸려면 서비스 코드를 통째로 고쳐야 합니다.

헥사고날 아키텍처(포트와 어댑터)는 이 문제를 의존성 방향을 한 방향으로 고정해서 풉니다. 이 글에서는 원 개념이 무엇을 의도했는지, Go 프로젝트에서 포트와 어댑터를 어떻게 나누는지, 그리고 적용 과정에서 흔히 무너지는 지점을 정리합니다.

# 1. 원래 의도 - 대칭성

Alistair Cockburn이 정의한 이 패턴의 목적은 계층을 늘리는 것이 아닙니다. 원문의 의도는 다음 한 문장으로 요약됩니다.

Allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases.

핵심은 대칭성입니다. 애플리케이션을 실행하는 주체가 HTTP 요청이든 CLI든 테스트 코드든 동등해야 하고, 애플리케이션이 사용하는 대상이 실제 DB든 인메모리 가짜 구현이든 동등해야 합니다. "계층 3개를 만든다"가 아니라 "바깥세상과 닿는 모든 지점을 인터페이스로 만든다" 가 규칙입니다.

# 2. 인바운드 포트와 아웃바운드 포트

포트는 애플리케이션이 외부와 주고받는 인터페이스이고, 어댑터는 그 인터페이스의 구현체입니다. 방향에 따라 둘로 나뉩니다.

구분 다른 이름 누가 정의하는가
인바운드 포트 driving / primary 애플리케이션 WorkspaceService 인터페이스
아웃바운드 포트 driven / secondary 애플리케이션 WorkspaceRepository, HelmClient

두 포트 모두 애플리케이션이 정의한다는 점이 중요합니다. 아웃바운드 포트를 인프라 쪽에서 정의하면(예: DB 라이브러리 인터페이스를 그대로 사용) 의존성 방향이 뒤집혀 패턴이 무너집니다.

        인바운드 어댑터                    아웃바운드 어댑터
    ┌──────────────────┐            ┌────────────────────┐
    │ HTTP 핸들러       │            │ Postgres 리포지토리  │
    │ gRPC 서버         │            │ Helm 클라이언트      │
    │ CLI, 스케줄러      │            │ Kubernetes 클라이언트│
    └────────┬─────────┘            └──────────▲─────────┘
             │ 호출                              │ 구현
      ┌──────▼──────────────────────────────────┴──────┐
      │  인바운드 포트  →  서비스(도메인)  →  아웃바운드 포트  │
      └────────────────────────────────────────────────┘
                     의존성은 항상 안쪽으로

# 3. Go 디렉터리 구조

Go에서는 인터페이스를 사용하는 쪽 패키지에 선언하는 관례가 이 패턴과 잘 맞습니다.

internal/
├── core/
│   ├── domain/          # 엔티티. 외부 의존 0
│   ├── ports/
│   │   ├── in/          # 인바운드 포트 (서비스 인터페이스)
│   │   └── out/         # 아웃바운드 포트 (리포지토리·클라이언트 인터페이스)
│   └── services/        # 인바운드 포트 구현. out 포트에만 의존
└── adapters/
    ├── in/
    │   └── http/        # 핸들러. in 포트에 의존
    └── out/
        ├── postgres/    # out 포트 구현
        ├── helm/
        └── k8s/

포트 선언은 도메인 언어로 씁니다. 인프라 용어(SQL, Helm, Namespace)가 포트 시그니처에 등장하면 추상화가 샌 것입니다.

// internal/core/ports/out/workspace_repository.go
package out

type WorkspaceRepository interface {
    FindByID(ctx context.Context, id domain.WorkspaceID) (*domain.Workspace, error)
    Save(ctx context.Context, w *domain.Workspace) error
}

// internal/core/ports/in/workspace_service.go
package in

type WorkspaceService interface {
    Create(ctx context.Context, cmd domain.CreateWorkspace) (*domain.Workspace, error)
}

서비스는 구현체가 아니라 포트에만 의존하고, 생성자로 주입받습니다.

// internal/core/services/workspace.go
type workspaceService struct {
    repo   out.WorkspaceRepository
    deploy out.Deployer          // Helm 인지 다른 무엇인지 서비스는 모른다
}

func NewWorkspaceService(r out.WorkspaceRepository, d out.Deployer) in.WorkspaceService {
    return &workspaceService{repo: r, deploy: d}
}

# 4. 지켜야 할 규칙 세 가지

# 4-1. 의존성은 항상 안쪽으로

coreadapters를 import하지 않습니다. 이 규칙 하나만 자동 검사해도 구조가 무너지는 것을 대부분 막을 수 있습니다.

# core 가 adapters 를 참조하면 실패
go list -deps ./internal/core/... | grep -q '/internal/adapters/' \
  && { echo "의존성 방향 위반"; exit 1; } || echo OK

# 4-2. 도메인 타입만 포트를 넘나든다

핸들러가 받은 JSON 구조체(DTO)를 그대로 서비스에 넘기면, HTTP 스펙 변경이 도메인까지 파급됩니다. 어댑터 경계에서 DTO ↔ 도메인 변환을 해야 합니다.

func (h *Handler) Create(c *gin.Context) {
    var req CreateWorkspaceRequest      // 어댑터 소유 DTO
    if err := c.ShouldBindJSON(&req); err != nil { /* 400 */ }

    ws, err := h.svc.Create(c.Request.Context(), req.ToCommand())  // 도메인 타입으로 변환
    ...
    c.JSON(http.StatusOK, NewWorkspaceResponse(ws))                // 다시 DTO 로
}

# 4-3. context.Context를 포트 계약에 포함한다

포트 시그니처에 ctx를 넣어 두면 취소·타임아웃·요청 스코프 값이 어댑터까지 자연스럽게 전달됩니다. 나중에 추가하면 모든 구현체를 고쳐야 하므로 처음부터 넣는 편이 비용이 적습니다.

# 5. 이 구조가 실제로 돌려주는 것

얻는 것 구체적으로
클러스터 없는 테스트 아웃바운드 포트를 가짜 구현으로 대체
저장소 교체 인메모리 → DB 전환 시 어댑터만 추가
병렬 개발 포트 확정 후 서비스와 어댑터를 동시에 작업
장애 격리 외부 시스템 호출이 한 디렉터리에 모임

인메모리 리포지토리로 시작해 나중에 DB로 넘어가는 전개가 특히 편합니다. 서비스와 테스트는 그대로 두고 어댑터만 갈아 끼우면 됩니다.

func TestCreate_중복이름은_거부한다(t *testing.T) {
    repo := memory.NewWorkspaceRepository()   // 가짜 어댑터
    svc := services.NewWorkspaceService(repo, deploy.Noop{})
    // 클러스터도 DB도 필요 없다
}

# 6. 흔히 무너지는 지점

안티패턴 증상 교정
포트에 인프라 타입 노출 포트 시그니처에 *v1.Pod, *gorm.DB 등장 도메인 타입으로 감싼다
어댑터가 다른 어댑터 호출 HTTP 핸들러가 리포지토리를 직접 사용 반드시 서비스를 경유
포트당 구현 1개인데 인터페이스만 늘림 파일 수만 증가, 이득 없음 교체·테스트 대체 가능성이 실제로 있는 곳에만 포트
서비스가 다른 서비스를 직접 참조 순환 의존, 트랜잭션 경계 모호 상위 유스케이스로 합치거나 인바운드 포트로 호출
DTO를 도메인에 재사용 API 변경이 도메인까지 파급 경계에서 변환

세 번째가 가장 자주 나옵니다. 구현이 하나뿐이고 앞으로도 하나일 대상까지 인터페이스로 감싸는 것은 이 패턴이 요구하는 바가 아닙니다. 원 개념이 말하는 대칭성은 "실제 구현과 테스트용 구현을 바꿔 끼울 수 있는가"이고, 그 필요가 없다면 구조체를 직접 쓰는 편이 낫습니다.

# 7. 점진적으로 도입하기

이미 돌아가는 서비스에 한 번에 적용하려 하면 실패합니다. 순서를 나누면 단계마다 따로 배포할 수 있습니다.

  1. 도메인 타입 분리 - 엔티티를 별도 패키지로 옮기고 외부 의존을 제거
  2. 아웃바운드 포트 추출 - 외부 호출(DB·API·배포 도구)부터 인터페이스로 뽑고 기존 코드를 어댑터로 이동
  3. 서비스에서 구현체 제거 - 생성자 주입으로 전환, 이 시점에 단위 테스트 작성 가능
  4. 인바운드 포트 정리 - 핸들러가 서비스 인터페이스에만 의존하도록
  5. DTO 계층 분리 - API 스펙과 도메인 분리
  6. 의존성 방향 검사 자동화 - CI에 4-1의 검사 추가

2번까지만 해도 테스트 가능성은 크게 좋아집니다. 5·6번은 팀 규모가 커질 때 효과가 나타납니다.

# 8. 마무리

  • 헥사고날 아키텍처의 목표는 계층 추가가 아니라 의존성 방향을 안쪽으로 고정하는 것입니다.
  • 인바운드·아웃바운드 포트 모두 애플리케이션이 정의해야 합니다. 인프라가 인터페이스를 소유하면 방향이 뒤집힙니다.
  • 도메인 타입만 포트를 넘나들게 하고, context.Context는 처음부터 계약에 포함합니다.
  • 구현이 하나뿐이고 대체 가능성도 없는 대상까지 인터페이스로 만들 필요는 없습니다. 테스트 대체와 교체 가능성이 판단 기준입니다.

# 참고