Distributed Lock

Dongwoo Kim·2024년 4월 21일
0

TIL / WIL

목록 보기
113/113
post-thumbnail

개요

코어팀에서 통합결제, 구독 시스템을 만들면서 결제나 포인트 변동과 같은 기능에서 여러번의 요청을 받을 경우 중복으로 결제되거나 포인트가 두번씩 차감되는 등의 문제가 발생하였다. 이유는

  1. 한 번만 동작하길 기대하는 요청을 동시에 여러번 요청받은 경우 (버튼 따닥)
  2. 먼저 요청된 작업이 데이터베이스에 반영되기 전에 다른 요청의 작업을 시작한 경우

이를 해결할 수 있는 Distributed Lock를 알아보았고 코어시스템에 임시로 적용해보았다. 여기서 그 과정과 Distributed Lock에 대해 간략히 소개해보고자 한다.

크레딧이 141원만 있는 상태에서 결제 재시도를 연속으로 두번 요청한 경우 카드결제요청 과 크레딧차감이 두번씩 일어난 것을 볼 수 있다.

Distributed Lock이란

Conditions

개요에서 말한 상황처럼 특정 지점 또는 같은 리소스에 여러 클라이언트나 요청이 동시에 접근할 경우 상호 배제(Mutual exclusion)를 위해서 하나의 클라이언트는 잠금(Lock, 접근권한)을 획득하고 해당 잠금이 해제될까지 리소스에 대한 접근 및 수정을 진행한다. 다른 클라이언트들은 해당 잠금이 해제되어 본인이 잠금을 획득할 때까지 기다려야한다.(blocking)

이를 구현하기 위한 몇가지 조건이 있는데 이를 번역기를 돌려보고 해석해보자면 다음과 같다.

  1. Safety property: Mutual exclusion. At any given moment, only one client can hold a lock.
  2. Liveness property A: Deadlock free. Eventually it is always possible to acquire a lock, even if the client that locked a resource crashes or gets partitioned.
  3. Liveness property B: Fault tolerance. As long as the majority of Redis nodes are up, clients are able to acquire and release locks.
  1. 안전자산: 상호배제. 특정 순간에 단 하나의 클라이언트만 잠금을 보유할 수 있습니다.
  2. 활성 속성 A: 교착 상태가 없습니다. 결국 리소스를 잠근 클라이언트가 충돌하거나 분할되는 경우에도 항상 잠금을 획득할 수 있습니다.
  3. 활성 속성 B: 내결함성. 대부분의 Redis 노드가 작동하는 한 클라이언트는 잠금을 획득하고 해제할 수 있습니다.
  1. 1번의 경우가 우리가 원하는 잠금의 기능이고
  2. 잠금에는 유효시간이 존재하고 해당 시간이 지나면 잠금은 해제되어 다른 클라언트가 잠금을 획득할 수 있어야한다.
  3. 잠금을 가진 클라이언트가 잠금을 해제할 수 있어야한다.

Single Instance

해당 잠금을 구현한 redis와 같은 cache 인스턴스가 단일 인스턴스인경우에는 리소스별 잠금을 키로 구현할 수 있다.(1번 조건) 이 경우 키의 만료시간이 잠금의 유효시간이 된다. (2번 조건) 그리고 해당 키의 값을 클라이언트의 식별값으로하여 해당 잠금을 가진 클라이언트를 확인할 수 있다. 그리고 해당 값이 일치할때만 잠금을 해제한다.(3번 조건)

Redlock Algorithm

하지만 여러개의 인스턴스가 존재할 경우 여러문제가 발생한다. 여러 인스턴스별로 한 리소스에대한 잠금을 가진 클라이언트가 다를 수 있기 때문이다. 이때 여러 분산된 잠금을 Distributed Locks라고 하며 Redis에서는 이를 관리하는 Distributed Lock Manager(DLM)를 구현하는 Redlock 알고리즘을 소개하고있다.

우리는 해당 알고리즘을 구현하는 것아니라 구현된 라이브러리를 사용할 것이기 때문에 간단하게만 소개하자면

  1. 기본적으로 N개의 인스터스가 존재할 경우 절반을 넘는(N/2 + 1) 인스턴스에 대한 잠금을 획득한 클라이언트가 해당 잠금을 획득했다고 간주한다.
  2. 잠금을 획득하기위해 N개의 인스턴스를 순회하는 시간은 키 만료시간에 비해 아주 짧아야한다.
  3. 잠금을 획득하는데 소요된 시간이 잠금 유효시간보다 짧은 경우 잠금을 획득한것으로 간주한다.
  4. 잠금을 획득하지못하면 획득했던 잠금들을 모두 해제한다.
  5. 최종적인 잠금의 유효시간은 TTL- (T2-T1) - CLOCK_DRIFT
    • TTL : 키 만료시간
    • T1 : 첫번째 키의 잠금을 획득한 시간
    • T2 : 마지막 키의 잠금을 획득한 시간
    • CLOCK_DRIFT : 인스턴스간 시간오차
  6. 중간에 다운된 인스턴스는 TTL 보다 긴 텀을 두고 다시 활성화되어야한다.

