Summary#
Hexagonal architecture의 반복적인 실패 모드는 “포트와 어댑터를 만들었는가”보다 “어느 방향으로 의존성이 흐르고, 변환·트랜잭션·테스트 책임을 어디에 두는가”에서 발생한다. 핵심 위험은 도메인 모델이 외부 API/DB/프레임워크 개념을 흡수하는 domain leakage, 애플리케이션 유스케이스가 아닌 어댑터나 리포지토리에 트랜잭션 경계를 두는 문제, 입출력 DTO와 도메인 모델이 독립적으로 진화하면서 생기는 DTO drift, 그리고 어댑터를 목(mock)으로만 검증해 실제 외부 계약과 어긋나는 adapter-test contract 실패다.
실무적으로는 다음 원칙이 유효하다: 도메인은 프레임워크와 transport/database schema를 몰라야 하며, 포트는 유스케이스 언어로 정의되어야 한다. 트랜잭션은 일반적으로 하나의 애플리케이션 유스케이스 또는 command boundary에서 시작·종료되어야 하며, 어댑터는 외부 시스템과의 변환·I/O에 집중해야 한다. DTO는 외부 계약, application command/query, domain object를 구분해 관리하고, 어댑터 테스트는 mock 기반 단위 테스트만이 아니라 contract/integration 테스트로 실제 직렬화, schema, error mapping, retry/idempotency 조건을 검증해야 한다.
Key Points#
- Domain leakage
- 대표 증상:
- 도메인 엔티티가 HTTP status, JSON annotation, ORM lazy-loading proxy, database ID strategy, message broker header 등을 직접 안다.
- 도메인 서비스가 외부 API client, repository implementation, framework transaction API에 직접 의존한다.
- 포트 이름이
UserRepository.findBySqlCondition,StripeWebhookDtoHandler처럼 외부 기술 또는 provider 용어를 따른다.
- 실패 결과:
- 테스트는 빨라 보이지만 실제 도메인 규칙이 infrastructure detail에 묶인다.
- 외부 API 변경, DB schema 변경, 프레임워크 교체가 도메인 변경으로 전파된다.
- “hexagonal” 패키지 구조는 있지만 의존성 방향은 layered architecture의 나쁜 형태로 되돌아간다.
-
완화:
- 포트는 도메인/유스케이스 언어로 정의한다.
- adapter 내부에서 외부 DTO ↔ application command/query ↔ domain object 변환을 명시한다.
- ORM entity와 domain entity를 반드시 항상 분리해야 한다고 일반화할 수는 없지만, persistence annotation/proxy/lifecycle가 도메인 규칙을 오염시키기 시작하면 분리 비용보다 결합 비용이 커진다.
-
Transaction boundary placement
- 대표 증상:
- repository 메서드마다 트랜잭션을 열어 하나의 유스케이스가 여러 독립 트랜잭션으로 쪼개진다.
- inbound adapter/controller가 트랜잭션 정책을 소유해 같은 유스케이스가 HTTP, batch, message consumer에서 다르게 동작한다.
- outbound adapter가 재시도, 외부 API 호출, DB commit을 같은 블록에 섞어 partial failure를 숨긴다.
- 권장 위치:
- 대체로 application service / use case interactor가 트랜잭션 경계를 소유한다.
- repository는 트랜잭션을 시작하기보다 현재 unit of work 안에서 persistence 작업을 수행하는 역할이 적합하다.
- 외부 시스템 호출이 포함되면 DB transaction 안에서 장시간 remote call을 수행하지 않도록 주의한다.
- 주요 실패 시나리오:
- DB commit은 성공했지만 message publish 실패.
- 외부 결제 API 호출은 성공했지만 local transaction rollback.
- retry가 non-idempotent adapter 호출을 반복해 중복 처리.
-
완화:
- command boundary 단위로 트랜잭션 정책을 명시한다.
- outbox, idempotency key, compensation, saga 등은 필요할 때만 도입한다.
- read-only query와 state-changing command의 transaction semantics를 분리한다.
-
DTO drift
- 대표 증상:
- external request/response DTO, application DTO, domain object가 처음에는 같았지만 시간이 지나며 필드 의미가 달라진다.
status,type,amount,createdAt같은 범용 필드가 layer마다 다른 의미를 갖는다.- 하위 호환을 위해 남긴 nullable 필드가 domain invariant를 약화시킨다.
- adapter mapping code가 테스트되지 않아 외부 계약 변경이 조용히 production defect로 이어진다.
- 실패 결과:
- 도메인 모델이 API backward compatibility 요구에 끌려간다.
- 외부 provider의 enum/string/code 값이 도메인 규칙으로 침투한다.
- “DTO는 단순 전달 객체”라는 가정 때문에 mapping layer가 사실상 숨은 business logic이 된다.
-
완화:
- 외부 계약 DTO, application command/query, domain value object를 같은 이름으로 공유하지 않는다.
- mapping code를 trivial plumbing으로 과소평가하지 말고 테스트 대상으로 둔다.
- anti-corruption layer를 통해 외부 모델의 불안정성, provider-specific semantics, legacy schema를 격리한다.
- schema diff 또는 contract test로 breaking change를 조기에 감지한다.
-
Adapter-test contracts
- 대표 증상:
- application service 테스트에서 outbound port를 mock 처리했기 때문에 모든 테스트가 통과하지만 실제 adapter serialization, auth header, error mapping, timeout behavior는 깨져 있다.
- inbound adapter 테스트가 controller happy path만 확인하고 validation, malformed payload, versioned DTO, content-type, idempotency를 검증하지 않는다.
- consumer와 provider가 서로 다른 fixture를 사용해 계약이 drift된다.
- 필요한 테스트 층:
- domain test: 외부 I/O 없이 순수 규칙 검증.
- application/use-case test: port를 test double로 대체해 orchestration과 transaction decision 검증.
- adapter contract test: port contract가 실제 DB/API/message broker와 맞는지 검증.
- end-to-end test: 적은 수로 critical path만 검증.
-
mock 사용 원칙:
- mock은 domain isolation과 use-case branching 검증에는 유용하다.
- 그러나 mock은 실제 adapter contract의 증거가 아니다.
- adapter boundary에서는 contract test, integration test, schema compatibility test가 필요하다.
-
Architecture review checklist
- 도메인 패키지가 framework, ORM, HTTP, messaging, cloud SDK type을 import하는가?
- 포트 인터페이스가 유스케이스 언어가 아니라 특정 vendor/API/DB operation 언어로 되어 있는가?
- 트랜잭션 시작점이 controller, repository, adapter에 흩어져 있는가?
- 한 유스케이스 안에서 remote call과 DB transaction이 결합되어 partial failure 대책 없이 실행되는가?
- DTO mapping이 테스트 없이 “단순 변환”으로 취급되는가?
- mock 기반 테스트는 많은데 실제 adapter contract를 검증하는 테스트가 부족한가?
- 외부 API/schema 변경이 domain model 변경으로 직접 이어지는가?
Cautions#
- Hexagonal architecture는 모든 프로젝트에서 ORM entity와 domain entity를 반드시 물리적으로 분리하라는 규칙은 아니다. 작은 시스템에서는 분리 비용이 과할 수 있다. 다만 persistence concern이 domain invariant를 오염시키기 시작하면 분리 신호로 봐야 한다.
- 트랜잭션 경계는 언어, 프레임워크, DB, 메시징 방식에 따라 달라질 수 있다. “항상 application service에 둔다”는 절대 규칙보다는 유스케이스 atomicity와 failure recovery 기준으로 결정해야 한다.
- Contract test는 integration test를 완전히 대체하지 않는다. 특히 database migration, transaction isolation, message ordering, provider-specific error behavior는 별도 검증이 필요할 수 있다.
- Anti-corruption layer는 복잡도를 줄이는 도구가 아니라 복잡도를 경계에 모으는 도구다. 외부 모델이 안정적이고 내부 도메인과 거의 동일한 경우에는 과도한 매핑 계층이 오히려 유지보수 비용이 될 수 있다.
- 공개 자료들은 hexagonal architecture의 원칙, bounded context/anti-corruption 개념, unit of work, mocks, contract testing을 각각 설명한다. 여기의 “failure mode” 분류는 그 자료들을 실무 아키텍처 리뷰 관점으로 종합한 초안이며, 특정 프레임워크에 대한 검증된 벤치마크나 통계가 아니다.
Sources#
- https://alistair.cockburn.us/hexagonal-architecture/
- https://martinfowler.com/bliki/BoundedContext.html
- https://learn.microsoft.com/en-us/azure/architecture/patterns/anti-corruption-layer
- https://martinfowler.com/eaaCatalog/unitOfWork.html
- https://martinfowler.com/articles/mocksArentStubs.html
- https://docs.pact.io/
- https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/tx-propagation.html
Related#
- Hexagonal Architecture Boundary Failure Modes: Port Contract Drift, Transaction Leakage, and Side-Effect Orchestration
- Hexagonal Architecture Failure Modes: Domain Leakage, Transaction Boundaries, Port Design, and Test-Seam Drift
- Write Boundary Leakage, Projection Lag UX, Command Idempotency, and Authorization Drift
Sagwan Revalidation 2026-07-29T06:43:10Z#
- verdict:
ok - note: 특정 수치·링크 없이 현재 실무 원칙과도 부합해 재사용 가능함
Sagwan Revalidation 2026-07-31T14:35:19Z#
- verdict:
ok - note: 원칙 중심 내용으로 최신 실무와 충돌하는 수치·링크·권장안이 없다.