Redis Functions

Redis Functions

Redis Functions는 서버에서 실행할 코드를 관리하는 API예요. Redis 7에서 등장한 이 기능은 이전 버전 Redis의 EVAL 사용을 대체해요.

출처: 공식문서

서문(또는, Eval Scripts의 문제점은?)

이전 버전의 Redis는 EVAL 명령을 통해서만 스크립팅을 제공했어요. 이 명령은 Lua 스크립트를 서버에 보내 실행하게 하죠. Eval Scripts의 핵심 사용 사례는 애플리케이션 로직의 일부를 Redis 안에서 효율적이고 원자적으로 실행하는 거예요. 이런 스크립트는 여러 키에 걸친 조건부 업데이트를 수행하고, 여러 데이터 타입을 결합할 수 있어요.

EVAL을 쓰려면 애플리케이션이 매번 전체 스크립트를 보내야 해요. 이는 네트워크와 스크립트 컴파일 오버헤드를 만들기 때문에, Redis는 EVALSHA 명령 형태의 최적화를 제공해요. 먼저 SCRIPT LOAD로 스크립트의 SHA1을 얻으면, 이후에는 그 다이제스트만으로 반복 호출할 수 있어요.

설계상 Redis는 로드된 스크립트만 캐시해요. 즉 스크립트 캐시는 언제든 사라질 수 있어요 — SCRIPT FLUSH 호출 후, 서버 재시작 후, 혹은 replica로 failover할 때 등. 누락된 스크립트가 있으면 런타임 중에 애플리케이션이 재로드할 책임이 있어요. 기본 전제는 스크립트가 애플리케이션의 일부이고 Redis 서버가 유지하지 않는다는 거예요.

이 접근은 많은 가벼운 스크립팅 사용 사례에는 맞지만, 애플리케이션이 복잡해지고 스크립팅에 더 크게 의존하면 몇 가지 어려움이 생겨요:

  • 모든 클라이언트 애플리케이션 인스턴스가 모든 스크립트의 사본을 유지해야 해요. 즉 스크립트 업데이트를 애플리케이션의 모든 인스턴스에 적용할 메커니즘이 필요하다는 뜻이에요.
  • 캐시된 스크립트를 트랜잭션 맥락에서 호출하면, 스크립트가 없어서 트랜잭션이 실패할 확률이 높아져요. 실패 가능성이 크면 캐시 스크립트를 워크플로의 구성 요소로 쓰는 게 매력적이지 않죠.
  • SHA1 다이제스트는 의미가 없어서(예: MONITOR 세션에서) 시스템 디버깅을 매우 어렵게 만들어요.
  • EVAL을 순진하게 쓰면, 클라이언트가 KEYS·ARGV Lua API를 책임감 있게 쓰는 대신 스크립트를 원문 그대로 렌더링하는 안티 패턴을 부추겨요.
  • 일시적(ephemeral)이기 때문에 스크립트가 다른 스크립트를 호출할 수 없어요. 이는 클라이언트 측 전처리를 하지 않는 한 스크립트 간 코드 공유·재사용을 거의 불가능하게 해요(첫 번째 항목 참고).

이런 필요를 해결하면서 이미 확립되고 사랑받는 일시적 스크립트에 파괴적인 변경을 피하기 위해, Redis v7.0은 Redis Functions를 도입했어요.

Redis Functions란?

Redis functions는 일시적 스크립팅에서 한 단계 진화한 거예요.

Functions는 스크립트와 같은 핵심 기능을 제공하지만, 데이터베이스의 일급(first-class) 소프트웨어 산출물이에요. Redis는 functions를 데이터베이스의 필수 부분으로 관리하고, 데이터 영속성과 복제를 통해 가용성을 보장해요. functions는 데이터베이스의 일부이므로 사용 전에 선언되기 때문에, 애플리케이션은 런타임에 로드하거나 트랜잭션 중단 위험을 감수할 필요가 없어요. functions를 쓰는 애플리케이션은 데이터베이스에 내장된 스크립트 로직이 아니라 그들의 API에만 의존해요.

