지갑 잔액 차감 동시성 제어 및 락(Lock) 실험

2026. 7. 10. 04:22·DB

실험 조건 및 가설

  • 테스트 대상 도메인: 토스 실시간 간편 결제 플랫폼 시나리오 (지갑 잔액 차감 및 결제 이력 적재)
  • 실험 엔티티 구조: 하나의 트랜잭션 내에서 Wallet WalletOptimistic 잔액 차감(UPDATE) 후, PaymentHistory 내역 생성(INSERT) 수행
  • 최초 지갑 잔액: 10,000원
  • 동시 요청 수: 100개 (32개 고정 스레드 풀 FixedThreadPool 활용)
  • 회당 차감 금액: 100원 (총 10,000원 차감 시도)

본 실험에서 검증하고자 하는 '데이터 정합성(Data Integrity)'은 단순히 "모든 요청이 에러 없이 성공하여 잔액이 0원이 되었는가"가 아닙니다.
대규모 동시성 트래픽 속에서 락으로 인해 실패할지언정, 사용자가 확인한 성공 결과(결제 내역)와 실제 자산 금고(지갑 잔액)의 싱크가 단 1원의 오차도 없이 원자적으로 일치하는가를 본질적인 합격 기준으로 정의합니다.

  • 데이터 정합성 검증 공식:

{실제 차감된 총 금액 (초기 잔액 - 최종 잔액)} = {적재된 결제 내역 수(historyCount)} * 100 {원}


Case 1: No Lock (아무런 제어 장치가 없는 동시성 요청)

  • 락이 없는 경우 동시성 환경에서의 주문 내역과, 실제 차감된 돈의 정합성이 맞지 않는 문제가 발생한다
  • No Lock 정합성 테스트 결과 생성된 총 주문 내역 수: 100건 최종 남은 DB 실제 잔액: 8900원 지갑에서 실제로 차감된 총 금액: 1100원
  • 애플리케이션 계층에서는 100건의 주문이 모두 정상 처리(200 OK)되어 결제 내역이 생성되었으나, 실제 DB 자산은 고작 1,100원(11회 분량)만 차감되었습니다. 결과적으로 8,900원의 금액 오차(자산 손실)를 입으며 시스템의 무결성을 지키지 못함

원인 분석

  • 레이스 컨디션 진입:
    • Thread A와 Thread B가 거의 동일한 밀리초(ms) 단위로 DB에서 동일 유저의 지갑 잔액(10,000원)을 읽어옵니다. 이 시점에 두 스레드가 자바 메모리상에 보유한 데이터는 둘 다 10,000원으로 동일합니다.
  • 덮어쓰기 발생:
    • 두 스레드는 각자 독립적으로 10,000원 - 100원 = 9,900원이라는 차감 연산을 수행합니다. 이후 Thread A가 먼저 DB에 9,900원을 반영하고 커밋하지만, 곧바로 뒤이어 도착한 Thread B가 A가 업데이트한 사실을 모른 채 자신이 계산한 9,900원을 DB에 그대로 덮어써 버립니다.
  • 결론:
    • 비즈니스적으로는 분명히 2번의 차감 요청이 수행되어 잔액이 9,800원이 되어야 하지만, 앞선 트랜잭션의 연산 결과가 유실(Lost Update)되면서 돈이 100원만 깎이는 현상이 100번의 요청 동안 무한히 반복된 것입니다.

Case 2 : 낙관적 락

  • 낙관적 락을 사용하는 경우 동시성 환경에서의 주문 내역과 실제 차감된 돈의 정합성이 맞는다
  • 낙관적 락 정합성 테스트 결과 생성된 총 주문 내역 수: 15건 최종 남은 DB 실제 잔액: 8500원 지갑에서 실제로 차감된 총 금액: 1500원 낙관적 락 실패 횟수: 85번
  • 100개의 동시 요청 중 85건의 요청은 버전 충돌로 인해 실패 처리되었습니다. 최종적으로 오직 15건의 주문만 정상 완료되었으며, 지갑 잔액 또한 정확히 1,500원만 차감되었습니다.
    [성공한 주문 수(15건) × 100원 = 실제 차감액(1,500원)] 공식이 완벽히 성립하여 자산 정합성이 완벽하게 보장됨을 증명합니다.

