Redis 트랜잭션(Transaction) 이해하기

Redis는 단순한 Key-Value 저장소를 넘어 여러 명령을 하나의 작업 단위로 처리할 수 있는 트랜잭션 기능을 제공한다. 이를 활용하면 여러 데이터 변경 명령을 순서대로 실행하고, 다른 클라이언트의 명령이 중간에 끼어들지 않도록 제어할 수 있다.

하지만 Redis의 트랜잭션은 MySQL과 같은 관계형 데이터베이스의 트랜잭션과 동작 방식이 다르다. 특히 원자성(Atomicity), 롤백(Rollback), 동시성 제어에 대한 차이를 정확히 이해해야 한다.

Redis 트랜잭션이란?

Redis 트랜잭션은 여러 Redis 명령을 큐에 등록한 뒤, EXEC 명령을 통해 순서대로 실행하는 기능이다.

일반적인 Redis 명령은 개별적으로 실행된다. 여러 명령을 연속해서 호출하면 다른 클라이언트의 명령이 그 사이에 실행될 수 있다.

반면 Redis 트랜잭션은 MULTI와 EXEC를 사용해 명령을 하나의 실행 단위로 묶는다.

주요 명령은 다음과 같다.

  • MULTI: 트랜잭션 시작
  • EXEC: 큐에 등록된 명령 실행
  • DISCARD: 실행 전 트랜잭션 취소
  • WATCH: 특정 키의 변경 여부 감시
  • UNWATCH: 감시 중인 키 해제

MULTI와 EXEC의 동작 방식

Redis 트랜잭션은 MULTI를 호출하면 시작된다. 이후 입력하는 명령은 즉시 실행되지 않고 큐에 등록된다.

EXEC가 호출되면 큐에 등록된 명령들이 등록된 순서대로 실행된다.

예를 들어 계정의 포인트를 차감하고 거래 내역을 기록한다고 가정해 보자.

MULTI
DECRBY user:100:points 100
LPUSH user:100:history "포인트 100 차감"
EXEC

각 명령의 의미는 다음과 같다.

  • MULTI: 트랜잭션을 시작한다.
  • DECRBY: 포인트를 차감하는 명령을 큐에 등록한다.
  • LPUSH: 거래 내역을 추가하는 명령을 큐에 등록한다.
  • EXEC: 등록된 명령을 순서대로 실행한다.

EXEC가 실행되는 동안에는 다른 클라이언트의 명령이 해당 트랜잭션의 명령 사이에 끼어들지 않는다.

다만 이 예제는 트랜잭션의 실행 순서만 보장할 뿐, 포인트가 충분한지 검증하거나 차감 실패 시 거래 내역을 자동으로 취소하지는 않는다.

Redis 트랜잭션의 원자성

데이터베이스에서 원자성은 작업 전체가 성공하거나 전체가 실패하는 성질을 의미한다.

Redis 트랜잭션도 여러 명령의 실행 도중 다른 클라이언트의 명령이 끼어들지 않도록 처리한다. 그러나 이를 모든 명령이 성공하거나 모두 취소되는 완전한 원자성으로 이해하면 안 된다.

Redis 트랜잭션에서는 크게 두 가지 오류를 구분해야 한다.

실행 전 명령 오류

명령의 문법이 잘못되었거나 인자 개수가 맞지 않는 등 큐에 등록하는 단계에서 오류가 발생할 수 있다.

이 경우 Redis는 해당 명령을 큐에 넣지 못한다. 일반적으로 이러한 오류가 발생한 트랜잭션에서 EXEC를 실행하면 트랜잭션 전체가 거부된다.

MULTI
SET user:100:name "Kim"
SET user:100:age
EXEC

두 번째 SET 명령은 필수 인자가 부족하다. Redis는 이 오류를 감지하고 트랜잭션 실행을 거부할 수 있으며, 첫 번째 SET도 실행되지 않는다.

실행 중 명령 오류

명령 자체는 유효하지만 실제 실행 시 데이터 타입 등의 문제로 실패할 수도 있다.

SET user:100:points "hello"

MULTI
INCR user:100:points
SET user:100:status "active"
EXEC

