Skip to Content
← 블로그
개편기

하나의 결제 시스템을 두 개의 배포 단위로 나누기까지

주 1회 수준의 Merge·테스트 충돌을 Gradle 멀티모듈과 의존성 역전으로 줄인 과정

만화로 보는 요약 — 먼저 읽어보세요
만화로 보는 요약 — 먼저 읽어보세요

API(HTTP) Client 통신을 담당한 개발자와 TCP(Socket) Client 통신을 담당한 개발자는 서로 다른 진입 채널을 개발했지만, 기능을 추가할 때는 같은 common 클래스와 PaymentService를 수정했습니다. 그 결과 Merge·테스트 충돌이 주 1회 수준으로 발생했고, TCP 통신을 위한 변경이 공유 클래스를 거쳐 API 테스트까지 실패시켰습니다.

운영 장애가 전환의 계기는 아니었습니다. 개발 단계에서 반복되던 변경 충돌과 하나로 묶인 빌드·배포 단위가 문제였습니다. 같은 결제 규칙은 재사용하되, API와 TCP는 독립적으로 변경하고 배포할 수 있는 구조가 필요했습니다.

제가 Gradle 멀티모듈 전환을 먼저 제안했고, 인프라팀과 API·TCP의 배포 단위를 협의했습니다. 이후 모듈 경계와 의존 방향 설계, Gradle 설정, 비대했던 서비스의 기능별 UseCase 분리, 헥사고날 포트·어댑터 구현, 기존 코드 마이그레이션, CI/CD, 테스트와 배포를 직접 이끌었습니다. 결제 후 부가 작업을 AFTER_COMMIT 이벤트와 Outbox로 분리하는 개선도 함께 진행했습니다.

먼저 결과를 요약하면 다음과 같습니다.

  • API·TCP 실행 모듈에 각각 애플리케이션 진입점과 bootJar를 구성해 독립 배포하고 있습니다.
  • 주 1회 수준이던 Merge·테스트 충돌을 대부분 해소했습니다.
  • Jenkins 관측 기준 전체 빌드 약 55초를 실행 모듈별 빌드 약 25초로 줄였습니다.
  • 결제 성공 후 부가 작업을 AFTER_COMMIT + @Async로 분리하고, 실패는 Outbox에서 10분 주기로 최대 3회 재처리했습니다.

장애가 아니라 변경이 전파되고 있었다

기존 애플리케이션은 하나의 Repository와 하나의 Gradle 모듈 안에서 Bounded Context를 패키지로 구분하고 있었습니다. 서비스가 작을 때는 빠르게 기능을 추가하기 좋은 구조였습니다.

문제는 API와 TCP라는 서로 다른 진입 채널이 늘어난 뒤에도 빌드와 배포 경계가 그대로였다는 점입니다.

API Handler ───┐
TCP Handler ────┼──> common + PaymentService ──> DB 처리·외부 연동
Batch ─────────┘

가장 큰 병목은 common과 비대해진 PaymentService였습니다. 공통 로직이라는 이유로 여러 기능이 한 클래스에 모였고, 서로 다른 개발자가 각자의 비즈니스 요구를 처리하기 위해 같은 파일을 수정했습니다.

이 구조는 세 가지 비용을 만들었습니다.

  1. 소스 충돌: HTTP 연동 담당자와 TCP 소켓 연동 담당자가 같은 공통 클래스를 수정하면서 Merge 충돌이 반복됐습니다.
  2. 테스트 영향 전파: TCP 통신 로직을 바꾸기 위해 공유 클래스를 수정하면 API 테스트도 함께 실패했습니다.
  3. 배포 영향 전파: 한 채널만 변경해도 전체 애플리케이션을 다시 빌드하고 배포해야 했습니다.

실제 운영 장애가 발생한 것은 아니지만, 한 배포 단위에 API와 TCP를 계속 묶어두면 한쪽의 코드·설정·리소스 문제가 다른 채널의 배포와 운영에도 영향을 줄 수 있었습니다. 개발 충돌은 이미 관측된 문제였고, 장애 전파는 구조를 통해 예측할 수 있는 위험이었습니다. 둘을 구분해 접근했습니다.

MSA보다 먼저 배포 경계를 바로잡았다

처음부터 MSA를 목표로 삼지는 않았습니다. 해결해야 했던 문제는 서비스를 많이 만드는 것이 아니라, 변경 이유가 다른 API와 TCP를 독립적으로 빌드하고 배포하는 일이었습니다.

