클린 & 육각형 아키텍처
의존성 방향을 고정하고 포트/어댑터로 경계를 명시해 테스트 가능성과 교체 가능성을 높이는 클린·육각형 아키텍처 규칙
Posted
By okorion
클린 & 육각형 아키텍처
이 글의 결론: 클린과 육각형 아키텍처의 핵심은 ‘구조’가 아니라 의존성 방향을 강제로 고정하는 규칙이다.
이 아키텍처들은 예쁜 다이어그램을 그리기 위한 것이 아니라, 코드에서 무엇을 import할 수 있고 무엇을 절대 import하면 안 되는지를 결정하기 위한 규칙 세트다.
Clean vs Hexagonal 결론: 둘은 목적이 같고, 강조점만 다르다.
무엇이 같은가
- 도메인 중심: 비즈니스 규칙이 프레임워크보다 안쪽에 있다.
- 의존성 역전: 외부(웹, DB, 메시지)가 내부(도메인/유스케이스)에 의존한다.
- 테스트 우선성: 도메인은 인프라 없이 테스트 가능해야 한다.
무엇이 다른가
| 관점 | Clean Architecture | Hexagonal Architecture |
|---|---|---|
| 핵심 개념 | 레이어(Entities, Use Cases, Interface Adapters) | 포트(Port)와 어댑터(Adapter) |
| 사고 방식 | “안으로 갈수록 순수해야 한다” | “외부와의 접점은 포트로만” |
| 강조점 | 의존성 방향 규칙 | 경계(입·출력) 명시성 |
| 실무 적용 | 레이어 기준 패키징 | 포트 기준 인터페이스 설계 |
정리하면, Clean은 원칙, Hexagonal은 표현 방식에 가깝다. Spring Boot에서는 보통 Clean의 의존성 규칙 + Hexagonal의 포트/어댑터 구조를 함께 쓴다.
의존성 방향 규칙 결론: “안쪽은 바깥을 몰라야 하고, 바깥이 안쪽에 맞춘다.”
허용되는 방향
1
2
3
4
5
6
7
Domain
↑
Application (UseCase)
↑
Adapter (Web, Persistence, Messaging)
↑
Framework / Infrastructure (Spring, JPA, Kafka)
금지되는 방향(절대)
- ❌ Domain → JPA Entity / Repository
- ❌ Domain → Kafka Producer / Consumer
- ❌ UseCase → Controller / REST DTO
- ❌ Domain → Spring Annotation (
@Entity,@Transactional)
이 규칙이 깨지는 순간, 도메인은 테스트 불가능해지고, 기술 교체 비용이 폭증한다.
“모듈 분리”의 목적 결론: 분리는 미학이 아니라 비용 제어 수단이다.
모듈을 나누는 이유는 단 하나다.
테스트 용이성
- 도메인/유스케이스는 Spring 없이 JUnit으로 테스트 가능해야 한다.
변경 비용 절감
- Kafka → 다른 브로커, JPA → 다른 ORM 변경 시 도메인/유스케이스는 무변경.
교체 가능성 확보
- Web Adapter를 REST → gRPC로 바꿔도 UseCase는 유지.
분리가 이 3가지를 충족하지 못하면 과설계다.
실무 과설계 포인트 결론: 대부분 “미래를 과대평가”해서 생긴다.
과설계 지점 3개 + 줄이는 기준
Port를 CRUD마다 쪼개는 경우
- 문제: 인터페이스 폭발, 가독성 하락
줄이는 기준:
- 팀 ≤ 5명
- 변경 빈도 낮음 → UseCase 단위 Port로 묶기
도메인/엔티티/DTO를 모두 분리
- 문제: 변환 코드만 늘어남
줄이는 기준:
- 외부 노출 DTO가 내부 규칙을 오염시키지 않는 경우 → 읽기 모델에서는 DTO = Entity 허용
모든 호출을 Port로 감싸는 경우
- 문제: 추상화의 목적 상실
줄이는 기준:
- 교체 가능성 거의 없음
- 기술 의존이 문제 안 되는 영역 → 직접 호출 허용
나쁜 예 결론: “도메인이 기술 디테일을 알면 이미 패배다.”
❌ 나쁜 구조 (오염된 도메인)
1
2
3
4
5
6
7
8
9
10
11
12
13
@Entity
public class Order {
@Id
private Long id;
@KafkaListener(topics = "payment")
public void handlePayment(PaymentEvent event) {
if (event.isApproved()) {
this.status = OrderStatus.APPROVED;
}
}
}
문제점:
- 도메인이 JPA와 Kafka를 직접 의존
- 테스트하려면 Spring + Kafka 필요
- Order는 “비즈니스 규칙”이 아니라 “프레임워크 객체”가 됨
개선 예 결론: “도메인은 순수하게, 기술은 바깥으로 밀어낸다.”
✅ Port / Adapter 분리 구조
1
2
3
4
5
6
// Domain
public class Order {
public void approve() {
this.status = OrderStatus.APPROVED;
}
}
1
2
3
4
5
6
7
8
9
10
11
// Application
public class ApproveOrderUseCase {
private final OrderRepositoryPort orderRepository;
public void execute(Long orderId) {
Order order = orderRepository.load(orderId);
order.approve();
orderRepository.save(order);
}
}
1
2
3
4
5
// Adapter
@Repository
public class JpaOrderRepository implements OrderRepositoryPort {
// JPA 구현
}
핵심:
- 도메인은 비즈니스 규칙만
- UseCase는 흐름과 조합
- 기술 세부사항은 Adapter로 격리
테스트 전략 결론: “테스트 종류는 의존성 경계로 나뉜다.”
도메인 단위 테스트
- 대상: Entity, Domain Service
특징:
- Spring ❌
- DB ❌
- 빠르고 많이 실행
유스케이스 테스트
- 대상: Application Service
특징:
- Port는 Mock/Fake
- 흐름 검증 중심
통합 테스트
- 대상: Adapter + Framework
특징:
- Spring Boot Test
- Testcontainers(DB/Kafka)
- 개수 최소화
구성 요소별 규칙 표 결론: “각 역할마다 허용 의존성을 명확히 고정하라.”
| 구성 요소 | 역할 | 허용 의존성 | 안티패턴 |
|---|---|---|---|
| Controller | 외부 요청 수신 | UseCase | 비즈니스 로직 포함 |
| UseCase | 유스케이스 흐름 | Domain, Port | JPA/REST 직접 호출 |
| Port | 경계 인터페이스 | Domain 타입 | 기술 타입 노출 |
| Adapter | 기술 구현 | Port, Framework | 비즈니스 판단 |
| Entity | 도메인 규칙 | Domain 내부 | @Entity, @Kafka |
| DTO | 데이터 전달 | Primitive | 로직 포함 |
흔한 오해 / 실수 3가지
- “클린/육각형 = 패키지 구조”라는 오해
- 문제:
domain / application / adapter폴더만 나누고 의존성은 자유롭게 참조 - 결과: 도메인이 JPA/Kafka/Spring에 오염, 테스트 불가
- 교정 기준: import 방향을 먼저 검사한다. 패키지는 부차적이다.
- 문제:
- 모든 기술 접근을 Port로 감싸야 한다는 강박
- 문제: 로그, 시간, 단순 유틸까지 Port 인터페이스로 추상화
- 결과: 인터페이스 폭증, 가독성 저하
- 교정 기준: 교체 가능성이 실제로 있는 경우만 Port로 만든다.
- UseCase에 비즈니스 판단을 넣는 실수
- 문제:
if (status == …)같은 규칙이 Application Service에 존재 - 결과: 도메인 규칙이 여러 진입점에서 분기됨
- 교정 기준: “상태가 바뀐다” → 무조건 Domain 메서드로 이동.
- 문제:
대표 실패 시나리오 3가지
- JPA 변경이 도메인 변경으로 전파되는 경우
- 상황: 컬럼 추가/관계 변경 → 도메인 코드 수정 불가피
- 원인: 도메인이 JPA 엔티티와 동일 객체
- 결과: 기술 변경이 비즈니스 변경 비용으로 전환
- 대응: 도메인 모델과 영속 엔티티 분리 + Adapter 매핑
- 테스트가 전부 @SpringBootTest가 되는 경우
- 상황: 간단한 규칙 테스트에도 컨텍스트 필요
- 원인: 도메인이 Spring/Repository에 의존
- 결과: 테스트 느림, 실패 원인 불명확
- 대응: 도메인 순수 객체화, Port Mock으로 UseCase 테스트 분리
- Kafka/REST 변경이 유스케이스 로직을 깨는 경우
- 상황: 메시지 포맷 변경 → UseCase 수정
- 원인: Adapter DTO가 Application까지 침투
- 결과: 외부 인터페이스 변경에 내부 로직 동반 수정
- 대응: DTO/Mapper를 Adapter 내부에 고정
실무 체크리스트 (클린 & 육각형 적용 점검)
- Domain 패키지에서
spring,jpa,kafkaimport가 0개인가 - 상태 변경이 setter 없이 의미 있는 메서드로만 가능한가
- UseCase가 Controller/Listener DTO를 참조하지 않는가
- Port 인터페이스가 “교체 가능성” 기준으로만 존재하는가
- Adapter에 비즈니스 판단(if/else)이 없는가
- 도메인 테스트가 Spring 없이 실행되는가
- 기술 변경(JPA → 다른 저장소)을 가정했을 때 수정 파일 수를 예측할 수 있는가
- 읽기 최적화 요구가 도메인 모델을 왜곡하고 있지 않은가
- 패키지 구조가 의존성 방향을 강제하고 있는가
- 추상화 하나마다 “이걸 바꾸기 위해 존재한다”는 문장이 있는가
- Port가 CRUD 단위가 아니라 유스케이스 단위인가
- 신규 서비스에 이 구조를 복붙해도 설명 없이 이해 가능한가
This post is licensed under CC BY 4.0 by the author.
