본문 바로가기

카테고리 없음

Redis는 싱글스레드지만 동시성 문제가 발생한다

1. Redis는 싱글 스레드로 동작하지만 동시성 문제가 발생할 수 있다.

Redis는 싱글스레드로 동작하여 명령어 한개씩은 원자적으로 진행되지만,

아래 상황에서는 각 명령어를 원자적으로 처리해도, 동시성 문제가 발생할 수 있다.

Client A: GET stock   (결과: 1)
Client B: GET stock   (결과: 1)
Client A: stock - 1 → SET stock 0
Client B: stock - 1 → SET stock 0

결론적으로 Redis는 싱글 스레드로 동작하지만, 여러 작업을 번갈아 가며 진행하기 때문에 동시성 문제가 발생할 수 있다.

즉, 동시성은 있지만 병렬성은 없는 것이다.

    - 동시성 : 하나의 CPU 또는 스레드가 여러 작업을 번갈아 가며 진행해, 겉보기엔 동시에 처리되는 것처럼 보이는 상태

    - 병렬성 : 여러 CPU/코어가 실제로 동시에 서로 다른 작업을 동시에 실행하는 상태

번외로, Redis가 싱글스레드지만 성능이 좋은 이유는 I/O multiplexing model 기술 덕분이다.

더보기

하나의 스레드에서 다수의 클라이언트에 연결된 소켓(파일 디스크립터)을 관리하면서 소켓에 이벤트(read/write)가 발생할 때만 해당 이벤트를 처리하도록 구현했다.

쉽게 말해, I/O에 대해서는 multi-thread로 관리해 빠른 I/O를 진행하고 실제 명령어 실행은 single-thread를 사용해 원자적으로 처리한다. 따라서 멀티프로세싱, 멀티쓰레딩 방식보다 더 적은 리소스를 사용한다.

2. 동시성을 해결하는 방법

2.1 단일 원자 명령 사용

단일 원자 명령(DECR)은 한 개의 명령어로도 GET → (클라이언트에서 계산) → SET 과 같이 여러 단계가 아니라,

클라이언트와 한개의 연결로 서버 내부에서 읽고, 계산하고, 쓰기까지 한 번에 처리된다.

Redis가 단일 스레드 이벤트 루프에서 명령을 순차적으로 처리하기 때문에 다른 클라이언트가 중간에 끼어들 수 없다.

ex. INCRBY/DECRBY, SETNX, GETDEL, HINCRBY, ZADD NX

2.2 Transaction에서 ( 낙관적 lock 사용 - WATCH )

Transaction이란, 원자성, 일관성, 고립성, 영속성을 가진다.

    - 고립성 : 한 개의 트랙잭션을 실행 할 때에는 다른 트랜잭션이 끼어들 수 없다. => 싱글 스레드에서 트랜잭션을 이용하면 한 개의 트랜잭션 속 여러 명령어들이 실행 중일 때, 다른 트랜잭션의 명령어가 끼어들 수 없다.

Redis Transaction 명령어들
> MULTI : 트랜잭션 시작
    - 트랜잭션 속에 있는 여러 명령어들을 queue에 넣음.

> WATCH : Redis에서 낙관적 Lock을 담당하는 커맨드
 
> EXEC : 트랜잭션 실행  
    - queue에 쌓여있는 명령어를 실행 (RDBMS의 commit과 비슷)

> DISCARD : 트랜잭션 실패 시, queue에 쌓여있는 명령어들을 폐기 (RDBMS의 rollback과 비슷)

 

RedisWATCH에 대해 알아보자.

2.2.1 철학 : Transaction 사이에 WATCH(optimistic locking)를 사용
Pessimitic Locking(공유하고 있는 자원에 대하여 실제로 락을 걸어 다른 접근을 허용X) 개념과 대조되는 Optimistic Locking 은 실제로 락을 걸지 않고 timestamp 나 checksum 같은 버전 정보를 이용한다. 데이터에 접근하고 있는 도중 다른 트랜잭션으로 인해 해당 데이터의 버전이 바뀌어 처음과 일치하지 않으면 문제 상황을 알려준다.

 

