[Thread A] balance 읽기 → 1000점
[Thread B] balance 읽기 → 1000점 ← 아직 A의 차감이 반영되지 않음
[Thread A] 500점 차감 → 500점 저장
[Thread B] 300점 차감 → 700점 저장 ← A의 차감이 덮어써짐 (Lost Update!)
실제 잔액: 200점이어야 하지만 → 700점으로 저장됨
정합성 보장을 위해 낙관적 락, 비관적 락, 분산 락을 비교 분석했다.
상황
요구사항에 따라 결제 완료 후 주문 내역을 데이터 수집 플랫폼(외부 API)으로 실시간 전송해야 한다.
문제점
DB 락을 잡은 상태에서 외부 API를 호출하게 되면, 외부 서버의 응답이 지연될 경우
락 점유 시간이 비정상적으로 증가하여 커넥션 풀이 고갈되고 전체 성능이 심각하게 감소한다.
[트랜잭션 시작] → [X-Lock 획득] → [포인트 차감] → [외부 API 호출 ← 여기서 3초 지연!]
↑
이 동안 Lock이 유지됨
다른 요청은 전부 대기 상태
해결 방안
외부 API 호출 구간을 락이 걸린 메인 트랜잭션에서 완전히 분리한다.
Spring의 @TransactionalEventListener와 @Async를 활용하여,
락이 안전하게 해제된 이후 비동기로 외부 전송이 이루어지도록 구조를 개선한다.
// 이벤트 발행 (트랜잭션 내부)
applicationEventPublisher.publishEvent(new OrderCompletedEvent(orderId));
// 이벤트 리스너 (트랜잭션 커밋 이후 비동기 실행)
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendToDataPlatform(OrderCompletedEvent event) {
externalApiClient.send(event.getOrderId());
}
주의: AFTER_COMMIT 방식의 한계
AFTER_COMMIT 이후 이벤트는 트랜잭션 롤백과 무관하게 발행된다.
즉, 외부 API 전송이 실패하더라도 이미 커밋된 결제는 취소되지 않는다.
이 경우를 대비해 재시도 큐(Retry Queue) 또는 아웃박스 패턴(Outbox Pattern) 도입을
향후 아키텍처 개선 방향으로 검토할 필요가 있다.
향후 트래픽 확장을 고려해 분산 락을 검토하면서 다음과 같은 한계점과 개선 방향을 정리했다.
문제점 1 — TTL 설정의 위험성
락의 TTL이 너무 짧으면 (예: 3초), 트랜잭션이 끝나기 전에 락이 만료되어
중복 진입이 가능해지는 치명적인 버그가 발생할 수 있다.
단순히 TTL 숫자를 크게 잡는 것은, 서버 다운 시 락이 오래 물려있는 부작용이 생기므로 올바른 해결책이 아니다.
올바른 해결책: Redisson Watchdog 패턴
Redisson은 락 보유 중 TTL을 자동으로 갱신(renew) 해주는 Watchdog 기능을 제공한다.
로직 수행 시간이 예측 불가능하더라도 락이 조기 만료되지 않으며,
서버가 다운되면 Watchdog도 함께 종료되므로 락이 자동으로 만료된다.
단순 TTL 조정 대신 Redisson의 lock() (TTL 미지정) 방식을 사용하는 것이 안전하다.
문제점 2 — Key 생성 방식의 취약점
@RedisLock 적용 시 파라미터 순서(argIndex)를 기반으로 키를 생성하면,
추후 코드 유지보수 중 파라미터 순서가 바뀌었을 때 의도치 않은 락 키가 생성되어 락이 제대로 작동하지 않는 취약점이 생긴다.
// ❌ 위험: argIndex 기반 키 생성 → 파라미터 순서 변경 시 버그
@RedisLock(key = "point", argIndex = 0)
public void chargePoint(Long userId, int amount) { ... }
// ✅ 안전: SpEL(Spring Expression Language) 기반 명시적 키 생성
@RedisLock(key = "'point:' + #userId")
public void chargePoint(Long userId, int amount) { ... }