[프로젝트] Redis를 활용한 동시성 이슈 해결

mingeloper·2024년 1월 3일
0

프로젝트[KOING]

목록 보기
1/7
post-thumbnail

안녕하세요
현재 진행하고 있는 KOING 프로젝트에서 동시성 이슈가 발생할 수 있다고 판단했습니다.
저는 Redis를 활용해 이를 해결했고 이번 포스팅에서 그 경험을 공유해드리겠습니다.

동시성 이슈는 무엇인가?

먼저 동시성 이슈에 대해 간단하게 알아보겠습니다.

우리가 사용하는 서비스는 보통 서버에 요청을 보내고, 서버에서 요청을 처리하고 다시 응답하는 방식으로 작동합니다.

예를 들어 인스타그램에서 친구의 게시물에 좋아요를 누를 때를 생각해보겠습니다.
인스타그램이 어떻게 해당 기술을 구현했는지는 정확히 알 수 없어서 해당 로직은 단지 제 생각이 라는 점 참고해주세요.
그리고 인스타그램이 그럴리가 없겠지만, 서버는 한 대라고 가정할게요.
친구의 게시글에 하트모양을 누르면 인스타그램 서버에 좋아요를 눌렀으니 좋아요 개수를 업데이트 해달라는 요청이 전송될 것입니다.
그러면 서버에서 요청을 받고 해당 게시글이 저장된 DB를 읽어오고 좋아요 개수를 업데이트 한 후, DB를 업데이트하게 될 것입니다.
DB에 잘 저장이 되면 정상적으로 업데이트가 완료되고 우리는 해당 게시글에 제가 누른 좋아요가 잘 적용된 걸 볼 수 있겠죠.
한 사람씩 좋아요를 누르면 문제가 전혀 없지만, 인스타그램 같이 많은 사람이 이용하는 서비스에서는 완벽히 동시에는 아니지만 비슷한 시간에 같은 요청, 즉 같은 게시글에 좋아요를 누르는 상황이 발생할 수 있습니다.

여기서 문제가 발생합니다.
만약 두명이 같은 게시글에 좋아요를 누르게 되면 조금이라도 먼저 보내진 요청(A요청)을 처리하게 됩니다. 위와 마찬가지로 서버에서는 해당 게시글의 데이터를 읽어오고 좋아요를 업데이트하고 DB에 쓰기 작업을 하게 되죠.
그런데 첫번째 요청(A요청)이 끝나기 전, 즉 A요청으로 인한 DB업데이트가 이루어지기 전에 두번째 요청(B요청)이 서버로 전송되게 됩니다.
그러면 서버는 해당 요청을 처리하기 시작하고 B요청 또한 A요청이 업데이트 전 내용을 읽어오게 됩니다.
결과적으로 A요청과 B요청은 같은 데이터를 읽어오고 처리하고 DB에 업데이트 하게 되고 좋아요 개수는 2개가 올라야하지만 1개만 오르는 문제가 발생합니다.

글이 길어졌지만,
결국 요청이 여러개 왔을 때 DB데이터가 순서대로 변경되어야 하는데, 앞의 요청이 끝나기전에 뒤의 요청이 들어와 원하는 결과가 나타나지 않는 것이 핵심입니다.

문제 상황

KOING 프로젝트에서 가이드는 투어를 생성하고 투어리스트는 해당 투어를 신청할 수 있습니다.
현재 한 개의 투어당 한 팀만 신청할 수 있고 신청 과정에서 결제도 해야하기 때문에 신청을 완료하기까지의 텀이 긴 편입니다.
따라서 한 개의 투어를 동시에 신청하게 되었을 때 동시성 이슈가 발생할 할 수 있었고 이를 해결해야 했습니다.
A팀과 경복궁 투어를 신청하고 결제를 하는 도중에 B팀이 해당 투어를 신청하는 과정이 시작하면, A팀이 결제를 완료한 후에도 B팀이 결제를 할 수 있는 상황이 생기는 것입니다.
또한 커뮤니티 기능에서도 좋아요 기능이 구현되어 있어서 여기에서도 동시성 이슈가 발생할 수 있습니다.

어떻게 문제를 해결할 것인가?

자, 그럼 어떻게 문제를 해결해야 할까요?

일단 어느 사용자가 투어 신청 버튼을 눌러 신청 로직이 시작하는 순간, 다른 사용자는 해당 투어의 신청 로직을 수행할 수 없도록 해야했습니다.
처음에는 투어에 지금 신청이 진행중인지 Flag를 추가해서, 그 Flag가 True이면 누군가 신청로직을 진행하고 있고, False면 아직 신청 로직이 시작되지 않았다고 구별하려 했습니다.
그러나 마찬가지로 신청 로직 시작 후 Flag가 True로 바뀌기 전에 동시성 이슈가 발생할 수 있었고 만약 신청을 하다가 취소를 하게되면 다시 Flag를 False로 바꿔야되는 불편함이 발생합니다.

