아키텍처는 고정된 정답이 아니다. 서비스의 규모, 팀의 크기, 비즈니스 복잡도에 따라 적합한 아키텍처가 달라진다. 당근페이 백엔드 팀의 4년간 아키텍처 진화 여정은 이 사실을 생생하게 보여준다. Layered Architecture에서 시작해 Hexagonal을 거쳐 Clean Architecture + Monorepo에 이르기까지, 각 전환의 이유와 트레이드오프를 살펴본다.
1단계: Layered Architecture — 빠른 시작
모든 백엔드의 출발점이라 해도 과언이 아닌 Layered Architecture다. Controller, Service, Repository의 3계층으로 나누고, 위에서 아래로 의존한다.
Layered Architecture 프로젝트 구조
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는 이 시점에 완벽한 선택이었다. 마치 새 집에 입주할 때 일단 큰 방에 짐을 다 풀어놓는 것과 같다. 빠르고, 쉽고, 당장 생활할 수 있다.
문제가 드러나다
서비스가 성장하면서 문제가 나타났다.
비즈니스 로직의 서비스 레이어 집중: 모든 핵심 로직이 PaymentService에 들어가면서, 이 클래스가 수천 줄로 비대해졌다. 하나의 변경이 예상치 못한 다른 기능에 영향을 줬다.
비대해진 Service 레이어 (문제)
class PaymentService(
private val paymentRepository: PaymentRepository,
private val userRepository: UserRepository,
private val notificationClient: NotificationClient,
private val fraudDetectionClient: FraudDetectionClient,
private val ledgerClient: LedgerClient,
// ... 10개 이상의 의존성
) {
// 결제 생성, 취소, 환불, 부분 환불, 정산, 알림, 사기 탐지...
// 수천 줄의 코드
}
의존성 방향 문제: Repository가 JPA Entity에 직접 의존하고, Service가 Repository의 구현 상세에 의존한다. DB를 바꾸려면 Service까지 수정해야 했다. 이건 계층 분리의 의미가 퇴색되는 것이다.
NOTE 잠깐! 이 용어는?
의존성 역전 원칙(DIP): 상위 모듈이 하위 모듈에 직접 의존하지 않고, 둘 다 추상화에 의존해야 한다는 원칙이다. Layered Architecture에서는 Service가 Repository 구현체에 직접 의존하므로 DIP를 위반하기 쉽다.
2단계: Hexagonal Architecture — 외부 의존성 분리
문제의 근본 원인은 비즈니스 로직이 인프라(DB, 외부 API)에 직접 의존하는 것이었다. Hexagonal Architecture(Ports & Adapters)는 이 문제를 해결한다.
핵심 아이디어는 단순하다. 비즈니스 로직을 중심에 놓고, 외부 세계와의 소통은 포트(Port)라는 인터페이스를 통해서만 한다. 실제 구현은 어댑터(Adapter)가 담당한다.
Hexagonal Architecture 프로젝트 구조
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
비유하자면, Layered Architecture가 "큰 방에 가구를 배치한 것"이라면, Hexagonal Architecture는 "방을 용도별로 나누고, 방 사이에 문(포트)을 설치한 것"이다. 거실에서 부엌으로 가려면 반드시 문을 통해야 한다.
Port 인터페이스 예시
// 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
}
}
이제 DB를 PostgreSQL에서 MongoDB로 바꾸더라도 SavePaymentPort의 구현체(어댑터)만 새로 만들면 된다. 도메인 코드는 한 줄도 건드릴 필요가 없다.
새로운 문제
Hexagonal Architecture가 해결한 것도 많았지만, 새로운 고민이 생겼다.
어댑터 폭발: 외부 시스템이 늘어날 때마다 포트 인터페이스와 어댑터 구현체가 쌍으로 증가했다. 결제 시스템은 PG사, 은행 API, 알림 서비스, 원장 시스템, 사기 탐지 등 외부 연동이 많은 도메인이다. 포트와 어댑터의 수가 기하급수적으로 늘었다.
포트 인터페이스 관리 복잡성: "이 기능에 맞는 포트가 이미 있는가, 새로 만들어야 하는가?"라는 판단이 반복적으로 필요했다. 유사한 포트가 난립하거나, 하나의 포트가 너무 많은 메서드를 갖게 되는 양극단이 발생했다.
모듈 경계의 모호함: 여러 도메인(결제, 송금, 정산)이 하나의 프로젝트 안에 있으니, 도메인 간 경계가 모호해졌다. PaymentService가 TransferRepository의 포트를 직접 호출하는 일이 생겼다.
NOTE 잠깐! 이 용어는?
어댑터 폭발(Adapter Explosion): Hexagonal Architecture에서 외부 시스템 연동이 많아질 때, 포트와 어댑터의 수가 과도하게 증가하는 현상이다. 각 외부 시스템마다 인터페이스-구현체 쌍이 필요하므로 파일 수가 급격히 늘어난다.
3단계: Clean Architecture + Monorepo — 모듈 경계 명확화
최종적으로 당근페이 팀이 도달한 구조는 Clean Architecture를 Monorepo 안의 모듈 단위로 적용하는 방식이었다.
비유를 이어가면, Hexagonal Architecture가 "한 건물 안에서 방을 나눈 것"이라면, Clean Architecture + Monorepo는 "아파트 단지를 만들고, 각 동(모듈)이 독립적으로 운영되는 것"이다. 각 동은 자체적으로 완결된 구조를 갖지만, 단지 내 공유 시설(공통 모듈)도 이용할 수 있다.
Clean Architecture + Monorepo 프로젝트 구조
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)의 모듈 의존성으로 경계를 컴파일 타임에 강제한다.
build.gradle.kts — 모듈 의존성 제어
// payment 모듈의 build.gradle.kts
dependencies {
implementation(project(":modules:shared"))
// transfer 모듈에 직접 의존하지 않는다!
// 도메인 간 통신은 이벤트를 통해서만
}
2. domain → usecase → adapter 단방향 의존: 각 모듈 안에서 의존성은 안쪽(domain)으로만 향한다. domain은 아무것에도 의존하지 않고, usecase는 domain에만 의존하고, adapter는 usecase에 의존한다.
3. 도메인 간 통신은 이벤트 기반: PaymentService가 TransferRepository를 직접 호출하는 대신, 도메인 이벤트를 발행한다.
도메인 이벤트를 통한 모듈 간 통신
// 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)
)
}
}
아키텍처 전환의 트리거와 판단 기준
각 전환에는 명확한 트리거가 있었다.
| 전환 | 트리거 | 핵심 판단 기준 |
|---|---|---|
| Layered → Hexagonal | Service 클래스 비대화, DB 교체 불가 | 인프라 의존성 분리 필요성 |
| Hexagonal → Clean + Monorepo | 도메인 간 경계 모호, 어댑터 폭발 | 모듈 경계의 컴파일 타임 강제 필요성 |
중요한 점은, 이전 아키텍처가 "나빴던" 것이 아니라 "당시 상황에 맞았던 것"이라는 사실이다. 초기에 Clean Architecture + Monorepo를 도입했다면 오버엔지니어링이었을 것이다. 세 명이 사는 집에 아파트 단지를 지을 필요는 없다.
아키텍처 전환을 고려해야 할 신호들:
- 하나의 변경이 예상치 못한 곳에 영향을 미친다 (경계 부재)
- 새로운 기능 추가 시 기존 코드를 광범위하게 수정해야 한다 (높은 결합도)
- 특정 클래스나 모듈이 지나치게 비대해진다 (낮은 응집도)
- "이 코드가 여기 있는 게 맞나?"라는 의문이 자주 든다 (구조적 모호함)
- 팀 규모가 커져서 동시 작업 시 충돌이 잦다 (물리적 분리 필요)
마무리
당근페이의 아키텍처 여정에서 배울 수 있는 가장 큰 교훈은 "아키텍처는 진화하는 것"이라는 점이다. 처음부터 완벽한 아키텍처를 설계하려는 시도는 대부분 실패한다. 현재의 문제를 정확히 진단하고, 그 문제를 해결하는 방향으로 점진적으로 개선하는 것이 현실적인 접근이다.
Layered에서 시작해도 괜찮다. 중요한 건 현재 구조의 한계가 보일 때 전환할 준비가 되어 있는가다. 그 준비란 결국 좋은 테스트 커버리지, 명확한 인터페이스 설계, 그리고 팀의 아키텍처 이해도다. 구조를 바꾸는 건 코드만의 문제가 아니라 팀 전체의 합의와 학습이 필요한 과정이다.
'노트 > 끄적끄적' 카테고리의 다른 글
| 장애 터졌을 때, 첫 수가 전부다 — 우아한형제들의 First Action 전략 (0) | 2026.02.15 |
|---|---|
| Redux를 서버에 올리면? 당근의 이벤트 소싱 라이브러리 Ventyd (0) | 2026.02.15 |
| CSS @scope는 BEM의 대안이 될 수 있는가 (0) | 2026.02.15 |
| CSS contrast-color()가 없어도 괜찮다 — 3가지 대안으로 흉내내기 (0) | 2026.02.15 |