← 개념 목록

Ports and Adapters

개념

애플리케이션의 핵심 비즈니스 로직을 포트라는 추상 인터페이스로 감싸고, 어댑터를 통해 UI나 데이터베이스 같은 기술적 세부사항과 분리함으로써 교체 가능성과 테스트 용이성을 높이는 소프트웨어 아키텍처 패턴이다.

개요

Ports and Adapters는 Alistair Cockburn이 2005년에 명명한 소프트웨어 아키텍처 패턴으로, Hexagonal Architecture라는 이름으로 더 널리 알려져 있습니다. 이 패턴의 핵심 아이디어는 단순합니다. 애플리케이션의 핵심 로직(코어)을 외부 세계와 완전히 분리하고, 코어와 외부가 대화하는 지점을 포트라는 추상 인터페이스로 규정한 뒤, 실제 기술(웹 프레임워크, 데이터베이스, 메시지 큐, 테스트 하네스 등)은 어댑터라는 구현체로 그 포트에 꽂아 넣는 것입니다.

이렇게 만들어진 애플리케이션은 UI 없이도, 데이터베이스 없이도 동작할 수 있고, 사람이 아니라 자동화된 테스트나 배치 스크립트가 대신 구동할 수도 있습니다. 코어 입장에서는 자신을 누가 호출하는지, 자신이 호출하는 대상이 진짜 데이터베이스인지 인메모리 목(mock)인지 알 필요가 없습니다. 이 대칭성이야말로 포트와 어댑터 아키텍처가 전통적인 계층형 아키텍처(Layered Architecture)와 근본적으로 다른 지점입니다.

이 문서에서는 이 패턴이 등장하게 된 배경, 핵심 개념, 어니언 아키텍처 및 클린 아키텍처와의 관계, TypeScript와 Node.js를 이용한 구현 방법, 테스트 전략, 그리고 실무에서 이 패턴을 적용하고 때로는 제거하기도 한 실제 사례들을 폭넓게 정리합니다.

역사와 배경

계층형 아키텍처의 구조적 문제

전통적인 3계층 혹은 N계층 아키텍처는 프레젠테이션 계층, 비즈니스 로직 계층, 데이터 접근 계층을 위에서 아래로 쌓는 방식입니다. Cockburn은 이 구조에서 반복적으로 나타나는 두 가지 문제를 지적했습니다.

첫째, 사용자 인터페이스 코드에 비즈니스 로직이 스며드는 문제입니다. 화면의 필드 크기나 버튼 배치 같은 시각적 세부사항에 로직이 얽히면 자동화된 테스트 스위트로 시스템을 검증하기 어려워지고, 사람이 직접 조작하던 시스템을 배치로 돌리거나 다른 프로그램이 호출하도록 바꾸는 일도 사실상 불가능해집니다. 조직들은 이 문제를 해결하겠다며 새로운 계층을 하나 더 만들지만, 그 경계를 지키는 강제 장치가 없기 때문에 몇 년 뒤에는 다시 비즈니스 로직이 그 계층에 뒤섞여 있는 것을 발견하게 됩니다.

둘째, 이와 대칭적으로 반대편에서는 애플리케이션 로직이 외부 데이터베이스나 서비스에 묶여 버리는 문제가 있습니다. 데이터베이스 서버가 다운되거나 대대적인 개편이 필요할 때, 개발자들은 그 데이터베이스의 존재 자체에 발이 묶여 작업을 진행하지 못하게 됩니다.

Cockburn은 이 두 문제가 겉보기에는 다른 위치(왼쪽의 UI, 오른쪽의 데이터베이스)에서 벌어지는 것처럼 보이지만, 사실은 동일한 원인, 즉 비즈니스 로직과 외부 개체 사이의 상호작용이 뒤엉켜 있다는 하나의 원인에서 비롯된다는 점을 간파했습니다.

2005년, 포트와 어댑터의 탄생

Cockburn은 1998년경부터 이 구조를 육각형 그림으로 그려 왔지만, 그 육각형의 각 변이 정확히 무엇을 의미하는지는 스스로도 한동안 명확히 정리하지 못했다고 밝힌 바 있습니다. Portland Pattern Repository(Ward Cunningham의 위키위키 사이트)에서 이 아이디어를 공유하고 여러 의견을 주고받은 끝에, 2005년 9월 4일 “The Hexagonal (Ports and Adapters) Architecture”라는 기술 보고서(HaT Technical Report 2005.02)를 통해 이 패턴을 정식으로 명명하고 정리했습니다.

이 원논문에서 Cockburn은 패턴의 의도를 다음과 같이 요약합니다. 사용자, 프로그램, 자동화된 테스트나 배치 스크립트가 모두 동일한 방식으로 애플리케이션을 구동할 수 있어야 하고, 애플리케이션은 실제 운영 환경의 장치나 데이터베이스로부터 독립적으로 개발되고 테스트될 수 있어야 한다는 것입니다. 외부 세계에서 이벤트가 포트에 도착하면 기술 특화된 어댑터가 이를 애플리케이션이 이해할 수 있는 호출이나 메시지로 변환해 전달하고, 애플리케이션이 무언가를 내보낼 때도 마찬가지로 포트를 거쳐 어댑터가 수신 측 기술에 맞는 신호로 바꾸어 줍니다. 애플리케이션은 포트 너머에 있는 것이 사람인지 테스트 스크립트인지 알지 못한 채, 오직 포트가 규정한 의미론적으로 일관된 상호작용만을 수행합니다.

원논문에서 소개된 예제 코드는 의도적으로 아주 단순합니다. 금액에 요율을 곱해 할인액을 계산하는 discount(amount) = amount * rate(amount)라는 애플리케이션을, 먼저 상수 요율로 테스트 하네스(FIT)와 함께 만들고, 그다음 GUI를 붙이고, 마지막으로 실제 저장소를 인터페이스 뒤로 숨긴 목(mock) 저장소로 교체 가능하게 만드는 세 단계로 발전시킵니다. 이 과정 자체가 포트와 어댑터 아키텍처의 핵심을 그대로 보여줍니다. 코어는 그대로 두고, 바깥의 어댑터만 바꾸어 끼운다는 것입니다.

왜 육각형인가