해결 방법을 찾아보기 시작했고 구글링과 책을 참고한 끝에 동시성 이슈를 해결하는 방법이 크게 3가지 정도 있다는 것을 알게 되었습니다.

  1. synchronized 키워드를 활용하는 방법
    Java에서 사용하는 synchronized 키워드를 활용하는 방법입니다.
    동시성 이슈가 발생하는 메소드 앞에 synchronized 키워드를 붙이면 해당 메소드는 한 번에 한 개의 thread 에서만 접근이 가능합니다.
    그러면 여러개의 요청이 들어와도 가장 먼저 들어온 요청만 해당 메소드에 접근이 가능해지고, 메소드가 종료 되면 다음 요청이 해당 메소드를 실행하기 때문에 동시성 이슈 해결이 가능해집니다.
    그러나 synchronized는 키워드는 @Transactional과 함께 사용이 불가능 합니다.
    A transaction이 열려서 해당 메소드를 실행하고 메소드 실행이 끝나면 B transaction에서 해당 메소드를 실행하게 되는데, 이 타이밍이 A transaction이 commit 되기 전이기 때문입니다.
    또한 synchronized 키워드는 하나의 프로세스에서만 유효하기 때문에 서버가 여러대라면 이슈 해결이 불가능합니다.
    현재 프로젝트의 서버가 한 대이기는 하지만 실제 서비스를 고려하는 서비스 이기에 서버 한 대에서만 적용가능한 방법을 사용하는 것은 좋지 않다고 생각했습니다.

  2. Database 혹은 JPA를 사용하여 DB에 Lock을 사용하는 방법
    Database에 Lock을 걸어 동시성 이슈를 해결할 수 있습니다.
    해당 DB에 락을 가진 하나의 서버만 접근할 수 있게 하는 비관적 락,
    version 관리를 통해 업데이트 하려는 DB버전과 최신 버전을 비교하여 반영 여부를 선택하는 낙관적 락,
    비관적 락과 비슷하지만 table과 row 단위가 아닌 메타데이터 단위로 락을 거는 네임드 락
    이렇게 세가지 Lock이 존재합니다.

  3. Redis를 활용하여 분산락을 이용하는 방법
    마지막으로 redis를 활용한 방법이 있습니다.
    redis의 라이브러리 중에 lettuce와 redisson이라는 라이브러리가 있는데 해당 라이브러리를 활용하여 동시성 이슈를 해결할 수 있습니다.

    Luttuce는 Setnx 명령어를 활용하여 분산락을 구현합니다.
    Spin Lock 방식으로 Lock을 획득하려는 Thread가 Lock을 획득할 수 있는지 계속 확인하고 이 로직을 개발자가 작성해주어야 합니다.
    따라서 Lock을 얻기 위한 재시도 때문에 부하가 발생할 수 있습니다.

    Redisson은 Pub-sub 기반으로 Lock을 구현합니다.
    Pub-sub 방식은 채널을 만들어서 락을 점유중인 Thread가 락을 해제하면, 대기중인 다른 Thread에게 Lock을 획득할 수 있다고 알려주는 방식입니다.
    따라서 재시도 로직을 작성할 필요가 없습니다.

위 방법들 중 제가 선택한 방법은 Redis의 Redisson 라이브러리를 활용한 방법입니다.
Database Lock을 활용한 방법도 가능한 방법이지만 Redis가 DB Lock 방법보다 더 효율적이라고 알고있었습니다.
제가 생각해본 이유로는 redis가 in-memory DB이기에 I/O 연산이 더 빠르기도 하고 DB는 이미 데이터에 대한 I/O 부하를 많이 받고있기 때문인 것 같습니다.
물론 상황에 따라 다르고 무조건 redis가 효율적인 것은 아닐 것입니다.
Redis를 사용하려면 redis를 구축하는 비용 등, 추가적인 비용이 필요하지만 Jwt refresh token을 redis에 저장하기 위해 이미 redis를 도입 한 후라 redis를 활용하기로 했습니다.
그리고 Redisson을 활용하면 재시도에 따른 부하가 발생하지 않아서 Redisson을 선택했습니다.

적용 과정

클라우드 환경에서는 redis 서버를 열어 운영하지만 로컬에서 redis 서버를 매번 여는 것이 불필요하다 판한해서 이전에 Embedded Redis를 적용해 놓았습니다.
따라서 포스팅은 Embedded redis 환경입니다.

Redisson을 사용하는 것은 어렵지 않습니다.
Gradle에 의존성을 추가해주기만 하면 redisson을 사용할 수 있습니다.

앞서 말씀드린 것과 같이 투어 신청 로직이 시작 되면 다른 사용자는 해당 투어 신청 로직을 시작할 수 없어야 했습니다.
그래서 Lock을 거는 시점이 상당히 중요했습니다.
기존에 투어를 신청하고 결제하는 로직으로는 이 시점을 특정하기 어려웠습니다.
또한 기존 결제 로직으로는 다른 문제가 많이 발생했기에 결제 로직 수정도 작업해야 했습니다.
이 부분에 대해서는 추후 포스팅 하겠습니다.

