상태 전이와 비관적 락 - 엔티티로 막은 줄 알았던 두 구멍
TL;DR
- 증상: 주문 단계(대기/접수/완료/취소)와 재고, 두 군데를 막았다고 생각했는데 완료된 주문이 다시 접수로 돌아가고, 잠금을 걸었는데도 재고가 마이너스로 빠졌습니다.
- 원인 1: 단계 검증을 서비스 코드 여기저기에 손으로 넣다 보니, 나중에 추가한 호출부에서 빠뜨렸습니다.
- 원인 2: 잠금은 제대로 걸렸지만, JPA가 들고 있던 낡은 재고 객체를 돌려줘서 옛 수량으로 차감했습니다.
- 해결: 단계는 setter를 없애고 주문 객체 안의 규칙으로만 바꾸게 했고, 재고는 미리 읽지 않고 잠금과 함께 최신으로 다시 읽었습니다.
- 한계: 객체 안의 단계 검증은 동시 접근을 막지 못합니다. 동시성은 결국 DB 잠금이나 버전으로 막아야 합니다.
서론
주문 도메인을 직접 설계하면서, 저는 두 군데를 “안전하게 막았다”고 생각했습니다. 하나는 주문 단계였고, 하나는 재고였습니다.
단계는 Order에 status 필드를 두고 서비스에서 바꿨습니다. 잘못된 단계 변경을 막으려고 “지금 접수 상태일 때만 완료로” 같은 검증을 호출부마다 붙였습니다. 재고는 동시 주문에 대비해 잠금(@Lock)을 걸었습니다. 둘 다 막는 코드가 분명히 있었습니다.
그런데 둘 다 뚫렸습니다. 완료된 주문이 다시 접수로 돌아갔고, 잠금을 걸었는데도 재고가 마이너스로 빠졌습니다. 막는 코드가 있다는 것과 실제로 막힌다는 건 다른 문제였습니다.
이 글은 그 두 구멍을 메운 과정입니다. 앞쪽은 주문 단계를 객체 안으로 가둔 이야기, 뒤쪽은 잠금을 걸고도 낡은 값으로 차감된 이야기입니다. 두 번째가 더 오래 헤맸고, 더 배운 쪽입니다.
환경
- Java: 17
- Framework: Spring Boot 3.x, Spring Data JPA (Hibernate)
- Database: MySQL 8.0 (InnoDB)
- 검증: JUnit 멀티스레드 테스트(CountDownLatch), Hibernate SQL 로그
구멍 1: 단계 검증이 코드 여기저기 흩어져 있었다
처음 단계가 깨진 건 환불 쪽이었습니다. 주문 완료 API에는 “접수 상태인지” 검증을 넣었는데, 나중에 추가한 배치 환불 처리에서는 그 검증을 빠뜨렸습니다. 두 코드가 서로 멀리 떨어져 있으니 “여기도 검증이 필요하다”가 눈에 들어오지 않았습니다.
상태를 바꾸는 입구가 setStatus 하나로 열려 있으면, 검증은 그 입구를 쓰는 모든 호출부의 책임이 됩니다. 호출부가 늘 때마다 검증할 자리도 늘고, 한 군데만 빠뜨리면 그대로 통과합니다. 막는 책임을 사람의 주의력에 맡긴 셈입니다.
그래서 “어떤 단계 변경이 허용되는가”라는 규칙을, 단계를 가진 쪽이 직접 들고 있게 옮겼습니다. 참고로 한 단계에서 다음 단계로 넘어가는 것을 “전이(transition)“라고 부릅니다. 먼저 허용된 전이 목록을 상태 enum 안에 박았습니다.
public enum OrderStatus {
PENDING, ACCEPTED, COMPLETED, CANCELED;
// 각 상태에서 넘어갈 수 있는 다음 상태들만 적어둔 표
private static final Map<OrderStatus, Set<OrderStatus>> ALLOWED = Map.of(
PENDING, EnumSet.of(ACCEPTED, CANCELED), // 대기 -> 접수 또는 취소
ACCEPTED, EnumSet.of(COMPLETED, CANCELED), // 접수 -> 완료 또는 취소
COMPLETED, EnumSet.noneOf(OrderStatus.class),// 완료 -> 어디로도 못 감
CANCELED, EnumSet.noneOf(OrderStatus.class) // 취소 -> 어디로도 못 감
);
public boolean canMoveTo(OrderStatus next) {
return ALLOWED.getOrDefault(this, EnumSet.noneOf(OrderStatus.class)).contains(next);
}
}getOrDefault를 쓴 건, 나중에 상태를 새로 추가하고 표에 등록하는 걸 깜빡해도 에러(NPE) 대신 “전이 불가”로 안전하게 떨어지게 하기 위해서입니다.
상태가 네 개에 규칙도 단순해서 이렇게 enum 표로 충분했습니다. 만약 상태마다 “들어올 때 / 나갈 때 실행할 동작”이 복잡해지면 상태 패턴(State Pattern) 같은 구조로 가겠지만, 지금 단계에서 도입하면 오히려 과합니다.
허용된 전이를 그림으로 보면 이렇습니다.
stateDiagram-v2
[*] --> 대기
대기 --> 접수
대기 --> 취소
접수 --> 완료
접수 --> 취소
완료 --> [*]
취소 --> [*]그다음 Order에서 setStatus를 지웠습니다. 대신 accept(), complete()처럼 “무엇을 하려는지”가 드러나는 메서드만 열고, 실제 변경은 내부의 changeStatus 한 곳을 거치게 했습니다.
@Entity
public class Order {
@Enumerated(EnumType.STRING)
private OrderStatus status = OrderStatus.PENDING;
public void accept() { changeStatus(OrderStatus.ACCEPTED); }
public void complete() { changeStatus(OrderStatus.COMPLETED); }
public void cancel() { changeStatus(OrderStatus.CANCELED); }
private void changeStatus(OrderStatus next) {
if (!status.canMoveTo(next)) { // 표에 없는 전이면 거부
throw new IllegalStateException("잘못된 상태 전이: " + status + " -> " + next);
}
this.status = next;
}
}여기서 setStatus가 없는데 어떻게 DB에서 값을 채워 객체를 만드냐는 의문이 들 수 있습니다. @Enumerated를 필드에 붙이면 JPA(Hibernate)는 setter 없이도 필드에 값을 직접 넣어 객체를 만듭니다. 그래서 setter가 없어도 저장·조회에는 아무 문제가 없습니다.
이제 서비스에는 status를 직접 건드릴 방법이 없습니다. order.complete()를 부르면 주문 객체가 스스로 “지금 완료로 갈 수 있나”를 확인하고, 아니면 예외를 던집니다. 효과는 호출부 개수와 상관없어집니다. 호출하는 곳이 셋이든 백이든, 막는 자리는 changeStatus 한 곳뿐입니다. 새 호출부를 추가하다 검증을 빠뜨릴 일 자체가 사라집니다.
그런데 객체 안 검증만으로는 동시 접근을 못 막는다
여기까지는 “한 번에 한 명”을 가정한 이야기입니다. 같은 주문에 두 요청이 거의 동시에 들어오면 어떻게 될까요.
두 작업이 같은 주문을 거의 같은 순간에 읽으면, 둘 다 메모리에서 “지금 접수 상태니까 완료 가능”을 통과합니다. changeStatus의 검사는 각자의 작업이 메모리에서 보는 값만 확인하기 때문입니다. 즉 객체 안 검증은 “규칙에 어긋난 전이”는 막아도, “동시에 같은 전이”는 못 막습니다.
그래서 주문을 접수하면서 재고를 차감하는 부분에는 DB 수준의 잠금이 필요했습니다. 그리고 거기서 두 번째 구멍을 만났습니다.
구멍 2: 잠금은 걸렸는데 낡은 값으로 차감했다
재고 차감에 비관적 락(@Lock(PESSIMISTIC_WRITE))을 걸었습니다. 비관적 락은 쉽게 말해 “내가 이 재고 줄이는 동안, 다른 작업은 이 행에 손대지 마” 하고 DB의 그 행을 잠그는 것입니다. 그런데도 동시 주문 테스트에서 재고가 마이너스로 빠졌습니다.
비관적 락과 낙관적 락의 기초는 격리 수준과 락 전략 글에서 다뤘습니다. 여기서는 “잠금은 걸렸는데도 안 맞은” 경우만 짚습니다.
처음엔 잠금이 안 걸린 줄 알고 @Lock을 의심했습니다. Hibernate가 실제로 DB에 보내는 SQL을 로그로 켜보니 for update(행을 잠가달라는 SQL)는 멀쩡히 찍혀 있었습니다. 잠금은 분명히 걸린 겁니다. 그런데도 재고는 마이너스였습니다.
원인은 잠금이 아니라 제가 읽은 값이었습니다. 문제가 된 흐름은 이랬습니다.
// 주문과 재고를 한 번의 쿼리로 같이 끌어온다(JOIN FETCH)
Order order = orderRepository.findWithStock(orderId); // 이때 재고가 메모리에 적재됨
// 잠금을 걸어 재고를 '다시' 조회한다 - for update는 DB로 나가 잠금은 걸린다
Stock stock = stockRepository.findForUpdate(order.getStock().getId());
stock.decrease(quantity);여기서 알아야 할 게 하나 있습니다. JPA에는 영속성 컨텍스트(1차 캐시) 라는 게 있습니다. 한 작업(트랜잭션) 동안 JPA가 쓰는 임시 메모장이라고 보면 됩니다. 한 번 읽은 객체는 이 메모장에 적어두고, 같은 걸 또 찾으면 DB 대신 메모장에서 꺼내 줍니다. 같은 데이터를 두 번 안 읽으려는 최적화입니다.
문제는 위 코드에서, 앞줄의 JOIN FETCH(주문 가져올 때 재고도 같이 끌어오는 조회)가 재고를 이미 이 메모장에 적어둔 상태라는 점입니다. 그래서 뒤이은 잠금 조회는 for update를 DB로 보내 행에 잠금은 걸지만, 정작 돌려주는 객체는 메모장에 적혀 있던 낡은 재고였습니다. JPA가 “한 작업 안에서 같은 객체는 늘 같게 보여줘야 한다”는 원칙 때문에, 새로 읽은 값을 버리고 메모장의 옛 객체를 그대로 돌려준 겁니다.
결과적으로 잠금은 최신 행에 걸렸지만, 차감 계산은 낡은 수량을 기준으로 돌아갔습니다. “잠금이 안 걸린” 게 아니라 “잠금 걸고 읽은 값이 낡은(stale) 값”이었던 겁니다.
고친 방법은 단순했습니다. 재고를 미리 JOIN FETCH로 끌어오지 않고, 잠금과 함께 처음부터 최신으로 읽었습니다. 이미 메모장에 올라와 있는 경우라면 강제로 다시 읽어야(em.refresh) 합니다.
// 미리 끌어오지 않으면 메모장에 없으니, 잠금과 함께 최신 값을 읽는다
Stock stock = stockRepository.findForUpdate(stockId);
stock.decrease(quantity);
// 이미 메모장에 올라와 있었다면 강제로 다시 읽는다
// entityManager.refresh(stock);@Lock을 붙였느냐만 보면 잠금은 분명히 걸려 있었습니다. 진짜 봐야 했던 건 “잠금 걸고 읽은 이 객체가 방금 DB에서 온 게 맞는가”였습니다.
조금 더 들어가면, 이건 단순 실수가 아니라 두 기능의 목적이 부딪힌 자리였습니다. 영속성 컨텍스트(메모장)는 “한 작업 안에서 같은 데이터는 늘 같은 객체로 보여준다”를 보장합니다. 이 일관성 덕분에 변경 감지나 불필요한 재조회 방지 같은 JPA의 편의가 가능합니다. 그런데 비관적 락은 정반대를 원합니다. “방금 DB에서 잠그고 읽은, 지금 이 순간의 최신값”이어야 합니다. 제 재고 버그는 이 두 가지가 충돌한 지점이었습니다. 잠금의 발목을 잡은 건 동시 주문이 아니라, 평소엔 고마웠던 그 메모장이었습니다.
곁가지: @Version은 왜 정리했나
처음 재고 객체에는 예전에 붙여둔 @Version이 남아 있었습니다. @Version은 낙관적 락이라고 부르는 방식으로, “일단 고 치고, 그 사이 누가 먼저 바꿨으면 충돌로 알려줘” 하는 장치입니다. 비관적 락(미리 잠그기)과 낙관적 락(나중에 충돌 확인)을 같이 쓰는 것 자체는 JPA가 지원하는 정상 조합입니다.
다만 제 의도는 “비관적 락으로 한 줄씩 차례로 처리”하는 것뿐이었습니다. 이때 @Version이 남아 있으면, 저장하는 순간 버전 검사가 덧붙어 예상 못 한 충돌 예외(OptimisticLockException)를 맞을 수 있습니다. 차례 처리에는 필요 없는 장치라 정리했습니다. 재고 마이너스의 원인은 아니었고, 잠금 흐름을 단순하게 두기 위한 정리였습니다.
검증: SQL 로그와 멀티스레드 테스트
두 구멍을 고치고 나서, 같은 실수를 반복하지 않으려고 검증을 두 가지로 박았습니다.
하나는 SQL 로그입니다. 잠금을 거는 쿼리는 실제 SQL에 for update가 찍히는지 눈으로 확인합니다. 구멍 2에서 봤듯 for update가 찍혔다고 끝은 아니지만, 적어도 잠금 자체가 나가는지는 여기서 갈립니다.
다른 하나는 멀티스레드 테스트입니다. 스레드 하나로 도는 일반 테스트는 잠금이 없어도 통과하기 때문에, 동시 접근 결함을 아예 재현하지 못합니다.
int threadCount = 50;
ExecutorService pool = Executors.newFixedThreadPool(threadCount);
CountDownLatch start = new CountDownLatch(1);
for (int i = 0; i < threadCount; i++) {
pool.submit(() -> {
start.await(); // 모든 스레드를 같은 출발선에 세운다
orderService.accept(orderId);
return null;
});
}
start.countDown(); // 신호 한 번에 동시에 출발CountDownLatch로 스레드들을 같은 출발선에 세웠다가 신호 한 번에 동시에 재고를 건드리게 했습니다. 구멍을 고치기 전에는 재고가 마이너스로 떨어졌고, 고친 뒤에는 마이너스가 사라졌습니다.
한 가지 더, 단계 검증은 재고 차감보다 앞에 뒀습니다. 같은 작업(트랜잭션) 안이라 검증을 뒤에 둬도 어차피 실패하면 전체가 되돌려지므로(롤백), 데이터가 깨지지는 않습니다. 다만 잘못된 전이가 재고 행을 먼저 잠그고 차감까지 한 뒤 막히면, 쓸데없는 잠금과 쓰기, 그리고 되돌리는 비용이 듭니다. 검증을 앞에 두는 건 데이터 안전 때문이 아니라 그 낭비와 경합을 줄이기 위한 선택입니다.
시스템 점검 체크리스트
- 상태 변경 입구가
setStatus같은 setter로 외부에 열려 있지 않은가 - 전이 규칙이 서비스가 아니라 객체(또는 상태 enum) 안에 있는가
- 객체 안 검증과 별개로, 동시 전이를 막을 DB 잠금이나 버전이 있는가
- 잠금을 건 객체가 같은 작업에 서 미리 조회돼 낡은 값으로 돌아오고 있지 않은가
- 동시성을 스레드 하나짜리 테스트가 아니라 멀티스레드로 재현해 봤는가
결론
두 구멍의 공통점은, 코드에는 분명히 막는 장치가 적혀 있었다는 점입니다. 단계에는 검증이, 재고에는 @Lock이 있었습니다. 하나는 검증이 호출부마다 흩어져 빠뜨릴 수 있었고, 하나는 잠금은 걸렸는데 읽은 값이 낡았습니다.
특히 잠금 쪽에서 오래 헤맸습니다. 저는 “잠금이 걸리느냐”만 신경 썼는데, 정작 중요한 건 “잠금 걸고 읽은 값이 최신이냐”였습니다. 프레임워크가 좋은 뜻으로 해주는 일이 내 의도와 어긋나는 자리는, 보통 그 편의가 가장 고마웠던 곳이었습니다.
다음에 재고 같은 자원을 다룰 때는, @Lock을 붙이는 것과 함께 “이 객체가 이미 메모장(영속성 컨텍스트)에 올라와 있지 않은가”부터 확인하려고 합니다. 그리고 단계가 있는 도메인이라면 status 필드의 setter부터 지우고 시작할 생각입니다.
참고 :
https://docs.spring.io/spring-data/jpa/reference/jpa/locking.html
https://jakarta.ee/specifications/persistence/
