Skip to Content
← 블로그
개선기

결제 Polling의 RDB 접근을 최대 360회에서 120회로 줄이기까지

요청마다 분리 실행되던 READ·UPDATE 트랜잭션을 Redis Cache-Aside로 줄인 과정

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

10만 개 가맹점을 보유한 대형 고객사가 결제 플랫폼에 들어올 예정이었습니다. 예상 일 거래량이나 Peak TPS까지 전달받은 것은 아니었습니다. 고객사 도입도 아직 완료되지 않았습니다.

그럼에도 서버를 먼저 점검해야 한다고 판단했습니다. 결제 상태 조회, 승인, 취소가 같은 RDB와 커넥션 풀을 사용했기 때문입니다. 반복 조회가 커넥션을 점유하면 상태 조회만 느려지는 것이 아니라 핵심 결제 처리까지 영향을 받을 수 있었습니다.

별도의 k6 부하 테스트를 진행하지는 못했습니다. 대신 운영 Pinpoint에서 여러 API를 비교했습니다. 일주일 중 거래가 가장 많았던 날짜와 Peak 시간대를 살펴본 결과, 가장 많은 요청이 모이는 경로는 결제 상태 Polling이었습니다.

이 글은 대규모 트래픽을 견뎠다는 성공담이 아닙니다. 예상 트래픽 수치가 없는 상황에서 운영 데이터를 근거로 병목 후보를 찾고, 바꿀 수 없는 외부 계약 대신 서버 내부의 요청당 비용을 줄인 과정입니다.

보통 5번이면 끝나지만, 계약상 120번을 견뎌야 했다

결제는 비동기로 완료됩니다. POS는 승인 결과가 나올 때까지 1초 간격으로 PL(Payment Lookup)을 호출했습니다. 일반적인 거래는 약 3~5초 안에 끝났지만, 사용자의 비밀번호 입력이나 결제 앱 지연이 길어지면 거래 만료시간까지 최대 120회 요청할 수 있었습니다.

HTTP API로 조회하는 POS와 TCP 연결에서 PL을 보내는 POS가 같은 서비스 로직을 사용했습니다. 통신 방식은 달라도 RDB에 도달하는 경로는 같았습니다.

Polling 한 번에는 세 개의 DB 작업이 실행됐습니다. 이 작업들은 하나의 트랜잭션으로 묶여 있지 않았습니다. 가맹점과 거래 상태를 확인하는 READ 트랜잭션 1개, Polling 횟수를 기록하는 UPDATE 트랜잭션 1개가 순서대로 분리 실행됐습니다. 따라서 요청 한 번이 RDB 트랜잭션 두 개를 만들었습니다.

트랜잭션포함된 DB 작업과 목적거래당 최대 실행 횟수
READ 트랜잭션가맹점 유효성 SELECT + 거래 상태 SELECT120개 트랜잭션·240회 SELECT
UPDATE 트랜잭션운영 분석을 위한 Polling 횟수 UPDATE120개 트랜잭션·120회 UPDATE
합계요청당 트랜잭션 2개·DB 작업 3회최대 240개 트랜잭션·360회 RDB 접근

360회는 운영에서 측정한 평균 쿼리 수가 아닙니다. 최대 Polling 120회 × 요청당 DB 작업 3회로 계산한 최악 조건의 상한값입니다. 같은 조건에서 트랜잭션 수의 상한은 120회 × 요청당 트랜잭션 2개 = 240개입니다. 일반 거래는 3~5초 안에 끝났기 때문에 항상 이 횟수만큼 실행된 것은 아닙니다.

트랜잭션 경계를 단순화하면 다음과 같은 구조였습니다. 실제 클래스와 컬럼명은 익명화했습니다.

public PollingResponse poll(PollingRequest request) {
    PollingSnapshot snapshot = pollingReader.find(request);
    pollingCounter.increase(request.transactionNo());
    return PollingResponse.from(snapshot);
}

@Transactional(readOnly = true)
public PollingSnapshot find(PollingRequest request) {
    return pollingRepository.findSnapshot(request);
}

@Transactional
public void increase(String transactionNo) {
    pollingRepository.incrementPollingCount(transactionNo);
    // UPDATE ... SET poll_count = poll_count + 1
}

