분산락 적용하기 (개념)

유수민·5일 전
0
post-thumbnail

📌 적용 배경

이번에 회사에서 하는 프로젝트는 '오더 상태 관리'이다. '오더서밋, 오더취소, 배송, 오 더컨펌' 까지의 다양한 오더 상태에 대한 관리를 적용하는 프로젝트이다.

우리 회사는 공급사 상품들의 묶음 단위인 딜을 이용해 주문을 한다. 상품이 있으니까 재고가 있겠지? 즉, 각 오더 상태의 역할별로 재고가 차감되거나 복원된다.

  • 오더 서밋시 : 재고 차감
  • 오더 취소시 : 재고 복원
  • 오더 컨펌시 : 재고 차감 / 재고 복원
    이러한 상태가 변경될때 각 딜에는 항상 중복되는 상품이 존재하기 때문에 동시성 문제가 발생하게 된다. 여기서 추가로 딜에는 여러 상품들이 있기 때문에 여러 상품을 동시에 락을 걸어야 하는 상황이다.

📌 동시성 문제를 해결하는 방법

여러가지 방법이 있는데 비관적락, 낙관적락, 분산락, 네임드락 등이 있다. 각각의 특징을 간단히 알아보자면,
1) 비관적락(DB락)

  • DB에서 직접 락을 걸어 다른 트랜잭션 차단
  • 장점 : 데이터 정합성 강하게 보장, 실시간 동시 수정 방지 가능
  • 단점 : 성능 저하(트랜잭션이 길어질수록 락 유지시간 증가), 데드락
  • 적용 예시) 은행 계좌 잔고 업데이트

2) 낙관적락(버전 필드)

  • 충돌 감지 후 재시도 (rollback & retry)
  • 장점 : 락을 안걸어서 성능이 좋음
  • 단점 : 충돌이 빈번할 경우 계속 재시도하여 성능 저하를 일으킴. 정합성이 다소 낮음

3) 분산락(Redis, Zookeeper)

  • 여러 서버에서 동일한 리소스를 동시에 수정하지 못하도록 제어
  • 장점 : 분산 시스템에도 동기화 가능
  • 단점 : 락 관리(해제, TTL 설정 등) 신경 써야 함, 분산 환경에서 네트워크 이슈로 인해 지연 가능

우리 회사의 경우, 멀티 인스턴스 환경에서 오더상태 변경을 해야하고 재고관리에 있어서 강한 정합성을 요구하기 때문에 분산락을 적용하기로 결정하였다.

📌 분산락

분산락이란 무엇일까?
앞서 언급했듯이 분산락은 여러 서버에서 동일한 리소스를 동시에 접근하지 못하도록 제어하는 것을 의미한다.(비관적 락이나 낙관적 락은 하나의 DB에서만 동작하는 락) 좀 더 기술적 용어를 사용해서 설명하자면,

💡 분산락
락을 획득한 프로세스 혹은 스레드만이 공유 자원 혹은 Critical Section 에 접근할 수 있도록 하는 것

키(락)를 가진 사람(프로세스/스레드)만 보물이 있는 공간(공유자원)의 문을 열 수 있는 것이다 🗝

분산락을 적용하는 방법은 여러가지가 있다. Redis, Zookeeper, MySql 등등.. 결론적으로 말하자면, 우린 Redis를 사용하였다.
우선 Redis는 그동안 캐시용도로 이미 구성해놓은 반면에 Zookeeper는 추가적인 인프라 구성이 필요하기 때문에 제외하게 되었다. 그리고 알다시피 Redis는 싱글스레드로 작동하기 때문에 동시성 문제도 현저히 작다. 아 물론 Mysql도 있긴 한데, 락을 사용하기 위해 별도의 커넥션 풀을 관리해야 하고 락에 관련된 부하를 RDS에서 받으니 Redis를 사용하는 것이 더 효율적이다.

Redisson을 사용한 이유는?

Redis는 인메모리 데이터 저장소로 사용되지만 , 캐시 역할을 넘어서 다양한 분산 시스템 기능을 지원하는 구현제(라이브러리, 프레임워크)들이 존재한다. 그 중 난 분산락을 위한 구현체에 대해 간단히 알아보자면,

  • Jedis -> Lettuce가 성능이 더 좋아서 Lettuce로 대체됨
  • Lettuce
  • Redisson

1) Lettuce

  • Spring Data Redis에서 기본적으로 사용하는 Redis 클라이언트
  • setnx를 활용한 스핀락 : 반복적으로 락 획득 시도 -> 레디스에 많은 부하 발생. CPU를 계속 사용하면서 재시도하는 방식
  • 락 획득 방식
    (1) SET NX 명령어로 락 획득을 시도
    (2) 락이 없으면 성공 → 작업 진행 후 DEL로 락 해제
    (3) 이미 락이 있으면 실패 → 일정 시간 대기 후 재시도 (스핀락 방식)
    (4) TTL(EX)을 설정하여 데드락 방지