일시적 스크립트가 애플리케이션 영역의 일부로 간주되는 반면, functions는 사용자가 제공한 로직으로 데이터베이스 서버 자체를 확장해요. 모듈과 비슷하게, 한 번 개발해 시작 시 로드하고 다양한 애플리케이션/클라이언트가 반복 사용하는, 핵심 Redis 명령으로 구성된 더 풍부한 API를 노출하는 데 쓸 수 있어요. 모든 function은 고유한 사용자 정의 이름을 가져서 호출·실행 추적이 훨씬 쉬워요.

Redis Functions의 설계는 functions 작성에 쓰는 프로그래밍 언어와 서버의 관리 사이의 경계도 그으려 해요. Lua는 Redis가 현재 내장 실행 엔진으로 지원하는 유일한 언어 인터프리터인데, 단순하고 배우기 쉽게 설계됐어요. 하지만 Lua를 언어로 선택한 것 자체가 많은 Redis 사용자에게 도전과제로 남죠.

Redis Functions 기능은 구현 언어에 대해 어떤 가정도 하지 않아요. 함수 정의의 일부인 실행 엔진이 함수 실행을 처리해요. 엔진은 이론적으로 실행 중인 함수를 종료할 수 있는 능력 같은 몇 가지 규칙만 지키면 어떤 언어로든 함수를 실행할 수 있어요.

현재 Redis는 앞서 말했듯 단일 내장 Lua 5.1 엔진을 탑재해요. 미래에 추가 엔진을 지원할 계획이 있어요. Redis functions는 Redis Lua 스크립트 디버거를 제외하고는 Lua가 일시적 스크립트에게 제공하는 모든 기능을 쓸 수 있어요.

Functions는 코드 공유를 활성화해 개발을 단순화하기도 해요. 모든 function은 한 개의 라이브러리(library)에 속하고, 주어진 라이브러리는 여러 function으로 구성될 수 있어요. 라이브러리의 내용은 불변(immutable)이고, function의 선택적 업데이트는 허용되지 않아요. 대신 라이브러리는 모든 function과 함께 한 연산으로 전체가 업데이트돼요. 이는 같은 라이브러리 안의 다른 function을 호출하거나, 언어 네이티브 인자를 받을 수 있는 라이브러리 내부 메서드의 공통 코드를 사용해 function 간 코드를 공유하는 걸 가능하게 해요.

Functions는 앞서 말한 것처럼 논리적 스키마를 통해 데이터 엔티티의 일관된 뷰를 유지하는 사용 사례를 더 잘 지원하도록 설계됐어요. 그래서 functions는 데이터 자체와 함께 저장돼요. AOF 파일에도 영속화되고 master에서 replica로 복제되므로, 데이터만큼 내구성이 있어요. Redis를 일시적 캐시로 쓸 때는 functions를 더 내구성 있게 만들기 위한 추가 메커니즘(아래 설명)이 필요해요.

Redis의 다른 모든 연산처럼 fnction 실행은 원자적이에요. 함수 실행은 트랜잭션 의미론과 유사하게 전체 시간 동안 모든 서버 활동을 차단해요. 이 의미는 스크립트의 모든 효과가 "아직 일어나지 않았거나" "이미 일어났다"는 거죠. 실행된 함수의 차단 의미론은 모든 연결된 클라이언트에 항상 적용돼요. 함수 실행이 Redis 서버를 차단하므로, functions는 빠르게 실행을 마치도록 설계됐으니 오래 실행되는 함수는 피해야 해요.

라이브러리와 함수 로드

구체적인 예시와 Lua 스니펫으로 Redis Functions를 탐구해 볼게요.

이 시점에서 Lua(특히 Redis 안의 Lua)에 익숙하지 않다면, Introduction to Eval ScriptsLua API 페이지의 예시를 먼저 보는 게 언어 이해에 도움이 돼요.

