세 번의 리모델링 — 당근페이가 아키텍처를 갈아엎은 진짜 이유
아키텍처에 정답은 없다. 있는 건 지금 이 시점에 가장 덜 나쁜 선택뿐이다. 당근페이 백엔드 팀은 4년간 세 번의 대규모 구조 전환을 겪었다. Layered에서 Hexagonal로, 다시 Clean Architecture + Monorepo로. 각 전환은 "이전 구조가 틀렸다"가 아니라 "이전 구조의 한계가 보였다"는 신호에서 시작됐다.
1막: Layered Architecture — 원룸에서 시작하기
모든 백엔드의 출발점이라 해도 과언이 아닌 구조다. Controller → Service → Repository, 위에서 아래로 의존한다. 마치 원룸에 이사 들어가는 것과 같다. 빠르고, 단순하고, 당장 생활할 수 있다.
src/
controller/
PaymentController.kt
TransferController.kt
service/
PaymentService.kt
TransferService.kt
repository/
PaymentRepository.kt
TransferRepository.kt
entity/
Payment.kt
Transfer.kt장점은 선명하다.
- 직관적: 새 팀원이 즉시 코드 위치를 파악할 수 있다
- 빠른 개발: CRUD 위주의 초기 서비스에 딱 맞는다
- 레퍼런스 풍부: Spring 생태계 예제 대부분이 이 구조다
초기 당근페이에는 완벽한 선택이었다. 결제와 송금이라는 핵심 기능을 빠르게 세워야 했기 때문이다.
[💡 잠깐! 이 용어는?] Layered Architecture: 애플리케이션을 표현(Controller), 비즈니스 로직(Service), 데이터 접근(Repository) 등 수평 계층으로 분리하는 구조. 각 계층은 바로 아래 계층에만 의존한다.
원룸의 한계
서비스가 커지면서 문제가 드러났다. PaymentService 하나에 결제 생성, 취소, 환불, 부분 환불, 정산, 알림, 사기 탐지까지 몰려들었다. 수천 줄짜리 클래스가 탄생했다.
class PaymentService(
private val paymentRepository: PaymentRepository,
private val userRepository: UserRepository,
private val notificationClient: NotificationClient,
private val fraudDetectionClient: FraudDetectionClient,
private val ledgerClient: LedgerClient,
// ... 10개 이상의 의존성
) {
// 결제 생성, 취소, 환불, 부분 환불, 정산, 알림, 사기 탐지...
// 수천 줄의 코드가 한 클래스에 집중
}더 근본적인 문제는 의존성 방향이었다. Service가 Repository의 구체 구현에 직접 의존하고, Repository가 JPA Entity에 묶여 있으니 DB를 바꾸려면 Service까지 뜯어고쳐야 했다. 계층을 나눈 의미가 사라진 것이다.
2막: Hexagonal Architecture — 방 사이에 문을 달다
문제의 근본 원인은 비즈니스 로직이 인프라(DB, 외부 API)에 직접 의존한다는 것이었다. Hexagonal Architecture(Ports & Adapters)는 이걸 뒤집는다. 비즈니스 로직을 중심에 놓고, 외부와의 소통은 **포트(Port)**라는 인터페이스를 통해서만 이뤄진다.
원룸에서 여러 방이 있는 집으로 이사한 것과 같다. 방 사이에 문(포트)이 있고, 거실에서 부엌으로 가려면 반드시 문을 통해야 한다.
src/
domain/
Payment.kt
PaymentPolicy.kt
port/
in/
CreatePaymentUseCase.kt
CancelPaymentUseCase.kt
out/
SavePaymentPort.kt
LoadPaymentPort.kt
SendNotificationPort.kt
adapter/
in/
web/
PaymentController.kt
message/
PaymentEventListener.kt
out/
persistence/
PaymentJpaAdapter.kt
PaymentJpaRepository.kt
notification/
FcmNotificationAdapter.kt// Inbound Port — 외부에서 도메인으로 들어오는 요청
interface CreatePaymentUseCase {
fun execute(command: CreatePaymentCommand): Payment
}
// Outbound Port — 도메인에서 외부로 나가는 요청
interface SavePaymentPort {
fun save(payment: Payment): Payment
}
interface SendNotificationPort {
fun send(userId: String, message: String)
}class PaymentService(
private val savePaymentPort: SavePaymentPort,
private val sendNotificationPort: SendNotificationPort,
) : CreatePaymentUseCase {
override fun execute(command: CreatePaymentCommand): Payment {
val payment = Payment.create(
amount = command.amount,
userId = command.userId
)
val saved = savePaymentPort.save(payment)
sendNotificationPort.send(command.userId, "결제가 완료되었습니다")
return saved
}
}[💡 잠깐! 이 용어는?] 의존성 역전 원칙(DIP): 상위 모듈이 하위 모듈의 구체 구현에 의존하지 않고, 둘 다 추상화(인터페이스)에 의존해야 한다는 원칙. Hexagonal Architecture의 Port가 이 역할을 수행한다.
이제 DB를 PostgreSQL에서 MongoDB로 바꿔도 SavePaymentPort의 구현체(어댑터)만 새로 작성하면 된다. 도메인 코드는 한 줄도 건드릴 필요가 없다.
새 집의 새로운 고민
해결한 건 많았지만, 다른 종류의 문제가 떠올랐다.
어댑터 폭발: 결제 시스템은 PG사, 은행 API, 알림 서비스, 원장, 사기 탐지 등 외부 연동이 많다. 각 연동마다 포트와 어댑터가 쌍으로 증가했다. 파일 수가 감당하기 어려울 만큼 불어났다.
포트 관리의 딜레마: "이 기능에 맞는 포트가 이미 있는가, 새로 만들어야 하는가?" 판단이 반복됐다. 유사한 포트가 난립하거나, 하나의 포트가 너무 많은 메서드를 품는 양극단이 발생했다.
도메인 간 경계 모호: 결제, 송금, 정산이 하나의 프로젝트 안에 있으니, PaymentService가 TransferRepository의 포트를 직접 호출하는 일이 생겼다. 방을 나눴지만 벽이 종이벽인 셈이었다.
3막: Clean Architecture + Monorepo — 아파트 단지로의 확장
최종 구조는 각 도메인을 독립 모듈로 분리하고, Clean Architecture를 모듈 내부에 적용하는 방식이다. 한 건물 안에서 방을 나누던 것에서, 아파트 단지를 짓고 각 동이 독립적으로 운영되는 구조로 전환한 것이다. 각 동은 자체 완결적이지만, 공용 시설(공통 모듈)도 함께 이용한다.
modules/
payment/
domain/
Payment.kt
PaymentPolicy.kt
usecase/
CreatePaymentUseCase.kt
CancelPaymentUseCase.kt
adapter/
in/
PaymentController.kt
out/
PaymentPersistenceAdapter.kt
transfer/
domain/
Transfer.kt
usecase/
CreateTransferUseCase.kt
adapter/
in/
TransferController.kt
out/
TransferPersistenceAdapter.kt
settlement/
domain/
Settlement.kt
usecase/
CreateSettlementUseCase.kt
adapter/
...
shared/
event/
DomainEvent.kt
util/
MoneyUtils.kt핵심 변화 세 가지가 있다.
1. 도메인별 물리적 분리: 결제, 송금, 정산이 각각 독립 모듈이 되었다. Gradle의 모듈 의존성으로 경계를 컴파일 타임에 강제한다. 종이벽이 아니라 철근 콘크리트 벽이다.
// payment 모듈의 build.gradle.kts
dependencies {
implementation(project(":modules:shared"))
// transfer 모듈에 직접 의존하지 않는다!
// 도메인 간 통신은 이벤트를 통해서만
}2. 안쪽으로만 향하는 의존성: 각 모듈 내부에서 domain은 아무것에도 의존하지 않고, usecase는 domain에만 의존하고, adapter는 usecase에 의존한다. 화살표가 항상 중심을 향한다.
3. 이벤트 기반 도메인 간 통신: 모듈 사이 직접 호출은 금지다. 도메인 이벤트를 발행하고, 관심 있는 모듈이 구독하는 방식으로 소통한다.
// payment 모듈
class CreatePaymentUseCase(
private val savePaymentPort: SavePaymentPort,
private val eventPublisher: DomainEventPublisher,
) {
fun execute(command: CreatePaymentCommand): Payment {
val payment = Payment.create(command.amount, command.userId)
val saved = savePaymentPort.save(payment)
eventPublisher.publish(PaymentCompletedEvent(saved.id, saved.amount))
return saved
}
}
// settlement 모듈 — payment에 직접 의존하지 않는다
class PaymentCompletedEventHandler(
private val createSettlementUseCase: CreateSettlementUseCase,
) {
fun handle(event: PaymentCompletedEvent) {
createSettlementUseCase.execute(
CreateSettlementCommand(event.paymentId, event.amount)
)
}
}[💡 잠깐! 이 용어는?] 어댑터 폭발(Adapter Explosion): Hexagonal Architecture에서 외부 시스템 연동이 늘어날 때, 포트-어댑터 쌍이 과도하게 증가하는 현상. 각 외부 시스템마다 인터페이스와 구현체가 필요하므로 파일 수가 급격히 불어난다.
무엇이 전환을 촉발했나
| 전환 | 촉발 신호 | 핵심 판단 기준 |
|---|---|---|
| Layered → Hexagonal | Service 클래스 비대화, DB 교체 불가능 | 인프라 의존성 분리 필요 |
| Hexagonal → Clean + Monorepo | 도메인 간 경계 모호, 어댑터 폭발 | 모듈 경계의 컴파일 타임 강제 필요 |
중요한 건 이전 아키텍처가 "잘못된" 것이 아니었다는 점이다. 그 시점에 맞았던 것이다. 세 명이 사는 집에 아파트 단지를 지을 필요는 없다. 초기에 Clean Architecture + Monorepo를 도입했다면 오버엔지니어링이었을 것이다.
아키텍처 전환을 고려해야 할 신호들:
- 하나의 변경이 예상치 못한 곳에 영향을 미친다 (경계 부재)
- 새 기능 추가 시 기존 코드를 광범위하게 수정해야 한다 (높은 결합도)
- 특정 클래스나 모듈이 끝없이 비대해진다 (낮은 응집도)
- "이 코드가 여기 있어도 되나?"라는 의문이 반복된다 (구조적 모호함)
- 팀 규모가 커져서 동시 작업 시 충돌이 잦다 (물리적 분리 필요)
마무리
당근페이의 여정에서 배울 수 있는 핵심은 **"아키텍처는 진화한다"**는 것이다. 처음부터 완벽한 설계를 목표로 하면 대부분 실패한다. 현재 구조의 한계를 정확히 진단하고, 그 한계를 해소하는 방향으로 점진적으로 옮겨가는 것이 현실적인 경로다. Layered에서 시작해도 괜찮다. 중요한 건 전환이 필요할 때 전환할 수 있는 체력 — 충분한 테스트 커버리지, 명확한 인터페이스, 팀 전체의 아키텍처 이해도 — 을 평소에 쌓아두는 것이다.
관심 있을 만한 포스트
중앙 교환기를 세워라 — 당근이 AI 난립을 하나의 플랫폼으로 정돈한 전략
'AI 활용에 가장 앞선 당근' 비전 아래 여러 제품 팀이 AI를 더 잘 활용할 수 있도록 구축한 GenAI 플랫폼.
Action-Reducer-State의 귀환 — 프론트엔드 패턴이 서버를 점령한 이유
프론트엔드에서 익숙한 Redux의 Action-Reducer-State 패턴을 서버 사이드에 적용한 당근마켓의 이벤트 소싱 라이브러리 Ventyd를 분석한다.
AI 코딩의 맹점 — Artifacts 없이 에이전트는 기억을 잃는다
PRD, ADR, TDD가 AI 코딩 워크플로우에서 왜 선택이 아닌 필수인지, 실전 구조와 함께 살펴본다.
Next-Translate 3.0 — Turbopack과 App Router를 위한 i18n 재건
1년간 공백 후 돌아온 Next-Translate 3.0이 Turbopack 지원, 비동기 params, App Router 안정화를 한 번에 처리하는 방법.
V8 WasmGC 투기적 최적화 — 가상 메서드를 인라인으로 만드는 법
V8이 WasmGC의 가상 메서드 디스패치에 투기적 인라이닝을 도입해 Dart와 Java 앱에서 최대 8% 성능을 끌어낸 방법.
Vinext — Vite 위에서 Next.js를 1주일 만에 다시 만든 이야기
Cloudflare가 AI와 함께 단 일주일, $1,100의 API 비용으로 Next.js 호환 프레임워크를 Vite 위에 구축한 과정.
Tsonic — TypeScript를 네이티브 바이너리로 컴파일하는 실험
TypeScript → C# → NativeAOT 파이프라인으로 네이티브 실행 파일을 만드는 Tsonic. 어떻게 동작하고, 어떤 한계가 있는지 살펴봤다.
VS Code 팀의 AI 에이전트 병렬화 — 월간 릴리스를 주간으로 만든 워크플로우
VS Code 팀이 월간 릴리스에서 주간 릴리스로 전환한 비결. 에이전트 세션 병렬화, 자동화 파이프라인, 품질 게이트 설계 전반을 공개했다.