원인 분석

  • 버전 관리를 통한 동시성 제어: WalletOptimistic 엔티티는 @Version 필드를 통해 낙관적 락을 수행합니다.
  • 레이스 컨디션에서의 충돌: 동시성 환경에서 여러 스레드가 거의 동시에 DB를 조회(SELECT)하면, 스레드들이 읽어온 객체의 version 초기값은 모두 동일(예: version = 0)할 수 있게 됩니다.
  • 조건절 불일치로 인한 예외 발생:
    • 각 스레드가 연산을 마치고 UPDATE 쿼리를 날릴 때, JPA는 내부적으로 WHERE id = ? AND version = ? 조건과 함께 버전을 1 증가시키는 쿼리를 수행합니다.
    • 가장 먼저 도착한 1등 스레드가 커밋을 성공시키는 순간, DB 내부의 version 컬럼은 1로 업데이트됩니다.
    • 뒤이어 도착한 나머지 99개의 스레드들은 여전히 WHERE version = 0 조건으로 수정을 시도하기 때문에, DB 엔진 내에서 조건이 일치하는 행을 찾지 못해 수정된 행의 수(Affected Rows)가 0건이 됩니다.
    • JPA는 이를 데이터 충돌로 인지하여 ObjectOptimisticLockingFailureException 예외를 던지고 트랜잭션을 롤백시킵니다.
  • 트랜잭션 원자성 사수:
    • 예외가 터지는 순간 스프링의 @Transactional 프록시 레이어는 해당 스레드의 트랜잭션을 통째로 롤백(Rollback) 시킵니다. 이 덕분에 지갑 잔액이 안 깎인 스레드는 아랫줄의 PaymentHistory 인서트 쿼리까지 함께 취소(유실)되어 주문 내역과 잔액의 싱크가 깨지지 않고 안전하게 보존되는 것입니다.

Case 3 : 비관적 락

  • 비관적 락을 사용하는 경우 동시성 요청이 밀려올 때 애플리케이션이 아닌 DB 레벨에서 트래픽을 대기 시키기 때문에,
    단 한 건의 실패(예외)도 없이 100건의 요청을 모두 성공시킵니다.
  • 비관적 락 정합성 테스트 결과 생성된 총 주문 내역 수: 100건 최종 남은 DB 실제 잔액: 0원 지갑에서 실제로 차감된 총 금액: 10000원
  • 낙관적 락과 달리 실패한 결제가 없습니다. 100개의 동시성 요청이 대기(Blocking)를 거쳐 모두 순차적으로 반영되었으며,
    그 결과 지갑 잔액은 정확히 0원이 되었고 주문 히스토리도 유실 없이 100건이 적재되었습니다.

원인 분석

  • FOR UPDATE를 통한 로우 레벨 락(Row-level Lock) 선점:
    • WalletPessimisticRepository에서 호출한 findByUserIdWithLock 메서드는 MySQL 엔진에 SELECT ... FOR UPDATE 쿼리를 날립니다.
      이 쿼리는 조회하는 동시에 해당 유저의 지갑 레코드(Row)에 다른 트랜잭션이 접근하지 못하도록 배타적 쓰기 락(X-Lock)을 걸어버립니다.
  • 스레드 블로킹(Blocking) 메커니즘:
    • 가장 먼저 도착한 1등 스레드가 락을 획득하고 트랜잭션을 시작하는 순간, 동시에 들이닥친 나머지 99개의 스레드는 예외가 발생하는 것이 아니라 1등 스레드가 연산을 마치고 커밋하여 락을 풀어줄 때까지 DB 문턱에서 대기(Blocking) 상태로 줄을 서게 됩니다.
  • 갱신 손실(Lost Update)의 원천 차단:
    • 2등 스레드는 1등 스레드가 10,000원 - 100원 = 9,900원으로 잔액을 완전히 업데이트하고 커밋한 이후의 상태를 바라보며 락을 이어받습니다.
      즉, 모든 스레드가 앞선 트랜잭션의 결과가 완전히 반영된 최신 잔액을 조회하여 차감 연산을 수행하므로, No Lock에서 발생했던 덮어쓰기(Lost Update) 현상이 원천적으로 불가능해집니다.
  • 안정적인 비즈니스 흐름 보장:
    • 트랜잭션이 실패하지 않고 순차적으로 통과하기 때문에, 지갑 차감 아랫줄에 있는 PaymentHistory 인서트문 또한 롤백 없이 100번 모두 안전하게 커밋됩니다.
      결과적으로 결제 내역과 지갑 자산이 완벽하게 1:1 매칭되는 무결한 데이터 상태가 완성됩니다.

