Project/대용량 트래픽 프로젝트

[프로젝트] Redis 분산락으로 재고 감소 동시성 이슈 해결하기 1 - 기술적 의사결정

쉬지마 이굥진 2024. 12. 11. 03:47

필자는 MSA 기반 이커머스 프로젝트에서 상품/재고/예약구매 도메인을 맡아 진행중이다.

지난 프로젝트에서 쿠폰 도메인을 맡아 개발했을 때 Race condition 문제를 예상치 못하게 겪고 (..) 이번 프로젝트에서 상품/재고 도메인을 맡았을 땐 어느 정도 동시성 이슈가 생기리라고 예상을 했었다.

그래서 이번 포스팅에선 해당 이슈가 왜 발생하는지 원인을 분석하고, 문제를 해결하기 위한 방법과 그에 따른 기술적 의사결정 과정을 적어보려 한다. 

 

문제 상황

바로 테스트 코드를 작성해보자.

동시에 100개의 요청이 들어올 때의 테스트 코드를 작성하고 돌려보니 역시나 실패다. 

 

실패하는 이유는 역시나 위에 언급해 둔 Race condition 문제 때문이다. 

💡Race Condition 이란?
두 개 이상의 스레드가 공유하고 있는 공유 자원을 동시에 수정하려고 할 때 발생하는 문제

 

해결 방법

해결 방법은 간단하다!

하나의 스레드가 작업이 완료된 이후에 다른 스레드가 데이터에 접근 할 수 있도록 만들면 된다.

 

기술 선택지

Race condition을 해결할 수 있는 방법은 'Java에서 지원하는 방법'과 'DB에서 지원하는 방법'으로 나눠서 생각해볼 수 있다. 

 

Java에서 지원하는 방법

 

1. synchronized 사용

synchronized 는 Java에서 동기화를 지원하는 키워드로, 멀티스레드 환경에서 데이터의 일관성과 동기화를 보장할 수 있어서 고려했다.

하지만 synchronized는 하나의 프로세스 안에서만 보장이 된다. 즉, 서버가 1대일 때는 데이터에 접근을 1대의 서버만 해서 괜찮겠지만, 서버가 2대 혹은 그 이상일 경우 데이터에 접근을 여러 대가 할 수 있게 되서 이 경우엔 Race condition 문제가 또 발생할 수 있다. 

 

현재 진행하고 있는 프로젝트는 MSA 기반의 대용량 트래픽 처리를 목적으로 하는 프로젝트라 .. synchronized를 사용하면 간단히 해결할 수 있겠지만 너무나도 임시방편이라고 느껴졌다 🥲

 

DB에서 지원하는 방법

 

1. Pessimistic Lock (비관적 락) 사용

비관적 락은 실제로 데이터에 Lock을 걸어서 정합성을 맞추는 방법이다. exclusive lock을 걸게 되면 다른 트랜잭션에서는 lock이 해제되기 전에 데이터를 가져갈 수 없다. 데드락이 걸릴 수 있기 때문에 조심해서 사용해야 한다는 주의점이 있다.

 

2. Optimistic Lock (낙관적 락) 사용

낙관적 락은 실제 Lock을 이용하지 않고, 버전을 이용해 정합성을 맞추는 방법이다. 먼저 데이터를 읽은 후, update 할 때 현재 내가 읽은 버전이 맞는지 확인하면서 update 한다. 충돌이 발생했을 때의 예외 처리 로직을 잘 설계해야 한다는 주의점이 있다. (e.g. facade 로직 작성)

 

위 두 옵션의 장/단점을 비교해봤다.

  • Pessimistic Lock
    • 장점
      • 충돌이 빈번하게 일어난다면 Optimistic Lock보다 성능이 좋을 수 있다.
      • Lock을 통해 업데이트를 제어하기 때문에 정합성이 보장된다.
    • 단점
      • DB 데이터 자체에 Lock을 잡기 때문에 성능 감소가 있을 수 있다.
  • Optimistic Lock
    • 장점
      • 별도의 Lock을 잡지 않으므로 Pessimistic Lock보다 성능이 좋다.
    • 단점
      • 업데이트가 실패 시 재시도 로직을 개발자가 직접 작성해줘야 한다.
💡비관적 락 vs 낙관적 락 결론
충돌이 빈번하게 일어난다고 예상된다면 Pessimistic Lock을, 빈번하게 일어나지 않을 것이라고 예상된다면 Optimistic Lock 을 쓰면 되겠다고 생각했으나, 결론적으론 Redis의 분산락을 사용하기로 결정했다.

 

포스팅을 읽는 독자분들은 엥 ?! 비교 다 해놓고 갑자기 분산락?! 이라는 생각이 드실 것 같다.

 

분산락을 사용하기로 한 이유

낙관적 락과 비관적 락 말고 Redis의 분산락을 사용하기로 한 이유는 아래와 같다.

프로젝트는 멀티 스레드 환경에서 구동이 되고, 이번 프로젝트는 서버가 하나지만 보통은 서버를 다중화해서 사용하게 된다. 이런 분산환경 속에서 동시성 이슈를 해결하기 위해선 분산락이 필요하다고 판단했다.