이 패턴의 이름 때문에 흔히 오해가 생깁니다. 육각형이 여섯 개의 면을 가지므로 포트가 정확히 여섯 개여야 한다고 생각하는 경우가 그것입니다. 그러나 Cockburn 본인이 밝혔듯, 육각형이라는 도형 자체에는 특별한 의미가 없습니다. 단지 위-아래, 좌-우로만 그릴 수 있는 전통적인 사각형 레이어 다이어그램에서 벗어나, 애플리케이션 주위에 필요한 만큼의 포트와 어댑터를 자유롭게 그려 넣을 수 있는 여백을 확보하기 위한 시각적 장치였을 뿐입니다.

실제로 Cockburn이 제시한 사례들을 보면 포트의 개수는 보통 두 개에서 네 개 사이입니다. 예를 들어 기상 경보 시스템은 기상 데이터 피드, 관리자, 알림을 받는 구독자, 구독자 데이터베이스라는 네 개의 포트를 가지며, 커피 머신 제어기는 사용자, 레시피와 가격이 담긴 데이터베이스, 디스펜서, 동전함이라는 네 개의 포트를 가집니다. 포트를 몇 개로 나눌지는 정답이 정해진 문제가 아니라 설계자의 판단과 취향의 영역이며, 극단적으로는 유스케이스마다 포트를 하나씩 둘 수도 있고 반대로 왼쪽 전체와 오른쪽 전체를 각각 하나의 포트로 뭉칠 수도 있습니다.

핵심 개념

애플리케이션 코어

애플리케이션 코어는 이 아키텍처에서 가장 중요하게 보호해야 할 대상입니다. 도메인 모델과 유스케이스 로직이 여기에 위치하며, 이 코드는 특정 웹 프레임워크나 데이터베이스, 메시징 시스템에 대해 아무것도 알지 못합니다. 코어가 알고 있는 것은 오직 자신이 정의한 포트 인터페이스뿐입니다. Herberto Graça는 이를 사용자 인터페이스, 애플리케이션 코어, 인프라스트럭처라는 세 가지 근본 블록으로 정리한 바 있는데, 이 가운데 인프라스트럭처와 사용자 인터페이스는 언제든 교체될 수 있는 세부사항이고, 진정한 의미의 애플리케이션은 코어라는 시각입니다.

포트

포트는 코어와 외부가 대화하기 위해 코어 쪽에서 정의하는 계약입니다. 대부분의 언어에서 포트는 인터페이스 하나 혹은 여러 인터페이스와 DTO(데이터 전송 객체)의 조합으로 표현됩니다. 여기서 반드시 지켜야 할 원칙은, 포트는 어디까지나 코어의 필요에 맞추어 설계되어야 하며 외부 도구의 API를 그대로 흉내 내서는 안 된다는 것입니다. 예를 들어 결제 대행사의 SDK 인터페이스를 그대로 포트로 삼아버리면, 그 결제 대행사를 교체할 때 코어 코드까지 함께 흔들리게 됩니다.

주도하는 포트와 주도받는 포트

Cockburn은 포트를 원론적으로는 모두 대칭적인 것으로 취급하지만, 실제 구현 단계에서는 두 가지 성격으로 나뉜다는 점을 지적합니다. 하나는 주도하는(primary, driving) 포트이고 다른 하나는 주도받는(secondary, driven) 포트입니다. 이 구분은 유스케이스 이론의 주요 행위자와 보조 행위자 개념과 맞닿아 있습니다. 주요 행위자는 애플리케이션을 정지 상태에서 꺼내어 어떤 기능을 수행하도록 촉발하는 쪽이고, 보조 행위자는 반대로 애플리케이션이 답을 얻거나 통지를 남기기 위해 구동하는 쪽입니다. 다시 말해 누가 대화를 시작하고 주도하느냐가 이 둘을 가르는 기준입니다.

실전에서의 인바운드 포트와 아웃바운드 포트

Herberto Graça를 비롯한 여러 실무자들은 primary/secondary라는 원어 대신 인바운드(inbound) 포트와 아웃바운드(outbound) 포트라는 표현을 즐겨 씁니다. 인바운드 포트는 유스케이스 하나를 대표하는 애플리케이션 서비스 인터페이스인 경우가 많고, 아웃바운드 포트는 리포지토리, 외부 API 클라이언트, 메시지 발행자 인터페이스인 경우가 많습니다.

어댑터

어댑터는 특정 기술과 포트 사이를 실제로 이어 주는 코드입니다. GoF의 디자인 패턴 책에 나오는 어댑터 패턴, 즉 한 클래스의 인터페이스를 클라이언트가 기대하는 다른 인터페이스로 변환한다는 개념을 애플리케이션 경계라는 맥락에 적용한 것이 바로 이 어댑터입니다.

주도하는 어댑터

주도하는(driving, primary) 어댑터는 포트를 감싸서(wrap) 코어에게 무엇을 하라고 지시하는 쪽입니다. HTTP 컨트롤러, CLI 커맨드, 메시지 컨슈머, 자동화된 테스트 하네스가 모두 이 범주에 속합니다. 이들은 외부에서 들어온 요청(HTTP 요청의 바디, 커맨드라인 인자, 큐 메시지 등)을 코어가 이해하는 형태로 번역하여 인바운드 포트의 메서드를 호출합니다.

주도받는 어댑터

주도받는(driven, secondary) 어댑터는 반대로 포트를 구현(implement)하여 코어 안쪽에 주입되는 쪽입니다. PostgreSQL 리포지토리, 외부 결제 API 클라이언트, 이메일 발송기, 인메모리 목 구현체가 여기 속합니다. 코어는 이 구현체를 직접 알지 못한 채 오직 포트 인터페이스를 통해서만 이들을 호출합니다. 데이터베이스 벤더를 MySQL에서 PostgreSQL이나 MongoDB로 바꾸고 싶다면, 같은 포트 인터페이스를 구현하는 새로운 어댑터를 만들어 갈아 끼우기만 하면 됩니다.

의존성 규칙과 제어의 역전

이 아키텍처에서 나타나는 흥미로운 특징은, 어댑터는 특정 기술과 특정 포트 둘 다에 의존하지만 코어의 비즈니스 로직은 오직 포트(인터페이스)에만 의존한다는 점입니다. 포트는 코어의 필요에 맞추어 설계되었으므로, 결과적으로 의존성의 방향은 항상 바깥에서 안쪽, 즉 중심을 향하게 됩니다. 이것이 바로 아키텍처 수준에서 구현된 제어의 역전(Inversion of Control)이며, Robert C. Martin이 정리한 의존성 역전 원칙(Dependency Inversion Principle)의 아키텍처적 표현이기도 합니다. 원칙 자체는 고수준 모듈이 저수준 모듈에 의존해서는 안 되며 둘 다 추상화에 의존해야 한다는 것인데, 포트가 바로 그 추상화의 역할을 맡습니다.