INCR은 숫자가 아닌 문자열에 적용할 수 없으므로 오류가 발생한다. 그러나 뒤에 있는 SET 명령은 계속 실행된다.

즉, EXEC는 명령 실행 도중 오류가 발생했다고 해서 이미 실행된 명령을 자동으로 되돌리거나 나머지 명령을 모두 취소하지 않는다.

Redis 트랜잭션에는 일반적인 관계형 데이터베이스 트랜잭션과 같은 자동 롤백 기능이 없다.

DISCARD를 이용한 트랜잭션 취소

DISCARD는 EXEC가 실행되기 전에 큐에 등록된 명령을 취소할 때 사용한다.

MULTI
SET user:100:name "Kim"
SET user:100:status "active"
DISCARD

이 경우 등록된 명령은 실행되지 않는다.

주의할 점은 DISCARD가 이미 실행된 명령을 되돌리는 롤백 명령은 아니라는 것이다. 트랜잭션이 EXEC로 실행된 이후에는 DISCARD로 결과를 취소할 수 없다.

WATCH를 이용한 낙관적 동시성 제어

Redis의 WATCH는 특정 키가 변경되었는지 감시하는 기능이다.

트랜잭션이 실행되기 전에 다른 클라이언트가 감시 대상 키를 변경했다면 EXEC는 트랜잭션을 실행하지 않고 중단된다.

이를 통해 여러 클라이언트가 동일한 데이터를 동시에 수정할 때 발생할 수 있는 경쟁 조건을 방지할 수 있다.

예를 들어 포인트가 충분할 때만 차감하는 로직을 구현한다고 가정하자.

WATCH user:100:points

GET user:100:points

애플리케이션은 조회 결과를 확인한 뒤 포인트가 충분하면 다음 명령을 실행한다.

MULTI
DECRBY user:100:points 100
EXEC

WATCH를 설정한 시점부터 EXEC가 호출될 때까지 감시 대상 키가 변경되면 트랜잭션이 중단된다.

애플리케이션은 이 경우 최신 포인트를 다시 조회하고 조건을 재검증해야 한다.

다만 위 예제에는 중요한 한계가 있다. 포인트를 조회하고 충분한지 판단하는 작업이 애플리케이션에서 이루어지므로, 실제 구현에서는 조회와 조건 검증, 재시도 정책까지 함께 설계해야 한다.

WATCH를 활용한 실제 구현 예시

Redis 클라이언트 라이브러리를 사용하는 경우 다음과 같은 구조로 구현할 수 있다.

async function spendPoints(redis, userId, amount) {
  const key = `user:${userId}:points`;

  for (let attempt = 0; attempt < 5; attempt++) {
    await redis.watch(key);

    try {
      const points = Number(await redis.get(key));

      if (!Number.isFinite(points) || points < amount) {
        await redis.unwatch();
        return false;
      }

      const result = await redis
        .multi()
        .decrby(key, amount)
        .exec();

      // WATCH 대상 키가 변경되면 실행이 중단될 수 있다.
      if (result !== null) {
        return true;
      }
    } catch (error) {
      throw error;
    }
  }

  throw new Error("포인트 차감 재시도 횟수 초과");
}

위 코드는 개념을 설명하기 위한 예시다. 실제 라이브러리에 따라 EXEC 중단 결과와 오류 처리 방식이 다를 수 있다. 또한 운영 코드에서는 WATCH 상태가 남지 않도록 연결 관리와 예외 처리도 신중하게 설계해야 한다.

Redis 트랜잭션과 Lua 스크립트의 차이

Redis에서 여러 명령을 조건에 따라 안전하게 실행해야 한다면 트랜잭션 외에도 Lua 스크립트를 사용할 수 있다.

트랜잭션과 Lua 스크립트의 핵심적인 차이는 조건 검증과 데이터 변경을 어디에서 수행하는가에 있다.

Redis 트랜잭션은 여러 명령을 큐에 등록하고 순서대로 실행한다. 조건 검증이 애플리케이션에서 이루어진다면 WATCH와 재시도 로직이 필요할 수 있다.

