본문 바로가기
🚀 데브 노트 (IT 지식 & 팁)

재고가 마이너스가 됐어요! 동시성 문제 해결을 위한 비관적 락, 낙관적 락, Redis 분산 락 완벽 가이드

by 데브프리 2026. 8. 3.

안녕하세요, 여러분의 든든한 IT 길잡이 데브프리입니다.

 

스프링 부트(Spring Boot)로 쇼핑몰의 재고 차감 기능이나 콘서트 선착순 예매 시스템을 만들어본 적 있으신가요? 개발 서버에서 혼자 테스트할 때는 수량이 1개씩 아주 예쁘게 줄어듭니다. 하지만 실서버에 배포하고 이벤트 트래픽이 몰리는 순간, 남은 재고가 '-5개'로 뚫려버리거나 한정판 상품을 2명이 동시에 결제해버리는 대형 사고가 터지곤 하죠.

 

이 현상을 백엔드 진영에서는 '동시성 문제(Concurrency Issue)' 혹은 '경쟁 상태(Race Condition)'라고 부릅니다. 2026년 최신 Spring Boot 3.4/3.5 환경에서도 이 동시성을 완벽하게 제어하는 것은 백엔드 서버 안정성을 결정짓는 핵심 과제입니다.

 

오늘은 실무에서 동시성 이슈가 발생했을 때 이를 방어하는 3가지 핵심 무기인 비관적 락(Pessimistic Lock), 낙관적 락(Optimistic Lock), 그리고 Redis 분산 락(Redisson)의 원리와 트러블슈팅 과정, 그리고 상황별 베스트 프랙티스를 명쾌하게 정리해 드릴게요.

 

1. 실전 트러블슈팅: 갱신 분실(Lost Update)의 공포

재고가 딱 1개 남은 한정판 운동화가 있습니다. 사용자 A와 사용자 B가 0.001초 차이로 동시에 '구매하기' 버튼을 눌렀다고 가정해 봅시다.

  1. A의 스레드가 DB에서 재고를 조회합니다. (현재 재고: 1)
  2. 동시에 B의 스레드도 DB에서 재고를 조회합니다. (현재 재고: 1)
  3. A가 재고를 1 감소시켜 '0'으로 업데이트하고 커밋합니다.
  4. B도 자신이 읽었던 '1'에서 1을 감소시켜 '0'으로 업데이트하고 커밋합니다.

분명 2명이 결제에 성공하여 상품이 2개 출고되어야 하는데, DB의 최종 재고는 마이너스가 아닌 '0'으로 남습니다. 누군가의 갱신 내역이 덮어씌워져 사라져버리는 이 현상을 '갱신 분실(Lost Update)'이라고 합니다. 회사 입장에서는 엄청난 금전적 손실과 고객 클레임을 유발하는 최악의 버그입니다.

다중 스레드 환경에서의 경쟁 상태(Race Condition)와 갱신 분실 비유
동시에 같은 데이터를 읽고 수정하려고 할 때 아무런 보호 장치가 없다면, 한쪽의 데이터 업데이트 내역이 완전히 무시되는 갱신 분실(Lost Update) 문제가 발생합니다.

 

2. 방어 전략 ①: 낙관적 락 (Optimistic Lock)

낙관적 락은 "설마 동시에 수정하는 일이 자주 있겠어?"라고 낙관적으로 가정하고, 실제 데이터베이스의 락을 걸지 않은 채 애플리케이션(JPA) 단에서 버전(Version)을 이용해 충돌을 감지하는 방식입니다.

동작 원리 및 트러블슈팅

엔티티에 @Version 어노테이션이 붙은 필드를 하나 추가합니다. 누군가 데이터를 수정하면 버전이 1에서 2로 올라갑니다.

사용자 B가 버전 1일 때의 데이터를 읽어서 수정하려 하는데, DB에는 이미 A가 수정을 마쳐서 버전이 2로 올라가 있다면? 스프링 부트는 가차 없이 아래와 같은 에러를 뿜어냅니다.

Java

