앞서 동일 사용자가 같은 요청을 여러 번 보내는 상황에 대비해, 결제 준비 및 결제 승인 API에 멱등키(user_id)를 적용해 요청을 멱등하게 처리하도록 설계하였다. (이전 글 : https://www.nextblog.me/parkmineum/299303e2-5222-8028-9949-d48dbc75cb8f )
⚠️ 결제 요청 중복 제어 — 멱등성과 DB 제약의 구조적 한계
멱등성은 이미 처리된 요청의 재처리를 방지하는 장치일 뿐, 동시에 유입되는 요청 간 경쟁 상태는 제어하지 못한다.
즉, 두 요청이 거의 동시에 들어오면 두 스레드 모두 “아직 처리되지 않음”으로 판단하고 동일 로직을 수행하게 될 수 있다.

멱등 체크는 조회 기반 검증이기 때문에, 동시 요청 상황에서는 두 스레드 모두 정상 흐름으로 진입하는 구조적 한계가 있다.
또한 UNIQUE 제약이나 DB 레벨 중복 방안 역시 근본적인 해결책이 될 수 없다. 두 요청이 이미 외부 PG API 호출까지 완료한 이후라면, DB에서 제약 조건으로 한 건만 반영되더라도 실제 결제는 이미 중복으로 발생한 상태가 된다.
따라서, DB 레벨의 장치는 데이터 정합성은 맞출 수 있지만, 중복 결제는 보장하지 못한다.
⚠️ PG 호출은 트랜잭션으로 보호할 수 없다.
결제 승인 과정에서 PG 호출은 DB 트랜잭션과 독립적으로 동작한다. 따라서, 두 작업을 하나의 트랜잭션으로 묶어 원자성을 보장하는 것은 구조적으로 불가능하다.
또한 PG API는 네트워크 지연, 타임아웃, 응답 지연, 장애 등 외부 불확실성을 갖는다.
이 상태에서 트랜잭션을 유지하면, 외부 응답 시간만큼 DB 자원이 종속되며 문제가 발생한다
- 트랜잭션 장기화 → 락 점유 증가 및 데드락 위험
- Undo 로그 및 MVCC 증가 → DB 부하 상승
- 커넥션 점유 지속 → 커넥션 풀 고갈
즉, 외부 API 대기 시간이 그대로 DB 자원 점유로 이어지는 구조적 병목이 된다.
중복 결제 대응 방안 — MySQL 분산락
구분 | 비관적 락 | 낙관적 락 | 분산 락 |
보호 범위 | DB 레코드 | DB 레코드 (버전) | 비즈니스 로직 전체 |
동작 시점 | 트랜잭션 중 | 커밋 시점 | 진입 시점 |
PG API 영향 | 직접 영향 (락 장기 점유) | 사후 검증 (이미 호출됨) | 없음 (사전 차단) |
비관적 락과 낙관적 락은 모두 DB 레코드 기준으로 동작한다.
즉, PG 호출 이전에 중복 실행을 차단할 수 없는 한계를 갖는다.
- 비관적 락: 트랜잭션 동안 락을 유지하여, PG 지연이 그대로 DB 락 점유 시간 증가로 이어짐
- 낙관적 락: 커밋 시점에 충돌을 검증하므로, 이미 외부 결제가 실행된 이후에야 충돌이 드러남
이를 해결하기 위해, 결제 임계구역을 제어하는 분산락을 선택했다. 동일 주문에 대해 락을 획득한 요청만 결제 로직에 진입하여, 나머지는 즉시 차단(Fail-fast)된다.

이는 다음과 같은 장점을 갖는다.
- 비즈니스 키 기반으로 동작하므로, 분산 환경에서도 전역 단일 실행 보장
- GET_LOCK(key, timeout)을 통해 대기 시간·실패 기준을 명시적으로 제어 가능
- MySQL 내부에서 락 획득이 원자적으로 처리되어, 동시 요청도 단일 흐름으로 직렬화
MySQL 분산락이 아닌 다른 대안은? 상태 머신, Redis 분산락
MySQL 분산락이 아닌 다른 대안은? — 상태 머신
(유니크 제약 & Native Query 기반 Upsert 같은 방식도 존재하지만, 대부분 PG 응답이 온 이후의 데이터 정합성 보장에 대한 것들이다.)
1. 상태 머신 기반 (State Transition)
- 결제 시작 시 주문 상태를
PROCESSING으로 원자적(UPDATE SET)으로 바꾸고, 이미PROCESSING/APPROVED면 후속 요청을 차단하는 방식
- 즉, 상태 선점에 성공한 요청만 PG 호출을 수행
별도 락 시스템 없이도 어느 정도 중복 진입을 제어할 수 있으며, 흐름도 명확하여 비즈니스 모델과 자연스럽게 결합된다. 또한 재처리, 타임아웃 복구 등의 확장에도 유리해 가장 우선적으로 고려할 수 있는 대안이다.
다만, 상태 의존도가 커지는만큼 상태에 대한 해석과 기준이 명확해야한다.
PROCESSING싱태가 얼마나 오래 지속되어야 비정상으로 볼지
- PG timeout 후 실제 승인 여부를 어떻게 판별할지 등등
이에 따라, 상태 머신을 1차 제어 수단으로 활용하되, 동일 주문에 대한 동시 진입 자체를 보다 확실히 차단하기 위해 분산락을 보조 안전장치로 함께 적용하였다.
2. Redis 분산락(SETNX, Redisson)
- Redis에 key를 잡아 임계영역 진입을 제어하는 방식
- DB 커넥션을 점유하지 않고, 락 획득/해제가 빠르다.
그러나, TTL 기반이기에 지연으로 인해 락이 만료되는 것을 방지하기 위해, 별도의 Watchdog 메커니즘이나 동적 TTL 설정이 필요하기에, 구현 복잡도가 증가한다.
또한, Outbox 패턴을 통해 결제 상태를 기록하고 있다는 점에서, 외부 시스템인 Redis에 대해 원자적으로 묶을 수 없다. 장애 상황에서는 두 시스템 간 일관성 불일치 가능성이 존재한다는 점을 고려해야 한다.
⚠️ MySQL 분산락의 트레이드오프 및 한계 — 락 유지와 커넥션 점유
MySQL 분산락은 DB 커넥션(세션) 단위로 동작한다. 즉, 락을 유지하려면 GET_LOCK을 획득한 커넥션을 계속 점유해야 한다.
문제는 결제 승인 흐름에서 다음의 두 요구사항이 동시에 존재한다는 점이다.
- 결제 승인 전체 구간(PG API 호출 포함)을 하나의 임계영역으로 보호해야 함
- PG 호출 동안에는 DB 커넥션을 점유하지 않아야 함
문제는 이 두 조건이 동시에 성립할 수 없다는 점이다.
PG 호출 구간에는 커넥션 반환 후, 이후 같은 key로 다시 재획득하면 되지 않을까?
실제로 같은 Key(order:1)로 락을 다시 획득하는 것 자체는 가능하지만, 같은 락을 이어서 유지하는 것이 아닌, 새로 다시 획득하는 것이다.
- 서버 A가 GET_LOCK(order-1) 획득
- 결제 상태 변경 (PREPARED -> PROCESSING)
- 외부 PG API 호출 전에 커넥션 반환 → 락 해제
- 서버 B가 먼저 GET_LOCK(order-1) 획득 ⇒ 서버 B도 같은 주문 처리 진입
이 과정에서 다른 서버/스레드가 같은 주문(Key)에 대해 락을 먼저 획득한다면, 정합성이 깨진다.
💥 추가 제약 — 트랜잭션 바인딩
namedLockRepository.getLock(lockName, timeout); namedLockRepository.releaseLock(lockName);
getLock()은 @Transactional에 의해 현재 트랜잭션에 바인딩된 커넥션에서 실행된다. 즉, 락을 획득한 커넥션을 직접 제어할 수 없고, 트랜잭션이 끝날 때까지 반환 불가능하다.
releaseLock() 역시 같은 트랜잭션, 같은 커넥션을 그대로 사용한다. 결론적으로, 락 쿼리 + 비즈니스 로직 + 트랜잭션이 하나의 커넥션에 묶이는 구조적 한계가 존재한다.
해결 방안 — 커넥션은 점유하되, 영향 범위 최소화
기존 구조에서는 락 획득/해제와 비즈니스 로직이 하나의 DataSource(HikariCP 풀)을 공유하고 있었기에, 락과 관련 지연이 비즈니스 트랜잭션 전체로 전파되는 구조였다.
이를 해결하기 위해, 락의 생명주기와 비즈니스 트랜잭션의 생명주기를 분리하였다.
- 락은 하나의 커넥션에서 끝까지 유지
- 비즈니스 트랜잭션은 별도의 커넥션으로 짧게 분리
핵심은 커넥션 점유는 불가피하다는 것이고, 대신 그 영향을 구조적으로 분리하기 위해 커넥션 풀 분리를 결정하였다.
0. 테스트 및 커넥션 풀 사이즈 산정
커넥션 풀 분리 이후, 각 풀의 적절한 사이즈를 결정하기 위해, 사전 부하 테스트를 통해 커넥션 사용 패턴을 분석하였다.
[사전 테스트] 커넥션 점유로 인한 병목이 비즈니스 처리로 전파되지 않음을 확인
유의미한 차이를 보이기 위해
RPS = 25로 약 125개(25 RPS × 5s) 수준의 동시 커넥션 점유를 발생시켰다.

- 활성 커넥션 지표
- 기존(공유 풀) : 동일 풀을 공유하며, 전체 커넥션 사용량 급증
- 분리 이후 : 락 커넥션은 별도 풀에서 유지되고, 비즈니스 커넥션은 짧게 사용 후 반환되어 안정적으로 유지
- 대기 스레드 지표
- 기존(공유 풀) : 커넥션 부족으로 비즈니스 대기 스레드 급증 (최대 100+), 요청 병목 발생
- 분리 이후 : 비즈니스 대기 스레드 0 유지, 락 대기는 존재하지만 비즈니스 처리에는 영향 없음

- 커넥션 획득 대기 시간
- 기존(공유 풀) : 커넥션 획득까지 최대 20~30초 대기 발생
- 분리 이후 : 비즈니스 커넥션 대기 시간 거의 0 수준 유지
- 커넥션 실사용 점유 시간
- 기존(공유 풀) : 비즈니스 커넥션이 외부 API 호출 동안 장시간 점유
- 분리 이후 : 비즈니스 커넥션은 짧게 사용 후 즉시 반환, 락 커넥션만 별도 점유
사전 테스트를 통해, 커넥션 풀 분리 구조에서 PG 지연이 비즈니스 처리 전반으로 전파되지 않음을 확인하였다.
이후, 운영 환경에 가까운 조건에서 풀 사이즈를 검증하기 위해 결제 RPS 5 / 조회 RPS 20로 부하를 설정하였다.
-
max_connections(151)의 약 80% 수준인 120개를 전체 커넥션 풀 상한으로 설정
- 결제 (락 전용 풀 — 20)
- 계산: 5 RPS × 5초 = 약 25개의 동시 커넥션 요구
- 현상: 풀 사이즈(20)를 초과한 일부 요청은 대기 큐에 머무르지만, 해당 대기는 락 전용 풀 내부에서만 발생하며 비즈니스 처리에는 영향 없음
- 조회 (비즈니스 풀 — 100)
- 계산: 20 RPS × 0.1초 = 약 2개의 커넥션으로 충분
- 현상: 충분한 풀 사이즈를 확보하여, 결제 부하 상황에서도, 조회 요청은 지연 없이 안정적으로 처리

- 활성 커넥션 지표
- 비즈니스 커넥션 : 비즈니스 커넥션은 약 20~24 수준에서 안정적으로 유지
- 락 커넥션 : 락 커넥션은 설정한 20개에서 고정 (포화 상태 유지)
- 대기 스레드 지표
- 비즈니스 대기 스레드 : 0 유지
- 락 대기 스레드 : 최대 약 11개 발생
⇒ 락 부하/대기는 락 풀 내부에서만 처리되고, 비즈니스 커넥션 사용량은 안정적으로 유지됨

- 활성 커넥션 지표
- 비즈니스 커넥션 : 대기 시간 0
- 락 커넥션 :최대 약 4.5초 대기 발생
- 대기 스레드 지표
- 비즈니스 커넥션 : 짧은 트랜잭션만 수행 → 짧은 점유 후 반환
- 락 커넥션 : 외부 API 대기 동안 약 5초 지속 점유
⇒ 짧은 작업(비즈니스)과 장기 점유(락)가 역할별로 명확히 분리됨
1. DataSource 커넥션 풀 분리
spring: datasource: # 비즈니스 트랜잭션용 풀 hikari.maximum-pool-size: 100 hikari.pool-name: HikariCP-Business datasource-lock: # 네임드 락 전용 풀 hikari.maximum-pool-size: 20 hikari.pool-name: HikariCP-Lock
- Business Pool: 비즈니스 트랜잭션 전용
- Lock Pool: 분산락 전용
위 테스트에서 확인했듯이, 락과 관련된 모든 작업은 Lock 전용 풀에서만 수행되도록 제한되여, 락 대기나 장시간 커넥션 점유가 비즈니스 DB 처리량에 영향을 주지 않는다.
2. JDBC 커넥션 직접 제어


Lock 전용 커넥션 풀에서
getConneciton()을 통해 커넥션 직접 획득하게 하여, 비즈니스 트랜잭션과 분리된 물리적으로 독립된 커넥션을 사용하도록 하였다. 또한
businessLogic.get() 구간은 동일한 Key에 대해 락이 적용되므로, 동시 요청이 들어오더라도 자동으로 직렬화되어 실행된다. try-with-resources 구조를 통해서는, 예외 발생 시에도 커넥션이 반드시 반환되도록 보장하였고, 그에 따라 락 역시 안전하게 해제되도록 설계하였다.
구분 | JdbcTemplate 방식 | JDBC 직접 제어 방식 |
커넥션 획득 | @Transactional 을 통해 자동 할당 | dataSource.getConnection() 직접 호출 |
커넥션 반환 | 메서드 종료 시 자동 반환 | try-with-resources나 close()로 직접 반환 |
락 유지 | 트랜잭션 범위에 종속됨 | 트랜잭션과 별개로 원하는 만큼 유지 가능 |
JDBC를 직접 제어하는 방식은 기존의 @Transactional을 통해 자동으로 커넥션을 할당 받아오던 방식을 우회한 결정이다.
➕) 락 전용 커넥션 경량화 — 장기 점유 부담 최소화