실무에서 이 의존성 역전은 대개 의존성 주입(Dependency Injection)으로 실현됩니다. 코어의 클래스는 생성자를 통해 포트 인터페이스를 받아들이고, 실제로 어떤 구현체(실제 데이터베이스 어댑터인지 인메모리 목인지)를 주입할지는 애플리케이션을 조립하는 별도의 지점(컴포지션 루트)에서 결정합니다.

구조와 요청 흐름

전체 구조

아래 다이어그램은 하나의 애플리케이션 코어가 여러 포트를 통해 좌우의 외부 세계와 연결되는 모습을 보여 줍니다. 왼쪽은 코어를 구동하는 주도하는 영역이고, 오른쪽은 코어에 의해 구동되는 주도받는 영역입니다.

주도받는 영역 Driven Side

애플리케이션 코어

주도하는 영역 Driving Side

웹 클라이언트

CLI 도구

자동화 테스트

인바운드 포트

애플리케이션 서비스

도메인 모델

아웃바운드 포트

관계형 데이터베이스

메시지 브로커

외부 결제 API

이 그림에서 화살표가 모두 인바운드 포트와 아웃바운드 포트를 향해, 즉 코어를 향해 모인다는 점에 주목할 필요가 있습니다. 웹 클라이언트든 CLI든 테스트든 결국 동일한 인바운드 포트를 호출하며, 코어는 데이터베이스인지 메시지 브로커인지 외부 API인지 구분하지 않고 동일한 아웃바운드 포트를 통해 요청을 내보냅니다.

요청 처리 흐름

하나의 요청이 실제로 어떻게 흘러가는지를 회원 가입 유스케이스를 예로 들어 시퀀스 다이어그램으로 나타내면 다음과 같습니다.

데이터베이스리포지토리 어댑터(주도받는 어댑터)아웃바운드 포트애플리케이션 서비스인바운드 포트HTTP 컨트롤러(주도하는 어댑터)클라이언트데이터베이스리포지토리 어댑터(주도받는 어댑터)아웃바운드 포트애플리케이션 서비스인바운드 포트HTTP 컨트롤러(주도하는 어댑터)클라이언트POST /users 요청execute(command)실제 구현으로 위임findByEmail(email)인터페이스 구현체 호출SELECT 쿼리조회 결과User 또는 null 반환결과 전달save(user)인터페이스 구현체 호출INSERT 쿼리결과 반환HTTP 201 응답

컨트롤러는 인바운드 포트라는 인터페이스만 알고 있을 뿐 RegisterUserService라는 구체 클래스를 알지 못하며, 애플리케이션 서비스 역시 PostgresUserRepository라는 구체 클래스를 알지 못한 채 UserRepository라는 아웃바운드 포트만 알고 있다는 점이 이 흐름의 핵심입니다.

관련 아키텍처와의 관계

포트와 어댑터 아키텍처가 등장한 이후, 비슷한 문제의식에서 출발한 여러 아키텍처가 뒤이어 제안되었습니다. 이들은 서로 배타적이라기보다는 같은 뿌리에서 조금씩 다른 강조점을 가지고 자라난 형제 관계에 가깝습니다.

어니언 아키텍처

Jeffrey Palermo는 2008년 자신의 블로그에서 어니언 아키텍처(Onion Architecture)를 제안했습니다. 그는 전통적인 상향식 계층형 아키텍처의 가장 큰 문제로 UI와 비즈니스 로직이 데이터 접근 계층에 결합되어 버리는 현상을 지목했습니다. 데이터 접근 기술은 업계에서 역사적으로 최소 삼 년에 한 번꼴로 바뀌어 왔는데, 결합이 강한 시스템은 이런 변화에 대응하지 못한 채 낡은 상태로 방치되다가 결국 다시 작성되는 운명을 맞는다는 것입니다.

어니언 아키텍처의 핵심 규칙은 모든 코드가 자신보다 더 중심에 있는 계층에는 의존할 수 있지만, 자신보다 바깥에 있는 계층에는 결코 의존할 수 없다는 것입니다. 가장 중심에는 도메인 모델이 있고, 그 바로 바깥에는 리포지토리 인터페이스 같은 계약이 있으며, 가장 바깥 테두리에는 UI, 인프라스트럭처, 테스트가 배치됩니다. 데이터베이스는 더 이상 아키텍처의 기반이 아니라 외부에 있는 하나의 세부사항으로 취급됩니다.

Palermo 스스로도 이 아키텍처가 포트와 어댑터 아키텍처와 전제를 공유한다는 점을 밝혔습니다. 인프라스트럭처를 외부화하고 그 위에 어댑터 코드를 얹어 결합을 방지한다는 발상이 그것입니다. 다만 어니언 아키텍처는 여기서 한 걸음 더 나아가, 코어 내부를 도메인 모델과 도메인 서비스 등으로 더 세분화하는 데 관심을 둡니다.

클린 아키텍처

Robert C. Martin은 2012년 자신의 블로그 글 “The Clean Architecture”에서, 그동안 등장한 여러 유사 아키텍처, 즉 Cockburn의 헥사고날 아키텍처, Palermo의 어니언 아키텍처, 자신이 이전에 제안했던 스크리밍 아키텍처(Screaming Architecture), James Coplien과 Trygve Reenskaug의 DCI, Ivar Jacobson의 BCE(Boundary-Control-Entity)를 한데 모아 하나의 실행 가능한 아이디어로 통합하고자 했습니다. 그는 이들이 세부 사항은 다르지만 모두 관심사의 분리라는 동일한 목표를 가지며, 프레임워크로부터의 독립성, 테스트 용이성, UI로부터의 독립성, 데이터베이스로부터의 독립성, 외부 기관으로부터의 독립성이라는 다섯 가지 성질을 만족하는 시스템을 지향한다고 설명했습니다.

