레거시 정산 개편기: 신규 시스템 투입 여정부터 대규모 배치 운영 노하우까지

2 hours ago 1

안녕하세요, 토스페이먼츠 Server Developer 강민주, 박진현입니다.

"정산 시스템을 왜 개편하나요?"

토스페이먼츠에서 정산 시스템을 운영하며 가장 많이 받았던 질문이에요. 하지만 20년이 넘은 레거시 시스템을 운영하다 보니, 저희가 반드시 극복해야 할 명확한 한계가 있었습니다. 지금부터 토스페이먼츠 정산 플랫폼팀이 20년 레거시 정산 시스템을 어떻게 개편했는지, 그 여정을 공유합니다.

정산이란 무엇인가?

먼저 정산이 무엇인지부터 짚어볼게요. 정산은 쉽게 말해 고객이 결제한 돈을 PG에서 상점에 정확히 전달하는 과정입니다. 하지만 이 과정이 생각보다 간단하지 않아요.

상점별로 계약 조건에 따라 수수료 계산 방식이 다르고, 돈을 지급하는 일자를 조정하는 등 매우 정밀한 작업들이 필요하기 때문이죠. 토스페이먼츠 PG 정산 시스템은 20년이라는 기간 동안 이런 정밀한 작업들을 수행해오고 있었습니다.

참고: https://docs.tosspayments.com/resources/glossary/settlement

왜 레거시 시스템을 개편해야 했나?

20년간 운영해온 시스템을 개편한다는 것은 쉬운 결정이 아니었습니다. 하지만 저희가 극복해야 할 명확한 한계가 세 가지 존재했어요.

1️⃣ 하나의 공통 쿼리에 종속된 비즈니스 로직

문제 상황: 거대한 공통 쿼리의 늪

정산을 하기 위해서는 어떤 기능들이 필요할까요?

이처럼 거래 한 건을 정산하기 위해선 다양한 기능들이 조건에 따라 적용되어야 하는데요. 기존 시스템은 이 모든 것을 하나의 거대한 쿼리로 처리하고 있었습니다.

SELECT DECODE(조건1, 'A', CASE WHEN 조건2 THEN (SELECT ... FROM ... WHERE ...) ELSE DECODE(조건3, 'B', 값1, 값2) END, 값3) AS 수수료, -- ... 수많은 중첩된 DECODE와 CASE WHEN FROM 테이블1 JOIN 테이블2 ON ... JOIN 테이블3 ON ... -- ... 수많은 JOIN과 SUBQUERY

수많은 도메인을 엮기 위해 여러 JOIN과 SUBQUERY, UNION ALL을 사용했고, 분기문을 처리하기 위해 중첩된 DECODE나 CASE WHEN 표현식을 사용하고 있었어요.

이렇게 모든 것을 공통 쿼리로 처리하려다 보니, 작은 요구사항 하나를 반영하는 데도 어느 조건을 수정해야 하는지 명확하지 않았습니다. 또 변경에 대한 영향 범위가 상당히 크고, 테스트를 위한 비용도 높았기 때문에 전반적인 개발 유지보수 비용이 컸어요.

접근 방법: 분할정복

저희는 이 거대한 쿼리를 분석하면서 분할정복에 집중했습니다. JOIN, UNION ALL 등을 중심으로 엮여있는 각 연결고리를 분석하면서 카테고리를 나눴어요.

1단계: 도메인 분리

2단계: 세부 기능 분리

분류된 카테고리에서 기능들을 더욱 세분화하여 문제를 작은 단위로 분할했습니다. 이렇게 문제를 작고 명확한 단위가 될 때까지 나누고 나니, 비로소 각 기능에 대한 분석과 코드 개선을 진행할 수 있는 단단한 기반이 마련되었습니다.

분할정복의 추가 이점: 점진적 전환

분할정복 접근 방식은 시스템 개편에 또 다른 장점을 가져다 주었습니다. 시스템 전체가 개편되기 전이라도, 정복한 기능부터 점진적으로 새로운 시스템으로 전환할 수 있다는 것이죠.

