락(Lock)을 걸었는데 왜 오버부킹이 날까? — JPA 1차 캐시의 함정 탈출기
안녕하세요. 반려생활 개발팀에서 백엔드 개발을 하고 있는 박성환입니다.
락(Lock)을 걸었는데 왜 오버부킹이 날까? — JPA 1차 캐시의 함정 탈출기

안녕하세요. 반려생활 개발팀에서 백엔드 개발을 하고 있는 박성환입니다.
반려생활에는 “댕플스테이”라는 특별한 상품이 있습니다. “댕댕이”와 “템플스테이”를 합친 이름으로, 반려견과 보호자가 함께 사찰에서 머물며 휴식과 체험을 즐기는 프로그램입니다. 오직 반려생활 앱에서만 선착순으로 구매 가능한 소수 정예 프로그램이다 보니, 댕플스테이는 무엇보다 정확한 재고 처리가 중요했습니다.
저희는 선착순 구매 시 발생할 수 있는 동시성 문제를 막기 위해, 재고 차감 시점에 데이터베이스의 배타적 잠금인 PESSIMISTIC_WRITE 락(Lock)을 적용했습니다. 그런데 내부 테스트 결과는 예상과 전혀 달랐습니다. 남은 재고는 분명 1개 였는데 주문은 2건 모두 성공해 버린 것입니다. 더 기이한 점은 최종 재고가 '-1'이 아니라 '0'으로 남았다는 사실이었습니다.
DB 락이 제대로 동작하지 않은 걸까요?
결론부터 말씀드리면 DB 락은 정상적으로 걸리고 있었습니다. 진짜 문제는 '락을 걸고 조회한 엔티티가 DB의 최신 값으로 갱신되지 않았다'는 데 있었습니다. 트랜잭션 내부에서 이미 영속성 컨텍스트(1차 캐시)에 올라와 있던 과거의 엔티티 상태를 그대로 바라보고 있었던 것이죠.
이번 글에서는 PESSIMISTIC_WRITE로 DB의 행(Row)을 안전하게 보호하더라도, JPA 1차 캐시로 인해 애플리케이션이 과거의 데이터를 참조하게 되는 원인을 Hibernate 내부 코드 흐름을 따라가며 파헤쳐 보려 합니다.
완벽해 보였던 재고 차감 로직
먼저, 문제가 발생한 재고 도메인 코드를 살펴보겠습니다. (이해를 돕기 위해 코드는 단순화했습니다.)
남은 수량보다 큰 수량을 차감하려고 하면 즉시 예외(SoldOutException)를 던지고, 그렇지 않으면 정상적으로 수량을 차감하는 구조입니다.
@Entity
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private long availableQuantity;
public void deduct(long quantity) {
if (availableQuantity < quantity) {
throw new SoldOutException();
}
availableQuantity -= quantity;
}
}
그리고 이 엔티티를 활용하는 실제 결제 처리 흐름은 대략 다음과 같았습니다.
1. 장바구니 조회
-> 결제할 상품 정보를 가져온다.
-> 이 과정에서 연관된 재고 엔티티가 함께 영속성 컨텍스트에 올라온다.
2. 주문 변환
-> 장바구니 데이터를 바탕으로 주문 상태를 만든다.
3. 재고 조회 및 락 획득
-> 동시성 제어를 위해 PESSIMISTIC_WRITE 락을 걸고 재고를 조회한다.
4. 재고 차감
-> stock.deduct(...)를 호출한다.
운영 DB로 사용 중인 MySQL 로그를 확인해 보니, 3번 단계에서 실제로 SELECT … FOR UPDATE 쿼리가 정상적으로 호출되고 있었습니다. 이 쿼리는 DB의 행 단위 락(Row-level Lock)을 획득하므로, 여러 요청이 동시에 들어오더라도 락을 획득한 순서대로 안전하게 차감 로직이 수행될 것이라 판단했습니다.
하지만 진짜 함정은 1번 단계에 숨어 있었습니다.
결제 처리 로직이 시작되기 전, 장바구니를 조회하면서 이미 재고 엔티티가 영속성 컨텍스트(1차 캐시)에 로드되어 있었다는 점입니다. 이렇게 되면 이후 3번 단계에서 비관적 락을 걸고 DB를 다시 조회하더라도, 애플리케이션이 바라보는 객체의 상태는 우리의 예상과 다르게 흘러갈 수 있었습니다.
문제 재현: 락은 객체 상태를 보장하지 않는다
복잡한 비즈니스 로직을 걷어내고, 문제의 핵심만 짚어보기 위해 상황을 단순화해 보겠습니다.
- 트랜잭션 B가 먼저 재고를 조회하여 영속성 컨텍스트(1차 캐시)에 올립니다. (이 시점의 재고: 1)
- 그 사이 트랜잭션 A가 같은 재고에 비관적 락을 걸고 조회한 뒤, 수량을 0으로 차감하고 커밋합니다. (DB 반영 완료, DB 재고: 0)
- 이후 트랜잭션 B가 다시
PESSIMISTIC_WRITE락을 사용해 동일한 재고를 조회합니다.

