활동 참여하기 api 구현기 with 동시성 이슈

최지환·2023년 8월 14일
0

졸업작품-동네줍깅

목록 보기
8/11
post-thumbnail

졸업 전시회 이후 플로깅 활동 참여하기 api의 필요성을 느끼고 참여하기 api 를 구현하기로 했다.

플로깅 활동 구인 게시글에 참여하기 버튼을 누르면 해당 참여인원을 카운트 해줘야 했다.

따라서 Recruitment_board 내부에 참여 카운트 필드를 넣고 회원이 참여요청 시 count ++ 을 하는 로직을 생각하였다.

하지만 이런 경우 동시성 문제가 발생할 것이라 생각하였고 이를 해결하려면 어떻게 할지 찾아보았다.

우선 동시성 이슈가 무엇인지 알아보았다.

동시성 이슈

여러 쓰레드가 동시에 같은 인스턴스의 필드 값을 변경 하면서 발생하는 문제를 의미.

하나의 자원에 대하여 여러 쓰레드가 조회, 변경, 저장 작업을 하면서 원하는 대로 로직이 수행되지 않고 데이터 정합성이 깨지는 것이다.

아래 그림을 보자

쓰레드 A, B 에서 count 안에 있는 값 1 을 각각 + 1 해주는 상황이다. A, B 쓰레드는 count 값 1 일 때 동시에 조회하여 변경, 저장을 거쳤을 때 count 는 2가 된다. 결과적으로 count ++ 을 두번 실행하지 못하였다.

정상적이라면 쓰레드 A 가 작업을 마친 후 쓰레드 B 가 작업을 실행한다면 count 값은 3이 될 것이다. 하지만 count 값은 2 이다. 이런 상황이 동시성 문제가 발생한 것 이다.

한 쓰레드 에서 조회 및 변경된 값을 저장하는 과정 사이에 다른 쓰레드로 부터 저장된 값이 변경되었다면 발생한다.

플로깅 활동 카운트 부분에도 이런 동시성 문제가 발생할 것이라 생각했다. 게시글 내부에 count 가 1 인 경우, 여러 회원이 동시에 참여하기 api 를 콜한다면?

count 가 api 를 호출한 회원의 명 수 많큼 플러스가 되지 않을 수 있었다.

따라서 이런 동시성 문제를 해결 할 수 있는 방법을 공부해보았다.


우선 동시성 문제가 발생할 수 있는 상황을 코드로 확인 해보자.

코드 예제는 Stock 을 이용하였고 재고 감소 로직에서 발생하는 동시성 문제이다.

Stock

@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("foo");
        }
        this.quantity = this.quantity - quantity;
    }
}

StockService


@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.saveAndFlush(stock);
    }
}

테스트 코드


@SpringBootTest
class StockServiceTest {

    @Autowired
    private StockService stockService;

    @Autowired
    private StockRepository repository;

    @BeforeEach
    private void before() {
        Stock stock = new Stock(1L, 100L);
        repository.save(stock);
    }

    @AfterEach
    private void after() {
        repository.deleteAll();
    }

    @Test
    void 동시에_100개_감소_요청() throws InterruptedException {
        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32); //멀티 쓰레드를 사용하기 위함. 비동기로 실행하는 작업을 단순화하게 사용할 수 있도록 하는 자바 API

        CountDownLatch latch = new CountDownLatch(threadCount); //100개의 요청을 카운트하기 위함.

        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() ->
            {
                try {
                    stockService.decrease(1L, 1L);
                }
                finally {
                    latch.countDown(); // 카운트
                }
            });
        }

        latch.await();//다른 쓰레드에서 실행 중인 작업이 완료될 때 까지 대기
        Stock stock = repository.findById(1L).orElseThrow();
        assertThat(stock.getQuantity()).isEqualTo(0L);
    }

}

위 테스트를 실행하면 테스트는 실패한다.

총 100번 stockService.decrease() 를 호출 했기 때문에 stock 의 quantity 는 0 이 되어야 한다고 생각했지만 50 이라는 결과가 나오는 것을 알 수 있다.

여러 쓰레드에서 stockService.decrease() 를 호출 하면서 조회, 변경, 저장 이 이루어 지면서 동시성 문제가 발생함을 알 수 있다.

해결법

synchronized 사용

첫번째로 동시성 문제를 해결 할 수 있는 방법은 자바의 synchronized 를 사용하는 방법이다.

