Redis 동시성 제어

박정호·2025년 5월 5일

고민

  • Lock을 사용할건가 말건가?
  • 사용한다면 어떤 Lock을 사용?

티켓팅 시스템을 구현하는 중 이선좌(이미 선점된 좌석) 확인을 위해 redis를 도입하기로 했다.

이때, 두가지 고민이 있었다.

  1. 고가용성을 어떻게 확보할 것인가?

    • 좌석을 선점했지만 그 정보가 시스템 장애로 인해 사라진다면, 좌석을 선점했다고 철썩같이 믿고 있던 사람은 당황스러울 것이다. 이에 대한 해결책으로 고가용성 클러스터 구축을 택했다.

    Redis 고가용성을 위한 구축

  1. 많은 트래픽이 몰렸을 때, redis 동시성 제어를 어떻게 할 것인가?

    많은 사람들이 동시성 제어에 대한 해결책으로 Lock을 사용했다. 하지만 이것이 정녕 맞는, 최선의 방법인가에 대하여 고민하게 되었다.

    동시성 제어를 위해 락을 걸면 결국 사용자들의 write에 대한 성능 저하가 발생할 수 밖에 없다. 많은 트랙픽이 몰리고 1분1초가 급박한 상황에는 치명적일 수 있다. redis의 성능을 최대한 살리면서 동시성 제어를 할 방안에 대하여 더 생각해보게 되었다.

조사

우선 Lock에 대한 이해도를 높이는 것이 우선이라 생각되어 학습을 진행했다. 레디스와 같은 DB는 락을 걸어도 레디스 전체에 걸리는게 아니라 특정 키 값에만 걸릴 것 같았고, 이에 대한 확신을 얻기 위해 조사했다.

조사한 결과 레디스에서는 SETNX 또는 Redisson으로 키에만 락을 걸 수 있다고 한다.

ex)

SET lock:seat:B-17 my-unique-id NX PX 3000

이건 단지 하나의 키, 즉 lock:seat:B-17에만 락이 걸리는 것이다.

→ Redis의 다른 키나 다른 좌석(lock:seat:A-1등)은 아무 영향 없음.

키에만 락이 걸리므로 성능 저하는 발생하지 않을 것으로 예상되어 락을 사용하기로 했다.

분산락, 스핀락, 낙관적락 등 다양한 락 방법이 존재했고 더 적합한 락 구현을 위해 비교했다.

분산락

분산 락(distributed lock)은 경쟁 상태를 해결하기 위해 공통된 저장소를 사용하여 정해진 작업의 원자성을 보장한다. 만일 서버를 한 대만 운영한다면 내부 스레드를 제어한다던지의 방식으로 동시성 이슈를 해결할 수 있겠지만, 여러 서버를 운영하는 경우 다른 서버에서 발생하는 작업을 제어하기 어려울 수 있다. 그래서 동일한 자원을 서로 다른 서버에서 접근하는 경우 한 쪽이 작업한 내용이 씹히는 경우도 종종 발생한다. 이런 일이 자주 생기진 않겠지만 단 한 번 발생으로도 위에서 언급한 물량 초과 예시처럼 비즈니스 로직상 치명적인 결과가 발생할 수도 있다.

이런 일을 방지하기 위해 레디스와 같이 모든 서버에서 공통으로 접근 가능한 저장소를 이용하여 한 클라이언트가 락을 획득한 동안에는 나머지 클라이언트들은 대기하도록 하면 한 번에 하나의 작업만 수행되도록 보장할 수 있다.

스핀락

락을 걸지 못하면 루프를 돌면서 계속 락을 얻으려고 시도하는 방식이다.

스핀락을 사용하면 반드시 하나의 클라이언트만 락을 획득하게 되고 작업 후에 다시 락을 반환하게 된다. 또한 락을 얻지 못한 클라이언트들은 락이 해제될 때까지 락 획득을 시도한다.

이로 인해, 트래픽이 몰리는 경우 레디스에 부하가 가는 단점이 발생한다. 단시간에 락을 얻기 위해 대기하는 클라이언트가 늘어나면 각 클라이언트가 락을 얻기 위해 지속적으로 레디스에 요청을 보내 서버에 많은 부하가 갈 수 있다.

이를 해결하기 위해 락 획득에 실패한 클라이언트에게 다음 시도까지 sleep tie을 주어 일정 시간 대기하도록 하거나, timeout을 설정하여 일정 시간이 지나면 에러를 발생시켜 락 획득을 중지하는 방식을 사용한다.

Redisson Client

Redisson 라이브러리를 사용하면 스핀락의 단점을 보완할 수 있다고 한다.

Redsson client는 스핀락처럼 지속적으로 레디스를 조회하며 락 획득 여부를 확인하지 않는다. Pub/Sub 구조를 활용하여 레디스에서 락을 해제하게 되면 대기중인 클라이언트에게 알림을 주어 다시 락을 획득하다록 신호를 준다. 신호를 받은 클라이언트는 대기 상태에서 벗어나 다시 락 획득을 시도한다. Redisson client는 이러한 작업을 타임아웃이 될 때까지 반복한다.

낙관적락

충돌로 인한 race condition이 발생하지 않는다고 낙관적이라고 가정하는 방식이다. DB가 제공하는 락 기능이 아니라 어플리케이션에서 제공하는 버전관리 기능을 사용한다.

충돌 가능성을 최소화하면서 데이터 변경 시점에만 충돌을 검증하는 방식이다.