락 전용 커넥션은 결제 임계구역 동안 유지되지만, 락 획득/해제와 같은 메타데이터 작업만 수행하고 실제 데이터 변경에는 관여하지 않는다.
이에 따라 불필요한 리소스 사용을 줄이기 위해 경량 커넥션으로 분리하여 운영하였다.
- readOnly(true) : 데이터 변경이 발생하지 않음을 명시하여, 읽기 전용 최적화 경로를 활용하고 오사용을 방지
- autoCommit(true) : 트랜잭션 시작을 제거하여 Undo 로그, MVCC Snapshot 생성 등 불필요한 트랜잭션 오버헤드 차단
결과적으로, 락 전용 커넥션은 최소한의 리소스만 점유하게 된다.
최종 구조 — 이중 커넥션 풀 기반 결제 프로세스
1. 개선 이전 (락과 트랜잭션이 동일 커넥션)
GET_LOCK → (트랜잭션 내부) 비즈니스 로직 → 외부 API 호출(5초) → commit → RELEASE_LOCK
2. 개선 이후

순서 | 처리 단계 | Lock Pool | Business Pool | 상태/비고 |
1 | GET_LOCK 획득 | [획득 후 유지] | - | 동일 주문번호의 중복 진입 차단 (세션 시작) |
2 | 결제 상태 변경 (PREPARED → PROCESSING) | [점유 중] | ✔ | DB 커밋 즉시 반환 (커넥션 점유 최소화) |
3 | PG API 호출 | [점유 중] | - | DB 미사용. 네트워크 지연 발생 시에도 비즈니스 풀은 안전함 |
4 | 결제 결과 반영 (PROCESSING → APPROVED) | [점유 중] | ✔ | 최종 결과 DB 기록 후 즉시 반환 |
5 | RELEASE_LOCK 해제 | [해제 및 반환] | - | 락 세션 종료 및 Lock Pool로 커넥션 복귀 |
락은 Lock Pool의 커넥션이 지속적으로 유지, 비즈니스 트랜잭션은 짧고 독립적인 커넥션으로 각각 수행된다.
- PG 작업 구간은 지연·실패 가능성이 있는 구간에서는 커넥션을 점유하지 않는다.
- 결제 상태 변경 구간만 짧은 트랜잭션으로 원자적으로 처리한다.
즉, 트랜잭션 범위는 최소화하면서도, 결제 승인 전체 구간은 하나의 논리적 임계영역으로 보호할 수 있게 되었다.