결론적으로 현재 투어 신청 요청을 하면 가장 먼저 해당 투어 신청에 대한 정보가 생성됩니다.
저는 이 투어 신청 정보 데이터를 기준으로 잡았습니다.

코드를 먼저 보시죠.

public SuperResponse proceedPayment(final PaymentInfoCreateCommand command) {

    final String lockName = "lockName";
    RLock rLock = redissonClient.getLock(lockName); // (1)

    try {
        System.out.println(Thread.currentThread().getName() + " " + lockName + " " + "tryLock");
        boolean available = rLock.tryLock(3, 30, TimeUnit.SECONDS); // (2)
        System.out.println(Thread.currentThread().getName() + " " + lockName + " " + "getLock");
        if (!available) {
            System.out.println("redisson getLock timeout");
            throw new IllegalArgumentException();
            return ErrorResponse.error(ErrorCode.NOT_ACCEPTABLE_DURING_PAYMENT_EXCEPTION);
        }

        return paymentInfoService.createPaymentInfo(command);
    } catch (InterruptedException e) {
       throw new RuntimeException(e);
       return ErrorResponse.error(ErrorCode.INTERNAL_SERVER_EXCEPTION);
    } finally {
        rLock.unlock(); // (3)
        System.out.println(Thread.currentThread().getName() + " " + lockName + " " + "releaseLock");
     }
}

해당 코드는 paymentInfoService의 createPaymentInfo 부분을 파사드로 만든 부분입니다.
처음에는 createPaymentInfo 메소드 안에서 redisson을 활용하여 lock을 획득하고 해제했었는데, 그렇게 되면 transaction commit 시점 전에 lock을 해제해 버려서 문제가 발생했습니다.
그래서 파사드 패턴을 통해 createPaymentInfo를 보다 더 큰 범위에서 transaction을 열었습니다.

코드를 살펴보면 lockName 변수가 보입니다.
이 lockName이 어떤 lock을 획득하려는지 정하는 변수가 됩니다.
위에서 redisson은 pub-sub 구조하고 했는데요, 비유하자면 lockName이 구독하려는 topic이 되는 셈입니다.
A 사용자가 lockName 이라는 이름의 lock을 사용하고 있으면 B 사용자는 lockName을 이용하기 위해서 기다려야 하는 것이죠.
그러다 A 사용자가 lockName을 unlock 하게 되면 B 사용자는 이를 감지하고 lock 획득을 시도합니다.
물론 대기하는 사용자가 여러명이면 무조건 B 사용자가 lock을 획득한다고 보장할 수는 없습니다.
순서가 보장되지는 않는다는 말이죠.
그리고 (2), (3)번은 각각 lock을 획득을 시도하고 unlock하는 부분입니다.

해결!

테스트를 통해 제대로 작동하는지 확인해 보겠습니다

	@Test
    void 동시에_같은_투어_결제정보를_10개_생성한다() throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        CountDownLatch countDownLatch = new CountDownLatch(10);

        PaymentInfoCreateCommand paymentInfoCreateCommand = new PaymentInfoCreateCommand(1L, 1L, 1L, "20240102");

        for (int i = 0; i < 10; i++) {
            executorService.submit(() -> {
                try {
                    paymentInfoServiceFacade.proceedPayment(paymentInfoCreateCommand);
                } finally {
                    countDownLatch.countDown();
                }
            });
        }

        countDownLatch.await();
        int paymentInfoCount = paymentInfoRepository.findAll().size();

        assertThat(paymentInfoCount).isEqualTo(2);
    }

테스트를 실행하기 전 paymentInfo에는 한개의 정보가 들어있었습니다.

paymentInfo는 정상적인 투어 신청 기록 기준으로, 한 개의 투어에 하나만 생성될 수 있어야하므로 10개의 Thread에서 요청을 보내도 하나의 기록만 생성되어야 합니다.
따라서 기대 값은 2입니다.

테스트가 성공했습니다.


로그를 살펴보면 10개의 Thread 들이 lock 획득을 시도하는 것을 볼 수 있습니다.
그리고 마지막에 보면 thread-5가 lock 획득에 성공했습니다.
그리고 paymentInfo 생성을 시도하고 생성에 성공하는 것을 볼 수 있습니다.
까만 부분은 DB생성 로그라서 지웠습니다.


그리고 thread-5는 lock을 release 하고 thread-4 가 lock을 획득했습니다.
thread-4도 paymentInfo 생성을 시도하는데 이미 thread-5가 해당 paymentInfo를 생성했으므로 생성에 성공했다는 로그가 나오지 않습니다.
이후 thread-4도 lock을 release하고 thread-8이 lock을 획득하지만 결과는 같습니다.
동시성 이슈를 해결한 것을 확인할 수 있습니다.

추가사항

투어 신청에서 동시성 이슈를 해결했지만 커뮤니티 게시글에서 좋아요 기능에서도 이를 활용해야 합니다.
따라서 AOP를 활용하여 재사용 가능한 코드로 수정하는 작업을 해보려 합니다.

참고

profile
내일 더 성장하고 가치를 개발하는 개발자

0개의 댓글