예를 들어, 고시환율 환전과 체크카드 결제유형에 대한 새로운 요구사항이 추가됐다면, 해당 기능들의 개편 우선순위를 높여서 요구사항을 충족시키는 방안을 계획할 수 있었습니다. 덕분에 회사의 방향과 맞춰, 장기 프로젝트 중에서도 단계별로 실질적 가치를 제공할 수 있었어요.

개편된 코드: 비즈니스 규칙이 드러나다

SELECT DECODE(조건1, 'A', CASE WHEN 조건2 THEN (SELECT ... FROM ... WHERE ...) ELSE DECODE(조건3, 'B', 값1, 값2) END, 값3) AS Fee, ..... FROM 테이블1 JOIN 테이블2 ON ... JOIN 테이블3 ON ... class SettlementFeeCalculator( private val originalTransactionAmount: Money, private val feeContract: FeeContract, ) { fun calculate(): Fee { val feeAmount = originalTransactionAmount .times(feeContract.getRate()) .plus(feeContract.getFixedFeeAmount()) .... return Fee.create( amount = feeAmount, vatIncluded = feeContract.isVatIncluded() ... ) } }

예전에는 DECODE나 CASE WHEN 문을 한참 해석해야만 비즈니스 규칙을 겨우 알 수 있었는데요. 이제는 코드가 비즈니스 규칙을 명확하게 드러내고, 역할에 맞는 객체로 분리되어 요구사항에 필요한 변경 지점과 영향받을 수 있는 지점을 훨씬 명확하게 관리할 수 있게 되었습니다.

2️⃣ 데이터 모델링의 한계

문제 상황: 집계된 데이터의 추적 불가능

기존 정산 시스템은 거래별 결과를 개별적으로 저장하지 않았어요. 대신, 특정 기준에 따라 거래 결과를 집계(Sum)한 상태로 저장하고 있었습니다.

이 구조는 과거의 집계 요구사항에 맞춰서 효율적이었지만, 문제 상황이 발생하면 큰 제약이 있었습니다.

예를 들어 거래 A, B, C 중 어떤 건에서 오류가 났는지 추적이 불가능했습니다. 또 이미 집계된 상태라, 다른 목적(예: 가맹점별·상품별 조회)에 데이터를 재활용하기도 어려웠어요.

결국 ‘편의성’을 위해 희생한 ‘추적 가능성’이 기술적 부채가 되어버렸던 셈입니다.

개선 1 : 최소 단위 데이터 관리

이를 해결하기 위해, 정산 데이터를 거래 단위로 명확하게 관리하는 구조로 전면 개편했어요. 즉, 모든 거래가 1:1로 처리되고 저장되는 형태입니다.

이렇게 데이터의 최소 단위를 확보하자 문제 발생 시 원인 추적이 용이해졌습니다. 그리고 최소 단위 데이터를 조합해, 다양한 관점(조회·리포트·분석)에 맞는 결과를 유연하게 제공할 수 있게 되었습니다.

개선 2: 설정 정보 스냅샷

거래 단위로 데이터를 관리하게 되었지만, 여전히 한 가지 문제가 남았는데요. 바로 결과값이 ‘어떤 설정값’에 의해 계산되었는지를 증명할 수 없었다는 점입니다.

가맹점의 계약 조건(수수료율, VAT 포함 여부 등)은 언제든 변경될 수 있어요. 단순히 결과 금액만 저장한다면, 당시의 계약 조건을 재현할 수 없게 됩니다. 이 문제를 해결하기 위해, 모든 계산 결과에 대해 당시의 설정 정보를 스냅샷으로 함께 저장하는 방식을 도입했습니다.

이제 각 거래 결과는 자신이 계산될 당시의 정확한 계약 조건과 환경을 증명할 수 있습니다. 즉, 시간이 지나더라도 그 때의 맥락을 그대로 복원할 수 있게 된 것이죠.

추가 개선 : 상태 기반 재처리

