TL;DR
이커머스 시스템에서 주문이 발생하면 재고 차감과 포인트 차감이 동시에 일어납니다. 여러 사용자가 동시에 주문을 요청할 때 데이터 정합성을 보장하기 위해 비관적 락을 적용했지만, 예상치 못한 문제가 발생했습니다.
이 문제를 분석하고 해결한 과정을 정리했습니다.
동시성 제어 없이는 재고를 초과한 주문이나 마이너스 포인트가 발생할 수 있습니다.
주문 시스템에서 사용할 수 있는 동시성 제어 방식은 크게 두 가지입니다.
낙관적 락(Optimistic Lock)
비관적 락(Pessimistic Lock)
주문 시스템에서는 비관적 락을 선택했습니다.
선택 이유:
1. 재고와 포인트는 데이터 정합성이 매우 중요한 데이터
2. 잘못된 데이터로 인한 비즈니스 손해가 심각할 수 있음
3. 성능 문제는 재고와 포인트를 상품/유저와 분리하여 완화
10포인트를 가진 사용자가 4원짜리 상품(재고 10개)을 10번 동시에 주문합니다.
예상 결과

실패 건수: 0
남은 재고: 0개
남은 포인트: 2원
모든 주문이 성공했습니다. 재고는 정상적으로 차감되었지만, 포인트는 10건 모두 정상 실행되었는데도 오류가 발생하지 않았습니다.
로그를 확인한 결과:
10 → 6 업데이트 후에도 계속 10포인트로 조회됨6 → 2로만 조회되어 오류 없이 완료쿼리문에 비관적 락은 제대로 동작하고 있지만 조회 포인트가 업데이트 되지 않고 있었습니다.
문제를 이해하기 위해 먼저 사용하고 있는 MySQL의 동작 방식을 알아봤습니다.
MySQL InnoDB의 기본 격리 수준은 Repeatable Read이며, MVCC(Multi-Version Concurrency Control)로 동시성을 제어합니다.
MVCC의 핵심 개념:
여기서 중요한 포인트가 있습니다.
SELECT FOR UPDATE는 MVCC 스냅샷을 무시하고 항상 최신 커밋된 데이터에 락을 겁니다.
즉, Repeatable Read 격리 수준이더라도 SELECT FOR UPDATE, UPDATE, DELETE 같은 쓰기 락 작업은 마치 Read Committed처럼 동작합니다.
그렇다면 SELECT FOR UPDATE도 최신 데이터를 조회해야 정상인데, 왜 오래된 데이터가 조회되었을까요?
Optional<User> user = userRepository.findById(command.userId());
포인트를 조회하는 곳에서 User.getPoint()를 직접 호출하는 곳은 없었습니다. 유일하게 Point와 연관되는 곳은 주문 생성 전 해당 객체들의 존재 여부를 확인하기 위해 조회되던 userRepository.findById 밖에 없었습니다.
User 객체가 실제로 필요한 곳이 없어, 해당 구문을 주석 처리하고 테스트를 실행했습니다.

