시작하며

프로젝트 구조를 이야기할 때 자주 나오는 주제가 있다.

Layered ArchitectureHexagonal Architecture 는 무엇이 다를까?

둘 다 코드를 역할별로 나누고 복잡도를 낮추기 위한 구조이기 때문에 비슷해 보인다.
하지만 실제로는 무엇을 중심에 두는지, 의존성이 어디를 향하는지, 외부 기술을 어떻게 격리하는지 에서 차이가 꽤 크다.

그리고 여기서 한 가지 더 중요한 질문이 있다.

이런 아키텍쳐를 쓰면 순환참조도 자동으로 사라질까?

내 생각은 아니다 이다.
좋은 아키텍쳐는 순환참조를 줄이는 데 도움을 주지만, 순환참조를 해결하는 만능 열쇠는 아니다. 결국 순환참조는 의존성의 성격 을 보고 풀어야 한다.

이번 글에서는 다음 내용을 정리해보려고 한다.

  1. Layered Architecture 는 어떻게 구성되는가
  2. Hexagonal Architecture 는 어떻게 구성되는가
  3. 둘의 핵심 차이는 무엇인가
  4. 순환참조는 왜 생기고 어떻게 해결해야 하는가

한 줄로 먼저 정리하면

  • Layered Architecture 는 역할을 층으로 나누는 구조이다.
  • Hexagonal Architecture 는 도메인을 중심에 두고 바깥과의 연결을 Port 와 Adapter 로 감싸는 구조이다.

즉, Layered 는 계층 구조 로 바라보고, Hexagonal 은 핵심 보호 구조 로 바라본다.

Layered Architecture 는 어떻게 구성되는가

Layered Architecture 는 가장 익숙한 백엔드 구조이다.
보통 아래와 같은 계층으로 나눈다.

  • Presentation Layer
  • Application Layer
  • Domain Layer
  • Infrastructure Layer

Layered Architecture 구조

위 그림처럼 보통 상위 레이어가 하위 레이어를 호출하는 구조를 가진다.

Copy
Presentation -> Application -> Domain -> Infrastructure

1. Presentation Layer

사용자의 요청을 직접 받는 계층이다.

  • HTTP Controller
  • GraphQL Resolver
  • Request Validation
  • Response Mapping

여기서는 비즈니스 로직을 최대한 적게 두고, 입력과 출력의 형식을 맞추는 역할에 집중하는 것이 좋다.

2. Application Layer

유스케이스를 실행하는 계층이다.

  • 트랜잭션 처리
  • 여러 도메인 조합
  • DTO 변환
  • 흐름 제어

실무에서는 흔히 Service 라고 부르는 계층이 여기에 해당한다.
예를 들어 주문 생성, 환불 처리, 회원 탈퇴 같은 유스케이스를 조합하는 역할을 맡는다.

3. Domain Layer

핵심 비즈니스 규칙이 위치하는 계층이다.

  • Entity
  • Value Object
  • Domain Service
  • Policy

할인 규칙, 주문 가능 여부, 상태 전이 같은 비즈니스 규칙은 가능하면 여기 가까이 두는 편이 좋다.

4. Infrastructure Layer

외부 기술과 연결되는 계층이다.

  • DB 접근
  • 외부 API 호출
  • 메시지 큐
  • 메일 발송
  • 프레임워크 연동

즉, 애플리케이션을 둘러싼 기술적인 세부 구현이 위치한다.

Layered Architecture 의 장점과 한계

장점은 분명하다.

  • 구조가 직관적이다.
  • 팀원 대부분이 익숙하다.
  • 작은 프로젝트에서 빠르게 개발을 시작하기 좋다.
  • 프레임워크와 잘 맞는다.

하지만 시간이 지나면 자주 생기는 문제가 있다.

  • Service 에 비즈니스 로직이 과도하게 몰린다.
  • Domain 이 ORM 모델이나 Repository 세부사항에 끌려간다.
  • 계층은 나눴지만 핵심 로직이 흩어진다.

즉, 레이어를 나눴다고 해서 자동으로 좋은 구조가 되지는 않는다.

