개요
재고시스템으로 알아보는 동시성이슈 해결방법 강의 | 최상용 - 인프런
최상용 | 동시성 이슈란 무엇인지 알아보고 처리하는 방법들을 학습합니다., 동시성 이슈 처리도 자신있게! 간단한 재고 시스템으로 차근차근 배워보세요. 백엔드 개발자라면 꼭 알아야 할 동
www.inflearn.com
해당 게시글은 위 인프런 강의를 수강한 내용을 바탕으로 작성되었습니다.
개발 환경
- Java 17
- SpringBoot 3.3.4
- Gradle
- MySQL, JPA
목표
예제 프로젝트를 통해 멀티스레드 환경에서 발생할 수 있는 동시성 이슈를 효과적으로 예방/해결할 수 있는 방법을 학습한다.
선수 과목
- Spring, JPA
동시성 이슈 발생
우선 동시성 이슈가 발생하는 것을 확인할 수 있는 예제 코드를 작성해 보자.
Entity
@Entity
@NoArgsConstructor
public class Stock {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
@Getter
private Long quantity;
public Stock(Long productId, Long quantity) {
this.productId = productId;
this.quantity = quantity;
}
public void decrease(Long quantity) {
if (this.quantity - quantity < 0) {
throw new RuntimeException("재고는 0개 미만이 될 수 없습니다");
}
this.quantity -= quantity;
}
}
Repository
public interface StockRepository extends JpaRepository<Stock, Long> {
}
Service
@Service
public class StockService {
private final StockRepository stockRepository;
public StockService(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
@Transactional
public void decrease(Long id, Long quantity) {
// Stock 조회
// 재고를 감소
// 갱신된 값을 저장
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}
Test Code
@SpringBootTest
class StockServiceTest {
@Autowired
private StockService stockService;
@Autowired
private StockRepository stockRepository;
@BeforeEach
public void before() {
stockRepository.saveAndFlush(new Stock(1L, 100L));
}
@AfterEach
public void after() {
stockRepository.deleteAll();
}
@Test
public void 재고감소() {
stockService.decrease(1L, 1L);
// 100 - 1 = 99
Stock stock = stockRepository.findById(1L).orElseThrow();
assertEquals(99, stock.getQuantity());
}
@Test
public void 동시에_100개의_요청() throws InterruptedException {
int threadCount = 100;
// 비동기 작업을 단순하게 작성할 수 있도록 도와주는 클래스
ExecutorService executorService = Executors.newFixedThreadPool(32);
// 다른 Thread에서 수행 중인 작업이 완료될 때까지 대기할 수 있도록 도와주는 클래스
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
stockService.decrease(1L, 1L);
} finally {
latch.countDown();
}
});
}
latch.await();
Stock stock = stockRepository.findById(1L).orElseThrow();
// 100 - (1 * 100) = 0
assertEquals(0, stock.getQuantity());
}
}
'재고감소' 테스트는 정상적으로 통과하나, 동시에 100개 요청하는 테스트 코드를 실행하면 아래와 같은 오류가 발생한다.