테스트가 성공했습니다!
userRepository.findById로 조회된 user를 사용하지 않고 조회 여부만으로 테스트의 결과가 달라지고 있었습니다. 조회만으로 1차 캐시가 생성되고, 이후 Point 조회 시 SELECT FOR UPDATE 값보다 1차 캐시의 값이 우선적으로 리턴되고 있는 상황이었습니다.
JPA의 영속성 컨텍스트는 엔티티를 조회하면 1차 캐시에 저장합니다. 그리고 동일 트랜잭션 내에서 같은 엔티티를 다시 조회하면 DB를 거치지 않고 1차 캐시의 값을 반환합니다.
여기서 핵심은 "DB를 거치지 않는다"는 점입니다.
앞서 설명한 MySQL의 MVCC와 SELECT FOR UPDATE는 데이터베이스 레벨의 이야기입니다. 반면 JPA 1차 캐시는 애플리케이션 레벨의 이야기입니다.
문제의 핵심:
1. 🔄 userRepository.findById() 호출 → User 엔티티가 1차 캐시에 저장됨
2. 🚫 이후 SELECT FOR UPDATE로 Point를 조회하려 해도 → DB 쿼리가 아예 실행되지 않음
3. ⚠️ DB에서 최신 버전을 읽을 기회 자체가 없어짐
4. ❌ 결과: 1차 캐시에 있는 "오래된 값"이 그대로 반환됨
즉, MySQL의 SELECT FOR UPDATE가 아무리 최신 데이터를 가져오려 해도, JPA가 DB 조회 자체를 차단하기 때문에 무용지물이 되는 상황입니다.
// Optional<User> user = userRepository.findById(command.userId()); // 주석 처리
이 한 줄을 주석 처리했더니 테스트가 통과했습니다. 그 이유는:
findById()로 User를 조회 → Point도 함께 1차 캐시에 저장 → 이후 SELECT FOR UPDATE가 DB에 가지 않음SELECT FOR UPDATE가 실제로 DB에서 최신 데이터를 가져옴결국 불필요한 User 조회가 1차 캐시를 오염시켜, 비관적 락의 동작을 무력화시킨 것입니다.
여기서 의문이 생깁니다. userRepository.findById()로 User만 조회했는데, 왜 Point까지 1차 캐시에 저장되었을까요?
User와 Point는 @OneToOne 관계로 연결되어 있고, fetch=LAZY로 설정되어 있습니다. LAZY 로딩이라면 Point는 실제로 사용되는 시점에만 조회되어야 합니다. 즉, user.getPoint()를 명시적으로 호출하지 않는 한 Point는 1차 캐시에 들어가지 않아야 정상입니다.
그런데 실제로는 Point도 함께 조회되어 1차 캐시에 저장되고 있었습니다. 왜 그럴까요?
의심이 든 부분을 검증하기 위해 테스트 코드를 작성했습니다. findById(User) 시, Point 객체가 LAZY/EAGER 중 어떤 방식으로 조회되었는지 확인하는 테스트입니다.

분명 fetch=LAZY로 설정했는데, Hibernate가 이를 무시하고 EAGER로 동작하고 있었습니다.
@OneToOne 관계에서 LAZY 로딩을 명시하더라도, Hibernate는 EAGER 로딩을 강제하는 경우가 많습니다.
프록시 생성의 난제: @OneToOne 관계에서 LAZY 로딩을 구현하려면 프록시(Proxy) 객체를 사용해야 합니다. 하지만 @OneToOne은 특별한 문제가 있습니다.
예를 들어, User를 조회할 때 Hibernate는 "이 User에게 연관된 Point가 있는가?"를 알아야 프록시를 만들지 null을 반환할지 결정할 수 있습니다. 하지만 이를 알려면 어차피 Point 테이블을 조회해야 합니다.
결과적으로:
특히 양방향 관계에서, 그리고 연관 필드가 Not-Null인 경우 이런 현상이 자주 발생합니다.
이제 모든 퍼즐이 맞춰졌습니다:
userRepository.findById() 호출@OneToOne 양방향 관계 + Hibernate의 내부 최적화 → Point도 함께 EAGER 로딩SELECT FOR UPDATE로 Point를 조회하려 시도@OneToOne(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY, optional = true)에서 optional 제거FetchType.LAZY를 명시적으로 지정 (방어적 코딩)결과: 두 방법 모두 실패 ❌
현재 상태에서 가장 수정이 적은 방법들로도 해결되지 않아, 양방향 관계를 단방향으로 변경하기로 결정했습니다.
포인트를 유저 안에 넣을지, 유저와 포인트를 분리할지 설계 시점에서 정확하게 결정하지 못한 것이 근본 원인이었습니다. 중간에 방향을 바꾸려다 보니 불완전한 구조가 되었고, 이것이 예상치 못한 버그로 이어졌습니다.
최종 결정: User와 Point를 명확하게 분리하는 단방향 관계로 재설계
SELECT FOR UPDATE보다 1차 캐시가 먼저 동작한다는 것을 경험으로 배웠습니다발생한 문제를 해결하면서, Repeatable Read에 대해 알아보고, 각 DB별 기본 트랜잭션 격리에 대해서 학습하니 각 특성에 대해 더 잘 이해할 수 있었습니다.