Hexagonal Architecture 는 어떻게 구성되는가

Hexagonal Architecture 는 Ports and Adapters Architecture 라고도 부른다.

이 구조의 핵심은 간단하다.

핵심 비즈니스 로직은 외부 기술을 몰라야 한다.

DB, 웹 프레임워크, 메시지 큐 같은 것은 바깥에 두고, 안쪽에는 도메인과 유스케이스를 배치한다.

Hexagonal Architecture 구조

그림처럼 Hexagonal Architecture 는 보통 아래 요소로 설명할 수 있다.

  • Application
  • Domain
  • Inbound Port / Inbound Adapter
  • Outbound Port / Outbound Adapter

1. Domain

가장 안쪽에 있는 핵심이다.

  • Entity
  • Value Object
  • Domain Rule
  • Domain Policy

여기에는 DB, HTTP, MQ, 프레임워크에 대한 코드가 들어오지 않는 것이 이상적이다.

2. Application

유스케이스를 실행한다.

  • CreateOrderUseCase
  • CancelOrderUseCase
  • IssueCouponUseCase

즉, 시스템이 제공하는 기능 단위를 표현한다.

3. Inbound Adapter

바깥에서 안쪽으로 요청을 전달하는 진입점이다.

  • REST Controller
  • GraphQL Resolver
  • Scheduler
  • Kafka Consumer

중요한 점은 이들이 바로 도메인 세부 구현을 다루는 것이 아니라, 안쪽의 유스케이스를 호출한다는 점이다.

4. Outbound Port

핵심 로직이 바깥에 요구하는 기능의 계약 이다.

예를 들면 이런 인터페이스가 될 수 있다.

Copy
LoadUserPort
SaveOrderPort
PublishEventPort

핵심은 무엇이 필요하다 를 표현하고, 어떻게 구현하는지 는 말하지 않는 것이다.

5. Outbound Adapter

Port 의 실제 구현체이다.

  • MySQL Adapter
  • Redis Adapter
  • External API Adapter
  • Message Bus Adapter

즉, Port 를 기술 세부사항으로 연결하는 부분이다.

둘의 가장 큰 차이

둘 다 결국 여러 영역으로 코드를 나누기 때문에 얼핏 보면 비슷하다.
하지만 차이는 경계를 어떻게 자르느냐의존성을 어떻게 통제하느냐 에 있다.

Layered Architecture 의 관점

  • 컨트롤러
  • 서비스
  • 리포지토리

처럼 역할 을 기준으로 나눈다.

Hexagonal Architecture 의 관점

  • 안쪽은 핵심 비즈니스
  • 바깥은 연결부

처럼 중심과 외부 를 기준으로 나눈다.

그래서 Hexagonal Architecture 는 일반적으로 의존성 역전을 더 강하게 요구한다.

요청 하나가 흐르는 방식 비교

예를 들어 주문 생성 기능이 있다고 해보자.

Layered Architecture

Copy
OrderController
  -> OrderService
    -> OrderRepository
      -> MySQL

직관적이고 빠르게 구현할 수 있다.
하지만 시간이 지나면 OrderService 가 아래 역할을 모두 떠안는 경우가 많다.

  • 검증
  • 비즈니스 규칙
  • 외부 API 호출
  • 이벤트 발행
  • 저장

Hexagonal Architecture

Copy
OrderController(Adapter In)
  -> CreateOrderUseCase
    -> SaveOrderPort
      -> OrderPersistenceAdapter
        -> MySQL

여기서는 유스케이스가 저장이 필요하다는 사실만 알고, 실제 MySQL 구현은 바깥 Adapter 가 담당한다.

이 방식은 테스트 작성과 구현 교체에 유리하다.

순환참조는 왜 생길까

여기서 중요한 포인트가 있다.

Layered Architecture 든 Hexagonal Architecture 든, 구조만 도입한다고 순환참조가 없어지지는 않는다.