@Service
public class StockService {
    private final StockRepository stockRepository;

    public StockService(StockRepository stockRepository) {
        this.stockRepository = stockRepository;
    }

    public **synchronized** void decrease(Long id, Long quantity) {
        Stock stock = stockRepository.findById(id).orElseThrow();

        stock.decrease(quantity);

        stockRepository.saveAndFlush(stock);
    }
}

동시성 문제가 발생하는 메서드에 synchronized 키워드를 붙히면 된다. 이때 @Transactinal 은 제거해주어야 한다. → spring 에서 @Transactional 코드는 동기화(synchronized) 로 감싸진 코드가 아니기 때문에 동기화가 이루어지지 않음. 따라서 첫번째 쓰레드 commit 이 이루어 지기 전에 두번째 쓰레드 commit 이 이루어질 수 있음.

synchronized 는 멀티스레드 환경에서 쓰레드간 데이터를 동기화 시켜주기 위해서 자바에서 제공하는 예약어이다.

synchronized 를 명시해주면 하나의 쓰레드만 해당 메서드에 접근이 가능합니다. → 자바레벨(어플리케이션)에서 데이터에 락을 건다고 이해하면 됩니다.

이후 테스트를 돌려보면 성공한다.

하지만 이런 방식에는 한계점 또한 존재한다. 애플리케이션 레벨에서 프로세스의 동시접근에 대해만 동기화를 잡아주기 때문에 멀티 서버로 서비스를 배포한 상황에서는 동시성 문제가 발생 할 수 있다.

하단 이미지를 봐보자.

server1server2 에서 쓰레드간 동기화를 해주기 위해 synchronizedstock.decrease() 를 호출한다고 하더라도, 각 서버에서 동시에 DB에 조회, 변경, 저장 로직을 수행한다면 동시성 문제가 발생 할 수 있다.

이런 문제를 해결하기 위해서는 다른 방법들이 있다.

Pessimistic Lock

  • 데이터 자체에 Lock 을 걸어서 정합성을 맞추는 방법
  • 다른 트랜잭션에서는 Lock 이 해제 되기 전에 데이터를 가져갈 수 없게됨.
  • 이 방법에 경우 동시성문제가 무조건 발생할 것이라고 예상하고 락을 걸어버리는 비관적 락 방식임.

Service

@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.saveAndFlush(stock);
    }
}

repository

→ @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(Long id);
}

테스트 코드

@SpringBootTest
class PessimisticLockStockServiceTest {
    @Autowired
    private PessimisticLockStockService pessimisticLockStockService;

    @Autowired
    private StockRepository repository;

    @BeforeEach
    private void before() {
        Stock stock = new Stock(1L, 100L);
        repository.save(stock);
    }

    @AfterEach
    private void after() {
        repository.deleteAll();
    }

    @Test
    void 동시에_100개_감소_요청() throws InterruptedException {
        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32); //멀티 쓰레드를 사용하기 위함. 비동기로 실행하는 작업을 단순화하게 사용할 수 있도록 하는 자바 API

        CountDownLatch latch = new CountDownLatch(threadCount); //100개의 요청을 카운트하기 위함.

        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() ->
            {
                try {
                    pessimisticLockStockService.decrease(1L, 1L);
                }
                finally {
                    latch.countDown(); // 카운트
                }
            });
        }

        latch.await();//다른 쓰레드에서 실행 중인 작업이 완료될 때 까지 대기
        Stock stock = repository.findById(1L).orElseThrow();
        assertThat(stock.getQuantity()).isEqualTo(0L);
    }

결과

for update 확인 가능

데이터를 수정하기 위해 Select 를 하는 중이기 때문에 다른 트랜잭션이 레코드를 Select 하지 못하게 read, write 락을 검

다만 이 방식은 항상 락을 걸기 때문에 동시성 문제가 빈번하게 자주 발생한다면 효과적인 방법임.

다만 그렇지 않은 경우 불필요하게 lock 을 잡음.


Optimistic Lock

  • 낙관적 락
  • Version(버전)을 이용하여 데이터 정합성을 맞추는 방법.
  • DB로 부터 데이터를 조회한 후, update 실행 시 현재 DB에 있는 데이터가 내가 읽었던 version 이 맞는지 확인 후 업데이트를 하는 방식
  • 내가 조회했던 데이터의 버전과 DB의 버전이 다른 경우 다시 조회 한 후 작업을 수행.