알게된 점 (Lessons Learned)

  •  
    • 이 문제는 비관적 락 테스트를 진행할 때 마주진 예외이다, 이 예외는 @Lock(LockModeType.PESSIMISTIC_WRITE) 로 Repository 메서드에 락을 걸게 되는데
      이렇게 락이 걸린 메서드가 트렌젝션이 실행되지 않는 곳에서 호출했을 경우 나타나가 된다
    • @Lock(LockModeType.PESSIMISTIC_WRITE) 어노테이션이 부여된 리포지토리 메서드는 호출 시 MySQL 엔진에 SELECT ... FOR UPDATE 구문을 전송하여 쓰기 락(X-Lock)을 획득하게 된다.
      데이터베이스 메커니즘상 락의 수명 주기는 트랜잭션의 수명 주기와 같기 때문에(트랜잭션이 종료되어야 락이 해제됨). 따라서 락을 걸고 유지하려면 반드시 활성화된 트랜잭션@Transactional 범위 내부여야 한다.
      하지만 멀티스레드 비동기 연산이 끝난 후, 테스트 코드의 검증 레이어(메인 스레드)는 트랜잭션이 없는 맨바닥 상태였기 때문에 JPA 레이어에서 이를 부적절한 사용으로 판단하고 예외를 발생시킨 것이다.
  • org.springframework.dao.InvalidDataAccessApiUsageException : Query requires transaction be in progress, but no transaction is known to be in progress
  • 어노테이션을 사용한 낙관적 락, 비관적 락 적용 방법
    • 낙관적 락은 엔티티(Entity) 클래스의 특정 필드 위
    • 비관적 락은 리포지토리(Repository) 인터페이스의 메서드 위에 사용한다
  • 테스트 코드의 // when 부분에서 List<CompletableFuture<Void>> 생성 부분
    • 테스트 코드를 작성하면서 이 List<CompletableFuture<Void>> 부분을 보면 IntStream 을 통해 100개의 CompletableFuture를 생성해 실행 시키는것을 알 수 있다.
      왜 이렇게 생성하는지 보니 동시성 환경에서 최신 자바 동시성 테스트 코드에서는 이 형식을 표준처럼 아주 많이 사용한다고 한다.
    • 일반적인 for 반복문으로 CompletableFuture를 생성하고 반복문 외부에 선언된 List<CompletableFuture<Void>> 에 add 하는 코드와 동일한 동작을 하지만
      선언된 리스트가 밖에 있다보니 원자성과 불변성에서 코드상의 문제가 발생할 여지를 최소화 한다고 한다.
  • 표 정리
비교 항목 낙관적 락 (@Version) 비관적 락 (@Lock)
기본 전제 충돌이 거의 없을 것이다 충돌이 무조건 있을 것이다.
락의 주체 애플리케이션 (JPA/Hibernate) 데이터베이스 엔진 (MySQL FOR UPDATE)
트래픽 밀릴 때 롤백 및 재시도 비용 급증 (성능 저하) 스레드 대기(Blocking)로 커넥션 풀 고갈 위험
데이터 무결성 충돌 시 에러 발생 (버전 불일치 예외) 원천 차단 (완벽한 무결성 보장)


낙관적 락은 가벼운 데이터, 낮은 충돌 확률일때 사용 비관적 락은 무거운 자산 데이터, 높은 충돌 확률일때 사용

만약 배달의민족, 쿠팡, 토스처럼 트래픽이 분당 수백만 건씩 몰리는 트래픽 환경이 되면 그때는 비관적 락도 쓰지 못합니다.

DB 커넥션 풀이 다 말라붙어서 서버가 터지게 되기 때문이다 그래서 실무에서는 DB를 잠그는 대신 Redis를 활용한 분산 락(Distributed Lock)을 쓰거나, Kafka 같은 메시지 큐(Message Queue)를 앞에 두어 트래픽 자체를 순차적으로 통과시키는 방식으로 고도화합니다.

 

https://github.com/oTAETAEo/db-playground/tree/main/src/main/java/com/taehyun/db_playground/mysql/lock/scenario01

 

db-playground/src/main/java/com/taehyun/db_playground/mysql/lock/scenario01 at main · oTAETAEo/db-playground

DB 사용시 발생할 수 있는 시나리오를 정의하고 학습하는 공간 입니다. Contribute to oTAETAEo/db-playground development by creating an account on GitHub.

github.com

 

'DB' 카테고리의 다른 글

MySQL 복합 인덱스로 빠른 조회 및 Using filesort 정렬 부하 해결하기  (0) 2026.07.09
'DB' 카테고리의 다른 글
  • MySQL 복합 인덱스로 빠른 조회 및 Using filesort 정렬 부하 해결하기
taetae-o
taetae-o
taetae-o 님의 블로그 입니다.
  • taetae-o
    taetae-o 님의 블로그
    taetae-o
  • 전체
    오늘
    어제
    • 분류 전체보기 (19)
      • HTTP (5)
      • 우테코 (4)
      • java (4)
      • ai (2)
      • spring boot (1)
      • 프로젝트 (1)
        • Tooplay (1)
      • DB (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Thread
    네이버
    대학생
    우테코
    벡엔드
    꿈
    Tomcat
    백엔드
    spring
    열심히
    프리코스
    HTTP
    개발자
    카카오
    java
    Spring Boot
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
taetae-o
지갑 잔액 차감 동시성 제어 및 락(Lock) 실험
상단으로

티스토리툴바