조회 결과를 가져오는 READ 트랜잭션이 끝난 뒤 Polling 횟수 UPDATE 트랜잭션이 실행됐습니다. 횟수는 엔티티를 읽고 다시 저장하지 않고 DB에서 직접 poll_count + 1로 증가시켰습니다. UPDATE가 실패하면 횟수만 누락한 채 성공 응답을 주지 않고 PL 요청도 실패로 처리했습니다.

Polling을 없애는 대신, 한 번의 비용을 줄였다

가장 먼저 떠오르는 해법은 Polling 횟수를 줄이거나 Push 방식으로 바꾸는 것입니다. 하지만 POS 연동 규격은 이미 확정돼 여러 연동사가 운영 중이었고, 단말의 기술 환경에도 제약이 있었습니다. 서버가 일방적으로 계약을 바꿀 수 없었습니다.

문제를 다음과 같이 다시 정의했습니다.

최대 120회의 요청을 막을 수 없다면, 한 번의 요청이 RDB에서 수행하는 일을 어디까지 줄일 수 있을까?

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

선택지장점제외하거나 선택한 이유
POS Polling 규격 변경요청 자체를 제거하거나 간격을 늘릴 수 있음운영 중인 연동 계약과 POS 기술 제약 때문에 변경 범위가 지나치게 큼
SQL·인덱스 최적화인프라 추가 없이 개별 조회시간 단축조회 조건은 이미 UNIQUE 인덱스를 사용해, 핵심 문제인 반복 횟수를 줄이지 못함
RDB Read ReplicaSELECT 부하를 Primary에서 분리인프라 비용·운영 복잡도·복제 지연이 생기고 Polling 횟수 UPDATE는 Primary에 남음
애플리케이션 로컬 캐시가장 빠르고 구현 범위가 작음결제 서버 4대에서 요청이 분산돼 서버별 상태 불일치와 중복 적재 가능
Redis Cache-Aside4개 인스턴스가 같은 상태를 보고 RDB 장애 전파를 줄임Redis 장애·캐시 정합성·용량을 별도로 관리해야 함

Redis는 이미 Master·Replica 구조로 운영 중이었습니다. 정상 상황에서는 Master에서 읽고 쓰고, Master 장애 시 Replica가 승격되는 구성이었습니다. 새 인프라를 추가하지 않고 4개 결제 서버가 같은 거래 상태를 조회할 수 있다는 점이 가장 큰 선택 이유였습니다.

캐시에는 거래 전체가 아니라 PL 응답 스냅샷만 담았다

Key는 가맹점 POS와 거래번호를 조합했습니다.

polling:{posId}:{transactionNo}

Value는 RedisTemplate<String, Object>opsForValue()를 사용해 Map 전체를 하나의 Value로 저장했습니다. Redis Hash의 HGET/HSET 구조가 아니라 Key 하나에 직렬화된 Map 하나를 대응시킨 GET/SET 구조입니다.

  • 거래 상태
  • 거래 진행 단계
  • 응답 코드
  • 거래번호
  • 생성 시각

Key가 존재한다는 것은 거래 생성 시점에 해당 가맹점 POS와 거래번호가 이미 검증됐다는 뜻으로 사용했습니다. Cache Hit에서는 이 생성 시점의 검증 결과를 재사용하고, Cache Miss일 때만 RDB에서 가맹점과 거래의 존재 여부를 다시 확인했습니다. RDB에도 데이터가 없으면 미존재 가맹점 또는 미존재 거래로 응답했습니다.

캐시 적용 범위는 승인 결과를 기다리는 Polling 구간으로 제한했습니다. 취소와 환불 흐름에는 사용하지 않았습니다. 따라서 여기서 말하는 상태의 단조 증가는 승인 대기 → 승인 결과 범위에만 해당합니다.

TTL은 Key 전체에 125초를 설정했습니다. 거래 생성 시점부터 최대 유효시간 120초에 네트워크 오버헤드 5초를 더한 값입니다. 승인 상태로 갱신해도 TTL을 연장하지 않았습니다.

redisTemplate.opsForValue().set(
    key,
    pollingSnapshot.toMap(),
    Duration.ofSeconds(125)
);

Value 저장과 TTL 설정을 별도 set()·expire() 호출로 나누지 않았습니다. opsForValue().set(key, value, timeout) 한 번으로 함께 전달해, Value만 저장되고 TTL 설정이 누락되는 중간 실패 구간을 만들지 않았습니다.

POS가 승인을 확인한 뒤에도 Key를 바로 삭제하지 않았습니다. 네트워크 재시도나 중복 Polling이 들어오면 RDB를 다시 조회하지 않고 같은 승인 결과를 반환하기 위해 거래 만료시간까지 유지했습니다.