Redis는 이를 WATCH 명령어를 이용해 구현한다고 한다.

  • WATCH 명령으로 특정 키 감시 시작
  • 키 감시 중에는 다른 클라이언트가 그 키를 변경하면 트랜잭션이 실패
  • MULTI → EXEC 블록에서 트랜잭션 실행
  • EXEC 시점에 키가 바뀌지 않았으면 성공, 바뀌었으면 실패
WATCH balance         # balance 키를 감시

GET balance           # 예: balance = 100

MULTI                 # 트랜잭션 시작
DECRBY balance 10     # balance = balance - 10
EXEC                  # 변경 시도
  • 만약 EXEC 전에 다른 클라이언트가 balance 값을 수정하면, EXEC는 실패합니다. (null 반환)
  • 이걸로 낙관적으로 충돌을 감지하고, 실패 시 다시 시도할 수 있습니다.

장점은 락처럼 블로킹을 하지 않아 성능이 좋다는 것이다.

하지만 충돌 가능성이 없다고 생각하고 구현하기 때문에 실제로 충돌이 일어나 트랜잭션이 실패 했을 때 재시도 로직이 필요하다.

트래픽이 높지 않고 충돌 가능성이 낮은 경우 효율적이지만 많은 충돌이 예상되는 로직에서는 데이터의 정합성을 보장할 수 없을 것이다.

Lock 방식 선택

여기까지 조사했을 땐 우리 서비스에서는 분산락을 사용하는 것이 적합하다는 생각을 했다. 고가용성을 위해 Muli-AZ로 분산 서버를 구축할 예정이기 때문이다.

위에서 조사한대로 레디스에서는 SETNX 또는 Redisson으로 키에만 락을 걸 수 있지만 SETNX 방식으로 락을 구현하면 "노드 간 분산 락 일관성 보장"이 안 된다는 문제가 있다. SETNX는 단일 레디스에서 단순 락을 걸 때 주로 사용한다고 한다.

Redis 락을 구현하기 위해 Redis의 라이브러리를 비교해야 하지만 여기선 생략하고 간단히 Lettuce와 Redisson의 차이만 집고 넘어가겠다.

Redisson과 Lettuce의 철학 차이

Lettuce는 Redis 명령어를 얇은 클라이언트 레벨에서 감싸서, 낮은 수준의 API를 제공한다. Redis 자체 기능을 직접 다루고 싶은 개발자에게 적합하다고 보면 된다.

예를 들어 단순히 SET, GET, HSET, DEL 같은 작업만 하고 싶다면 Lettuce는 빠르고 효율적이다.

반면 Redisson은 Redis를 단순한 Key-Value 저장소가 아니라, 분산 환경에서 동시성을 제어하는 인프라로 확장해서 쓰기 좋게 만들어준다.

분산 락, 세마포어, CountDownLatch, 분산 캐시 같은 고급 기능이 이미 내장돼 있어서, Redis를 활용해 복잡한 동시성 문제를 해결할 수 있어.

Redisson은 Redis를 Java 객체처럼 다루도록 설계돼 있다.

설계한 서비스 기준 분석

  • ✅ 티켓팅처럼 트래픽이 몰리는 순간적인 경쟁 상황
  • 동시성 제어가 매우 중요 (좌석이 동시에 여러 사용자에게 넘어가면 절대 안 됨)
  • Redis 클러스터를 사용하고 있음 (단일 인스턴스 아님)
  • ✅ 좌석 선점 시 락을 걸고, 타 사용자의 접근을 막아야 함

Redisson을 사용하면 좋다.

  • Lettuce를 사용하면 WATCH/MULTI/EXEC나 Lua 스크립트를 직접 짜야 함. → 복잡 + 실수 위험
    if redis.call("get", KEYS[1]) == ARGV[1] then
      return redis.call("del", KEYS[1])
    else
      return 0
    end
    → 직접 Lua 락 해제 스크립트를 짜야 하고, 키 충돌, TTL, 해시 슬롯까지 다 관리해야함.
  • RedissongetLock() → tryLock() → unlock() 으로 끝.
  • 거기에 RedLock 알고리즘까지 구현돼 있어서, 분산 환경에서도 신뢰성 있는 락을 제공해준다.
    RLock lock = redissonClient.getLock("seat:{lock}:101-B14");
    if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
      try {
        // 선점 처리
      } finally {
        lock.unlock();
      }
    }
    → 내부적으로 RedLock, 해시 슬롯, TTL 다 자동으로 처리됨.

위와 같은 이점으로 인해, Redisson에서 제공하는 Redlock을 사용하기로 했다.

Redlock 이란?

Redis의 창시자 Antirez(살바토레)가 제안한 분산 환경에서 안전한 락 알고리즘으로 3개 이상 노드에 동시에 락을 시도하고, 과반수 이상이 성공하면 락 획득 성공한다. 락은 TTL 기반으로 자동 해제되고 장애 발생 시에도 락의 일관성과 안전성을 유지할 수 있도록 설계되어있다. Redisson은 Redlock을 사용할 수 있는 라이브러리이다.

결론

Redisson 라이브러리의 Redlock을 활용하여 분산 환경에서 안전한 분산락을 구현할 것이다.

참고

Redis 분산 락을 활용한 동시성 처리 - miintto.log

락이란? 분산락, 스핀락의 개념

[Redis] Redis 라이브러리 3종 비교 (Spring redis vs Lettuce vs Redisson)

0개의 댓글