반면 Lua 스크립트는 조건 검증과 데이터 변경 로직을 Redis 서버 내부에서 실행한다. 스크립트가 실행되는 동안 다른 클라이언트의 명령이 중간에 끼어들지 않기 때문에 검사와 변경 사이의 경쟁 조건을 방지하기에 유리하다.

예를 들어 포인트가 충분한 경우에만 차감하려면 다음과 같이 구현할 수 있다.

local points = tonumber(redis.call('GET', KEYS[1]) or '0')
local amount = tonumber(ARGV[1])

if not amount or amount <= 0 then
    return redis.error_reply('Invalid amount')
end

if points < amount then
    return 0
end

redis.call('DECRBY', KEYS[1], amount)
return 1

애플리케이션은 이 스크립트에 포인트 키와 차감할 양을 전달한다.

  • 반환값 1: 차감 성공
  • 반환값 0: 포인트 부족

스크립트는 포인트를 조회하고 조건을 검사한 뒤 차감하는 과정을 하나의 실행 단위로 처리한다.

다만 Lua 스크립트 역시 실행 도중 오류가 발생했을 때 이미 수행된 쓰기 작업을 자동으로 롤백하지 않는다. 따라서 스크립트 내부에서도 오류가 발생할 수 있는 작업의 순서와 검증을 신중하게 설계해야 한다.

Redis 트랜잭션의 한계

Redis 트랜잭션을 설계할 때는 다음과 같은 한계를 고려해야 한다.

  • 자동 롤백 부재: 실행 중 명령이 실패해도 앞서 성공한 명령은 되돌려지지 않는다.
  • 조건부 실행의 한계: 조건에 따라 명령을 실행하려면 WATCH 또는 Lua 스크립트 등을 고려해야 한다.
  • 외부 시스템과의 원자성 부재: Redis와 MySQL을 함께 변경하더라도 두 시스템의 변경을 하나의 트랜잭션으로 묶을 수 없다.
  • 재시도에 따른 중복 처리: 클라이언트가 실행 결과를 받지 못해 재시도하면 작업이 중복될 수 있다.
  • 명령 실행 순서의 보장과 영속성은 별개: 트랜잭션이 정상 실행되었다고 해서 장애 상황에서도 데이터가 반드시 영구 보존된다는 뜻은 아니다. 영속성은 AOF와 RDB 설정, 복제 및 장애 조치 정책 등을 별도로 고려해야 한다.

특히 포인트, 재화, 결제, 게임 결과 정산처럼 데이터 정합성이 중요한 영역에서는 단순히 MULTI와 EXEC를 사용했다는 사실만으로 안전성을 보장할 수 없다.

실시간 게임 서버에서의 활용

실시간 멀티플레이 게임 서버에서는 Redis를 이용해 매칭 대기열, 임시 게임 상태, 온라인 상태, 랭킹, 포인트 등의 데이터를 관리할 수 있다.

이러한 데이터는 여러 서버 인스턴스에서 동시에 접근할 수 있으므로, 작업의 특성에 따라 적절한 동시성 제어 방법을 선택해야 한다.

작업고려할 방식
여러 독립적인 키 변경MULTI / EXEC
조회한 값에 대한 조건부 변경WATCH와 재시도
조건 검증과 변경을 한 번에 수행Lua 스크립트
Redis와 MySQL의 데이터 동기화Outbox, Saga 등 별도 설계
중복 요청에 대한 안전성멱등성 키와 중복 처리 방지

예를 들어 포커 게임 서버에서 플레이어의 포인트를 차감하는 경우, Redis 내부의 포인트 변경뿐 아니라 실제 게임 결과와 MySQL에 저장된 정산 내역 사이의 정합성도 고려해야 한다.

Redis에서 포인트를 차감한 직후 서버가 종료되거나 데이터베이스 저장에 실패한다면 두 시스템의 상태가 달라질 수 있다. 이런 문제는 Redis 트랜잭션만으로 해결할 수 없다.

따라서 중요한 재화와 정산 데이터는 MySQL을 기준 데이터 저장소로 사용할지, Redis를 기준으로 사용할지 먼저 결정하고, 두 시스템 사이의 데이터 일관성을 유지하기 위한 복구 및 재처리 전략을 설계해야 한다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다