검토한 선택지는 다음과 같았습니다.

선택지장점비용과 한계판단
단일 모듈 유지전환 비용이 가장 작음공통 클래스 충돌, 테스트·배포 영향 범위가 그대로 남음제외
API·TCP MSA 분리프로세스와 배포를 완전히 격리동일 DB와 결제 로직 공유, 불명확한 기능 경계, 낮은 트래픽 대비 운영 복잡성 증가제외
Gradle 멀티모듈하나의 Repository에서 결제 로직을 공유하면서 실행·배포 단위 분리공유 모듈의 경계를 계속 관리해야 함선택
Nexus 공통 라이브러리여러 프로젝트의 범용 코드 재사용API·TCP의 배포 결합 자체는 해결하지 못함보완 수단

API와 TCP는 프로토콜은 달랐지만 같은 결제 도메인과 DB를 사용했습니다. 기능과 데이터 소유권이 명확히 나뉘지 않은 상태에서 MSA로 분리하면 서비스 간 호출과 배포·모니터링 비용만 먼저 늘어납니다. 트래픽도 독립적인 수평 확장이 필요할 정도는 아니었습니다.

따라서 하나의 Repository를 유지하면서 실행 애플리케이션은 독립 배포하고, 결제 규칙은 하위 모듈에서 공유하는 Gradle 멀티프로젝트 구조를 선택했습니다. 단일 배포 단위가 아니므로 이를 멀티모듈 모놀리스라고 부르기보다, 공유 모듈을 사용하는 독립 배포형 구조로 보는 편이 정확합니다. 범용 유틸처럼 여러 프로젝트에서 같은 의미로 사용하는 코드만 Nexus 라이브러리로 추출했습니다.

실행 모듈과 비즈니스 모듈을 나눴다

전환 후 구조는 다음과 같습니다. 이름은 설명을 위해 단순화했습니다.

payment-platform
├── module-api           # API Endpoint와 요청·응답 변환
├── module-socket        # Socket Handler와 전문 변환
├── module-batch         # 재처리 등 배치 진입점
├── module-core          # UseCase, 도메인 규칙, Outbound Port
├── module-persistence   # DB Adapter와 Repository 구현
└── module-infra         # 외부 시스템 통신 Adapter

Nexus
└── common-library       # 여러 프로젝트가 공유하는 범용 코드와 유틸

모듈 경계에는 두 가지 기준을 사용했습니다. module-api, module-socket, module-batch실행·배포 단위로 나눴고, module-core, module-persistence, module-infra책임과 의존 방향으로 나눴습니다. 실행 모듈은 각 채널의 연결과 프로토콜 처리를 책임지고 필요한 구현 모듈을 조립합니다. module-core는 채널과 무관한 결제 규칙과 인터페이스를 소유합니다.

module-api ─────┐
module-socket ──┼──> module-core + module-persistence + module-infra
module-batch ───┘                  │                       │
                                  └───────┬───────────────┘
                                          v
                               module-core의 Port 구현

의존 방향: module-persistence ──> module-core
           module-infra ─────────> module-core

Gradle 의존 방향도 같은 원칙으로 구성했습니다.

// module-persistence/build.gradle
dependencies {
    implementation project(":module-core")
}

// module-infra/build.gradle
dependencies {
    implementation project(":module-core")
}

// module-api/build.gradle
// module-socket과 module-batch도 같은 조립 구조를 사용했습니다.
dependencies {
    implementation project(":module-core")
    implementation project(":module-persistence")
    implementation project(":module-infra")
}

핵심은 module-core가 persistence와 infra의 구현 클래스를 알지 않는다는 점입니다. 저장과 외부 호출에 필요한 인터페이스를 core가 정의하고, 바깥쪽 모듈이 이를 구현합니다. 실행 모듈은 Spring DI를 통해 인터페이스와 구현체를 조립합니다.

API와 TCP 실행 모듈에는 각각 SpringBootApplication 진입점과 bootJar를 구성했습니다. 반면 core·persistence·infra는 단독 실행하지 않는 공유 모듈로 유지했습니다. Gradle 모듈을 나누는 데서 끝내지 않고, 실행 가능한 산출물과 배포 파이프라인까지 분리해야 독립 배포가 완성된다고 판단했습니다.

