지난 번엔 모든 멤버들이 가입된 동아리를 가져오는 과정에서 생긴 N+1 문제를 해결하여 성능을 향상시켰다. 이번엔 재고가 1개 남은 물품을 동시에 예약하는 경우를 가정하고 해당 상황에서 생기는 문제를 해결해보았다.
동일한 물품에 대한 동시 대여
서비스를 운영하면서 재고가 1개 남은 물품에 있어서 여러 명이 동시에 예약을 진행한다면 어떻게 될까? 라는 생각이 들어 직접 테스트를 해보았다. 현재 사용자가 물품을 예약하는 흐름은 다음과 같다.
@Transactional
public BookDTO bookItem(String studentId, long itemId, int count){
if (count<=0) throw new MessageException("물품 대여 개수가 잘못되었습니다.", HttpStatus.NOT_ACCEPTABLE);
Member renter = memberService.findByStudentId(studentId);
Item item = itemService.findById(itemId);
if (isPenalty(studentId)) {
throw new HavePenaltyException();
}
MemberRentingSize memberRentingSize = checkMemberRenting(studentId);
memberRentingSize.variety.add(itemId);
if (memberRentingSize.variety.size() > 3) {
throw new LimitRentException("물품은 세 종류까지만 대여가 가능합니다.");
}
if (memberRentingSize.count+count > 5) {
throw new LimitRentException("물픔은 최대 5개까지만 대여가 가능합니다.");
}
if (count + checkItemBooking(itemId) + item.getRentingCount() > item.getCount()) {
throw new NotEnoughItemException();
}
ItemRent itemRent = itemRentRepository.save(new ItemRent(renter, item, count));
return new BookDTO(itemRent, dateCheckService.needReceiveDate(itemRent.getOfferDate()));
}
먼저 사용자는 한 번에 최대 3종류의 물품과 5개까지만 대여가 가능하기 때문에 지금 사용자가 빌린 물품의 종류가 있는 경우 checkMemberRenting 메서드로 MemberRentingSize DTO에 정보를 받아온다.
public static class MemberRentingSize {
public Set<Long> variety;
public int count;
}
MemberRentingSize는 물품 id를 나타내는 variety 필드와 총 개수를 나타내는 count 필드를 가지고 있다.
이후 받아온 정보에 현재 빌리려는 물품id를 variety 변수에 추가해주고 이후 물품의 종류와 개수를 판단한다. 만약 물품 개수 조건에 부합하지 않는다면 LimitRentException 예외를 반환한다.
이후 현재 사용자의 예약 요청이 가능한지를 판단하게 된다. 하지만 여기서 여러 명의 사용자가 현재 물품의 재고보다 많은 수량을 동시에 예약하는 상황이 일어난다면 어떻게 될까??
* 당시엔 물품을 예약 중인 개수와 빌려간 개수를 표기하기 위해 따로 관리하고 있었는데, 굳이 필드에서 재고의 개수를 빼주는 연산이 필요없다고 생각하여 굳이 물품의 재고 필드를 수정하여 저장하지 않았다.
먼저 해당 문제를 테스트로 재현해보자. 테스트에 사용된 데이터 셋은 다음과 같다.


@Test
void 동시에_같은_물품을_대여할_때_재고_초과_문제_재현() throws Exception {
Item item = itemService.findById(1L);
ExecutorService pool = Executors.newFixedThreadPool(3);
CountDownLatch countDownLatch = new CountDownLatch(3);
CountDownLatch start = new CountDownLatch(1);
List<Future<?>> futures = new ArrayList<>();
List<String> sids = List.of("123", "456", "789");
for (String sid : sids) {
futures.add(pool.submit(() -> {
try {
start.await();
itemRentService.bookItem(sid, item.getId(), 1);
} catch (Throwable t) {
throw new RuntimeException(t);
} finally {
countDownLatch.countDown();
}
}));
}
start.countDown();
for (Future<?> future : futures) {
future.get();
}
pool.shutdown();
Item fresh = itemService.findById(1L);
System.out.println("실제 예약된 개수 = " + itemRentService.checkItemBooking(fresh.getId()));
assertEquals(2, itemRentService.checkItemBooking(fresh.getId()));
}
Thread 3개를 동시에 실행하여 3명이서 현재 재고가 2개인 물품에 동시에 신청하는 테스트를 구성했다. 2개가 예약되는 것을 조건으로 걸고 테스트를 진행했는데 사실 여기서 정말 바보같은 실수를 했다..(다행히 문제 상황을 확인하는데에는 지장이 없었지만... 아래에서 수정했습니다,,)