과정 설명

  1. server1 과 server 2 에서 Stock 조회, 이때 Stock 의 version = 1

  1. 작업 후 update 쿼리를 날림. 이때 DB에 버전 1에 대한 변경임을 알려주고 동시에 version + 1 한 값으로 version 수정,
    Server1 에 대한 작업이 먼저 처리가 되면 server2 의 update 쿼리의 where 절에 걸리게 되어 update 실패

  1. server 2 는 version 2 에 해당하는 stock 를 다시 조회 한 후 업데이트 쿼리를 다시 날림.

    낙관적 락을 사용 해보자.

    stock

    @Entity
    public class Stock {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        private Long productId;
    
        private Long quantity;
    
        **@Version
        private Long version; //version 추가**sp
    
        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("foo");
            }
            this.quantity = this.quantity - quantity;
        }
    }

    repository

    public interface StockRepository extends JpaRepository<Stock, Long> {
    
        @Lock(value = LockModeType.OPTIMISTIC)
        @Query("select s from Stock s where s.id = :id")
        Stock findByIdWithOptimisticLock(Long id);
    }

    → 사용하는 엔티티에 @Version 이 명시 되어 있으면 해당 데이터의 조회시 낙관적 락이 적용 됨. 따라서 @Lock(value = LockModeType.OPTIMISTIC) 를 굳이 적어줄 필요는 없음.

    다만 코드에 대해 상세한 명시를 하기 위해서는 적어주는 것이 좋아보임.

    OptimisticLockStockFacade

    @Service
    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; // 락 흭득 성공 시 break
                } catch (Exception e) {
                    Thread.sleep(50);//락 미흭득 시 대기
                }
            }
        }
    }

    락 처리에 대한 로직을 facade 패턴을 써서 분리함.

    테스트 코드

    @SpringBootTest
    class OptimisticLockStockFacadeServiceTest {
    
        @Autowired
        private OptimisticLockStockFacade optimisticLockStockFacadeService;
    
        @Autowired
        private StockRepository repository;
    
        @BeforeEach
        private void before() {
            Stock stock = new Stock(1L, 100L);
            repository.save(stock);
        }
    
        @AfterEach
        private void after() {
            repository.deleteAll();
        }
    
        @Test
        void 동시에_100개_감소_요청() throws InterruptedException {
            int threadCount = 100;
            ExecutorService executorService = Executors.newFixedThreadPool(32); //멀티 쓰레드를 사용하기 위함. 비동기로 실행하는 작업을 단순화하게 사용할 수 있도록 하는 자바 API
    
            CountDownLatch latch = new CountDownLatch(threadCount); //100개의 요청을 카운트하기 위함.
    
            for (int i = 0; i < threadCount; i++) {
                executorService.submit(() ->
                {
                    try {
                        optimisticLockStockFacadeService.decrease(1L, 1L);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    } finally {
                        latch.countDown(); // 카운트
                    }
                });
            }
    
            latch.await();//다른 쓰레드에서 실행 중인 작업이 완료될 때 까지 대기
            Stock stock = repository.findById(1L).orElseThrow();
            assertThat(stock.getQuantity()).isEqualTo(0L);
        }
    }

    결과 - 성공

    이 후 @Version 에 대해서 여러가지 테스트를 더 해본 결과

    OptimisticLock select 를 통해 update 를 날린 경우에만 Version 값이 변경 된다는 것을 확인.

    단순 Update 로는 @Version 과 별개로 업데이트가 이루어짐 → version 값이 변경되지 않음.


네임드락 (Named Lock)

- 말 그대로 이름을 가진 락이다. 이름을 가진 lock 을 흭득 후 해제 할 때 까지 다른 세션에서는 이 lock 을 흭득 할 수 가 없다.
- 주의점으로는 lock 이 transaction 이 종료 될 때 자동으로 해제 되지 않기 때문에 개발자가 직접 로직을 작성해야한다.
- `Pessimistic lock` 과 유사하지만 `pessimistic lock` 은 row 나 table 단위로 락을 걸지만, named lock 은 metadata 단위로 락을 건다. → lock 정보를 저장한 또 다른 공간에 lock을 검.

세션1 이 A 라는  이름으로 lock 을 걸면 세션 2 는 세션 1이 락을 해제한 후에 A 락을 얻을 수 있음.

