헥사고날 아키텍처

2026. 5. 20. 18:49·Backend
반응형

백엔드 코드를 처음 들여다봤을 때, 폴더 구조부터 살펴봤습니다.

controller, service, repository. 레이어드 아키텍처 특유의 3계층 구조가 눈에 들어왔고, MVC로 프론트 개발을 해온 입장에서 그나마 낯익었습니다. "아, 이 정도는 알겠다" 싶었는데, 문제는 그 옆에 있던 다른 폴더들이었습니다.

port, adapter, in, out. 같은 기능인데 파일이 왜 이렇게 많지? 싶을 정도로 인터페이스가 난무했고, 처음엔 그냥 "백엔드는 원래 파일이 많구나" 하고 넘어갔습니다. 이후 AI한테 물어보니 헥사고날 아키텍처를 적용한 거라고 했습니다.

그 이후로 개념을 잡으려고 여러 글을 찾아봤는데, 다들 설명이 너무 이론적이라 솔직히 읽어도 머릿속에 잘 안 들어왔습니다. 결국 코드 뜯어보고 AI에게 질문해가며 내용을 정리했습니다.


먼저 레이어드 아키텍처의 불편한 지점부터

헥사고날을 이해하려면 기존 레이어드가 왜 아쉬운지를 먼저 봐야 합니다.

Presentation (Controller)
      ↓
 Business (Service)
      ↓
  Data (Repository)

방향이 명확하고, 처음 배우기도 좋습니다. 그런데 실제로 쓰다 보면 Service가 TypeORM이나 JPA 같은 DB 구현체를 직접 바라보는 경우가 생깁니다. 이론적으로는 "Service는 순수해야 해"라고 하지만, 실제론 지키기가 쉽지 않거든요.

결국 DB를 바꾸거나 테스트 환경에서 DB를 교체하고 싶어지면 Service 코드까지 건드려야 하는 상황이 옵니다. 비즈니스 로직이 인프라 세부사항에 물려있는 거죠.

레이어드의 의존 방향은 항상 위에서 아래로 흐릅니다. 헥사고날은 이 방향 자체를 문제로 봤습니다.


헥사고날 아키텍처가 다른 점

Alistair Cockburn이 제안한 패턴으로, Ports and Adapters라고도 부릅니다. 이름에서 이미 힌트가 다 있는데, 핵심은 비즈니스 로직을 외부 세계로부터 완전히 격리하겠다는 겁니다.

그리고 외부와 소통할 때는 반드시 Port(포트) 라는 인터페이스를 끼워넣습니다. 실제 DB 연결이나 HTTP 처리 같은 구현 세부사항은 Adapter(어댑터) 에 가둬두고요.

 
[ HTTP 요청 / CLI / Event... ]
            ↓
     [ Driving Adapter ]       ← 외부 요청을 받아서 안으로 전달
            ↓
    [ Input Port (Interface) ]
            ↓
      [ Application / Domain ] ← 여기가 핵심, 외부를 아무것도 모름
            ↓
    [ Output Port (Interface) ]
            ↓
     [ Driven Adapter ]        ← 도메인의 요청을 받아서 외부로 전달
            ↓
[ DB / 외부 API / 이메일... ]

도메인은 위도 아래도 직접 바라보지 않습니다. 인터페이스만 바라봅니다. 그리고 인터페이스를 실제로 채우는 건 Adapter가 합니다.


Port를 프론트 시각으로 이해하기

처음 "Port가 뭔데?"에서 막혔습니다. 근데 생각해 보니까 프론트에서도 비슷한 걸 경험한 적이 있었습니다.

axios를 쓰다가 fetch나 다른 라이브러리로 바꿔야 할 때, HTTP 클라이언트를 직접 컴포넌트에서 부르면 나중에 갈아엎기가 너무 힘들어집니다. 그래서 get, post 같은 메서드를 가진 추상화 레이어를 하나 두고, 실제 구현은 뒤에서 갈아끼울 수 있게 만드는 패턴이요. Port가 그겁니다. 비즈니스 로직이 기대하는 "동작의 약속"만 정의해두고, 뒤에 뭘 연결할지는 신경 안 쓰는 것.


코드로 보면 이렇게 생겼습니다

TypeScript 기반으로 흐름만 따라가 보겠습니다.

도메인 — 아무것도 모릅니다

 
 
typescript
// domain/User.ts
export class User {
  constructor(
    public readonly id: string,
    public readonly email: string,
    public readonly name: string,
  ) {}

  static create(email: string, name: string): User {
    if (!email.includes('@')) {
      throw new Error('유효하지 않은 이메일입니다.');
    }
    return new User(crypto.randomUUID(), email, name);
  }
}

DB도, HTTP도, 어떤 외부 의존도 없습니다. 비즈니스 규칙만 있습니다.

Output Port — 도메인이 외부에 거는 약속

 
 
typescript
// port/out/UserRepository.ts
export interface UserRepository {
  save(user: User): Promise<void>;
  findById(id: string): Promise<User | null>;
}

