헥사고날 아키텍처 내부는 애플리케이션 컴포넌트로 나눈다

헥사고날 아키텍처를 배우고 나면 안과 밖의 경계는 선명해진다. 어댑터는 밖에, 도메인은 안에. 포트로만 소통한다. 그런데 정작 실무에서 막히는 지점은 그 다음이다. 회원 하나만 있을 때는 헥사곤 하나로 충분했는데, 강사가 들어오고 강의가 들어오면 헥사곤 안쪽이 금세 뒤엉킨다. 여기서 자연스럽게 떠오르는 발상이 “그럼 안쪽에 작은 헥사곤을 또 만들면 되지 않을까”다

결론부터 말하면 그건 안 된다. 헥사고날 아키텍처는 중첩되지 않는다. 대신 내부는 UML 컴포넌트 개념으로 얼마든지 나눌 수 있다. 이 글은 그 구분이 왜 필요한지, 그리고 실제로 두 번째 모듈인 강사(Instructor)를 추가하면서 어떤 결정들이 갈렸는지를 정리한 것이다

이 글은 토비님의 스프링 백엔드 강의에서 다루는 온라인 학습 서비스 예제 프로젝트 Splearn을 따라 구현하면서, 강의 내용과 직접 작성한 코드를 함께 정리한 것이다. 설계 판단의 근거와 용어는 강의를 따르되, 코드와 해석은 직접 구현한 저장소 기준이다

이 글에서 자주 나오는 용어

  • 포트(Port): 애플리케이션이 외부와 주고받을 기능을 선언한 인터페이스. 구현은 담지 않는다
  • 어댑터(Adapter): 포트를 실제 기술로 구현한 것. 웹 컨트롤러, JPA 리포지토리 구현, 메일 발송기가 여기 해당한다
  • 애그리거트(Aggregate): 함께 변경되어야 하는 엔티티들의 묶음. 회원과 회원 상세 정보처럼 따로 저장하면 데이터 정합성이 깨지는 관계를 하나로 취급한다
  • 애그리거트 루트: 애그리거트의 대표 엔티티. 외부는 항상 루트를 통해서만 내부에 접근한다
  • DDD(Domain-Driven Design): 도메인 지식을 코드 구조에 직접 반영하는 설계 접근. 애그리거트와 리포지토리는 여기서 나온 패턴이다
  • ArchUnit: 패키지 의존 방향 같은 아키텍처 규칙을 테스트 코드로 검증하는 자바 라이브러리. 규칙을 어기면 테스트가 실패한다

컴포넌트는 교체 가능한 모듈이다

모듈은 기준이 정해진 개념이 아니다. 자바에서는 package가 대표적인 모듈 단위이고, class도 더 작은 단위의 모듈이다. 그중 UML이 정의하는 컴포넌트는 조건이 더 붙는다

컴포넌트 = 명확한 인터페이스를 통해 외부와 협력하면서
          독립적으로 교체 가능한 시스템의 구성 단위

핵심은 두 가지다. 내부 구현이 캡슐화되어 외부에서 들여다볼 수 없어야 하고, 외부와는 오직 인터페이스로만 소통해야 한다. 이 두 조건이 충족되면 같은 인터페이스를 제공하는 다른 구현으로 갈아 끼울 수 있다. 데스크톱 PC에서 GPU를 교체할 수 있는 이유와 같다. PCI라는 인터페이스가 사전에 정의되어 있고 호환되기 때문이다

