재고 시스템으로 알아보는 동시성 이슈
인상 깊었던 내용
“동시성이 왜 발생했을까?”
- Race Condition 이 발생했기 때문
- 둘 이상의 스레드가 공유 자원에 접근할 수 있고 동시에 변경을 하려 할 때 발생하는 문제
레이스 컨디션(Race Condition) 이란 ?
-
둘 이상의 스레드가 공유 데이터에 액세스할 수 있고 동시에 변경하려고 할 때 발생하는 문제
- 둘 이상의 스레드 : 요청
- 공유 데이터 : 재고 데이터
- 동시에 변경하려고 할 때 : 업데이트 할때
- 발생하는 문제 : 값이 정상적으로 바뀌지 않는 문제
-
해결방법
- 하나의 스레드만 데이터에 액세스 할 수 있도록 한다.
동시성 해결 방법
- Java 언어 자체적으로 지원하는 Synchronized 이용하기
자바 자체적으로 지원하는 Synchronized 를 이용해서 문제를 해결하기 위해서는 Transactional 어노테이션을 사용할 수 없다.
문제점
여러 서버가 한 번에 접근한다면 어떻게 될까?
syncrhonized 예약어는 각 프로세스 내부에서만 보장이 되기 때문에 다른 프로세스에서 접근하는 행위는 막을 수 없게된다.
-> 결국 여러 스레드에서 동시에 접근하게 되어 Race Condition 이 발생한다.
실제 운영 환경에서는 서버를 여러대 두고 사용하는 것이 범용적이기 때문에 synchronized 예약어로 동시성 문제를 해결하진 않는다.
만약 Transactional 을 사용하면 어떻게 될까?

- Spring은 Transaction을 AOP 방식으로 처리하기 위해 Transactional 을 사용하는 클래스를 래핑한 새로운 클래스를 만들어, startTransaction -> Transactional 을 명시한 메서드 실행 -> endTransaction 순서로 실행하게 된다.
- 여기서 startTransaction -> stockService.decrease 까지 실행 되었고 다른 스레드에서 다시 startTransaction, stockService.decrease 를 실행 한다면 아직 endTransaction 이 안된 데이터로 인해 2번의 재고 감소를 했지만 실질적으로는 재고 감소가 1번 밖에 발생하지 않게된다.
- MySQL 자체 Lock 사용하기
-
Pessimistic Lock (비관적 락)
- 실제로 데이터에 Lock 을 걸어서 정합성을 맞추는 방법입니다. exclusive lock 을 걸게되며 다른 트랜잭션에서는 lock 이 해제되기전에 데이터를 가져갈 수 없게됩니다.
데드락이 걸릴 수 있기때문에 주의하여 사용하여야 합니다.
-
Optimistic Lock (낙관적 락)
- 실제로 Lock 을 이용하지 않고 버전을 이용함으로써 정합성을 맞추는 방법입니다. 먼저 데이터를 읽은 후에 update 를 수행할 때 현재 내가 읽은 버전이 맞는지 확인하며 업데이트 합니다. 내가 읽은 버전에서 수정사항이 생겼을 경우에는 application에서 다시 읽은후에 작업을 수행해야 합니다.
-
Named Lock
- 이름을 가진 metadata locking 입니다. 이름을 가진 lock 을 획득한 후 해제할때까지 다른 세션은 이 lock 을 획득할 수 없도록 합니다. 주의할점으로는 transaction 이 종료될 때 lock 이 자동으로 해제되지 않습니다. 별도의 명령어로 해제를 수행해주거나 선점시간이 끝나야 해제됩니다
낙관적락과 비관적락을 사용하는 기준은 “동시에 수정을 하는일이 빈번하게 일어나는가?” 이다.
-
동시에 수정을 하는일이 적다면 낙관적락
- 충돌이 빈번하게 일어나는 상황에서 낙관적락을 사용하게 된다면 모든 요청이 완료될때까지 재시도를 수행한다.
- 이는 성공할때까지 시도한다고 가정했을 때 데이터베이스에 굉장히 많은 요청을 보내게 되기 때문에 비효율적이다.
-
동시에 수정하는 상황이 빈번하게 일어난다면 비관적락
- 비관적락을 사용한다면 lock 을 걸고 수행하기때문에 이후의 요청들은 업데이트가 완료될때까지 기다렸다가 순차적으로 진행된다.
- 단점으로 데이터 로우와 테이블에 직접적인 물리적 락을 사용하기 때문에 빈번하지 않은 충돌 상황에서는 성능 감소가 일어날 수 있다.
이러한 관점에서 봤을 때 충돌가능성이 많다면 비관적락이 더 좋은 선택이 될 수 있다.
1,000명의 사용자가 동시에 A 라는 상품을 구매할 때 “충돌이 빈번하게 일어난다” 라고 생각할 수 있다.
반대로 1,000명의 사용자가 A 라는 상품을 구매하지만 구매시간이 각자 다를 때 (예를들어 사용자 1은 1시에, 사용자 2는 1시 1분에 사용자 3은 1시 2분에…) “충돌이 비교적 적게 일어난다” 라고 생각할 수 있다.
낙관적락의 구현은 JPA를 사용한다면 크게 어렵지 않게 구현할 수 있기때문에 실무에서도 사용된다.
Redis 활용하기
- Lettuce 활용 Lettuce 를 활용하면 NamedLock 과 비슷하게 동작하지만 별도의 세션을 관리할 필요가 없는 장점이 있다. 다만, Lettuce는 스핀 락 방식으로 Lock 을 획득하지 못했을 때 획득 재시도 하는 로직을 작성하는데, 이 때 일정 시간의 텀을 두지 않으면 레디스에 부하를 줄 수 있다.
Lettuce 는 setnx 명령어를 통해 데이터를 저장하는데, set 하는 key 가 존재하지 않을 때만 데이터를 저장한다.
이 원리로 Lock 을 제어한다.

