Spring

[Spring] 동시성 문제를 해결하는 방법

아윤_ 2024. 9. 19. 23:33

동시성 문제는 백엔드 개발에 있어 매우 중요한 문제 중 하나이다.

백엔드 개발자는 동시성을 고려한 프로그래밍을 할 줄 알아야 한다.

따라서 이번 장에서는 스프링과 자바를 사용하여

간단한 예시를 통해 동시성 문제가 발생하는 경우에 대해 살펴보고

이를 해결하는 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);
    }
}
  • 실행 결과 정상적으로 재고가 잘 감소되는 것을 확인할 수 있다.

 

1개의 재고를 감소시키는 경우

 

문제점

  • 현재 테스트 케이스는 요청이 한 개씩 들어오는 상황에 대해서만 고려한 것이다. 하지만, 요청이 동시에 여러 개가 들어오는 경우 다음과 같은 문제가 발생한다.

 

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인데, 실제로는 완전 다른 값이 나온 것을 확인할 수 있다.

 

멀티 스레드를 사용해 동시에 100개의 재고를 감소시키는 경우 (테스트 실패)

 

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를 사용했음에도 불구하고 테스트에 실패한다.

 

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);
}
  • 테스트에 성공하는 것을 확인할 수 있다.

 

Transactional 어노테이션 제거 후 테스트 실행

 

문제점

  • 자바의 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);
    }
}
  • 실행 결과 테스트가 통과하는 것을 확인할 수 있다.

 

Pessimistic Lock을 사용한 동시성 문제 해결

  • 실제 쿼리를 살펴보면 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);
    }

}

 

  • 실패 시 재시도 로직이 있어 이전보다는 실행 시간이 오래 걸렸지만, 테스트에 성공하는 것을 확인할 수 있다.

 

Optimistic lock을 활용한 동시성 문제 해결

 

장점

  • 별도의 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);
    }
}

 

  • 테스트 케이스 실행 결과 정상적으로 테스트가 성공한다.

 

Named lock을 사용한 동시성 문제 해결

 

 

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);
    }

}
  • 실행 결과

 

Lettuce를 활용한 동시성 문제 해결

 

 

장점

  • 구현이 간단하다
  • 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);
    }
}
  • 실행 결과

 

Redisson을 활용한 동시성 문제 해결

 

장점

  • pub-sub 기반이므로 redis의 부하를 줄여준다.
  • 잠금 획득 재시도를 기본으로 제공한다.

 

단점

  • 구현이 조금 복잡하다.
  • 별도의 라이브러리를 사용해야 한다.
  • lock 을 라이브러리 차원에서 제공해주기 때문에 사용법을 공부해야 한다.

 

실무

  • 재시도가 필요하지 않다면 lettuce를 활용한다.
  • 재시도가 필요한 경우 redisson을 활용한다.

 

 

MySQL VS Redis

 

MySQL

  • 이미 MySQL을 사용하고 있다면 별도의 비용없이 사용할 수 있다.
  • 어느 정도의 트래픽까지는 문제 없이 사용할 수 있다.
  • Redis보다는 성능이 좋지 않다.

 

Redis

  • 활용 중인 redis가 없다면 별도의 구축 비용과 인프라 관리 비용이 필요하다.
  • MySQL보다 성능이 우수하다.

 

실무

  • MySQL로 처리가 가능한 트래픽이라면 MySQL을 사용한다.
  • MySQL로 더이상 처리가 불가능할 정도의 트래픽이 발생한다면 redis를 도입한다.

 

참고
 

재고시스템으로 알아보는 동시성이슈 해결방법 강의 | 최상용 - 인프런

최상용 | 동시성 이슈란 무엇인지 알아보고 처리하는 방법들을 학습합니다., 동시성 이슈 처리도 자신있게! 간단한 재고 시스템으로 차근차근 배워보세요. 백엔드 개발자라면 꼭 알아야 할 동

www.inflearn.com