2.2.2 구현 방식 : CAS

CAS(Campare-And-Swap) 는 낙관적 락의 대표 구현 기법으로, 비교 후 변경을 뜻한다.

2.2.2.1 CAS 동작방식

  1. 인자로 기존 값(Compared Value)과 변경할 값(Exchanged Value)을 전달한다.
  2. 기존 값(Compared Value)이 현재 메모리가 가지고 있는 값(Destination)과 같다면 변경할 값(Exchanged Value)을 반영하며 true를 반환한다.
  3. 반대로 기존 값(Compared Value)이 현재 메모리가 가지고 있는 값(Destination)과 다르다면 값을 반영하지 않고 false를 반환한다.

2.2.2.2 CAS 사용처

안전한 임계 영역이 필요하지만 연산이 길지 않고 아주 짧게 끝날때 사용해야한다.

ex. 숫자 값의 증가, 자료 구조의 데이터 추가
이렇게 CPU 싸이클이 금방끝나는 연산에 사용하면 효과적이다. 반면에 데이터베이스를 기다린다거나, 다른 서버의 요청을 기다리는 것 처럼 수 ms 이상의 시간이 걸리는 작업이라면 CAS보다 동기화 락을 사용하거나 스레드가 대기하는 방식이 더 효과적이다.

 

 

2.2.2.3 CAS 사용 방식

  @PostMapping("/transaction")
  public ResponseEntity<?> transaction(String key) {
      redisTemplate.execute(new SessionCallback() {
          @Override
          public Object execute(RedisOperations operations) throws DataAccessException {
              try {
                  operations.watch(key); // CAS
                  operations.opsForValue().set(key, "abc"); // 이 값은 바뀜
                  
                  operations.multi(); // transaction start
                  operations.opsForValue().set(key, "def");  // 이 값은 바뀌지 않음.
              } catch (Exception e) {
                  e.printStackTrace();
                  operations.discard();  // rollback (큐 버리기)
              }
              return operations.exec(); // transaction end (큐 실행)
          }
      });
      return null;
  }

위 코드에서 key에 대한 value가 “def”로 변경되지 않은 이유

Redis의 WATCH는 CAS를 기반으로 동작하는데, WATCH가 감시하는 key의 값이 EXEC 전에 변경됐기 때문에 트랜잭션 자체가 취소됨.

⇒ WATCH는 감시 시작 시점부터 EXEC 직전까지 해당 key가 이미 반영된 변경이 있으면, MULTI~EXEC 구간에 해당 key에 대해 변경이 일어나려고 할 때 트랜잭션의 모든 명령을 취소한다.

↓ 실행흐름 자세히 보기

더보기
  1. 기존 key에 대한 값이 “aaa”인 경우
  2. operations.watch(key) 실행
    Redis에 "key"라는 키를 감시하겠다고 등록
  3. operations.opsForValue().set(key, "abc") 실행 ← (이미 반영된 변경)
    기존 값은 “aaa”, 바꿀 값은 "abc"를 보내 값이 “abc”로 변경됨
    *자기 자신(현재 커넥션)이 바꾼 것도 감시 대상에 포함
  4. operations.multi() 실행
    이제부터 트랜잭션 모드 진입.
    이후의 명령(set(key, "def"))은 즉시 실행되지 않고 큐에 쌓임
  5. operations.exec() 실행 시점
    EXEC가 실행될 때, Redis는 WATCH로 감시하던 key들이 WATCH 등록 이후 반영된 변경이 있는지 확인
    → WATCH 시점: 'aaa'
    → 지금 값: 'abc'
    → 값이 바뀌었네! 트랜잭션 전체 취소!"
    결과적으로 값은 "abc" 그대로

2.2.3 WATCH 예시 모음 ↓ 

더보기
초기: key="aaa"

1) WATCH key
   - 감시 시작 (기준 상태: "aaa")

2) SET key "abc"     <-- 트랜잭션 바깥에서 실제로 값이 바뀜
   - 이미 변경 발생! (WATCH~EXEC 사이의 '적용된' 변경)

