아키텍처는 고정된 정답이 아니다. 서비스의 규모, 팀의 크기, 비즈니스 복잡도에 따라 적합한 아키텍처가 달라진다. 당근페이 백엔드 팀의 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, 알림 서비스, 원장 시스템, 사기 탐지 등 외부 연동이 많은 도메인이다. 포트와 어댑터의 수가 기하급수적으로 늘었다.

 

포트 인터페이스 관리 복잡성: "이 기능에 맞는 포트가 이미 있는가, 새로 만들어야 하는가?"라는 판단이 반복적으로 필요했다. 유사한 포트가 난립하거나, 하나의 포트가 너무 많은 메서드를 갖게 되는 양극단이 발생했다.

 

모듈 경계의 모호함: 여러 도메인(결제, 송금, 정산)이 하나의 프로젝트 안에 있으니, 도메인 간 경계가 모호해졌다. PaymentServiceTransferRepository의 포트를 직접 호출하는 일이 생겼다.

 

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. 도메인 간 통신은 이벤트 기반: PaymentServiceTransferRepository를 직접 호출하는 대신, 도메인 이벤트를 발행한다.

 

도메인 이벤트를 통한 모듈 간 통신

// 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에서 시작해도 괜찮다. 중요한 건 현재 구조의 한계가 보일 때 전환할 준비가 되어 있는가다. 그 준비란 결국 좋은 테스트 커버리지, 명확한 인터페이스 설계, 그리고 팀의 아키텍처 이해도다. 구조를 바꾸는 건 코드만의 문제가 아니라 팀 전체의 합의와 학습이 필요한 과정이다.