모든 Redis function은 Redis에 로드되는 하나의 라이브러리에 속해요. 라이브러리를 데이터베이스에 로드하는 건 FUNCTION LOAD 명령으로 해요. 이 명령은 라이브러리 페이로드를 입력으로 받는데, 페이로드는 사용할 엔진과 라이브러리 이름 같은 메타데이터를 제공하는 Shebang 문으로 시작해야 해요. Shebang 형식은:

#!<engine name> name=<library name>

빈 라이브러리 로드를 시도해 볼게요:

redis> FUNCTION LOAD "#!lua name=mylib\n"
(error) ERR No functions registered

이 오류는 예상된 거예요. 로드된 라이브러리에 function이 없기 때문이죠. 모든 라이브러리는 성공적으로 로드되려면 최소 하나의 등록된 function을 포함해야 해요. 등록된 function은 이름이 있고 라이브러리의 진입점 역할을 해요. 대상 실행 엔진이 FUNCTION LOAD 명령을 처리할 때 라이브러리의 function들을 등록해요.

Lua 엔진은 로드 시 라이브러리 소스 코드를 컴파일·평가하고, redis.register_function() API를 호출해 함수가 등록되길 기대해요.

다음 스니펫은 knockknock이라 불리는 단일 함수(문자열 응답을 반환)를 등록하는 간단한 라이브러리를 보여줘요:

#!lua name=mylib
redis.register_function(
  'knockknock',
  function() return 'Who\'s there?' end
)

위 예시에서 기능에 대해 Lua의 redis.register_function() API에 두 인자를 제공했어요: 등록 이름과 콜백이에요.

라이브러리를 로드하고 FCALL로 등록된 함수를 호출할 수 있어요:

redis> FUNCTION LOAD "#!lua name=mylib\nredis.register_function('knockknock', function() return 'Who\\'s there?' end)"
mylib
redis> FCALL knockknock 0
"Who's there?"

FUNCTION LOAD 명령이 로드된 라이브러리의 이름을 반환하는 걸 주목하세요. 이 이름은 나중에 FUNCTION LISTFUNCTION DELETE에 쓸 수 있어요.

FCALL에 두 인자를 제공했어요: 함수의 등록 이름과 숫자 0. 이 숫자는 그 뒤에 오는 키 이름의 개수를 나타내요(EVALEVALSHA가 동작하는 방식과 같아요).

키 이름과 추가 인자가 함수에 어떻게 제공되는지는 곧 설명할게요. 이 간단한 예시는 키가 없으니 지금은 0을 썼어요.

입력 키와 일반 인자

다음 예시로 넘어가기 전에, Redis가 키 이름인 인자그렇지 않은 인자를 구분한다는 점을 이해하는 것이 중요해요.

Redis에서 키 이름은 그냥 문자열이지만, 다른 문자열 값들과 달리 데이터베이스의 키를 나타내요. 키의 이름은 Redis의 근본적인 개념이고 Redis Cluster 운영의 기반이에요.

중요: 단일 배포와 클러스터 배포 모두에서 Redis Functions가 올바르게 실행되도록, 함수가 접근하는 모든 키 이름은 입력 키 인자로 명시적으로 제공돼야 해요.

키 이름이 아닌 함수 입력은 일반 입력 인자예요.

이제 애플리케이션이 데이터 일부를 Redis Hashes에 저장한다고 가정해 볼게요. 그 Hash의 필드를 설정·갱신하고 _last_modified_라는 새 필드에 마지막 수정 시간을 저장하는, HSET-류의 방법을 원해요. 그걸 다 하는 함수를 구현할 수 있어요.

우리 함수는 TIME을 호출해 서버 시계를 읽고, 새 필드 값과 수정 타임스탬프로 대상 Hash를 갱신할 거예요. 함수가 받는 입력 인자는: Hash의 키 이름과 갱신할 필드-값 쌍이에요.