클린 아키텍처는 이를 네 개의 동심원으로 시각화합니다. 가장 안쪽에는 엔터프라이즈 전체에 통용되는 업무 규칙을 담은 엔티티(Entities)가 있고, 그 바깥에는 애플리케이션에 특화된 업무 규칙과 유스케이스를 담은 유스케이스(Use Cases) 계층이 있습니다. 그 바깥에는 컨트롤러, 프레젠터, 게이트웨이 등 데이터 형식을 변환하는 인터페이스 어댑터(Interface Adapters) 계층이, 가장 바깥에는 웹 프레임워크나 데이터베이스 같은 프레임워크와 드라이버(Frameworks and Drivers) 계층이 놓입니다.

여기서 가장 유명한 규칙이 바로 의존성 규칙(The Dependency Rule)입니다. 소스 코드 의존성은 오직 안쪽을 향해야 하며, 안쪽 원에 있는 코드는 바깥쪽 원에 있는 그 어떤 이름(클래스, 함수, 변수 등)도 알아서는 안 됩니다. 흐름 제어와 소스 코드 의존성의 방향이 어긋나는 지점, 예를 들어 유스케이스가 프레젠터를 호출해야 하는 경우에는 유스케이스 쪽에 출력 포트 인터페이스를 두고 프레젠터가 그것을 구현하게 함으로써 다형성을 이용해 의존성 방향을 다시 안쪽으로 꺾어 줍니다. 이 기법은 포트와 어댑터 아키텍처에서 아웃바운드 포트가 하는 역할과 본질적으로 동일합니다.

DDD 계층과 Explicit Architecture

Herberto Graça는 자신의 연재 글 “DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together”에서 이 모든 아이디어를 하나의 실무적인 그림으로 종합했습니다. 그는 먼저 포트와 어댑터 아키텍처가 규정하는 세 가지 근본 블록, 즉 사용자 인터페이스를 가능하게 하는 코드, 애플리케이션 코어(비즈니스 로직), 코어를 데이터베이스나 검색엔진, 외부 API 같은 도구에 연결하는 인프라스트럭처 코드를 그대로 받아들입니다.

그다음 어니언 아키텍처가 제안한 방식으로 도메인 주도 설계(DDD)의 계층을 코어 내부에 겹쳐 놓습니다. 가장 바깥쪽에는 애플리케이션 계층이 있어 유스케이스를 대표하는 애플리케이션 서비스와 포트 인터페이스가 위치하고, 그 안쪽에는 도메인 계층이 있어 여러 엔티티에 걸친 로직을 다루는 도메인 서비스가 위치하며, 가장 안쪽에는 오직 자기 자신에게만 의존하는 도메인 모델(엔티티, 값 객체, 도메인 이벤트)이 위치합니다. 이 접근은 실제로 대규모 SaaS 전자상거래 플랫폼과, 매달 수천만 건의 메시지를 처리하는 마켓플레이스 시스템에 적용된 바 있다고 알려져 있습니다.

세 아키텍처 비교

세 아키텍처는 공통적으로 의존성이 중심을 향해야 한다는 규칙을 공유하지만, 강조점과 은유는 조금씩 다릅니다.

구분 포트와 어댑터(Cockburn, 2005) 어니언 아키텍처(Palermo, 2008) 클린 아키텍처(Martin, 2012)
핵심 은유 육각형과 포트, 어댑터 겹겹이 둘러싸인 양파 네 개의 동심원
강조점 코어와 외부 기술의 대칭적 분리 도메인 모델을 중심에 둔 계층 세분화 프레임워크 독립성의 명문화
코어 내부 구조 규정 규정하지 않음 도메인 모델, 도메인 서비스, 애플리케이션 서비스 제시 엔티티와 유스케이스로 이원화
대표 저자 Alistair Cockburn Jeffrey Palermo Robert C. Martin

다음 다이어그램은 클린 아키텍처의 네 개 원을 중첩된 상자로 단순화하여 표현한 것입니다. 실제로는 원이지만, 마크다운 다이어그램 도구의 한계로 인해 중첩 구조로 대신 나타냈습니다.

프레임워크와 드라이버(가장 바깥)

인터페이스 어댑터

유스케이스 애플리케이션 서비스

엔티티 도메인 모델(가장 안쪽)

의존성은 언제나 바깥 원에서 안쪽 원을 향해야 하며, 안쪽 원은 자신을 감싸는 바깥 원에 대해 아무것도 알지 못합니다.

관련 개념과 패턴

Cockburn은 원논문의 마지막 부분에서 포트와 어댑터 아키텍처와 맞닿아 있는 개념들을 정리했습니다. 개인 위키를 확장할 때 함께 살펴보면 좋은 개념들입니다.

  • 어댑터 패턴: GoF의 디자인 패턴 책에 실린 일반적인 어댑터 패턴을 애플리케이션 경계라는 맥락에 적용한 것이 포트와 어댑터입니다.
  • MVC와 그 변형들: Model-View-Controller, Model-View-Presenter 같은 패턴은 주도하는 포트 쪽에서의 포트와 어댑터 개념을 구현한 것으로 볼 수 있습니다.
  • 목 오브젝트와 루프백: 외부 개체를 대신하는 인메모리 대역을 만든다는 아이디어로, 주도받는 어댑터를 테스트용으로 대체할 때 핵심적으로 쓰입니다.
  • 의존성 역전 원칙과 의존성 주입: Robert C. Martin의 의존성 역전 원칙과 Martin Fowler가 정리한 의존성 주입 패턴은 포트와 어댑터를 실제 코드로 구현하는 메커니즘을 제공합니다.
  • 스크리밍 아키텍처, DCI, BCE: Robert C. Martin이 클린 아키텍처로 통합한 다른 계보의 아키텍처들로, 각기 다른 관점에서 관심사의 분리를 추구합니다.

TypeScript로 구현하기

이제 Node.js와 TypeScript로 포트와 어댑터 아키텍처를 실제로 구현하는 예시를 살펴봅니다. 이메일 중복 검증을 포함한 회원 가입 유스케이스를 예로 듭니다.

프로젝트 구조

포트와 어댑터 아키텍처를 폴더 구조로 표현하는 방법에는 여러 스타일이 있지만, 다음과 같이 코어(도메인, 애플리케이션)와 어댑터(인바운드, 아웃바운드)를 명확히 분리하는 방식이 흔히 쓰입니다.

