헥사고날 아키텍처를 폴더 구조로만 이해하면, 코어를 DB로부터는 지키면서 소비자에게는 그대로 유출하는 이상한 코드가 나온다. 진입점인 Web Controller도 어댑터라는 사실 하나만 놓쳐도 그렇게 된다. 이 글은 "API 계약은 소비자의 언어여야 한다"는 오래된 원칙을 헥사고날의 관점에서 다시 세우고, 코드 생산이 공짜가 되어가는 AI 시대에 개발자가 끝까지 쥐고 있어야 할 기본기가 왜 결국 설계와 사용성인지에 대한 내 생각을 정리한 것이다.
사실 이 글은 이론에서 출발하지 않았다. 같은 실수를 반복하는 동료에게 이걸 어떻게 설명해야 할지 며칠을 고민하다 쓰게 됐다. 말로는 몇 번을 해봤는데, "코드는 도는데 뭐가 문제냐"는 얼굴 앞에서 매번 말문이 막혔다. 그래서 내 머릿속에 흩어져 있던 감각, "이건 아닌데" 하는 그 직관을 남에게 건넬 수 있는 언어로 벼려보기로 했다. 그 과정에서 정작 정리된 건 내 원칙이었다.
Intro
- Web Controller는 어댑터다. 정확히는 inbound(driving) adapter. 그 존재 이유는 외부의 표현과 내부 코어의 표현을 경계에서 번역하는 것 하나뿐이다.
- 내부 데이터 모델을 그대로 JSON으로 뱉는 순간, 그 어댑터는 아무것도 어댑팅하지 않은 빈 껍데기가 된다. 헥사고날 폴더 이름을 붙인 3-tier 코드일 뿐이다.
- 흔한 증상: outbound(코어↔DB)는 격리를 지켰는데 inbound(소비자↔코어)는 유출한다. 같은 원칙을 한쪽만 지킨 건, 이해해서 지킨 게 아니라 관행을 따라 한 것이다.
- "내부에서 뭐라 부를지 합의했다"와 "그게 공개 계약이다"는 전혀 다른 명제다. 그 사이에 경계 번역(anti-corruption layer)이라는 별개의 설계 행위가 있다.
- AI는 내부 모델을 그대로 미러링한 컨트롤러를 순식간에 만들어준다. 그게 최소 저항 경로이기 때문이다. 어디에 경계를 긋고, 소비자에게 무엇을 어떤 언어로 보여줄지는 AI가 대신 판단해주지 않는다. 그게 마지막까지 사람의 몫이다.
잘 지은 육각형 집의 현관에서 벌어지는 일
한 백엔드 코드를 상상해보자. 주문 도메인이 있고, 헥사고날 아키텍처로 아주 반듯하게 짜여 있다.
domain에는 순수한Order,Fulfillment모델이 있다. 스프링도 JPA도 모른다.application에는 UseCase와 InPort/OutPort가 있다. 인프라를 모른다.adapter/persistence에는 JPA 엔티티와 OutPort 구현체가 있다.FulfillmentEventEntity를 도메인FulfillmentEvent로 매퍼가 번역한다. 코어는 JPA 엔티티를 절대 보지 않는다.
여기까지는 교과서다. 의존성 방향도 맞고, ArchUnit 가드도 통과하고, 코어는 인프라로부터 완벽히 격리돼 있다.
그런데 배송 추적 API를 열어보면 이렇게 되어 있다.
@Tag(name = "FulfillmentEvent", description = "이행 이벤트(append-only 상태 전이) 관리 API")
@RestController
@RequestMapping("/v1/orders/{orderId}/fulfillment-events")
class FulfillmentEventController(
private val getFulfillmentEventsInPort: GetFulfillmentEventsInPort,
) {
@Operation(
summary = "이행 이벤트 목록 조회",
description = "ROOT 전이부터 말단 이벤트까지 event_type 순으로 조회합니다.",
)
@GetMapping
fun getEvents(@PathVariable orderId: String):
ResponseEntity<CommonResponse<List<FulfillmentEventResponse>>> = ...
}
그리고 FulfillmentEventResponse에는 eventType, occurredAt, actorId, parentEventId 같은 필드가 그대로 담겨 나간다. 이건 사실상 fulfillment_event 테이블 한 행을 JSON으로 옮긴 것이다.
이 API를 쓰는 사람은 누구인가? 모바일 앱의 "주문 상세" 화면을 그리는 클라이언트 개발자다. 그 사람이 알고 싶은 건 단 하나다. "내 주문 지금 어디쯤 왔어?" 결제 완료 → 상품 준비 → 배송 시작 → 배송 중 → 배송 완료. 그 다섯 단계 중 지금 어디고, 언제쯤 도착하는지.
그런데 이 API는 그 사람에게 event_type이 뭔지, append-only 상태 전이가 뭔지, parentEventId로 뭘 조립해야 하는지를 먼저 이해하라고 요구한다. 화면 하나 그리려고 우리 팀의 상태 머신 설계 문서를 읽어야 한다.
집은 육각형으로 반듯하게 지어놓고, 현관에서 손님한테 우리 집 배관도와 전기 배선도를 펼쳐 보여주는 격이다. 손님이 알고 싶은 건 "화장실이 어디예요"인데.
진입점도 어댑터다: inbound/outbound 비대칭이라는 증상
왜 이런 일이 벌어질까. 대부분 헥사고날을 폴더 구조로 이해했기 때문이다. domain, application, adapter 디렉터리를 나누고 의존성 화살표를 안쪽으로 향하게 하면 헥사고날이라고 믿는 것이다. 절반만 맞다.
핵심을 다시 보자. Web Controller는 그 자체가 어댑터다. 정확히는 inbound(driving) adapter. 컨트롤러를 굳이 별도 어댑터 레이어로 떼어낸 이유는 단 하나. 외부 세계(HTTP·소비자)의 표현과 내부 코어의 표현을 경계에서 서로 번역하기 위해서다. 그게 이 어댑터의 존재 이유 전부다.
그렇다면 내부 모델을 한 글자도 안 바꾸고 그대로 뱉는 컨트롤러는 무엇인가. 아무것도 어댑팅하지 않은 어댑터다. pass-through, 빈 껍데기. 어댑터의 자리에 앉아 있지만 어댑터의 일을 하지 않는다.
여기서 가장 선명한 진단 지표가 나온다. inbound와 outbound의 비대칭.