기존 시스템에서는 수천만 건의 거래를 하나의 트랜잭션으로 처리하고, 모든 결과가 문제없이 계산된 후에야 한 번에 커밋되었는데요. 새로운 시스템은 이러한 비효율을 제거하기 위해 거래별로 상태를 독립적으로 기록하도록 설계했습니다.

이제 각 거래의 상태를 기반으로 실패 건만 선별적으로 재처리할 수 있습니다. 그 결과, 장애 복구에 걸리는 시간을 크게 줄일 수 있었습니다.

3️⃣ 데이터 고해상도 문제

정산 데이터를 최소 단위로 세밀하게 관리하게 되면서, 기존에 비해 저장되는 데이터의 양이 폭발적으로 증가했습니다. 이로 인해 저장 효율과 조회 성능을 동시에 유지해야 하는 새로운 도전 과제가 등장했어요. 이를 해결하기 위해 저희는 데이터베이스 구조 자체를 최적화하는 두 가지 접근을 적용했습니다.

개선 1: 파티셔닝과 인덱스 전략

정산 업무의 대부분 조회는 정산일자를 기준으로 수행됩니다. 따라서 물리적 데이터 분할과 효율적인 탐색이 가능한 구조를 설계했어요.

  • 날짜 기반 Range 파티셔닝으로 검색 범위를 최소화
  • 정산일자 선두 복합 인덱스로 탐색 경로를 단축

결과적으로, 거대한 테이블에서도 효율적인 조회 방식을 확보할 수 있었습니다.

개선 2: 조회 전용 테이블 설계

최소 단위 데이터를 그대로 유지하다 보니, 기존 시스템보다 조회 시점의 실시간 집계 비용이 커지는 문제가 있었습니다. 즉, 동일한 조회 요청이라도 과거보다 응답 속도가 느려지는 현상이 발생한 것이죠.

이를 해결하기 위해, 조회 패턴에 맞춘 전용 테이블을 추가했습니다.

이렇게 제품에 필요한 인덱스를 조회 전용 테이블에 추가함으로써, 배치의 쓰기 성능에 영향을 주지 않으면서 효율적인 조회가 가능하도록 1차적인 개선을 마쳤습니다.

하지만 운영 기간이 길어지면서 저희는 조회 성격이 두 가지로 나뉜다는 것을 느꼈어요. 서비스의 핵심 기능은 앞서 설계한 조회 전용 테이블을 통해 저희가 직접 관리하며 응답 속도를 보장했지만, 어드민 계열의 제품의 상황은 달랐습니다. 어드민에서는 훨씬 복잡하고 다양한 조건의 집계 요구사항이 빗발쳤는데, 그때마다 '조회 전용 테이블'을 매번 새로 만드는 것은 유지보수 측면에서 매우 비효율적이었기 때문입니다.

결국 저희는 "핵심 조회와, 복잡한 어드민 계열의 조회는 유연한 데이터 플랫폼으로" 역할을 나누기로 결정했어요.

이에 따라 어드민의 다양한 조회 니즈는 데이터 팀이 구축한 고성능 데이터 서빙 플랫폼을 통해 해결할 수 있었는데요. 데이터 팀과 어떻게 시너지를 냈는지, 그 구체적인 아키텍처가 궁금하시다면 다음에 발행될 콘텐츠와 발표를 확인해 보시기 바랍니다.

4️⃣ 배치 시스템 성능 문제

문제 상황: 처리 시간의 폭발적 증가

기존 레거시 배치 시스템에는 치명적인 성능 이슈가 있었습니다. 거래 데이터가 실제 정산 데이터로 만들어지기까지 오랜 시간이 걸렸고, 거래가 조금만 늘어나도 처리 시간이 굉장히 빠르게 늘어나는 문제가 있었죠. 이대로 두면 회사가 목표로 하는 거래 규모까지 거래량이 늘어났을 때, 배치를 하루 종일 돌려도 처리가 끝나지 않는 상태가 벌어질 것이 예상되었어요.