순환참조는 주로 아래 상황에서 생긴다.

  • 도메인 A 가 도메인 B 의 기능을 직접 호출한다.
  • 도메인 B 도 다시 도메인 A 를 호출한다.
  • 하나의 유스케이스 안에서 책임 경계가 불분명하다.
  • 읽기 전용 조회와 비즈니스 행동이 뒤섞인다.

예를 들어 이런 식이다.

Copy
OrderService -> UserService
UserService -> OrderService

처음에는 편해 보이지만, 결국 모듈 초기화 문제, 테스트 어려움, 변경 파급 증가로 이어진다.

아키텍쳐는 순환참조 해결의 만능이 아니다

Layered Architecture 를 써도 OrderServiceUserService 가 서로를 호출하면 순환참조가 생긴다.
Hexagonal Architecture 를 써도 OrderUseCaseUserUseCase 가 서로의 Port 를 잘못 물고 들어가면 같은 문제가 생긴다.

즉, 문제의 핵심은 아키텍쳐 이름이 아니라 누가 누구를 왜 참조하는가 이다.

순환참조 해결 전략

그래서 순환참조는 의존성의 종류 에 따라 풀어야 한다.

순환참조를 해결하는 방법

1. 하나의 유스케이스라면 상위 Orchestrator 로 올린다

가장 흔한 경우다.

OrderServiceUserService 를 호출하고, UserService 가 다시 OrderService 를 호출하는 이유는 사실 같은 유스케이스를 서로 나눠서 처리하고 있기 때문인 경우가 많다.

이럴 때는 둘 중 하나가 다른 하나를 직접 호출하게 두지 말고, 상위 레벨의 Application Service 또는 UseCase 가 둘을 조합하도록 만드는 편이 낫다.

Copy
PlaceOrderUseCase
  -> UserDomain
  -> OrderDomain

즉, 동등한 두 서비스가 서로 부르는 구조상위 조정자가 아래를 호출하는 구조 로 바꾼다.

이 방식은 Layered Architecture 에서도 유효하고, Hexagonal Architecture 에서도 유효하다.

2. 후속 반응이라면 Domain Event 로 분리한다

어떤 기능은 같은 트랜잭션 안에서 즉시 처리할 필요가 없고, 단지 무언가가 일어났음 을 다른 쪽에 알려주면 된다.

예를 들면,

  • 주문 완료 후 알림 발송
  • 회원 탈퇴 후 쿠폰 정리
  • 결제 완료 후 포인트 적립

이런 경우 A 가 B 를 직접 호출하기보다 이벤트를 발행하고, B 가 그 이벤트를 구독하게 만들면 결합도를 낮출 수 있다.

Copy
OrderDomain -> OrderPlacedEvent -> PointHandler

다만 이 방식은 같은 동기 흐름으로 강하게 묶여 있는 작업 에 무조건 쓰면 안 된다.
결국 이벤트는 후속 반응을 분리할 때 유용한 것이지, 모든 의존성을 숨기는 도구는 아니다.

3. 읽기 의존성이라면 Query Service 로 분리한다

많은 순환참조는 사실 행동 이 아니라 조회 때문에 생긴다.

예를 들어 주문 도메인이 사용자 이름이나 등급만 읽고 싶은데, 그걸 얻으려고 UserService 전체를 주입하는 식이다.

이럴 때는 아래처럼 읽기 전용 조회를 별도 Query 로 분리하는 편이 훨씬 낫다.

Copy
OrderUseCase -> UserLookupQuery

즉, 사용자에 대한 모든 기능 이 아니라 필요한 조회 계약 하나 만 의존하게 만든다.

4. 진짜 공통 규칙이라면 Shared Policy 로 추출한다

서로 다른 두 도메인이 동일한 규칙을 공유한다면, 그 규칙을 한쪽 도메인 소유로 억지로 두지 말고 공통 정책으로 추출하는 것이 낫다.

예를 들면,

  • 권한 판정 규칙
  • 금액 계산 정책
  • 공통 검증 로직

이런 것은 CommonPolicy, PricingPolicy, PermissionPolicy 처럼 별도 컴포넌트로 뽑아낼 수 있다.