org.springframework.orm.ObjectOptimisticLockingFailureException: Object of class [Item] with identifier [1]: optimistic locking failed
  • 실무 베스트 프랙티스: 낙관적 락은 충돌이 났을 때 에러를 던지고 끝내버립니다. 따라서 사용자 B에게 "결제 실패했습니다"라고 보여줄 게 아니라면, 백엔드 개발자가 반드시 백오프(Backoff)와 재시도(Retry) 로직을 직접 구현해야 합니다. (최신 스프링에서는 @Retryable을 활용해 2~3회 정도 재시도하게 만드는 것이 정석입니다.)
  • 장단점: 실제 DB 락을 걸지 않으므로 평상시 성능이 매우 빠릅니다. 하지만 충돌이 잦은 환경에서는 끝없는 재시도가 발생해 오히려 DB 커넥션을 고갈시키는 원인이 됩니다.

3. 방어 전략 ②: 비관적 락 (Pessimistic Lock)

비관적 락은 "무조건 충돌이 날 거야!"라고 비관적으로 가정하고, 데이터를 읽는 순간부터 데이터베이스 자체에 물리적인 락(X-Lock, 배타락)을 걸어버리는 무식하지만 가장 확실한 방법입니다.

동작 원리 및 설정

Spring Data JPA에서는 쿼리 메서드 위에 어노테이션 하나만 붙여주면 끝납니다. 이 쿼리는 SQL의 SELECT ... FOR UPDATE 구문으로 실행됩니다.

Java

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT i FROM Item i WHERE i.id = :id")
Optional<Item> findByIdWithPessimisticLock(@Param("id") Long id);
  • 실무 베스트 프랙티스: A가 데이터에 락을 걸고 있는 동안, B, C, D 사용자는 A의 트랜잭션이 끝날 때까지 꼼짝없이 멈춰서 기다려야(Blocking) 합니다.
  • 치명적인 단점: 충돌을 완벽하게 막아주지만, 동시 처리량이 극단적으로 떨어집니다. 특히 서로 다른 두 개의 테이블을 교차해서 수정하는 로직에 비관적 락을 남발하면, 영원히 서로의 락이 풀리기를 기다리는 데드락(Deadlock, 교착 상태)에 빠져 서버가 기절하게 됩니다.

 

4. 실무의 마스터키 ③: Redis 분산 락 (Redisson)

모놀리식 환경에서 서버가 1대일 때는 DB 락이나 자바의 synchronized로 어느 정도 버틸 수 있습니다. 하지만 서버가 2대 이상으로 스케일 아웃(Scale-out)된 대규모 실무 환경이라면 이야기가 다릅니다.

 

이때 가장 현대적인 실무 표준으로 쓰이는 것이 바로 Redis를 활용한 분산 락(Distributed Lock)입니다. 그중에서도 Redisson(레디슨) 라이브러리가 독보적인 입지를 차지하고 있죠.

Redis Redisson Pub/Sub 기반 분산 락 동작 원리 비유
Redisson 분산 락은 스레드들이 락을 얻기 위해 계속 찔러보지 않도록, 락이 해제되면 대기 중인 다음 스레드에게 "이제 네 차례야"라고 알려주는 효율적인 Pub/Sub 메커니즘을 사용합니다.

왜 기본 Lettuce 대신 Redisson을 쓸까요?

스프링 부트의 기본 Redis 클라이언트인 Lettuce로 분산 락을 만들면, 락을 얻지 못한 스레드들이 0.1초마다 "락 풀렸어? 락 풀렸어?" 하고 계속 Redis 서버를 찔러보는 스핀 락(Spin Lock) 방식으로 구현해야 합니다. 이는 Redis 서버의 CPU를 순식간에 터뜨립니다.

 

반면 Redisson은 Pub/Sub (구독/발행) 방식을 사용합니다. 락이 풀릴 때까지 조용히 대기석에 앉아있으면, Redisson이 "락 풀렸으니 다음 분 들어오세요~" 하고 알림을 줍니다. Redis 서버에 가해지는 부하가 획기적으로 줄어들죠. 게다가 락을 점유하고 서버가 뻗었을 때를 대비해 알아서 락을 해제해 주는 타임아웃 메커니즘까지 완벽하게 추상화되어 있습니다.

Java

// 실무 Redisson 락 적용 예시 
RLock lock = redissonClient.getLock("item_stock_lock:" + itemId);

