세 번의 리모델링 — 당근페이가 아키텍처를 갈아엎은 진짜 이유

12 min read
Clean Architecture당근백엔드아키텍처
세 번의 리모델링 — 당근페이가 아키텍처를 갈아엎은 진짜 이유

아키텍처에 정답은 없다. 있는 건 지금 이 시점에 가장 덜 나쁜 선택뿐이다. 당근페이 백엔드 팀은 4년간 세 번의 대규모 구조 전환을 겪었다. Layered에서 Hexagonal로, 다시 Clean Architecture + Monorepo로. 각 전환은 "이전 구조가 틀렸다"가 아니라 "이전 구조의 한계가 보였다"는 신호에서 시작됐다.


1막: Layered Architecture — 원룸에서 시작하기

모든 백엔드의 출발점이라 해도 과언이 아닌 구조다. Controller → Service → Repository, 위에서 아래로 의존한다. 마치 원룸에 이사 들어가는 것과 같다. 빠르고, 단순하고, 당장 생활할 수 있다.

layered-architecture-structure.txt
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 하나에 결제 생성, 취소, 환불, 부분 환불, 정산, 알림, 사기 탐지까지 몰려들었다. 수천 줄짜리 클래스가 탄생했다.

bloated-service.kt
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)**라는 인터페이스를 통해서만 이뤄진다.

원룸에서 여러 방이 있는 집으로 이사한 것과 같다. 방 사이에 문(포트)이 있고, 거실에서 부엌으로 가려면 반드시 문을 통해야 한다.

hexagonal-architecture-structure.txt
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
port-interface.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)
}
domain-service.kt
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, 알림 서비스, 원장, 사기 탐지 등 외부 연동이 많다. 각 연동마다 포트와 어댑터가 쌍으로 증가했다. 파일 수가 감당하기 어려울 만큼 불어났다.

포트 관리의 딜레마: "이 기능에 맞는 포트가 이미 있는가, 새로 만들어야 하는가?" 판단이 반복됐다. 유사한 포트가 난립하거나, 하나의 포트가 너무 많은 메서드를 품는 양극단이 발생했다.

도메인 간 경계 모호: 결제, 송금, 정산이 하나의 프로젝트 안에 있으니, PaymentServiceTransferRepository의 포트를 직접 호출하는 일이 생겼다. 방을 나눴지만 벽이 종이벽인 셈이었다.


3막: Clean Architecture + Monorepo — 아파트 단지로의 확장

최종 구조는 각 도메인을 독립 모듈로 분리하고, Clean Architecture를 모듈 내부에 적용하는 방식이다. 한 건물 안에서 방을 나누던 것에서, 아파트 단지를 짓고 각 동이 독립적으로 운영되는 구조로 전환한 것이다. 각 동은 자체 완결적이지만, 공용 시설(공통 모듈)도 함께 이용한다.

clean-architecture-monorepo-structure.txt
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는 domain에만 의존하고, adapter는 usecase에 의존한다. 화살표가 항상 중심을 향한다.

3. 이벤트 기반 도메인 간 통신: 모듈 사이 직접 호출은 금지다. 도메인 이벤트를 발행하고, 관심 있는 모듈이 구독하는 방식으로 소통한다.

event-based-communication.kt
// 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 → HexagonalService 클래스 비대화, DB 교체 불가능인프라 의존성 분리 필요
Hexagonal → Clean + Monorepo도메인 간 경계 모호, 어댑터 폭발모듈 경계의 컴파일 타임 강제 필요

중요한 건 이전 아키텍처가 "잘못된" 것이 아니었다는 점이다. 그 시점에 맞았던 것이다. 세 명이 사는 집에 아파트 단지를 지을 필요는 없다. 초기에 Clean Architecture + Monorepo를 도입했다면 오버엔지니어링이었을 것이다.

아키텍처 전환을 고려해야 할 신호들:

  • 하나의 변경이 예상치 못한 곳에 영향을 미친다 (경계 부재)
  • 새 기능 추가 시 기존 코드를 광범위하게 수정해야 한다 (높은 결합도)
  • 특정 클래스나 모듈이 끝없이 비대해진다 (낮은 응집도)
  • "이 코드가 여기 있어도 되나?"라는 의문이 반복된다 (구조적 모호함)
  • 팀 규모가 커져서 동시 작업 시 충돌이 잦다 (물리적 분리 필요)

마무리

당근페이의 여정에서 배울 수 있는 핵심은 **"아키텍처는 진화한다"**는 것이다. 처음부터 완벽한 설계를 목표로 하면 대부분 실패한다. 현재 구조의 한계를 정확히 진단하고, 그 한계를 해소하는 방향으로 점진적으로 옮겨가는 것이 현실적인 경로다. Layered에서 시작해도 괜찮다. 중요한 건 전환이 필요할 때 전환할 수 있는 체력 — 충분한 테스트 커버리지, 명확한 인터페이스, 팀 전체의 아키텍처 이해도 — 을 평소에 쌓아두는 것이다.

관심 있을 만한 포스트

중앙 교환기를 세워라 — 당근이 AI 난립을 하나의 플랫폼으로 정돈한 전략

'AI 활용에 가장 앞선 당근' 비전 아래 여러 제품 팀이 AI를 더 잘 활용할 수 있도록 구축한 GenAI 플랫폼.

당근GenAI

Action-Reducer-State의 귀환 — 프론트엔드 패턴이 서버를 점령한 이유

프론트엔드에서 익숙한 Redux의 Action-Reducer-State 패턴을 서버 사이드에 적용한 당근마켓의 이벤트 소싱 라이브러리 Ventyd를 분석한다.

이벤트 소싱Redux

AI 코딩의 맹점 — Artifacts 없이 에이전트는 기억을 잃는다

PRD, ADR, TDD가 AI 코딩 워크플로우에서 왜 선택이 아닌 필수인지, 실전 구조와 함께 살펴본다.

AI 코딩Artifacts

Next-Translate 3.0 — Turbopack과 App Router를 위한 i18n 재건

1년간 공백 후 돌아온 Next-Translate 3.0이 Turbopack 지원, 비동기 params, App Router 안정화를 한 번에 처리하는 방법.

Next.jsi18n

V8 WasmGC 투기적 최적화 — 가상 메서드를 인라인으로 만드는 법

V8이 WasmGC의 가상 메서드 디스패치에 투기적 인라이닝을 도입해 Dart와 Java 앱에서 최대 8% 성능을 끌어낸 방법.

V8WebAssembly

Vinext — Vite 위에서 Next.js를 1주일 만에 다시 만든 이야기

Cloudflare가 AI와 함께 단 일주일, $1,100의 API 비용으로 Next.js 호환 프레임워크를 Vite 위에 구축한 과정.

VinextNext.js

Tsonic — TypeScript를 네이티브 바이너리로 컴파일하는 실험

TypeScript → C# → NativeAOT 파이프라인으로 네이티브 실행 파일을 만드는 Tsonic. 어떻게 동작하고, 어떤 한계가 있는지 살펴봤다.

TypeScriptNativeAOT

VS Code 팀의 AI 에이전트 병렬화 — 월간 릴리스를 주간으로 만든 워크플로우

VS Code 팀이 월간 릴리스에서 주간 릴리스로 전환한 비결. 에이전트 세션 병렬화, 자동화 파이프라인, 품질 게이트 설계 전반을 공개했다.

VS CodeAI