[주문 서비스 만들기 2편] 동시성 문제, 원자적 UPDATE로 해결하기

조혜은·2026년 4월 1일

Java

목록 보기
8/10

저번 시간에 이어 계속 구현해보았다.

1. 상품에 재고 추가

이번 요구사항에는 상품에 재고(stock) 정보를 추가하고, 주문 생성 시 재고가 차감되도록 구현하는 것이 포함되어 있었다.
먼저 Product 엔티티에 stock 필드와 차감 메서드를 추가했다.

주문 생성 시 상품을 조회한 뒤 product의 decreaseStock()을 호출하는 방식으로 재고를 차감했다. 재고가 0일 때는 예외를 던져 주문이 생성되지 않도록 처리했다.

2. 동시성 문제

기본 구현은 완성했지만 한 가지 문제가 남아있었다. 동시에 여러 요청이 들어올 경우 재고가 정확히 차감되지 않을 수 있다는 점이다.

일반적인 재고 차감 흐름은 이렇다.

1. SELECT stock FROM product WHERE id = 1  → stock = 10
2. stock - 1 = 9 
3. UPDATE product SET stock = 9 WHERE id = 1

1번과 3번 사이에 다른 요청이 끼어들면 문제가 생긴다.

A: 조회 → stock = 10
B: 조회 → stock = 10  ← A가 아직 커밋 안 했으니 똑같이 10 읽음
A: stock = 9로 업데이트
B: stock = 9로 업데이트  ← 2번 차감됐는데 9가 됨 

3. 락 방식 비교

대표적으로 낙관적 락과 비관적 락이 있다.

낙관적 락은 충돌이 없을 거라고 가정하고 진행한다.

트랜잭션 A 시작 (version=1)
트랜잭션 B 시작 (version=1)
A가 재고 차감 후 커밋 → version=2
B가 커밋 시도 → version이 달라! → 예외 발생
→ B는 재시도 해야 함

DB 락을 걸지 않고 @Version 필드로 버전을 관리하며, 커밋 시점에 버전이 다르면 충돌로 판단해 예외를 발생시킨다. 충돌이 적은 환경에서 성능이 좋지만, 충돌 발생 시 재시도 로직을 직접 구현해야 한다.

비관적 락은 충돌이 날 거라고 가정하고 조회 시점에 DB 행을 잠근다.

트랜잭션 A 시작 → 락 획득 
트랜잭션 B 시작 → 락 대기 
A가 재고 차감 후 커밋 → 락 해제
B가 락 획득 → 차감 진행

다른 트랜잭션은 락이 해제될 때까지 대기하므로 순서가 보장된다. 구현이 단순하지만 대기가 발생해 성능이 떨어질 수 있다.

낙관적 락비관적 락
충돌 감지커밋 시점조회 시점
충돌 많을 때재시도 반복 → 비효율대기 후 순서 처리 → 안정적
충돌 적을 때빠름불필요한 대기 발생
구현 복잡도재시도 로직 필요단순

각각 언제 쓰면 좋을까?

낙관적 락

  • 같은 데이터를 동시에 수정할 가능성이 낮을 때
  • 예: 게시글 수정, 회원 정보 수정, 좋아요

비관적 락

  • 같은 데이터를 동시에 수정할 가능성이 높을 때
  • 예: 재고 차감, 좌석 예약, 포인트 차감, 쿠폰 발급

재고 차감은 동시 요청이 많이 몰릴 수 있어 비관적 락이 적합하다고 생각했다. 그런데 찾아보다가 원자적 UPDATE라는 방식을 알게 됐다.

4. 원자적 UPDATE로 개선

락 없이 쿼리 자체에서 조회와 차감을 한 번에 처리하는 방식이다.

UPDATE product SET stock = stock - 1 WHERE id = 1 AND stock > 0

이런식으로 업데이트 쿼리에서 재고 차감을 해버림.

DB가 이 과정을 한 번에 처리하기 때문에 끼어들 틈이 없다. 재고가 부족하면 AND stock >= quantity 조건을 만족하지 못해 영향받은 행이 0개가 되고, 반환값으로 성공/실패를 구분한다.

ㄴ> repository에 추가해줌.

int updated = productRepository.decreaseStock(productId, quantity);
if (updated == 0) {
    throw new IllegalStateException("재고가 부족합니다.");
}