Jenkins도 전체 프로젝트를 한 번에 빌드하는 대신 실행 모듈별 Task를 호출하도록 나눴습니다.

./gradlew :module-api:build
./gradlew :module-socket:build

각 Task는 의존하는 core·persistence·infra를 함께 빌드하지만, 관련 없는 다른 실행 모듈은 제외합니다. 즉 Gradle 자체를 빠르게 만든 것이 아니라 변경과 무관한 빌드 범위를 제거한 것이 55초에서 25초로 줄어든 주된 이유였습니다.

PaymentService를 기능별 UseCase로 해체했다

모듈만 나눈다고 충돌이 자동으로 사라지지는 않습니다. 모든 비즈니스 로직이 계속 하나의 PaymentService에 남아 있다면 개발자는 다시 같은 파일을 수정하게 됩니다.

그래서 비대했던 서비스의 책임을 기능 단위 인터페이스로 분리했습니다. 아래 코드는 실제 구조를 설명하기 위해 이름을 단순화한 예시입니다.

// module-core
public interface ApprovePaymentUseCase {
    PaymentResult approve(PaymentCommand command);
}

public interface CancelPaymentUseCase {
    PaymentResult cancel(CancelCommand command);
}

public interface PaymentRepositoryPort {
    Payment save(Payment payment);
}

public interface PaymentProviderPort {
    ApprovalResult approve(Payment payment);
}

public class ApprovePaymentService implements ApprovePaymentUseCase {
    private final PaymentRepositoryPort repositoryPort;
    private final PaymentProviderPort providerPort;

    public ApprovePaymentService(
            PaymentRepositoryPort repositoryPort,
            PaymentProviderPort providerPort) {
        this.repositoryPort = repositoryPort;
        this.providerPort = providerPort;
    }

    @Override
    public PaymentResult approve(PaymentCommand command) {
        Payment payment = repositoryPort.save(Payment.from(command));
        return PaymentResult.from(providerPort.approve(payment));
    }
}
// module-persistence -> module-core
@Repository
public class JpaPaymentRepositoryAdapter implements PaymentRepositoryPort {
    // DB 저장 구현
}

// module-infra -> module-core
@Component
public class PaymentProviderAdapter implements PaymentProviderPort {
    // 외부 결제 시스템 통신 구현
}

API와 TCP는 각자의 Handler에서 같은 UseCase를 호출하되, 요청 파싱과 응답 변환은 실행 모듈 안에 남겼습니다. 결제 규칙은 재사용하면서 채널별 변경 이유를 분리한 것입니다.

이 과정이 Merge 충돌을 줄인 직접적인 이유였습니다. Gradle 설정이 파일 충돌을 해결한 것이 아니라, 서로 다른 기능을 개발하는 사람이 같은 클래스에 모이지 않도록 책임을 다시 나눈 것이 핵심이었습니다.

모듈 경계를 실행 시점까지 확장했다

멀티모듈 전환으로 소스와 배포의 경계는 나눴지만, 결제 트랜잭션에는 상태 갱신·App Push·실시간 데이터 처리·지표 수집이 함께 묶여 있었습니다. 부가 작업의 지연과 실패가 결제 응답으로 전파되지 않도록, 구조적 경계를 실행 시점까지 확장했습니다. 이 작업은 실제 장애 복구가 아니라 예방적 개선이었습니다.

결제 원장과 Outbox의 PENDING 레코드는 같은 DB 트랜잭션에 저장했습니다. 결제가 롤백되면 Outbox도 함께 롤백되고, 결제가 커밋되면 부가 작업을 다시 처리할 근거가 남습니다. 실제 부가 작업은 커밋 이후 별도 스레드에서 시도했습니다.

@TransactionalEventListener(phase = AFTER_COMMIT)@Async("eventExecutor")로 부가 작업을 실행했습니다. Listener 내부에서 예외를 직접 포착하고, 처리 결과는 @Transactional(propagation = REQUIRES_NEW)을 적용한 새 트랜잭션에서 SUCCESS 또는 FAILED로 기록했습니다. 이미 끝난 결제 트랜잭션에 후처리 결과 저장을 기대지 않기 위한 선택이었습니다.