단, 여기서도 무분별한 common 폴더는 위험하다.
정말로 여러 도메인이 공유하는 규칙인지 먼저 확인해야 한다.

5. Port 와 Interface 는 수단이지 정답이 아니다

Hexagonal Architecture 를 이야기하면 흔히 인터페이스를 두면 된다 고 말한다.
하지만 인터페이스는 의존성의 방향을 바꾸는 수단일 뿐, 잘못된 책임 분리를 자동으로 고쳐주지는 않는다.

예를 들어 이런 상황은 여전히 문제다.

  • OrderUseCaseUserPort 를 호출한다.
  • UserUseCase 가 다시 OrderPort 를 호출한다.

형태만 Port 로 바뀌었을 뿐 사실상 순환 의존성은 그대로다.

즉, 인터페이스보다 먼저 봐야 할 것은 이 호출이 정말 필요한가, 상위 조정자가 가져가야 하는가, 이벤트로 분리해야 하는가 이다.

6. NestJS 의 forwardRef() 는 마지막 수단이다

NestJS 에서는 DI 초기화 문제를 피하기 위해 forwardRef() 를 사용할 수 있다.
하지만 이건 설계 문제를 해결했다기보다, 컨테이너가 일단 뜨도록 우회하는 경우가 많다.

물론 프레임워크 레벨에서 어쩔 수 없이 써야 하는 경우도 있다.
하지만 비즈니스 서비스끼리의 순환참조를 forwardRef() 로 넘기기 시작하면 보통 구조 문제가 더 커진다.

내 기준에서는 forwardRef() 는 해결책이라기보다 경고 신호에 가깝다.

실무에서는 어떻게 선택하면 좋을까

개인적으로는 이렇게 정리한다.

  • 단순 CRUD 위주라면 Layered Architecture 로 시작해도 충분하다.
  • 도메인이 복잡하고 오래 진화해야 한다면 Hexagonal Architecture 가 유리하다.
  • 어떤 구조를 쓰든 순환참조는 별도의 설계 문제로 봐야 한다.

즉, 아키텍쳐 선택과 순환참조 해결은 연결되어 있지만 같은 문제는 아니다.

내 생각

실무에서는 둘 중 하나만 순수하게 쓰기보다, Layered Architecture 를 기본으로 가져가되 핵심 유스케이스나 외부 연동 경계에 Hexagonal 관점을 일부 도입하는 경우가 많다.

이 방식이 현실적인 이유는 다음과 같다.

  • 팀이 이해하기 쉽다.
  • 처음부터 과한 추상화를 만들지 않아도 된다.
  • 복잡도가 커지는 지점에만 의존성 역전과 Port/Adapter 를 도입할 수 있다.

그리고 순환참조가 보이기 시작하면 그때는 무조건 기술 트릭부터 찾지 말고 아래 순서로 보는 것이 좋다.

  1. 이 둘은 사실 하나의 유스케이스인가?
  2. 이 의존성은 조회인가 행동인가?
  3. 후속 반응이라면 이벤트로 빼야 하는가?
  4. 진짜 공통 규칙이라면 별도 정책으로 뽑아야 하는가?

이 질문이 정리되면 대부분의 순환참조는 생각보다 깔끔하게 풀린다.

마무리

Layered Architecture 와 Hexagonal Architecture 는 서로 완전히 대체하는 개념이라기보다, 시스템을 바라보는 다른 렌즈에 가깝다.

  • Layered Architecture 는 역할 분리에 강하다.
  • Hexagonal Architecture 는 핵심 보호에 강하다.

하지만 둘 다 순환참조를 자동으로 해결해주지는 않는다.
결국 중요한 것은 비즈니스 로직이 어디에 있어야 하는지, 의존성이 왜 필요한지, 그 의존성이 진짜 같은 레벨에서 발생해야 하는지 를 계속 점검하는 것이다.

좋은 아키텍쳐의 목적은 멋진 다이어그램이 아니라, 시간이 지나도 안전하게 바꿀 수 있는 구조를 만드는 것이라고 생각한다.

참고 자료