동시성 문제는 백엔드 개발에 있어 매우 중요한 문제 중 하나이다.
백엔드 개발자는 동시성을 고려한 프로그래밍을 할 줄 알아야 한다.
따라서 이번 장에서는 스프링과 자바를 사용하여
간단한 예시를 통해 동시성 문제가 발생하는 경우에 대해 살펴보고
이를 해결하는 3가지 방법에 대해 알아보도록 한다.
재고시스템
재고감소 로직 작성
- 재고시스템의 간단한 재고감소 로직을 예시로 동시성 문제를 살펴볼 것이다. 해당 글은 동시성 문제에 대해 집중적으로 다루는 글이므로 코드에 대한 상세한 내용은 생략하고 설명을 진행한다.
Stock
package com.example.stock.domain;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class Stock {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
public Stock() {
}
public Stock(Long productId, Long quantity) {
this.productId = productId;
this.quantity = quantity;
}
public Long getQuantity() {
return quantity;
}
public void decrease(Long quantity) {
if (this.quantity - quantity < 0) {
throw new RuntimeException("재고는 0개 미만이 될 수 없습니다.");
}
this.quantity -= quantity;
}
}
StockRepository
package com.example.stock.repository;
import com.example.stock.domain.Stock;
import org.springframework.data.jpa.repository.JpaRepository;
public interface StockRepository extends JpaRepository<Stock, Long> {
}
StockService
package com.example.stock.service;
import com.example.stock.domain.Stock;
import com.example.stock.repository.StockRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@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 = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.save(stock);
}
}
StockServiceTest
- StockService에 대한 테스트 코드를 작성하여 재고 감소가 정상적으로 되는지 확인해 보자.
- 현재 100개의 재고에서 1개의 재고를 감소시켰을 때 99개가 되는지 확인하는 테스트 코드를 작성하였다.
import com.example.stock.repository.StockRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
@SpringBootTest
class StockServiceTest {
@Autowired
private StockService stockService;
@Autowired
private StockRepository stockRepository;
@BeforeEach
public void before() {
stockRepository.save(new Stock(1L, 100L));
}
@AfterEach
public void after() {
stockRepository.deleteAll();
}
@Test
@DisplayName("100개의 재고 중 한 개를 감소했을 때 99가 된다.")
public void decreaseStock() {
// given
// when
stockService.decrease(1L, 1L);
// then
Stock stock = stockRepository.findById(1L).orElseThrow();
assertThat(stock.getQuantity()).isEqualTo(99);
}
}
- 실행 결과 정상적으로 재고가 잘 감소되는 것을 확인할 수 있다.
문제점
- 현재 테스트 케이스는 요청이 한 개씩 들어오는 상황에 대해서만 고려한 것이다. 하지만, 요청이 동시에 여러 개가 들어오는 경우 다음과 같은 문제가 발생한다.
StockServiceTest
- 동시에 100개의 재고 감소 요청이 발생하는 테스트 코드를 추가해 보자.
- 이를 위해 멀티 스레드를 사용해 테스트를 진행한다.
@Test
@DisplayName("동시에 100개의 재고 감소 요청이 발생")
public void decreaseConcurrently() throws InterruptedException {
// given
int threadCount = 100; // 멀티 스레드 사용
ExecutorService executorService = Executors.newFixedThreadPool(32); // 비동기로 실행하는 작업을 단순화하여 사용할 수 있게 도와주는 Java의 API
CountDownLatch latch = new CountDownLatch(threadCount); // 다른 Thread에서 수행 중인 작업이 완료될 때까지 대기할 수 있도록 도와주는 클래스
// when
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
// then
assertThat(stock.getQuantity()).isEqualTo(0);
}
- ExecutorService : 비동기로 실행하는 작업을 단순화하여 사용할 수 있게 도와주는 JAVA의 API
- CountDownLatch : 다른 스레드에서 수행 중인 작업이 완료될 때까지 대기할 수 있도록 도와주는 클래스
예상 결과
- 동시에 100개의 재고를 감소시켰기 때문에 이후 재고가 0개가 될 것이라고 예상된다.
- 하지만, 실제로 테스트 코드를 실행해 보면 테스트가 실패한다.
- 로그를 살펴보면 우리가 기대한 값은 0인데, 실제로는 완전 다른 값이 나온 것을 확인할 수 있다.
Race Condition
- 해당 테스트가 실패한 이유는 바로 Race Condition 때문이다.
- Race Conditiond이란, 둘 이상의 Thread가 공유데이터에 액세스 할 수 있고 동시에 변경을 하려고 할 때 발생하는 문제를 말한다.
- 아래의 표를 통해 좀 더 자세히 살펴보자.
- 우리는 Thread1이 갱신한 값을 Thread2가 가져간 이후 갱신을 하는 것으로 예상했다.
Thread1 | Stock | Thread2 |
select * from stock where id = 1; | 5 | |
update set quantity = 4 from stock where id = 1; | 4 | |
4 | select * from stock where id = 1; | |
3 | update set quantity = 4 from stock where id = 1; |
- 하지만 실제로는 Thread1이 데이터를 가져가서 갱신되기 전에 Thread2가 데이터를 가져가 갱신하게 된다.
- Thread1이 갱신을 하고 Thread2도 갱신을 하지만 둘 다 재고가 5인 상태에서 1을 감소시키기 때문에 갱신이 누락되게 된다.
- 이처럼 두 개 이상의 스레드가 공유 데이터에 액세스를 할 수 있고, 동시에 변경을 하려고 할 때 발생하는 문제가 Race Condition이다.
- 이 문제를 해결하기 위해서는 하나의 스레드가 작업을 완료한 이후에 다른 스레드가 데이터에 접근할 수 있도록하면 된다.
Thread1 | Stock | Thread2 |
select * from stock where id = 1; | 5 | |
5 | select * from stock where id = 1; | |
update set quantity = 4 from stock where id = 1; | 4 | |
4 | update set quantity = 4 from stock where id = 1; |
Synchronized 사용
Synchronized
- 동시성 문제를 해결하기 위한 첫 번째 방법은 Synchronized를 사용하는 것이다.
- 자바에서는 Synchronized를 사용해 한 개의 Thread만 접근이 가능하도록 할 수 있다.
- Synchronized를 메서드 선언부에 붙여주면 해당 메서드는 한 개의 Thread만 접근이 가능하게 된다.
StockService
@Transactional
public synchronized void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.save(stock);
}
StockServiceTest
- 다시 이전에 실패했던 테스트를 실행해 보면 synchronized를 사용했음에도 불구하고 테스트에 실패한다.
Transactional
- 테스트가 실패한 이유는 바로 스프링의 Transactional 어노테이션 때문이다.
- Transactional 어노테이션을 사용하면 아래처럼 프록시 객체 형태로 동작하게 된다.
- 프록시 인스턴스에서 트랜잭션을 제어하는 경우 synchronized 키워드가 붙어있지 않는다.
- 트랜잭션이 종료되기 전에는 db에 데이터가 반영되지 않기 때문에 동시에 서로 다른 스레드가 데이터에 접근할 수 있다. 따라서 이전과 동일한 이유로 테스트 코드를 실행했을 때 실패한 것이다.
package io.devlabs.stocksystem.service;
public class TransactionStockService {
private StockService stockService;
public TransactionStockService(StockService stockService) {
this.stockService = stockService;
}
public void decrease(Long id, Long quantity) {
startTransaction();
stockService.decrease(id, quantity);
endTransaction();
}
private void startTransaction() {
System.out.println("Start Transaction");
}
private void endTransaction() {
System.out.println("Commit");
}
}
- Transactional 어노테이션을 주석 처리하고 다시 테스트 코드를 실행해 보면
//@Transactional
public synchronized void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.save(stock);
}
- 테스트에 성공하는 것을 확인할 수 있다.
문제점
- 자바의 synchronized를 사용해 동시성 문제를 해결해 보았다. 하지만, synchronized를 이용했을 때 생기는 문제점이 존재한다.
- 자바의 synchronized는 하나의 프로세스 안에서만보장이 된다.
- 즉, 서버 인스턴스가 1개일 때는 문제가 없지만 서버가 2개 이상일 경우 여러 스레드에서 동시에 데이터에 접근을 할 수 있게 되어 Race Condition이 발생한다.
- 실제 운영되는 서비스는 대부분 수십대의 서버 인스턴스를 사용하기 때문에 synchronized 키워드는 거의 사용되지 않는다.
MySQL
- 이번에는 MySQL을 활용해 동시성 문제를 해결하는 방법에 대해 알아보자
다양한 방법 알아보기
1. Pessimistic Lock
- 실제로 데이터에 Lock을 걸어 정합성을 맞추는 방법이다.
- Raw, Table 단위로 동작한다.
- exclusive lock을 걸게 되며 다른 트랜잭션에서는 lock이 해제되기 전에 데이터를 가져갈 수 없다.
- 하지만 데드락이 걸릴 수 있어 주의해서 사용해야 한다.
2. Optimistic Lock
- 실제로 Lock을 이용하지 않고 버전을 이용함으로써 정합성을 맞추는 방법이다.
- 먼저 데이터를 읽은 후에 update를 수행할 때 현재 내가 읽은 버전이 맞는지 확인하며 업데이트한다.
- 내가 읽은 버전에서 수정사항이 생겼을 경우 application에서 다시 읽은 후에 작업을 수행해야 한다.
3. Named Lock
- 이름을 가진 metadata locking이다.
- 이름을 가진 lock을 획득한 후 해제할 때까지 다른 세션은 이 lock을 획득할 수 없도록 한다.
- transaction이 종료될 때 lock이 자동으로 해제되지 않아 별도의 명령어로 해제를 수행해 주거나 선점시간이 끝나야 해제된다.
Pessimistic Lock 활용
StockRepository
- StockRepository에 @Lock 어노테이션을 활용해 Pessimistic Lock을 걸어준다.
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticLock(@Param("id") Long id);
}
PessimisticLockStockService
- PessimisticLockStockService 클래스를 추가해 findByIdWithPessimisticLock을 사용하여 재고를 감소하는 decrease 메서드를 추가한다.
@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);
}
}
StockServiceTest
- 테스트 코드에서 기존에 주입받아 사용했던 StockService를 PessimisticStockService로 변경한 후 다시 테스트를 실행한다.
@SpringBootTest
class StockServiceTest {
@Autowired
private PessimisticLockStockService stockService;
@Autowired
private StockRepository stockRepository;
@BeforeEach
public void before() {
stockRepository.save(new Stock(1L, 100L));
}
@AfterEach
public void after() {
stockRepository.deleteAll();
}
@Test
@DisplayName("동시에 100개의 재고 감소 요청이 발생")
public void decreaseConcurrently() throws InterruptedException {
// given
int threadCount = 100; // 멀티 스레드 사용
ExecutorService executorService = Executors.newFixedThreadPool(32); // 비동기로 실행하는 작업을 단순화하여 사용할 수 있게 도와주는 Java의 API
CountDownLatch latch = new CountDownLatch(threadCount); // 다른 Thread에서 수행 중인 작업이 완료될 때까지 대기할 수 있도록 도와주는 클래스
// when
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
// then
assertThat(stock.getQuantity()).isEqualTo(0);
}
}
- 실행 결과 테스트가 통과하는 것을 확인할 수 있다.
- 실제 쿼리를 살펴보면 for update라는 문구를 볼 수 있는데 이 부분이 실제로 Lock을 걸고 데이터를 가져오는 부분이다.
장점
- 충돌이 빈번하게 일어날 경우 Optimistic Lock보다 성능이 좋을 수 있다.
- Lock을 통해 update를 제어하므로 데이터 정합성이 보장된다.
단점
- 별도의 Lock을 잡기 때문에 성능 감소가 될 수 있다.
Optimistic Lock 활용
Stock
- Optimistic Lock을 사용하기 위해 version 필드를 추가하고, 어노테이션을 붙여준다.
@Entity
public class Stock {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
@Version
private Long version;
public Stock() {
}
public Stock(Long productId, Long quantity) {
this.productId = productId;
this.quantity = quantity;
}
public Long getQuantity() {
return quantity;
}
public void decrease(Long quantity) {
if (this.quantity - quantity < 0) {
throw new RuntimeException("재고는 0개 미만이 될 수 없습니다.");
}
this.quantity -= quantity;
}
}
StockRepository
- LockModeType을 OPTIMISTIC으로 설정한다.
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(value = LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(@Param("id") Long id);
}
OptimisticLockStockService
- findByIdWithOptimisticLock 메서드를 사용하여 재고를 감소시킨다.
@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
- Optimistic lock은 실패 시 재시도를 해야 한다. 따라서 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);
}
}
}
}
OptimisticLockStockFacadeTest
- 테스트 코드를 작성하여 실제 결과를 확인해 보자.
@SpringBootTest
class OptimisticLockStockFacadeTest {
@Autowired
private OptimisticLockStockFacade optimisticLockStockFacade;
@Autowired
private StockRepository stockRepository;
@BeforeEach
public void before() {
stockRepository.save(new Stock(1L, 100L));
}
@AfterEach
public void after() {
stockRepository.deleteAll();
}
@Test
@DisplayName("동시에 100개의 재고 감소 요청이 발생")
public void decreaseConcurrentlyWithOptimisticLock() throws InterruptedException {
// given
int threadCount = 100; // 멀티 스레드 사용
ExecutorService executorService = Executors.newFixedThreadPool(32); // 비동기로 실행하는 작업을 단순화하여 사용할 수 있게 도와주는 Java의 API
CountDownLatch latch = new CountDownLatch(threadCount); // 다른 Thread에서 수행 중인 작업이 완료될 때까지 대기할 수 있도록 도와주는 클래스
// when
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
// then
assertThat(stock.getQuantity()).isEqualTo(0);
}
}
- 실패 시 재시도 로직이 있어 이전보다는 실행 시간이 오래 걸렸지만, 테스트에 성공하는 것을 확인할 수 있다.
장점
- 별도의 Lock을 잡지 않으므로 Pessimistic lock보다 성능상 이점이 있다
단점
- update가 실패했을 때 개발자가 직접 재시도 로직을 작성해주어야 한다는 번거로움이 있다.
활용
- 충돌이 빈번하게 일어나지 않을 것이라 예상되는 경우에 사용해야 한다.
- 충돌이 빈번하게 일어나는 경우엔 Pessimistic lock을 사용하자.
Named Lock 활용
Overview
- Name lock은 Stock에 Lock을 걸지 않고, 별도의 공간에 Lock을 건다.
- session1이 '1'이라는 이름으로 Lock을 걸면 다른 세션에서는 session1이 Lock을 해제한 후에 획득할 수 있다.
- Named lock은 주로 분산락을 구현할 때 사용한다.
- Pessimistic Lock은 타임아웃을 구현하기 힘들지만, Named Lock은 손쉽게 가능하다.
- 또한 이외에도 Named Lock은 insert 시에 정합성을 맞춰야 하는 경우 유용하게 사용될 수 있다.
- Lock을 얻거나 해제하는 작업에 반드시 유의해야 한다.
LockRepository
- 편의를 위해 데이터 소스를 분리하지 않고 Stock Entitiy를 사용한다.
- 실무에서는 커넥션풀이 부족할 수 있으므로 데이터 소스를 분리하고 별도의 JDBC를 사용하는 등의 조치를 취해야 한다.
public interface LockRepository extends JpaRepository<Stock, Long> {
@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
- LockRepository에서 Lock을 획득하고 StockService를 이용해 재고를 감소시킨 다음 모든 로직이 종료되었을 때 Lock을 해제한다.
@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());
}
}
}
StockService
- StockService는 부모의 트랜잭션과 별도로 실행되어야 한다.
- 따라서 propagation을 변경해 준다.
@Service
public class StockService {
private final StockRepository stockRepository;
public StockService(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.save(stock);
}
}
application.yml
- 마지막으로 같은 데이터 소스를 사용하므로 커넥션 풀 사이즈를 넉넉히 40으로 설정한다.
spring:
jpa:
hibernate:
ddl-auto: create
show-sql: true
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:33306/stock_example
username: root
password: 1234
hikari:
maximum-pool-size: 40
logging:
level:
org:
hibernate:
SQL: DEBUG
type:
descriptor:
sql:
BasicBinder: TRACE
NamedLockFacadeTest
@SpringBootTest
class NamedLockStockFacadeTest {
@Autowired
private NamedLockStockFacade namedLockStockFacade;
@Autowired
private StockRepository stockRepository;
@BeforeEach
public void before() {
stockRepository.save(new Stock(1L, 100L));
}
@AfterEach
public void after() {
stockRepository.deleteAll();
}
@Test
@DisplayName("동시에 100개의 재고 감소 요청이 발생")
public void decreaseConcurrentlyWithNamedLock() throws InterruptedException {
// given
int threadCount = 100; // 멀티 스레드 사용
ExecutorService executorService = Executors.newFixedThreadPool(32); // 비동기로 실행하는 작업을 단순화하여 사용할 수 있게 도와주는 Java의 API
CountDownLatch latch = new CountDownLatch(threadCount); // 다른 Thread에서 수행 중인 작업이 완료될 때까지 대기할 수 있도록 도와주는 클래스
// when
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
// then
assertThat(stock.getQuantity()).isEqualTo(0);
}
}
- 테스트 케이스 실행 결과 정상적으로 테스트가 성공한다.
Redis
- 이번에는 Redis를 사용해 동시성 문제를 해결하는 방법에 대해 알아보자.
Redis 라이브러리
Lettuce
setnx 명령어
- 분산락을 구현할 수 있다.
- 해당하는 key, value가 없을 때만추가한다.
spin lock 방식
- 스레드가 lock을 사용할 수 있는지 반복적으로 확인하면서 락 획득을 시도하는 방법이다.
- lock 획득 재시도 로직을 별도로 작성해야 한다.
- Thread1에서 key가 1인 값을 set 할 경우 현재 레디스에 key가 1인 데이터가 없으므로 set에 성공한다.
- Thread2가 key가 1인 값을 set 할 경우 이미 레디스에 key가 1인 데이터가 있으므로 set에 실패하며, 일정 시간 이후 lock 획득 재시도를 한다.
Redisson
pub-sub 기반 lock
- 채널을 하나 만들고 lock을 점유 증인 스레드가 lock을 획득하려 대기 중인 스레드에게 해제를 알려주면 안내를 받은 스레드가 lock 획득 시도를 하는 방식
- 별도의 재시도 로직을 작성하지 않아도 된다.
- Thread1이 먼저 lock을 점유한 뒤, 해제 메시지를 채널로 보낸다.
- 이후 Thread2는 채널로부터 획득을 시도하라는 메시지를 받고, lock 획득을 시도하게 된다.
Lettuce를 사용한 재고감소 로직 작성
RedisLockRepository
- 로직 실행 전 key와 setnx 명령어를 활용해 lock을 하고, 로직이 끝나면 unlock 메서드를 통해 lock을 해제한다.
@Component
public class RedisLockRepository {
private RedisTemplate<String, String> redisTemplate; // Redis 명령어 실행할 수 있도록 템플릿 추가
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)); // key: stockId, value: lock
}
public Boolean unlock(Long key) {
return redisTemplate.delete(generateKey(key));
}
private String generateKey(Long key) {
return key.toString();
}
}
LettuceLockStockFacade
- lettuce를 사용하는 방식은 로직 실행 전후로 lock 획득, 해제를 수행해줘야 하므로 facade 클래스를 생성한다.
- while 문을 사용해 lock 획득을 시도하며, 만약 lock 획득에 실패할 경우 Thread.sleep을 활용해 100ms의 텀을 두고 재시도 할 수 있도록 한다. 이는 redis에 갈 수 있는 부하를 줄여주기 위함이다.
- lock 획득에 성공하였다면 StockService를 활용해 재고를 감소시킨다.
- 모든 로직이 종료되었다면, unlock 메서드를 사용해 lock을 해제한다.
@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)) { // lock 획득 실패하는 경우 100ms의 텀을 주고 재시도
Thread.sleep(100);
}
try { // lock 획득 성공 시 재고 감소
stockService.decrease(id, quantity);
} finally { // 모든 로직이 종료되면 lock 해제
redisLockRepository.unlock(id);
}
}
}
LettuceLockStockFacadeTest
- LettuceLockStockFacade를 사용하도록 변경한다.
@SpringBootTest
class LettuceLockStockFacadeTest {
@Autowired
private LettuceLockStockFacade lettuceLockStockFacade;
@Autowired
private StockRepository stockRepository;
@BeforeEach
public void before() {
stockRepository.save(new Stock(1L, 100L));
}
@AfterEach
public void after() {
stockRepository.deleteAll();
}
@Test
@DisplayName("동시에 100개의 재고 감소 요청이 발생")
public void decreaseConcurrentlyWithLettuce() throws InterruptedException {
// given
int threadCount = 100; // 멀티 스레드 사용
ExecutorService executorService = Executors.newFixedThreadPool(32); // 비동기로 실행하는 작업을 단순화하여 사용할 수 있게 도와주는 Java의 API
CountDownLatch latch = new CountDownLatch(threadCount); // 다른 Thread에서 수행 중인 작업이 완료될 때까지 대기할 수 있도록 도와주는 클래스
// when
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
// then
assertThat(stock.getQuantity()).isEqualTo(0);
}
}
- 실행 결과
장점
- 구현이 간단하다
- spring data redis 를 이용하면 lettuce가 기본이기 때문에 별도의 라이브러리를 사용하지 않아도 된다.
단점
- spin lock 방식이므로 redis에 부하를 줄 수 있다. 따라서 lock 획득 재시도 간에 텀을 둬야 한다.
Redisson을 사용한 재고감소 로직 작성
build.gradle
- redisson을 사용하기 위해선 관련 라이브러리를 추가해야 한다.
dependencies {
implementation 'org.redisson:redisson-spring-boot-starter:3.23.2'
}
RedissonLockStockFacade
- redisson은 lock 관련 클래스들을 라이브러리에서 제공해주므로 별도의 repository를 작성하지 않아도 된다.
- 로직 실행 전후로 lock 획득을 해제를 위해 facade 클래스를 작성한다.
@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();
}
}
}
RedissonLockStockFacadeTest
@SpringBootTest
class RedissonLockStockFacadeTest {
@Autowired
private RedissonLockStockFacade redissonLockStockFacade;
@Autowired
private StockRepository stockRepository;
@BeforeEach
public void before() {
stockRepository.save(new Stock(1L, 100L));
}
@AfterEach
public void after() {
stockRepository.deleteAll();
}
@Test
@DisplayName("동시에 100개의 재고 감소 요청이 발생")
public void decreaseConcurrentlyWithRedisson() throws InterruptedException {
// given
int threadCount = 100; // 멀티 스레드 사용
ExecutorService executorService = Executors.newFixedThreadPool(32); // 비동기로 실행하는 작업을 단순화하여 사용할 수 있게 도와주는 Java의 API
CountDownLatch latch = new CountDownLatch(threadCount); // 다른 Thread에서 수행 중인 작업이 완료될 때까지 대기할 수 있도록 도와주는 클래스
// when
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
// then
assertThat(stock.getQuantity()).isEqualTo(0);
}
}
- 실행 결과
장점
- pub-sub 기반이므로 redis의 부하를 줄여준다.
- 잠금 획득 재시도를 기본으로 제공한다.
단점
- 구현이 조금 복잡하다.
- 별도의 라이브러리를 사용해야 한다.
- lock 을 라이브러리 차원에서 제공해주기 때문에 사용법을 공부해야 한다.
실무
- 재시도가 필요하지 않다면 lettuce를 활용한다.
- 재시도가 필요한 경우 redisson을 활용한다.
MySQL VS Redis
MySQL
- 이미 MySQL을 사용하고 있다면 별도의 비용없이 사용할 수 있다.
- 어느 정도의 트래픽까지는 문제 없이 사용할 수 있다.
- Redis보다는 성능이 좋지 않다.
Redis
- 활용 중인 redis가 없다면 별도의 구축 비용과 인프라 관리 비용이 필요하다.
- MySQL보다 성능이 우수하다.
실무
- MySQL로 처리가 가능한 트래픽이라면 MySQL을 사용한다.
- MySQL로 더이상 처리가 불가능할 정도의 트래픽이 발생한다면 redis를 도입한다.
참고
재고시스템으로 알아보는 동시성이슈 해결방법 강의 | 최상용 - 인프런
최상용 | 동시성 이슈란 무엇인지 알아보고 처리하는 방법들을 학습합니다., 동시성 이슈 처리도 자신있게! 간단한 재고 시스템으로 차근차근 배워보세요. 백엔드 개발자라면 꼭 알아야 할 동
www.inflearn.com
'Spring' 카테고리의 다른 글
[Spring] @RestControllerAdvice을 사용해 기존의 예외 처리 방식 변경하기 (0) | 2024.10.19 |
---|---|
[Spring JPA] 연관관계 매핑 (4) (0) | 2024.05.16 |
[Spring JPA] 객체와 테이블 매핑 (3) (0) | 2024.05.13 |
[Spring JPA] 영속성 컨텍스트 (2) (0) | 2024.05.12 |
[Spring JPA] JPA 소개 (1) (0) | 2024.05.12 |