src/
├── domain/
│   └── user.ts
├── application/
│   ├── ports/
│   │   ├── in/
│   │   │   └── register-user.use-case.ts
│   │   └── out/
│   │       ├── user-repository.port.ts
│   │       └── id-generator.port.ts
│   └── services/
│       └── register-user.service.ts
├── adapters/
│   ├── in/
│   │   └── http/
│   │       └── user.controller.ts
│   └── out/
│       ├── persistence/
│       │   ├── in-memory-user.repository.ts
│       │   └── postgres-user.repository.ts
│       └── id/
│           └── uuid-generator.ts
└── composition-root.ts

여기서 domainapplication은 애플리케이션 코어에 해당하고, adapters는 외부 기술과 연결되는 부분입니다. 의존성 규칙에 따라 domainapplication 안의 어떤 파일도 adapters 폴더의 구체 클래스를 직접 import 해서는 안 됩니다.

도메인 계층

도메인 계층은 외부 프레임워크나 라이브러리에 의존하지 않는 순수한 TypeScript 코드로 작성합니다.

// 파일: src/domain/user.ts

export class Email {
  private readonly value: string;

  constructor(rawValue: string) {
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(rawValue)) {
      throw new Error(`유효하지 않은 이메일 형식입니다: ${rawValue}`);
    }
    this.value = rawValue.toLowerCase();
  }

  toString(): string {
    return this.value;
  }

  equals(other: Email): boolean {
    return this.value === other.value;
  }
}

export class User {
  private constructor(
    public readonly id: string,
    public readonly email: Email,
    public readonly name: string,
    public readonly registeredAt: Date,
  ) {}

  static register(id: string, email: Email, name: string): User {
    if (name.trim().length === 0) {
      throw new Error("이름은 비어 있을 수 없습니다.");
    }
    return new User(id, email, name, new Date());
  }

  static reconstitute(id: string, email: Email, name: string, registeredAt: Date): User {
    return new User(id, email, name, registeredAt);
  }
}

register는 새로운 사용자를 생성할 때 쓰는 정적 팩터리이고, reconstitute는 영속성 계층에서 조회한 값으로 도메인 객체를 다시 만들어 낼 때 쓰는 팩터리입니다. 두 경우를 구분해 두면, 나중에 “새로 등록될 때만 검증해야 하는 규칙”과 “이미 저장된 데이터를 복원할 때는 건너뛰어도 되는 규칙”을 명확히 나눌 수 있습니다.

포트 정의

인바운드 포트

인바운드 포트는 유스케이스 하나를 대표하는 인터페이스입니다. 컨트롤러 같은 주도하는 어댑터는 오직 이 인터페이스만 알면 됩니다.

// 파일: src/application/ports/in/register-user.use-case.ts

export interface RegisterUserCommand {
  email: string;
  name: string;
}

export interface RegisterUserResult {
  userId: string;
}

export interface RegisterUserUseCase {
  execute(command: RegisterUserCommand): Promise<RegisterUserResult>;
}

아웃바운드 포트

아웃바운드 포트는 코어가 외부에 무언가를 요청할 때 사용하는 인터페이스입니다. 리포지토리, ID 생성기, 이벤트 발행자 등이 여기에 해당합니다.

// 파일: src/application/ports/out/user-repository.port.ts

import { User } from "../../../domain/user";

export interface UserRepository {
  findByEmail(email: string): Promise<User | null>;
  save(user: User): Promise<void>;
}
// 파일: src/application/ports/out/id-generator.port.ts

export interface IdGenerator {
  next(): string;
}

애플리케이션 서비스

애플리케이션 서비스는 인바운드 포트를 구현하며, 아웃바운드 포트에 의존하여 유스케이스를 실행합니다. 이 클래스는 어떤 웹 프레임워크도, 어떤 데이터베이스 드라이버도 import 하지 않습니다.

// 파일: src/application/services/register-user.service.ts

import { RegisterUserCommand, RegisterUserResult, RegisterUserUseCase } from "../ports/in/register-user.use-case";
import { UserRepository } from "../ports/out/user-repository.port";
import { IdGenerator } from "../ports/out/id-generator.port";
import { Email, User } from "../../domain/user";

export class RegisterUserService implements RegisterUserUseCase {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly idGenerator: IdGenerator,
  ) {}

  async execute(command: RegisterUserCommand): Promise<RegisterUserResult> {
    const email = new Email(command.email);

    const existingUser = await this.userRepository.findByEmail(email.toString());
    if (existingUser !== null) {
      throw new Error("이미 가입된 이메일입니다.");
    }

    const user = User.register(this.idGenerator.next(), email, command.name);
    await this.userRepository.save(user);

    return { userId: user.id };
  }
}

어댑터 구현

주도하는 어댑터: HTTP 컨트롤러

Express를 예로 든 HTTP 컨트롤러입니다. 이 컨트롤러는 RegisterUserService라는 구체 클래스를 전혀 모른 채 RegisterUserUseCase 인터페이스에만 의존합니다.

// 파일: src/adapters/in/http/user.controller.ts

import { Request, Response } from "express";
import { RegisterUserUseCase } from "../../../application/ports/in/register-user.use-case";

export class UserController {
  constructor(private readonly registerUserUseCase: RegisterUserUseCase) {}

  register = async (req: Request, res: Response): Promise<void> => {
    try {
      const result = await this.registerUserUseCase.execute({
        email: req.body.email,
        name: req.body.name,
      });
      res.status(201).json(result);
    } catch (error) {
      res.status(400).json({ message: (error as Error).message });
    }
  };
}

주도받는 어댑터: 영속성

테스트나 로컬 개발에 쓰는 인메모리 구현체와, 운영 환경에서 쓰는 PostgreSQL 구현체를 함께 살펴봅니다. 두 구현체는 동일한 UserRepository 포트를 구현하므로 서로 자유롭게 교체할 수 있습니다.

// 파일: src/adapters/out/persistence/in-memory-user.repository.ts

import { User } from "../../../domain/user";
import { UserRepository } from "../../../application/ports/out/user-repository.port";

export class InMemoryUserRepository implements UserRepository {
  private readonly usersById = new Map<string, User>();

  async findByEmail(email: string): Promise<User | null> {
    for (const user of this.usersById.values()) {
      if (user.email.toString() === email) {
        return user;
      }
    }
    return null;
  }

  async save(user: User): Promise<void> {
    this.usersById.set(user.id, user);
  }
}
// 파일: src/adapters/out/persistence/postgres-user.repository.ts

import { Pool } from "pg";
import { Email, User } from "../../../domain/user";
import { UserRepository } from "../../../application/ports/out/user-repository.port";