for문을 총 100번 순회하며 비동기적으로 재고의 감소가 이루어질 수 있도록 하여 최종적으로 0개의 재고가 남을 것을 기대했지만, 90개가 남아버렸다는 것이다.
이는 스레드가 비동기적으로 처리되며 경쟁 조건(Race Condition)이 발생했기 때문이다.
경쟁 조건(Race Condition)
- 둘 이상의 스레드가 공유 자원에 접근할 수 있고, 동시에 변경하려고 할 때 발생하는 문제
예제에서 Thread1부터 Thread32가 동시에 Stock의 quantity를 조회하면 모든 스레드에 quantity가 100으로 조회될 것이고, 이를 감소시킨 뒤 저장하면 작업은 총 32번 수행되었지만 재고는 99로 기록되는 것이다. 한 마디로, 다른 스레드가 갱신한 값이 아니라 그 이전의 값을 그대로 가져다 쓰기 때문에 문제가 발생하는 것이다.
따라서 하나의 스레드가 작업을 완료한 이후에 다른 스레드가 데이터에 접근할 수 있도록 해야 한다.
Synchronized 사용
자바에서는 Synchronized를 사용하면 손쉽게 메서드에 한 번에 한 개의 스레드만 접근이 가능하도록 할 수 있다.
Service 수정
@Transactional
public synchronized void decrease(Long id, Long quantity) {
// Stock 조회
// 재고를 감소
// 갱신된 값을 저장
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
위같이 메서드 선언부에 synchronized를 입력하여 쉽게 적용할 수 있다. 하지만 테스트 코드를 다시 실행해 보면 다음과 같이 테스트에 실패하게 된다.

분명 synchronized를 사용하여 한 번에 한 개의 스레드만 접근 가능하도록 했으나, 예상과 다르게 동작한다는 것을 확인할 수 있다. 이는 @Transactional의 동작과 관련된 문제이다.
@Transactional을 사용하면 메서드를 한 번 래핑한 클래스로 수행하게 되는데, 메서드가 성공적으로 끝났을 때 DB에 결과값을 반영하도록 한다. 이때, DB에 반영하는 동작을 수행할 때에는 synchronized를 지정한 메서드를 수행하고 있는 중이 아니므로 DB에 결과값이 반영되기도 전에 다른 스레드가 메서드에 접근하는 것이다.
Transaction 동작 예제
public class TransactionStockService {
private StockService stockService;
private TransactionStockService(StockService stockService) {
this.stockService = stockService;
}
public void decrease(Long id, Long quantity) {
startTransaction();
stockService.decrease(id, quantity);
// 트랜잭션을 완료하는 동작을 수행할 때에는 stockService.decrease 메서드를 수행하는 중이 아니므로, 다른 스레드가 접근 가능
endTransaction();
}
private void startTransaction() {
System.out.println("Trasaction Start");
}
private void endTransaction() {
System.out.println("Commit");
}
}
따라서 임시방편으로 @Transactional 어노테이션을 주석 처리한 뒤 테스트 코드를 실행하면 다음과 같이 통과하게 된다.

한계

Java의 Synchronized는 하나의 프로세스 안에서만 보장이 된다. 즉, 서버가 한 대 이상일 경우 각 서버에서 공유 자원에 동시에 접근할 수 있게 되므로 경쟁 조건을 해결할 수 없다.
따라서 실제 운영 환경에서는 서버를 2대 이상 사용하는 경우가 많으므로 synchronized는 잘 사용하지 않는 방법이다.
Database를 활용하는 방법
Database를 활용하여 경쟁 조건을 해결하는 방법에는 크게 세 가지가 있다.
1. Pessimistic Lock (비관적 락)
특징 | - Low나 Table 등의 단위로 데이터에 실제로 Lock을 거는 방법 - Exclusive Lock(배타적 잠금)을 걸게 되면 lock이 해제되기 전까지 다른 트랙잰션에서 데이터를 가져갈 수 없음 |
장점 | - 충돌이 빈번한 환경에서는 Optimistic Lock보다 성능이 좋을 수 있음 - Lock을 통해 제어되기 때문에 데이터 정합성이 보장됨 |
단점 | - 교착 상태(Deadlock)이 걸릴 수 있기 때문에 사용에 주의해야 함 - 별도의 Lock을 잡기 때문에 성능 감소가 일어날 수 있음 |
* 교착 상태: 두 작업이 서로가 끝나기만을 기다려 무한정 대기중인 상태
예제 코드
// Repository
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticLock(Long id);
}
// PessimisticLockStockService 신규 작성
@Service
public class PessimisticLockStockService {
private final StockRepository stockRepository;
public PessimisticLockStockService(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
@Transactional
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findByIdWithPessimisticLock(id);
stock.decrease(quantity);
stockRepository.save(stock);
}
}
// Test 코드 내 stockService 구현체 변경
@Autowired
private PessimisticLockStockService stockService;

