동시성 문제와 여러 해결 방법들
동시성 문제와 여러 해결 방법들

동시성 문제와 여러 해결 방법들

Tags
Spring
Core
Concurrency
Published
October 10, 2025
Author
lkdcode

🔥 concurrency-problem

서버는 사람이던 시스템이던 동시에 여러 요청을 처리한다.
클라이언트의 요청마다 쓰레드를 할당해서 작업을 수행하게 되는데 이때 공유되는 자원에 대한 경합이 동시성 문제을 유발시킬 수 있다. 즉, 서로 다른 스레드가 하나의 자원을 놓고 수정하는 과정에서 동시성 문제가 발생한다.
 
“재고가 100개일 때 100명이 동시에 요청하면 재고는 반드시 0이 되는가?”
동시성 문제를 유발시키고 다양한 방법으로 해결해보자.
 
  1. 문제 발생
  1. 비관적 락
    1. 애플리케이션 수준
    2. 데이터베이스 수준
  1. 낙관적 락
  1. 단일 스레드 처리
 

🚀 1. 문제 발생

@Transactional fun apply(userId: Long, eventId: Long) { if (eventHistoryJpaRepository.existsByEventIdAndUserIdCustom(userId, eventId)) return val stockEntity = eventStockJpaRepository.loadById(eventId) if (stockEntity.stock <= 0) return stockEntity.stock -= 1 val history = EventHistoryJpaEntity(userId = userId, eventId = eventId) eventHistoryJpaRepository.save(history) }
사용자의 고유 아이디와 이벤트의 고유 아이디로 로직을 수행하게 되는데
중복 신청한 경우, 재고가 음수인 경우는 모두 return 으로 실패처리하고 그외에 경우는 재고 수량을 1 차감하고
해당 사용자를 이벤트 신청 내역에 등록한다.
 
위의 로직대로라면 재고가 100개고 동시 요청자가 100명이니 재고는 0개 신청자 수는 100이 되어야 한다.
재고가 음수가 되는 경우와 중복 신청을 막아주고 신청자 1명당 1개의 재고를 차감해준다.
동시에 여러 사용자가 요청한 경우, 결과는 아래와 같다.
notion image
 
실행 결과를 보면 신청자 수는 100명으로 올바르지만, 남은 재고는 89개로 누락된 것을 확인할 수 있다.
실행할 때마다 남은 재고의 결과는 달라지지만 중요한 것은 재고 차감이 누락된 것이다.
notion image
 
위와 같이 공유되는 자원을 동시에 수정하게 되면 예상치 못한 상황이 발생한다.
스레드 1과 스레드 2가 조회한 재고는 93개로 동일하다.
스레드 1이 92개로 재고를 차감하고 스레드 2도 92개로 재고를 차감하면서 커밋을 한다.
이렇게 커밋의 결과가 덮어쓰기가 되어 누락된 것이다.
이를 레이스 컨디션 이라 하며 어떻게 해결할 수 있는지 여러 방법들을 살펴보자.
 

🚀 2. 비관적 락

비관적 락은 실패할 가능성이 높아서 비관적이다. 정상적으로 변경할 가능성이 떨어지므로 잠금을 먼저 획득하는 선점 잠긍릍 통해 동시성 문제에 대응한다. SpringBoot 처럼 애플리케이션 수준에서의 락과 MySQL 과 같은 데이터베이스 수준에서의 락으로 해결해보자.
 

🎯 2-1. Application 비관적 락

크게 synchronizedReentrantLock 을 사용해서 구현할 수 있다.
synchronized 는 자동 락 해제와 상대적으로 쉽게 사용할 수 있고 ReentrantLock 는 조금 더 정밀 제어가 가능하다.
ReentrantLocksynchronized 에 없는 기능들을 제공하는데 대표적으로 잠금 획득 대기 시간을 지정하는 기능이 있다.
 
notion image
 
단일 서버(단일 JVM, 싱글톤 빈) 에서 특별한 이유없이는 늘 성공할 것이다.
실패를 유발하려면 멀티 빈, 멀티 JVM 등 분산 환경에서 사용하면 실패할 것이다.
 
notion image
똑같은 기능을 수행하는 클래스를 하나 더 두고 테스트를 진행한다. 2개의 빈이 마치 분산 서버인 것처럼 테스트를 수행하게 되는데, 신청자 수와 남은 재고가 서로 일치하지 않는 동시성 문제가 발생한다. 분산 서버에서는 각 인스턴스마다 락을 획득하는 방식으로 동작하므로 위의 레이스 컨디션 처럼 문제가 발생할 수 있다.
 

🎯 2-2. Database 비관적 락