3) MULTI
   - 이후 명령은 큐잉

4) SET key "def"     <-- 아직 실행되지 않고 큐에만 들어감

5) EXEC
   - 체크: WATCH 이후 '이미 반영된' 변경이 있었는가? YES ("aaa"→"abc")
   - 결과: EXEC = null (트랜잭션 전체 취소), key는 "abc" 그대로

초기: key="aaa"

1) WATCH key
   - 감시 시작 (기준 상태: "aaa")

2) MULTI
   - 이후 명령은 큐잉

3) SET key "abc"     <-- 아직 실행 '안 됨', 큐에만 쌓임

4) EXEC
   - 체크: WATCH 이후 '이미 반영된' 변경이 있었는가? NO
   - 큐 실행 시작 → SET key "abc" 실행
   - 결과: EXEC = [OK...] (성공), key는 "abc"로 변경됨

초기: key="aaa"

1) WATCH key
	- 감시 시작 (기준 상태: "aaa")

2) MULTI
   - 이후 명령은 큐잉

3) SET key "abc" <-- 아직 실행 '안 됨', 큐에만 쌓임

4) SET key "cde" <-- 아직 실행 '안 됨', 큐에만 쌓임

5) SET key "def" <-- 아직 실행 '안 됨', 큐에만 쌓임

6) EXEC
   - 체크: WATCH 이후 '이미 반영된' 변경이 있었는가? NO
   - 큐 실행 시작 → "abc" → "cde" → "def" 순으로 실행
   - 결과: EXEC = [OK...] (성공), 최종 key는 "def"로 변경됨

2.3 Lua 스크립트(EVAL)로 원자적 멀티 연산

  • 여러 연산을 서버 측에서 한번에 실행하여 완전 원자적이다.
  • 스크립트는 짧고 빠르게; Redis Cluster에선 같은 해시 슬롯(키에 {...} 태그)으로 묶어야 한다.

2.4 분산 락

여러 서버·프로세스가 같은 리소스에 접근할 때 순서를 강제하기 위해,

보통 Redis 같은 중앙 집중화된 빠른 저장소에 락 상태(데이터 자체는 아님)를 두어 정합성을 지키는 도구

 

분산 락이 필요한 이유

  • 데이터가 한 곳의 서버에 모여 있지 않고 여러 서버(노드)에 저장되는 분산 DB를 사용 중인 경우,
  • DB별로 락을 걸면 각각의 락이 독립적임 → DB마다 정합성 깨질 수 있음.
  • Redis 하나에 락 상태를 두면 모든 DB가 같은 락 상태를 바라봄
환경 문제 분산 락이 필요한 이유
파티셔닝
(한 DB안에서 같은 스키마인 데이터를 table 나누기)
키 단위로 데이터가 분리 저장됨 여러 파티션에서 하나의 논리적 리소스를 갱신할 때, 갱신 순서 조율 필요 → 락으로 직렬화
샤딩
(
같은 스키마인 데이터를 Shard key 기준으로 DB 인스턴스 나누기) = 수평확장
데이터가 여러 샤드(서버)에 나뉘어 저장됨 어떤 연산이 여러 샤드를 동시에 업데이트해야 할 때, 중간에 한 샤드만 변경되면 정합성 깨짐 → 락으로 동시 작업 방지

크로스-샤드 : user_id로 샤드를 나누고 “장바구니 → 결제 → 주문 생성”은 한 사용자의 샤드 내부에서 처리되게 설계(샤드-어피니티),
불가피한 다샤드 업데이트는 SAGA 패턴으로 보상 트랜잭션 설계
레플리케이션
(DB 복제하기)
Master → Replica 복제 지연 두 프로세스가 Master에 쓰기 경쟁 시, 순서가 뒤바뀌면 Replica 데이터 불일치 가능 → 락으로 순서 보장

 

쇼핑몰 시스템을 예시로 알아보자

- 현재 DB 보유

    MySQL: 주문 정보

    MongoDB: 상품 재고 메타데이터

    Elasticsearch: 검색 인덱스

    → 재고 차감 로직은 세 DB를 모두 갱신해야 함.