Hibernate 로그를 확인하면 아래와 같은 로그가 남는 것을 확인할 수 있는데, 'for update' 부분이 lock을 획득하고 데이터를 가져오는 부분이다.
Hibernate: select s1_0.id,s1_0.product_id,s1_0.quantity from stock s1_0 where s1_0.id=? for update
2. Optimistic Lock (낙관적 락)
특징 | - 실제 데이터에 Lock을 걸지 않고, Version을 이용하는 방법 - update를 수행할 때 조회한 버전과 실제 DB에 저장된 버전이 일치하는지 확인하여 수행 - 다른 트랙잭션에서 먼저 업데이트하여 버전이 충돌하는 경우, 데이터를 롤백하거나 로직을 재시도 |
장점 | - 별도의 락을 잡지 않아 Pessimistic Lock 보다 성능이 좋음 - 자원을 장시간 점유하지 않기 때문에 데드락이 잘 발생하지 않음(ForeignKey가 존재할 경우엔 발생 가능) |
단점 | - 업데이트가 실패했을 때 재시도 로직을 개발자가 직접 작성해야 함 - 충돌이 빈번한 경우에는 Pessimistic Lock 보다 성능이 떨어질 수 있음 |
예제 코드
// Stock에 version 컬럼 추가
@Version // javax.persistence 패키지를 사용해야 함. Java17 이상은 jakarta.persistence
private Long version;
// StockRepository에 OptimisticLock 사용 메서드 추가
@Lock(LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(Long id);
// OptimisticLockStockService 신규 작성
@Service
public class OptimisticLockStockService {
private final StockRepository stockRepository;
public OptimisticLockStockService(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
@Transactional
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findByIdWithOptimisticLock(id);
stock.decrease(quantity);
stockRepository.save(stock);
}
}
// OptimisticLockStockFacade 신규 작성 (실패 시 재시도를 위한 클래스)
@Component
public class OptimisticLockStockFacade {
private final OptimisticLockStockService optimisticLockStockService;
public OptimisticLockStockFacade(OptimisticLockStockService optimisticLockStockService) {
this.optimisticLockStockService = optimisticLockStockService;
}
public void decrease(Long id, Long quantity) throws InterruptedException {
while (true) {
try {
optimisticLockStockService.decrease(id, quantity);
break;
} catch (Exception e) {
Thread.sleep(50);
}
}
}
}
테스트 코드
@SpringBootTest
class OptimisticLockStockFacadeTest {
@Autowired
private OptimisticLockStockFacade optimisticLockStockFacade;
@Autowired
private StockRepository stockRepository;
@BeforeEach
public void before() {
stockRepository.saveAndFlush(new Stock(1L, 100L));
}
@AfterEach
public void after() {
stockRepository.deleteAll();
}
@Test
public void 동시에_100개의_요청() throws InterruptedException {
int threadCount = 100;
// 비동기 작업을 단순하게 작성할 수 있도록 도와주는 클래스
ExecutorService executorService = Executors.newFixedThreadPool(32);
// 다른 Thread에서 수행 중인 작업이 완료될 때까지 대기할 수 있도록 도와주는 클래스
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
optimisticLockStockFacade.decrease(1L, 1L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
latch.countDown();
}
});
}
latch.await();
Stock stock = stockRepository.findById(1L).orElseThrow();
// 100 - (1 * 100) = 0
assertEquals(0, stock.getQuantity());
}
}
테스트 코드를 실행하면 마찬가지로 테스트에 성공하게 된다.
한편 OptimisticLockStockFacade의 decrease 메서드를 디버그해보면 실제로 version이 충돌하여 ObjectOptimisticLockingFailureException이 발생하는 모습을 확인할 수 있다.

3. Named Lock