하지만 실제로 테스트를 실행해본 결과 3개가 모두 성공적으로 예약되어 테스트가 실패했다! 실제 DB도 확인해보자.

DB에서도 3개의 예약이 모두 성공한 것을 확인할 수 있다.
혹시 몰라서 해당 테스트를 다시 돌려보았지만 이번엔 당연하게도 다음과 같이 물품이 부족하다는 에러가 발생했다.

그렇다면 동시에 사용자가 요청하면 실제로 물품이 부족한데도 성공적으로 예약이 가능한 치명적인 상황이다.
왜 이런 문제가 발생할까?
같은 물품을 두고 한 트랜잭션이 끝나기 전에 다른 트랜잭션이 시작하면 상대 트랜잭션의 변경이 아직 보이지 않는 스냅샷을 보고 결정을 내린다.
간단하게 말하자면 1, 2, 3의 요청이 모두 빌리는데 지장 없는 처음의 상태를 확인하고 요청을 실행하기 때문에 생기는 문제이다.
어떻게 해결할 수 있을까?
이 문제는 개발하면서 흔히 생길 수 있는 동시성 문제이다. 동시성 문제란 여러 작업이 동시에 실행될 때 발생할 수 있는 예상치 못한 오류나 데이터 충돌 현상을 말한다. 공유 자원에 여러 스레드, 혹은 사용자 요청이 동시에 접근하면서 문제가 생기는 경우가 많다.
현재 상황은 Tomcat을 사용하고 있기 때문에 하나의 요청에 하나의 스레드가 배정되고, 동시에 여러 사용자가 요청을 보내면 여러 스레드가 동시에 DB(공유자원)에 접근하면서 문제가 생기는 상황이라고 볼 수 있다.
그럼 어떻게 동시성 문제를 해결할 수 있을까? 크게 다음과 같은 4가지 방법을 생각해볼 수 있다.
1. synchronized
임계구역에 한 번에 하나의 스레드만 진입하도록 막는 방법이다. JVM이 다른 스레드(요청)가 같은 객체에 들어오려 하면 대기(Blocking)상태로 만들어 여러 스레드가 접근하지 못하도록 방지한다.
@Transactional
@Synchronized
public BookDTO bookItem(String studentId, long itemId, int count){
if (count<=0) throw new MessageException("물품 대여 개수가 잘못되었습니다.", HttpStatus.NOT_ACCEPTABLE);
Member renter = memberService.findByStudentId(studentId);
Item item = itemService.findById(itemId);
if (isPenalty(studentId)) {
throw new HavePenaltyException();
}
MemberRentingSize memberRentingSize = checkMemberRenting(studentId);
memberRentingSize.variety.add(itemId);
if (memberRentingSize.variety.size() > 3) {
throw new LimitRentException("물품은 세 종류까지만 대여가 가능합니다.");
}
if (memberRentingSize.count+count > 5) {
throw new LimitRentException("물픔은 최대 5개까지만 대여가 가능합니다.");
}
if (count + checkItemBooking(itemId) + item.getRentingCount() > item.getCount()) {
throw new NotEnoughItemException();
}
ItemRent itemRent = itemRentRepository.save(new ItemRent(renter, item, count));
return new BookDTO(itemRent, dateCheckService.needReceiveDate(itemRent.getOfferDate()));
}
이렇게 Lombok에서 제공해주는 @Synchronized로 메서드를 쉽게 직렬화할 수 있다. 해당 애노테이션은 메서드에 synchronized(obj) {}를 감싸준다. 하지만 이렇게 메서드 자체를 한 번에 하나의 스레드만 처리할 수 있도록 한다면 엄청난 병목 현상이 생길 것이다. 또한 JVM이 메서드의 접근을 막는 방식이기 때문에 다른 인스턴스나 다른 메서드에서 해당 행이나 테이블에 접근할 수 있기 때문에 DB 무결성과 정합성을 보장하지는 않는다. 따라서 Synchronized는 해당 상황에서 좋지 않은 선택이다.
2. ReentrantLock
락 객체를 직접 lock()/unlock() 으로 제어해 상호배제를 만든다.
@Component
public class ItemLocks {
private final ConcurrentHashMap<Long, ReentrantLock> map = new ConcurrentHashMap<>();
public ReentrantLock acquire(long itemId) {
ReentrantLock lock = map.computeIfAbsent(itemId, k -> new ReentrantLock(true));
lock.lock();
return lock;
}
public void release(long itemId, ReentrantLock lock) {
try { lock.unlock(); }
finally {
if (!lock.hasQueuedThreads()) {
map.remove(itemId, lock);
}
}
}
}
이렇게 물품에 대한 id를 키로 락을 만들어 같은 id(물품)으로 들어오는 동시 요청을 직렬화한다. ConcurrentHashMap은 여러 스레드가 동시에 접근해도 안전한 동시 맵이다. 잠깐 설명을 해보자면 CAS 연산과 volatile 키워드를 사용하여 여러 스레드에서 동시에 같은 키에 대한 연산을 수행해도 안전하다. 자세한 내용은 다른 글에서 다루어 보겠다!
acquire() 메서드는 다음과 같다.
ConcurrentHashMap을 사용하여 원자적으로 락을 없으면 생성, 있으면 재사용 한다. 이후 스레드가 락을 획득한다.
release() 메서드는 다음과 같다.
획득한 락을 해제하고 hasQueuedThreads()로 대기하는 스레드가 없다면 맵에서 제거하여 GC의 대상이 되도록 만든다.
해당 방법은 동일한 물품에 대해서만 락이 잠기기 때문에 메서드 전체를 잠그는 것보다 병목현상이 덜하다. 하지만 이 방법도 JVM에서 동작하는 것이기 때문에 다른 인스턴스나 메서드가 DB의 행에 접근할 수 있다는 단점이 있다.
3. 비관적 락
트랜잭션 안에서 대상 행(정확히는 인덱스 레코드)에 배타 락을 잡는다. 배타 락이란 그 행을 바꾸려는 다른 트랜잭션의 접근을 차단해서 직렬화를 강제하는 방식이다.
// repository 계층
@Lock(PESSIMISTIC_WRITE)
@Query("select i from Item i where i.id = :id")
Optional<Item> findByIdForUpdate(Long id);
// service 계층
@Transactional
public Item findByIdForUpdate(Long itemId) {
return itemJpaRepository.findByIdForUpdate(itemId)
.orElseThrow(() -> new NotFoundItemException("물품이 없습니다."));
}
위 코드처럼 @Lock(PESSIMISTIC_WRITE)를 명시하면 해당 쿼리의 행에 배타 락(잠금)을 잡는다. 행이 잠금되어있는 동안(트랜잭션이 종료되기 전)은 다른 잠금 시도나 쓰기 연산(update)을 막는다.
4. 낙관적 락
읽을 때는 아무도 막지 않고 쓰는 순간 version을 확인하여 변경 충돌을 감지하고, 충돌이면 실패 → 재시도.
즉 사후 감지 + 재시도로 동시성을 제어하는 방법이다.
// Entity
@Entity
public class Item {
@Id @GeneratedValue @Column(name="item_id")
private Long id;
private String name;
private Integer count;
private Integer rentingCount;
@Version
private Long version;
...
}
// repository 계층: 읽기만 해도 버전을 강제로 +1 (flush/commit 시)
@Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
@Query("select i from Item i where i.id = :id")
Optional<Item> findByIdWithOFI(@Param("id") Long id);
@Version 필드는 애플리케이션이 직접 수정하지 않고 JPA가 갱신한다. 이후 해당 엔티티에 접근할 때 마다 version을 하나씩 올리고 잠금 없이 커밋할 때 버전 정보가 유효하지 않다면 해당 트랜잭션은 롤백되어 재시도할 수 있다.
정리하자면 다음과 같다.
- synchronized/ReentrantLock: 동일 JVM 내부에서만 직렬화.
- 비관적 락: 사전 차단, 멀티 인스턴스에서도 유효
- 낙관적 락: 사후 감지/재시도, 저경합에 유리
이렇게 동시성 문제를 해결하는 여러 방법들을 알아보았다. 이 중 비관적 락이 가장 적합하다고 생각했고 그 이유는 다음과 같다.
먼저 물품 재고는 시스템의 핵심이기 때문에 DB 레벨에서 제어하는 것이 좋다고 생각했다. JVM이 제어하는 synchronized/ReentrantLock는 다른 메서드나 인스턴스에서 유효하지 않기 때문에 무력한 경우가 생길 수 있다. 또한 낙관적 락은 Item을 반드시 갱신해야 충돌을 감지할 수 있고 충돌 시 재시도하는 로직을 제어하는 것이 어렵다고 생각했기 때문이다. 재시도한 경우에도 계속해서 경합에 실패하는 경우가 생길 수 있는 등 여러 변수가 있다고 생각하여 비관적 락을 선택하여 로직을 수정하였다.
// repository 계층
@Lock(PESSIMISTIC_WRITE)
@Query("select i from Item i where i.id = :id")
Optional<Item> findByIdForUpdate(Long id);
// service 계층
@Transactional
public Item findByIdForUpdate(Long itemId) {
return itemJpaRepository.findByIdForUpdate(itemId)
.orElseThrow(() -> new NotFoundItemException("물품이 없습니다."));
}
@Transactional
public BookDTO bookItem(String studentId, long itemId, int count){
if (count<=0) throw new MessageException("물품 대여 개수가 잘못되었습니다.", HttpStatus.NOT_ACCEPTABLE);
Item item = itemService.findByIdForUpdate(itemId);
Member renter = memberService.findByStudentId(studentId);
if (isPenalty(studentId)) {
throw new HavePenaltyException();
}
MemberRentingSize memberRentingSize = checkMemberRenting(studentId);
memberRentingSize.variety.add(itemId);
if (memberRentingSize.variety.size() > 3) {
throw new LimitRentException("물품은 세 종류까지만 대여가 가능합니다.");
}
if (memberRentingSize.count+count > 5) {
throw new LimitRentException("물픔은 최대 5개까지만 대여가 가능합니다.");
}
if (count + checkItemBooking(itemId) + item.getRentingCount() > item.getCount()) {
throw new NotEnoughItemException();
}
ItemRent itemRent = itemRentRepository.save(new ItemRent(renter, item, count));
return new BookDTO(itemRent, dateCheckService.needReceiveDate(itemRent.getOfferDate()));
}
수정된 코드이다.
@Test
void 동시에_같은_물품을_대여할_때_재고_초과_문제_재현() throws Exception {
Item item = itemService.findById(1L);
ExecutorService pool = Executors.newFixedThreadPool(3);
CountDownLatch countDownLatch = new CountDownLatch(3);
CountDownLatch start = new CountDownLatch(1);
List<Future<?>> futures = new ArrayList<>();
List<String> sids = List.of("123", "456", "789");
for (String sid : sids) {
futures.add(pool.submit(() -> {
try {
start.await();
itemRentService.bookItem(sid, item.getId(), 1);
} catch (Throwable t) {
throw new RuntimeException(t);
} finally {
countDownLatch.countDown();
}
}));
}
start.countDown();
boolean exceptionOccurred = false;
for (Future<?> future : futures) {
try {
future.get();
} catch (ExecutionException e) {
if (cause instanceof RuntimeException && cause.getCause() instanceof NotEnoughItemException) {
exceptionOccurred = true;
System.out.println("NotEnoughItemException 발생");
}
}
}
pool.shutdown();
Item fresh = itemService.findById(1L);
System.out.println("실제 예약된 개수 = " + itemRentService.checkItemBooking(fresh.getId()));
assertTrue(exceptionOccurred,
"2개 재고에서 3개 예약이므로 NotEnoughItemException이 발생해야 함");
}
아까 말했던 바보같은 실수인데, 아까의 테스트는 사실 3개가 정상적으로 들어갔으면 중간에 오류가 터져서 실패한다... 따라서 테스트 코드도 수정했다. 이제 동일한 DB 조건에서 다시 한 번 테스트 코드를 돌려보자!