export class PostgresUserRepository implements UserRepository {
  constructor(private readonly pool: Pool) {}

  async findByEmail(email: string): Promise<User | null> {
    const result = await this.pool.query(
      "SELECT id, email, name, registered_at FROM users WHERE email = $1",
      [email],
    );

    if (result.rowCount === 0) {
      return null;
    }

    const row = result.rows[0];
    return User.reconstitute(row.id, new Email(row.email), row.name, row.registered_at);
  }

  async save(user: User): Promise<void> {
    await this.pool.query(
      `INSERT INTO users (id, email, name, registered_at)
       VALUES ($1, $2, $3, $4)`,
      [user.id, user.email.toString(), user.name, user.registeredAt],
    );
  }
}

ID 생성기 포트를 구현하는 어댑터도 간단히 만들 수 있습니다.

// 파일: src/adapters/out/id/uuid-generator.ts

import { randomUUID } from "node:crypto";
import { IdGenerator } from "../../../application/ports/out/id-generator.port";

export class UuidGenerator implements IdGenerator {
  next(): string {
    return randomUUID();
  }
}

컴포지션 루트

포트를 실제 어댑터로 채워 넣는 조립 지점을 컴포지션 루트(composition root)라고 부릅니다. 애플리케이션 전체에서 구체 클래스와 인터페이스가 서로 이어지는 유일한 장소로, 여기서만 new PostgresUserRepository(...) 같은 구체 클래스 생성이 등장하고, 다른 모든 곳에서는 인터페이스만 오갑니다.

// 파일: src/composition-root.ts

import express from "express";
import { Pool } from "pg";
import { RegisterUserService } from "./application/services/register-user.service";
import { PostgresUserRepository } from "./adapters/out/persistence/postgres-user.repository";
import { UuidGenerator } from "./adapters/out/id/uuid-generator";
import { UserController } from "./adapters/in/http/user.controller";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

const userRepository = new PostgresUserRepository(pool);
const idGenerator = new UuidGenerator();
const registerUserService = new RegisterUserService(userRepository, idGenerator);
const userController = new UserController(registerUserService);

const app = express();
app.use(express.json());
app.post("/users", userController.register);

app.listen(3000, () => {
  console.log("서버가 3000번 포트에서 시작되었습니다.");
});

운영 환경에서 다른 데이터베이스로 전환하고 싶다면, 이 파일에서 PostgresUserRepository를 새로운 어댑터로 바꾸는 것만으로 충분합니다. RegisterUserServiceUser 도메인 클래스는 단 한 줄도 건드릴 필요가 없습니다.

테스트 전략

테스트 더블의 종류

포트와 어댑터 아키텍처가 테스트에 유리한 이유는, 아웃바운드 포트 자리에 실제 어댑터 대신 테스트 더블(test double)을 자유롭게 끼워 넣을 수 있기 때문입니다. Martin Fowler는 “Mocks Aren’t Stubs”에서 Gerard Meszaros의 분류를 빌려 테스트 더블을 다음과 같이 구분합니다.

종류 설명
더미(Dummy) 매개변수 목록을 채우기 위해서만 전달되며 실제로 쓰이지는 않는 객체
페이크(Fake) 실제로 동작하는 구현을 갖지만 운영에는 적합하지 않은 지름길을 택한 객체(인메모리 데이터베이스 등)
스텁(Stub) 테스트 중 호출에 대해 미리 정해진 답을 돌려주는 객체
스파이(Spy) 스텁이면서 동시에 자신이 어떻게 호출되었는지 기록하는 객체
목(Mock) 자신이 받을 것으로 기대되는 호출의 명세를 미리 프로그래밍해 두고, 그 기대와 다르게 호출되면 검증 단계에서 실패를 보고하는 객체

앞서 만든 InMemoryUserRepository는 이 분류에서 페이크에 해당합니다. 실제로 동작은 하지만 데이터가 프로세스 메모리 안에서만 존재하므로 운영 환경에는 적합하지 않습니다.

유스케이스 단위 테스트

아웃바운드 포트를 페이크로 교체하면, 데이터베이스나 네트워크 없이도 애플리케이션 서비스의 로직을 빠르고 결정적으로 테스트할 수 있습니다.

// 파일: src/application/services/register-user.service.spec.ts

import { RegisterUserService } from "./register-user.service";
import { InMemoryUserRepository } from "../../adapters/out/persistence/in-memory-user.repository";
import { UuidGenerator } from "../../adapters/out/id/uuid-generator";

describe("RegisterUserService", () => {
  it("이메일이 중복되지 않으면 회원 가입에 성공한다", async () => {
    const repository = new InMemoryUserRepository();
    const service = new RegisterUserService(repository, new UuidGenerator());

    const result = await service.execute({ email: "hong@example.com", name: "홍길동" });

    expect(result.userId).toBeDefined();
  });

  it("이미 등록된 이메일이면 예외를 던진다", async () => {
    const repository = new InMemoryUserRepository();
    const service = new RegisterUserService(repository, new UuidGenerator());
    await service.execute({ email: "hong@example.com", name: "홍길동" });

    await expect(
      service.execute({ email: "hong@example.com", name: "다른사람" }),
    ).rejects.toThrow("이미 가입된 이메일입니다.");
  });
});

이 테스트는 Express도, PostgreSQL도 전혀 필요로 하지 않습니다. 이것이 바로 Cockburn이 원논문에서 말한, 애플리케이션을 UI나 데이터베이스 없이도 개발하고 테스트할 수 있다는 목표가 실제로 구현된 모습입니다.

어댑터 계약 테스트

다만 페이크와 실제 어댑터가 정말로 같은 규약을 따르는지는 별도로 검증해야 합니다. 그렇지 않으면 페이크로는 통과하던 테스트가 실제 데이터베이스에서는 다르게 동작하는 상황이 생길 수 있습니다. 이를 위해 동일한 테스트 스위트를 여러 구현체에 대해 반복 실행하는 계약 테스트(contract test)를 작성하는 것이 좋은 방법입니다.

// 파일: src/application/ports/out/user-repository.contract.ts

import { UserRepository } from "./user-repository.port";
import { Email, User } from "../../domain/user";