전용 ThreadPoolTaskExecutor는 배포 서버의 2 CPU 환경을 기준으로 Core 4, Max 8, Queue 100으로 구성했습니다. Queue까지 가득 차면 CallerRunsPolicy가 작업을 호출 스레드에서 실행합니다. 작업을 버리지 않고 역압을 거는 대신, 포화 시에는 결제 처리 스레드의 응답이 늦어질 수 있다는 트레이드오프를 받아들였습니다.

아래는 메서드명만 익명화한 실제 Executor 설정입니다.

@Bean("eventExecutor")
public Executor eventExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

    executor.setCorePoolSize(4); // 2 CPU x 2
    executor.setMaxPoolSize(8);
    executor.setQueueCapacity(100);

    executor.setRejectedExecutionHandler(
            new ThreadPoolExecutor.CallerRunsPolicy());

    executor.setTaskDecorator(new AsyncDecorator());
    executor.setThreadNamePrefix("EVENT-ASYNC-");
    executor.setThreadGroupName("EVENT-ASYNC-GRP-");
    executor.initialize();

    return executor;
}

TaskDecorator는 호출 스레드의 MDC traceId와 로깅 컨텍스트를 비동기 스레드에 복사했습니다. 작업이 끝나면 finally에서 MDC를 정리해 재사용되는 스레드에 이전 거래의 컨텍스트가 남지 않게 했습니다.

10분 주기 배치는 FAILED 상태만 최대 3회 재처리했고, 마지막까지 실패한 건은 개발팀이 원인과 로그를 확인하도록 전달했습니다. 결제 성공 여부를 다시 판단하거나 보상하는 배치가 아니라, 결제 성공 후 부가 작업의 실패만 복구하는 배치입니다.

독립 배포는 가능성이 아니라 일상이 됐다

전환은 운영 전에 진행했기 때문에 API·TCP를 단계적으로 교체하지 않고 전체 구조를 한 번에 변경했습니다. 단위 테스트와 통합 테스트로 기존 요청·응답과 결제 흐름을 검증했습니다. 이미 운영 중인 시스템이었다면 Big Bang 전환보다 채널 단위 마이그레이션과 롤백 계획이 필요했겠지만, 당시에는 무중단 전환 비용을 추가할 이유가 작았습니다.

항목전환 전전환 후
Merge·테스트 충돌공통 클래스 수정으로 주 1회 수준 발생대부분 해소
CI 빌드전체 애플리케이션 약 55초Jenkins 실행 모듈별 빌드 약 25초
배포 단위API·TCP 전체를 함께 배포별도 진입점·bootJar·파이프라인으로 독립 배포
후처리 실패 영향결제 흐름으로 전파될 수 있는 구조커밋 이후 실행, Outbox에서 재처리

Jenkins 실제 빌드에서 전체 프로젝트 약 55초가 실행 모듈별 약 25초로 줄었습니다. 동일 조건을 통제한 성능 벤치마크가 아니라 CI 관측값이므로, Gradle 성능이 55% 개선됐다고 해석하지는 않았습니다. 더 중요한 변화는 API와 TCP를 실제 운영에서도 서로 다른 시점에 독립 배포할 수 있게 된 것입니다. 지금도 두 실행 모듈은 변경 범위에 따라 각각 배포하고 있습니다.

충돌이 완전히 0건이 됐다고 쓰지는 않습니다. module-core를 함께 변경하면 두 채널이 영향을 받을 수 있고, 공유 코드가 존재하는 한 협업 비용도 남습니다. 다만 채널별 Handler와 기능별 UseCase로 변경 이유를 나누면서, 기존에 주 1회 수준으로 발생하던 Merge·테스트 충돌은 대부분 해소됐습니다.

멀티모듈이 해결하지 못한 것

멀티모듈은 물리적인 경계를 제공하지만 경계를 계속 지켜주지는 않습니다. 이번 구조에도 세 가지 한계가 남았습니다.

첫째, Nexus 라이브러리가 새로운 common이 될 수 있습니다. 특정 결제 업무를 알아야 이해할 수 있는 코드는 올리지 않고, 여러 프로젝트에서 같은 의미로 사용하는 범용 코드만 추출한다는 기준을 유지해야 합니다.

둘째, API와 TCP는 독립 배포하지만 같은 DB를 공유합니다. Flyway 도입을 요청했지만, 자동 마이그레이션만으로는 금융권 내부통제에서 요구하는 사람의 검토·승인과 감사 증적을 강제할 수 없어 도입하지 못했습니다. 운영 변경 절차를 통해 하위 호환되는 스키마를 먼저 반영하고, 기존 버전과 신규 버전이 함께 동작할 수 있는 상태에서 각 실행 모듈을 순차 배포했습니다. 애플리케이션 배포는 분리됐지만 스키마 변경까지 완전히 독립적이지는 않았습니다.