Redis Functions용 Lua API는 이 입력들을 함수 콜백의 첫 번째와 두 번째 인자로 접근할 수 있게 해줘요. 콜백의 첫 번째 인자는 함수에 대한 모든 키 이름 입력으로 채워진 Lua 테이블이고, 두 번째 인자는 모든 일반 인자로 구성돼요.

우리 함수와 라이브러리 등록의 가능한 구현은 이래요:

#!lua name=mylib

local function my_hset(keys, args)
  local hash = keys[1]
  local time = redis.call('TIME')[1]
  return redis.call('HSET', hash, '_last_modified_', time, unpack(args))
end

redis.register_function('my_hset', my_hset)

라이브러리 정의를 담은 mylib.lua라는 새 파일을 만들면, 이렇게 로드할 수 있어요(유용한 공백을 제거하지 않고):

$ cat mylib.lua | redis-cli -x FUNCTION LOAD REPLACE

FUNCTION LOAD 호출에 REPLACE 수정자를 추가해 기존 라이브러리 정의를 덮어쓰겠다고 Redis에 알렸어요. 그렇지 않으면 Redis가 라이브러리가 이미 존재한다고 오류를 냈을 거예요.

이제 갱신된 라이브러리 코드가 Redis에 로드됐으니 함수를 호출할 수 있어요:

redis> FCALL my_hset 1 myhash myfield "some value" another_field "another value"
(integer) 3
redis> HGETALL myhash
1) "_last_modified_"
2) "1640772721"
3) "myfield"
4) "some value"
5) "another_field"
6) "another value"

이 경우 키 이름 인자 수로 1을 써서 FCALL을 호출했어요. 즉 함수의 첫 번째 입력 인자는 키 이름이고(따라서 콜백의 keys 테이블에 포함), 그 뒤의 모든 인자는 일반 인자로 간주되어 콜백의 두 번째 인자로 전달되는 args 테이블을 구성해요.

라이브러리 확장하기

애플리케이션에 도움이 되도록 라이브러리에 함수를 더 추가할 수 있어요. Hash에 추가한 메타데이터 필드는 Hash 데이터 접근 시 응답에 포함되면 안 돼요. 반면 주어진 Hash 키의 수정 타임스탬프를 얻는 수단은 제공하고 싶어요.

이 목표를 위해 라이브러리에 새 함수 두 개를 추가할 거예요:

  • my_hgetall Redis Function은 주어진 Hash 키 이름에서 메타데이터(즉 _last_modified_ 필드)를 제외한 모든 필드와 값들을 반환해요.
  • my_hlastmodified Redis Function은 주어진 Hash 키 이름의 수정 타임스탬프를 반환해요.

라이브러리 소스 코드는 이렇게 생겼을 거예요:

#!lua name=mylib

local function my_hset(keys, args)
  local hash = keys[1]
  local time = redis.call('TIME')[1]
  return redis.call('HSET', hash, '_last_modified_', time, unpack(args))
end

local function my_hgetall(keys, args)
  redis.setresp(3)
  local hash = keys[1]
  local res = redis.call('HGETALL', hash)
  res['map']['_last_modified_'] = nil
  return res
end

local function my_hlastmodified(keys, args)
  local hash = keys[1]
  return redis.call('HGET', hash, '_last_modified_')
end

redis.register_function('my_hset', my_hset)
redis.register_function('my_hgetall', my_hgetall)
redis.register_function('my_hlastmodified', my_hlastmodified)

위의 모든 것이 간단하다고 느껴져야 하지만, my_hgetallredis.setresp(3)도 호출한다는 점을 주목하세요. 이는 함수가 redis.call()RESP3 응답을 기대한다는 뜻이에요. RESP3는 기본 RESP2 프로토콜과 달리 사전(연관 배열) 응답을 제공하죠. 덕분에 함수가 응답에서 특정 필드(Lua 테이블에서는 nil로 설정)를 삭제할 수 있어요. 우리 경우엔 _last_modified_ 필드예요.

라이브러리 구현을 mylib.lua 파일에 저장했다면, 이렇게 교체할 수 있어요:

$ cat mylib.lua | redis-cli -x FUNCTION LOAD REPLACE

로드 후엔 FCALL로 라이브러리의 함수를 호출할 수 있어요:

redis> FCALL my_hgetall 1 myhash
1) "myfield"
2) "some value"
3) "another_field"
4) "another value"
redis> FCALL my_hlastmodified 1 myhash
"1640772721"

FUNCTION LIST 명령으로 라이브러리 세부 정보를 얻을 수도 있어요:

redis> FUNCTION LIST
1) 1) "library_name"
   2) "mylib"
   3) "engine"
   4) "LUA"
   5) "functions"
   6) 1) 1) "name"
         2) "my_hset"
         3) "description"
         4) (nil)
         5) "flags"
         6) (empty array)
      2) 1) "name"
         2) "my_hgetall"
         3) "description"
         4) (nil)
         5) "flags"
         6) (empty array)
      3) 1) "name"
         2) "my_hlastmodified"
         3) "description"
         4) (nil)
         5) "flags"
         6) (empty array)

라이브러리를 새 기능으로 쉽게 갱신할 수 있다는 걸 볼 수 있어요.

라이브러리에서 코드 재사용

함수를 데이터베이스 관리 소프트웨어 산출물로 묶는 것과 더불어, 라이브러리는 코드 공유도 촉진해요. 다른 함수에서 호출하는 오류 처리 헬퍼 함수를 라이브러리에 추가할 수 있어요. 헬퍼 check_keys()는 입력 keys 테이블에 단일 키가 있는지 검증해요. 성공 시 nil을, 실패 시 error reply를 반환해요.

갱신된 라이브러리 소스 코드는:

#!lua name=mylib

local function check_keys(keys)
  local error = nil
  local nkeys = table.getn(keys)
  if nkeys == 0 then
    error = 'Hash key name not provided'
  elseif nkeys > 1 then
    error = 'Only one key name is allowed'
  end

  if error ~= nil then
    redis.log(redis.LOG_WARNING, error);
    return redis.error_reply(error)
  end
  return nil
end

local function my_hset(keys, args)
  local error = check_keys(keys)
  if error ~= nil then
    return error
  end

  local hash = keys[1]
  local time = redis.call('TIME')[1]
  return redis.call('HSET', hash, '_last_modified_', time, unpack(args))
end

local function my_hgetall(keys, args)
  local error = check_keys(keys)
  if error ~= nil then
    return error
  end

  redis.setresp(3)
  local hash = keys[1]
  local res = redis.call('HGETALL', hash)
  res['map']['_last_modified_'] = nil
  return res
end

local function my_hlastmodified(keys, args)
  local error = check_keys(keys)
  if error ~= nil then
    return error
  end

  local hash = keys[1]
  return redis.call('HGET', keys[1], '_last_modified_')
end

redis.register_function('my_hset', my_hset)
redis.register_function('my_hgetall', my_hgetall)
redis.register_function('my_hlastmodified', my_hlastmodified)

위로 Redis의 라이브러리를 교체한 뒤, 새 오류 처리 메커니즘을 바로 시험해 볼 수 있어요:

127.0.0.1:6379> FCALL my_hset 0 myhash nope nope
(error) Hash key name not provided
127.0.0.1:6379> FCALL my_hgetall 2 myhash anotherone
(error) Only one key name is allowed

그리고 Redis 로그 파일에는 이와 유사한 줄이 있어야 해요:

...
20075:M 1 Jan 2022 16:53:57.688 # Hash key name not provided
20075:M 1 Jan 2022 16:54:01.309 # Only one key name is allowed

클러스터에서의 Functions

앞서 말했듯 Redis는 로드된 함수의 replica 전파를 자동으로 처리해요. Redis Cluster에서는 함수를 모든 클러스터 노드에 로드하는 것도 필요해요. 이는 Redis Cluster가 자동으로 처리하지 않으므로 클러스터 관리자가 처리해야 해요(모듈 로딩, 설정 설정 등처럼).