과연 트랜잭션 B가 바라보는 재고 수량은 트랜잭션 A가 변경한 0일까요, 아니면 자신이 처음에 읽었던 1일까요? 다음 테스트 코드를 통해 확인해 보겠습니다.
@Test
@DisplayName("락만 다시 걸면 1차 캐시에 남아 있던 재고로 차감이 통과한다")
void findWithLockAllowsCachedStockDeductWithoutReload() {
Long stockId = tx(em -> {
Stock stock = new Stock(1);
em.persist(stock);
return stock.getId();
});
EntityManager emB = entityManagerFactory.createEntityManager();
EntityTransaction txB = emB.getTransaction();
txB.begin();
try {
// 시점 1: 트랜잭션 B가 재고를 1차 캐시에 올린다. 현재 재고는 1이다.
Stock stockInB = emB.find(Stock.class, stockId);
// 시점 2: 트랜잭션 A가 재고를 0으로 차감하고 커밋한다.
tx(emA -> {
Stock stockInA = emA.find(Stock.class, stockId, LockModeType.PESSIMISTIC_WRITE);
stockInA.deduct(1);
return null;
});
// 시점 3: 트랜잭션 B가 다시 PESSIMISTIC_WRITE 락으로 재고를 조회한다.
Stock lockedStockInB = emB.find(Stock.class, stockId, LockModeType.PESSIMISTIC_WRITE);
// 같은 객체가 반환되고, 수량은 여전히 1이다.
assertThat(lockedStockInB).isSameAs(stockInB);
assertThat(lockedStockInB.getAvailableQuantity()).isEqualTo(1);
assertThatCode(() -> lockedStockInB.deduct(1))
.doesNotThrowAnyException();
} finally {
txB.rollback();
emB.close();
}
}
놀랍게도 이 테스트는 성공합니다. 트랜잭션 A가 DB의 재고를 0으로 만들었음에도 불구하고, 트랜잭션 B는 여전히 과거의 데이터인 ‘재고 1’을 바라보고 있습니다. 그 결과, deduct(1)을 호출해도 품절 예외(SoldOutException)가 발생하지 않고 무사히(?) 통과해 버립니다. 두 번의 차감이 일어나는 치명적인 순간입니다.
여기서 주의 깊게 봐야 할 핵심은 PESSIMISTIC_WRITE 락 쿼리 자체는 DB에 정상적으로 전달되었다는 사실입니다.
실제로 DB 로그를 보면 SELECT ... FOR UPDATE 쿼리는 정상적으로 실행되며, DB 수준의 락도 안전하게 획득합니다. 하지만 JPA는 이미 영속성 컨텍스트에서 관리 중인 엔티티 객체가 있을 경우, DB에서 새로 읽어온 데이터로 기존 객체의 필드 값을 자동으로 덮어쓰지 않습니다. 즉, DB 락은 획득했지만 애플리케이션 메모리 상의 객체는 갱신되지 않은 채 과거 상태에 머물러 있었던 것입니다.
Hibernate 1차 캐시의 핵심: entitiesByKey
락을 걸고 DB에서 최신 데이터를 읽어왔음에도 객체 상태가 갱신되지 않은 이유는 Hibernate가 1차 캐시(영속성 컨텍스트)를 관리하는 방식에 있습니다.
Hibernate에서 1차 캐시는 StatefulPersistenceContext라는 클래스가 담당하여 관리합니다. 그리고 이 클래스 내부에서 영속 상태의 엔티티들을 보관하는 핵심 자료구조는 다음과 같은 해시맵(HashMap)입니다.
// org.hibernate.engine.internal.StatefulPersistenceContext.java#entitiesByKey
private HashMap<EntityKey, EntityHolderImpl> entitiesByKey;
여기서 맵의 키(Key)로 사용되는 EntityKey는 '엔티티 타입'과 '식별자(PK)'의 조합으로 생성됩니다.
EntityKey = 엔티티 타입 + 식별자
예를 들어, 앞선 테스트 코드에서 Stock 엔티티의 id가 1이었다면, Hibernate는 Stock 타입과 식별자 1을 조합한 키를 사용해 entitiesByKey 맵을 뒤져보는 식입니다.
바로 이 구조 때문에, 같은 트랜잭션 안에서는 동일한 식별자를 가진 엔티티를 여러 번 조회하더라도 매번 DB 결과를 새로 객체로 만드는 대신 캐시에 이미 존재하는 같은 객체 인스턴스가 반환됩니다.
이것이 바로 JPA가 강력하게 보장하는 ‘애플리케이션 수준의 동일성(Identity)'입니다. 하지만 역설적이게도 이 강력한 동일성 보장 원칙이, 비관적 락 상황에서는 최신 데이터로의 갱신을 가로막는 원인이 되어버린 것입니다.
ResultSet을 엔티티로 조립하는 과정
DB에서 읽어온 ResultSet의 row 데이터를 자바 엔티티 객체로 해석하고 조립하는 과정은 Hibernate 내부의 EntityInitializerImpl 클래스가 담당합니다.
이 클래스의 resolveInstance(...) 메서드를 살펴보면, 새 객체를 생성하기 전 PersistenceContext(1차 캐시)에 앞서 설명한 EntityKey를 가진 엔티티가 이미 존재하는지 먼저 확인하는 것을 알 수 있습니다.
// org.hibernate.sql.results.graph.entity.internal.EntityInitializerImpl.java#resolveInstance
// ... 생략
final PersistenceContext persistenceContext = rowProcessingState.getSession()
.getPersistenceContextInternal();
data.entityHolder = persistenceContext.claimEntityHolderIfPossible(
data.entityKey,
null,
rowProcessingState.getJdbcValuesSourceProcessingState(),
this
);
// ... 생략
캐시 확인 결과, 이미 영속성 컨텍스트에서 관리 중인 엔티티 인스턴스가 존재한다면 어떻게 될까요? Hibernate는 DB에서 가져온 데이터로 새 객체를 만드는 대신, 기존에 캐시되어 있던 객체를 결과로 재사용합니다.
// org.hibernate.sql.results.graph.entity.internal.EntityInitializerImpl.java#resolveEntityInstance1
// ... 생략
final Object existingEntity = data.entityHolder.getEntity();
if (existingEntity != null) {
data.setInstance(data.entityInstanceForNotify = existingEntity);
}
// ... 생략
이 짧은 조건문(if (existingEntity != null))이 바로 이번 댕플스테이 재고 동시성 문제의 원인이었습니다.
PESSIMISTIC_WRITE 락을 걸기 위해 실행된 SELECT ... FOR UPDATE 쿼리는 분명 DB에 전달되었고, DB 수준에서 행(Row) 락도 정상적으로 획득했습니다. 하지만 DB에서 반환된 최신 데이터의 ResultSet을 엔티티로 변환하는 마지막 단계에서, 1차 캐시에 이미 등록되어 있던 '과거 상태의 인스턴스'가 튀어나온 것입니다.
결과적으로 락은 DB에 걸렸지만, 애플리케이션이 바라보는 엔티티 객체의 필드 값은 과거에 머물러 있게 됩니다.
이 상황을 요약하면 다음과 같습니다.
PESSIMISTIC_WRITE
-> DB row lock은 획득한다.
-> 하지만 이미 1차 캐시에 있는 엔티티의 필드 값을 자동으로 갱신하지는 않는다.
1차 캐시에 같은 EntityKey가 있다면
-> SQL은 실행된다.
-> ResultSet 처리 과정에서 기존 관리 인스턴스가 재사용된다.
-> 애플리케이션은 오래된 상태의 객체를 그대로 바라볼 수 있다.
결국 이번 트러블슈팅의 핵심은 ”락 쿼리가 DB에 나갔는가?”가 아니었습니다. 쿼리는 우리가 의도한 대로 완벽하게 나갔습니다. 진짜 던져야 했던 질문은 ”그 쿼리의 결과가 애플리케이션 메모리에 이미 올라와 있던 엔티티의 상태를 최신화했는가?”였고, Hibernate의 1차 캐시 메커니즘으로 인해 그 대답은 ‘아니오’였던 것입니다.
해결책: refresh로 1차 캐시 상태를 강제 갱신하기
이미 1차 캐시에 올라와 있는 엔티티의 상태를 DB의 최신 값으로 덮어쓰려면 EntityManager.refresh(...) 메서드를 사용해야 합니다.
em.refresh(entity, LockModeType.PESSIMISTIC_WRITE)를 호출하면, 기존 엔티티 인스턴스의 동일성(Identity)은 그대로 유지하면서 DB에서 최신 row를 다시 읽어와 객체의 필드 값을 강제로 갱신합니다. 동시에 우리가 원했던 비관적 락도 안전하게 획득할 수 있죠.
실제로 이 방법이 문제를 해결하는지 테스트 코드로 확인해 보겠습니다.
@Test
@DisplayName("이미 관리 중인 엔티티를 최신 재고로 갱신하면 품절 예외가 발생한다")
void refreshWithPessimisticWriteReloadsAlreadyManagedEntity() {
Long stockId = tx(em -> {
Stock stock = new Stock(1);
em.persist(stock);
return stock.getId();
});
EntityManager emB = entityManagerFactory.createEntityManager();
EntityTransaction txB = emB.getTransaction();
txB.begin();
try {
// 시점 1: 트랜잭션 B가 재고를 1차 캐시에 올린다.
Stock stockInB = emB.find(Stock.class, stockId);
// 시점 2: 트랜잭션 A가 재고를 0으로 차감한다.
tx(emA -> {
Stock stockInA = emA.find(Stock.class, stockId, LockModeType.PESSIMISTIC_WRITE);
stockInA.deduct(1);
return null;
});
// 시점 3: 기존 엔티티를 DB 최신 값으로 갱신하면서 락을 획득한다.
emB.refresh(stockInB, LockModeType.PESSIMISTIC_WRITE);
assertThat(stockInB.getAvailableQuantity()).isZero();
assertThatThrownBy(() -> stockInB.deduct(1))
.isInstanceOf(SoldOutException.class);
} finally {
txB.rollback();
emB.close();
}
}
이제 시점 3에서 refresh()를 사용한 트랜잭션 B는 과거의 데이터(1)가 아닌, 트랜잭션 A가 변경한 최신 데이터(0)를 정확하게 바라보게 됩니다. 이후 deduct(1)을 호출하면 마침내 우리가 의도했던 대로 SoldOutException이 발생하며 중복 차감을 막아냅니다.
더 깊은 고민, 그리고 아키텍처의 진화
refresh(...)를 통해 트랜잭션 내에서 발생하는 1차 캐시 이슈는 안전하게 해결했습니다. 하지만 여기서 고민을 멈추지 않고, "애초에 왜 재고 차감 이전 단계에서 재고 엔티티가 미리 로딩되어야 했을까?"라는 질문을 던져보았습니다.
생각해 보면 장바구니에 상품을 담거나 주문 상태를 생성하는 단계에서는 재고 엔티티 전체가 아닌 '재고의 식별자(stockId)'만 있어도 충분합니다. 실제 재고 엔티티를 조회하고 영속화하는 과정은 차감이 일어나는 최후의 순간으로 미루는 것이 동시성 제어 측면에서 훨씬 안전하고 깔끔합니다.
따라서 앞으로 시스템을 고도화해 나갈 때는 다음과 같은 구조적 접근도 함께 고려해 보려 합니다.
- 식별자(ID) 참조로 분리: Cart나 Order 도메인에서
Stock엔티티를 직접 참조하는 대신stockId만 유지합니다. 실제 락을 동반한 조회의 범위를 최소화할 수 있습니다. - 조건부 업데이트: 애플리케이션으로 엔티티를 가져오는 과정 자체를 생략하고, DB 쿼리로 원자적 차감을 수행합니다. (예:
UPDATE stock SET quantity = quantity - 1 WHERE id = ? AND quantity >= 1)
오버엔지니어링을 경계한 실용적인 기술 선택
동시성 문제를 제어하는 방법에는 여러 가지 선택지가 있습니다. 성능과 정합성 사이의 트레이드-오프를 고려해 낙관적 락(Optimistic Lock)과 재시도 로직을 구현할 수도 있고, 서버가 확장되는 스케일 아웃 환경을 고려해 Redis 기반의 분산 락을 도입할 수도 있습니다.
그렇다면 반려생활 개발팀은 왜 비관적 락(Pessimistic Lock)을 선택했을까요?
가장 큰 이유는 오버엔지니어링을 경계했기 때문입니다. 현재 댕플스테이는 소수 정예로 운영되는 프로그램 특성상, 당장 인프라의 복잡도를 극적으로 높여야 할 만큼 대규모의 병목 트래픽이 발생하는 상황은 아니었습니다.
만약 Redis 분산 락을 도입한다면 관리해야 할 인프라 포인트가 늘어나고, 낙관적 락을 적용한다면 충돌 발생 시의 재시도 로직 등 애플리케이션 복잡도가 불필요하게 올라갑니다. 따라서 현재의 서비스 규모와 비즈니스 요구사항(절대 초과 예약이 발생하면 안 됨)을 고려했을 때, 가장 직관적이면서도 강력하게 데이터 정합성을 보장할 수 있는 DB 레벨의 비관적 락이 가장 합리적이고 실용적인 선택이었습니다.
물론 앞으로 반려생활 서비스가 더욱 폭발적으로 성장하여 트래픽이 크게 늘어난다면, 그때는 병목 현상을 해소하기 위해 분산 락 도입 등 더 넓은 범위의 아키텍처 개선을 이어나갈 것입니다.
마치며
처음 비관적 락(PESSIMISTIC_WRITE)을 적용했을 때만 해도, DB 수준에서 확실하게 잠금을 걸었으니 동시성 문제는 완벽히 해결될 것이라 예상했습니다. 하지만 실제로는 DB와 애플리케이션이 서로 다른 상태를 바라보고 있었습니다.
“DB 락은 순서를 보장하지만, 객체의 상태를 보장하지는 않는다.”
잠금은 데이터베이스에 걸리지만, 엔티티의 상태는 온전히 JPA 1차 캐시가 통제하고 있었습니다. 따라서 이번 동시성 이슈의 진짜 원인은 ‘락이 제대로 걸렸는가’가 아니라 ‘내가 바라보는 데이터가 어느 시점의 상태인가’에 있었습니다.
메타데이터
- post_id
- 33570ca77b56
- slug
- 락-lock-을-걸었는데-왜-오버부킹이-날까-jpa-1차-캐시의-함정-탈출기-33570ca77b56
- url
- https://blog.ban-life.com/%EB%9D%BD-lock-%EC%9D%84-%EA%B1%B8%EC%97%88%EB%8A%94%EB%8D%B0-%EC%99%9C-%EC%98%A4%EB%B2%84%EB%B6%80%ED%82%B9%EC%9D%B4-%EB%82%A0%EA%B9%8C-jpa-1%EC%B0%A8-%EC%BA%90%EC%8B%9C%EC%9D%98-%ED%95%A8%EC%A0%95-%ED%83%88%EC%B6%9C%EA%B8%B0-33570ca77b56
- canonical_url
- https://blog.ban-life.com/%EB%9D%BD-lock-%EC%9D%84-%EA%B1%B8%EC%97%88%EB%8A%94%EB%8D%B0-%EC%99%9C-%EC%98%A4%EB%B2%84%EB%B6%80%ED%82%B9%EC%9D%B4-%EB%82%A0%EA%B9%8C-jpa-1%EC%B0%A8-%EC%BA%90%EC%8B%9C%EC%9D%98-%ED%95%A8%EC%A0%95-%ED%83%88%EC%B6%9C%EA%B8%B0-33570ca77b56
- author_url
- https://medium.com/@mrpark219
- status
- ok
- fetched_at
- 2026-06-29 01:02:39