신규 시스템에서는 Spring Batch로 전환하여 배치 코드를 작성했지만, 프레임워크 자체가 문제를 해결해주지는 않았습니다. 여전히 두 가지 문제가 존재했기 때문이죠.

  • 대량의 I/O 발생
  • 단일 스레드 기반 처리의 한계

대표적인 두 개의 문제에 대해서 어떻게 해소했는지 공유드리겠습니다.

해결 방법 1: I/O 횟수 줄이기

개선 1: 설정 정보 캐싱

기존에는 거래 단위로 가맹점의 설정 정보를 매번 DB에서 조회했습니다. 하지만 배치 실행 시점에는 이미 적용해야할 설정 정보가 확정된 상태였어요. 따라서 배치 시작 시점에 모든 설정 정보를 한 번에 캐싱하도록 개선했습니다.

class SettlementStepExecutionListener( private val contractRepository: ContractRepository, private val contractCache: ContractCache ) : StepExecutionListener { override fun beforeStep(stepExecution: StepExecution) { val contracts = contractRepository.findAllActiveContracts() contractCache.putAll(contracts.associateBy { it.merchantId }) } } fun calculate(transaction: Transaction) { val contract = contractCache.get(transaction.merchantId) }

결과적으로 반복적인 DB 조회가 제거돼, 배치 처리 중 I/O 횟수를 줄일 수 있었어요.

개선 2: ItemProcessor에서 Bulk 조회

Spring Batch의 기본 구조는 ItemReader → ItemProcessor → ItemWriter 순서로, ItemProcessor가 개별 건마다 실행되는 형태입니다. 이때 ItemProcessor 내부에서 I/O 발생시키는 로직이 존재한다면, 거래 100만 건 처리 시 100만 번의 I/O가 추가 발생해요.

이를 해결하기 위해 처리 단위를 묶는 Wrapper 구조를 도입했습니다.

Before) 건별 조회

class TransactionProcessor( private val merchantRepository: MerchantRepository ) : ItemProcessor<Transaction, SettlementResult> { override fun process(transaction: Transaction): SettlementResult { val merchant = merchantRepository.findById(transaction.merchantId) return calculate(transaction, merchant) } }

After) Bulk 조회

class TransactionBatchProcessor( private val merchantRepository: MerchantRepository, private val settlementCalculator: SettlementCalculator ) : ItemProcessor<Transactions, List<SettlementResult>> { override fun process(batch: Transactions): List<SettlementResult> { val merchantIds = batch.extractMerchantIds() val merchantsMap = merchantRepository.findAllByIds(merchantIds) return batch.values.map { transaction -> val merchant = merchantsMap[transaction.merchantId] ?: throw IllegalStateException("가맹점 정보 없음") settlementCalculator.calculate(transaction, merchant) } } }

결과적으로 ItemProcessor 내부에서의 I/O 횟수는 데이터 개수(N)가 아니라 청크 반복 횟수(N / Chunk Size)만큼 획기적으로 줄어들었어요.

개선 3: JDBC Batch Insert

기존 시스템은 결과 데이터 한 건당 하나의 INSERT 쿼리를 수행하고 있었는데요. 신규 시스템에서는 JDBC의 Batch Insert 방식을 도입해서 여러 건의 데이터를 하나의 쿼리로 묶어서 처리했습니다.

results.forEach { result -> jdbcTemplate.update(sql, result.id, result.amount, ...) } jdbcTemplate.batchUpdate( "INSERT INTO settlement_result (id, amount, ...) VALUES (?, ?, ...)", results ) { ps, argument -> ps.setString(1, argument.id) ps.setBigDecimal(2, argument.amount) }

해결 방법 2: 병렬 처리

I/O 최적화를 통해 개별 트랜잭션의 처리 속도를 높였지만, 단일 스레드로는 물리적인 시간의 한계가 명확했습니다. 이 부분에 대한 개선책을 알려드릴게요.

개선 1: 외부 API 병렬 호출