export function runUserRepositoryContractTests(
  createRepository: () => UserRepository | Promise<UserRepository>,
): void {
  describe("UserRepository 계약", () => {
    it("저장한 사용자를 이메일로 다시 찾을 수 있다", async () => {
      const repository = await createRepository();
      const user = User.register("user-1", new Email("test@example.com"), "테스트");

      await repository.save(user);
      const found = await repository.findByEmail("test@example.com");

      expect(found?.id).toBe("user-1");
    });

    it("존재하지 않는 이메일은 null을 반환한다", async () => {
      const repository = await createRepository();
      const found = await repository.findByEmail("nobody@example.com");
      expect(found).toBeNull();
    });
  });
}

이 계약을 InMemoryUserRepositoryPostgresUserRepository 양쪽에 모두 적용하면, 어댑터를 교체하더라도 코어가 기대하는 동작이 깨지지 않는다는 확신을 가질 수 있습니다.

장점

  • 코어가 특정 프레임워크나 데이터베이스에 결합되지 않으므로, 기술 스택을 교체할 때 코어 로직을 다시 작성하지 않아도 됩니다.
  • UI나 데이터베이스 없이도 비즈니스 로직을 개발하고 자동화된 테스트로 검증할 수 있어, 테스트 실행 속도가 빠르고 결과가 결정적입니다.
  • 같은 코어를 웹 UI, CLI, 배치 작업, 다른 애플리케이션의 호출 등 여러 주도하는 어댑터가 공유할 수 있습니다.
  • 포트 인터페이스가 명세 역할을 하므로, 유스케이스가 기술적 세부사항 없이 짧고 읽기 쉬운 형태로 문서화됩니다.
  • Netflix의 사례처럼, 하위 데이터 소스를 다른 서비스로 교체하는 작업을 단 한 줄의 설정 변경만으로 처리할 수 있어 배포 위험을 크게 낮춥니다.

단점과 비판

과잉 설계의 위험

이 아키텍처에 대한 가장 흔한 비판은 불필요한 복잡성입니다. 콘퍼런스 발표자로도 잘 알려진 Victor Rentea는 클린 아키텍처, 어니언 아키텍처, 헥사고날 아키텍처가 오늘날 백엔드 설계의 표준처럼 통용되고 있지만, 이를 원칙대로만 적용하면 실제 문제의 크기에 맞지 않는 과도하게 복잡하고 방대한 설계가 되어 쓸모없는 개발 노력과 위험을 낳을 수 있다고 지적합니다. 그는 이 아키텍처가 수년에 걸쳐 유지보수되는 시스템, 즉 비즈니스 로직은 비교적 안정적으로 유지되지만 주변 기술은 계속 바뀌는 시스템에 적합하다고 봅니다. 반대로 단순한 CRUD 애플리케이션에 이 구조를 그대로 적용하면 컨트롤러 한 줄이면 끝날 일을 포트, 어댑터, DTO, 매핑 로직으로 겹겹이 감싸는 결과를 낳습니다.

이 밖에도 실무자들은 학습 곡선, 파일 수 증가로 인한 탐색 비용, 그리고 신규 합류자에게 구조를 설명하는 데 드는 비용을 공통적으로 단점을 꼽습니다. 계층이 늘어날수록 하나의 기능 변경이 여러 파일에 걸쳐 파급되므로, 팀의 경험과 도메인에 대한 확신이 충분하지 않다면 오히려 생산성을 떨어뜨릴 수 있습니다.

도메인 경계가 모호할 때

이 아키텍처가 특히 위험해지는 지점은 보호해야 할 도메인이 명확하지 않을 때입니다. 포트와 어댑터의 경계는 결국 도메인 모델의 경계를 반영해야 하는데, 무엇이 핵심 도메인 로직이고 무엇이 단순히 외부 데이터를 조합해 보여주는 로직인지 애매하다면 포트 인터페이스는 외부 도구의 응답 형식을 그대로 베낀 것에 가까워집니다. 이렇게 되면 외부의 변화가 오히려 포트에까지 영향을 미치는 주객전도가 벌어지고, 변경으로부터 도메인을 보호한다는 이 아키텍처의 전제 자체가 무너집니다. 이 현상은 뒤에서 다룰 카카오페이의 사례에서 구체적으로 확인할 수 있습니다.

언제 사용해야 하는가

여러 실무 사례와 비평을 종합하면, 다음과 같은 조건이 갖추어졌을 때 포트와 어댑터 아키텍처의 효용이 커진다고 정리할 수 있습니다.

  • 도메인 모델을 비교적 명확하게 정의할 수 있는 서비스인가. 보호해야 할 핵심 로직의 경계가 뚜렷할수록 포트의 경계도 뚜렷해집니다.
  • 폭넓은(다양한 대상의) 외부 의존성보다 코어 로직 자체가 풍부한 서비스인가. 로직 대부분이 여러 외부 API 응답을 조합하는 데 쓰인다면, 포트와 어댑터에 오히려 로직이 쏠려 버릴 위험이 있습니다.
  • 같은 코어 모듈을 둘 이상의 컴포넌트(API 서버, 배치, 메시지 컨슈머 등)가 재사용하는가. 인바운드 포트가 단 하나뿐이고 재사용하는 곳이 없다면, 모듈을 분리해 얻는 이득이 크지 않습니다.
  • 시스템이 장기간에 걸쳐 유지보수되며, 그 사이 주변 기술은 여러 번 바뀔 것으로 예상되는가.

반대로 프로젝트가 작고 수명이 짧거나, 대부분의 요청이 단순한 CRUD이거나, 팀이 이 패턴에 익숙하지 않아 합의된 방식으로 적용하기 어려운 상황이라면, 처음부터 전체 구조를 도입하기보다 아웃바운드 포트 한두 개 정도의 얇은 추상화로 시작해 필요에 따라 점진적으로 확장하는 편이 현실적입니다.

실제 사용 사례

Netflix

Netflix는 2020년 자사 기술 블로그에서, 스튜디오 제작 관련 애플리케이션을 개발할 때 포트와 어댑터 아키텍처 원칙을 적용한 경험을 공개했습니다. 이들은 입력과 출력을 설계의 가장자리에 두어, 비즈니스 로직이 REST로 노출되는지 GraphQL로 노출되는지에 관계없이 동일하게 유지되도록 만드는 것을 목표로 삼았습니다. 실제로 어느 한 엔티티에 대한 읽기 작업을 기존 모놀리스에서 새로운 GraphQL 기반 마이크로서비스로 옮겨야 하는 상황이 예상보다 일찍 찾아왔는데, 리포지토리 인터페이스를 구현하는 새로운 데이터 소스 어댑터 하나만 추가하는 것으로 두 시간 만에 전환을 마칠 수 있었다고 밝혔습니다. 배포와 활성화를 분리해 설정만으로 어떤 데이터 소스를 쓸지 결정할 수 있었기 때문에, 문제가 생기면 되돌리기도 쉬웠다는 점이 강조되었습니다.