DB 에 메타데이터인 lock 에 락 정보를 저장함.

![](https://velog.velcdn.com/images/cjh8746/post/4b81f841-c44a-415a-966c-8d9d9b3ebf90/image.png)


NamedLockStockFacade

```java
@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());
        }
    }
}
```

service

```java
@Service
public class StockService {
    private final StockRepository stockRepository;

    public StockService(StockRepository stockRepository) {
        this.stockRepository = stockRepository;
    }

		//부모트랜잭션과 같이 묶이면 Database 에 commit 되기전에 락이 풀려버리는 현상이 발생 ㅎ
		//따라서 lock 해제 전 Database 에 commit 이 되도록 하기 위해 REQUIRES_NEW 설정 
		//REQUIRES_NEW 설정  기존의 트랜잭션을 메소드가 종료할 때까지 잠시 대기 상태로 두고 자신의 트랜잭션을 실행함.
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public  void decrease(Long id, Long quantity) {
        Stock stock = stockRepository.findById(id).orElseThrow();

        stock.decrease(quantity);

        stockRepository.saveAndFlush(stock);
    }
}
```

LockRepository

```java

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

테스트 코드

```java
@SpringBootTest
class NamedLockStockFacadeTest {

    @Autowired
    private NamedLockStockFacade namedLockStockFacade;

    @Autowired
    private StockRepository stockRepository;

    @BeforeEach
    public void insert() {
        Stock stock = new Stock(1L, 100L);

        stockRepository.saveAndFlush(stock);
    }

    @AfterEach
    public void delete() {
        stockRepository.deleteAll();
    }

    @Test
    public void 동시에_100개의요청() throws InterruptedException {
        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        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();
        assertThat(stock.getQuantity()).isEqualTo(0);
    }
}
```

결과 - 성공

![](https://velog.velcdn.com/images/cjh8746/post/545e7f7a-a564-49b3-9cef-a15a42741f10/image.png)


---

레디스 사용

- Redis 를 사용하여 동시성 문제를 해결 할 수 있다.

Lettuce

- Spring Gradle 에 Redis 의존성 필요.
    
    ![](https://velog.velcdn.com/images/cjh8746/post/19409681-5165-45fd-9435-80cd8139d6f3/image.png)

    
- spin Lock 방식.
- lock 을 레디스를 통해 관리

RedisLockRepository 

```java
@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));
    }

	  private String generateKey(Long key) {
        return key.toString();
    }

    public Boolean unlock(Long key) {
        return redisTemplate.delete(generateKey(key));
    }

  
}
```

RedisLockRepository 를 통해 레디스로부터 락을 받고, 풀어줌.

LettuceLockStockFacade - 감소 로직 호출 부분

```java
@Component
public class LettuceLockStockFacade {

    private RedisLockRepository redisLockRepository;

    private StockService stockService;

    public LettuceLockStockFacade(RedisLockRepository redisLockRepository, StockService stockService) {
        this.redisLockRepository = redisLockRepository;
        this.stockService = stockService;
    }

    public void decrease(Long key, Long quantity) throws InterruptedException {
        while (!redisLockRepository.lock(key)) {
            Thread.sleep(100);
        }

        try {
            stockService.decrease(key, quantity);
        } finally {
            redisLockRepository.unlock(key);
        }
    }
}
```

RedisLockRepository 를 통해서 락을 받고, 락이 존재하면 로직 실행, 그렇지 않은 경우 스핀락 방식으로 쓰레드 대기.

테스트 코드

```java

@SpringBootTest
class LettuceLockStockFacadeTest {

    @Autowired
    private LettuceLockStockFacade lettuceLockStockFacade;

    @Autowired
    private StockRepository stockRepository;

    @BeforeEach
    public void insert() {
        Stock stock = new Stock(1L, 100L);

        stockRepository.saveAndFlush(stock);
    }

    @AfterEach
    public void delete() {
        stockRepository.deleteAll();
    }