도메인을 구분하게 되면서 신규 시스템에서는 정보를 얻어오기 위해 별도의 API 통신이 필요한 경우가 생겼습니다. 그리고 이를 순차적으로 호출하다 보니, 네트워크 대기 시간(Latency)이 고스란히 누적되는 문제가 있었습니다. 저희는 직렬로 호출하던 것을 동시성을 통해 단축시켰어요.

Before)

val merchantInfo = merchantApi.getInfo(merchantId) val exchangeRate = exchangeApi.getRate(currency) val feeRule = feeApi.getRule(merchantId)

After)

val infoFuture = apiExecutor.submit<MerchantInfo> { merchantApi.getInfo(merchantId) } val rateFuture = apiExecutor.submit<BigDecimal> { exchangeApi.getRate(currency) } val ruleFuture = apiExecutor.submit<FeeRule> { feeApi.getRule(merchantId) } val merchantInfo = infoFuture.get() val exchangeRate = rateFuture.get() val feeRule = ruleFuture.get()

단건 처리 기준으로는 수십 밀리 초의 차이일 수 있지만, 배치 처리해야 할 데이터가 수백만 건이었기 때문에 전체 수행 시간을 단축시킬 수 있었습니다.

개선 2: Multi-threaded Step + 모듈러 연산

Spring Batch의 Multi-Threaded Step 기능을 적용했지만, 데이터를 읽어오는 로직이 Thread-safe하지 않아 문제가 있었습니다. 이미 처리된 거래를 다른 스레드가 다시 읽어와 '중복 정산'을 일으키거나, 특정 거래를 모든 스레드가 건너뛰어 '정산 누락'을 발생시키는 상황이 생길 수 있었기 때문인데요. 처음에는 동기화(Synchronization)를 적용했지만, 결국 데이터를 읽는 시점에는 병렬 처리의 이점이 없어졌습니다.

그래서 저희는 모듈러 연산을 사용해 각 스레드가 처리할 데이터를 미리 명확하게 나누어 할당하는 방식으로 해결했습니다.

이 방식을 통해 스레드 간의 경합을 차단했고, 멀티 스레드를 기반으로 성능 확보를 할 수 있었습니다.


지금까지 20년 넘은 레거시 정산 시스템의 한계점과 개선 방법에 대해 전반적으로 소개해드렸습니다. 그런데 이렇게 개선한 시스템을 과연 라이브 환경에 바로 적용할 수 있었을까요?

저희는 그러지 못했습니다. 개선된 시스템이 기존과 동일한 동작을 보장한다는 증거가 있어야 하고, 시스템 전환의 영향도를 최소화할 수 있어야 했기 때문인데요. 지금부터는 신규 시스템 개발 이후의 과정, 즉 검증과 투입 그리고 배치 운영에 대해 소개하겠습니다.

신규 시스템 검증: 수만 개의 테스트 케이스

여러분은 여러분이 개발한 시스템을 어떻게 확신하시나요?

새로 개발한 시스템이 기존 시스템과 동일하게 작동하고, 생각하는 모든 케이스들을 정확히 처리하고 있다는 확신을 가질 수 있어야 실제 서비스에 적용할 수 있을 텐데요. 저희 시스템이 검증해야 하는 경우의 수는 얼마나 될까요?

이렇게만 해도 수만 개 이상의 조합이 나옵니다. 직접 손으로 검증하는 건 불가능에 가까웠죠.

테스트 자동화 플랫폼 활용

저희는 수만 개의 테스트 케이스를 검증하기 위해 테스트 자동화 플랫폼을 적극 활용했습니다.(이 플랫폼은 지난 SLASH24의 정웅님의 발표에서도 소개되었습니다.) 테스트 자동화 플랫폼은 저희가 원하는 형태의 테스트 방식을 표준화하여 제공하고, 수만 개 이상의 테스트 케이스 관리가 용이합니다.

Copy @RestController class SettlementCalculationTestController( private val settlementCalculator: SettlementCalculator ) { @PostMapping("/test/calculate") fun calculate(@RequestBody request: TestCalculationRequest): SettlementResult { return settlementCalculator.calculate( transaction = request.toTransaction(), contractSettings = request.contractSettings ) } }

