Post

클린 & 육각형 아키텍처

의존성 방향을 고정하고 포트/어댑터로 경계를 명시해 테스트 가능성과 교체 가능성을 높이는 클린·육각형 아키텍처 규칙

클린 & 육각형 아키텍처

이 글의 결론: 클린과 육각형 아키텍처의 핵심은 ‘구조’가 아니라 의존성 방향을 강제로 고정하는 규칙이다.

이 아키텍처들은 예쁜 다이어그램을 그리기 위한 것이 아니라, 코드에서 무엇을 import할 수 있고 무엇을 절대 import하면 안 되는지를 결정하기 위한 규칙 세트다.


Clean vs Hexagonal 결론: 둘은 목적이 같고, 강조점만 다르다.

무엇이 같은가

  • 도메인 중심: 비즈니스 규칙이 프레임워크보다 안쪽에 있다.
  • 의존성 역전: 외부(웹, DB, 메시지)가 내부(도메인/유스케이스)에 의존한다.
  • 테스트 우선성: 도메인은 인프라 없이 테스트 가능해야 한다.

무엇이 다른가

관점Clean ArchitectureHexagonal 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)

이 규칙이 깨지는 순간, 도메인은 테스트 불가능해지고, 기술 교체 비용이 폭증한다.


“모듈 분리”의 목적 결론: 분리는 미학이 아니라 비용 제어 수단이다.

모듈을 나누는 이유는 단 하나다.

  1. 테스트 용이성

    • 도메인/유스케이스는 Spring 없이 JUnit으로 테스트 가능해야 한다.
  2. 변경 비용 절감

    • Kafka → 다른 브로커, JPA → 다른 ORM 변경 시 도메인/유스케이스는 무변경.
  3. 교체 가능성 확보

    • Web Adapter를 REST → gRPC로 바꿔도 UseCase는 유지.

분리가 이 3가지를 충족하지 못하면 과설계다.


실무 과설계 포인트 결론: 대부분 “미래를 과대평가”해서 생긴다.

과설계 지점 3개 + 줄이는 기준

  1. Port를 CRUD마다 쪼개는 경우

    • 문제: 인터페이스 폭발, 가독성 하락
    • 줄이는 기준:

      • 팀 ≤ 5명
      • 변경 빈도 낮음 → UseCase 단위 Port로 묶기
  2. 도메인/엔티티/DTO를 모두 분리

    • 문제: 변환 코드만 늘어남
    • 줄이는 기준:

      • 외부 노출 DTO가 내부 규칙을 오염시키지 않는 경우 → 읽기 모델에서는 DTO = Entity 허용
  3. 모든 호출을 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, PortJPA/REST 직접 호출
Port경계 인터페이스Domain 타입기술 타입 노출
Adapter기술 구현Port, Framework비즈니스 판단
Entity도메인 규칙Domain 내부@Entity, @Kafka
DTO데이터 전달Primitive로직 포함

흔한 오해 / 실수 3가지

  1. “클린/육각형 = 패키지 구조”라는 오해
    • 문제: domain / application / adapter 폴더만 나누고 의존성은 자유롭게 참조
    • 결과: 도메인이 JPA/Kafka/Spring에 오염, 테스트 불가
    • 교정 기준: import 방향을 먼저 검사한다. 패키지는 부차적이다.
  2. 모든 기술 접근을 Port로 감싸야 한다는 강박
    • 문제: 로그, 시간, 단순 유틸까지 Port 인터페이스로 추상화
    • 결과: 인터페이스 폭증, 가독성 저하
    • 교정 기준: 교체 가능성이 실제로 있는 경우만 Port로 만든다.
  3. UseCase에 비즈니스 판단을 넣는 실수
    • 문제: if (status == …) 같은 규칙이 Application Service에 존재
    • 결과: 도메인 규칙이 여러 진입점에서 분기됨
    • 교정 기준: “상태가 바뀐다” → 무조건 Domain 메서드로 이동.

대표 실패 시나리오 3가지

  1. JPA 변경이 도메인 변경으로 전파되는 경우
    • 상황: 컬럼 추가/관계 변경 → 도메인 코드 수정 불가피
    • 원인: 도메인이 JPA 엔티티와 동일 객체
    • 결과: 기술 변경이 비즈니스 변경 비용으로 전환
    • 대응: 도메인 모델과 영속 엔티티 분리 + Adapter 매핑
  2. 테스트가 전부 @SpringBootTest가 되는 경우
    • 상황: 간단한 규칙 테스트에도 컨텍스트 필요
    • 원인: 도메인이 Spring/Repository에 의존
    • 결과: 테스트 느림, 실패 원인 불명확
    • 대응: 도메인 순수 객체화, Port Mock으로 UseCase 테스트 분리
  3. Kafka/REST 변경이 유스케이스 로직을 깨는 경우
    • 상황: 메시지 포맷 변경 → UseCase 수정
    • 원인: Adapter DTO가 Application까지 침투
    • 결과: 외부 인터페이스 변경에 내부 로직 동반 수정
    • 대응: DTO/Mapper를 Adapter 내부에 고정

실무 체크리스트 (클린 & 육각형 적용 점검)

  1. Domain 패키지에서 spring, jpa, kafka import가 0개인가
  2. 상태 변경이 setter 없이 의미 있는 메서드로만 가능한가
  3. UseCase가 Controller/Listener DTO를 참조하지 않는가
  4. Port 인터페이스가 “교체 가능성” 기준으로만 존재하는가
  5. Adapter에 비즈니스 판단(if/else)이 없는가
  6. 도메인 테스트가 Spring 없이 실행되는가
  7. 기술 변경(JPA → 다른 저장소)을 가정했을 때 수정 파일 수를 예측할 수 있는가
  8. 읽기 최적화 요구가 도메인 모델을 왜곡하고 있지 않은가
  9. 패키지 구조가 의존성 방향을 강제하고 있는가
  10. 추상화 하나마다 “이걸 바꾸기 위해 존재한다”는 문장이 있는가
  11. Port가 CRUD 단위가 아니라 유스케이스 단위인가
  12. 신규 서비스에 이 구조를 복붙해도 설명 없이 이해 가능한가

This post is licensed under CC BY 4.0 by the author.