functions의 목표 중 하나가 클라이언트 애플리케이션과 분리돼 살아가는 것이므로, 이는 Redis 클라이언트 라이브러리 책임이 아니어야 해요. 대신 redis-cli --cluster-only-masters --cluster call host:port FUNCTION LOAD ...를 사용해 모든 master 노드에서 로드 명령을 실행할 수 있어요.

또한 redis-cli --cluster add-node는 기존 노드 중 하나에서 새 노드로 로드된 함수를 자동으로 전파한다는 점을 기억하세요.

Functions와 일시적(ephemeral) Redis 인스턴스

어떤 경우에는 미리 로드된 함수 집합과 함께 새 Redis 서버를 시작해야 할 수도 있어요. 흔한 이유는:

  • 새 환경에서 Redis 시작
  • functions를 쓰는 일시적(캐시 전용) Redis 재시작

이런 경우, Redis가 인바운드 사용자 연결과 명령을 받아들이기 전에 미리 로드된 함수가 사용 가능한지 확인해야 해요.

그렇게 하려면 redis-cli --functions-rdb를 사용해 기존 서버에서 functions를 추출할 수 있어요. 이는 Redis가 시작 시 로드할 수 있는 RDB 파일을 생성해요.

Function flags

Redis는 함수가 실행될 때 어떻게 동작할지에 대한 정보가 있어야, 리소스 사용 정책을 제대로 적용하고 데이터 일관성을 유지할 수 있어요.

예를 들어 Redis는 특정 함수가 읽기 전용임을 알아야, 읽기 전용 replica에서 FCALL_RO로 실행을 허용할 수 있어요.

기본적으로 Redis는 모든 함수가 임의의 읽기 또는 쓰기 연산을 수행할 수 있다고 가정해요. Function Flags는 등록 시점에 더 구체적인 함수 동작을 선언할 수 있게 해줘요. 어떻게 동작하는지 볼게요.

이전 예시에서 데이터만 읽는 함수 두 개를 정의했어요. 읽기 전용 replica를 상대로 FCALL_RO로 실행을 시도할 수 있어요:

redis > FCALL_RO my_hgetall 1 myhash
(error) ERR Can not execute a function with write flag using fcall_ro.

Redis가 이 오류를 반환하는 이유는 함수가 이론적으로 데이터베이스에서 읽기와 쓰기 연산을 모두 수행할 수 있기 때문이에요. 안전장치로서 기본적으로 Redis는 함수가 둘 다 한다고 가정해 실행을 차단해요. 서버는 다음 경우에 이 오류로 응답해요:

  • 읽기 전용 replica를 상대로 FCALL로 함수 실행.
  • FCALL_RO로 함수 실행.
  • 디스크 오류 감지(Redis가 영속화할 수 없어 쓰기를 거부).

이런 경우 함수 등록에 no-writes 플래그를 추가해 안전장치를 비활성화하고 실행을 허용할 수 있어요. 플래그로 함수를 등록하려면 redis.register_functionnamed arguments 변형을 쓰세요.

갱신된 라이브러리 등록 코드 스니펫은:

redis.register_function('my_hset', my_hset)
redis.register_function{
  function_name='my_hgetall',
  callback=my_hgetall,
  flags={ 'no-writes' }
}
redis.register_function{
  function_name='my_hlastmodified',
  callback=my_hlastmodified,
  flags={ 'no-writes' }
}

라이브러리를 교체하면 Redis는 읽기 전용 replica를 상대로 my_hgetallmy_hlastmodifiedFCALL_RO로 실행하는 걸 허용해요:

redis> FCALL_RO my_hgetall 1 myhash
1) "myfield"
2) "some value"
3) "another_field"
4) "another value"
redis> FCALL_RO my_hlastmodified 1 myhash
"1640772721"

플래그 전체 문서는 Script flags를 참고하세요.

더 알아보기 (Learn more)