같은 원칙 "경계 너머의 표현으로부터 반대편을 격리한다" 인데, 나가는 쪽(DB)에서는 지키고 들어오는 쪽(소비자)에서는 안 지킨다. FulfillmentEventEntity가 코어로 새어 들어오는 건 막았으면서, 도메인 모델이 소비자로 새어 나가는 건 방치한다.
한쪽만 지켜졌다는 사실 자체가 진단이다. 원칙을 이해해서 지켰다면 양쪽에 똑같이 적용됐을 것이다. 한쪽만 됐다는 건 outbound는 남들 하는 대로(엔티티-도메인 매퍼는 관행이니까) 따라 했고, inbound는 스스로 판단해야 하는데 그 판단 기준이 없어서 무너졌다는 뜻이다.
그리고 이 지점에서 흔히 나오는 반론이 있다. "내부 모델 이름은 팀에서 합의된 거예요."
맞다. 내부 데이터 모델을 어떻게 설계하고 뭐라고 부를지는 팀의 정당한 결정이다. fulfillment_event라는 append-only 상태 전이 설계가 좋은 설계일 수도 있다. 하지만 그건 지금 문제와 무관하다.
"우리가 이걸 뭐라고 부를지 내부에서 합의했다" ≠ "따라서 이게 공개 계약이다."
이 둘 사이에는 경계 번역(anti-corruption layer) 이라는 별개의 설계 행위가 있다. 내부 어휘가 확정됐다는 사실은 오히려 "이제 남은 유일한 일은 경계에서 번역하는 것 하나"라는 뜻이다. API 작성자의 계약 설계 책임을 면제해주는 게 아니라, 반대로 그 책임만 남겨놓는 것이다. 내부가 잘 굳어 있을수록 변명의 여지는 좁아진다.
여기서 이름에 대한 짧은 관찰 하나를 덧붙이고 싶다. 내부 라벨은 종종 대립항 없는 수식어를 달고 있다. base~, common~, meta~, core~. 이런 접두어는 "이것 말고 다른 것과 구분하기 위한" 내부 분류 언어지, 소비자 도메인에 실재하는 개념이 아니다. common-code에 대응하는 uncommon-code가 없고, base-entity에 대응하는 derived-entity를 소비자가 알 필요가 없다. 대립항이 없는 수식어가 계약에 노출돼 있다면, 그건 십중팔구 내부 라벨이 새어 나온 신호다. API 경로나 태그에서 이런 단어를 발견하면 멈춰야 한다.
API 계약이란 결국 내 코드를 평생 읽지 않을 사람에게 건네는 언어다. 그 사람은 우리 ERD를 모르고, 알 이유도 없고, 알아야 한다면 그건 계약이 실패한 것이다. 좋은 계약은 소비자가 자기 화면의 언어("주문 진행 단계")만으로 이해되고, 나쁜 계약은 생산자의 저장 구조("이행 이벤트 전이")를 먼저 학습하라고 요구한다.
그래서 앞의 API는 이렇게 갔어야 한다. 내부는 그대로 두고, 진입 어댑터만.
@Tag(name = "Order", description = "주문 조회 API")
@RequestMapping("/v1/orders/{orderId}/tracking")
// GET → { currentStep: "IN_TRANSIT", steps: [...], estimatedArrival: ... }
// 응답 타입: OrderTrackingResponse (FulfillmentEventResponse 아님)
UseCase도 OutPort도 fulfillment_event도 손대지 않는다. 그것들은 내부 진실원으로서 옳다. 바꾸는 건 진입 어댑터 한 겹. 컨트롤러 경로, Swagger 태그·설명, 그리고 응답 DTO의 이름과 모양뿐이다. 유출이냐 격리냐는 전적으로 이 한 겹에서 갈린다.
AI가 대신 짜주는 시대에, 하필 이게 왜 더 중요해지나
여기서 시대 이야기를 해야겠다. 나는 요즘 코드를 AI와 함께 짠다. 컨트롤러 하나, DTO 하나, 매퍼 하나 만드는 건 이제 몇 초짜리 일이다. 그리고 바로 그 편리함이 이 글의 안티패턴을 더 흔하게 만든다.
AI에게 "이 도메인 조회 API 만들어줘"라고 하면, AI는 십중팔구 내부 모델을 그대로 미러링한 컨트롤러를 만들어준다. FulfillmentEvent 도메인이 있으면 FulfillmentEventResponse를, event_type 필드가 있으면 그 필드를 그대로. 왜냐하면 그게 최소 저항 경로이기 때문이다. AI는 눈앞의 내부 구조를 반영하는 데는 탁월하지만, "이 API를 실제로 쓸 앱 개발자가 무엇을 알고 싶은가"를 대신 상상해주지는 않는다. 그건 프롬프트에 없는 정보다. 그 정보는 오직 소비자를 떠올리는 사람의 머릿속에만 있다.
그래서 나는 이렇게 정리했다. AI 시대에 개발자의 일은 두 층으로 갈린다.
- 생산(production)의 층: 코드를 타이핑하는 일. 문법, 보일러플레이트, 관용구, 매퍼. 이 층은 빠르게 AI로 넘어가고 있고, 넘어가는 게 맞다.
- 판단(judgment)의 층: 무엇을 만들지, 어디에 경계를 긋고, 소비자에게 무엇을 어떤 언어로 보여줄지 결정하는 일. 이 층은 AI가 대신해주지 않는다. 프롬프트를 쓰는 사람이 이 판단을 내려주지 않으면, AI는 그냥 눈앞의 내부 구조를 복사한다.
코드 생산이 공짜에 가까워질수록, 남는 차별점은 경계를 어디에 긋는가, 그 경계에서 어떤 언어로 말하는가가 된다. 역설적이게도, 타이핑이 쉬워질수록 설계와 사용성의 중요도는 올라간다. 아무나 그럴듯한 컨트롤러를 순식간에 뽑을 수 있게 됐기 때문에, "그럴듯하지만 내부가 다 비치는" 코드가 그 어느 때보다 쉽게, 대량으로 생산된다. 그걸 걸러내는 눈이 곧 실력이 된다.
나는 AI에게 사고를 외주 줄 수 있다고 믿지 않는다. 타이핑은 외주 줄 수 있다. 하지만 "이 API를 쓰는 사람이 우리 ERD를 몰라도 되게 하겠다"는 의도는 외주가 안 된다. 그건 소비자를 향한 공감이고, 공감은 프롬프트 바깥에 있다.
내가 끝까지 쥐고 있으려는 두 가지
돌아보면 이 글의 사례는 대단한 알고리즘도, 어려운 분산 시스템 문제도 아니다. 컨트롤러 한 겹에서 소비자의 언어로 번역하느냐라는, 지극히 기본적인 이야기다. 그런데 나는 이 "기본"이야말로 오래 갈 것이라고 본다.
헥사고날은 폴더를 나누는 규칙이 아니라 경계에서 표현을 번역하겠다는 태도다. 어댑터를 관통해 코어가 비쳐 보이는 순간, 그건 이미 헥사고날이 아니라 DB 스키마를 REST로 export한 3-tier 코드에 육각형 이름표를 붙인 것이다. 육각형을 그렸다는 사실이 유연함을 증명하지 않는다. 유출이 없다는 사실이 증명한다.
그래서 내가 백엔드 개발자로서, 그리고 AI와 함께 일하는 사람으로서 끝까지 놓지 않으려는 기본기는 결국 두 가지다.
- 설계 : 경계를 의식하는 힘. 무엇이 안이고 무엇이 밖인지, 어디서 표현을 바꿔야 하는지를 아는 것. 내부의 정당한 결정과 외부에 노출할 계약을 분리해서 생각하는 것.
- 사용성 : 내 코드를 평생 읽지 않을 사람의 관점에서 계약을 바라보는 힘. "이 사람이 이걸 쓰려고 무엇을 먼저 알아야 하지?"를 묻고, 그 답이 "우리 내부 구조"라면 부끄러워할 줄 아는 것.
코드는 AI가 써줄 수 있다. 하지만 어디에 선을 긋고, 그 선 너머의 사람에게 어떤 언어로 말할지는 아직, 그리고 꽤 오랫동안, 사람의 일이다. 나는 그 일을 내 것으로 남겨두려 한다. 그게 내 개발 철학이고 원칙이다.
셀프 체크리스트 : API 하나 열기 전에
- 이 endpoint 경로에 소비자가 모르는 단어(내부 테이블·전략·자료구조 이름)가 있는가?
- Swagger Tag가 소비자 화면/기능 단위인가, 내부 모듈/개념 단위인가?
- description에 "루트·노드·트리·전이·엔티티·이벤트" 같은 내부 구현 용어가 있는가?
- 응답 DTO 타입 이름이 내부 도메인/테이블 이름을 그대로 노출하는가?
- 같은 화면을 먹이는 기존 API들과 어휘가 일관되는가?
- 소비자가 이 API를 쓰려고 우리 내부 문서(ERD·설계doc)를 읽어야 하는가?
- 대립항 없는 수식어(base/common/meta/core…)가 계약에 노출됐는가? → 내부 라벨 유출 신호
하나라도 걸리면, 진입 어댑터를 소비자의 언어로 다시 짠다. 내부는 건드리지 않는다. 그 한 겹이 헥사고날의 진짜 값어치다.
'아키텍처' 카테고리의 다른 글
| 리뷰가 놓친 걸 빌드가 잡았다: ArchUnit으로 헥사고날에 가드를 걸기 (0) | 2026.06.21 |
|---|---|
| 팀 전원이 운영할 수 있는 CI/CD를 설계하다 - 망 분리 환경에서 GitHub Actions + Helm + ArgoCD로 (0) | 2026.03.07 |
| Transactional Outbox Pattern과 적용 회고 (from. BaaS) (0) | 2025.08.01 |