DB 수준 비관적 락은 해당 Row 를 잡고 직렬화 하게 만든다.
SELECT .. FOR UPDATE 같은 비관적 락은 데이터베이스 수준에서 동시성 문제를 해결해준다.
notion image
 
첫 번째 트랜잭션이 락을 획득하게되면 다른 트랜잭션은 대기 상태가 된다.
트랜잭션1이 먼저 잠금을 획득했고 트랜잭션2가 잠금 획득을 위해 대기 중이다.
트랜잭션1이 commit/rollback 으로 락을 반납하면 트랜잭션2가 해당 레코드에 대해 쿼리를 수행하게 된다.
 
데이터베이스 수준에서의 잠금은 잠금을 획득하지 못한 다른 트랜잭션은 대기해야 하므로 처리량이 저하되는 단점이 있다. 또 2개 이상의 레코드에서 락을 가져갈 땐 데드락에 특히 신경을 써야 한다.
 
notion image
예를 들어 A,B 2개의 레코드에서 잠금을 획득해야 하는 상황에서
트랜잭션1이 A 락을 획득한 후 B 락 획득을 위해 대기하는 동시에
트랜잭션2가 B 락을 획득한 후 A 락 획득을 위해 대기하는 경우 서로 무한 대기에 빠지게 된다.
이를 데드락이라하며 잠금 획득 순서 설정, 타임아웃 등으로 해결할 수 있다.
 

🚀 3. 낙관적 락

낙관적 락은 실패할 가능성이 적고 명시적으로 잠금을 사용하지 않는 대신 데이터를 조회하는 시점과 수정하려는 시점의 값을 비교하여 동시성 문제를 처리한다.
애플리케이션 수준이나 데이터베이스 수준이나 CAS 방식으로 수행되므로 어느 계층에서 하냐의 차이만 있고 개념적으로는 동일하다.
version 과 관련된 컬럼이 있다면 해당 컬럼을 이용하고 그게 아니라면 최초 조회했을 때와 값이 같은지 비교할만한 대상을 선택한 후 CAS 로 업데이트해주면 된다.
사용자는 매 요청마다 성공할 수는 없겠지만 대기 시간 없이 즉답하므로 오히려 사용자 경험이 낫다고 볼 수 있다.
CAS 는 원자적으로 수행되어야 하므로 한 방 SQL 이어야 한다.
조회하고 비교해서 UPDATE 하게 되면 그 짧은 시간동안에 정합성(TOUTOC)이 틀어질 수 있다.
사전 조회는 해도 되지만 비교 자체는 WHERE 절에서 DB 가 수행해야 한다.
Application 수준에서의 낙관적 락은 분산 시스템(DB 분산 제외)에서도 유용하게 사용이 가능하다.
notion image
 
이처럼 여러 스레드(트랜잭션)가 동시에 수행하더라도 CAS 방식으로 수행되는 쿼리는 재고 차감 누락없이 로직을 수행할 수 있다. 단 전체 요청이 모두 성공하지는 않는다.
CAS 쿼리의 결과는 Row 수를 반환하는데 0이면 실패, 1이면 성공을 의미한다. 조건에 따라 1개 이상의 레코드를 수정할 수 있지만 0이면 실패한 것은 동일하다. Row 수의 결과에 따라 재시도를 하거나, 익셉션을 발생시켜 사용자로부터 재시도/취소를 유도하거나 등 적절하게 핸들링해주면 된다.
 

🚀 4. 단일 스레드

분산 환경에서 경합을 줄이는 방법 중 하나는 단일 스레드 이벤트 루프(ex. Redis)로 해당 작업을 직렬화하는 것이다. 한 인스턴스 내부에서는 명령이 순차 실행되고 단일 명령(ex.Lua 스크립트)/트랜잭션 단위는 원자적으로 처리된다.
 
기존 CAS/선점 락 모두 각 스레드가 DB(자원)에 접근한 후 충돌이 나면 실패하거나 재시도하는 방식인 반면
Redis 큐/스트림은 각 스레드가 작업을 큐에 적재하고 워커가 순서대로 실행하므로 충돌 자체가 크게 줄어든다.
 
LPUSH + BRPOP 조합은 FIFO 가 보장되고 Streams 는 컨슈머 그룹/파티셔닝 단위로 순서가 보장된다.
멱등 처리는 필수고 메시지 유실 및 재처리는 적절히 대응하면 된다.
notion image
 
작업 순서를 직렬화하니 처리량이 저하된다고 생각할 수 있지만 직렬화는 키 단위로만 적용하고 파티션을 늘려 처리량을 확보할 수 있다.
 
notion image
위의 레이스 컨디션 이 발생한 코드도 작업의 순서를 직렬화하니 성공하는 것을 확인할 수 있다.