저희는 가능한 모든 케이스를 취합해 테스트 데이터를 구성하고, 해당 데이터를 입력값으로 하여 계산 결과를 바로 확인할 수 있는 별도 테스트용 계산 API를 구현했습니다. 이 계산 API는 배치 모듈에서 사용하고 있는 코어 계산 모듈을 그대로 사용하고 있어, 실제 계산 배치에서 돌고 있는 로직과 동일한 계산 로직을 실행시킬 수 있어요.

저희는 라이브 환경에 시스템을 적용하기 전 이 테스트 검증을 항상 진행함으로써 시스템 변경의 안정성을 높이고 있습니다.

안전한 라이브 투입: 배치 카나리 시스템

정산 로직을 검증했다면, 실제 투입은 어떻게 진행되었을까요? 저희가 신규 시스템 투입에 대해 고민했던 부분이 2가지가 있었습니다:

  • 신규 시스템 투입 이후 이슈가 발생하더라도 빠른 시간 안에 복구가 가능해야 한다.
  • 문제가 발생하더라도 충분히 빠르게 대응할 수 있는 감당 가능한 규모여야 한다.

원자적 투입 단위 정의

저희는 시스템의 변화를 원자적으로 적용해야 하는 적정 단위를 정의했습니다.

이렇게 생각한 투입 단위를 기반으로 대상 가맹점을 그룹화하였고, 특정 조건에 맞는 가맹점들을 대상으로 live 환경에서 검증 및 신규 시스템으로 투입될 수 있도록 하는 배치 레이어에서의 카나리 시스템을 구현했어요.

배치 카나리 구조

세밀한 투입 단위

저희는 투입의 단위를 더 세분화했습니다:

가령, 하나의 가맹점 안에서도 특정 결제수단 건이 신규 시스템 기준으로 문제가 있을 때에는 해당 영역에 대해서만 레거시 시스템 기반으로 만들어진 데이터를 투입하도록 할 수 있어요. 그리고 지속적인 검증을 통해 오랜 기간 불일치가 발생하지 않는 가맹점에 대해서는 온전히 신규 시스템으로 정산이 될 수 있도록 시스템을 구축했습니다.


대규모 배치 운영 노하우

지금까지 다양한 케이스의 거래들을 빠르게 검증하고, 시스템의 안정성을 챙기면서 실제 라이브 환경까지 투입을 어떻게 진행했는지 말씀드렸는데요. 이제 마지막으로 대량의 배치 시스템에 대한 관리 및 모니터링을 어떻게 발전시켜왔는지 설명드리겠습니다.

현재 운영 중인 Jenkins Job의 개수: 1,500+

현재 저희 정산 시스템으로 등록되어서 유지보수되고 있는 Jenkins Job의 개수는 1,716개입니다. 엄청나죠?

레거시 배치 시스템의 문제점

과거 정산 배치는 굉장히 많은 수의 배치가 단일 인스턴스로 띄워져 있는 여러 개의 IDC 서버에 마구 흩어져 있었습니다. 배치들은 관리가 어렵고 장애에 취약한 상태였어요. 한 서버에 이슈가 생겨 배치가 실행되지 못하면 즉각 정상화하기 어려운 구조였죠.

해당 배치에 대한 실행 정보는 서버 내의 crontab으로 정의되어 있었는데, 이 선언되어 있는 리스트가 서버마다 다르게 되어 있어 제대로 관리되지 못하고 있었어요.

1단계 개선: Kubernetes로 이전

문제 지점이 너무나도 많았지만, 처음에는 하드웨어 장애에 취약한 환경에서 벗어나는 것을 최우선 목표로 두었습니다. 배치 실행이 단일 서버에 종속되지 않도록 AWS 환경 내의 Kubernetes로 배치들을 옮겨와서 관리했어요.