도메인은 이 인터페이스만 바라봅니다. 뒤에서 MySQL을 쓰는지 PostgreSQL을 쓰는지 전혀 모릅니다.

Application — 유즈케이스 구현

 
 
typescript
// application/CreateUserService.ts
export class CreateUserService implements CreateUserUseCase {
  constructor(private readonly userRepository: UserRepository) {}

  async execute(email: string, name: string): Promise<User> {
    const user = User.create(email, name);
    await this.userRepository.save(user);
    return user;
  }
}

userRepository가 인터페이스라는 게 포인트입니다. 테스트할 때 Mock으로 교체하거나, DB를 바꿔도 이 코드는 손댈 필요가 없습니다.

Driven Adapter — 약속을 실제로 채우는 구현체

 
 
typescript
// adapter/out/persistence/TypeORMUserRepository.ts
export class TypeORMUserRepository implements UserRepository {
  async save(user: User): Promise<void> {
    await this.dataSource.getRepository(UserEntity).save(/* ... */);
  }

  async findById(id: string): Promise<User | null> {
    // TypeORM으로 실제 DB 조회
  }
}

Driving Adapter — 외부 요청을 도메인으로 연결

 
 
typescript
// adapter/in/web/UserController.ts
export class UserController {
  constructor(private readonly createUserUseCase: CreateUserUseCase) {}

  async createUser(req: Request, res: Response) {
    const { email, name } = req.body;
    const user = await this.createUserUseCase.execute(email, name);
    res.status(201).json(user);
  }
}

컨트롤러도 유즈케이스 인터페이스만 바라봅니다. HTTP 요청을 도메인 언어로 번역해주는 역할만 합니다.


공부하면서 실제로 헷갈렸던 것들

Driving vs Driven이 계속 헷갈렸습니다

이름이 비슷해서 읽어도 잘 안 외워졌는데, 방향으로 기억하니까 정리가 됐습니다. 외부 요청이 도메인 안으로 들어오면 Driving, 도메인이 외부로 나가면 Driven. 컨트롤러는 Driving, DB는 Driven.

파일이 너무 많아지는 게 부담이었습니다

인터페이스랑 구현체가 분리되다 보니 같은 기능인데 파일이 확연히 늘어납니다. 처음엔 이게 좋은 건지 헷갈렸는데, 테스트 코드를 짜보면서 이해가 됐습니다. DB 없이 Application 레이어만 단독으로 테스트할 수 있다는 게 실제로 체감이 됐거든요. 반면 도메인 복잡도가 별로 없고 외부 의존이 자주 안 바뀌는 프로젝트라면 솔직히 오버엔지니어링일 수 있습니다.

레이어드랑 비교해서 뭐가 다른지 헷갈렸습니다

레이어드 아키텍처헥사고날 아키텍처
의존 방향 위 → 아래 외부 → 도메인 방향으로
도메인 순수성 인프라에 종속될 가능성 있음 완전히 격리
DB 교체 비용 Service까지 영향 가능 Adapter만 교체
테스트 DB 없이 테스트하기 까다로울 수 있음 Mock Adapter로 쉽게 교체
초기 설계 비용 낮음 상대적으로 높음

마치며

솔직히 처음엔 "왜 이렇게 복잡하게 쓰나" 싶었습니다. 인터페이스 하나 쓰려고 파일을 이렇게 많이 만들어야 하나 싶기도 했고요. 근데 테스트 코드를 짜면서 DB 없이 비즈니스 로직만 검증할 수 있다는 걸 직접 경험하고 나니, 이 구조가 왜 나왔는지 조금은 이해가 됐습니다.

프론트에서 컴포넌트 경계 설계를 고민하는 것처럼, 백엔드는 의존 방향으로 아키텍처를 설계한다는 감각이 생긴 것 같습니다.

반응형

'Backend' 카테고리의 다른 글

쉬어가면서 — 앞으로 작성할 백엔드 주제들  (0) 2026.05.11
[Backend] 서버 이론 공부  (0) 2023.06.13
'Backend' 카테고리의 다른 글
  • 쉬어가면서 — 앞으로 작성할 백엔드 주제들
  • [Backend] 서버 이론 공부
Leo(상원)
Leo(상원)
나의 공부를 기록하며, 성장의 밑거름이 되도록 나와함께 성장하는 블로그
    반응형
  • Leo(상원)
    Leo Blog
    Leo(상원)
  • 전체
    오늘
    어제
    • IT 개발 공부 (83)
      • CS (3)
      • 내일배움캠프 (42)
      • 자료구조 (1)
      • JavaScript (9)
      • Frontend (14)
      • Backend (3)
      • FT-면접질문 (4)
      • Web (1)
      • Etc (5)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    빌드 툴
    use client
    react19
    Server Components
    React Compiler
    Webpack
    Vite
    next16
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
Leo(상원)
헥사고날 아키텍처
상단으로

티스토리툴바