세 번의 리모델링 — 당근페이가 아키텍처를 갈아엎은 진짜 이유
당근페이 백엔드가 Layered에서 Hexagonal을 거쳐 Clean Architecture + Monorepo로 진화한 과정과 각 단계의 트레이드오프를 다룬다.
아키텍처에 정답은 없다. 있는 건 지금 이 시점에 가장 덜 나쁜 선택뿐이다. 당근페이 백엔드 팀은 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에서 시작해도 괜찮다. 중요한 건 전환이 필요할 때 전환할 수 있는 체력 — 충분한 테스트 커버리지, 명확한 인터페이스, 팀 전체의 아키텍처 이해도 — 을 평소에 쌓아두는 것이다.
같은 카테고리 · Architecture
비슷한 주제의 최신 글
태그가 겹치는 글
공통 태그가 많을수록 위에 보인다