Cache Hit에서도 UPDATE 트랜잭션은 남겼다

개선 전에는 PL 요청 하나가 READ 트랜잭션과 UPDATE 트랜잭션을 각각 실행했습니다. Cache Hit 이후 사라진 것은 가맹점과 거래 상태를 확인하던 READ 트랜잭션 1개입니다. 운영 분석에 필요한 Polling 횟수 UPDATE는 별도의 트랜잭션으로 매 요청 유지했습니다. 따라서 Cache Hit 경로도 트랜잭션이 0개가 아니라 요청당 UPDATE 트랜잭션 1개를 사용합니다.

현재 실행 순서는 다음과 같습니다.

Cache Hit
Redis 조회 → UPDATE 트랜잭션(Polling 횟수 +1) → POS 응답

Cache Miss
Redis 조회 → READ 트랜잭션(가맹점·거래 조회) → Redis SET
→ UPDATE 트랜잭션(Polling 횟수 +1) → POS 응답

상태 조회와 Polling 횟수 기록은 실패 의미가 달랐습니다.

경로실행되는 작업응답 조건
Cache HitRedis GET + UPDATE 트랜잭션Polling 횟수 UPDATE까지 성공해야 응답
Cache MissRedis GET + READ 트랜잭션 + Redis SET + UPDATE 트랜잭션RDB 조회와 Polling 횟수 UPDATE가 성공해야 응답
RedisExceptionRedis 오류 + READ 트랜잭션 + 캐시 재적재 시도 + UPDATE 트랜잭션RDB를 기준으로 복구하되 UPDATE가 실패하면 PL 실패

RedisExceptionnull과 동일하게 처리했습니다. Redis가 응답하지 않으면 RDB에서 상태를 조회하고, 결과가 있으면 캐시를 다시 채웠습니다. 상태 조회는 Redis 장애를 RDB로 우회하는 Fail-Open이었지만, 운영 분석에 사용하는 Polling 횟수 UPDATE까지 생략하지는 않았습니다. 이 UPDATE가 실패하면 PL 요청도 실패했습니다.

따라서 Redis 장애가 계속되면 모든 Polling이 Lettuce 오류를 거친 뒤 RDB로 몰릴 수 있습니다. Redis를 사용할 수 없더라도 기능은 RDB로 복구되지만, Redis Timeout만큼 응답이 지연되고 RDB READ 트랜잭션이 다시 늘어나는 구조입니다. 당시 사용한 클라이언트는 Lettuce였지만 정확한 연결·명령 Timeout 값은 남아 있지 않아 수치로 적지 않았습니다.

동일 거래의 Cache Miss에 분산 락은 사용하지 않았습니다. 하나의 거래번호는 POS 한 대만 조회하고, 이전 요청의 응답을 받은 뒤 다음 요청을 보내는 순차 계약이었기 때문입니다. 일반적인 Cache Stampede 대책을 추가하기보다 업무의 동시성 조건을 이용해 복잡도를 줄였습니다.

첫 캐시와 승인 상태는 DB 커밋 이후에만 썼다

거래 생성 시 최초 캐시는 거래 원장이 커밋된 이후에 저장했습니다. Redis 저장에 실패하더라도 다음 PL이 Cache Miss 경로로 RDB를 조회해 복구할 수 있었습니다. 따라서 첫 Polling부터 Cache Hit이 되는 것이 정상 경로였지만, Redis 실패 상황까지 포함한 절대적인 보장은 아니었습니다.

승인 상태 변경도 동기 @TransactionalEventListener(phase = AFTER_COMMIT)에서 Redis에 반영했습니다. 트랜잭션 안에서 캐시를 먼저 갱신하면 DB 롤백 후에도 Redis에 승인 상태가 남을 수 있기 때문입니다.

거래 상태 UPDATE
→ DB COMMIT
→ AFTER_COMMIT 이벤트
→ Redis 승인 상태 갱신

이 순서로 롤백된 상태가 캐시에 남는 문제는 막았습니다. 하지만 AFTER_COMMIT이 캐시 정합성의 모든 문제를 해결한 것은 아닙니다.

AFTER_COMMIT이 해결하지 못한 두 가지 취약점