    @Test
    public void 동시에_100개의요청() throws InterruptedException {
        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        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();

	     assertThat(stock.getQuantity()).isEqualTo(0L);
    }
}
```

---

Redisson 사용

- redisson 의존성 필요.
    
    ![](https://velog.velcdn.com/images/cjh8746/post/aa9ca5e7-785c-475e-bad0-66c1a2abcdff/image.png)

    
- Pub-sub 을 통해 락 관리 → lettuce 와 다르게 스핀락 방식이 아닌 `pub-sub 방식`이라 `자원의 낭비`가 덜함.
- pub-sub 방식 이해해보기
    - redis cli 창을 2개를 연다.
    - 
    1. 1번 cli 에서 subscribe ch1 명령어를 통해 ch1 을 구독한다.
    
    ![](https://velog.velcdn.com/images/cjh8746/post/633b9bc0-e54e-4fb1-9a8f-406218f3915a/image.png)

    
    1. 2번 cli 에서 ch1 로 메세지를 보낸다.
        
        ![](https://velog.velcdn.com/images/cjh8746/post/298ef4a1-f5ee-426c-96cc-06d354b045db/image.png)

        
    2. 1번 cli 에서 메세지가 온 것을 확인 할 수 있다.
        
        ![](https://velog.velcdn.com/images/cjh8746/post/58e26981-86bc-4dd7-a5c9-7b653c11e3e7/image.png)

        

##Lettuce 와 redisson 비교

- Lettuce
    - 구현이 간단함.
    - 스핀락 방식이기 때문에 여러 쓰레드가 같은 자원에 접근하면 부하 발생
    - Spring data redis 사용 시 lettuce 가 기본으로 있기 때문에 별도의 라이브러리 사용하지 않아도됨
    - 락 흭득 및 재시도 로직을 개발자가 작성해야함.
- Redisson
    - 락 흭득 및 재시도 로직을 기본으로 제공 -> (락 흭득 재시도를 위한 반복문 사용 x)
    - Pub-sub 방식으로 구현이 되어 있기 때문에 스핀락 방식인 lettuce 와 비교해서 부하가 덜함
    - Redisson 라이브러리를 사용해야함.
    

적용한 코드 확인

RedissonLockStockFacade

```java

@Component
public class RedissonLockStockFacade {

    private final RedissonClient redissonClient;
    private final StockService stockService;

    public RedissonLockStockFacade(RedissonClient redissonClient, StockService stockService) {
        this.redissonClient = redissonClient;
        this.stockService = stockService;
    }