특징 | - 이름을 가진 Metadata Lock을 사용하는 방법 - 이름을 가진 Lock을 획득한 뒤, Lock을 해제할 때까지 다른 세션은 이 Lock을 획득할 수 없도록 함 - Pessimistic Lock과 유사하나, Low나 Table 등의 데이터가 아니라 별도의 공간에 Metadata를 이용한 Lock - 분산 락(서버 분산 환경에서의 락)을 구현할 때 주로 사용 |
장점 | - Pessimistic Lock은 타임아웃을 구현하기 힘들지만 Named Lock은 타임아웃을 손쉽게 구현할 수 있음 - 데이터 삽입 시 정합성을 맞추기 위해 Lock을 사용할 때 유용함 |
단점 | - Transaction이 종료될 때 자동으로 Lock이 해제되지 않으므로, 별도의 명령으로 Lock 해제를 수행하거나 선점 시간이 끝나야 해제됨 - 구현 방법이 복잡할 수 있음 |
예제 코드는 편의를 위해 같은 데이터 소스를 활용하나, 실무에서 사용 시 Lock과 관련한 데이터소스를 분리해서 사용해야 한다. 같은 데이터소스를 사용할 경우, 커넥션 풀을 공유하기 때문에 커넥션 풀이 부족해질 경우 서비스에 영향을 미칠 수 있다.
예제 코드
// Lock 획득/해제 목적인 LockRepository 신규 작성
public interface LockRepository extends JpaRepository<Stock, Long> {
// MySQL 에서는 get_lock, release_lock을 통해 namedLock을 획득하거나 해제할 수 있다.
@Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
void getLock(String key);
@Query(value = "select release_lock(:key)", nativeQuery = true)
void releaseLock(String key);
}
// NamedLockStockFacade 신규 작성
// Lock 획득과 해제 작업을 위해 Service를 매핑한 클래스
@Component
public class NamedLockStockFacade {
private final LockRepository lockRepository;
private final StockService stockService;
public NamedLockStockFacade(LockRepository lockRepository, StockService stockService) {
this.lockRepository = lockRepository;
this.stockService = stockService;
}
@Transactional
public void decrease(Long id, Long quantity) {
try {
lockRepository.getLock(id.toString());
stockService.decrease(id, quantity);
} finally {
lockRepository.releaseLock(id.toString());
}
}
}
// StockServcie 수정
// 트랜잭션이 부모의 트랜잭션과 별개로 실행되어야 하기 때문에 propagation 추가
// Propagation.REQUIRES_NEW는 새로운 트랜잭션을 항상 시작하도록 강제하는 역할을 함
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void decrease(Long id, Long quantity) {
// Stock 조회
// 재고를 감소
// 갱신된 값을 저장
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
application.yml에 추가
spring:
datasource:
hikari:
maximum-pool-size: 40
테스트 코드
@SpringBootTest
public class NamedLockStockFacadeTest {
@Autowired
private NamedLockStockFacade namedLockStockFacade;
@Autowired
private StockRepository stockRepository;
@BeforeEach
public void before() {
stockRepository.saveAndFlush(new Stock(1L, 100L));
}
@AfterEach
public void after() {
stockRepository.deleteAll();
}
@Test
public void 동시에_100개의_요청() throws InterruptedException {
int threadCount = 100;
// 비동기 작업을 단순하게 작성할 수 있도록 도와주는 클래스
ExecutorService executorService = Executors.newFixedThreadPool(32);
// 다른 Thread에서 수행 중인 작업이 완료될 때까지 대기할 수 있도록 도와주는 클래스
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
namedLockStockFacade.decrease(1L, 1L);
} finally {
latch.countDown();
}
});
}
latch.await();
Stock stock = stockRepository.findById(1L).orElseThrow();
// 100 - (1 * 100) = 0
assertEquals(0, stock.getQuantity());
}
}

Redis를 활용하는 방법
대표적인 라이브러리로 아래와 같이 두 가지가 존재한다.
Lettuce | - setNx() 명령어를 사용하여 분산 락 구현 - Spin Lock 방식: 스레드가 주기적으로 락 획득을 시도 |
Redisson | - Pub-Sub 방식: 락을 점유중인 스레드가 점유 해제 시 락을 획득하려고 대기중인 스레드들에게 채널을 통해 락 획득을 시도하라고 일제히 전파 |
Redis를 동작하기 위해 아래와 같이 환경설정을 해야 한다.
build.gradle에 의존성 추가
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}
터미널에서 Docker로 Redis 실행
docker pull redis
docker run --name myredis -d -p 6379:6379 redis
1. Lettuce 활용
Lettuce를 활용하여 SpinLock 방식으로 Lock을 구현해보도록 하자.
예제 코드
// RedisLockRepository 신규 작성
@Component
public class RedisLockRepository {
private 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();
}
}
// LettuceLockStockFacade 신규 작성
// SpinLock을 직접 구현해야 한다.
@Component
public class LettuceLockStockFacade {
private final RedisLockRepository redisLockRepository;
private final StockService stockService;
public LettuceLockStockFacade(RedisLockRepository redisLockRepository, StockService stockService) {
this.redisLockRepository = redisLockRepository;
this.stockService = stockService;
}
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);
}
}
}
테스트 코드
@SpringBootTest
class LettuceLockStockFacadeTest {
@Autowired
private LettuceLockStockFacade lettuceLockStockFacade;
@Autowired
private StockRepository stockRepository;
@BeforeEach
public void before() {
stockRepository.saveAndFlush(new Stock(1L, 100L));
}
@AfterEach
public void after() {
stockRepository.deleteAll();
}
@Test
public void 동시에_100개의_요청() throws InterruptedException {
int threadCount = 100;
// 비동기 작업을 단순하게 작성할 수 있도록 도와주는 클래스
ExecutorService executorService = Executors.newFixedThreadPool(32);
// 다른 Thread에서 수행 중인 작업이 완료될 때까지 대기할 수 있도록 도와주는 클래스
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
lettuceLockStockFacade.decrease(1L, 1L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
latch.countDown();
}
});
}
latch.await();
Stock stock = stockRepository.findById(1L).orElseThrow();
// 100 - (1 * 100) = 0
assertEquals(0, stock.getQuantity());
}
}

