Spring - 동시성 제어 실험(Lock, Redis)

김상엽·2024년 3월 20일
0

Spring

목록 보기
18/26
post-thumbnail

TIL

동시성 제어

오늘은 동시성 제어에 도전해 보았다.
여태까지 구현했던 코드들은 로컬서버에서 나 혼자 구동하기 때문에
딱히 동시성에 대해 생각해보지 않았다.
하지만 최종프로젝트가 다가오면서, 동시성 제어에 도전해보았다.

설정한 상황(선착순 쿠폰 100개 이벤트)

Counter 라는 entity쿠폰 개수(count)100으로 가정하고,
쿠폰을 수령하면 count1 감소시키는 로직을 구현하였다.

1. 동시성 고려 X (기존에 구현한 방식)

    @Transactional
    public void decreaseCount() {
        Counter counter = counterRepository.findById(1L).orElseThrow();
        counter.setCount(counter.getCount() - 1);
        counterRepository.save(counter);
    }
  • Counter를 DB에서 불러와서
  • count의 값을 1 감소시키고
  • 다시 DB에 Counter를 저장했다.

1-1. 테스트 결과 (기존에 구현한 방식)

    @Test @DisplayName("RDBMS를 사용했을 시 동시성 문제가 발생")
    void concurrencyTest() {
        System.out.println("\n\n\n\n[concurrencyTest]");
        IntStream.range(0, 100).parallel().forEach(i -> counterService.decreaseCount());
        counterService.printCount();
    }
  • decreaseCount()를 반복해서 100번 수행한다면 당연히 count는 100번 감소하여 0이 될 것이다.
  • 하지만 병렬로 100번을 동시에 수행한다면 과연 count는 0이 될까?
  • 동시성 문제로 인해서 0이 아닌 83이 나오는걸 확인할 수 있다.
  • 감소된 count가 저장되기전에 다른곳에서 조회를 해서 문제가 발생하는 것으로 보인다.

2. 동시성 고려 시도 1(객체를 여러개 만들고 삭제하기)

  • 1번에서 실패한 상황을 조금 바꿔보았다.
  • 이번에는 Counter객체를 5개 만들고
  • count값이 100인 Counter를 DB에서 불러와서
  • 삭제하고 "내꺼!"를 출력하도록 구현해보았다.
  • 삭제로 한 이유는 삭제를 시도할때 이미 삭제가 되었다면 Exception이 발생해서 출력이 안될것이라고 예상했기 때문이다.

2-1. 테스트 결과 (객체를 여러개 만들고 삭제하기)

  • 나는 분명 쿠폰을 5개만 준비했지만.. 8명이 받아갔다.
  • 심지어 실패했을때 출력하도록한 "까비!" 또한 하나도 출력되지 않았다.
  • 예상이 빗나간 이유를 검색해 보니, JPA에서는 삭제할 Entity가 없어도 Exception을 발생시키지 않는다고 한다. 만약 발생시키고 싶다면 삭제 함수를 오버라이딩해서 예외를 발생하도록 추가해야 한다고 한다!

3. 동시성 고려 시도 2 (Lock)

  • 이번에는 Lock을 통해 동시성 문제를 해결해보았다.
    public void decreaseCountUsingLock() {
        RLock lock = redissonClient.getFairLock(LOCK_KEY);
        try {
            boolean isLocked = lock.tryLock(10, 60, TimeUnit.SECONDS);
            if (isLocked) {
                try {
                    Counter counter = counterRepository.findById(1L).orElseThrow();
                    counter.setCount(counter.getCount() - 1);
                    counterRepository.save(counter);
                } finally {
                    lock.unlock();
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
  • 간단히 설명하자면 10초동안 락이 풀릴때까지 기다리고,
  • 60초가 지나면 락이 자동으로 풀리게 설정하였다.
  • 물론 함수가 끝나면 바로 락이 풀린다.

3-1. 테스트 결과 (Lock)

  • Lock을 걸어서 100개의 시도가 순서대로 처리되어서 성공적으로 쿠폰이 0개 남았음을 확인할 수 있었다!

4. 동시성 고려 시도 3 (Redis)

  • 앞의 Lock을 활용할때도 Redis를 사용하였지만,
  • 이번에는 Redis싱글쓰레드라는 특징을 활용하여 동시성을 제어해보겠다.
    public void decrementCounter() {
        ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
        ops.decrement("counter");
    }

4-1. 테스트 결과 (Redis)

  • Redis가 싱글쓰레드라는 특징을 갖고있어서, 100개의 시도가 순서대로 처리되어서 성공적으로 쿠폰이 0개남았다!

오늘의 회고

여태까지 동시성 문제에 대해 고려하지 않았었다.
왜냐면 여태까지 진행했던 프로젝트들은 모두 로컬 서버에서 혼자 돌렸기때문에
내가 한명인 이상 동시성 문제가 발생할 수 없고, 정말 천문학적인 트래픽이 아니라면 동시에 처리될 확률이 엄청나게 낮기 때문이다.
그런데 생각해보면 평소에 배달대행앱을 자주 사용하는데, 이러한 선착순 이벤트를 구현한다면 동시성 문제를 반드시 해결해야한다는 생각이 들어서 오늘 실험을 해보았다.

실험을 하면서 가장 충격받은점은, JPA는 삭제에 실패해도 예외를 발생시키지 않는다는 점이다.

평소에 굉장히 편리해서 아주 좋아했던 내 친구 JPA였는데,
이러한 허점이 있다는 사실을 처음 알았다.

오늘 사용했던 Redis에 대해서는 사실 많이 아는것이 없다..(오늘 처음봤다)
추후에 Redis와 친해진 뒤에, Redis에 대한 정리글을 올릴 예정이다!

profile
개발하는 기록자

0개의 댓글