첫째, DB 커밋 후 Redis 갱신이 실패하면 기존 캐시가 남습니다. 당시 리스너에서는 RedisException을 내부 로그로만 기록했습니다. 호출자는 정상 응답을 받지만 POS는 TTL이 끝날 때까지 이전 상태를 볼 수 있습니다.

둘째, Cache Miss 재적재와 승인 갱신이 경쟁하면 과거 상태가 최신 상태를 덮을 수 있습니다.

거래 원장에는 updated_at이 있었지만, 마지막 변경 시각만으로 상태 변경 횟수와 순서를 항상 보장할 수는 없습니다. Redis Value에도 상태 순서를 비교할 버전 값은 없었습니다. 이 두 문제로 실제 장애나 CS가 발생한 적은 없습니다. 운영 중 발견한 장애가 아니라 코드 검토 과정에서 확인한 잠재 취약점입니다.

구현한 경로는 네 가지 시나리오로 검증했다

단위 테스트와 테스트 환경의 통합 테스트에서는 실제로 구현한 Cache-Aside의 정상·실패 경로를 다음과 같이 확인했습니다.

시나리오확인한 동작
Cache HitRedis Value로 응답하고 가맹점·거래 SELECT를 실행하지 않음
Cache MissRDB에서 상태를 조회한 뒤 Redis에 Map과 TTL을 다시 저장함
RedisException예외를 PL 실패로 바로 전파하지 않고 RDB 조회 경로로 전환함
TTL 만료거래 생성 기준 125초 뒤 Cache Miss가 되고 RDB 조회 경로로 전환함

DB Rollback 시 AFTER_COMMIT 리스너가 실행되지 않는 경우와 Cache Miss·승인 이벤트의 순서 역전은 별도 테스트 결과로 주장하지 않았습니다. 전자는 Spring 트랜잭션 이벤트의 실행 조건을 이용한 설계이고, 후자는 코드 검토에서 발견해 향후 검증 대상으로 남긴 취약점입니다.

결과는 계산값과 관측값을 나눠서 봤다

항목변화근거
Cache Hit 시 거래당 최대 RDB SELECT240회 → 0회최대 120회 × SELECT 2회의 코드 경로 계산
Cache Hit 시 거래당 최대 RDB 트랜잭션240개 → 120개요청당 READ·UPDATE 2개에서 UPDATE 1개만 유지
거래당 최대 전체 RDB 접근360회 → 120회SELECT 제거, Polling 횟수 UPDATE 유지
Polling API 응답시간최대 200ms 관측 → 평균 30ms 확인운영 Pinpoint, 거래량 최대 날짜의 Peak 시간대
운영 확인약 5개월 동안 관련 문제 미관찰현재 운영 트래픽 기준

최대 200ms평균 30ms는 같은 Polling API를 같은 Peak 구간에서 살펴본 값이지만 집계 기준이 다릅니다. 표본 수와 개선 전 평균값도 남아 있지 않습니다. 따라서 85% 단축처럼 감소율을 계산하지 않았습니다.

RDB 조회 최대 240회 제거 역시 실제 전체 쿼리 로그나 Redis Hit Ratio로 집계한 값이 아닙니다. Cache Hit과 최대 Polling을 가정한 코드 경로의 상한입니다. 현재는 Pinpoint로 API 응답시간만 확인했고 Redis Hit Ratio, Key 수, 메모리, Eviction, DB 커넥션 풀 지표는 별도로 구성하는 중입니다.

무엇보다 10만 가맹점 규모의 고객사는 아직 입점하지 않았습니다. 이 개선은 운영에 적용돼 약 5개월 동안 관련 문제가 관찰되지 않았지만, 목표 규모의 부하를 통과했다는 뜻은 아닙니다.

다시 설계한다면 성능보다 상태 순서를 먼저 보강한다

현재 구조에서 가장 먼저 보완할 부분은 Cache Hit Ratio가 아니라 상태 순서와 장애 복구입니다.

1. status_version으로 과거 상태의 역덮어쓰기를 막는다

거래 원장에 단조 증가하는 status_version을 추가하고 Redis에도 함께 저장합니다. 캐시 갱신은 Lua Script에서 현재 버전보다 새로운 값만 허용합니다.

Redis statusVersion = 2
뒤늦게 도착한 statusVersion = 1
→ 갱신 거부

updated_at은 마지막 변경 시각은 알려주지만 변경 횟수와 순서를 항상 보장하지 않습니다. 상태 순서 판단에는 명시적인 버전이 더 적합합니다.