2) Redisson

  • 별도의 Lock interface를 지원 : RedLock, RLock(단일 인스턴스 락) 지원

    💡 RedLock

    • Redis 기반의 분산 락을 더 안전하게 보장하기 위한 알고리즘
    • 멀티 Redis 노드 환경에서 장애 복구가 중요한 경우
    • 데이터 정합성이 중요한 글로벌 시스템
    • Redis 장애가 발생해도 락을 유지해야 하는 경우
    • RedLock은 과반수 이상의 Redis 노드에서 락을 획득해야 성공
  • Pub/Sub 방식을 이용하기에 락이 해제되면 락을 subscribe 하는 클라이언트는 락이 해제되었다는 신호를 받고 락 획득을 시도

  • Redisson은 락 대기 및 해제 처리를 최적화하여 불필요한 CPU 낭비 없이 안정적으로 락을 관리

  • 락이 만료되기 전에 자동으로 TTL을 연장하여, 장시간 작업에서도 안정적인 락 유지가 가능
    ( Lettuce는 TTL이 지나면 락이 풀릴 수 있어 작업 중 충돌 위험이 존재 )

결론적으로, Lettuce보다 안정적인 분산 락이 필요했고, CPU 사용을 줄이면서 TTL 자동 연장과 다양한 락 기능을 활용하기 위해 Redisson을 선택하게 된것이다. 그럼 이제, RedLock을 이용할지, RLock을 이용해서 구현할지에 대한 고민이 생긴다.

RedLock, RLock ? 어떤 것을 이용할까

❌ RedLock이 과할 수 있는 경우
싱글 Redis 노드 환경이거나, 락을 걸어야 하는 트랜잭션이 짧다면 RedLock은 오버헤드가 될 수도 있다

  • 단일 Redis 인스턴스 환경에서는 RedLock을 사용할 필요 없음
  • 과반수 노드가 죽으면 락 획득이 불가능해질 수도 있음

현재 우리의 레디스 환경은 하나의 레디스 인스턴스에서 모든 데이터와 락을 관리하는 싱글 노드 형태이기 때문에 RedLock보다는 RLock을 선택하는 것이 낫다는 판단이 되었다.

코드내에서 주목해야 할점

코드 내에서 주목해야 할 점을 난 2가지를 뽑았다.
1) RLock의 내부 코드 파헤치기
2) 트랜잭션 분리

🤔 RLock의 내부 코드 파헤치기

Redission을 이용한 분산락 코드는 사실 인터넷을 조금은 서칭하면 거의 비슷하게 나온다. 그런데 정작 내부의 RLock의 코드를 파헤친 기록은 없더이다. 퇴근하고 남는게 시간인데 놀면 뭐하나,, 내부 코드 뒤적거리면서 시간이나 보내야지 ⏳
적용한 코드를 크게 보면 간단하다

락 객체 생성(열쇠 가져오기) → 락 걸기(열쇠로 잠그기) → 락 해제(열쇠로 잠금 풀기)

1) 락 객체 생성(열쇠 가져오기)

자.. 락 객체 생성부터 알아볼까?

처음 시작은 getLock부터 시작한다. 이 코드를 따라가다보면, 최종적으로 RedissonLock 클래스의 생성자로 연결된다.

첫번째 코드 줄을 통해, RedissonLock은 RedissonBaseLock을 상속받고, 기본적인 락 이름(name)과 명령 실행기(commandExecutor)가 초기화함을 알 수 있다.
명령 실행기(commandExecutor)라는 것은 🎁 비동기 Redis 명령어 실행기를 의미한다. 음 Redis에 직접 명령을 보내는 역할인거다. 예를 들어 tryLock()을 호출하면, 내부적으로 SET NX PX 명령이 Redis에 전송되는 것이다. 그래서 명령 실행기를 초기화한다는 것은 commandExecutor를 통해 Redis와 통신할 준비를 한다는 거라고 생각하면 된다.

internalLockLeaseTime는 자동 락 해제 시간 설정하는 것이다. 여기서 우리가 주목해야 할것은 🎁 락 워치독 (Watchdog) 기능이다. 쉽게 말하면, 자동 연장 기능이다.

📌 락 워치독(Watchdog)은 왜 필요할까?
보통 Redis에서 락을 설정할 때 TTL(만료 시간)을 지정하는데, 작업이 TTL 안에 끝나지 않으면 락이 자동으로 해제되는 문제가 있다.

예를 들어 TTL이 5초인데 작업이 6초걸린다고 치자. 5초 후 락이 만료되고 자동으로 해제되면?
다른 프로세스가 같은 락을 획득할 수 있다 → 데이터 일관성 깨짐 😨
그래서 락을 획득한 스레드가 살아 있는 동안 TTL이 자동으로 연장된다는 기능이다. TTL을 직접 설정하지 않으면 기본 30초 동안 유지된다고 한다.