락을 걸지 않아 성능이 좋고, 쿼리 한 번으로 차감과 재고 확인이 동시에 되어 세 가지 방법 중 가장 단순하고 효율적이라고 판단해 이 방식을 선택했다.

5. Java 버전 선택 이유

이번 프로젝트에서 Java 21을 선택했다.
사실 거창한 이유는 없고, 저번 프로젝트에서 썼던 버전이라 그냥 이어서 썼다.

근데 저번 게시글 댓글에서 자바 버전 차이와 사용 이유를 알고 쓰는게 좋겠다는 피드백을 받고 버전 선택 이유에 대해 한번 정리해봤다.

버전주요 추가 기능
Java 8Lambda, Stream API, Optional, 새로운 날짜/시간 API
Java 11HttpClient API 표준화, var 키워드
Java 17Sealed Class, Record, Pattern Matching, Text Block 정식 출시
Java 21Virtual Thread 정식 출시, Record Pattern, Pattern Matching for Switch 정식 출시
Java 25Stream Gatherers, Scoped Values, AOT Class Loading, Compact Object Headers, Flexible Constructor Bodies 정식 출시

Java 21 주요 추가 기능

  • Virtual Thread (가상 스레드) - 기존 플랫폼 스레드보다 훨씬 가볍게 동작. 많은 요청을 처리하는 서버 환경에서 유리
  • Record Patterns - instanceof 패턴 매칭에서 Record의 필드를 바로 분해해서 사용 가능
  • Pattern Matching for Switch - switch문에서 타입 패턴 매칭 정식 출시. 타입 확인과 형변환을 한 번에 처리
  • Sequenced Collections - List, Set, Map에 순서가 보장되는 표준 인터페이스 추가. getFirst(), getLast() 등 사용 가능

Java 21의 핵심은 단연 가상 스레드였고, 나머지 기능들은 코드를 더 간결하게 쓸 수 있도록 언어 표현력을 높이는 방향이었다.

현재 기준 최신 LTS는 2025년 9월에 출시된 Java 25다.
자바 버전은 정말 금방금방 나오는 것 같다..

Java 25 주요 추가 기능

  • Stream Gatherers - 스트림 중간 연산을 직접 정의할 수 있게 됨. 슬라이딩 윈도우, 누적 합산 등을 외부 라이브러리 없이 처리 가능
  • Scoped Values - 기존 ThreadLocal의 메모리 누수 문제를 개선. 불변 값을 특정 범위 내에서만 안전하게 공유
  • AOT Class Loading - 클래스 로딩 결과를 캐시해두고 재사용해서 기동 시간 단축
  • Compact Object Headers - 객체 헤더 크기 축소로 메모리 사용량 감소
  • Flexible Constructor Bodies - 생성자에서 super() 전에 유효성 검사 로직 작성 가능

이번 프로젝트에서 가상 스레드를 포함해 Java 21에서 새로 추가된 기능들을 직접 쓰진 않았다. 사실 가상 스레드 말고는 뭐가 추가됐는지 까먹었는데, 이번 기회에 정리해보니 좋았다.

다음 프로젝트에서는 이번에 알게 된 기능들도 한번 써봐야겠다.
Java 25도 꽤 많은 게 추가됐는데 언젠가 버전을 올려볼 기회가 생기면 같이 살펴봐야겠다.

6. 에러 핸들링 고도화

단순 메시지 반환을 넘어 클라이언트가 "왜 실패했는지"를 코드로 식별할 수 있게 하는 것이 중요하다는 피드백을 받고 에러처리를 추가로 구현하였다.

현재 문제점

  • IllegalArgumentException을 그냥 던지고 있어 HTTP 상태코드가 항상 500으로 내려감
  • 에러마다 어떤 상황인지 코드로 식별 불가 (클라이언트가 에러 종류를 메시지 문자열로 파악해야 함)
  • 에러 응답 포맷이 ApiResponse와 달라 일관성 없음

개선 결과

  • ErrorCode 열거형을 도입하여 모든 에러를 한 곳에서 관리하고, 각 에러에 HTTP 상태코드 + 식별 코드 + 메시지를 함께 정의하였다.
  • BusinessException을 만들어 비즈니스 로직에서 발생하는 예외를 IllegalArgumentException 대신 명시적으로 던질 수 있게 하였다.
  • GlobalExceptionHandler를 통해 전역에서 예외를 캐치하고, 일관된 ErrorResponse 포맷으로 응답하도록 통일하였다.

오류 메세지도 깔꼼해졌다 👍

0개의 댓글