0. 들어가기 전
이전에는 DB 단의 동시성 처리 방법인 Lock에 대해서 알아봤습니다.
https://young-code.tistory.com/17
이번에는 애플리케이션 단에서 어떻게 동시성을 처리하는지 살펴봅시다.
실질적으로 서비스에서 동시성을 처리하는 부분은 애플리케이션 단이기 때문에 DB 단의 Lock은 개념적인 부분으로 이해하고, 실무에서는 이번에 포스팅하는 애플리케이션 단의 동시성 처리가 중요할 것 같습니다!
재고 시스템에서 동시성을 어떻게 처리하는지 살펴봅시다.
1. 재고 시스템 Base Code
동시성을 테스트해 볼 재고 시스템의 베이스 코드는 다음과 같습니다.
a. Stock Entity
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;
protected Stock() {
}
public Stock(final Long productId, final Long quantity) {
this.productId = productId;
this.quantity = quantity;
}
public void decrease(final Long quantity) {
if (this.quantity - quantity < 0) {
throw new RuntimeException("재고는 0개 미만이 될 수 없습니다.");
}
this.quantity -= quantity;
}
public Long getQuantity() {
return quantity;
}
}
- 상품 ID와 재고를 가지는 Stock 엔티티
- 재고를 감소시키는 기능 구현 (decrease 메서드)
b. Stock Service
package com.example.stock.application;
import com.example.stock.domain.Stock;
import com.example.stock.domain.StockRepository;
import jakarta.transaction.Transactional;
import org.springframework.stereotype.Service;
@Service
public class StockService {
private final StockRepository stockRepository;
public StockService(final StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
@Transactional
public void decrease(final Long id, final Long quantity) {
final Stock stock = stockRepository.findById(id)
.orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}
- 파라미터로 상품의 ID와 감소시킬 재고 수량을 받아서 재고를 감소시키는 decrease 메서드 구현
- 상품 ID에 해당하는 stock 찾고, 해당 stock의 재고 감소 후 saveAndFlush
c. Stock Service Test
package com.example.stock.application;
import static org.assertj.core.api.Assertions.assertThat;
import com.example.stock.domain.Stock;
import com.example.stock.domain.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;
@SpringBootTest
class StockServiceTest {
@Autowired
private StockService stockService;
@Autowired
private StockRepository stockRepository;
@BeforeEach
void setUp() {
stockRepository.saveAndFlush(new Stock(1L, 100L));
}
@AfterEach
void tearDown() {
stockRepository.deleteAll();
}
@Test
@DisplayName("재고를 감소시킨다.")
void decrease() {
// when
stockService.decrease(1L, 1L);
final Stock stock = stockRepository.findById(1L).orElseThrow();
// then
assertThat(stock.getQuantity()).isEqualTo(99);
}
}
- Stock Service를 테스트하는 테스트 클래스
- 테스트 메서드 실행 전 상품 ID 1L, 재고 100L인 상품 추가
- 테스트 메서드 실행 후 모든 상품 삭제 (초기화)
- 재고 감소 테서트 메서드에서 상품 ID가 1인 상품의 재고 1만큼 감소 후 99개가 남았는지 테스트
2. Base Code의 문제점 (동시성)
위에서 구현했던 로직은 하나의 요청에서는 테스트가 성공하여 정상적으로 동작합니다.
그러나, 여러 요청이 동시에 수행된다면 정상적으로 동작할까요?
100개의 요청이 동시에 들어왔을 때 어떻게 동작하는지 테스트 코드로 살펴봅시다.
해당 테스트 메서드를 이해하기 위해 멀티 스레드를 사용하는 비동기 테스트에 사용되는 클래스들을 간단히 알아보겠습니다.
a. Executors
Executors는 스레드 풀을 손쉽게 생성해주는 팩토리 클래스입니다.
스레드 풀에는 일반적으로 다음과 같은 3가지가 존재합니다.
- FixedThreadPool
- 고정된 스레드 개수를 가지는 스레드 풀
- 작업이 고정된 스레드 개수를 넘는다면 큐에서 대기
- CachedThreadPool
- 필요할 때 필요한 만큼의 스레드를 생성하는 스레드 풀
- 이미 생성된 스레드가 있다면 이를 재활용하여 사용
- ScheculedThreadPool
- 일정 시간 또는 주기적으로 실행되어야 하는 작업을 위한 스레드를 생성하는 스레드 풀
Executors에서 해당 스레드 풀을 생성하는 팩토리 메서드를 제공해서, 작업 등록 및 실행을 위한 인터페이스인 ExecutorService 인터페이스를 반환하여 이후에 스레드 풀에 작업을 실행할 수 있게 해줍니다.
b. ExecutorService
ExecutorService는 작업 등록 및 실행을 위한 인터페이스입니다.
ExecutorService가 제공하는 기능들은 라이프사이클 관리 기능, 비동기 처리 기능으로 나눌 수 있는데
여기서는 관련있는 비동기 처리 기능만 살펴보도록 하겠습니다.
- submit
- 단일 Task 처리
- 실행할 작업들을 추가하고, 실행한 작업의 상태와 결과를 나타내는 Future 객체를 반환
- 반환받은 Future의 get 메서드를 호출하여 실행한 작업 완료 시 결과를 확인할 수 있음
- invokeAll
- 여러 Task 처리
- 주어진 작업들을 실행하고 모든 결과가 나올 때까지 대기하는 블로킹 방식으로 작업 처리
- 동시에 주어진 작업을 모두 처리하고 모두 끝나면 각각의 상태와 결과를 가지는 List 반환
- invokeAny
- 여러 Task 처리
- 주어진 작업들을 실행하고 가장 먼저 실행된 결과가 나올 때까지 대기하는 블로킹 방식으로 작업 처리
- 동시에 주어진 작업을 모두 처리하고, 가장 빨리 끝난 작업 1개의 결과를 Future 객체로 반환
c. CountDownLatch
CountDownLatch는 하나 이상의 스레드가 다른 스레드에서 수행 중인 작업이 완료될 때까지 기다릴 수 있도록 하는 동기화 보조 장치입니다. CountDownLatch는 객체 생성 시 설정한 count를 기반으로 대기하게 됩니다.
final CountDownLatch countDownLatch = new CountDownLatch(100);
// 스레드 작업 로직
...
countDownLatch.countDown();
// 대기
countDownLatch.await();
- CountDownLatch 객체 생성 시 파라미터로 count를 설정하여 생성합니다.
- countDown() 메서드는 설정된 count를 1씩 감소하는 역할을 수행합니다.
- await() 메서드는 count가 0이 될 때까지 대기하는 역할을 합니다.
이러한 원리로 스레드 작업 수행 시 마다 countDown() 메서드를 호출하여 스레드의 모든 작업이 수행되어 count가 0이 되면 이후 작업을 수행하는 식으로 사용합니다.
💡 작업(Task) vs 스레드(Thread)
1. 작업(Task) : 실행해야 할 코드나 로직 자체
ex) 데이터 계산, 파일 읽기, API 호출 등의 실제 해야 할 일
2. 스레드(Thread) : 작업을 실행하는 실행자/처리자, 하나의 스레드가 여러 작업을 순차적으로 처리할 수 있음
ex) 작업을 실행하는 일꾼
쉽게 비유하면 : 작업 = 해야 할 일들의 목록 & 스레드 = 그 일을 처리하는 직원
따라서 ExecutorService는 작업들을 등록하고, 이 작업들을 스레드를 통해 실행하는 관리자 역할을 한다고 볼 수 있습니다!
d. Stock Service Test 동시성 테스트 코드
@Test
@DisplayName("동시에 100개의 요청으로 재고를 감소시킨다.")
void decrease_100_request() throws InterruptedException {
// given
final int threadCount = 100;
final ExecutorService executorService = Executors.newFixedThreadPool(32);
final CountDownLatch countDownLatch = new CountDownLatch(threadCount);
// when
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
stockService.decrease(1L, 1L);
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
final Stock stock = stockRepository.findById(1L).orElseThrow();
// then
assertThat(stock.getQuantity()).isEqualTo(0);
}
앞에서 살펴본 Stock Service Test 내에 해당 테스트 메서드를 작성했습니다.
앞에서 이해한 개념들을 바탕으로 메서드 동작을 살펴보면 다음과 같습니다.
- Executors.newFixedThreadPool(32) : 고정된 32개의 스레드를 가지는 스레드 풀이 설정된 ExecutorService 생성
이것은 마치 32명의 직원을 고용한 것과 같습니다.
- new CountDownLatch(threadCount) : 설정한 스레드 개수(100개)만큼의 count를 가진 CountDownLatch 생성
해야할 목록들이 100개 생성한 것과 같습니다.
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
stockService.decrease(1L, 1L);
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
- 스레드 개수(100개)만큼 반복하면서 스레드에서 비동기로 재고 감소 로직 실행
- 스레드 작업 실행마다 CountDownLatch의 count 1씩 감소
- 스레드 작업이 스레드 개수(100)만큼 실행되기 전까지 대기하고, 100개가 실행되어 count가 0이 되면 이후 로직 실행
🧑🏻💻 쉽게 이야기하면 다음과 같습니다.
총 100개의 작업(재고 감소 작업)이 생성됩니다.
32명의 직원(스레드)들이 100개의 작업을 나눠서 처리합니다.
한 명의 직원(스레드)이 여러 개의 작업을 순차적으로 처리할 수 있습니다.
이런 식으로 동작하므로 저희가 기대하는 동작이 되려면 100번 재고 감소 로직을 실행했으므로 모든 요청 이후에 재고를 조회했을 때 0개가 반환되어야 합니다. 그렇다면 재고가 100번 감소하여 0이 되었을까요?
결과는 0개가 되지 않고 테스트가 실패하는 것을 알 수 있습니다.
왜 이러한 결과가 발생했을까요? 바로 레이스 컨디션(Race Condition)이 발생했기 때문입니다.
💡레이스 컨디션(Race Condition)이란?
레이스 컨디션이란, 둘 이상의 Thread가 공유 자원에 접근해서 동시에 변경을 할 때 발생하는 문제입니다.
간단하게 위의 테스트 상황을 아래와 같은 그림을 나타내보았습니다.
위의 재고 시스템에서 상품을 위와 같은 코인으로 나타냈습니다.
기존 상품의 재고가 100인 상태에서 위와 같이 스레드 3개가 상품 하나에 접근했다고 생각해봅시다.
편의상 Thread1 > Thread2 > Thread3 순으로 빠르게 작업이 수행된다고 했을 때,
Thread 1, 2, 3에서 모두 같은 상품에 접근합니다.
이 때 Thread 1, 2, 3에서 재고 감소 로직을 처리할 때의 상품 재고는 모두 100일 것입니다.
그러므로 가장 마지막으로 실행되는 Thread 3의 작업이 끝날 때 재고가 최종적으로 99로 변하는 것입니다.
일반적인 예상으로는 작업 하나당 재고가 1개씩 줄어서 위의 상황이라면 재고가 97이 되기를 예상하지만, 위와 같은 Race Condition이 발생하여 재고가 정상적으로 줄지 않은 것입니다.
3. 동시성 문제를 해결하는 방법
앞에서 동시에 여러 사용자의 요청이 들어오면 Race Condition이 발생하고 재고 감소가 누락되는 현상을 볼 수 있었습니다.
이러한 동시성 문제를 어떻게 해결할 수 있을까요? 간략하게 요약해보면, 데이터에 동시에 하나의 스레드만 접근이 가능하도록 하면 해결이 가능합니다. 그렇다면 어떻게 데이터에 동시에 하나의 스레드만 접근이 가능하게 할 수 있을지 알아봅시다.
a. Java의 Synchronized
자바에서는 Synchronized 키워드를 통해 데이터에 하나의 스레드만 접근이 가능하도록 만들어줍니다.
사용 방법은 간단합니다.
@Transactional
public synchronized void decrease(final Long id, final Long quantity) {
final Stock stock = stockRepository.findById(id)
.orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
위와 같이 기존 재고 감소 로직 메서드 선언부에 synchronized 키워드를 붙여주면 됩니다. 이제, Synchronzied로 데이터에 동시에 하나의 스레드만 접근이 가능하도록 했으니 다시 테스트 코드를 실행해봅시다.
이번 결과에선 49가 나왔네요. 이전과 결과는 바뀌긴 했지만 여전히 재고가 0개가 되지 않음을 알 수 있습니다.
※ 문제 발생 원인
synchronized를 사용했는데 왜 여전히 테스트가 실패할까요? 원인은 스프링의 @Transactional의 동작 원리에 있습니다. @Transactional이 붙은 메서드는 다음과 같이 Proxy 객체를 생성하여 트랜잭션 관련 처리르 해줍니다.
public class StockServiceProxy {
private StockService stockService;
public StockServiceProxy(StockService stockService) {
this.stockService = stockService;
}
public void decrease(Long id, Long quantity) {
// 트랜잭션 시작 로직
...
stockService.decrease(id, quantity);
// 트랜잭션 종료 로직
...
}
}
이렇게 Proxy 클래스의 decrease를 호출하여 트랜잭션 처리와 함께 재고 감소 로직을 실행하게 됩니다.
이때, 재고 감소가 DB에 반영되는 시점은 트랜잭션이 커밋되고 종료되는 시점입니다.
즉, 재고 감소 로직인 stockService의 decrease가 호출되고 트랜잭션이 종료되기 전까지는 재고 감소가 DB에 반영되지 않습니다.
synchronized는 메서드 선언부에 사용되어 해당 메서드가 종료되면 다른 스레드에서 해당 메서드를 실행할 수 있게 됩니다.
따라서 재고 감소 로직이 실행되고 트랜잭션이 종료되기 전까지의 시점에서 다른 스레드가 재고 감소 로직을 실행할 수 있게 됩니다.
이때 다른 스레드에서 재고를 조회했을 때는 아직 이전 재고 감소 로직이 실행된 스레드에서 DB에 반영되기 전이므로 감소되지 않은 재고로 조회하여 똑같이 재고 감소가 누락되는 것입니다.
@Transactional을 주석 처리하고 테스트를 실행한 결과는 재고가 0이 되는 것을 확인할 수 있습니다.
※ Synchronized의 또 다른 문제점
그렇다면, 스프링의 @Transactional 때문에 정상적으로 synchronized가 동작하지 않았으니 synchronized를 사용하고 트랜잭션 처리를 동시성 보장하는 방향으로 바꿔야 할까요?
synchronized에는 다음과 같은 특징이 있습니다. 바로 데이터에 동시에 하나의 스레드만 접근이 가능하다는 조건이 하나의 프로세스에서만 보장되는 특징입니다. 이러한 특징때문에 Scale-out 시, 즉 서버가 여러 대일 때 동시성이 보장되지 않는다는 치명적인 단점이 있습니다.
위는 서버가 여러 대일 때 synchronized 키워드를 사용한 예시를 나타낸 그림입니다.
각각 서버의 입장에서는 스레드 1이 접근 시에 스레드 2, 3은 synchronized에 의해 접근을 막지만, synchronized는 동시성을 하나의 프로세스에서만, 즉 하나의 서버에서만 보장하기 때문에 DB(Stock)의 입장에서는 서버 1과 서버 2의 스레드 1이 동시에 Stock에 접근하여 똑같이 재고 감소 누락이 발생하게 되는 것입니다.
이러한 이유때문에 실무에서는 동시성을 해결하는 방법으로 synchronized는 거의 사용하지 않는다고 합니다.
b. DB단의 Lock 사용
앞서 synchornized 키워드를 사용해서는 문제점이 존재하여 완전히 동시성 문제를 해결할 수 없었습니다. 이번에는 DB에서 동시청 처리를 위해 사용하는 Lock을 이용해서 동시성 문제를 해결해봅시다.
해결 방법에는 다음과 같은 3가지 방법이 존재합니다.
- 비관적 락 (Pessimistic Lock)
- 낙관적 락 (Optimistic Lock)
- 네임드 락 (Named Lock)
하나씩 살펴봅시다.
b-1. 비관적 락 (Pessimistic Lock)
비관적 락은 실제로 DB 단에 X-Lock을 설정해서 동시성을 제어하는 방법입니다. DB 단에서 해당 자원의 점유는 트랜잭션 단위로 수행됩니다. A 트랜잭션에서 데이터에 X-Lock을 설정하면, 해당 트랜잭션이 종료되기 전까지는 다른 트랜잭션에서 해당 데이터를 수정할 수 없습니다. 이렇게 데이터에 직접 DB단의 X-Lock을 걸도록 애플리케이션 코드에서 지정하여 동시성을 처리하는 방법입니다.
이제 비관적 락을 사용해서 Race Condition을 해결해봅시다. Spring Data JPA를 사용할 때 비관적 락을 사용하는 방법은 간단합니다. Repository를 사용하는 곳에서 Lock 관련 어노테이션을 다음과 같이 선언해주면 됩니다.
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);
}
비관적 락을 사용하면 DB 단에 락을 설정하는 쿼리를 수행합니다.
이때, S-Lock을 설정하는 SELECT FOR SHARE 쿼리를 수행할지 X-Lock을 설정하는 SELECT FOR UPDATE 쿼리를 수행할지를 @Lock의 속성인 LockModeType을 통해 지정할 수 있습니다.
- LockModeType.PESSIMISTIC_WRITE : X-LOCK 쿼리 수행
- LockModeType.PESSIMISTIC_READ : S-LOCK 쿼리 수행
비관적 X-Lock을 설정하고 테스트를 실행한 결과, 정상적으로 재고가 0이 되어 테스트가 통과하는 것을 알 수 있습니다.
추가로 비관적 락을 사용하면 실제 락이 DB에 있기 때문에 서비스 레벨의 트랜잭션도 자연스럽게 차단됩니다.
- 첫 번째 서버의 트랜잭션이 실행될 떄
@Transactional
public void decreaseStock() {
// 이 시점에 DB에서 'SELECT ... FOR UPDATE' 쿼리가 실행됨
// DB의 해당 row에 락이 걸림
Stock stock = stockRepository.findByIdWithPessimisticLock(1L);
stock.decrease();
}
- 두 번째 서버에서 동시에 접근하려고 할 때
@Transactional
public void decreaseStock() {
// 이 줄에서 'SELECT ... FOR UPDATE' 쿼리가 실행되려고 하지만
// DB에서 이미 해당 row에 락이 걸려있음을 확인
// 따라서 DB가 이 쿼리의 실행 자체를 막음(대기시킴)
Stock stock = stockRepository.findByIdWithPessimisticLock(1L);
stock.decrease(); // 이 라인까지 실행되지 않음
}
즉, 서비스 레벨의 코드를 막는 게 아니라 DB에서 쿼리 실행 자체를 막기 때문에 자연스럽게 서비스 레벨의 코드도 진행되지 않는 것입니다.
b-2. 낙관적 락 (Optimistic Lock)
낙관적 락은 DB 단에 실제 Lock을 설정하지 않고, Version을 관리하는 컬럼을 테이블에 추가해서 데이터 수정 시마다 맞는 버전의 데이터를 수정하는지를 판단하는 방식입니다.
아래는 낙관적 락의 동작 원리를 나타낸 그림입니다.
🧑🏻💻 그림 설명
1. 2개의 스레드에서 동시에 DB에 접근하여 재고 100, Version이 1인 상품을 조회
2. 스레드 1에서 먼저 조회한 상품에 대한 업데이트 (quantity-1, version+1)
3. 스레드 2에서 조회한 상품에 대해 업데이트하려고 할 때 id가 1이고 version이 1인 상품은 존재하지 않으므로(이미 스레드 1에서 version 2로 업데이트) 예외 발생
4. 예외를 잡아서 다시 DB에서 상품을 재조회하여 version 2인 상품을 업데이트 (quantity-1, version+1)
여기서 1~3번 과정은 스프링에서 어노테이션을 선언하면 자동으로 동작합니다.
4번 과정은 애플리케이션에서 예외를 잡아서 다시 로직을 수행하도록 수동으로 코드를 구현해야 합니다.
낙관적 락을 스프링에서 구현하는 방법을 알아봅시다. 기본적으로 비관적 락과 비슷합니다.
Repository를 사용하는 곳에서 Lock 관련 어노테이션을 다음과 같이 선언해주면 됩니다.
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(Long id);
}
여기서 Lock 어노테이션 뿐만 아니라 한가지 설정을 더 해줘야 합니다.
낙관적 락은 버전을 비교하여 동시성을 처리한다고 했는데, 해당 Version 컬럼을 수동으로 엔티티에 추가해줘야합니다.
컬럼을 추가한 후에 @Version 어노테이션을 선언해주면 됩니다.
@Entity
public class Stock {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
@Version
private Long version;
...
}
이렇게 @Lock 어노테이션과 @Version 어노테이션을 선언한 후에 테스트 코드를 실행하면 어떻게 될까요?
동시에 요청이 와서 버전이 맞지 않는 데이터가 존재할 것이기 때문에 다음과 같이 에러가 발생합니다.
제가 원하는 재고 감소 로직은 버전이 일치하지 않을 때 예외가 발생해서 서비스가 멈추는 것이 아니라 예외가 발생하면 다시 재조회를 하여 버전을 업데이트하고 재고를 감소시키는 것을 원합니다. 그러므로 while 문과 try-catch를 조합하여 재고 감소 로직이 성공할 때까지 수행하도록 다음과 같이 구현해줘야 합니다.
public void decrease(Long id, Long quantity) throws InterruptedException {
while (true) {
try {
optimisticLockStockService.decrease(id, quantity);
break;
} catch (Exception e) {
Thread.sleep(50);
}
}
}
반복문 안에서 재고 감소 로직이 버전 문제로 실패하게 되면 50ms 동안 sleep한 후에 다시 재고 감소 로직을 실행하여 조회 후 업데이트하는 방식을 사용했습니다. 재고 감소 로직이 성공하면 break 문으로 빠져나와 다음 로직을 실행할 것입니다.
테스트 결과로는 낙관적 락을 사용해서 테스트가 성공하는 것을 알 수 있습니다.
💡비관적 락(Pessimistic) vs 낙관적 락(Optimistic Lock)
1. 비관적 락 (Pessimistic Lock)
- DB 단에서 락을 검
- SELECT FOR UPDATE 구문으로 DB에서 직접 락을 걸어서 제어
- 동시 접근 자체를 막음
2. 낙관적 락 (Optimistic Lock)
- 애플리케이션 단에서 버전 정보를 확인
- DB에 락을 걸지 않고, 버전 정보를 통해 변경 여부를 확인
- 동시 접근은 허용하고, 데이터 수정 시점에 버전이 다르면 실패 처리
b-3. 네임드 락 (Named Lock)
네임드 락은 임의로 락의 이름을 설정하고, 해당 락을 사용하여 동시성을 처리하는 방식입니다.
네임드 락도 비관적 락과 마찬가지로 DB 단에서 Lock을 설정하여 동시성을 처리합니다. 비관적 락과 다르게 주의할 점은 비관적 락은 Lock을 가진 트랜잭션이 종료되면 자동으로 Lock이 해제되어 대기중인 트랜잭션 작업이 수행될 수 있었습니다. 하지만 네임드 락으로 Lock을 설정하게 되면 트랜잭션이 종료되더라도 Lock이 자동으로 해제되지 않기 때문에 Lock 해제를 구현하거나 Lock Timeout 시간이 끝나야 Lock이 해제되게 됩니다.
또 비관적 락과 다른 점은 Lock을 설정하는 대상입니다. 비관적 락은 Stock 테이블, 정확히는 해당 테이블의 인덱스 레코드에 Lock을 설정하지만, 네임드 락은 해당 테이블이 아닌 별도의 DB 공간에 지정한 이름의 Lock을 설정하게 됩니다.
그래서 네임드 락을 사용하여 동시성을 처리할 때는 DB나 다른 시스템 내부에서 자동적으로 동시성을 처리한다기보다는 애플리케이션 코드 단에서 해당 로직을 사용할 때는 개발자가 지정한 이름의 Lock을 사용하게 함으로써 의도적으로 동시성을 보장하기 위해 개발자가 Lock을 설정하는 것이라고 이해하면 좋을 것 같습니다. 네임드 락은 주로 분산 락을 사용하려고 할 때 많이 사용하는 방식입니다.
Spring Data JPA를 사용할 때 애플리케이션 코드 단에서 다음과 같이 네임드 락을 구현할 수 있습니다.
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);
}
위와 같이 nativeQuery를 사용하여 사용자가 파라미터로 지정한 key로 네임드 락을 설정할 수 있습니다.
get_lock 명령어를 사용하여 네임드 락의 이름, time out 시간을 설정하고 release_lock 명령어를 통해 네임드 락을 해제하도록 구현했습니다. 앞서 말했듯이 네임드 락은 비관적 락과 달리 트랜잭션 종료 시 자동으로 해제되지 않기 때문에 release_lock 명령어를 통해 로직이 끝나면 수동으로 해제해야합니다.
해당 쿼리 메서드를 다음과 같이 사용해서 구현할 수 있습니다.
@Transactional
public void decrease(final Long id, final Long quantity) {
try {
lockRepository.getLock(id.toString());
stockService.decrease(id, quantity);
} finally {
lockRepository.releaseLock(id.toString());
}
}
여기서는 네임드 락의 지정 이름을 Stock의 id로 설정하여 Lock을 거는 것을 확인할 수 있습니다. 또한, try-finally 키워드를 사용하여 Lock을 설정하고 재고 감소 로직을 실행한 후에는 바로 Lock을 해제하는 것을 알 수 있습니다.
💡 네임드 락 궁금증
1. get_lock과 release_lock을 DB에서 따로 생성해야하나요?
- 아니요! get_lock과 release_lock은 MySQL에서 제공하는 내장 함수이기 때문에 별도의 테이블을 만들 필요가 없습니다.
2. 락 요청을 했을 때 이미 사용중이라면 네임드 락의 경우에는 바로 에러가 뜨나요?
- GET_LOCK의 timeout 매개변수(위에서는 3000 - 3초) 동안 대기하게 됩니다.
3. Stock id는 물품의 번호를 말하는건가요?
- 맞습니다. Stock id는 재고 관리하는 물품의 고유 번호입니다.
※ Named Lock 사용 시 주의점
Named Lock을 사용할 때는 네임드 락을 설정하는 부분과 비즈니스 로직의 트랜잭션을 분리해야 합니다. 결과적으로 예시에서는 다음과 같이 분리합니다.
@Service
public class StockService {
...
// 비즈니스 로직
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void decrease(final Long id, final Long quantity) throws SQLException {
final Stock stock = stockRepository.findById(id)
.orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}
@Component
public class NamedLockStockFacade {
...
// Lock 설정, 해제 로직
@Transactional
public void decrease(final Long id, final Long quantity) throws SQLException {
try {
lockRepository.getLock(id.toString());
stockService.decrease(id, quantity);
} finally {
lockRepository.releaseLock(id.toString());
}
}
}
우선 Facade 패턴을 사용하여 로직 자체를 나누고, 트랜잭션도 전파 수준을 REQUIRES_NEW로 설정하여 분리했습니다.
만약 Lock 설정/해제 부분과 비즈니스 로직이 하나의 트랜잭션으로 묶인다면 어떤 문제가 발생할까요?
스레드 A, B에서 동시에 재고 감소 로직을 요청했다고 가정해봅시다.
스레드 A에서 먼저 네임드 Lock을 설정하고 재고 감소 로직을 실행한 후에 네임드 Lock을 해제합니다. 여기서 해당 재고 감소 로직을 실행한 트랜잭션이 커밋되는 시점이 중요합니다. 트랜잭션 분리가 이루어지지 않았다면 Lock이 해제가 마지막 로직이므로 네임드 Lock 해제 후 트랜잭션이 커밋될 것입니다. 이때 스레드 B 입장에서는 네임드 Lock에 의해서 블로킹 상태로 대기하고 있다가, 네임드 Lock이 해제되는 순간 자신의 로직을 실행합니다. 여기서 스레드 B가 조회한 상품은 스레드 A에서 재고 감소 로직이 커밋되지 않아서 기존 상품이 조회될 것이므로 동시성 문제에서 발생했던 재고 감소 누락이 똑같이 발생할 것입니다.
이렇게 Named Lock을 사용할 때는 트랜잭션을 분리해야 하기 때문에 그에 따라 커넥션도 2개를 사용하게 됩니다.
이에 따라 커넥션 사용량이 많아질 수 있기 때문에 재고 감소 로직인 비즈니스 로직의 커넥션과 Named Lock을 획득하는 로직의 커넥션을 분리해야 하기 때문에 DataSource를 분리하는 것이 좋습니다.
💡 Propagation.REQUIRES_NEW?
Spring에서 트랜잭션의 전파 방식을 설정할 때 사용하는 어노테이션입니다. 여기서 Propagation.REQUIRES_NEW는 현재 실행 중인 트랜잭션이 있어도, 새로운 트랜잭션을 생성해 실행하도록 지정하는 전파 방식입니다.
💡 Facade 패턴?
복잡한 시스템을 간단한 인터페이스로 제공하는 디자인 패턴입니다.
재고 로직 예시에서는 클라이언트는 로직 감소 주문만 하고 트랜잭션이 나눠져 있고 동시성 처리는 어떻게 하는지 등 이런 내부 로직을 알 필요가 없습니다.
💡 @Transactional이 있어도 stockRepository.saveAndFlush 있으면 바로 커밋하는거 아니야?
saveAndFlush()를 호출해도 트랜잭션이 끝나지 않으면 실제 DB에 완전히 반영되지 않습니다.
// Propagation.REQUIRES_NEW 예시 코드
@Service
public class MyService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog(String log) {
// 새로운 트랜잭션에서 로그 저장
}
@Transactional
public void mainProcess() {
// 메인 트랜잭션
try {
// 주요 작업 수행
saveLog("Main process started");
// 주요 작업 수행
} catch (Exception e) {
// 예외 처리
}
}
}
4. 3가지 Lock 장단점 비교
앞서 동시성을 해결하는 방법으로 크게 2가지를 살펴봤습니다.
- 자바의 synchronized 키워드
- 여러 Lock 사용
- 비관적 락 (Pessimistic Lock)
- 낙관적 락 (Optimistic Lock)
- 네임드 락 (Named Lock)
둘 중에서 실무에서 자주 사용하는 방법은 Lock을 사용해서 동시성을 처리하는 방법입니다. 따라서, 동시성을 처리할 때 어떤 상황에서 어떤 Lock을 사용해야 하는지 요약해보고 글을 마무리하도록 하겠습니다.
a. 비관적 락 (Pessimistic Lock)
- 장점
- Race Condition이 빈번하게 일어난다면 낙관적 락보다 성능이 좋다.
- DB 단의 Lock을 통해서 동시성을 제어하기 때문에 확실하게 데이터 정합성이 보장된다.
- 단점
- DB 단의 Lock을 설정하기 때문에 한 트랜잭션 작업이 정상적으로 끝나지 않으면 다른 트랜잭션 작업들이 대기해야 하므로 성능이 감소할 수 있다.
b. 낙관적 락 (Optimistic Lock)
- 장점
- DB 단에서 별도의 Lock을 설정하지 않기 때문에 하나의 트랜잭션 작업이 길어질 때 다른 작업이 영향받지 않아서 성능이 좋을 수 있다.
- 단점
- 버전이 맞지 않아서 예외가 발생할 때 재시도 로직을 구현해야 한다.
- 버전이 맞지 않는 일이 여러번 발생한다면 재시도를 여러번 거칠 것이기 때문에 성능이 좋지 않다.
c. 네임드 락 (Named Lock)
- 장점
- Lock의 대상이 테이블, 레코드 같은 DB 객체가 아니라 따로 Lock을 위한 공간에 Lock을 설정하기 때문에 같은 Named Lock을 사용하는 작업 이외의 작업은 영향 받지 않는다.
- UPDATE 작업이 아닌 INSERT 작업의 경우에는 기준을 잡을 레코드가 존재하지 않아 비관적 락을 사용할 수 없는데, 이때 Named Lock을 사용할 수 있다.
- 분산 락을 구현할 수 있다.
- 단점
- 트랜잭션 종료 시에 Lock 해제, 세션 관리 등을 수동으로 처리해야 하기 때문에 구현이 복잡할 수 있다.
추가로 대부분이 비관적 락과 낙관적 락을 많이 비교해서 상황에 맞게 사용하고, 네임드 락은 분산락에 사용하는 것 같습니다. 그래서 비관적 락과 낙관적 락을 어떤 상황에 사용해볼지 생각해보면 충돌이 빈번하게 일어날 것이라고 예상된다면 비관적 락을, 충돌이 빈번하지 않지만 충돌 발생 시 동시성을 지켜야 한다고 생각한다면 낙관적 락을 사용하는 것을 추천한다고 합니다.
💡UPDATE 작업이 아닌 INSERT 작업의 경우에는 기준을 잡을 레코드가 존재하지 않아 비관적 락을 사용할 수 없는데, 이때 Named Lock을 사용할 수 있다?
UPDATE 케이스는 이미 존재하는 레코드를 기준으로 락을 걸 수 있습니다,
SELECT * FROM stock WHERE id = 1 FOR UPDATE;
INSERT 케이스는 아직 레코드가 없어서 락을 걸 기준이 없습니다.
INSERT INTO stock (id, quantity) VALUES (1,100);
이때 Named Lock을 사용하면 특정 키로 락을 걸수 있습니다. (레코드 존재 여부와 무관)
getLock("insert_stock_1");
try {
// INSERT 작업 수행
} finally {
releaseLock("insert_stock_1");
}
'Programming > Spring' 카테고리의 다른 글
[Spring] OAuth2.0 + JWT 정리 (3) | 2024.10.29 |
---|