Redis 분산락 Redisson 사용 방식

RLock lock = redissonClient.getLock("lock:product:123");  <- lock key
try {
    if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
        // MySQL 재고 차감
        updateStockInMySQL(123, -1);
        // MongoDB 재고 차감
        updateStockInMongo(123, -1);
        // Elasticsearch 인덱스 업데이트
        updateStockInElastic(123);
    } else {
        throw new RuntimeException("다른 프로세스가 재고 차감 중");
    }
} finally {
    lock.unlock();
}

하지만 Redis가 단일노드라면 단일 장애 지점(SPOF, Single Point Of Failure)이 될 수 있다.

⇒ 따라서 Matser-Slave 복제 모드로 레디스 서버를 구축 필요 있다. 

하지만 Master 서버에 장애가 생겨도 Slave가 Master로 승격되는 Failover를 통해 항상 해결되지는 않는다. 레디스의 복제는 비동기식이기 때문에 아래와 같은 race condition이 발생할 수 있다.

  • 클라이언트 A가 마스터에서 잠금을 획득한다.
  • 키에 대한 쓰기가 복제본으로 전송되기 전에 마스터가 다운된다.
  • 복제본이 마스터로 승격된다.
  • 클라이언트 B는 A가 이미 잠금을 보유하고 있는 동일한 리소스에 대한 잠금을 획득한다.

이런 경우에는 Redlock 알고리즘(다중 Redis 노드)을 고려해야 한다.

Redlock 알고리즘이란? 

다중 redis 노드에서 Quorum 이상의 노드에서 잠금을 획득하면 분산락을 획득한 것으로 판단한다.

 

Redlock 알고리즘 진행 방식

--  클라이언트는 A시간 안에 redis 노드들에게서 잠금을 획득하려고 시도한다. A보다 큰 B시간이 지나면 전체 노드에서 잠금이 해제된다.

--  클라이언트가 과반 수의 redis에서 분산락 잠금을 획득하면 분산락을 획득한 것으로 간주한다.

--  그렇지 않은 경우에는 B시간이 지나, 과반수에 미치지 못했지만 이미 획득한 redis 노드를 포함해 전체 노드에서 분산락이 잠금이 해제된다.

 

하지만 이것 또한 정확하지 않다.

- 클럭 드리프트 현상 (노드별로 클럭이 정확한 속도로 동작하지 않음 = 시간이 안 맞음)

  1. 클라이언트 1이 노드 A, B, C에서 잠금을 획득하지만, 네트워크 문제로 인해 D와 E에서는 잠금 획득에 실패한다.
  2. 이때 노드 C의 시계가 앞으로 이동하여 잠금이 만료된다.
  3. 클라이언트 2가 노드 C, D, E에서 잠금을 획득하지만, 네트워크 문제로 인해 A와 B에서는 잠금 획득에 실패한다.
  4. 이제 클라이언트 1과 2는 모두 자신이 잠금을 획득했다고 믿는다.

- 애플리케이션 중단 및 네트워크 지연

  1. 클라이언트 1이 분산락을 획득한다.
  2. 이때 클라이언트1에서 애플리케이션 중지가 발생하고, 그 사이에 분산락이 만료된다.
  3. 클라이언트 2는 분산락을 획득하고 파일을 갱신한다.
  4. 클라이언트 1의 애플리케이션이 재개되고 진행 중이었던 파일을 갱신한다.
  5. 동시성 문제가 발생한다.

 

3. 요약 선택 가이드

  • “성능·제어력 우선, 우리가 직접 제어”: Lettuce + 원자 명령 / WATCH-MULTI / Lua
  • “락·세마포어 등 고수준 도구 바로 쓰고 싶다”: Redisson(RLock 등)
  • “진짜 트랜잭션 수준의 강한 정합성(멀티리전/파티션 내성) 필수”: 애초에 Redis 락 말고 DB 트랜잭션/메시지 큐/워크플로 엔진 고려

 

참고 )

https://wildeveloperetrain.tistory.com/137

https://mangkyu.tistory.com/311