세 번째 쿼리문 사이에 예외가 정상적으로 발생하고 실제 예약된 개수가 2개인 것을 확인할 수 있다!
p.s
현재는 단일 서버이지만 분산 환경에서 동시성 문제가 발생한다면 해결하기 위해 분산락을 적용할 수 있다. 분산락에 대한 내용은 다음 포스팅을 통해 공부해보자!
그리고 이번 테스트 코드를 작성하면서 메서드 호출자의 스레드에서 생긴 예외가 아니라면 예외가 터지지 않고 그냥 지나간다는 사실을 알게 되었다. ExecutorService.submit 메서드는 다른 스레드에서 실행되고 예외를 Future에 저장한다. 그래서 다른 스레드에서 던진 예외는 호출 스레드로 자동 전파되지 않아 Future로 잡아서 get으로 호출하지 않는 이상 예외가 발생하지 않는다. 한 요청에 하나의 스레드가 배정되는 동기 서블릿인 Tomcat은 이런 상황이 잘 발생하지 않겠지만 Netty와 같은 비동기 서버를 사용한다면 해당 상황을 잘 확인해야할 것 같다.
'Computer Science > Spring Boot' 카테고리의 다른 글
| Log4J2 Properties 개발기 1편 (0) | 2025.10.20 |
|---|---|
| Spring Security - 인증/인가와 FilterChain (w. JWT, Authorization) (0) | 2025.09.12 |
| N+1 해결을 통한 성능 개선기 (0) | 2025.09.02 |
| JPA N+1 문제 (0) | 2025.09.02 |
| Spring MVC와 Servlet (0) | 2025.08.29 |