이 불편을 그대로 두지 않고, 작성·검토·결재·적용 이력을 남기는 DB 변경 결재 도구를 직접 만들었습니다. 아직 운영에는 도입하지 못했고 개발환경에서 사용하고 있습니다. Flyway 도입 요청이 왜 실패했고 어떤 대안을 설계했는지는 개발 DB에서 운영 DB까지, 그 사이엔 절차가 없었다에 별도로 정리했습니다.

셋째, Outbox 배치는 FAILED만 재조회했습니다. 커밋 직후 프로세스가 종료돼 Listener가 실행되지 않으면 PENDING이 계속 남을 수 있습니다. 부가 작업 성공 후 SUCCESS 저장 전에 프로세스가 종료되면 중복 실행될 수도 있습니다. 단일 배치 서버였기 때문에 동시 실행을 고려하지 않았지만, 단일 인스턴스도 이 두 장애 구간까지 제거하지는 못합니다.

다시 설계해도 배포 경계부터 나눈다

현재 조건이라면 다시 설계해도 독립 배포형 Gradle 멀티프로젝트를 선택하겠습니다. API와 TCP는 진입 채널만 다르고 동일한 결제 규칙과 데이터를 사용하기 때문입니다. 대신 다음 세 가지를 보완하겠습니다.

1. 모듈 규칙을 CI에서 검사한다

module-core가 infra·persistence 구현을 참조하지 않는지, 모듈 사이에 순환 의존이 없는지, 실행 모듈이 공개된 Port를 통해 기능을 호출하는지 자동 검증하겠습니다. Gradle 의존 관계와 ArchUnit 테스트를 CI에 넣어 구조가 다시 무너지면 빌드를 실패시키겠습니다.

2. 공유 DB의 호환성을 배포 계약으로 만든다

자동 마이그레이션을 사용할 수 없는 제약은 유지하되, 스키마 변경을 확장 → 애플리케이션 순차 배포 → 정리 단계로 관리하겠습니다. API와 TCP 중 어느 한쪽이 이전 버전이어도 동작하는 기간을 배포 조건으로 명시하겠습니다.

3. Outbox의 복구 경계를 닫는다

일정 시간 이상 남은 PENDING도 재처리 대상으로 포함하고, 고유한 event_id와 조건부 UPDATE로 중복 반영을 차단하겠습니다. PENDING·FAILED 건수, 가장 오래된 미처리 시간, 재시도 소진 건수와 처리 지연도 수집하겠습니다. 배치가 여러 대로 늘어날 때는 DB Lock이나 분산 락을 추가로 검토하되, 외부 처리 성공과 상태 저장 사이의 장애는 멱등성으로 방어하겠습니다.

MSA는 트래픽이 늘었다는 이유만으로 선택하지 않겠습니다. 결제 기능이 독립적인 데이터 소유권, 배포 주기, 확장 요구와 운영 책임을 갖게 될 때 다시 검토하겠습니다. API와 TCP라는 전송 방식의 차이보다 비즈니스와 데이터의 경계가 먼저 분리되는 조건이 필요합니다.

마치며

이 전환에서 얻은 판단 기준은 세 가지입니다.

  1. 실행 모듈은 배포 단위로, 공유 모듈은 책임과 의존 방향으로 나눈다. API와 TCP는 같은 결제 규칙을 사용했지만 수정과 배포 시점은 달랐습니다.
  2. 멀티모듈의 효과는 Gradle 파일보다 책임 분리에서 나온다. 비대했던 PaymentService를 기능별 UseCase로 나눴기 때문에 충돌이 줄었습니다.
  3. 독립 배포는 실행 파일을 나누는 것에서 끝나지 않는다. CI Task와 배포 파이프라인, 공유 DB의 하위 호환 조건까지 함께 설계해야 합니다.

아키텍처 전환의 결과는 멋진 구조도가 아니었습니다. API와 TCP의 변경이 대부분 같은 파일에 몰리지 않게 됐고, 필요한 모듈만 빌드해 독립적으로 배포할 수 있게 된 것이 실제 변화였습니다.