    public void decrease(Long key, Long quantity) {
        RLock lock = redissonClient.getLock(key.toString());

        try {
            boolean available = lock.tryLock(5, 1, TimeUnit.SECONDS);

            if (!available) {//락 흭득 실패시
                System.out.println("락 흭득 실패" );
                return;
            }
            stockService.decrease(key, quantity);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }
}
```

프로젝트 내 문제 해결

synchronized,낙관적 락, 비관적 락, 네임드 락, lettuce, redisson 등 동시성 문제를 해결할 수 있는 방법들을 알아보았다.

나는 낙관적 락 방식을 선택했다.

근거

  • 활동 참여하기 api 에 여러 사용자가 동시간에 요청을 보내는 경우가 거의 없음. → 비관적 락 사용 x
  • 현재 서버 애플리케이션을 1대만 배포한 상황이지만 나중에 추가 될 수도 있기 때문에 synchronized 는 지양
  • 별도로 라이브러리를 사용해서 관리를 하기에는 비용이 든다고 생각함. → redis 자체를 사용해 본적이 많이 없기 때문에 구현 시간 단축을 위해 낙관적 락 사용.

우선 테스트 코드로 동시성이 발생하는데 확인 해보자.

동시성 문제 발생 테스트 코드


@SpringBootTest
class RecruitmentBoardConcurrencyTest {

    @Autowired
    private RecruitmentBoardService recruitmentBoardService;

    @Autowired
    private RecruitmentBoardRepository recruitmentBoardRepository;

    @Test
    @DisplayName("동시성 문제 확인 - 8명이 동시에 요청한 경우 카운트가 8이 되어야 하지만 8이 되지 않음.")
    void SuccessCountUp() throws InterruptedException {
        int threadCount = 7;
        ExecutorService executorService = Executors.newFixedThreadPool(32); //멀티 쓰레드를 사용하기 위함. 비동기로 실행하는 작업을 단순화하게 사용할 수 있도록 하는 자바 API

        CountDownLatch latch = new CountDownLatch(threadCount); //100개의 요청을 카운트하기 위함.

        for (int i = 0; i < threadCount; i++) {
            int memberId = i + 2;
            executorService.submit(() -> {
                try {
                    recruitmentBoardService.participate((long) memberId, 1L);
                } catch (Exception e) {
                    System.out.println(e.getMessage());
                } finally {
                    latch.countDown(); // 카운트
                }
            });
        }
        latch.await();

        RecruitmentBoard recruitmentBoard = recruitmentBoardRepository.findById(1L).get();
        assertThat(recruitmentBoard.getParticipationCount().getCount()).isEqualTo(8);
    }
}

게시글에 7 명의 사용자가 참여하기 요청을 보냈지만 참여 카운트 횟수는 2임. 총 7의 인원이 참여하기 처리가 정상적으로 이루어 지지 않음.

구현

  1. RecruitmentBoard 에 version 칼럼 추가
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RecruitmentBoard {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "recruitment_board_id", nullable = false)
    private Long id;

    @Column(nullable = false)
    private LocalDateTime creatingDateTime;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member writer;

    @Embedded
    private ParticipationCount participationCount;

    @OneToMany(mappedBy = "recruitmentBoard")
    private List<Participation> participationList = new ArrayList<>();

    @OneToMany(mappedBy = "recruitmentBoard", cascade = CascadeType.REMOVE)
    private List<Comment> comments = new ArrayList<>();

    **@Version
    private Long version;**

    //...
}
  1. RecruitmentBoardRepository 에 조회 메서드 추가
public interface RecruitmentBoardRepository extends JpaRepository<RecruitmentBoard, Long> {

    **@Lock(value = LockModeType.OPTIMISTIC)**
    @Query("SELECT b " +
            "FROM RecruitmentBoard b " +
            "WHERE b.id = :boardId")
    Optional<RecruitmentBoard> findByIdWithOptimisticLock(final Long boardId);

	//...
}

@Lock(value = LockModeType.OPTIMISTIC) 옵션의 경우 추가 하지 않아도 됨.
****엔티티에 @Version 이 있으면 자동으로 OPTIMISTIC Lock 으로 조회를 함.

  1. 락 흭득 및 재흭득 처리를 위한 OptimisticLockRecruitmentBoardFacade 생성

@Component
@RequiredArgsConstructor
public class OptimisticLockRecruitmentBoardFacade {

    private final RecruitmentBoardService recruitmentBoardService;

    public RecruitmentBoardIdResponse participate(Long memberId, Long boardId) throws InterruptedException {
        RecruitmentBoardIdResponse recruitmentBoardIdResponse;
        while (true) {
            try {
                recruitmentBoardIdResponse = recruitmentBoardService.participate(memberId, boardId);
                break;
            } catch (ObjectOptimisticLockingFailureException e) {
                Thread.sleep(50);
            }
        }
        return recruitmentBoardIdResponse;
    }
}
  1. 테스트

@SpringBootTest
class RecruitmentBoardConcurrencyTest {

    @Autowired
    private OptimisticLockRecruitmentBoardFacade recruitmentBoardService;

    @Autowired
    private RecruitmentBoardRepository recruitmentBoardRepository;

    @Test
    @DisplayName("동시성 문제 확인 - 8명이 동시에 요청한 경우 카운트가 8이 되어야 하지만 8이 되지 않음.")
    void SuccessCountUp() throws InterruptedException {
        int threadCount = 7;
        ExecutorService executorService = Executors.newFixedThreadPool(32); //멀티 쓰레드를 사용하기 위함. 비동기로 실행하는 작업을 단순화하게 사용할 수 있도록 하는 자바 API

        CountDownLatch latch = new CountDownLatch(threadCount); //100개의 요청을 카운트하기 위함.

        for (int i = 0; i < threadCount; i++) {
            int memberId = i + 2;
            executorService.submit(() -> {
                try {
                    recruitmentBoardService.participate((long) memberId, 1L);
                } catch (Exception e) {
                    System.out.println(e.getMessage());
                } finally {
                    latch.countDown(); // 카운트
                }
            });
        }
        latch.await();

        RecruitmentBoard recruitmentBoard = recruitmentBoardRepository.findById(1L).get();
        assertThat(recruitmentBoard.getParticipationCount().getCount()).isEqualTo(8);
    }
}

통과

끝.

참고

https://www.inflearn.com/course/동시성이슈-재고시스템/dashboard

1개의 댓글

comment-user-thumbnail
2023년 8월 14일

좋은 정보 얻어갑니다, 감사합니다.

답글 달기