Redis의 Transaction, Pipelining, Lua Script

 

Redis는 단일 명령어 단위로는 싱글 스레드 특성 덕분에 원자성이 보장됩니다. 하지만 여러 명령을 조합해서 하나의 작업을 처리해야 하는 순간, 그 사이에 다른 클라이언트가 끼어들 수 있다는 문제가 생깁니다. 또한 명령을 하나씩 보내고 응답을 기다리는 구조는 네트워크 비용이 누적되면서 성능 병목이 되기도 합니다.

 

이 글에서는 이런 문제를 해결하기 위한 Transaction, Pipelining, Lua Script 세 가지 방식을 중심으로, 각각의 동작 원리와 차이점, 사용 시 주의할 점을 정리해 보겠습니다.


Transaction

특징

Redis의 트랜잭션은 여러 개의 Redis 명령어를 하나의 단위로 묶어서 실행하는 기능입니다. `MULTI` 명령으로 트랜잭션을 시작하고 `EXEC`으로 실행합니다.

 

MySQL의 트랜잭션과 비슷해 보이지만 실제로는 꽤 다릅니다. Redis는 단일 스레드로 동작하기 때문에 `EXEC`로 호출하는 순간 큐에 있는 모든 명령이 순차적으로 실행되는데요. 그 사이에 다른 클라이언트의 명령이 끼어들지 못합니다.

 

 

동작 방식

트랜잭션의 동작 흐름은 다음과 같습니다.

  1. `MULTI` 명령으로 트랜잭션을 시작합니다.
  2. 이후 입력하는 모든 명령은 즉시 실행되지 않고 Queue에만 저장됩니다. 어떤 명령을 입력해도 바로 에러가 나거나 결과가 나오는 게 아니라, 큐에 추가만 됩니다.
  3. `EXEC`을 호출하면 Queue에 쌓인 모든 명령을 순차적으로 실행합니다.
  4. 각 명령의 결과를 모아서 클라이언트에게 한 번에 전송합니다.

정리하면, `MULTI`부터 `EXEC` 사이의 명령은 실행이 아닌 예약이고, `EXEC` 시점에 비로소 일괄 실행되는 구조입니다.

 

 

WATCH와 DISCARD

WATCH

  • 낙관적 잠금(Optimistic Locking) 방식입니다. `WATCH`는 특정 키의 변경을 감시하는데요. 트랜잭션 실행 전에 누군가 해당 키를 변경하면 `EXEC` 시점에 트랜잭션 전체가 실패합니다. 다른 클라이언트가 값을 바꿨는지 먼저 확인하고, 변경이 없을 때만 트랜잭션을 실행하는 방식입니다.

DISCARD

  • 트랜잭션을 명시적으로 취소합니다. Queue에 쌓인 모든 명령을 버리고 트랜잭션을 종료합니다.

 

한계

Redis 트랜잭션의 가장 큰 한계는 롤백이 없다는 점입니다. RDBMS에서는 트랜잭션 중 오류가 발생하면 전체를 롤백하지만, Redis는 그렇지 않습니다.

 

문법 오류(존재하지 않는 명령어 등)가 있으면 `EXEC` 자체가 실패하고 모든 명령을 실행하지 않습니다. 하지만 런타임 오류(예를 들어 String 타입 키에 리스트 명령을 실행하는 경우)는 해당 명령만 실패하고 나머지 명령은 정상 실행됩니다. 일부 명령이 실패해도 이미 실행된 명령은 되돌릴 수 없습니다.

 

격리성(Isolation) 측면에서도 RDBMS와 다른데요. Redis는 싱글 스레드 특성상 `EXEC` 실행 중에는 자연스럽게 다른 클라이언트가 끼어들지 못하므로 격리가 보장됩니다. 하지만 이건 RDBMS처럼 격리 수준을 선택하는 개념이 아니라, 단일 스레드이기 때문에 부수적으로 따라오는 특성입니다.

 

이런 특성 때문에 Redis 트랜잭션은 RDBMS의 트랜잭션처럼 완벽한 ACID를 보장하지 않습니다. Redis가 제공하는 것은 명령의 일괄 실행과 실행 중 다른 클라이언트의 끼어들기 방지 정도입니다.


Pipelining

특징

Pipelining은 여러 명령을 한 번에 서버로 전송하고, 응답도 한 번에 받는 방식입니다. 트랜잭션이 원자성을 위한 것이라면, Pipelining은 성능 최적화를 위한 것입니다.

 

RTT (Round Trip Time)