2. Redisson 활용
이번에는 Redisson을 활용하여 Pub-Sub 기반의 Lock을 구현해보도록 하자.
Redisson 라이브러리를 활용할 경우 별도로 의존성을 추가해야 한다.
build.gradle에 추가
dependencies {
implementation 'org.redisson:redisson-spring-boot-starter:3.24.1'
}
예제 코드
// RedissonLockStockFacade 클래스 신규 추가
// Pub-Sub을 이용해 Lock의 획득/해제를 구현해야 한다.
@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 {
// 테스트가 실패할 경우 waitTime을 늘려주면 된다.
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();
}
}
}
테스트 코드
@SpringBootTest
class RedissonLockStockFacadeTest {
@Autowired
private RedissonLockStockFacade redissonLockStockFacade;
@Autowired
private StockRepository stockRepository;
@BeforeEach
public void before() {
stockRepository.saveAndFlush(new Stock(1L, 100L));
}
@AfterEach
public void after() {
stockRepository.deleteAll();
}
@Test
public void 동시에_100개의_요청() throws InterruptedException {
int threadCount = 100;
// 비동기 작업을 단순하게 작성할 수 있도록 도와주는 클래스
ExecutorService executorService = Executors.newFixedThreadPool(32);
// 다른 Thread에서 수행 중인 작업이 완료될 때까지 대기할 수 있도록 도와주는 클래스
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
redissonLockStockFacade.decrease(1L, 1L);
} finally {
latch.countDown();
}
});
}
latch.await();
Stock stock = stockRepository.findById(1L).orElseThrow();
// 100 - (1 * 100) = 0
assertEquals(0, stock.getQuantity());
}
}

3. Lettuce vs Redisson
장점 | 단점 | |
Lettuce | - 구현이 간단하다. - spring data redis를 이용하면 Lettuce가 내장되어 별도의 라이브러리를 사용하지 않아도 된다. |
- Spin Lock 방식이기 때문에 동시에 많은 스레드가 Lock 획득 대기중인 상태이면 Redis에 부하가 갈 수 있다. |
Redisson | - Lock 획득 재시도를 기본 제공한다. - Pub-Sub 방식 구현이기 때문에 Lettuce와 비교했을 때 Redis에 부하가 덜 간다. |
- Lock을 라이브러리 차원에서 제공하기 때문에 관련 사용법을 숙지해야 한다. |
위와 같은 특징들 때문에 실무에서는 재시도가 필요하지 않은 경우 Lettuce, 재시도가 필요하다면 Redisson을 혼용하는 방식을 채택한다.
'Dev > Java' 카테고리의 다른 글
[Effective Java : Item 90] 직렬화된 인스턴스 대신 직렬화 프록시 사용을 검토하라 (2) | 2024.05.22 |
---|---|
[Effective Java : Item 86] Serializable을 구현할지는 신중히 결정하라 (0) | 2024.05.08 |
[Effective Java : Item 83] 지연 초기화는 신중히 사용하라 (0) | 2024.04.09 |
[Effective Java : Item 77] 예외를 무시하지 말라 (0) | 2024.04.01 |
[Effective Java : Item 69] 예외는 진짜 예외 상황에만 사용하라 (2) | 2024.03.29 |