한 번쯤 이런 경험을 해보셨을 겁니다. 오후 한 나절이면 끝날 것 같은 기능 하나가 사흘씩 걸리는 상황 — 이메일 발송 방식을 "간단히" 바꿨더니 열두 개 파일에 손을 대야 한다는 걸 뒤늦게 깨달았을 때 말이죠. 결제 코드가 데이터베이스를 알고, 데이터베이스 코드가 웹 요청을 알고, 그 사이 어딘가에 — 거의 보이지도 않을 만큼 묻혀서 — 여러분의 제품을 여러분의 제품답게 만드는 실제 비즈니스 규칙이 숨어 있습니다.
Ports & Adapters (Alistair Cockburn이 2005년에 만든 이름인 Hexagonal Architecture라고도 불립니다)는 이런 상황이 반복되지 않도록 코드를 배치하는 방법입니다. 프레임워크가 아니라서 설치할 필요도 없고, 어떤 언어에서든 사용할 수 있습니다. 사실 근사한 이름을 달고 있는 하나의 아이디어에 불과합니다. 그림과 실제 예제를 곁들여 천천히 풀어 드릴 테니, 마지막에는 언제 써야 하는지 — 그리고 언제 쓰지 말아야 하는지 — 정확히 알게 되실 겁니다.
문제: 잘못된 방향으로 자라나는 코드
대부분의 앱은 똑같이 친근하게 시작됩니다 — 컨트롤러가 서비스를 호출하고, 서비스가 데이터베이스와 몇 개의 외부 서비스(vendor)와 이야기하면 모두가 행복합니다. 문제는 시간이 지나면서 생깁니다. 다음은 겨우 몇 달 된 checkout 코드인데, 이미 두통을 유발하고 있습니다.
// checkout.ts — 모든 것이 한데 엉켜 있음
class OrderService {
async checkout(cart: Cart) {
const db = new PgClient(process.env.DB_URL) // 특정 데이터베이스
const stripe = new Stripe(process.env.STRIPE_KEY) // 특정 vendor
const sg = new SendGrid(process.env.SENDGRID_KEY) // 또 다른 vendor
// 실제 비즈니스 규칙은 vendor 호출 사이에 묻혀 있음…
const total = cart.items.reduce((s, i) => s + i.price, 0)
await stripe.charges.create({ amount: total, source: cart.token })
await db.query('INSERT INTO orders …')
await sg.send({ to: cart.email, template: 'order-ok' })
}
}
각 줄이 딱히 "틀린" 건 아닙니다. 하지만 비즈니스 로직(주문이 무엇인지, 언제 유효한지, "확인됨"이 무슨 뜻인지)이 세 개의 vendor와 데이터베이스 드라이버에 뒤엉켜 있습니다. 실제 데이터베이스 없이는 규칙을 테스트할 수 없고, SendGrid를 바꾸려면 수술이 필요하며, 새로 합류한 팀원은 주문보다 Stripe를 먼저 배우게 됩니다.
Ports & Adapters는 하나의 질문에 답합니다: 돈을 버는 부분(비즈니스 규칙)을 언젠가 반드시 교체하게 될 부분(데이터베이스, vendor, 프레임워크, UI)으로부터 어떻게 분리할 것인가?
핵심 아이디어, 그림 하나로
비즈니스 규칙 — 도메인(domain) — 을 중앙에 놓으세요. 그리고 이 도메인이 어떤 프레임워크, 데이터베이스 드라이버, vendor SDK도 import하지 못하도록 금지하세요. 그런 다음 외부 세계는 오직 port(인터페이스)를 통해서만 도메인과 소통하게 하고, adapter가 반대편에서 지저분한 실세계 작업을 처리하도록 합니다.
전체 구조는 이것이 전부입니다. 원 대신 육각형을 그리는 이유는 귀여운 데 있습니다. 여섯 변이 "앱에는 출입구가 많다(웹 UI, API, 테스트, 크론 잡, 데이터베이스, 큐…)"는 사실을 상기시켜 주기 때문입니다. 단순히 "위"와 "아래"만 있는 게 아닙니다. 숫자 6 자체에는 별 의미가 없으니 port 수를 세지는 마세요.
용어 정리, 쉬운 말로
세 단어가 모든 것을 담당합니다. 전문 용어 없이 설명하겠습니다.
- Domain (핵심): 비즈니스 규칙과 개념 —
Order,Money, "아이템이 없는 장바구니는 결제할 수 없다" 같은 것들. 순수한 로직입니다. 여러분의 기술 스택이 아닌, 비즈니스를 설명하는 것처럼 읽혀야 합니다. - Port: 도메인이 소유하는 인터페이스로, 도메인이 필요로 하거나 제공하는 것을 설명합니다 — "
save(order)를 할 수 있어야 한다"거나 "결제를charge()할 수 있어야 한다"처럼요. Port는 도메인의 언어로 작성된 약속이며, vendor 이름은 일절 등장하지 않습니다. - Adapter: 실제 기술을 사용해 port를 구현하는 구체적인 코드 —
PostgresOrderRepo나StripeGateway같은 것들. Adapter는 Stripe, Postgres, React가 존재하도록 허용되는 곳입니다.
그리고 port에는 두 가지 종류가 있습니다. 이것이 유일하게 배울 가치가 있는 미묘한 차이입니다.
- Driving(주도적, primary) adapter는 왼쪽에 위치하며 앱 안으로 호출합니다: 웹 컨트롤러, CLI 명령어, 테스트 같은 것들. 이들은 input port(유스케이스 인터페이스)를 사용합니다.
- Driven(수동적, secondary) adapter는 오른쪽에 위치하며 앱이 호출합니다: 데이터베이스, 이메일 서비스, 결제 API 같은 것들. 이들은 도메인이 정의한 output port를 구현합니다.
무언가가 앱과의 대화를 시작하면 그것은 driving입니다. 앱이 먼저 대화를 시작하면 상대방이 driven입니다. "구매" 버튼을 클릭하는 사용자는 driving이고, 결제 vendor는 driven입니다.
구체적인 예제: 변환 전과 후
그 checkout 코드를 풀어 봅시다. 먼저 도메인이 필요로 하는 것을 — port를 — 비즈니스 언어만 사용해 적어봅니다.
// ports.ts — 도메인의 어휘. vendor 이름은 절대 금지.
interface PaymentGateway { charge(amount: Money, token: string): Promise<Receipt> }
interface OrderRepository { save(order: Order): Promise<void> }
interface Notifier { orderConfirmed(order: Order): Promise<void> }
이제 유스케이스는 오직 그 약속들에만 의존하며, 그 외에는 아무것도 없습니다. 소리 내어 읽어보세요. vendor 목록이 아니라 결제 과정을 설명하는 것처럼 들릴 겁니다.
// checkout-service.ts — port에만 의존하며 vendor에는 절대 의존하지 않음
class CheckoutService {
constructor(
private payments: PaymentGateway,
private orders: OrderRepository,
private notify: Notifier,
) {}
async checkout(cart: Cart): Promise<Order> {
const order = Order.fromCart(cart) // 순수한 비즈니스 규칙
await this.payments.charge(order.total, cart.token)
await this.orders.save(order)
await this.notify.orderConfirmed(order)
return order
}
}
마지막으로 실제 vendor들은 port를 구현하는 adapter 안에 삽니다. 하나를 교체하는 것은 국소적인 변경으로 끝나고 — 테스트에서는 fake로 교체해 예전에 네트워크 호출 한 번 걸리던 시간에 수천 개의 케이스를 실행할 수 있습니다.
// adapters/*.ts — vendor는 여기에, 구현하는 port 뒤에 숨어서
class StripeGateway implements PaymentGateway { /* Stripe SDK */ }
class PostgresOrderRepo implements OrderRepository { /* SQL은 여기에 */ }
class SendGridNotifier implements Notifier { /* SendGrid */ }
// 테스트에서는 실제 vendor를 인메모리 fake로 교체 — 네트워크 없이, 밀리초 단위로:
const service = new CheckoutService(new FakePayments(), new InMemoryOrders(), new NullNotifier())
무엇이 이동했는지 주목하세요. Stripe, Postgres, SendGrid는 이제 가장자리의 세부 사항입니다. 돈을 버는 부분이 중앙에 자리 잡아 읽기 쉽고 테스트하기 쉬우며, 그 vendor들이 존재한다는 사실을 전혀 모릅니다.
모든 것을 하나로 묶는 단 하나의 규칙
아무것도 기억하지 못하더라도 이것만은 기억하세요: 소스 코드의 의존성은 항상 안쪽을 향합니다. 바깥 레이어(adapter, 프레임워크, I/O)는 안쪽 레이어에 대해 알 수 있습니다. 중앙의 도메인은 외부에 대해 아무것도 알 수 없습니다.
이것이 바로 여러분이 전체 웹 프레임워크를 교체하거나 MySQL에서 DynamoDB로 마이그레이션하더라도 도메인이 눈치채지 못하는 이유입니다. 의존성의 화살표는 절대 핵심 밖을 향하지 않으므로, 가장자리에서의 변경이 안쪽으로 파급될 수 없습니다. (의존성 역전 원칙(Dependency Inversion Principle) — SOLID의 "D" — 를 들어보셨다면, 이것이 바로 그 원칙의 실제 모습입니다. 도메인이 인터페이스를 정의하고, adapter가 그에 맞게 구현합니다.)
Driving과 Driven, 실무에서 이해하기
왜 주도적/수동적 구분이 일상적으로 중요할까요? 누가 인터페이스를 소유하는지를 알려주기 때문입니다.
- Driven port(데이터베이스, 이메일)의 경우, 도메인이 인터페이스를 소유하고 adapter가 맞춰 구현합니다. 이것이 vendor를 자유롭게 교체할 수 있는 이유입니다.
- Driving port(유스케이스)의 경우, 애플리케이션이 인터페이스를 소유하고 외부 세계(HTTP, CLI, 테스트 하네스)가 이를 호출합니다. 이것이 같은 로직으로 오늘은 REST API를, 내일은 gRPC 엔드포인트나 예약 작업을 규칙 변경 없이 제공할 수 있는 이유입니다.
유용한 테스트: 완전히 새로운 전달 메커니즘(예: Slack 봇)은 오직 새로운 driving adapter여야 합니다. 완전히 새로운 vendor(예: Stripe에서 Adyen으로 전환)는 오직 새로운 driven adapter여야 합니다. 둘 중 하나가 도메인을 열어야 한다면, 무언가가 새어 나간 것입니다.
회사 규모별로 어떻게 보이는가
많은 조언이 잘못되는 지점이 바로 여기입니다 — 아키텍처를 "모두에게 맞는 한 가지 크기"로 취급하는 것이지요. Ports & Adapters의 적절한 양은 회사가 어느 단계에 있는지에 크게 달려 있습니다. 솔직한 버전을 알려드리겠습니다.
| 회사 단계 | 해결하는 고통 | 얼마나 적용할까 |
|---|---|---|
| 솔로 / 초기 스타트업 | 급하게 SQLite와 결제 제공업체를 선택했는데 이제 갇혀버릴까 봐 두렵습니다. | 최대 한두 개의 port — 보통 데이터베이스와 결제. 나머지는 직접 호출로 두세요. 여기서는 속도가 순수함보다 중요합니다. |
| 소규모 / 성장 중 (≈ Series A) | 테스트가 실제 데이터베이스와 vendor 샌드박스를 건드려 느리고, 불안정하며, 개발자들이 실행을 포기합니다. | 모든 I/O 주위에 port를 씌우세요. Fake 덕분에 유닛 테스트가 밀리초 안에 실행되고 신규 입사자가 하루 만에 도메인을 이해합니다. |
| 중간 규모 / 스케일업 | 여러 팀이 서로 밟고 있습니다. 비용 절감을 위해 "SendGrid 이탈"을 추진했더니 한 달짜리 재작업이 되었습니다. | Port가 팀 간의 계약이 됩니다. Vendor 교체는 새 adapter 하나로 끝나고, 마이그레이션이 아닙니다. |
| 대기업 / 엔터프라이즈 | 레거시 시스템, 지역별 vendor, 감사, 그리고 "단일 vendor 종속 금지"를 요구하는 조달 규정이 있습니다. | port당 여러 adapter(구형 + 신형), strangler-fig 마이그레이션, 그리고 vendor 독립성이 문서화된 요구 사항. |
실제 사례 세 가지
소규모 이커머스 쇼핑몰 (직원 10명). "만일을 대비해" 단 하나의 메서드를 가진 Notifier port로 이메일을 감쌌습니다. 2년 후 이메일 비용이 급증했고, SendGrid에서 Amazon SES로 이전하는 것은 새 adapter 하나와 한 줄의 연결 변경으로 끝났습니다 — 오후 반나절 만에 배포 완료. 주문 로직 — 핵심 자산 — 은 손도 대지 않았습니다.
스케일업 단계의 핀테크 (엔지니어 약 120명). 결제가 한 나라에서는 국내 제공업체를, 다른 나라에서는 국제 제공업체를 거쳐야 했고, 거래별로 런타임에 결정이 이루어졌습니다. PaymentGateway가 port였기 때문에 "어떤 제공업체를 쓸까"는 인터페이스 뒤의 라우팅 결정이 되었습니다. 컴플라이언스 감사자들도 좋아했습니다. 경계가 명확하고 테스트 가능한 이음새였으니까요.
대형 은행 (엔지니어 1,000명 이상). 20년 된 메인프레임을 하룻밤에 교체할 수는 없었습니다. AccountLedger port를 정의하고 adapter를 두 개 작성했습니다 — 하나는 구형 메인프레임과, 하나는 새 코어뱅킹 시스템과 통신하도록 — 그런 다음 트래픽을 점진적으로 옮겼습니다(일명 "strangler fig"). 마이그레이션이 그 아래에서 보이지 않게 진행되는 동안 팀들은 port를 기준으로 새 기능을 개발했습니다.
Ports & Adapters vs 전통적인 계층형 아키텍처
대부분의 팀은 전통적인 계층형 아키텍처(UI 위, 서비스 중간, 데이터베이스 아래)에서 출발합니다. 두 가지를 비교해 보겠습니다.
| 질문 | 전통 계층형 (UI → service → DB) | Ports & Adapters |
|---|---|---|
| 누가 누구에 의존하는가? | 하향식: 각 레이어가 아래 레이어에 의존하며, 데이터베이스에서 끝납니다. | 모든 것이 안쪽으로 의존하며 도메인에서 끝납니다. 데이터베이스는 하나의 플러그인일 뿐입니다. |
| DB 없이 규칙을 테스트할 수 있는가? | 보통 안 됩니다 — 서비스가 DB에 직접 접근합니다. | 가능합니다 — fake repository를 주입하면 DB도 네트워크도 필요 없습니다. |
| Vendor를 교체한다면? | 서비스 레이어를 건드리고 바깥으로 파급됩니다. | 새 adapter 하나를 작성하면 됩니다. 도메인은 변경되지 않습니다. |
| 비즈니스 규칙은 어디에 있는가? | 서비스와 DB 전반에 흩어져 있는 경우가 많습니다. | 도메인에 집중되어 있으며 비즈니스 언어로 작성됩니다. |
| 비용 | 형식이 적지만, 시간이 지나면서 스택에 묶입니다. | 처음에 인터페이스를 몇 개 더 만들어야 하지만, 변경 속도가 빨라질수록 보상받습니다. |
핵심적인 차이는 화살표의 방향입니다. 계층형 코드에서는 결국 모든 것이 데이터베이스에 의존합니다. Ports & Adapters에서는 데이터베이스가 여러분에게 의존합니다. 그 역전이 전부입니다.
언제 사용해야 하는가 (그리고 언제 하지 말아야 하는가)
좋은 아키텍처는 노력과 위험 부담을 맞추는 것입니다. 다음의 경우에 Ports & Adapters를 선택하세요.
- 앱에 보호할 가치가 있는 실제 비즈니스 규칙이 있는 경우 (단순한 테이블 CRUD가 아닌).
- 수명 주기 동안 통합 대상 — vendor, 데이터베이스, 채널 — 을 교체하거나 추가할 것으로 예상하는 경우.
- 테스트 속도와 신뢰성이 중요하며, 빠르고 결정론적인 유닛 테스트를 원하는 경우.
- 여러 팀이 있거나 수명이 길어 명확한 경계가 스스로 비용을 회수하는 경우.
다음의 경우에는 회의적이거나 — 또는 아주 가볍게만 — 적용하세요.
- 얇은 CRUD 앱이거나 버릴 프로토타입인 경우. 간접 참조는 순전한 오버헤드입니다.
- "도메인"이 기본적으로 데이터베이스 스키마와 동일한 경우. 보호할 것이 없습니다.
- 빠르게 출시해야 하는 솔로 개발자이고, 나중에 재작성하는 비용이 지금 형식을 갖추는 비용보다 낮은 경우.
가치 없는 CRUD 엔드포인트를 세 개의 인터페이스로 감싼다고 해서 "깔끔해"지는 것이 아닙니다 — 그냥 읽기 어려워질 뿐입니다. 아키텍처는 변경 가능성을 사기 위해 치르는 비용입니다. 변경할 것이 없다면, 아무것도 아닌 것에 돈을 내고 있는 겁니다.
흔한 실수 (그리고 쉬운 해결법)
- 새는 port.
PaymentGateway가Stripe.Charge객체를 반환한다면, vendor가 port를 통해 새어 나온 겁니다. 해결책: port는 오직 자신만의 타입(Receipt,Money)으로만 이야기해야 합니다. - 빈혈 도메인(Anemic domain). 실제 로직이 모두 adapter에 있고 도메인은 데이터 봉투에 불과하다면, 경계를 잘못된 곳에 그린 겁니다. 해결책: 규칙을 핵심으로 밀어 넣으세요.
- 거대한 하나의 port. 메서드가 40개인
IRepository는 경계가 아니라 잡동사니 서랍입니다. 해결책: 목적에 맞게 이름 붙인 작은 port들(OrderRepository,InventoryRepository)을 사용하세요. - DTO와 도메인 모델 혼동. HTTP로 전송하는 형태가 도메인 엔티티는 아닙니다. 해결책: adapter가 와이어 포맷과 도메인 사이를 번역하도록 하세요.
- 모든 것에 인터페이스. 모든 클래스에 port가 필요한 건 아닙니다 — 경계를 넘는 것들(I/O, vendor, 전달 메커니즘)만 필요합니다. 해결책: 고통이 느껴질 때 port를 추가하고, 선제적으로 추가하지 마세요.
월요일에 시작하는 방법
아무것도 다시 작성하지 않아도 됩니다. 가장 고통스러운 곳에 이음새 하나를 만들고 거기서 멈추면 됩니다.
- 1. 가장 고통스러운 의존성을 선택하세요. 보통 데이터베이스이거나 불안정한 vendor입니다.
- 2. 원하는 인터페이스를 작성하세요. 비즈니스 언어로:
save(order),execSql(...)이 아니라. - 3. vendor 코드를 adapter 뒤로 이동시키세요 — 그것을 구현하는 adapter 뒤로. 다른 것은 아직 바꾸지 않아도 됩니다.
- 4. adapter를 주입하세요 — 인라인으로 생성하는 대신 — 그리고 테스트용 fake를 하나 작성하세요.
- 5. 편안함을 느끼고 반복하세요 — 단, 변경 빈도나 테스트의 고통이 또 다른 port를 정당화하는 곳에서만요.
일주일 안에 가장 까다로운 통합 주위에 빠른 테스트가 생기고, 도메인이 비즈니스처럼 읽히기 시작할 것입니다. 그것이 전부이며, 점진적으로 얻을 수 있습니다.
핵심 요약
- 하나의 아이디어: 비즈니스 규칙은 중앙에, 기술은 가장자리에, 오직 인터페이스를 통해서만 소통합니다.
- Port = 도메인이 소유하는 인터페이스. Adapter = 실제 구현체 (Stripe, Postgres, React).
- 의존성은 안쪽을 향합니다. 도메인은 외부 세계에 대해 아무것도 모릅니다.
- Driving adapter는 앱을 호출하고, driven adapter는 앱에 의해 호출됩니다.
- 위험 부담에 맞게 적용하세요: 스타트업에는 몇 개의 port, 엔터프라이즈에는 vendor 독립적인 경계, 버릴 CRUD 앱에는 거의 없어도 됩니다.
- 점진적으로 도입하세요 — 가장 고통스러운 이음새 하나씩. 대규모 재작성은 필요 없습니다.
이 시리즈의 다음 글에서는 이 기반을 발판 삼아 Ports & Adapters가 Clean Architecture, Onion Architecture와 어떤 관계인지 살펴보겠습니다 — 같은 북극성을 가리키는 세 가지 이름이지만 실무에서 진짜로 다른 점은 무엇인지도 함께요.