실무에서 주로 레이어드 아키텍처(Layered Architecture)를 자연스럽게 사용했습니다. Controller, Service, Repository처럼 층을 나누고 각 레이어에서 역할을 나누는 방식은 익숙하고 구조를 파악하기도 쉬웠습니다.
하지만, 시간이 지날수록 위의 구조에 대해 불편함이 생겼습니다.
- 도메인 로직이 Service나 Repository에 흩어져 도메인 규칙이 명확하지 않음
- 비즈니스 로직이 상위 레이어에 의존적으로 퍼짐
- Kafka, 외부 API, 배치 등 새로운 인프라를 기존 구조에 끼워 넣기 애매함
- 테스트 시 상위 계층의 많은 의존성 고려 필요
이런 문제를 겪으면서, 헥사고날 아키텍처(Hexagonal Architecture) 또는 포트-어댑터 아키텍처(Ports and Adapters Architecture)라는 구조를 도입하게 되었습니다.
이 글에서는 헥사고날 아키텍처 관련 개념들과 실무에서 고민되었던 부분들에 대해 기록해보고자 합니다.
헥사고날 아키텍처란⁉️
헥사고날 아키텍처는 시스템의 핵심 도메인 로직을 외부 환경과 철저히 분리하기 위해 고안된 아키텍처입니다.
🌟 핵심 요소
- 도메인 코어: 외부 기술이나 프레임워크에 전혀 의존하지 않는 순수한 비즈니스 로직 (POJO)
- 포트(Port): 도메인과 외부를 연결하는 인터페이스
- 드라이빙 포트: 외부 입력을 받아 도메인 유즈케이스 실행 (ex. REST API)
- 드리븐 포트: 도메인이 외부 기술을 사용할 때 의존하는 추상화 (ex. DB 저장, Kafka 발행)
- 어댑터(Adapter): 포트를 구현한 실제 외부 시스템 연결부
[ REST API ] [ Scheduler ]
│ │
▼ ▼
┌────────┐ ┌────────┐
│ Port │ │ Port │ ← Driving Port
└──┬─────┘ └──┬─────┘
▼ ▼
┌──────────────────────────┐
│ Domain Core │
└──────────────────────────┘
▲ ▲
┌──┴─────┐ ┌────┴───┐
│ Port │ │ Port │ ← Driven Port
└──┬─────┘ └──┬─────┘
▼ ▼
[ DB Adapter ] [ Kafka Adapter ]
도메인 로직은 이 구조 안에서 중심에 위치하며, 어떤 외부 기술도 직접 접근하지 못하도록 보호됩니다.
🧑💻 실무 코드 예시
포트 정의 (Driven Port)
public interface UserRepositoryPort {
User findById(Long id);
void save(User user);
}
애플리케이션 계층 (Application Layer)
// 인프라 계층 (Infrastructure Layer)
@Repository
public class UserJpaAdapter implements UserRepositoryPort {
private final UserJpaRepository userJpaRepository;
public UserJpaAdapter(UserJpaRepository userJpaRepository) {
this.userJpaRepository = userJpaRepository;
}
@Override
public User findById(Long id) {
return userJpaRepository.findById(id)
.map(entity -> new User(entity.getId(), entity.getName()))
.orElse(null);
}
@Override
public void save(User user) {
userJpaRepository.save(new UserEntity(user.getId(), user.getName()));
}
}
Application 서비스에서 포트 사용
// 애플리케이션 계층 (Application Layer)
public class UserService {
private final UserRepositoryPort userRepositoryPort;
public UserService(UserRepositoryPort userRepositoryPort) {
this.userRepositoryPort = userRepositoryPort;
}
public void registerUser(Long id, String name) {
User user = new User(id, name);
userRepositoryPort.save(user);
}
public User getUser(Long id) {
return userRepositoryPort.findById(id);
}
}
이 구조 덕분에 UserService는 데이터가 DB에 저장되는 방식(JPA, MyBatis, 파일 등)에 전혀 의존하지 않고, 오직 UserRepositoryPort만을 통해 동작합니다. 따라서 향후 외부 구현이 바뀌더라도 서비스 로직은 변경 없이 유지할 수 있어, 유지보수성과 테스트 용이성이 크게 향상됩니다.
레이어드 아키텍처와의 비교 ⚖️
레이어드 아키텍처 구조
[Controller]
↓
[Service Layer]
↓
[Repository]
↓
[Database / 외부 시스템]
✅ 장점
- 역할별로 레이어가 명확히 구분되어 있어 구조 파악이 쉬움
- 대부분의 프레임워크(Spring 등)와 잘 호환됨
⚠️ 한계점
- 하향식 단방향 흐름만 가능 → 유연성 부족
- 비즈니스 로직이 Service와 Repository에 혼재되어 도메인 규칙이 불분명해짐
- 외부 시스템(Kafka, Scheduler, 외부 API 등)을 추가하면 중간 계층(Service)에 과도한 책임이 집중됨
- 테스트 시 Service가 여러 의존성에 묶여 있어 단위 테스트가 어려움
헥사고날 아키텍처 구조
헥사고날 아키텍처는 도메인을 중심으로 포트와 어댑터가 이를 감싸는 구조입니다.
✅ 장점
- 도메인 로직을 중심에 두고 보호할 수 있음
- 외부 시스템과의 연결은 포트-어댑터 인터페이스를 통해 추상화되어 변경에 유연함
- 인프라 교체 시 도메인 코드 변경 없이 어댑터만 수정 가능
- 테스트 대상(도메인, 유스케이스)을 외부 의존성 없이 순수하게 테스트 가능
🔍 핵심 차이 요약
| 비교 항목 | 레이어드 아키텍처 | 헥사고날 아키텍처 |
| 구조 방식 | 하향식 단방향 흐름 | 도메인 중심의 방사형 구조 |
| 도메인 독립성 | 낮음 (Service/Repository에 혼재) | 높음 (외부와 철저히 분리) |
| 외부 시스템 확장 | 중간 계층(Service)에 부담 집중 | 포트/어댑터를 통해 유연하게 확장 가능 |
| 유지보수성 | 코드 복잡도 증가 시 하위 영향 큼 | 도메인 보호 → 변경에 유연함 |
| 테스트 용이성 | 많은 의존성으로 단위 테스트 어려움 | 순수 도메인 테스트 가능 |
도메인 모델은 왜 양파처럼 닿으면 안 되는가? 🧅
도메인 모델은 시스템의 핵심 비즈니스 규칙과 가치를 담고 있는 계층입니다.
이 계층은 외부 기술(DB, API, 프레임워크 등)로부터 완전히 독립적이어야 합니다.
하지만 다음과 같은 잘못된 연결이 일어나면 문제가 발생합니다.
❌ 문제점들
- 의존성 전이
- 외부 기술(JPA, API 클라이언트 등)의 변경이 도메인에 영향을 미쳐 수정 범위가 확산됨
- 예: JPA Entity를 도메인 객체로 바로 사용하면, JPA 변경 시 모든 코드에 영향
- 로직 누수
- 외부 시스템 처리 코드(DB 조회, API 호출 등)가 도메인 내부로 스며들어 도메인의 순수성 훼손
- 도메인이 더 이상 ‘비즈니스 규칙’만 담당하지 않고 ‘기술적인 책임’까지 떠안음
- 테스트 어려움
- 도메인이 외부 시스템에 직접 의존하게 되면, 해당 시스템 없이는 테스트조차 불가능
- 순수한 단위 테스트 작성이 불가능해지고, 항상 무거운 통합 테스트로 전락함
이 문제를 방지하기 위해, 도메인을 안쪽(Core)에 두고, 외부로 갈수록 기술적 관심사를 배치하는 양파 아키텍처(Onion Architecture)와 구조적으로 유사한 형태가 헥사고날 아키텍처이다.
🧅 Onion 구조 핵심
[ 외부 시스템 (DB, UI, API) ]
▲
[ 어댑터 (Adapter) ]
▲
[ 포트 (Port) ]
▲
[ 애플리케이션 서비스 (UseCase) ]
▲
[ 도메인 모델 (Entity, Value Object, 도메인 서비스) ]
- 가장 안쪽: 도메인 모델 (엔티티, 값 객체, 도메인 서비스)
- 그 바깥: 유즈케이스 (애플리케이션 서비스)
- 바깥쪽: 포트 → 어댑터 → 외부 시스템 (DB, UI, API 등)
💡 핵심 원칙
- 의존성 방향은 항상 바깥에서 안쪽으로만 향해야 함
- 도메인 계층은 외부 기술을 전혀 몰라야 함
- 외부 시스템을 쓰고 싶다면? 반드시 포트를 통해 호출하고 어댑터에서 구현
포트와 어댑터: 드라이빙 vs 드리븐의 철학
헥사고날 아키텍처는 도메인과 외부 세계 사이에 경계(Boundary)를 만드는 구조입니다.
이 경계를 구성하는 두 핵심 요소가 바로 포트(Port)와 어댑터(Adapter)입니다.
▶ 드라이빙 포트 (Driving Port)
`외부에서 들어오는 요청을 도메인으로 전달하는 입구`
- 사용자 입력, 스케줄러, 클라이언트 요청 등 외부의 입력을 받아 도메인 유즈케이스를 호출합니다.
- 즉, "도메인을 사용하는 주체" 입니다.
사용자 입력 → [Controller] → [Driving Port: UserUseCase] → [도메인 서비스]
◀ 드리븐 포트 (Driven Port)
`도메인이 외부 기술을 필요로 할 때 의존하는 출구`
- 도메인이 DB 저장, Kafka 발행, 이메일 발송 등의 외부 기술을 사용해야 할 때 직접 의존하지 않고 포트를 통해 요청합니다.
- 즉, "도메인을 위해 외부가 봉사하는 구조"입니다.
[도메인 서비스] → [Driven Port: UserRepositoryPort] → [DB Adapter]
💡 왜 인터페이스로 나누나?
| 구분 | 이유 |
| 유지보수 | 어댑터 구현이 변경되어도 도메인은 그대로 유지 |
| 테스트 용이 | 실제 어댑터 없이 포트 인터페이스를 Mock으로 대체 가능 |
| 의존성 분리 | 도메인은 외부 기술에 대해 ‘모른 채’ 존재할 수 있음 |
도메인 서비스 vs 애플리케이션 서비스의 역할은?
많은 개발자들이 처음 헥사고날 구조를 접하면 가장 먼저 헷갈려하는 게 바로 도메인 서비스와 애플리케이션 서비스의 역할 분리라고 생각합니다. 두 계층 모두 ‘로직’을 담당하는 것처럼 보이기 때문에 경계를 명확히 이해하지 않으면 혼재되기 쉽습니다.
아래 내용을 통해 무엇을 기준으로 구분해야 하는지 정리해 보겠습니다!
도메인 서비스란?
`비즈니스 규칙을 구현하는 순수한 서비스`
- 핵심 도메인 로직, 즉 “이게 비즈니스 상 맞는 행동인가?”를 판단하는 곳입니다.
- 하나의 엔티티로 처리하기 어려운 복합적인 도메인 로직을 다룹니다.
🔧 특징
- 도메인 객체(Value Object, Entity) 와만 협력
- 외부 기술에 전혀 의존하지 않음
- 순수 Java 객체(POJO) 로 작성 가능
- 단위 테스트가 매우 용이
📝 예시
- “연장근무 시간이 정책상 허용 범위인가?”
- “특정 날짜에 사용자가 근무 가능한 상태인가?”
애플리케이션 서비스란?
`요청 흐름을 조율하고 도메인/외부 시스템을 연결하는 서비스`
- 유저 요청을 받아서 도메인 서비스를 호출하고, 포트를 통해 외부 시스템과 통신합니다.
- 트랜잭션 시작/종료, 이벤트 발행, 로깅 등도 담당합니다.
- 유즈케이스 단위의 조정자(Coordinator) 역할을 수행합니다.
🔧 특징
- 도메인 서비스, 포트(외부 인터페이스) 를 호출
- 외부 시스템(DB, Email, Kafka 등)과의 연결 담당
- 인프라 계층에는 직접 접근하지 않음
📝 예시
- “연장근무 신청 요청 → 도메인 검증 → 저장 → 알림 발송”까지 전반적인 흐름 처리
핵심 구분 포인트
| 구분 | 도메인 서비스 | 애플리케이션 서비스 |
| 책임 | 비즈니스 규칙 | 유즈케이스 조율 |
| 외부 의존 | 없음 | 포트 기반 호출 |
| 테스트 | 단위 테스트 용이 | 통합 테스트 대상 |
👉 도메인 서비스는 로직을 “어떻게” 처리할지 고민하고, 애플리케이션 서비스는 “언제/어떤 순서로” 처리할지 고민합니다.
포트는 어디까지 나눠야 할까?
헥사고날 아키텍처에서 Port(포트) 는 시스템의 유연성과 확장성을 책임지는 핵심 경계입니다.
하지만 실무에서는 이런 고민이 따라옵니다.
“포트를 너무 많이 나누면 관리가 복잡하지 않을까?”
“모든 외부 연동을 포트로 감싸야 하나요?”
포트 분리 기준
| 상황 | 포트로 분리해야 할까? | 이유 |
| 외부 시스템과 연동 | ✅ 반드시 분리 | 외부 시스템 변경에 유연하게 대응 가능 |
| 구현 방식이 다양할 수 있음 | ✅ 추상화 필요 | ex. DB 저장 방식 변경, 이벤트 방식 변경 등 |
| 테스트에서 Mock이 필요한 경우 | ✅ 테스트 가능하게 | 단위 테스트 시 외부 의존 제거 |
| 부가적인 기능 (로깅, 슬랙 등) | ❌ 선택적 | 시스템 핵심 흐름과 분리됨 (필요 시만 도입) |
실무 기준 정리
| 외부 요소 | 포트 분리 대상 | 예시 |
| DB (JPA, MyBatis 등) | ✅ | UserRepositoryPort |
| Kafka / MQ | ✅ | EventPublisherPort |
| 외부 API | ✅ | OrganizationClientPort |
| 슬랙 알림, 로깅 등 부가 기능 | ❌ (필요시) | AlarmSenderPort (선택적) |
👉 포트는 “도메인이 외부 구현을 몰라도 동작할 수 있도록” 설계되어야 합니다.
(지나친 추상화보다는 “변경 가능성”에 집중하세요!)
Application 계층의 역할과 딜레마
헥사고날 아키텍처를 실무에 적용하다 보면, Application 서비스가 점점 비대해지는 현상을 자주 겪게 됩니다.
“왜 내 서비스 클래스는 점점 무거워질까?”
애플리케이션 계층의 역할
Application 계층은 유즈케이스 단위로 흐름을 조율하는 계층입니다.
이 계층은 아래와 같은 책임을 가집니다
- 유즈케이스 흐름 정의
- 포트 호출 (DB 저장, 메시지 발행 등)
- 트랜잭션 처리
- 인증/권한 검사
- 이벤트 발행 및 후속 처리
딜레마
이 모든 기능을 한 클래스(Service)에 몰아 넣다 보면 Application 계층이 다음과 같은 문제에 부딪힙니다.
| 문제 | 설명 |
| 🧱 조정자 역할 이상을 수행 | 흐름만 관리해야 하는데, 실제 구현까지 떠맡게 됨 |
| ⚠️ 도메인에 넣기 애매한 로직이 몰림 | 유효성 검사, 응답 조립, 정책 조정 등 |
| 🧪 테스트 복잡도 증가 | 다양한 포트를 mock 처리해야 하며 테스트 유지비용이 큼 |
👉 해결책은 다음과 같다.
| 🎯 도메인 규칙은 과감히 도메인 서비스로 분리 | 핵심 판단 로직은 도메인에게 위임 |
| 🧩 복잡한 흐름은 UseCase 단위 클래스로 분리 | SRP(단일 책임 원칙)를 적용해 응집도 향상 |
| 🛠️ 권한 검사, 응답 변환 등은 별도 유틸/헬퍼로 추출 | 흐름을 단순화하고 재사용성 확보 |
Application 계층은 유즈케이스의 조정자(Coordinator) 역할에 집중해야 합니다.
구현이 쌓이면 쌓일수록 “지저분한 컨트롤타워”가 되지 않도록, 도메인 서비스와 유틸 도구로 기능을 분산시켜 유지보수성과 테스트 용이성을 확보하세요.
인프라 계층은 얼마나 얇아야 할까?
헥사고날 아키텍처에서 인프라 계층은 기술 의존성을 담당하는 외곽 레이어입니다.
이 계층은 도메인을 돕기 위해 존재할 뿐, 도메인과는 철저히 분리되어야 합니다.
핵심 철학:
“도메인 로직은 기술을 몰라야 한다.”
→ 도메인은 DB, Redis, Kafka가 무엇인지 몰라도 작동해야 합니다.
인프라 계층의 이상적인 역할
| 역할 | 설명 |
| 🧩 Adapter만 위치 | 포트를 구현한 DB, API, 메시징 어댑터만 존재 |
| 🔌 기술 연결자 | 외부 시스템과의 통신(저장, 호출, 발행 등)만 수행 |
| ♻️ 재사용 가능한 구성 | 외부 의존성은 주입받고, 순수 구현만 작성 |
현실에서 흔한 문제
하지만 실무에서는 다음과 같은 문제들이 자주 발생합니다.
| 문제 | 설명 |
| 🧠 비즈니스 로직의 침투 | 예: Repository나 API Client에서 정책 조건 판단 |
| 🔄 로직 분산 | 트랜잭션 처리, 필터링 로직이 인프라에 몰림 |
| 🧯 도메인 책임을 떠안음 | “어디서 처리해야 할지 모르겠으니 여기에 그냥 넣자…” 현상 |
이런 문제는 도메인 계층의 순수성과 테스트 용이성을 훼손합니다.
해결 전략
- 포트에 로직을 정의하고, 어댑터는 구현만 하라
- 조건 분기, 유효성 검사 등은 포트 상위 계층에서 수행
- 트랜잭션은 애플리케이션 계층에서 관리
- 인프라에 @Transactional 붙이기 금지!
- 인프라는 Dumb하게!
- “입력 받으면 저장 or 호출만 한다” 수준으로 단순화
구조 예시 (Bad vs Good)
❌ 나쁜 예
public class UserJpaAdapter implements UserRepositoryPort {
public void save(User user) {
if (user.getAge() < 18) { // 비즈니스 로직
throw new IllegalArgumentException("미성년자 저장 불가");
}
...
}
}
✅ 좋은 예
public class UserJpaAdapter implements UserRepositoryPort {
public void save(User user) {
userJpaRepository.save(...); // 기술 처리만
}
}
// 도메인/애플리케이션 계층
if (user.getAge() < 18) throw new BusinessException(...);
userRepositoryPort.save(user);
👉 인프라는 "기술 연결자"에 집중! 비즈니스 로직이 있다면 반드시 포트를 통해 도메인 계층에서 처리되도록 설계합니다.
핵심 철학 정리
| 항목 | 설명 |
| 도메인은 중심 | 외부에 의존하지 않고, 오직 도메인 규칙과 모델만 존재 |
| 포트는 경계 | 도메인이 외부와 통신하기 위한 명세만 제공 |
| 어댑터는 구현 | 실제 외부 입출력을 담당하며, 도메인을 보호함 |
| 의존성 방향 | 항상 외부(어댑터)에서 내부(도메인)로 |
💡 헥사고날 아키텍처는 단순한 기술적 구조가 아니라, 도메인의 순수성과 독립성을 지키기 위한 철학입니다.
- 도메인은 외부 기술에 대해 아무것도 알아서는 안 되며,
- 유즈케이스(Application 서비스)는 언제 무엇을 어떤 흐름으로 실행할지 조율하고,
- 어댑터는 외부 시스템과의 연결에만 집중해야 합니다.
실무에서는 "조금만 편하게"라는 유혹이 자주 찾아오지만,
_지향점_을 명확히 설정하는 것만으로도 구조의 일관성, 유지보수성, 변화 대응력은 크게 향상됩니다.
아키텍처는 코드 구성을 넘어,비즈니스 로직을 보호하고 변화에 유연하게 대응하기 위한 방어 전략입니다.
'Architecture > DesignPatterns' 카테고리의 다른 글
| [만들면서 배우는 클린 아키텍처] 10 아키텍처 경계 강제하기 (0) | 2025.02.23 |
|---|---|
| [만들면서 배우는 클린 아키텍처] 09 애플리케이션 조립하기 (0) | 2025.02.16 |
| [만들면서 배우는 클린 아키텍처] 08 경계 간 매핑하기 (0) | 2025.02.04 |
| [만들면서 배우는 클린 아키텍처] 07 아키텍처 요소 테스트하기 (2) | 2025.01.29 |
| [만들면서 배우는 클린 아키텍처] 06 영속성 어댑터 구현하기 (0) | 2025.01.28 |