Pipelining을 이해하려면 먼저 RTT 개념을 알아야 합니다. RTT는 Round Trip Time의 약자로, 명령이 서버까지 갔다가 돌아오는 데 걸리는 시간입니다. 네트워크 통신에서 가장 큰 비용은 이 왕복 시간인데요.

 

RTT에는 여러 구성요소가 있습니다. 네트워크 물리적 거리에 따른 전파지연, 라우터와 스위치를 거치는 과정의 전송지연, 그리고 장비에서의 처리지연이 포함됩니다. 같은 데이터 센터 안이라면 RTT가 작지만, 별도 클라우드 서비스를 통해 Redis를 사용한다면 RTT가 더 커집니다.

 

일반 실행 vs Pipelining

일반 실행

  • 명령을 하나 보내고, 응답을 기다리고, 다음 명령을 보내는 방식입니다. 100개의 명령을 실행한다면 100번의 RTT가 필요합니다. 명령 실행 시간이 0.1ms라도 RTT가 10ms면 전체 시간의 99% 이상이 네트워크 대기에 소비됩니다.

Pipelining

  • 명령을 한꺼번에 보내고, 응답도 한꺼번에 받습니다. 100개의 명령을 보내도 RTT는 1번만 발생합니다. 네트워크 왕복 횟수를 줄여서 전체적인 처리 시간을 단축하는 것이 핵심입니다.
  • Pipelining은 클라이언트 측에서의 동작입니다. 명령을 모아서 TCP 송신 버퍼에 넣고, 한 번에 네트워크로 전송합니다. 서버 입장에서는 여러 명령이 연속으로 도착하면 순서대로 실행하고 결과를 출력 버퍼에 쌓아서 한꺼번에 클라이언트에게 전송합니다.

 

Transaction과의 차이

트랜잭션과 Pipelining은 둘 다 여러 명령을 묶어서 처리하지만 목적과 실행 시점이 다릅니다.

 

트랜잭션

  • 원자성 보장이 목적입니다. `EXEC` 호출 전까지 명령이 실행되지 않고 큐에 쌓여 있다가, `EXEC` 시점에 일괄 실행됩니다. 실행 중 다른 클라이언트의 명령이 끼어들지 못하고, 중간 상태가 외부에 보이지 않습니다.

Pipelining

  • 성능 최적화가 목적입니다. 트랜잭션과 달리 명령이 서버에 도착하는 즉시 실행됩니다. 단지 네트워크 왕복을 줄이기 위해 여러 명령을 한 번에 보내는 것일 뿐, 그 사이에 다른 클라이언트의 명령이 끼어들 수 있습니다. 원자성을 보장하지 않습니다.

 

정리하면 가장 큰 차이는 실행 시점입니다. 트랜잭션은 `EXEC` 이후에 실행, Pipelining은 도착 즉시 실행입니다.


Lua Script

특징

Lua Script는 Redis 서버 내에서 Lua라는 프로그래밍 언어로 작성된 스크립트를 실행하는 기능입니다. 스크립트가 실행되는 동안에는 다른 클라이언트가 끼어들 수 없기 때문에 원자적으로 실행됩니다. 중간 상태가 노출되지 않고, 이벤트 루프가 멈춰 있기 때문에 싱글 스레드의 원자성이 그대로 보장됩니다.

 

Lua는 기본적으로 매우 가볍고 빠른 언어입니다. 내장하기도 쉽고 문법도 간단해서 배우기 쉬운데요. 샌드박스 환경도 제공되어 스크립트가 Redis 서버의 안전성을 건드리지 않습니다. 게임 개발에서도 많이 사용되는 언어이기도 합니다.

 

왜 Transaction/Pipelining 대신 Lua Script인가

앞에서 다룬 Transaction과 Pipelining에는 각각 아쉬운 부분이 있습니다.

 

Transaction

  • 원자성을 보장하지만 복잡한 로직(조건문, 반복문, 계산)을 서버 측에서 처리할 수 없습니다. 명령을 큐에 쌓기만 할 뿐, 중간에 값을 확인하고 분기하는 것이 불가능합니다. 예를 들어 애플리케이션에서 `get`으로 값을 읽고 비교한 뒤 `set`을 하려면, 그 사이에 다른 클라이언트가 끼어들 수 있습니다.

Pipelining

  • 네트워크 RTT를 줄이지만 원자성을 보장하지 않습니다.

Lua Script는 이 두 가지를 모두 해결합니다. 복잡한 로직을 서버 측에서 원자적으로 실행할 수 있고, 한 번의 호출로 여러 명령을 실행하기 때문에 네트워크 RTT도 줄어듭니다.

 