UML 컴포넌트 다이어그램은 이 인터페이스를 두 종류로 나눈다

        ○──  Provided Interface (롤리팝)
             내가 외부에 제공하는 기능

        (──  Required Interface (소켓)
             내가 외부로부터 제공받아야 하는 기능

헥사고날 아키텍처를 설명할 때 나오는 롤리팝과 소켓은 여기서 그대로 가져온 것이다. 즉 제공 인터페이스와 요구 인터페이스라는 용어 자체가 헥사고날 고유의 것이 아니라 UML 컴포넌트의 어휘다. 이 사실을 알고 나면 헥사고날 아키텍처가 왜 컴포넌트처럼 생겼는지가 설명된다

헥사고날 아키텍처는 UML 컴포넌트에 전략 패턴을 더한 것이다

헥사고날 아키텍처로 만든 애플리케이션 전체는 그 자체로 하나의 컴포넌트다. 웹이든 배치든 테스트든, 이 애플리케이션의 기능을 쓰고 싶으면 Provided Interface를 호출하면 된다. DB나 메일 발송 같은 기능을 붙이고 싶으면 Required Interface에 연결하면 된다. 인터페이스가 호환되는 한 구현은 교체 가능하다

여기에 하나가 더 붙는다. 전략 패턴(Strategy Pattern)이다

일반적인 컴포넌트 조립은 빌드 타임에 정적으로 이루어진다. 그런데 헥사고날 아키텍처가 방어하려는 대상은 “변할 수 있는 외부 기술”이다. 변할 수 있는 것을 컴파일 타임에 못 박아 두면 방어의 의미가 없다. 그래서 특히 Required Interface 쪽은 인터페이스로만 선언되어 있어야 하고, 실제 구현은 런타임에 주입되어야 한다

UML 컴포넌트 (캡슐화 + 인터페이스 소통)
      +
전략 패턴 (구현의 런타임 교체)
      =
헥사고날 아키텍처

스프링을 쓰면 이 부분이 공짜로 해결된다. 스프링 빈과 빈 사이의 연결은 컨테이너가 런타임에 맺어 주기 때문이다. 스프링이 없는 환경이라면 이 조립을 담당하는 컨피규레이터를 직접 만들어야 한다. 스프링 위에서 헥사고날 아키텍처를 적용하기 편한 이유가 여기에 있다

헥사고날 아키텍처는 중첩되지 않는다

UML 컴포넌트는 중첩을 적극적으로 허용한다. Store 컴포넌트 안에 Order, Customer, Product 컴포넌트가 들어가고, 내부 컴포넌트의 인터페이스 중 일부가 바깥 컴포넌트의 인터페이스로 노출되는 구조가 UML 표준 문서에 그대로 나온다

그렇다면 헥사곤 안에 헥사곤을 넣어도 될까. 헥사고날 아키텍처를 만든 앨리스터 코오번(Alistair Cockburn)은 그런 시도가 시스템을 복잡하게 만들거나 실패할 것이라고 못을 박았다

이유는 헥사고날 아키텍처의 목적에서 나온다. 헥사고날 아키텍처는 데이터베이스, 유저 인터페이스, 외부 API처럼 변경 가능한 기술로부터 도메인의 핵심 로직을 보호하려고 만들어졌다. 즉 헥사곤의 경계는 곧 기술 경계다

헥사곤의 경계 = 변경 가능한 외부 기술과의 경계

  헥사곤 안에 헥사곤을 넣는다
    → 안쪽 헥사곤의 바깥도 "기술 경계"여야 한다
    → 그런데 그 위치는 이미 바깥 헥사곤의 안쪽이다
    → 기술 경계 안에 또 기술 경계가 생긴다 (모순)

중첩이 성립하지 않는 이유는 복잡도가 아니라 정의의 모순이다. 이 점이 UML 컴포넌트와 헥사고날 아키텍처가 갈라지는 지점이다. 헥사고날 아키텍처는 UML 컴포넌트의 철학을 애플리케이션 아키텍처 수준으로 끌어올린 특수한 적용 사례이지, UML 컴포넌트의 모든 성질을 물려받은 것이 아니다

그러나 내부는 얼마든지 컴포넌트로 나눌 수 있다

헥사고날 아키텍처는 헥사곤 내부를 어떻게 구성할지에 대해서는 아무 말도 하지 않는다. 도메인 모델 패턴을 쓸지, 트랜잭션 스크립트를 쓸지 자유롭게 결정하는 것과 같다. 내부를 여러 개의 컴포넌트로 쪼개는 것도 마찬가지로 자유다. 그 하나하나를 헥사고날 애플리케이션이라고 부르지 않을 뿐이다

┌─────────────────── 헥사고날 애플리케이션 (하나의 컴포넌트) ──────────────────┐
│                                                                     │
│   ○── [ 컴포넌트 A ] ──(  ○── [ 컴포넌트 B ] ──(                        │
│         (Member)                (Instructor)                        │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘
   ↑ Web UI / Test / Batch                    ↓ DB / Mail / Test Mock
     (Driving, Primary Port)                    (Driven, Secondary Port)

포트 이름이 두 벌인 이유는 방향 때문이다. 애플리케이션을 사용하는 쪽(웹, 배치, 테스트)이 붙는 포트를 드라이빙 포트 또는 프라이머리 포트라 하고, 애플리케이션이 사용하는 쪽(DB, 메일)이 붙는 포트를 드리븐 포트 또는 세컨더리 포트라 한다. 유스케이스에서 액터와 시스템의 관계를 구분할 때 쓰던 용어를 그대로 가져온 것이다. Provided/Required와 짝이 맞는다

내부 컴포넌트의 Provided Interface는 세 가지 방식으로 쓰인다. 어댑터를 통해 들어오는 외부 요청을 받는 전체 애플리케이션의 진입점이 되기도 하고, 다른 내부 컴포넌트가 사용하는 연결점이 되기도 하고, 둘 다이기도 하다

주의할 것은 하나뿐이다. 어댑터와 기술 종속 타입이 헥사곤 안으로 들어오면 안 된다. 구현체만의 문제가 아니라 인터페이스 시그니처도 마찬가지다. 포트에 HttpServletRequest가 노출되는 순간 그 포트는 배치나 데스크톱 애플리케이션에서 쓸 수 없다

반면 Repository는 다르다. 이건 DDD에서 나온 객체지향 개념이지 JPA 전용 타입이 아니다. 같은 인터페이스를 JDBC나 다른 저장 기술로 구현해서 어댑터를 갈아 끼울 수 있다. 그래서 포트 이름에 Repository가 들어가는 것은 기술 종속이 아니다. 판단 기준은 “이 타입이 특정 기술 스택 없이 존재할 수 있는가”다

애그리거트 + 애플리케이션 서비스 = 애플리케이션 컴포넌트

내부 컴포넌트의 단위를 무엇으로 잡을 것인가. 도메인 모델 패턴을 쓰고 있다면 답은 자연스럽게 나온다. 하나의 애그리거트와 그것을 감싸는 애플리케이션 서비스를 묶어 하나의 컴포넌트로 두는 것이다

이 단위는 헥사곤 전체와 성질이 거의 같다. 외부에 인터페이스로만 기능을 노출하고, 내부 구현은 캡슐화되어 있다. 이 글에서는 이 단위를 애플리케이션 컴포넌트라고 부른다. 헥사고날 아키텍처의 공식 용어는 아니다. 그러나 헥사곤 전체를 가리키는 “애플리케이션”과 그 안의 조각을 같은 말로 부르면 대화가 엉키기 때문에, 구분해서 부를 이름은 필요하다

실제 패키지 구조는 이렇게 나뉜다

kimspring.splearn
├── domain
│   ├── member/          Member, MemberDetail, MemberStatus, Profile
│   └── instructor/      Instructor, InstructorStatus
├── application
│   ├── member/                                            ← 회원 애플리케이션 컴포넌트
│   │   ├── provided/    MemberRegister, MemberFinder,     ← Provided Port
│   │   │                MemberAuthenticator
│   │   ├── required/    MemberRepository, EmailSender     ← Required Port
│   │   └── MemberModifyService, MemberQueryService,       ← 구현
│   │       MemberAuthenticationService
│   └── instructor/                                        ← 강사 애플리케이션 컴포넌트
│       ├── provided/    InstructorApplication, InstructorFinder
│       ├── required/    InstructorRepository
│       └── InstructorModifyService, InstructorQueryService
└── adapter              ← 헥사곤 바깥. webapi, integration, security

providedrequired 패키지가 컴포넌트의 두 인터페이스를 그대로 반영한다. 패키지 구조만 봐도 이 컴포넌트가 무엇을 제공하고 무엇을 요구하는지 읽힌다

애그리거트 사이 참조: 직접 참조로 시작한다

강사 도메인을 설계하면서 첫 번째 갈림길이 나온다. InstructorMember를 어떻게 참조할 것인가

// 선택지 1 — 직접 참조
@OneToOne(fetch = FetchType.LAZY)
Member member;

// 선택지 2 — 식별자 참조
Long memberId;

이 논쟁에는 계보가 있다. 애그리거트 패턴을 처음 소개한 에릭 에반스(Eric Evans)는 다른 애그리거트 루트에 대한 직접 참조를 유효한 선택지로 봤다. 이후 본 버논(Vaughn Vernon)이 『Implementing Domain-Driven Design』에서 식별자 참조를 권장했고, 이 주장이 널리 퍼지면서 “애그리거트 사이에는 ID만 가져야 한다”고 배운 개발자가 많아졌다

이 프로젝트의 선택은 직접 참조다. 근거는 세 가지다

첫째, 객체지향에서 연관된 객체를 직접 참조하는 것이 자연스럽다. ORM은 애초에 관계형 DB의 키 참조를 객체 참조로 바꿔 주기 위해 만들어진 기술이다. 그 기술을 쓰면서 다시 ID로 되돌리는 것은 앞뒤가 맞지 않는다

둘째, 탐색과 응집도다. long 값만 들고 있으면 “이건 무슨 엔티티였지”부터 추리해야 하고, 로직마다 리포지토리를 거쳐 다시 조회해야 한다

셋째, JPA로 쿼리를 작성할 때 직접 연관관계가 훨씬 유리하다

여기에 덧붙일 판단 기준이 하나 있다. 연관관계를 무조건 식별자로 만들면 코드가 데이터베이스 중심 설계로 변질된다. 도메인 모델을 표현하려고 객체지향 언어를 쓰면서, 정작 코드는 매번 DB를 의식하며 흘러가게 된다

물론 예외는 있다. 시스템이 마이크로서비스로 쪼개져 직렬화 비중이 크거나 변경 이벤트를 메시지 큐로 넘기는 구조라면 식별자 참조가 편하다. 다만 그건 선택이지 기본값이 아니다. 그리고 직접 참조로 만든 코드를 나중에 식별자로 바꾸는 작업은 생각보다 어렵지 않다. 그런 전환이 필요할 정도의 아키텍처 변화라면 어차피 코드 수정은 불가피하다

실제 Instructor 엔티티는 이렇게 만들어졌다

@Entity
@Getter
@ToString(callSuper = true, exclude = "member")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Instructor extends AbstractEntity {
    @OneToOne(fetch = FetchType.LAZY)
    Member member;

    @Enumerated(EnumType.STRING)
    @Column(length = 20)
    InstructorStatus status;

    public static Instructor apply(Member member) {
        state(member.isActive(), "등록 완료 상태가 아닌 회원은 강사 신청을 할 수 없습니다");

        Instructor instructor = new Instructor();
        instructor.member = member;
        instructor.status = PENDING;
        return instructor;
    }

    public void approve() {
        state(status == PENDING, "강사의 상태가 PENDING이 아닙니다");
        this.status = ACTIVE;
    }

    public void reject() {
        state(status == PENDING, "강사의 상태가 PENDING이 아닙니다");
        this.status = REJECTED;
    }

    public boolean isActive() {
        return status == ACTIVE;
    }

    public void ensureActive() {
        state(isActive(), "ACTIVE 상태가 아닙니다");
    }
}

여기서 state()org.springframework.util.Assert.state를 정적 임포트한 것이다. 조건이 false면 IllegalStateException을 던진다

몇 가지가 눈에 띈다

생성자 대신 apply()라는 정적 팩토리 메서드를 쓴 이유는 이름을 줄 수 있기 때문이다. 생성자는 “만든다”는 의미밖에 표현하지 못한다. 강사는 그냥 생성되는 것이 아니라 신청을 통해 만들어진다. 그 도메인 지식을 메서드 이름이 담고 있다

member의 null 체크가 없는 것도 의도적이다. null이면 isActive() 호출에서 NullPointerException이 난다. 애초에 여기까지 null이 넘어왔다는 것은 애플리케이션 서비스 쪽의 버그다. 버그에 친절한 메시지를 붙일 이유는 없다

ensureActive()는 실무에서 유용한 패턴이다. if (!instructor.isActive()) throw ...를 호출부마다 반복하는 대신, “네가 활성 상태임을 보장해”라고 엔티티에게 요청한다. 보장되면 조용히 리턴하고, 아니면 예외를 던진다. 호출부 코드가 짧아지고 도메인 모델 관점에서도 읽기 좋다

컴포넌트 사이 연결에는 네 가지 원칙이 필요하다

애그리거트가 둘 이상이 되면 연결 규칙을 정해 두어야 한다. 정하지 않으면 각자 편한 대로 짜고, 그 결과는 순환 참조와 결합도 폭증이다

1. 의존관계는 단방향이다

InstructorMember에 의존하는 것은 괜찮다. 동시에 MemberInstructor를 쓰기 시작하면 순환 참조가 된다. 어느 쪽이 어느 쪽에 의존할지는 도메인의 주체성으로 판단한다. 회원이 더 많고 기본이 되는 개념이므로, 강사가 회원에 의존하는 방향으로 정한다

Instructor ──▶ Member        (허용)
Instructor ◀──▶ Member       (차단)
2. 변경의 단위는 애그리거트다

직접 참조를 허용하면 lesson.getEnrollment().getMember().addPoint(...) 같은 코드가 언제든 나올 수 있다. JPA의 지연 로딩(연관 객체를 실제로 꺼내 쓰는 시점에 SELECT를 날려 채워 주는 기능)이 이런 탐색을 물리적으로 가능하게 만들기 때문이다. 수업 하나를 바꾸려다 회원의 포인트까지 건드리는 코드가 되고, 결합도는 그만큼 올라간다. 다른 애그리거트의 값을 읽는 것은 괜찮지만, 상태를 변경하는 메서드를 호출하는 것은 금지한다

3. 애그리거트를 넘는 변경은 애플리케이션 서비스에서 처리한다

여러 애그리거트가 함께 바뀌어야 한다면 도메인 레벨에서 타고 넘어가지 않는다. 애플리케이션 서비스가 다른 애플리케이션 서비스의 포트를 호출하는 방식으로 처리한다. 도메인 이벤트를 쓰는 더 느슨한 방법도 있지만, 그건 별도의 주제다

4. 포트로 넘기는 것은 엔티티가 아니라 식별자다

컴포넌트 사이의 포트 호출에서는 애그리거트 루트의 식별자를 전달한다. 이유는 외부 어댑터 때문이다. 어댑터는 엔티티를 전달할 방법이 없다. 대부분 식별자만 넘어온다. 포트 시그니처를 식별자 기준으로 통일해 두면 내부 호출과 외부 호출이 같은 인터페이스를 쓸 수 있다

여기서 실용적인 문제가 하나 생긴다. 이미 Member 엔티티를 손에 들고 있는 컴포넌트가 매번 member.getId()를 꺼내 넘기는 것은 번거롭다. 인터페이스의 default 메서드로 간단히 해결된다

public interface InstructorFinder {
    Instructor find(Long instructorId);

    Optional<Instructor> findByMember(Long memberId);

    default Optional<Instructor> findByMember(Member member) {
        return findByMember(member.getId());
    }
}

구현체는 Long을 받는 쪽 하나만 만들면 된다. 엔티티를 들고 있으면 엔티티를 넘기고, 어댑터에서 ID만 넘어오면 ID를 넘긴다. 중복 구현 없이 두 진입점을 제공한다. 인터페이스의 다른 메서드를 호출하는 코드를 default 메서드에 두는 것은 꽤 자주 쓰이는 기법이다

이 네 가지 원칙은 문서로만 두면 반드시 깨진다. 로직이 복잡해지면 “이게 바뀌면 저쪽도 바뀌어야지” 하며 참조를 타고 넘어가는 코드가 나온다. 그래서 규칙은 테스트로 강제해야 한다. 이 프로젝트는 이미 ArchUnit으로 계층 간 의존 방향을 검증하고 있고, 같은 방식으로 애그리거트를 넘어선 변경 메서드 호출도 차단할 수 있다

포트를 설계하며 갈린 지점들

포트는 컴포넌트의 얼굴이다. 여기서 내린 결정이 나중까지 남는다

find인가 findById인가

public interface InstructorFinder {
    Instructor find(Long instructorId);
    Optional<Instructor> findByMember(Long memberId);
}

findById는 스프링 데이터의 네이밍 규칙이다. 메서드 이름만 보고 쿼리를 생성해 주기 위한 관례이지 일반적인 작명 규칙이 아니다. Instructor를 리턴하고 파라미터가 Long이라면 find만으로 충분히 명확하다

반면 findByMember처럼 다른 애그리거트의 식별자로 찾는 경우는 명시해야 한다. 식별자가 전부 long 타입이라 컴파일러가 잡아 주지 못하기 때문이다. 같은 이유로 파라미터 이름도 id가 아니라 memberId, instructorId로 쓴다. 타입이 구분해 주지 못하는 것을 이름이 구분한다

Optional을 붙일 것인가

같은 조회인데 반환 타입이 다르다. 이건 스타일이 아니라 의미의 차이다

메서드반환의미
find(instructorId)Instructor이 ID는 반드시 존재한다. 없으면 예외 상황이다
findByMember(memberId)Optional<Instructor>없을 수 있다. 모든 회원이 강사는 아니다
InstructorRepository.findById()Optional<Instructor>스프링 데이터의 범용 규약

포트의 find는 이미 어딘가에서 얻어 온 ID로 조회한다는 전제를 담는다. 없다면 프로그램이 잘못된 것이므로 예외를 던진다. 리포지토리는 훨씬 범용적으로 쓰이므로 Optional로 반환하고 판단을 호출자에게 넘긴다

이 규약 덕분에 애플리케이션 서비스 코드에서 orElseThrow가 사라진다

@Override
public Instructor find(Long instructorId) {
    return instructorRepository.findById(instructorId)
        .orElseThrow(() -> new IllegalArgumentException("강사를 찾을 수 없습니다. ID: " + instructorId));
}

orElseThrowInstructorQueryService 한 곳에만 존재한다. 이 포트를 쓰는 다른 서비스는 그냥 instructorFinder.find(id)를 호출한다

리포지토리는 필요한 만큼만 선언한다

public interface InstructorRepository extends Repository<Instructor, Long> {
    Instructor save(Instructor instructor);
    Optional<Instructor> findById(Long instructorId);
    Optional<Instructor> findByMemberId(Long memberId);
}

JpaRepositoryCrudRepository가 아니라 마커 인터페이스인 Repository를 상속하고 필요한 메서드만 직접 선언했다. 마커 인터페이스란 메서드가 하나도 없고 “이 타입은 리포지토리다”라는 표시만 하는 인터페이스다. 상속해도 딸려 오는 메서드가 없다. 편의성은 떨어지지만 이 컴포넌트가 저장소에 정확히 무엇을 요구하는지가 인터페이스에 드러난다. Required Interface의 목적이 그것이다. findByMemberId처럼 스프링 데이터 네이밍 규칙을 따르면 쿼리는 여전히 자동 생성된다

memberId로 승인할 것인가 instructorId로 승인할 것인가

포트를 처음 작성할 때는 이렇게 시작했다

Instructor approve(Long memberId);

강사와 회원이 1:1이므로 어느 쪽 ID를 줘도 찾아낼 수는 있다. 그러나 이건 강사 컴포넌트의 포트다. 승인 대상은 이미 apply()로 생성된 강사이므로 강사 식별자를 받는 것이 자연스럽다

public interface InstructorApplication {
    Instructor apply(@Valid InstructorApplyRequest applyRequest);
    Instructor approve(Long instructorId);
    Instructor reject(Long instructorId);
}

사소해 보이지만 포트 시그니처는 나중에 어댑터, 프론트엔드, 다른 컴포넌트까지 전파된다. 구현에 들어가기 전에 포트에서 한 번 더 의심해 보는 편이 싸다

한편 apply()만 DTO를 받는다. 신청에는 앞으로 부가 정보가 붙을 가능성이 높기 때문이다

public record InstructorApplyRequest(
    @NotNull Long memberId
) {}

@Valid를 포트 시그니처에 붙여 두면 Bean Validation(@NotNull 같은 애노테이션으로 값 검증 규칙을 선언하는 자바 표준)이 서비스 진입 시점에 동작한다. 구현 클래스의 @Validated가 이 검증을 실제로 실행한다. 반면 승인과 거절은 당분간 부가 정보가 붙을 일이 없어 Long 하나로 둔다. 모든 메서드에 DTO를 기계적으로 만들 필요는 없다

포트에 throws를 선언하지 않은 것도 결정이다. 검사 예외(checked exception, 메서드 시그니처에 선언하고 호출자가 반드시 처리해야 하는 예외)는 정상 흐름에서도 예외 상황이 큰 비중으로 발생할 때 의미가 있다. 로그인 실패 같은 경우다. “강사를 찾을 수 없다” 같은 상황은 호출 전에 이미 걸러졌어야 하는 버그에 가깝다. 복구할 수 없는 예외를 시그니처에 나열하는 것은 인터페이스만 지저분하게 만든다

애플리케이션 서비스는 스펙처럼 읽혀야 한다

포트 구현은 절차를 그대로 서술하는 수준으로 짧게 유지한다

@Override
public Instructor apply(InstructorApplyRequest applyRequest) {
    Member member = memberFinder.find(applyRequest.memberId());

    checkDuplicateApplication(member);

    Instructor instructor = Instructor.apply(member);

    return instructorRepository.save(instructor);
}

회원을 찾아오고, 중복을 확인하고, 강사를 신청하고, 저장한다. 코드를 읽으면 그대로 스펙이 읽힌다. 이게 가능한 이유는 각 오브젝트의 응집도가 높기 때문이다. 리포지토리에서 직접 꺼내 orElseThrow를 붙이고, 기본 생성자로 엔티티를 만든 뒤 필드를 하나씩 채우는 방식이었다면 이 메서드는 훨씬 길어졌을 것이다

여기서 MemberFinder를 주입받은 부분이 앞에서 그린 컴포넌트 연결이 실제로 구현된 지점이다. 강사 컴포넌트의 Required Interface가 회원 컴포넌트의 Provided Interface를 사용한다

@Service
@Transactional
@Validated
@RequiredArgsConstructor
public class InstructorModifyService implements InstructorApplication {
    private final InstructorRepository instructorRepository;
    private final InstructorFinder instructorFinder;
    private final MemberFinder memberFinder;   // ← 회원 컴포넌트의 Provided Port

승인과 거절도 같은 형태다.

@Override
public Instructor approve(Long instructorId) {
    Instructor instructor = instructorFinder.find(instructorId);

    instructor.approve();

    return instructorRepository.save(instructor);
}

리포지토리를 직접 쓰지 않고 instructorFinder.find()를 거치는 이유는 앞서 정한 규약 때문이다. 존재 보장과 예외 메시지는 파인더 한 곳에서 책임진다

변경 후에도 save()를 호출하는 부분은 짚고 넘어갈 만하다. 순수 JPA만 놓고 보면 영속 상태 엔티티는 더티 체킹으로 반영된다. 더티 체킹이란 JPA가 트랜잭션이 끝날 때 관리 중인 엔티티의 변경 여부를 스스로 감지해 UPDATE를 실행하는 동작이다. 즉 save() 없이도 값은 저장된다. 그럼에도 호출하는 이유는 여기가 포트 구현이기 때문이다. InstructorRepository는 JPA 전용 인터페이스가 아니다. JDBC 어댑터로 교체해도 동작해야 하고, 그 구현에는 더티 체킹이 없다. 명시적인 save()는 저장소 구현을 교체 가능하게 유지하기 위한 비용이다

DB 제약에 도메인 규칙을 맡기지 않는다

기능을 다 만든 뒤 코드 리뷰에서 나온 지적이 이 글에서 가장 실전에 가까운 부분이다.

한 회원이 강사 신청을 두 번 하면 어떻게 될까. 처음 테스트를 돌렸을 때는 DB의 유니크 인덱스가 걸려서 예외가 났다. @OneToOne 매핑이 자동으로 유니크 제약을 만들기 때문이다. 동작은 한다. 그래서 그냥 두려고 했다.

리뷰에서 나온 반론은 이랬다. 한 회원이 하나의 강사만 될 수 있다는 것은 도메인 모델을 설계할 때 정한 중요한 규칙이다. 어떤 서비스는 한 회원이 여러 강사 페르소나를 갖게 허용할 수도 있다. 그건 도메인의 결정 사항이다. 그런데 지금 코드에는 그 결정이 어디에도 드러나 있지 않다. 코드만 읽으면 “중복 체크를 안 하는구나”로 보인다.

DB 제약이 막아 주는 것과 도메인 규칙이 코드에 표현되는 것은 다른 문제다. 전자는 사고를 막는 안전망이고, 후자는 설계 의도의 기록이다.

그래서 포트 패키지에 예외를 정의하고,

java

public class DuplicationInstructorApplicationException extends RuntimeException {
    public DuplicationInstructorApplicationException() {
    }

    public DuplicationInstructorApplicationException(String message) {
        super(message);
    }
}

애플리케이션 서비스에서 사전에 차단했다.

java

private void checkDuplicateApplication(Member member) {
    if (instructorRepository.findByMemberId(member.getId()).isPresent()) {
        throw new DuplicationInstructorApplicationException("회원은 중복해서 강사 신청을 할 수 없습니다");
    }
}

예외를 도메인이 아니라 포트(provided) 패키지에 둔 것도 결정이다. 이 규칙은 Instructor 엔티티 하나로는 판단할 수 없다. 저장소를 조회해야 알 수 있으므로 애플리케이션 서비스의 책임이고, 그 계약을 노출하는 자리는 포트다.

checkXxx라는 이름과 void 반환도 의도적이다. 문제가 없으면 조용히 통과하고, 문제가 있으면 예외를 던진다. 덕분에 apply() 본문은 여전히 네 줄로 읽힌다.

테스트가 통과했다고 검증된 것은 아니다

이 과정에서 실수가 하나 드러났다. 중복 신청 테스트를 처음 만들었을 때 두 번째 요청의 memberIdnull이 들어가 있었다. 그래서 예상했던 DataIntegrityViolationException이 아니라 ConstraintViolationException이 났다

당시에는 “예상과 다른 예외가 났네, 일단 Exception으로 두고 넘어가자”고 처리했다. 테스트는 초록불이었다. 하지만 검증하려던 것은 전혀 검증되지 않은 상태였다

여기서 얻을 수 있는 판단 기준은 명확하다. 예상과 다른 예외가 났다면 그 자리에서 이유를 확인해야 한다. assertion을 넓혀서 통과시키는 것은 문제를 미루는 것이다

비슷한 지적이 로그인 테스트에서도 나왔다. 예외가 던져지지 않으면 로그인이 성공한 것으로 보고 검증을 끝냈는데, login()은 로그인된 회원을 반환한다. 반환값이 앞에서 등록한 회원과 일치하는지 확인하지 않으면 그 메서드의 기능을 테스트했다고 말할 수 없다

var member = memberRegister.register(registerRequest);
member.activate();

var loggedInMember =
    memberAuthenticator.login(new MemberLoginRequest(registerRequest.email(), registerRequest.password()));

assertThat(loggedInMember).isEqualTo(member);

이메일과 비밀번호를 넘겨 정확히 그 회원을 찾아 반환하는지까지 확인해야 로그인 기능을 테스트했다고 말할 수 있다. 반면 approve()처럼 반환값이 입력과 자명하게 대응하는 경우는 상태만 검증해도 충분하다. 반환값 검증이 필요한지 여부는 “이 메서드가 무엇을 찾아내는가”로 판단한다

테스트 픽스처도 일관성을 맞춘다

회원은 MemberFixture로 테스트 객체 생성을 관리하는데 강사만 픽스처가 없었다. 지금은 생성이 단순해서 문제가 없지만, 속성이 늘어나면 여기저기 테스트를 모두 고쳐야 한다. 애그리거트 단위로 픽스처를 두는 것이 자연스럽다

public class InstructorFixture {
    public static Instructor createInstructor(Member member) {
        return Instructor.apply(member);
    }

    public static Instructor createInstructor() {
        return createInstructor(MemberFixture.createActiveMember());
    }

    public static Instructor createActiveInstructor() {
        Instructor instructor = createInstructor();
        instructor.approve();
        return instructor;
    }

    public static @Valid InstructorApplyRequest createApplyRequest(Member member) {
        return new InstructorApplyRequest(member.getId());
    }
}

주의할 점이 있다. 픽스처는 준비(given) 단계에만 적용한다. 검증 대상 자체를 픽스처로 대체하면 테스트가 무의미해진다. apply()를 검증하는 테스트에서 createInstructor()를 쓰면 아무것도 테스트하지 않는 셈이다

@Test
void approve() {
    Instructor instructor = InstructorFixture.createInstructor();  // 준비 → 픽스처 OK

    instructor.approve();                                          // 검증 대상

    assertThat(instructor.status).isEqualTo(InstructorStatus.ACTIVE);
}

@Test
void apply() {
    Member member = MemberFixture.createActiveMember();

    Instructor instructor = Instructor.apply(member);               // 검증 대상 → 직접 호출

    assertThat(instructor.member).isEqualTo(member);
    assertThat(instructor.status).isEqualTo(InstructorStatus.PENDING);
}

애플리케이션 서비스 테스트는 반드시 인터페이스(포트)를 주입받아 테스트한다. 구현 클래스가 아니라 포트를 테스트해야 구현 교체가 자유로워진다

@SpringBootTest
@Transactional
@RequiredArgsConstructor
class InstructorApplicationTest {
    final InstructorApplication instructorApplication;   // 구현체가 아니라 포트
    final InstructorRepository instructorRepository;
    final MemberRepository memberRepository;

@RequiredArgsConstructor + final 필드 조합은 @Autowired를 필드마다 붙이는 것보다 훨씬 간결하다. 레코드로 테스트 클래스를 만드는 방법도 있지만, 레코드는 필드를 가질 수 없어 픽스처 상태를 담아야 하는 테스트에는 쓸 수 없다

이 원칙이 그대로 통하지 않는 조건

여기까지의 코드는 전체 테스트 54개가 통과하는 상태다. 다만 다루지 않은 조건이 몇 가지 남아 있다

컴포넌트 경계를 언제 나눌지에 대한 정량적 기준은 없다. “애그리거트 하나 + 그것을 감싸는 애플리케이션 서비스”는 출발점이지 공식이 아니다. 애그리거트가 셋 이상 얽히는 도메인에서는 컴포넌트 하나가 여러 애그리거트를 품는 편이 나을 수 있다

패키지 구조만으로는 컴포넌트 경계가 물리적으로 강제되지 않는다. application.instructor 패키지에서 application.member의 내부 클래스를 참조해도 컴파일은 통과한다. ArchUnit 규칙이나 Gradle 멀티모듈 분리, JPMS 같은 강제 수단이 필요하다. 이 프로젝트는 ArchUnit을 쓰고 있지만, 애그리거트를 넘는 변경 호출을 차단하는 규칙은 아직 구현되지 않았다

애그리거트 여러 개가 동시에 변경되어야 하는 상황은 애플리케이션 서비스 간 호출로 처리하기로 했지만, 이 방식은 컴포넌트 간 결합을 늘린다. 도메인 이벤트가 대안이 되는 지점인데 그건 별도의 주제다

정리하면, 헥사고날 아키텍처는 애플리케이션과 외부 기술 사이에 단 하나의 경계를 긋는 도구이고, 그 안쪽을 나누는 일은 애그리거트와 애플리케이션 서비스를 묶은 애플리케이션 컴포넌트에 맡기면 된다

출처 – 토비의 클린 스프링 – 도메인 모델 패턴과 헥사고날 아키텍처 Part 2