AWS 서버리스 아키텍처

Amazon Web Services는 공식 기술 문서인 AWS Prescriptive Guidance를 통해 서버리스 환경에서 포트와 어댑터 아키텍처를 적용하는 방법을 안내하고 있습니다. 이 가이드는 Amazon API Gateway 같은 클라이언트, AWS Lambda 같은 주도하는 어댑터, Amazon DynamoDB나 Amazon RDS 같은 주도받는 어댑터로 구성된 완전 서버리스 구조를 예로 들며, 처음에는 단순한 구성으로 시작해 점진적으로 확장할 것을 권장합니다. 이는 앞서 언급한 과잉 설계 위험에 대한 실무적인 대응책이기도 합니다. AWS Compute Blog 역시 Lambda 기반 워크로드를 진화시키는 방법을 설명하며, 느슨한 결합과 강한 캡슐화가 이러한 진화를 가능하게 하는 핵심 특성이라고 소개한 바 있습니다.

카카오뱅크: 멀티모듈 헥사고날 아키텍처

카카오뱅크 기술 블로그는 여러 채널로 메시지와 알림을 발송하는 미들웨어 성격의 메시지 허브 시스템에 포트와 어댑터 아키텍처를 적용한 사례를 소개했습니다. API 서버, 데이터 API, 로더, 컨슈머라는 네 개의 서비스가 하나의 헥사고날 코어를 공유해야 하는 상황에서, 이들은 포트와 어댑터 방식으로 코어와 인프라를 분리하되 멀티모듈 구성을 통해 의존성 방향을 빌드 수준에서 강제했습니다. 다만 이 과정에서 모든 서비스가 동일한 데이터베이스에 접근하다 보니 커넥션 풀이 제한된 환경에서 스케일 아웃 전략을 새로 고민해야 했고, 이벤트 브로커의 메시지를 여러 서비스가 동시에 수신하면서 중복 처리 문제를 해결하기 위한 추가 설계가 필요했다는 점도 함께 밝히고 있습니다. 이는 포트와 어댑터 아키텍처가 코드 구조의 문제는 해결해 주지만, 인프라 수준의 확장 전략까지 자동으로 해결해 주지는 않는다는 점을 보여 주는 사례입니다.

카카오페이: 도입과 제거

카카오페이 기술 블로그에 실린 사례는 이 아키텍처를 실제로 도입했다가 다시 제거한, 상대적으로 드문 종류의 정직한 회고입니다. 2023년 개편된 카카오페이 홈 서비스는 카카오톡과 카카오페이 앱이라는 서로 다른 진입점에서 동일한 화면을 서버가 완전히 제어하는 서버 주도 UI(Server Driven UI) 방식으로 제공해야 했고, 이를 위해 스무 개가 넘는 연동 서버로부터 사용자 한 명의 홈 화면을 그리는 데만 쉰 번 가까운 API 호출이 필요한 구조였습니다.

이 팀은 외부 의존성 문제를 두 가지 방법으로 동시에 풀고자 했습니다. 하나는 연동 부서와 표준 API 스펙을 협의하는 프로세스적 해결책이었고, 다른 하나는 다양한 연동 API를 각각의 아웃바운드 포트로 추상화하고 멀티모듈로 의존성 방향을 강제하는 헥사고날 아키텍처 도입이었습니다.

그러나 운영을 이어 가는 과정에서 몇 가지 문제가 누적되었습니다. 표준 API 스펙이라는 프로세스적 해결책이 이미 외부 변화로부터 시스템을 상당 부분 보호하고 있었기 때문에, 아키텍처를 통한 보호라는 두 번째 해결책의 존재 의미가 옅어졌습니다. 또한 이 서비스의 핵심 로직이 사실은 외부 데이터를 조합해 화면 형식에 맞게 보여 주는 데 가까웠기 때문에, 지켜야 할 독립적인 도메인 모델이 무엇인지 자체가 애매해졌고, 그 결과 포트와 어댑터의 경계도 점차 흐려져 외부 클라이언트의 응답 형식이 거꾸로 포트 인터페이스를 결정해 버리는 주객전도가 나타났습니다. 신규 기능을 하나 추가할 때마다 도메인, 포트, 어댑터라는 여러 계층에 걸쳐 파일을 고쳐야 했고, 인바운드 포트가 사실상 HTTP API 하나뿐이어서 코어 모듈을 재사용하는 이점도 크지 않았으며, 팀에 새로 합류하는 사람에게 이 구조를 설명하는 비용도 점점 늘어났습니다.

결국 이 팀은 아키텍처가 주는 구조적 이점보다 운영상의 비효율이 더 크다고 판단하여 헥사고날 아키텍처를 제거하기로 결정했습니다. 제거 이후에는 계층별 패키징 대신 관심사(도메인 단위) 패키징으로, 멀티모듈 대신 단일 모듈로 되돌아갔고, 포트와 DTO로 나뉘어 있던 변환 로직을 통합했습니다. 다만 계층 간에 서로 다른 DTO를 엄격히 분리하는 습관만큼은 시스템 안정성에 크게 기여했다고 판단해 그대로 유지했습니다. 이 팀은 자신들의 경험을 바탕으로, 도메인 모델을 확실히 정의할 수 있고, 폭넓은 외부 의존성보다 핵심 로직이 풍부하며, 코어 모듈을 재사용하는 컴포넌트가 둘 이상 존재하는 서비스에 이 아키텍처가 더 적합하다는 결론을 제시했습니다.

이 두 카카오 계열사의 사례를 나란히 놓고 보면, 같은 아키텍처라도 도메인 경계가 뚜렷하고 코어를 재사용하는 컴포넌트가 여럿인 시스템(카카오뱅크의 메시지 허브)에서는 유효했지만, 도메인 경계가 모호하고 인바운드 포트가 사실상 하나뿐인 시스템(카카오페이 홈)에서는 오히려 비효율을 키웠다는 대조를 확인할 수 있습니다.


참고 자료