public void decrease(Long id, Long quantity) throws InterruptedException {
while (!redisLockRepository.lock(id)) {
Thread.sleep(100);
}
try {
stockService.decrease(id, quantity);
} finally {
redisLockRepository.unlock(id);
}}
@Component
public class RedisLockRepository {
private final RedisTemplate<String, String> redisTemplate;
public RedisLockRepository(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public Boolean lock(Long key) {
return redisTemplate
.opsForValue()
.setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000));
}
public Boolean unlock(Long key) {
return redisTemplate.delete(generateKey(key));
}
private String generateKey(Long key) {
return key.toString();
}
}
- Redisson 활용 Redisson 을 사용하기 위해 별도의 의존성을 추가 해야한다.
Redisson 은 Pub/Sub 구조로 Lettuce 가 무한으로 락을 획득하려고 재시도하는 단점을 개선할 수 있는 방식이다. 이는, 채널을 하나 두고 스레드 1이 락을 획득한 뒤 값을 저장하고 락을 풀게되면 채널에게 락을 다 썼다는 내용을 보내게 되고, 다른 스레드는 이 채널을 구독하며 기다리고 있다가 락을 다 썼다는 메시지를 본 후에 락 획득을 시도한다.
Redisson 은 비즈니스 로직에 직접적으로 락 제어 코드를 작성해야하는 복잡성이 있지만, 의존성 하나만 설치하면 별도의 작업 없이 다중 서버에서의 동시성을 해결할 수 있게된다.

@Component
public class RedissonLockStockFacade {
private RedissonClient redissonClient;
private StockService stockService;
public RedissonLockStockFacade(RedissonClient redissonClient, StockService stockService) {
this.redissonClient = redissonClient;
this.stockService = stockService;
}
public void decrease(Long id, Long quantity) {
RLock lock = redissonClient.getLock(id.toString());
try {
boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);
if (!available) {
System.out.println("Lock 획득 실패");
return;
}
stockService.decrease(id, quantity);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
} }
}
Lettuce vs Redisson
-
Lettuce
- 구현이 간단하다
- spring data redis 를 이용하면 lettuce 가 기본이기때문에 별도의 라이브러리를 사용하지 않아도 된다.
- spin lock 방식이기때문에 동시에 많은 스레드가 lock 획득 대기 상태라면 redis 에 부하가 갈 수 있다.
-
Redisson
- 락 획득 재시도를 기본으로 제공한다.
- pub-sub 방식으로 구현이 되어있기 때문에 lettuce 와 비교했을 때 redis 에 부하가 덜 간다.
- 별도의 라이브러리를 사용해야한다.
- lock 을 라이브러리 차원에서 제공해주기 떄문에 사용법을 공부해야 한다.
분산 락 선택 가이드
- 재시도가 필요하지 않은 lock 은 lettuce 활용
- 재시도가 필요한 경우에는 redisson 를 활용
Mysql vs Redis
-
Mysql
- 이미 Mysql 을 사용하고 있다면 별도의 비용없이 사용가능하다.
- 어느정도의 트래픽까지는 문제없이 활용이 가능하다.
- Redis 보다는 성능이 좋지않다.
-
Redis
- 활용중인 Redis 가 없다면 별도의 구축비용과 인프라 관리비용이 발생한다.
- Mysql 보다 성능이 좋다.
새롭게 배운 내용
자바에서 멀티스레드 환경으로 테스트 하는 방법
Executors 를 활용하여 newFixedThreadPool(n) -> 강의에선 n = 32 로 사용함 이 반환 값은 ExecutorService 가 됨
ExecutorService executorService = Executors.newFixedThreadPool(32);