시작하며
프로젝트 구조를 이야기할 때 자주 나오는 주제가 있다.
Layered Architecture 와 Hexagonal Architecture 는 무엇이 다를까?
둘 다 코드를 역할별로 나누고 복잡도를 낮추기 위한 구조이기 때문에 비슷해 보인다.
하지만 실제로는 무엇을 중심에 두는지, 의존성이 어디를 향하는지, 외부 기술을 어떻게 격리하는지 에서 차이가 꽤 크다.
그리고 여기서 한 가지 더 중요한 질문이 있다.
이런 아키텍쳐를 쓰면 순환참조도 자동으로 사라질까?
내 생각은 아니다 이다.
좋은 아키텍쳐는 순환참조를 줄이는 데 도움을 주지만, 순환참조를 해결하는 만능 열쇠는 아니다. 결국 순환참조는 의존성의 성격 을 보고 풀어야 한다.
이번 글에서는 다음 내용을 정리해보려고 한다.
- Layered Architecture 는 어떻게 구성되는가
- Hexagonal Architecture 는 어떻게 구성되는가
- 둘의 핵심 차이는 무엇인가
- 순환참조는 왜 생기고 어떻게 해결해야 하는가
한 줄로 먼저 정리하면
Layered Architecture는 역할을 층으로 나누는 구조이다.Hexagonal Architecture는 도메인을 중심에 두고 바깥과의 연결을 Port 와 Adapter 로 감싸는 구조이다.
즉, Layered 는 계층 구조 로 바라보고, Hexagonal 은 핵심 보호 구조 로 바라본다.
Layered Architecture 는 어떻게 구성되는가
Layered Architecture 는 가장 익숙한 백엔드 구조이다.
보통 아래와 같은 계층으로 나눈다.
- Presentation Layer
- Application Layer
- Domain Layer
- Infrastructure Layer
위 그림처럼 보통 상위 레이어가 하위 레이어를 호출하는 구조를 가진다.
Presentation -> Application -> Domain -> Infrastructure1. 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 는 보통 아래 요소로 설명할 수 있다.
- Application
- Domain
- Inbound Port / Inbound Adapter
- Outbound Port / Outbound Adapter
1. Domain
가장 안쪽에 있는 핵심이다.
- Entity
- Value Object
- Domain Rule
- Domain Policy
여기에는 DB, HTTP, MQ, 프레임워크에 대한 코드가 들어오지 않는 것이 이상적이다.
2. Application
유스케이스를 실행한다.
CreateOrderUseCaseCancelOrderUseCaseIssueCouponUseCase
즉, 시스템이 제공하는 기능 단위를 표현한다.
3. Inbound Adapter
바깥에서 안쪽으로 요청을 전달하는 진입점이다.
- REST Controller
- GraphQL Resolver
- Scheduler
- Kafka Consumer
중요한 점은 이들이 바로 도메인 세부 구현을 다루는 것이 아니라, 안쪽의 유스케이스를 호출한다는 점이다.
4. Outbound Port
핵심 로직이 바깥에 요구하는 기능의 계약 이다.
예를 들면 이런 인터페이스가 될 수 있다.
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
OrderController
-> OrderService
-> OrderRepository
-> MySQL직관적이고 빠르게 구현할 수 있다.
하지만 시간이 지나면 OrderService 가 아래 역할을 모두 떠안는 경우가 많다.
- 검증
- 비즈니스 규칙
- 외부 API 호출
- 이벤트 발행
- 저장
Hexagonal Architecture
OrderController(Adapter In)
-> CreateOrderUseCase
-> SaveOrderPort
-> OrderPersistenceAdapter
-> MySQL여기서는 유스케이스가 저장이 필요하다는 사실만 알고, 실제 MySQL 구현은 바깥 Adapter 가 담당한다.
이 방식은 테스트 작성과 구현 교체에 유리하다.
순환참조는 왜 생길까
여기서 중요한 포인트가 있다.
Layered Architecture 든 Hexagonal Architecture 든, 구조만 도입한다고 순환참조가 없어지지는 않는다.
순환참조는 주로 아래 상황에서 생긴다.
- 도메인 A 가 도메인 B 의 기능을 직접 호출한다.
- 도메인 B 도 다시 도메인 A 를 호출한다.
- 하나의 유스케이스 안에서 책임 경계가 불분명하다.
- 읽기 전용 조회와 비즈니스 행동이 뒤섞인다.
예를 들어 이런 식이다.
OrderService -> UserService
UserService -> OrderService처음에는 편해 보이지만, 결국 모듈 초기화 문제, 테스트 어려움, 변경 파급 증가로 이어진다.
아키텍쳐는 순환참조 해결의 만능이 아니다
Layered Architecture 를 써도 OrderService 와 UserService 가 서로를 호출하면 순환참조가 생긴다.
Hexagonal Architecture 를 써도 OrderUseCase 와 UserUseCase 가 서로의 Port 를 잘못 물고 들어가면 같은 문제가 생긴다.
즉, 문제의 핵심은 아키텍쳐 이름이 아니라 누가 누구를 왜 참조하는가 이다.
그래서 순환참조는 의존성의 종류 에 따라 풀어야 한다.
순환참조를 해결하는 방법
1. 하나의 유스케이스라면 상위 Orchestrator 로 올린다
가장 흔한 경우다.
OrderService 가 UserService 를 호출하고, UserService 가 다시 OrderService 를 호출하는 이유는 사실 같은 유스케이스를 서로 나눠서 처리하고 있기 때문인 경우가 많다.
이럴 때는 둘 중 하나가 다른 하나를 직접 호출하게 두지 말고, 상위 레벨의 Application Service 또는 UseCase 가 둘을 조합하도록 만드는 편이 낫다.
PlaceOrderUseCase
-> UserDomain
-> OrderDomain즉, 동등한 두 서비스가 서로 부르는 구조 를 상위 조정자가 아래를 호출하는 구조 로 바꾼다.
이 방식은 Layered Architecture 에서도 유효하고, Hexagonal Architecture 에서도 유효하다.
2. 후속 반응이라면 Domain Event 로 분리한다
어떤 기능은 같은 트랜잭션 안에서 즉시 처리할 필요가 없고, 단지 무언가가 일어났음 을 다른 쪽에 알려주면 된다.
예를 들면,
- 주문 완료 후 알림 발송
- 회원 탈퇴 후 쿠폰 정리
- 결제 완료 후 포인트 적립
이런 경우 A 가 B 를 직접 호출하기보다 이벤트를 발행하고, B 가 그 이벤트를 구독하게 만들면 결합도를 낮출 수 있다.
OrderDomain -> OrderPlacedEvent -> PointHandler다만 이 방식은 같은 동기 흐름으로 강하게 묶여 있는 작업 에 무조건 쓰면 안 된다.
결국 이벤트는 후속 반응을 분리할 때 유용한 것이지, 모든 의존성을 숨기는 도구는 아니다.
3. 읽기 의존성이라면 Query Service 로 분리한다
많은 순환참조는 사실 행동 이 아니라 조회 때문에 생긴다.
예를 들어 주문 도메인이 사용자 이름이나 등급만 읽고 싶은데, 그걸 얻으려고 UserService 전체를 주입하는 식이다.
이럴 때는 아래처럼 읽기 전용 조회를 별도 Query 로 분리하는 편이 훨씬 낫다.
OrderUseCase -> UserLookupQuery즉, 사용자에 대한 모든 기능 이 아니라 필요한 조회 계약 하나 만 의존하게 만든다.
4. 진짜 공통 규칙이라면 Shared Policy 로 추출한다
서로 다른 두 도메인이 동일한 규칙을 공유한다면, 그 규칙을 한쪽 도메인 소유로 억지로 두지 말고 공통 정책으로 추출하는 것이 낫다.
예를 들면,
- 권한 판정 규칙
- 금액 계산 정책
- 공통 검증 로직
이런 것은 CommonPolicy, PricingPolicy, PermissionPolicy 처럼 별도 컴포넌트로 뽑아낼 수 있다.
단, 여기서도 무분별한 common 폴더는 위험하다.
정말로 여러 도메인이 공유하는 규칙인지 먼저 확인해야 한다.
5. Port 와 Interface 는 수단이지 정답이 아니다
Hexagonal Architecture 를 이야기하면 흔히 인터페이스를 두면 된다 고 말한다.
하지만 인터페이스는 의존성의 방향을 바꾸는 수단일 뿐, 잘못된 책임 분리를 자동으로 고쳐주지는 않는다.
예를 들어 이런 상황은 여전히 문제다.
OrderUseCase가UserPort를 호출한다.UserUseCase가 다시OrderPort를 호출한다.
형태만 Port 로 바뀌었을 뿐 사실상 순환 의존성은 그대로다.
즉, 인터페이스보다 먼저 봐야 할 것은 이 호출이 정말 필요한가, 상위 조정자가 가져가야 하는가, 이벤트로 분리해야 하는가 이다.
6. NestJS 의 forwardRef() 는 마지막 수단이다
NestJS 에서는 DI 초기화 문제를 피하기 위해 forwardRef() 를 사용할 수 있다.
하지만 이건 설계 문제를 해결했다기보다, 컨테이너가 일단 뜨도록 우회하는 경우가 많다.
물론 프레임워크 레벨에서 어쩔 수 없이 써야 하는 경우도 있다.
하지만 비즈니스 서비스끼리의 순환참조를 forwardRef() 로 넘기기 시작하면 보통 구조 문제가 더 커진다.
내 기준에서는 forwardRef() 는 해결책이라기보다 경고 신호에 가깝다.
실무에서는 어떻게 선택하면 좋을까
개인적으로는 이렇게 정리한다.
- 단순 CRUD 위주라면 Layered Architecture 로 시작해도 충분하다.
- 도메인이 복잡하고 오래 진화해야 한다면 Hexagonal Architecture 가 유리하다.
- 어떤 구조를 쓰든 순환참조는 별도의 설계 문제로 봐야 한다.
즉, 아키텍쳐 선택과 순환참조 해결은 연결되어 있지만 같은 문제는 아니다.
내 생각
실무에서는 둘 중 하나만 순수하게 쓰기보다, Layered Architecture 를 기본으로 가져가되 핵심 유스케이스나 외부 연동 경계에 Hexagonal 관점을 일부 도입하는 경우가 많다.
이 방식이 현실적인 이유는 다음과 같다.
- 팀이 이해하기 쉽다.
- 처음부터 과한 추상화를 만들지 않아도 된다.
- 복잡도가 커지는 지점에만 의존성 역전과 Port/Adapter 를 도입할 수 있다.
그리고 순환참조가 보이기 시작하면 그때는 무조건 기술 트릭부터 찾지 말고 아래 순서로 보는 것이 좋다.
- 이 둘은 사실 하나의 유스케이스인가?
- 이 의존성은 조회인가 행동인가?
- 후속 반응이라면 이벤트로 빼야 하는가?
- 진짜 공통 규칙이라면 별도 정책으로 뽑아야 하는가?
이 질문이 정리되면 대부분의 순환참조는 생각보다 깔끔하게 풀린다.
마무리
Layered Architecture 와 Hexagonal Architecture 는 서로 완전히 대체하는 개념이라기보다, 시스템을 바라보는 다른 렌즈에 가깝다.
- Layered Architecture 는 역할 분리에 강하다.
- Hexagonal Architecture 는 핵심 보호에 강하다.
하지만 둘 다 순환참조를 자동으로 해결해주지는 않는다.
결국 중요한 것은 비즈니스 로직이 어디에 있어야 하는지, 의존성이 왜 필요한지, 그 의존성이 진짜 같은 레벨에서 발생해야 하는지 를 계속 점검하는 것이다.
좋은 아키텍쳐의 목적은 멋진 다이어그램이 아니라, 시간이 지나도 안전하게 바꿀 수 있는 구조를 만드는 것이라고 생각한다.