마지막 줄인 pubSub은 🎁 Pub/Sub 기능을 활용하여 락 해제 이벤트를 감지하는 역할이다.
Redis에서 분산 락을 사용할 때, 다른 클라이언트가 락을 대기하는 방식에는 2가지 방식이 있다.

  • 폴링(Polling) 방식: 주기적으로 Redis를 조회해서 락이 해제되었는지 확인함.
  • 이벤트 기반 방식: 락이 해제될 때 Redis가 직접 알림(Pub/Sub)을 보내서 대기 중인 클라이언트가 즉시 실행됨.

만약 폴링 방식이라면? 락을 얻으려는 클라이언트가 주기적으로 Redis에 요청을 보내 락이 해제되었는지 확인해야한다. 듣기만 해도, 불필요한 Redis 부하가 발생하고 클라이언트가 지속적으로 Redis에 요청을 보내므로 트래픽이 많아질 거라는 단점이 느껴지지?
그래서 Redisson에서는 락이 해제될 때 이벤트를 발생시켜 다른 클라이언트가 즉시 실행될 수 있도록 처리한다. 언제? RLock.unlock() 이 호출될때!

2) 락 걸기(열쇠로 잠그기)
이제 락을 어떻게 거는지 알아보자. 코드를 따라가다보면 Redission 클래스에서 tryLock()의 구현체를 확인할 수 있다.
코드에 대한 내용을 간단하게 정리하자면,
주어진 대기 시간(waitTime) 내에 락을 획득하려 시도하며, 락을 획득하면 지정된 임대 시간(leaseTime) 동안 락을 유지한다. 락을 즉시 획득하지 못한 경우, 다른 클라이언트의 락 해제 이벤트를 대기하기 위해 Pub/Sub 메커니즘을 활용하고, 대기 시간 내에 락을 획득하지 못하면 false를 반환하는 매커니즘을 확인할 수 있다.

3) 락 해제(열쇠로 잠금 풀기)

비동기적으로 락을 해제하는 모습을 볼 수 있다. 앞서 언급했듯이 Redisson에서는 락이 해제될 때 이벤트를 발생시켜 다른 클라이언트가 즉시 실행될 수 있도록 처리한다 -> 이부분을 찾기 위해 코드를 엄청 뒤졌는데 사실 해당 역할을 하는 코드를 찾을 수가 없어서 좀 아쉽다..ㅠ

🤔 트랜잭션 분리

코드를 살펴보면 락을 걸고 나서 트랜잭션을 분리해서 비즈니스 로직을 실행하는 역할을 하는 것을 볼 수 있다.


음..쉽게 말하면 DistributedLock 어노테이션이 선언된 메서드를 별도의 트랜잭션으로 실행하게 만든 코드인 것이다.

Propagation.REQUIRES_NEW 옵션을 지정해 부모 트랜잭션의 유무에 관계없이 별도의 트랜잭션으로 동작하게끔 설정하고 반드시 트랜잭션 커밋 이후 락이 해제되게끔 처리하고 있다. 왜 이렇게 분리를 했을까?
해당 내용은 컬리의 블로그에 너무 자세히 써져있다. 내가 진행한 프로젝트도 재고를 위한 분산락인데 여기서도 재고를 예시로 들어서 너무나 적절하게 써져있으니 해당 링크 참고하길 바란다. 결론을 말하자면 데이터 정합성을 위한 방법으로 트랜잭션 커밋 이후 락이 해제되게끔 처리 해놓았다.

📌 추가된 요구사항

실전으로 넘어가기 전에, 추가할 요구사항이 있다. 앞선 요구사항은 하나의 key 즉, 하나의 row만 락을 거는 형식으로 구현되어 있다. 하지만 우리 회사 특성상 주문시 여러 상품을 동시에 상태 변경하기 때문에 한번에 여러 상품의 재고를 변경해야한다. 따라서 하나의 row가 아닌 여러 row에 락을 걸어야 한다.

그렇다면 기존에 받는 키도 하나에서 여러개를 받게 되고 락도 동시에 여러개를 건다는 말이겠지? 정리하자면, 여러 개의 락을 동시에 걸고, 하나라도 실패하면 전체 실패하도록 하고 싶다는 것이다. 이때 난 RedissonMultiLock이라는 것을 사용했다.

즉, 하나의 트랜잭션처럼 모든 락이 성공해야만 실행되도록 할때 사용된다. 그렇다는 말은 락을 해제할때도 한꺼번에 해제한다는 말과 동일하다.

이제 추가된 요구까지 알아보았으니 본격적으로 테스트를 해볼까? 해당 내용은 다음편에 있다.

참고)

profile
배우는 것이 즐겁다!

0개의 댓글

관련 채용 정보