try {
    // 락 획득 시도 (대기 시간 5초, 락 점유 시간 3초)
    boolean isLocked = lock.tryLock(5, 3, TimeUnit.SECONDS);
    if (!isLocked) {
        throw new IllegalStateException("주문량이 많아 처리가 지연되고 있습니다.");
    }
    // 재고 차감 로직 실행
    itemService.decreaseStock(itemId, quantity);
} finally {
    if (lock.isLocked() && lock.isHeldByCurrentThread()) {
        lock.unlock(); // 로직 종료 후 락 반환
    }
}

 

💡 [데브프리의 심층 면접 꿀팁: Redisson을 썼는데도 뚫린다고요? '@Transactional'의 함정!]

면접관이 "분산 락을 걸 때 @Transactional을 같은 메서드에 붙이면 어떤 문제가 발생하나요?"라고 묻는다면 100점짜리 압박 질문입니다!

방금 작성한 재고 차감 메서드에 무심코 @Transactional을 붙였다고 가정해 봅시다. 스프링의 트랜잭션(AOP)은 메서드가 끝난 '이후'에 DB 커밋을 수행합니다.

즉, finally 블록에서 락이 먼저 풀려버리고, DB에 커밋이 완료되기 직전의 아주 짧은 찰나의 순간에 대기 중이던 다음 스레드가 락을 쥐고 아직 커밋되지 않은 과거의 데이터를 읽어버리게 됩니다! (결국 갱신 분실이 또 터집니다.)

이를 완벽하게 방어하려면 '락을 거는 클래스(퍼사드, Facade)'와 '트랜잭션이 걸린 비즈니스 로직 클래스'를 분리하여, 반드시 트랜잭션 커밋이 완료된 이후에 락이 해제되도록 아키텍처를 설계해야 한다는 점을 짚어주세요. 이 대답 하나면 면접관의 기립 박수를 받을 수 있습니다!

 

5. 전문가의 확고한 결론 (Verdict)

동시성 제어 기술, 실무에 언제 어떤 방식을 도입해야 할까요?

  • 낙관적 락 (Optimistic Lock) 도입 시점:
  • "충돌이 어쩌다 한 번 발생할 것 같은" 백오피스 관리자 페이지의 게시글 수정이나 회원 정보 수정 등 일반적인 비즈니스 로직에 도입하세요. DB 성능 저하 없이 가장 가볍고 깔끔하게 무결성을 유지할 수 있습니다.
  • 비관적 락 (Pessimistic Lock) 도입 시점:
  • 서버가 1대(또는 DB가 단일 인스턴스)이면서, 충돌이 꽤 빈번하게 일어나지만 별도의 Redis 인프라를 구축할 돈과 여력이 없는 프로젝트 초기에 도입하세요. 단, 데드락 위험성을 팀원들과 반드시 공유하고 트랜잭션 범위를 아주 짧게 쪼개야 합니다.
  • Redis 분산 락 (Redisson) 도입 시점:
  • 선착순 쿠폰 발급, 초특가 핫딜, 티켓팅, 재고 차감 등 '초당 수백, 수천 건의 충돌이 확정적으로 일어나는' 트래픽 폭주 환경이라면 선택이 아닌 필수입니다. 다중 서버 환경에서 락 관리를 DB에서 Redis로 분리하여 데이터베이스의 과부하를 막아주는 가장 강력한 아키텍처입니다.

동시성 관리는 개발자의 코딩 스킬을 넘어, 백엔드 아키텍처 전체의 흐름을 읽는 시야를 요구합니다. 오늘 정리해 드린 3가지 락의 특성을 명확히 이해하신다면, 트래픽이 폭주하는 이벤트 날에도 두 다리 쭉 뻗고 주무실 수 있을 것입니다.

 

항상 팩트에 기반한 객관적인 실무 가이드로 여러분의 삽질 시간을 줄여드리겠습니다. 다음 포스팅에서도 개발자들에게 유용한 도구 최적화 팁으로 찾아뵙겠습니다!

 

👉 실서버 뻗기 전에 꼭 확인하세요! 내 서버 스펙에 맞는 최적의 DB 커넥션 풀 개수 계산기 [링크]