redis-py를 이용한 python에서 distributed lock 구현

redis에서도 소개하듯이 redlock 알고리즘을 구현한 python 라이브러리들은 이미 존재한다.

하지만 현재 코어시스템의 캐시서버는 redis 단일 인스터스로 동작하기 때문에 엄밀히는 distributed lock이라고 할 수 없을 것이다. 때문에 distributed lock을 위한 추가적인 라이브러리를 추가하는 것에 대한 고민이 많았다. 때문에 이미 사용중인 redis-py 라이브러리에서 제공하는 Lock 기능을 이용하되 위에서 알아본 최소한의 조건들을 만족하도록하여 사용하고자했다.

선택의 가장 큰 영향을 준 요소: 적은 러닝커브, 빠른 대응, 현 상황에 맞는 크기의 작업소요

redis-py의 Lock기능도 distributed lock을 제공한다고는 되어있지만 명시적으로 redlock 알고리즘을 이용한다고는 표시되어있지않다.

잠금 리소스 단위

먼저 생각해야할 것은 무엇을 잠금할 것인가였다. 처음에는 데이터베이스 모델을 단위로 생각했지만 현재 코어시스탬 코드는 Django orm과 핵심로직 사이가 인터페이스로 완전 분리된 상태가 아니기때문에 위험한 선택이라고 생각했다. 가장 간단한 방법으로는 기능별로 잠금는 것이다. API 요청은 하나의 Feature 함수가 담당하고있고 해당 Feature 함수에서 각종 로직들을 실행하기 때문에 해당 Feature함수를 리소스단위로 보고 클라이언트가 해당 기능을 이용하는 동안 다른 클라이언트나 요청이 실행될 수 없도록 하는 것이다.

transaction과 lock

당연한 이야기지만 잠금의 획득과 해제는 transaction 범위 밖에서 일어나야한다. 즉

잠금획득 → transaction 시작 → transaction commit → 잠금 해제 순으로 이뤄져야한다. 따라서 리소스의 단위인 Feature 함수를 하나의 transaction 묶고 해당 함수에 전/후 작읍으로 잠금을 획득/해제 하는 데코레이터로 Lock기능을 구현하였다.

추가 작업

앞서 redis-py에서 제공하는 Lock기능은 Redlock 알고리즘을 보장하지 않기 때문에 최소한의 조건을 만족하도록해줘야했다. 그 중 하나가 timeout 이었다. redis-py의 Lock 구현체 코드를 보면 timeout (키 만료시간)을 입력하지않으면 해당 잠금을 해제하기전까지 잠금이 유지된다고한다.

최종적인 구조

# redis.py
class Cache__Manger:
    @staticmethod
    def get_lock_key(shop_id: int, feature) -> str:
        ...
        
    @staticmethod
    def get_lock(key: str) -> Lock:
        with Cache__Manager.get_redis_client() as conn:
            return Lock(conn, key, timeout=10)   # 잠금 만료시간 10초로 설정

# common/features.py
def feature_lock(func):
    def wrapper1(*args, **kwargs):
        shop_id = Common__Utils.get_parameter(args, kwargs, func, "shop_id")

        key = Cache__Manager.get_lock_key(shop_id, func) # 리소스 식별
        lock = Cache__Manager.get_lock(key)   
        if not lock.acquire(blocking=False):    # 잠금 획득
            raise FeatureLockError(f"{func.__name__}") 

        result = func(*args, **kwargs)          # 리소스 실행

        lock.release()                          # 잠금 해제

        return result

    return wrapper1

# features.py
@feature_lock         # 해당 함수를 리소스 단위로 잠금
@transaction.atomic   # Feature 함수를 transaction 단위로 동작
def retry_charge(shop_id: int):
  ...

추가 고려사항

  1. 외부 요청이 포함된 기능의 경우 (카드결제요청) 외부 api에 의한 지연시간을 어떻게 처리할 것인지?

    → 카드결제에 대한 별도 잠금이 있어야할 것 같다.

  2. 기능별이 아닌 모델별 잠금을 구현한다면?, 크레딧, 예치금과 같은 금원의 변동사항이 특정 기능에서 여러번 일어난다면 어떻게 처리할 것인지?

    → Django orm과 핵심로직 사이에 인터페이스를 두고 해당 모델의 조회, 수정 전/후로 잠금획득/해제를 구현하는 것을 보장할 수 있다면, 또는 특정 기능에서만 해당 모델에 대한 작업을 진행한다는 것을 보장하고 그 기능에 대한 잠금획득/해제를 구현한다면 가능할 것 같다.

Reference

Distributed Lock

Distributed Locks with Redis

[Redis] 레디스가 제공하는 분산락(RedLock)의 특징과 한계

Insight

풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법 - Spring Redisson

Distributed Lock 구현 과정

profile
kimphysicsman

0개의 댓글