2. Cache Sync Outbox와 Circuit Breaker로 Redis 장애를 격리한다

거래 상태 변경과 Cache Sync Outbox를 같은 DB 트랜잭션에 저장합니다. 커밋 직후 Redis 갱신을 먼저 시도하고, 실패하면 Outbox를 짧은 주기로 재처리합니다. 캐시 TTL이 125초이므로 10분 배치가 아니라 수초 단위의 제한된 재시도가 필요합니다.

동시에 Redis 오류가 연속되면 Circuit Breaker를 열어 Cache Read를 잠시 건너뛰고 RDB를 기준으로 응답합니다. Redis가 복구된 뒤에는 Outbox의 최신 status_version만 다시 반영해 오래된 캐시가 살아나는 문제를 줄일 수 있습니다.

이 구조는 DB 쓰기와 운영 복잡도를 늘립니다. 캐시는 원장의 기준이 아니므로 모든 서비스에 적용할 필요는 없습니다. 다만 승인 결과처럼 잘못된 캐시가 거래 확정 여부에 영향을 주는 경로라면, 단순 로그보다 명시적인 복구 기록이 필요합니다.

3. Polling 횟수는 Redis에서 세고 종료 시 한 번 반영한다

현재 남아 있는 최대 120회의 RDB UPDATE는 Redis HINCRBY로 이동할 수 있습니다. FINAL API를 지원하는 POS는 승인 결과를 확인하고 OKAY를 호출할 때 최종 카운트를 RDB에 반영합니다. FINAL API 미지원 또는 호출 누락 거래는 만료 배치가 보정합니다.

카운트 Key의 TTL은 상태 캐시보다 길게 두고, RDB에는 poll_count = :redisCount처럼 최종값을 저장해 재시도에도 멱등하게 만들어야 합니다. DB 반영이 성공한 뒤에만 Redis 카운트를 삭제합니다. 이 값은 금융 원장만큼 엄격하지는 않지만 장애 분석에 사용되므로, Redis 장애 한 번에 전체 값이 사라지는 설계는 피해야 합니다.

대형 고객사가 오기 전에 아직 해야 할 일

이번 개선에는 부하 테스트와 Redis 용량 산정이 빠져 있습니다. 고객사 입점 전에는 가맹점 수가 아니라 예상 동시 거래와 Peak TPS를 먼저 받아야 합니다.

  • k6로 HTTP·TCP 공통 Polling 경로의 Hit·Miss·Redis 장애 시나리오 검증
  • Redis Key 수, Value 크기, 메모리 사용량과 Eviction 임계치 산정
  • Redis Hit Ratio·응답시간과 DB 커넥션 풀 사용량 대시보드 구성
  • Master 장애와 Replica 승격 중 Fail-Open 동작 확인
  • Cache Miss와 승인 이벤트의 순서 역전 테스트

향후 POS 규격을 새로 정의할 수 있고 단말이 결과 Push를 안정적으로 받을 수 있다면, 캐시를 더 정교하게 만드는 것보다 Polling 자체를 줄이는 선택을 다시 검토하겠습니다. 현재 선택은 고정된 연동 계약 안에서 가장 작은 변경으로 위험을 줄인 답이었습니다.

저는 운영 Pinpoint 분석, 병목 후보 선정, 대안 비교, Redis Key·TTL과 Cache-Aside 설계, AFTER_COMMIT 이벤트, 예외 처리, 단위·통합 테스트, 배포와 운영 확인을 직접 담당했습니다. 인프라팀은 Redis Master 장애에 대비한 Replica 전환 구성을 점검했습니다.

마치며

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

  1. 바꿀 수 없는 외부 계약은 내부 비용으로 다시 푼다. Polling 횟수를 줄이지 못해도 요청 한 번의 RDB 조회는 줄일 수 있습니다.
  2. 캐시 정합성은 AFTER_COMMIT에서 끝나지 않는다. 롤백 방지와 이벤트 순서 보장은 서로 다른 문제입니다.
  3. 계산한 최대치와 운영에서 관찰한 값은 분리한다. 아직 들어오지 않은 트래픽을 견뎠다고 주장하지 않습니다.

Redis는 병목 후보를 줄이는 데 유효했습니다. 다음 단계는 더 높은 Hit Ratio가 아니라, 장애와 순서 역전이 발생해도 캐시가 원장보다 오래된 답을 고집하지 않도록 만드는 일입니다.