루아 스크립팅 (Lua Scripting)
루아 스크립팅 (Lua Scripting)
Redis 안에서 실제로 실행되는 커스텀 로직을 루아(Lua)로 작성할 수 있다는 걸 알고 계신가요? Redis는 데이터가 살아 있는 서버 쪽에서 직접 스크립트를 실행할 수 있는 프로그래밍 인터페이스를 제공해요. 이 글에서는 Redis 7 버전 이하에서 주로 쓰던 루아 스크립팅 — EVAL 명령 기반 접근 — 을 중심으로, 실제로 어떻게 스크립트를 만들어 실행하고 관리하는지 하나씩 살펴볼게요.
Redis 7부터는 루아 스크립팅을 보다 체계적으로 관리할 수 있는 Redis Functions가 새로 추가됐어요. 루아 스크립팅은 Redis 6.2 이하에서 서버를 프로그래밍하는 방식이기도 하고, Redis 7 이상에서도 그대로 사용할 수 있어요. Functions에 대한 자세한 내용은 별도 문서를 참고해 주세요.
배경: 왜 서버에서 스크립트를 실행할까
Redis는 정의 자체가 "추상 데이터 타입을 위한 도메인 특화 언어(Domain-Specific Language)"예요. Redis가 말하는 "언어"란 바로 Redis의 명령(command) 들이죠. 대부분의 명령은 핵심 데이터 타입을 조작하는 데 특화되어 있고, 많은 경우 애플리케이션 데이터를 관리하는 데 필요한 기능을 모두 제공해요.
Redis에서 프로그래머빌리티(programmability) 란, 서버가 임의의 사용자 정의 로직을 실행할 수 있는 능력을 말해요. 이런 로직 조각을 스크립트(script) 라고 부릅니다. 스크립트를 쓰면 데이터가 있는 곳에서 데이터를 처리할 수 있어요. 이를 데이터 지역성(data locality) 이라고 해요. 서버 안에서 정교한 작업 흐름을 책임감 있게 임베딩하면 네트워크 트래픽을 줄이고 전체 성능을 개선할 수 있어요. 개발자는 이 기능을 이용해 견고한 애플리케이션 특화 API를 구현할 수 있고, 이런 API는 비즈니스 로직을 캡슐화하고 여러 키와 다양한 데이터 구조에 걸친 데이터 모델을 관리할 수 있어요.
사용자 스크립트는 Redis에 임베디드된 샌드박스(sandbox) 처리된 스크립팅 엔진으로 실행돼요. 현재 Redis는 단 하나의 스크립팅 엔진, 즉 루아 5.1(Lua 5.1) 인터프리터를 지원해요.
복잡한 구현 전에, 먼저 루아 스크립팅이 왜 유용한지 핵심 속성부터 정리할게요.
- 지역성(Locality): 데이터가 사는 곳에서 로직을 실행해요. 데이터 지역성은 전체 지연 시간을 줄이고 네트워크 자원을 아껴요.
- 차단 의미론(Blocking semantics): 스크립트의 원자적(atomic) 실행을 보장해요.
- 조합(Composition): Redis에 없거나 너무 한정적이라 기본 포함되지 않은 단순한 능력을 조합할 수 있게 해줘요.
루아를 쓰면 애플리케이션 로직의 일부를 Redis 안에서 실행할 수 있어요. 여러 키에 걸친 조건부 업데이트를 수행하고, 원자적으로 여러 데이터 타입을 조합할 수도 있어요.
스크립트 실행 방식: EVAL과 Redis Functions
Redis는 스크립트를 실행하는 두 가지 수단을 제공해요.
첫 번째는 EVAL 명령이에요. EVAL은 Redis 2.6.0부터 지원됐고, 서버 측 스크립트를 실행하는 가장 빠르고 직관적인 방법이에요. 하지만 주의할 점이 있어요. EVAL 스크립트의 로직은 "Redis 서버의 확장"이 아니라 애플리케이션의 일부로 취급돼요. 스크립트는 서버에 그냥 캐시만 되고 휘발성이기 때문에, 스크립트를 실행하는 모든 애플리케이션 인스턴스는 언제든 다시 로드할 수 있도록 스크립트 원본 코드를 항상 보관하고 있어야 해요. 애플리케이션이 커질수록 이 방식은 개발과 유지보수가 점점 어려워질 수 있어요.
두 번째는 Redis 7.0에 추가된 Redis Functions예요. 함수는 본질적으로 일급 데이터베이스 요소(first-class database element) 인 스크립트예요. 함수는 스크립팅을 애플리케이션 로직으로부터 분리하고, 스크립트를 독립적으로 개발·테스트·배포할 수 있게 해줘요. 함수를 사용하려면 먼저 로드해야 하고, 로드되면 연결된 모든 클라이언트가 사용할 수 있어요. 이때 함수를 데이터베이스에 로드하는 것은 Redis 모듈을 로드하는 것과 같은 관리/배포 작업이 돼요.
스크립트든 함수든, 실행하는 동안 Redis는 원자적 실행을 보장해요. 스크립트가 실행되는 전체 시간 동안 서버의 모든 활동이 차단되며, 이는 트랜잭션의 의미론과 유사해요. 즉 스크립트의 모든 효과는 "아직 일어나지 않았거나" "이미 일어났거나" 둘 중 하나예요. 차단 의미론은 모든 연결된 클라이언트에게 항상 적용돼요.
여기서 잊지 말아야 할 단점이 있어요. 느린 스크립트를 실행하는 건 좋은 생각이 아니에요. 스크립팅의 오버헤드는 매우 낮아서 빠른 스크립트를 만드는 건 어렵지 않지만, 애플리케이션에서 느린 스크립트를 쓰려 한다면, 실행되는 동안 다른 모든 클라이언트가 차단되어 어떤 명령도 실행할 수 없다는 점을 반드시 알아두어야 해요.
루아 스크립팅 시작하기 (Getting started)
Redis로 스크립팅을 시작하려면 EVAL 명령을 사용해요. 첫 예시를 볼게요.
> EVAL "return 'Hello, scripting!'" 0
"Hello, scripting!"
이 예시에서 EVAL은 두 개의 인자를 받아요. 첫 번째 인자는 스크립트의 루아 소스 코드가 담긴 문자열이에요. 스크립트에 루아 함수 정의가 포함될 필요는 없어요. 그냥 Redis 엔진의 컨텍스트에서 실행될 루아 프로그램일 뿐이에요.
두 번째 인자는 스크립트 본문 다음에 오는 인자의 개수인데, 세 번째 인자부터가 Redis 키 이름을 나타내요. 이 예시에서는 스크립트에 키 이름이든 뭐든 어떤 인자도 전달하지 않았기 때문에 값 0을 사용했어요.
스크립트 매개변수화 (Parameterization)
애플리케이션이 필요에 따라 스크립트 소스 코드를 동적으로 생성할 수는 있어요. 하지만 이 방식은 강력히 권장되지 않아요. 예를 들어 애플리케이션이 아래처럼 서로 완전히 다르지만 실제로는 완전히 동일한 두 스크립트를 보낼 수 있어요.
redis> EVAL "return 'Hello'" 0
"Hello"
redis> EVAL "return 'Scripting!'" 0
"Scripting!"
이런 동작 방식이 Redis에 의해 차단되지는 않지만, 스크립트 캐시 문제 때문에 안티 패턴이에요. 애플리케이션이 같은 스크립트의 미묘한 변형들을 계속 생성하는 대신, 스크립트를 매개변수화하고 실행에 필요한 인자를 전달할 수 있어요.
아래 예시는 위와 동일한 효과를 매개변수화를 통해 얻는 방법을 보여줘요.
redis> EVAL "return ARGV[1]" 0 Hello
"Hello"
redis> EVAL "return ARGV[1]" 0 Parameterization!
"Parameterization!"
이 시점에서 Redis가 입력 인자 중 키 이름인 것과 아닌 것을 어떻게 구분하는지 이해하는 게 중요해요.
Redis에서 키 이름은 단순한 문자열이지만, 다른 어떤 문자열 값과도 달리 데이터베이스의 키를 나타내요. 키의 이름은 Redis의 근본적인 개념이고 Redis Cluster를 운영하는 기반이 돼요.
중요: 스크립트의 올바른 실행을 보장하려면, 스탠드얼론 및 클러스터 배포 모두에서, 스크립트가 접근하는 모든 키 이름은 입력 키 인자로 명시적으로 제공되어야 해요. 스크립트는 입력 인자로 주어진 이름의 키에만 접근해야 하며, 프로그램적으로 생성된 이름이나 데이터베이스에 저장된 데이터 구조의 내용에 기반한 키에는 접근하면 안 돼요.
함수에 전달되는 입력 중 키 이름이 아닌 것은 모두 일반 입력 인자예요.
위 예시에서 Hello와 *Parameterization!*은 모두 스크립트의 일반 입력 인자예요. 스크립트가 어떤 키도 건드리지 않기 때문에, 키 이름 인자가 0개임을 나타내는 숫자 인자 0을 사용했어요. 실행 컨텍스트는 인자를 KEYS와 ARGV라는 런타임 전역 변수를 통해 스크립트에 제공해요. KEYS 테이블은 스크립트 실행 전에 제공된 모든 키 이름 인자로 채워지고, ARGV 테이블은 일반 인자들로 채워져요.
입력 인자가 KEYS와 ARGV 런타임 전역 변수에 어떻게 분배되는지 보여주는 예시를 볼게요.
redis> EVAL "return { KEYS[1], KEYS[2], ARGV[1], ARGV[2], ARGV[3] }" 2 key1 key2 arg1 arg2 arg3
1) "key1"
2) "key2"
3) "arg1"
4) "arg2"
5) "arg3"
참고: 위에서 볼 수 있듯이, 루아의 테이블 배열은 RESP2 배열 응답으로 반환돼요. 그래서 클라이언트 라이브러리가 이를 프로그래밍 언어의 네이티브 배열 타입으로 변환할 가능성이 높아요. 자세한 데이터 타입 변환 규칙을 참고해 주세요.
스크립트에서 Redis와 상호작용하기
루아 스크립트에서 Redis 명령을 호출하려면 redis.call() 또는 redis.pcall()을 사용해요.
두 함수는 거의 동일해요. 둘 다 잘 구성된 명령이라면 Redis 명령과 함께 제공된 인자를 실행해요. 하지만 두 함수의 차이는 런타임 오류(예: 구문 오류) 처리 방식에 있어요.
redis.call()을 호출하며 발생한 오류는 이를 실행한 클라이언트에게 직접 반환돼요.redis.pcall()을 호출하며 만난 오류는 가능한 처리를 위해 스크립트의 실행 컨텍스트로 반환돼요.
다음 예시를 볼게요.
> EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 foo bar
OK
위 스크립트는 입력 인자로 키 이름 하나와 값 하나를 받아요. 실행되면 SET 명령을 호출해 입력 키인 foo를 문자열 값 "bar"로 설정해요.
읽기 전용 스크립트 (Read-only scripts)
읽기 전용 스크립트란 Redis 내부의 어떤 키도 수정하지 않는 명령만 실행하는 스크립트예요. 읽기 전용 스크립트는 두 가지 방법으로 실행할 수 있어요.
- 스크립트에
no-writes플래그를 추가하는 방법 - 읽기 전용 스크립트 명령 변형인
EVAL_RO,EVALSHA_RO,FCALL_RO중 하나로 실행하는 방법
읽기 전용 스크립트에는 다음과 같은 속성이 있어요.
- 항상 레플리카(replica) 에서 실행할 수 있어요.
- 항상
SCRIPT KILL명령으로 종료할 수 있어요. - Redis가 메모리 상한을 초과했을 때 OOM 오류로 실패하지 않아요.
- 조정된 장애 조치(failover) 중에 발생하는 쓰기 일시 정지 동안 차단되지 않아요.
- 데이터 세트를 수정할 수 있는 명령을 실행할 수 없어요.
- 현재
PUBLISH,SPUBLISH,PFCOUNT도 스크립트 안에서는 쓰기 명령으로 간주돼요. 이들은 레플리카와 AOF 파일로 명령을 전파(propagate)하려 할 수 있기 때문이에요.
모든 읽기 전용 스크립트가 제공하는 혜택 외에도, 읽기 전용 스크립트 명령은 다음과 같은 추가 장점이 있어요.
- ACL 사용자가 읽기 전용 스크립트만 실행하도록 구성할 수 있어요.
- 많은 클라이언트가 읽기 확장을 위해 레플리카로 읽기 전용 스크립트 명령을 라우팅하는 것을 더 잘 지원해요.
읽기 전용 스크립트 이력
읽기 전용 스크립트와 읽기 전용 스크립트 명령은 Redis 7.0에서 도입됐어요.
- Redis 7.0.1 이전에는
PUBLISH,SPUBLISH,PFCOUNT가 스크립트에서 쓰기 명령으로 간주되지 않았어요. - Redis 7.0.1 이전에는
no-writes플래그가allow-oom을 의미하지 않았어요. - Redis 7.0.1 이전에는
no-writes플래그가 쓰기 일시 정지 중 스크립트 실행을 허용하지 않았어요.
앞서 언급한 기능 중 하나가 필요하지 않다면, 표준 스크립팅 명령과 no-writes 플래그를 함께 사용하는 방식을 권장해요.
샌드박스 처리된 스크립트 컨텍스트 (Sandboxed script context)
Redis는 사용자 스크립트를 실행하는 엔진을 샌드박스 안에 둬요. 샌드박스는 우발적인 오용을 방지하고 서버 환경으로부터의 잠재적 위협을 줄이려고 존재해요.
스크립트는 Redis 서버의 기본 호스트 시스템(예: 파일 시스템, 네트워크)에 접근하거나, API가 지원하는 것 외의 다른 시스템 호출을 수행하려고 해서는 안 돼요.
스크립트는 Redis에 저장된 데이터와 실행 인자로 제공된 데이터에 대해서만 동작해야 해요.
스크립트 캐시 (Script cache)
지금까지는 EVAL 명령으로 스크립트를 실행했어요. EVAL을 호출할 때마다 요청에 스크립트 소스 코드도 함께 포함돼요. 동일한 매개변수화된 스크립트 집합을 실행하기 위해 EVAL을 반복 호출하면 네트워크 대역폭도 낭비되고 Redis에도 약간의 오버헤드가 발생해요. 당연히 네트워크와 컴퓨팅 자원을 아끼는 게 핵심이므로, Redis는 스크립트 캐싱 메커니즘을 제공해요.
EVAL로 실행하는 모든 스크립트는 서버가 유지하는 전용 캐시에 저장돼요. 캐시의 내용은 스크립트의 SHA1 다이제스트 합으로 정리되므로, 스크립트의 SHA1 다이제스트 합은 캐시 안에서 스크립트를 고유하게 식별해요. EVAL을 실행한 뒤 INFO를 호출해 보면, 새 스크립트가 실행될 때마다 used_memory_scripts_eval과 number_of_cached_scripts 지표가 증가하는 것을 확인할 수 있어요.
앞서 언급했듯이 동적 생성 스크립트는 안티 패턴이에요. 애플리케이션 실행 중에 스크립트를 생성하면 호스트의 캐시용 메모리 자원을 소진할 수 있어요. 대신 스크립트는 가능한 한 일반적이어야 하고, 인자를 통해 맞춤형 실행을 제공해야 해요.
스크립트는 SCRIPT LOAD 명령으로 소스 코드와 함께 서버의 캐시에 로드돼요. 서버는 스크립트를 실행하지 않고 단지 컴파일해서 캐시에 로드만 해요. 일단 로드되면 서버가 반환한 SHA1 다이제스트로 캐시된 스크립트를 실행할 수 있어요.
캐시된 스크립트를 로드하고 실행하는 예시를 볼게요.
redis> SCRIPT LOAD "return 'Immabe a cached script'"
"c664a3bf70bd1d45c4284ffebb65a6f2299bfc9f"
redis> EVALSHA c664a3bf70bd1d45c4284ffebb65a6f2299bfc9f 0
"Immabe a cached script"
캐시 휘발성 (Cache volatility)
Redis 스크립트 캐시는 항상 휘발성(volatile) 이에요. 데이터베이스의 일부로 간주되지 않고 영속화(persist)되지 않아요. 캐시는 서버가 재시작되거나, 레플리카가 마스터 역할을 맡는 장애 조치 중에, 또는 명시적으로 SCRIPT FLUSH로 비워질 때 지워질 수 있어요. 즉 캐시된 스크립트는 임시적(ephemeral)이고, 캐시의 내용은 언제든 사라질 수 있어요.
스크립트를 사용하는 애플리케이션은 항상 EVALSHA로 실행해야 해요. 스크립트의 SHA1 다이제스트가 캐시에 없으면 서버는 오류를 반환해요. 예를 들면:
redis> EVALSHA ffffffffffffffffffffffffffffffffffffffff 0
(error) NOSCRIPT No matching script
이 경우 애플리케이션은 먼저 SCRIPT LOAD로 로드한 뒤 EVALSHA를 다시 호출해 SHA1 합으로 캐시된 스크립트를 실행해야 해요. 대부분의 Redis 클라이언트는 이를 자동으로 처리하는 유틸리티 API를 이미 제공해요. 자세한 내용은 클라이언트 문서를 참고하세요.
파이프라이닝 맥락에서의 EVALSHA
파이프라이닝(pipelined) 요청 맥락에서 EVALSHA를 실행할 때는 특별한 주의가 필요해요. 파이프라이닝 요청의 명령은 보낸 순서대로 실행되지만, 그 사이에 다른 클라이언트의 명령이 끼어들 수 있어요. 그래서 NOSCRIPT 오류가 파이프라이닝 요청에서 반환될 수 있는데, 이를 처리할 방법이 없어요.
따라서 클라이언트 라이브러리 구현은 파이프라인 맥락에서 매개변수화된 일반 EVAL로 되돌아가야 해요.
스크립트 캐시 의미론 (Script cache semantics)
정상 운영 중에는 애플리케이션의 스크립트가 캐시에 (서버가 재시작되거나 캐시가 비워질 때까지) 무기한 남아 있게 되어 있어요. 그 근거는 잘 작성된 애플리케이션의 스크립트 캐시 내용은 계속 늘어날 가능성이 낮기 때문이에요. 수백 개의 캐시된 스크립트를 쓰는 큰 애플리케이션조차 캐시 메모리 사용량 측면에서 문제가 되지 않아야 해요.
스크립트 캐시를 비우는 유일한 방법은 명시적으로 SCRIPT FLUSH 명령을 호출하는 거예요. 이 명령을 실행하면 스크립트 캐시가 완전히 비워져서 지금까지 실행된 모든 스크립트가 제거돼요. 보통은 클라우드 환경에서 인스턴스를 다른 고객이나 애플리케이션에 재할당할 때만 필요해요.
또한 앞서 언급했듯이 Redis 인스턴스를 재시작하면 비영속 스크립트 캐시가 비워져요. 하지만 Redis 클라이언트의 관점에서, 두 명령 사이에 Redis 인스턴스가 재시작되지 않았음을 확실히 하는 방법은 딱 두 가지뿐이에요.
- 서버와의 연결이 영속적이고 지금까지 닫힌 적이 없다.
- 클라이언트가
INFO명령의run_id필드를 명시적으로 확인해 서버가 재시작되지 않았고 여전히 같은 프로세스인지 확인한다.
실용적으로는 클라이언트가, 주어진 연결의 맥락에서, 관리자가 명시적으로 SCRIPT FLUSH 명령을 호출하지 않는 한 캐시된 스크립트가 보장된다고 가정하는 게 훨씬 간단해요. 사용자가 Redis가 캐시된 스크립트를 유지한다고 믿을 수 있다는 사실은 파이프라이닝 맥락에서 의미론적으로 유용해요.
SCRIPT 명령
Redis의 SCRIPT 명령은 스크립팅 하위 시스템을 제어하는 여러 방법을 제공해요. 각각을 정리할게요.
SCRIPT FLUSH: Redis 스크립트 캐시를 강제로 비우는 유일한 명령이에요. 같은 Redis 인스턴스가 다른 용도로 재할당되는 환경에서 가장 유용해요. 클라이언트 라이브러리의 스크립팅 구현을 테스트할 때도 도움이 돼요.SCRIPT EXISTS: 하나 이상의 SHA1 다이제스트를 인자로 받아 1과 0으로 이루어진 배열을 반환해요. 1은 해당 SHA1이 스크립트로 인식되어 스크립팅 캐시에 이미 존재한다는 뜻이고, 0은 이 SHA1을 가진 스크립트가 (적어도 최근SCRIPT FLUSH호출 이후로는) 로드된 적이 없다는 뜻이에요.SCRIPT LOAD script: 지정된 스크립트를 Redis 스크립트 캐시에 등록해요.EVALSHA가 (예를 들어 파이프라인이나MULTI/EXEC트랜잭션에서) 스크립트를 실행할 필요 없이 실패하지 않도록 보장하고 싶은 모든 컨텍스트에서 유용해요.SCRIPT KILL: 서버를 종료하지 않고 오래 실행되는(즉 느린) 스크립트를 중단하는 유일한 방법이에요. 스크립트는 실행 시간이 구성된 최대 실행 시간 임계값을 초과하면 느린 것으로 간주돼요.SCRIPT KILL은 실행 중 데이터 세트를 수정하지 않은 스크립트에만 사용할 수 있어요 (읽기 전용 스크립트를 멈추는 것은 스크립팅 엔진이 보장하는 원자성을 위반하지 않기 때문이에요).SCRIPT DEBUG: 내장된 Redis 루아 스크립트 디버거 사용을 제어해요.
스크립트 복제 (Script replication)
스탠드얼론 배포에서는 마스터(master) 라고 하는 단일 Redis 인스턴스가 전체 데이터베이스를 관리해요. 클러스터 배포는 샤딩된 데이터베이스를 관리하는 최소 3개의 마스터를 가져요. Redis는 복제(replication)를 사용해 어떤 마스터에 대해서도 하나 이상의 레플리카, 즉 정확한 복사본을 유지해요.
스크립트가 데이터를 수정할 수 있기 때문에, Redis는 스크립트가 수행하는 모든 쓰기 작업이 레플리카에도 전송되어 일관성을 유지하도록 보장해요. 스크립트 복제에는 크게 두 가지 개념적 접근이 있어요.
- 축어적(Verbatim) 복제: 마스터가 스크립트의 소스 코드를 레플리카로 보내요. 레플리카는 스크립트를 실행하고 쓰기 효과를 적용해요. 짧은 스크립트가 많은 명령을 생성하는 경우(예: for 루프) 이 방식이 복제 대역폭을 절약할 수 있어요. 하지만 이 복제 방식은 레플리카가 마스터가 한 작업을 다시 수행하기 때문에 낭비적이에요. 더 중요하게는 모든 쓰기 스크립트가 결정적(deterministic)이어야 해요.
- 효과(Effects) 복제: 스크립트의 데이터 수정 명령만 복제돼요. 레플리카는 스크립트를 전혀 실행하지 않고 명령만 실행해요. 네트워크 트래픽은 더 길어질 수 있지만, 이 복제 방식은 정의상 결정적이라 특별한 고려가 필요 없어요.
축어적 스크립트 복제는 Redis 3.2에서 효과 복제가 추가되기 전까지 지원된 유일한 방식이었어요. 이를 활성화하려면 lua-replicate-commands 구성 지시문과 redis.replicate_commands() 루아 API를 사용할 수 있어요.
Redis 5.0에서 효과 복제가 기본 모드가 됐어요. Redis 7.0부터는 축어적 복제가 더 이상 지원되지 않아요.
스크립트 대신 명령 복제 (Replicating commands instead of scripts)
Redis 3.2부터 대체 복제 방법을 선택할 수 있게 됐어요. 전체 스크립트를 복제하는 대신, 스크립트가 생성한 쓰기 명령을 복제할 수 있어요. 이를 스크립트 효과 복제(script effects replication) 라고 해요.
참고: Redis 5.0부터 스크립트 효과 복제가 기본 모드이고 명시적으로 활성화할 필요가 없어요.
이 복제 모드에서는 루아 스크립트가 실행되는 동안, Redis가 실제로 데이터 세트를 수정하는 명령을 모두 수집해요. 스크립트 실행이 끝나면 스크립트가 생성한 명령 시퀀스는 MULTI/EXEC 트랜잭션으로 감싸져 레플리카와 AOF로 전송돼요.
이 방식은 사용 사례에 따라 여러 면에서 유용해요.
- 스크립트의 계산이 느리지만 효과가 몇 개의 쓰기 명령으로 요약될 수 있다면, 레플리카나 AOF 재로드 시 스크립트를 다시 계산하는 건 아까워요. 이런 경우 스크립트의 효과만 복제하는 게 훨씬 좋아요.
- 스크립트 효과 복제가 활성화되면 비결정적 함수에 대한 제한이 제거돼요. 예를 들어 스크립트 안에서
TIME이나SRANDMEMBER명령을 어디서든 자유롭게 사용할 수 있어요. - 이 모드에서 루아 PRNG(난수 생성기)는 호출할 때마다 무작위로 시드돼요.
서버 설정이나 기본값(Redis 7.0 이전)으로 이미 활성화되지 않았다면, 스크립트가 쓰기를 수행하기 전에 다음 루아 명령을 실행해야 해요.
redis.replicate_commands ()
redis.replicate_commands() 함수는 스크립트 효과 복제가 활성화되면 true를 반환하고, 스크립트가 이미 쓰기 명령을 호출한 뒤 호출되면 false를 반환해 일반적인 전체 스크립트 복제가 사용돼요.
이 함수는 Redis 7.0부터 폐기(deprecated) 되어, 계속 호출할 수는 있지만 항상 성공해요.
결정적 쓰기를 가진 스크립트
참고: Redis 5.0부터 스크립트 복제는 기본적으로 축어적이 아닌 효과 기준이에요. Redis 7.0에서 축어적 스크립트 복제는 완전히 제거됐어요. 아래 섹션은 효과 기반 스크립트 복제를 사용하지 않는 Redis 7.0 미만 버전에만 적용돼요.
스크립팅의 중요한 부분은 결정적 방식으로만 데이터베이스를 변경하는 스크립트를 작성하는 것이에요. 기본적으로 5.0 버전까지 Redis 인스턴스에서 실행된 스크립트는 결과 명령이 아니라 스크립트 자체를 보내 레플리카와 AOF 파일로 전파됐어요. 스크립트는 원격 호스트(또는 AOF 파일 재로드 시)에서 다시 실행되므로, 데이터베이스에 대한 변경이 재현 가능해야 해요.
스크립트를 보내는 이유는 스크립트가 생성하는 여러 명령을 보내는 것보다 훨씬 빠른 경우가 많기 때문이에요. 클라이언트가 마스터에 많은 스크립트를 보내고 있다면, 스크립트를 레플리카/AOF용 개별 명령으로 변환하면 복제 링크나 Append Only File에 너무 많은 대역폭이 필요해지고, 네트워크를 통해 받은 명령을 디스패치하는 게 루아 스크립트가 호출한 명령을 디스패치하는 것보다 Redis에 훨씬 많은 작업이므로 CPU도 너무 많이 필요해요.
일반적으로 스크립트의 효과 대신 스크립트를 복제하는 게 말이 되지만, 모든 경우가 그런 건 아니에요. 그래서 Redis 3.2부터 스크립팅 엔진은 스크립트 자체를 복제하는 대신 스크립트 실행에서 발생한 쓰기 명령 시퀀스를 복제하는 방식도 선택할 수 있게 됐어요.
이 섹션에서는 스크립트가 전체 스크립트를 보내는 방식(축어적)으로 복제된다고 가정할게요. 이 복제 모드를 전체 스크립트 복제(whole scripts replication) 라고 부를게요.
전체 스크립트 복제 방식의 주요 단점은 스크립트가 다음 속성을 가져야 한다는 거예요. 스크립트는 주어진 동일한 입력 데이터 세트에 대해 항상 동일한 Redis 쓰기 명령을 동일한 인자로 실행해야 해요. 스크립트가 수행하는 작업은 스크립트 실행이 진행되면서 또는 실행 사이에 변할 수 있는 숨겨진(명시적이지 않은) 정보나 상태에 의존할 수 없어요. 또한 I/O 장치의 외부 입력에 의존할 수 없어요.
시스템 시간 사용, 랜덤 값을 반환하는 Redis 명령(예: RANDOMKEY) 호출, 루아의 난수 생성기 사용 같은 행위는 일관되게 평가되지 않는 스크립트를 만들 수 있어요.
스크립트의 결정적 동작을 강제하기 위해 Redis는 다음을 수행해요.
- 루아는 시스템 시간이나 다른 외부 상태에 접근하는 명령을 내보내지 않아요.
- 스크립트가
RANDOMKEY,SRANDMEMBER,TIME같은 Redis 랜덤 명령 이후에 데이터 세트를 변경할 수 있는 Redis 명령을 호출하면 Redis는 오류로 스크립트를 차단해요. 즉 데이터 세트를 수정하지 않는 읽기 전용 스크립트는 그런 명령을 호출할 수 있어요. 랜덤 명령이 반드시 난수를 사용하는 명령을 뜻하는 건 아니라는 점 유의하세요. 어떤 비결정적 명령이든 랜덤 명령으로 간주돼요 (이에 대한 가장 좋은 예가TIME명령이에요). - Redis 4.0에서 무작위 순서로 요소를 반환할 수 있는 명령(예:
SMEMBERS. Redis Set은 정렬되지 않았기 때문)은 루아에서 호출될 때 다르게 동작하며, 데이터를 루아 스크립트에 반환하기 전에 암묵적 사전순 정렬 필터를 거쳐요. 그래서redis.call("SMEMBERS",KEYS[1])는 항상 같은 순서로 Set 요소를 반환하지만, 일반 클라이언트가 호출하는 같은 명령은 키에 정확히 같은 요소가 포함되어 있어도 다른 결과를 반환할 수 있어요. 다만 Redis 5.0부터는 효과 복제가 이런 유형의 비결정성을 우회하기 때문에 이 정렬은 더 이상 수행되지 않아요. 일반적으로 Redis 4.0용으로 개발할 때조차 루아에서 특정 명령이 정렬될 것이라고 가정하지 말고, 호출하는 원본 명령의 문서를 참고해 그 속성을 확인하세요. - 루아의 유사 난수 생성 함수
math.random은 수정되어 매 실행마다 항상 같은 시드를 사용해요. 즉math.random을 호출하면 (math.randomseed를 사용하지 않는 한) 스크립트가 실행될 때마다 항상 같은 숫자 시퀀스를 생성해요.
이 모든 것에도 불구하고, 쓰기와 랜덤 동작을 결합한 명령을 간단한 트릭으로 여전히 사용할 수 있어요. N개의 랜덤 정수로 리스트를 채우는 스크립트를 쓰고 싶다고 상상해 보세요.
초기 Ruby 구현은 다음과 같을 수 있어요.
require 'rubygems'
require 'redis'
r = Redis.new
RandomPushScript = <<EOF
local i = tonumber(ARGV[1])
local res
while (i > 0) do
res = redis.call('LPUSH',KEYS[1],math.random())
i = i-1
end
return res
EOF
r.del(:mylist)
puts r.eval(RandomPushScript,[:mylist],[10,rand(2**32)])
이 코드가 실행될 때마다 결과 리스트는 정확히 다음 요소를 가져요.
redis> LRANGE mylist 0 -1
1) "0.74509509873814"
2) "0.87390407681181"
3) "0.36876626981831"
4) "0.6921941534114"
5) "0.7857992587545"
6) "0.57730350670279"
7) "0.87046522734243"
8) "0.09637165539729"
9) "0.74990198051087"
10) "0.17082803611217"
스크립트를 결정적으로 유지하면서도 여전히 다른 랜덤 요소를 생성하려면, 루아의 난수 생성기에 대한 시드인 추가 인자를 스크립트에 넣을 수 있어요. 새 스크립트는 다음과 같아요.
RandomPushScript = <<EOF
local i = tonumber(ARGV[1])
local res
math.randomseed(tonumber(ARGV[2]))
while (i > 0) do
res = redis.call('LPUSH',KEYS[1],math.random())
i = i-1
end
return res
EOF
r.del(:mylist)
puts r.eval(RandomPushScript,1,:mylist,10,rand(2**32))
여기서 하는 일은 PRNG의 시드를 인자 중 하나로 보내는 거예요. 스크립트 출력은 같은 인자가 주어지면 항상 같고(우리의 요구사항), 하지만 매 호출마다 인자 중 하나를 바꿔서 클라이언트 측에서 랜덤 시드를 생성해요. 시드는 복제 링크와 Append Only File 양쪽에서 인자 중 하나로 전파되므로, AOF가 재로드되거나 레플리카가 스크립트를 처리할 때 같은 변경이 생성되도록 보장해요.
참고: 이 동작의 중요한 부분은 Redis가 math.random과 math.randomseed로 구현하는 PRNG가 Redis를 실행하는 시스템의 아키텍처와 무관하게 같은 출력을 보장한다는 거예요. 32비트, 64비트, 빅엔디언, 리틀엔디언 시스템 모두 같은 출력을 생성해요.
Eval 스크립트 디버깅
Redis 3.2부터 Redis는 네이티브 루아 디버깅을 지원해요. Redis 루아 디버거는 서버(즉 Redis 자체)와 기본값이 redis-cli인 클라이언트로 구성된 원격 디버거예요.
루아 디버거에 대한 자세한 내용은 Redis 문서의 Lua scripts debugging 섹션에 설명되어 있어요.
낮은 메모리 조건에서의 실행 (Execution under low memory conditions)
Redis의 메모리 사용량이 maxmemory 한도를 초과하면, 스크립트에서 추가 메모리를 사용하는 첫 번째 쓰기 명령이 스크립트를 중단시켜요 (redis.pcall을 사용하지 않은 경우).
하지만 다음과 같은 예외가 있어요. 스크립트의 첫 번째 쓰기 명령이 추가 메모리를 사용하지 않는 경우(예: DEL이나 LREM), Redis는 원자성을 보장하기 위해 스크립트의 모든 명령이 실행되도록 허용해요. 이후 스크립트의 쓰기가 추가 메모리를 소비하면, Redis의 메모리 사용량이 maxmemory 구성 지시문이 설정한 임계값을 초과할 수 있어요.
스크립트가 maxmemory 임계값을 넘게 만들 수 있는 또 다른 시나리오가 있어요. 메모리가 maxmemory보다 약간 낮은 상태에서 실행이 시작되어 스크립트의 첫 번째 쓰기 명령이 허용되는 경우예요. 스크립트가 실행됨에 따라 이후 쓰기 명령이 더 많은 메모리를 소비하면서 서버가 구성된 maxmemory 지시문보다 더 많은 RAM을 사용하게 돼요.
이런 시나리오에서는 maxmemory-policy 구성 지시문을 noeviction 이외의 값으로 설정하는 것을 고려해 보세요. 또한 루아 스크립트는 실행 사이에 eviction(축출)이 개입할 수 있도록 가능한 한 빨라야 해요.
플래그를 사용해 이 동작을 변경할 수도 있다는 점을 참고하세요.
Eval 플래그 (Eval flags)
일반적으로 Eval 스크립트를 실행할 때 서버는 스크립트가 데이터베이스에 어떻게 접근하는지 알지 못해요. 기본적으로 Redis는 모든 스크립트가 데이터를 읽고 쓴다고 가정해요. 하지만 Redis 7.0부터는 스크립트를 만들 때 플래그를 선언해 Redis에 어떻게 동작해야 하는지 알려줄 수 있어요.
그 방법은 스크립트의 첫 번째 줄에 셰뱅(Shebang) 문을 사용하는 거예요. 다음과 같이요:
#!lua flags=no-writes,allow-stale
local x = redis.call('get','x')
return x
Redis가 #! 주석을 보는 순간, 스크립트가 플래그를 선언한 것으로 취급해요. 플래그가 정의되지 않았더라도, #! 줄이 없는 스크립트와는 다른 기본값 세트를 가져요.
또 다른 차이는, #! 없는 스크립트는 다른 클러스터 해시 슬롯에 속한 키에 접근하는 명령을 실행할 수 있지만, #! 있는 스크립트는 기본 플래그를 물려받으므로 그렇게 할 수 없다는 거예요.
다양한 스크립트 플래그와 기본값을 알아보려면 Script flags를 참고하세요.
최대 실행 시간 (Maximum execution time)
스크립트는 최대 실행 시간(기본값 5초)의 제한을 받아요. 스크립트는 보통 1밀리초 이내에 실행되므로 이 기본 타임아웃은 엄청나게 큰 값이에요. 이 제한은 개발 중 우발적인 무한 루프를 처리하기 위해 존재해요.
스크립트의 최대 실행 시간은 밀리초 단위 정밀도로 수정할 수 있어요. redis.conf를 통해 또는 CONFIG SET 명령을 사용해서요. 최대 실행 시간에 영향을 주는 구성 매개변수는 busy-reply-threshold 라고 해요.
스크립트가 타임아웃 임계값에 도달해도 Redis가 자동으로 스크립트를 종료하지는 않아요. 그렇게 하면 스크립트가 원자적임을 보장하는 Redis와 스크립팅 엔진 사이의 계약을 위반하게 되기 때문이에요. 스크립트 실행을 중단하면 데이터 세트가 절반만 기록된 변경 상태로 남을 가능성이 있어요.
따라서 스크립트가 구성된 타임아웃보다 오래 실행되면 다음과 같은 일이 발생해요.
- Redis는 스크립트가 너무 오래 실행되고 있다고 로그에 기록해요.
- Redis는 다른 클라이언트의 명령을 다시 받기 시작하지만, 일반 명령을 보내는 모든 클라이언트에는 BUSY 오류로 응답해요. 이 상태에서 허용되는 명령은
SCRIPT KILL,FUNCTION KILL,SHUTDOWN NOSAVE뿐이에요. - 읽기 전용 명령만 실행하는 스크립트는
SCRIPT KILL과FUNCTION KILL명령으로 종료할 수 있어요. 이 명령들은 스크립트가 아직 데이터 세트에 아무것도 쓰지 않았으므로 스크립팅 의미론을 위반하지 않아요. - 스크립트가 단 한 번이라도 쓰기 작업을 수행했다면 허용되는 명령은
SHUTDOWN NOSAVE뿐이에요. 이 명령은 현재 데이터 세트를 디스크에 저장하지 않고 서버를 중지해요 (기본적으로 서버가 중단돼요).