하지만 Kubernetes로 배치 시스템을 운영하는 과정에서 여러 이슈가 있었는데요. AWS와 데이터센터 간 물리적 거리로 인해 RTT가 길어지면서 쿼리 실행으로만 구성된 레거시 배치의 성능 저하 문제가 발생했습니다. 또 레거시 배치는 정확히 한 번만 실행되어야 하는데, Kubernetes CronJob은 시스템 제약으로 정확히 한 번 실행을 보장할 수 없어 배치 중복 실행 문제가 생겼어요.

Kubernetes 환경에서는 배치가 안전하게 실행될 수 있는 환경을 보장받기가 어려웠죠.

2단계 개선: Jenkins 선택

저희는 배치 시스템을 운용하기 위한 더 안정적인 프레임워크를 고민했고, 그 결과 Jenkins를 선택했습니다. Jenkins 선택의 이유를 말씀드릴게요.

정산 배치는 자금 집행과 관련된 미션 크리티컬한 배치가 많아, 최대한 외부 영향 없이 안정적으로 시스템이 유지될 수 있도록 하는 것이 관건이었습니다.

3단계 개선: Dynamic Provisioning

안정성을 최우선으로 생각하고 Jenkins를 선택했지만, 최신 시스템 트렌드인 고가용성과 효율성을 놓치지 않기 위해 여러 기술 요소들을 많이 도입했습니다. 정산 시스템은 주요 배치들이 새벽과 같은 특정 시간에 몰려서 실행되는 경우가 많아 문제가 발생해요. Jenkins에 배치 실행 장비를 고정적으로 할당하다 보면 같은 시점에 실행되는 배치가 몰릴 때 배치 실행이 지연될 수 있어 유동적으로 감당하기 어렵고, 반대로 서버 장비를 과도하게 할당하면 특정 시간에는 서버 자원의 낭비가 발생합니다.

이를 해결하기 위해 Jenkins의 Dynamic Provisioning 기능을 구현했습니다. 배치 실행을 위한 노드 자원을 동적으로 관리할 수 있는 기능으로, 배치 실행 요청이 많을 때는 서버 자원을 동적으로 할당받아 배치를 실행하고 필요하지 않은 경우에는 서버 자원을 다시 회수함으로써 배치 실행에 대한 서버 비용 절감도 같이 챙겼어요.

4단계 개선: Job DSL을 활용한 Job 선언 코드화

4단계 개선에서는 Job DSL을 활용한 Job 선언 코드화를 진행했습니다. Jenkins를 많이 사용하신 분들은 공감하실 텐데요. Jenkins에서 파이프라인 실행 노드를 잘못 설정해서 배치가 실패했다거나, Jenkins 노드 내의 환경변수를 일괄적으로 바꿔야 하는데 바꿔야 하는 job은 수십 개인데 일일이 들어가서 바꿔야 한다거나. 혹은 배치 개발을 했는데 파라미터 하나를 추가하려면 Jenkins UI에 들어가서 config 변경 버튼 눌러서 파라미터를 하나하나 수정해야 하는 등의 경험이 있으실 겁니다.

저희도 마찬가지로 계속해서 신규 개발이 진행될 때마다 Jenkins UI를 통해 배치를 선언하는 것에 많은 시간이 들었습니다. 또 Job 선언이 shell 스크립트로 제각각 선언되어 있다 보니, Job 내에 정의되어 있는 여러 기능이나 설정들을 빠르게 확인하고 일괄적으로 변경하는 것이 쉽지 않았어요.

해결 방법: Job DSL 기반 코드화

