[TIL] 포인트 도메인의 동시성 제어와 데이터 정합성 보장 전략

김재진·2026년 3월 30일

내일배움캠프

목록 보기
61/70

1. 오늘의 목표

  • 다중 인스턴스 환경에서 커피숍 주문 시스템의 포인트 결제/충전 기능 구현
  • 다수의 요청이 동시에 발생할 때 나타나는 데이터 정합성(Lost Update) 문제 해결

2. 문제 상황 (Problem)

  • 동일한 사용자가 동시에 포인트를 충전하거나 여러 번 결제를 시도할 때,
    DB의 포인트 잔액(balance)을 읽고 업데이트하는 과정에서 Race Condition(경쟁 상태) 이 발생할 수 있다.
    이로 인해 포인트 차감이나 충전 내역이 누락되어 치명적인 데이터 불일치가 발생할 위험이 있다.
[Thread A] balance 읽기 → 1000점
[Thread B] balance 읽기 → 1000점   ← 아직 A의 차감이 반영되지 않음
[Thread A] 500점 차감 → 500점 저장
[Thread B] 300점 차감 → 700점 저장 ← A의 차감이 덮어써짐 (Lost Update!)
실제 잔액: 200점이어야 하지만 → 700점으로 저장됨

3. 해결 방안: 어떤 락(Lock)을 선택할 것인가?

정합성 보장을 위해 낙관적 락, 비관적 락, 분산 락을 비교 분석했다.

3-1. 낙관적 락 (Optimistic Lock)

  • @Version 필드를 통해 충돌을 감지하고, 충돌 시 애플리케이션 단에서 재시도하는 방식
  • 장점 : DB 단의 락이 없어 읽기 성능이 매우 빠름, 충돌이 적은 환경에서 효율적
  • 단점 : 충돌 발생 시 애플리케이션 단에서 재시도 로직을 직접 구현해야 함, 잦은 충돌 시 재시도로 인해 오히려 성능 저하

3-2. 비관적 락 (Pessimistic Lock)

  • DB 트랜잭션 시작 시점에 배타락(X-Lock) 을 걸어 데이터 정합성을 보장한다.
  • 장점 : 충돌을 원천 차단하여 데이터 정합성 완벽 보장, 재시도 로직이 필요 없어 구현이 비교적 단순함
  • 단점 : 락 점유 기간이 길어지면 DB 커넥션 풀이 마르고 전체 시스템 성능 감소 (데드락 위험), 락을 쥔 상태로 외부 API 등 무거운 작업을 수행하면 치명적

3-3. 분산 락 (Redis Lock)

  • DB 외부(Redis 등)에 락 정보를 보관하여 여러 서버가 락 상태를 공유
  • 장점 : 멀티 인스턴스 환경에서 DB에 가해지는 락 부하를 최소화, 인프라 레벨에서 스케일 아웃에 유리함
  • 단점 : Redis 등 추가 인프라 구축 및 관리 비용 발생, 락 만료 시간(TTL) 설정 등 예외 처리가 까다로움

해결 방안 > 비관적 락 혹은 분산 락

  • 이유 : Point 도메인의 특성 상 결제를 처리하게 되는데 결제에 있어서 정합성 보장을 최우선으로 생각했다. 동일 사용자의 동일 요청이 집중되는 구조일 경우 충돌이 많아 질 수 밖에 없다고 생각이 들었고 이 상황에서 낙관적 락을 사용할 경우 오리혀 성능의 저하가 있을 수 있다고 생각이 들어 비관적 락 혹은 분산 락을 선택하였다. 하지만 비관적 락 및 분산 락의 경우에도 발생할 수 있는 문제가 있어 다음과 같이 문제를 해결해야 한다.

4. 딥 다이브& 트러블 슈팅

이슈 1. 비관적 락과 외부 API 호출의 결합으로 인한 성능 저하

상황
요구사항에 따라 결제 완료 후 주문 내역을 데이터 수집 플랫폼(외부 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) 도입을
향후 아키텍처 개선 방향으로 검토할 필요가 있다.

이슈 2. 분산 락(Redis Lock) 도입 시 고려해야 할 잠재적 위험성

향후 트래픽 확장을 고려해 분산 락을 검토하면서 다음과 같은 한계점과 개선 방향을 정리했다.

문제점 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) { ... }

5. 마무리

  • 비관적 락으로 정합성을 보장하되, 외부 의존성(API)을 트랜잭션에서 분리하는 아키텍처적 고민을 할 수 있었다.
  • 분산 락 적용 시 앞서 나온 잠재적 위험성에 대해서 좀 더 분석을 하고 해결방안을 생각해서 도입에 신중해야 할 것 으로 생각된다.
profile
개발공부 처음해보는 사람

0개의 댓글