실무에서의 예를 들면, 재고 차감 같은 작업이 있습니다. 현재 재고를 확인하고, 충분하면 차감하고, 부족하면 실패 처리하는 이 과정을 `get`과 `set`으로 나눠서 하면 원자적으로 처리할 수 없는데요. Lua Script로 작성하면 전체 과정이 하나의 원자적 작업으로 실행됩니다.

 

KEYS와 ARGV

Lua Script에 인자를 넘길 때는 KEYS와 ARGV를 구분해서 사용합니다. 스크립트 호출 시 첫 번째 인자는 스크립트 문자열이고, 두 번째는 키 개수, 그 다음이 실제 키와 값입니다.

 

스크립트 내에서 `KEYS`로 접근하는 것은 Redis의 실제 키이고, `ARGV`로 접근하는 것은 그 외의 인자입니다. 키와 값을 구분하는 이유는 Redis 클러스터에서 스크립트가 어느 노드에서 실행될지 결정하기 위해서인데요. 스크립트에서 접근하는 키들이 모두 같은 Hash Slot에 속해야 실행이 가능합니다. 서로 다른 슬롯에 있는 키에 접근하려 하면 에러가 발생합니다.

 

반환값 변환

Redis의 Lua Script도 값을 반환할 수 있습니다. `return`문을 통해 반환하는데요. 이때 Lua의 타입이 Redis Protocol로 자동 변환됩니다.

  • Lua Number → Redis Integer
  • Lua String → Bulk String
  • Lua true → 1
  • Lua false → nil
  • Lua 테이블(array) → Redis 배열

 

주의할 점은 Lua의 Number는 Redis Integer로 변환되기 때문에 소수점이 사라진다는 점입니다. 실수 값을 그대로 반환하고 싶다면 문자열로 변환해서 반환해야 합니다.

 

스크립트 캐싱

Lua Script를 매번 전체 스크립트 문자열로 전송하면 네트워크 대역폭을 낭비하게 됩니다. 이를 해결하기 위해 Redis는 스크립트 캐싱 기능을 제공합니다.

 

스크립트를 처음 한 번 전송하면 Redis 서버에 캐시되고, SHA1 해시값이 반환됩니다. 이후에는 스크립트 전체를 보내는 대신 이 SHA1 해시만 보내서 실행할 수 있는데요. SHA1 해시는 40글자 정도로 구성된 문자열이기 때문에 큰 스크립트를 대체하기에 매우 효율적입니다.

 

다만 스크립트 캐시는 서버 재시작하면 사라집니다. 또한 스크립트 에러가 발생해도 이미 실행된 명령은 롤백되지 않습니다. 일반적인 패턴은 SHA1 해시로 먼저 실행을 시도하고, 캐시에 없으면 전체 스크립트를 다시 전송하는 방식입니다.

 

명령어 최소화

Lua Script를 사용할 때 실무적으로 중요한 것은 스크립트 내에서 Redis 명령 호출을 최소화하는 것입니다.

 

예를 들어 키가 존재하는지 확인한 뒤 값을 가져오는 경우, `EXISTS`로 확인하고 `GET`으로 값을 읽는 대신 `GET`만 호출해서 nil인지 확인하면 명령 횟수를 줄일 수 있습니다. 또한 여러 개의 키를 읽을 때는 `GET`을 여러 번 호출하는 것보다 `MGET`을 사용하는 것이 더 효과적입니다.

 

테이블 효율도 고려해야 합니다. Lua에서 큰 테이블은 기본적으로 메모리를 많이 사용하는데요. GC의 부담이 증가하기 때문에 필요한 만큼의 테이블을 사용하고, 임시로 사용하는 테이블은 로컬 변수로 반드시 선언해서 GC가 정리할 수 있도록 해야 합니다.


마무리

이번 글에서는 Redis에서 여러 명령을 효율적으로 실행하기 위한 Transaction, Pipelining, Lua Script 세 가지 방식을 정리해 보았습니다. 단순히 명령을 묶어서 실행하는 것처럼 보이지만, 각각이 해결하는 문제가 다르다는 점이 흥미로웠습니다. Transaction은 원자성, Pipelining은 네트워크 비용, Lua Script는 그 둘을 동시에 해결하면서 복잡한 로직까지 서버 측에서 처리할 수 있다는 차이가 정리하면서 명확해졌습니다.

 

다음 글에서는 Redis의 메시징 패턴인 Pub/Sub과 Stream에 대해 정리해 보도록 하겠습니다. 긴 글 읽어주셔서 감사합니다!