[ [ "jobName": JobNames.sample_myBatch_run_HelloWorld_job_live, "jobParameters": { stringParam { name(JobNames.sample_myBatch_run_HelloWorld_param_Name) defaultValue("string param") description("default-string") } booleanParam { name(JobNames.sample_myBatch_run_HelloWorld_param_Flag) description("boolean param") defaultValue(false) } }, "javaVmOptions": [ "-Dbatch.job.name=TestBatchJob" ], ] ] .each { def it -> new JavaRunJobBuilder() .jobName(it["jobName"] as String) .jobWorkerNodeSpecification(NodeLabel.runnerDynamicallyProvisioned_cpu2_mem8g_default_osc) .applicationName("test-batch") .applicationPhase("live") .applicationDeploymentLabel("test-job-01") .javaVersion(JavaVersion.JDK_17) .javaVmOptions(it["javaVmOptions"] as List<String>) .javaEntrypointScriptRelativePath("deploy/live/entrypointJenkinsPgSettingBatch.sh") .javaThreadDumpEnabled(true) .jmxAsyncProfilerEnabled(true) .pinpointAgentEnabled(true) .build(this) }

저희는 Job DSL 기반으로 job 선언 방식을 변경하면서 GitHub repository에 코드화되어 있는 설정 프로젝트를 켜서 코딩을 하듯이 배치 job을 선언하고 커밋만 하면 Jenkins에 새로운 job을 생성할 수 있게 되었습니다. 코드 리뷰도 할 수 있으니 배치 시스템의 스케줄링 안정성도 높아질 수 있죠. 또한 모든 배치 실행 환경의 JVM 버전, 노드 설정뿐만 아니라 모니터링 툴 설정같은 공통적인 설정도 일괄적으로 코드를 통해서 바꿀 수 있습니다.

배치 job 선언의 코드화는 여러 개발자들이 job을 선언하고 관리하는 비용을 획기적으로 줄이고, 각자의 도메인 application 개발에 더욱 집중할 수 있는 환경을 만들었습니다.

5단계 개선: 배치 모니터링 도구 강화

배치 시스템을 개발하면서 자주 마주치는 이슈 상황들이 있었습니다. 서버에서 OOM이 발생하거나 멀티스레딩 환경으로 배치를 구현하면서 thread block 문제에 맞닥뜨리기도 하고, reader / processor / writer 중 어느 한 부분의 지연으로 인해 전체적인 성능 지연이 발생하는 일들도 있었어요. 이런 문제가 발생할 때마다 이슈 지점의 정확한 분석이 필요했죠.

도입한 모니터링 도구들

1. Thread Dump

쓰레드의 데드락이나 병목 지점을 확인하기 위해서 배치 실행마다 Thread Dump를 생성할 수 있도록 기능을 추가했습니다.

2. Async Profiler

각 쓰레드가 어떤 형태로 CPU 리소스와 메모리를 사용하는지, 어떤 메서드에서 병목이 발생하고 있는지를 파악할 수 있도록 했습니다.

Copy# Async Profiler 실행 ./profiler.sh -d 30 -f /tmp/flamegraph.html <PID

3. Prometheus와 Pinpoint

각 실행 배치별 JVM 메모리 사용률과 CPU 사용률을 탐지하고, 외부 시스템 간의 통신 지연 혹은 쿼리 지연을 탐지하고 있습니다.

Copy# Prometheus 메트릭 수집 설정 scrape_configs: - job_name: 'settlement-batch' metrics_path: '/actuator/prometheus' static_configs: - targets: ['settlement-batch:8080']

마치며

지금까지 토스페이먼츠의 정산 시스템이 어떻게 발전되어 왔는지 말씀드렸습니다.

저희의 시스템은 하루에 수백만 케이스의 거래와 수천만 건의 데이터를 최대 10배 빠른 시간 안에 처리할 수 있는 시스템으로 개선되었어요. 이러한 성능적 개선과 더불어, 비즈니스의 요구사항을 유연하게 담을 수 있는 정산 시스템으로서 개편을 이뤄냈습니다.

이렇게 토스페이먼츠는 많은 기술적 변화를 이뤄왔는데요, 아직도 정산 시스템에는 더욱 발전시켜야 할 기술적 고민 포인트들이 많습니다. 많은 기술적 성장의 가능성이 열려 있는 토스페이먼츠에 많은 관심과 지원 부탁드립니다!

✅ 이번 아티클은 아래 Toss Makers Conference 25의 세션을 바탕으로 재구성되었습니다.

Read Entire Article