💡분산락의 목적
여러 분산된 서버가 존재할 때, 여러 서버에서 공유 자원에 접근하여 발생하는 Race Condition 자체를 없앤다.

따라서 분산락은 공유 자원 자체에 Lock을 설정하는 비관적 락/낙관적 락과 다르게 임계 영역(critical section)에 Lock을 설정한다.

 

그럼 분산락은 어떻게 구현하는게 좋을까?

분산 락을 구현할 때 많은 레퍼런스들을 살펴보면, 대부분 Redis를 사용해서 구현하는 것을 확인할 수 있었다. 

분산락은 MySQL로도 (네임드 락을 사용해) 구현할 수 있는데, 왜 많이들 Redis를 통해 구현하는거지 - 라는 의문이 들었다.

 

😯 MySQL로 구현 시

- 다른 중요 비즈니스 데이터가 존재하는 DB에서 Lock을 관리하기 때문에 부담이 있다.

- Lock을 자동으로 해제할 수 없어서 명시적으로 해제해줘야 한다.

 

😯 Redis로 구현 시

  • Redis는 분산형 메모리 내 데이터 저장소로, 분산환경에서의 lock을 구현하는 데 적합하다.
  • 메모리 내에서 데이터를 다루기 때문에 디스크 기반의 데이터베이스보다 빠른 응답시간을 제공한다.
  • session에 대해 신경 쓸 필요가 없다.
  • Redis는 기본적으로 In-Memory DB이므로 디스크 기반으로 동작하는 MySQL에 비해 성능이 뛰어나다. 
  • 무엇보다 이미 프로젝트 내에서 Redis를 사용 중이었으므로, 이를 구축하는데 드는 품이 추가로 들지 않았다.

Redis에서도 분산락을 구현하는 방법이 여러 가지가 있었다. (끝없는 의사결정의 연속 ... 거의 다왔어요 힘내요 ...)

Redis에서 제공하는 라이브러리 Lettuce와 Redisson으로 분산락을 구현할 수 있는데, 이 두 라이브러리의 특징을 비교해보자.

 

Redis에서 지원하는 방법

 

1. Lettuce 사용

setNX 명령어를 사용해서 분산락을 구현한다. spin lock 방식인데, 비동기 통신이 필요할 경우 사용한다. 

💡setNX 명령어란?
set if not exist의 줄임말로, 키와 밸류를 set 할 때 기존의 값이 없을 때만 set 하는 명령어이다.
💡spin lock 이란?
락을 획득하려는 스레드가 락을 사용할 수 있는지 반복적으로 확인하면서 락 획득을 시도하는 방식이다.
  • 장점
    • spring data redis를 이용하면 lettuce가 기본 라이브러리이기 때문에 별도의 라이브러리를 사용하지 않아도 된다.
  • 단점
    • spin Lock 방식이므로 동시에 많은 스레드가 Lock 획득 대기 상태라면 레디스에 부하를 줄 수 있다.
    • ⇒ 그래서 스레드 슬립을 통해 락 획득 재시도 간에 텀을 둬야 한다.
    • setnx, setex 등을 이용해 분산락을 직접 구현해야 한다. 개발자가 직접 retry, timeout과 같은 기능을 구현해 주어야 한다는 번거로움이 있습니다. retry 로직을 개발자가 작성해줘야 한다. (spin lock 방식)

 

2. Redisson 사용

pub-sub 기반으로 락 구현을 제공한다. 

pub-sub 방식

  • 장점
    • 별도의 Lock interface를 지원해서, 구현이 쉽고 타임아웃과 같은 설정 또한 지원한다. 
    • 대부분의 경우에는 별도의 retry 로직을 작성해주지 않아도 된다. (락 획득 재시도를 기본으로 제공하기 때문)
    • Lettuce는 계속 락 획득을 시도하는 반면, Redisson은 락 해제가 되었을 때 한 번, 혹은 몇 번만 시도를 하기 때문에 레디스의 부하를 줄여준다. (pub-sub 방식으로 구현되었기 때문이다)
  • 단점
    • 별도의 라이브러리를 사용해줘야 한다.
    • lock을 라이브러리 차원에서 제공해주기 때문에 사용법 학습을 따로 해줘야 한다.


결론

결론적으론 Redisson 라이브러리를 선택해서 쓰기로 했다. 

Redisson을 쓰기 위해 추가해줘야 하는 라이브러리가 무겁다고 해도, 요즘 환경에서는 크게 부담 될 정도는 아니고, 이것보단 Lettuce의 spin lock 방식이 레디스에 부담을 줄 수 있다고 생각했다.
또한 별도의 Lock interface를 지원하기 때문에 분산락 구현이 쉽다는 장점 또한 존재했다. 

 

다음 포스팅에선 이 기술적 의사결정을 바탕으로 분산락을 구현 및 적용해보는 시간을 가져보겠다 😀


References

https://helloworld.kurly.com/blog/distributed-redisson-lock/

https://ksh-coding.tistory.com/150

https://techblog.woowahan.com/2631/

https://0soo.tistory.com/256