ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Redis, Jmeter를 이용하여 동시성 문제 해결하기
    Redis 2022. 11. 6. 20:41
    반응형

    1) 서론

    하나의 일을 할 때에는 침착히 잘 해내는데, 여러 일을 동시에 할 때는 허둥지둥하여 망쳐버린 경험이 있으신가요?

    저는 여러 일을 동시에 진행할 때 우선순위를 잘 정하지 못하고, 처리하는데 급급하여 결과의 품질이 떨어지는 경험을 하곤 했습니다. 모든 일을 마치고 보면 항상 아쉬움이 남습니다. 조금 더 신경 썼다면 더 나은 결과물을 냈을 것 같다는 후회도 종종 하는데요.

    이러한 경험 때문인지 출근 후 가장 먼저 TO-DO 목록을 작성합니다. 그리고 일의 우선순위를 나름대로 정하여 어떠한 경우가 발생하더라도 그것을 지키려고 합니다.

    고객 혹은 업무 연관자들이 대기하는 시간이 길어질 것 같은 것을 먼저 처리하고, 혼자서 하는 개인 업무를 처리하는 방식으로 우선순위를 정하는데요.

    일의 우선순위는 사람마다 다르게 생각할 수 있습니다. 다만 중요한 것은 나름의 규칙과 방식을 세워서 처리하고 있다는 것입니다. 그리고 그것들이 충돌하는 것을 최대한 방지하려고 합니다.

    이번 글에서는 멀티 스레드 혹은 다중 서버 환경에서의 동시성 문제를 해결하는 과정을 공유드리려고 합니다.


    2) 아이디어

    아주 간단한 페이 서비스를 만든다고 가정합니다. (아이디어가 부족합니다)

    • 10자리의 랜덤 한 바코드 번호가 있습니다.
    • 1개의 바코드에 N개의 포인트들이 존재합니다.
    • 동일한 바코드를 여러 명이 동시에 사용할 수 있습니다.


    사용된 테스트 환경은 아래와 같습니다.

    • Kotlin 1.7
    • Spring Boot 2.7.5
    • Redis 7.0.5 (single instance)
    • Redisson 3.17.7
    • MySql 8.0.31
    • Jmeter 5.5

     

    디렉터리 구조는 아래와 같습니다.

    • api: 사용자에게 응답하는 표현 계층입니다
    • application: 도메인에 해당하는 비지니스 로직이 존재합니다
      • facade: 다른 도메인에서 어떤 데이터를 조회할 때 도메인이 섞이지 않도록 구분한 계층입니다. 반복되는 로직도 분리하여 사용할 수 있습니다
    • domain: pay 서비스의 point 도메인을 표현하는 계층입니다
      • vo: 불변성을 가지는 value 객체로서 데이터를 가집니다
    • infrastructure: repository 같은 DB에 접근하는 계층입니다

    3) 문제가 발생할 수 있는 상황

    Point 도메인의 기능 중 한 가지에서 아래와 같은 문제가 발생할 수 있습니다.

    • 동일한 바코드를 여러 명이 사용할 수 있다. (캡처된 그리고 실제 앱에서의 바코드 동시 사용)


    만약 이런 경우에는 어떻게 될까요?

    • 1000 잔여 포인트
    • 요청 당 10 포인트 사용
    • 동시에 100건의 요청 발생


    요청에 대한 처리가 모두 완료된 후 0 포인트를 기대합니다. 하지만 이와 같이 동시 요청이 발생했을 때 정말 0 포인트를 보장할 수 있을까요?

    요즈음은 대용량 트래픽을 처리하기 위해 docker container와 k8s를 이용해서 scale-out을 구현하는 경우가 많아졌습니다. 하지만 이러한 구성에는 어쩔 수 없이 동시성 문제가 발생할 수밖에 없는데요.

    3. 1) Jmeter를 활용한 100건 동시 요청 테스트

    아래와 같이 간단히 포인트를 차감하는 로직을 구현합니다.

    테스트를 위해 대략적으로 작성한 코드이므로 참고만 부탁드립니다.


    아래와 같이 Jmeter 설정합니다.

    • 요청 당 10 포인트씩 사용합니다.
    • 1초 동안 100건의 요청을 보냅니다. (1000 포인트 사용)

    

    • 모든 100건 요청이 1000 포인트 상태인 잔액 조회합니다
    • 동시에 10원씩 차감합니다
    • 모든 요청에 대한 결과는 990 포인트가 되고, 동일하게 업데이트합니다


    동시 요청이 발생한 경우 예상과 다르게 동작하는 것을 볼 수 있는데요. 이는 하나의 요청이 완료되기 전 모든 요청이 동일한 데이터를 조회하기 때문입니다. 그리고 동일하게 차감합니다.

    이러한 금액이 맞지 않는 결과가 현실에서 발생한다면, 매우 심각한 문제가 될 수 있습니다. 1원이라도 다르게 계산되는 금융 상품을 사용하고자 하는 고객은 없을 것 같습니다. 비롯 멤버십 포인트라고 하더라도요.

     

    돈이 남네요? 더 써도 되는건가?


    위의 문제를 해결하기 위해서는 하나의 요청이 완료되기 전까지 해당 데이터가 변하지 않는다는 것을 보장해야 합니다. 단순히 생각했을 때는 DB의 lock을 걸 수 있을 것 같습니다.

    하지만 DB를 사용한 lock 구현은 다양한 문제를 발생시킬 수 있는데요.

    • 스핀 락(spin lock)을 사용하기 때문에 lock 획득할 때까지 계속 시도하게되는데요. 불필요하게 DB에 부하를 주게 됩니다.
    • Race condition으로 인해 deadlock, starvation과 같은 사이드 이펙트가 발생할 수 있습니다


    그렇다면 어떻게 동시성 문제를 해결할 수 있을까요?


    4) Redis와 Redisson client를 이용한 분산 락

    RedisRedisson을 사용하면 동시성 문제를 쉽게 해결할 수 있습니다. 그리고 분산 락(distrubuted lock)을 이용하려고 합니다.

    Redisson은 Redis에서 제공하는 분산 락 알고리즘인 RedLock을 쉽게 사용할 수 있는 클라이언트 구현체입니다. 오픈소스로 시작된 클라이언트지만 Redis 공식 문서에서 자바 클라이언트로 소개되고 있습니다.

    RedLock의 알고리즘은 대략 이렇습니다.

    • Lock 시도하는 key 없다면 생성합니다
    • key 생성 시 만료 시간을 지정하여 생성합니다
    • Unlock과 동시에 key 삭제합니다
    • Key가 삭제됐음을 publish 하여 알려줍니다


    위에서 언급한 스핀 락(spin lock)과 다른 점이 있습니다.

    계속해서 key 획득 시도하는 것이 아니라, 대기 queue에 담긴 후 key 획득이 가능하다는 메시지를 subscribe 후 동작한다는 것입니다. 즉 계속해서 획득이 가능한지 확인하는 과정이 없어졌습니다.

    정확히는 분산락을 Pub-Sub으로 구현했습니다. Redis가 key 획득 가능 publish 하게 되면, Redisson이 subscribe 합니다.

    이전과 변경된 코드는 아래와 같습니다.

    • Facade 계층에 Redis 관련 서비스 객체가 추가되었습니다
    • 'use_point_lock' 이름의 key lock 획득 시도합니다
    • 동시 요청이 발생했을 때 고객이 무한정 대기하지 않도록 10초 동안 대기합니다


    그렇다면 위의 key 획득 로직은 Redis에서 어떻게 동작할까요? MONITOR 명령어를 통해서 확인해보겠습니다.

    [0 172.17.0.1:58576] "EVAL" "if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', KEYS[1], ARGV[2], 1); 
    redis.call('pexpire', KEYS[1], ARGV[1]); 
    return nil; end; if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); 
    redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; return redis.call('pttl', KEYS[1]);" "1" "use_point_lock" "10000" "bf4cbda3-f0e5-4768-a645-978426fcde5a:131"
    
    [0 lua] "exists" "use_point_lock"
    
    [0 lua] "hincrby" "use_point_lock" "bf4cbda3-f0e5-4768-a645-978426fcde5a:131" "1"
    
    [0 lua] "pexpire" "use_point_lock" "10000"
    
    [0 172.17.0.1:58524] "EXISTS" "use_point_lock"
    
    [0 172.17.0.1:59286] "EVAL" "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) 
    then return nil;end; local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); 
    if (counter > 0) then redis.call('pexpire', KEYS[1], ARGV[2]); return 0; else redis.call('del', KEYS[1]); 
    redis.call('publish', KEYS[2], ARGV[1]); return 1; 
    end; return nil;" "2" "use_point_lock" "redisson_lock__channel:{use_point_lock}" "0" "10000" "bf4cbda3-f0e5-4768-a645-978426fcde5a:131"
    
    [0 lua] "hexists" "use_point_lock" "bf4cbda3-f0e5-4768-a645-978426fcde5a:131"
    
    [0 lua] "hincrby" "use_point_lock" "bf4cbda3-f0e5-4768-a645-978426fcde5a:131" "-1"
    
    [0 lua] "del" "use_point_lock"
    
    [0 lua] "publish" "redisson_lock__channel:{use_point_lock}" "0"
    • EVAL 명령어로 lock 획득에 관한 스크립트가 실행됩니다
    • exists 명령어로 'use_point_lock' key 존재 여부 확인합니다
    • 만약 key가 없다면 hincrby 명령어를 호출하여 '1'을 value로 입력합니다
    • pexpire 명령어로 해당 key의 만료 시간을 입력합니다
    • unlock() 메서드를 사용하게 되면, del 명령어로 key 삭제합니다
    • key가 없음을 publish 하여, 다음 lock을 획득하려는 key에게 알려줍니다

    4. 1) Jmeter를 통해서 분산 락 테스트

    테스트 환경은 이전과 동일합니다.

    • 이전과 다르게 순차적으로 포인트 1)조회 2) 차감이 이루어집니다
    • 잔액은 0원으로 성공합니다

    5) 결론

    요즈음의 대용량 요청이 발생하는 환경에서 멀티 스레드 혹은 다중 서버 구성은 매우 일반적입니다. 하지만 이러한 구성을 하게 됐을 때 고려해야 할 부분이 많은데요. 이 글에서 공유드린 동시성 문제가 대표일 것 같습니다.

    이 글에서는 Redis를 이용해서 처리했습니다.

     

    위 방법의 단점도 존재합니다. 모든 요청을 줄세워 차례대로 처리하고 있습니다. 만약 하나의 lock이 긴 시간을 소요하게 된다면, 다른 요청들은 매우 긴 지연을 느낄 수 밖에 없습니다.

     

    그래서 lock 획득 대기 시간을 적절히 선택할 필요가 있을것 같습니다. 

    • 아주 짧은 시간을 설정하여 빠르게 재시도 하게 유도
    • 긴 시간을 설정하여, 고객이 지연을 느끼더라도 한번의 요청에 처리를 완료

     

    단순히 어떤 방법이 더 좋다고 판단하기는 어렵습니다. 해당 제품을 운영하는 측면에서 적당한 방법을 세워도 좋을것 같습니다.

     

    그리고 Redis가 해결의 전부가 아닐 수 있다는 생각을 합니다.

     

    요청이 소수이지만 혹시 모를 동시성 처리를 위해서라면 DB에 Pessimistic lock을 직접 사용할 수도 있을것 같습니다. 혹은 더 나아가서 Kafka를 이용하여 consumer와 partition의 개수를 일치시키는 방법도 있을 것 같습니다.


    하지만 인프라 관리 포인트를 늘리기에 부담스럽고 이미 caching을 위해 Redis를 사용하고 있다면, 분산 락의 구현을 Redis를 사용할 수 있을 것 같습니다. 그렇다면 동일한 인프라 환경에서 추가적인 이점을 얻을 수 있기 때문입니다.

    이번 상황을 통해 Redis가 단순히 caching 역할만 하지 않고, 분산 락을 구현할 수도 있다는 점을 배웠습니다. 또한 문제 해결을 위한 방법이 꼭 하나는 아닙니다. 그래서 항상 더 나은 방법은 없을지, 더 넓게 생각해야 한다는 것을 배웠습니다.


    6) 참고 문헌

    Redis distributed lock 공식 문서
    Redisson 공식 문서

    반응형

    댓글

Designed by Tistory.