애플리케이션은 Redis에 잘 연결됐다.

GET도 된다.
SET도 된다.

그런데 Lua Script를 실행하려고 EVAL을 보내는 순간 문이 닫힌다.

ERR Not supported command

이상하다.

Redis는 분명 EVAL을 지원한다. 같은 스크립트를 Redis에 직접 보내면 잘 실행된다. 그렇다면 중간에 있는 Proxy가 고장 난 걸까?

꼭 그렇지는 않다.

이 상황을 이해하려면 EVAL을 평범한 Redis 명령으로 보면 안 된다. EVAL은 명령 한 줄처럼 보이지만, 사실은 작은 프로그램을 서버 안으로 보내는 택배 상자에 가깝다.

상자 바깥에는 똑같이 EVAL이라고 적혀 있다.

하지만 하나를 열면 GET이 나오고, 다른 하나를 열면 DEL이 나오고, 또 다른 하나에서는 긴 반복문이 튀어나올 수 있다.

오늘은 이 한 줄짜리 상자가 왜 Redis Proxy와 접근 제어 시스템을 진지하게 고민하게 만드는지 이야기해보려 한다.

Proxy는 전선이 아니라 통역사다

Proxy를 단순한 전선으로 생각하면 모든 것이 이상해진다.

Client -------- Proxy -------- Redis

“Redis가 이해하는 명령이면 그냥 전달하면 되잖아?”

하지만 접근 제어 Proxy는 보통 명령을 그대로 흘려보내지 않는다.

Client command
→ protocol 해석
→ 명령 종류 식별
→ 지원 범위 확인
→ 사용자 권한 확인
→ Redis로 전달
→ 응답 해석
→ 감사 기록

Proxy가 명령을 읽는 이유는 분명하다.

누가 어떤 데이터를 읽으려는지 알아야 한다. 데이터를 바꾸는 명령인지, 위험한 운영 명령인지도 구분해야 한다. 실행 결과를 사용자에게 돌려주고 감사 기록에도 남겨야 한다.

그래서 Redis가 어떤 명령을 지원한다는 사실과 Proxy가 그 명령을 안전하게 처리할 수 있다는 사실은 서로 다르다.

이 차이를 놓치면 장애 원인을 엉뚱한 곳에서 찾게 된다.

“모르는 명령”과 “알지만 막은 명령”은 다르다

명령이 실패했을 때 오류 문구를 자세히 읽으면 첫 번째 단서를 얻을 수 있다.

Invalid command

이건 대체로 Proxy가 명령 이름이나 문법을 해석하지 못했다는 뜻이다. 새 Redis 명령을 오래된 Parser가 아직 모르는 상황이 대표적이다.

반면 이런 오류는 결이 다르다.

Not supported command

명령의 정체는 알아봤다. 다만 현재 처리 가능한 범위가 아니어서 Redis에 보내기 전에 멈춘 것이다.

쉽게 말하면 이렇다.

Invalid command       처음 듣는 말인데요?
Not supported command 무슨 말인지는 알지만 여기서는 처리할 수 없어요.
Permission denied     처리 가능한 명령이지만 당신에게는 권한이 없어요.
Redis error           Proxy를 통과했지만 Redis가 거절했어요.

네 문장은 비슷하게 실패를 말하지만, 확인해야 할 층은 완전히 다르다.

Not supported인데 Redis Cluster 상태만 계속 들여다보면 답이 나오지 않는다. 명령은 Cluster routing에 도착하기도 전에 멈췄을 가능성이 크기 때문이다.

EVAL은 명령이 아니라 작은 프로그램이다

평범한 명령은 겉모습만으로도 성격을 짐작하기 쉽다.

GET user:1
SET user:1 active
DEL user:1

GET은 읽기다.
SET은 쓰기다.
DEL은 삭제다.

그러나 EVAL은 다르다.

읽기만 하는 EVAL

EVAL "return redis.call('GET', KEYS[1])" 1 user:1

데이터를 읽기만 한다.

값을 바꾸는 EVAL

EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 user:1 active

같은 EVAL이지만 이번에는 데이터를 바꾼다.

여러 key를 지우는 EVAL

EVAL "for _, key in ipairs(KEYS) do redis.call('DEL', key) end return #KEYS" 2 cache:a cache:b

이번에는 여러 데이터를 삭제한다.

상자 바깥의 라벨은 모두 EVAL이다.
하지만 안에서 벌어지는 일은 조회, 수정, 대량 삭제로 달라진다.

심지어 데이터는 바꾸지 않더라도 긴 반복이나 무거운 계산으로 Redis를 오래 붙잡을 수 있다. Redis의 Lua Script는 원자적으로 실행되는 강력한 도구지만, 실행 중 다른 요청에 영향을 줄 수 있다는 뜻이기도 하다.

그래서 EVAL을 단순히 READ나 WRITE 한 칸에 넣는 순간 중요한 정보가 사라진다.

SCRIPT LOAD가 된다고 실행도 되는 건 아니다

Redis의 스크립트 관련 명령은 관리와 실행으로 나눠 봐야 한다.

SCRIPT LOAD    스크립트를 등록한다
SCRIPT EXISTS  등록 여부를 확인한다
SCRIPT FLUSH   저장된 스크립트를 비운다
EVAL           전달한 스크립트를 즉시 실행한다
EVALSHA        등록된 스크립트를 해시로 찾아 실행한다
FCALL          Redis Function을 호출한다

여기서 자주 생기는 오해가 있다.

SCRIPT LOAD를 처리할 수 있으니 Lua Script를 지원한다.

하지만 창고에 상자를 넣는 것과 상자 안의 기계를 실제로 돌리는 것은 다른 일이다.

SCRIPT LOAD가 가능해도 EVALSHA가 막혀 있으면 저장한 스크립트를 실행할 수 없다. Function 관리 명령이 보여도 FCALL 실행 경로가 없다면 실사용은 제한된다.

기능 지원을 판단할 때는 명령 하나의 존재가 아니라 처음부터 끝까지 실제 목적을 달성할 수 있는가를 봐야 한다.

이 원칙은 Redis에만 해당하지 않는다.

파일 업로드 API가 있다고 파일 처리가 완성되는 것은 아니다. 모델 등록 버튼이 있다고 추론 서비스가 준비된 것도 아니다. “관리 화면이 있다”와 “실행 경로가 완성됐다”는 자주 다른 이야기다.

Cluster에서는 key가 주소가 된다

Redis Cluster에서는 고민이 하나 더 늘어난다.

Cluster는 key를 hash slot으로 나누고, 각 slot을 담당하는 node에 데이터를 둔다. GET user:1처럼 key가 명령 앞에 잘 드러나면 어느 node로 보낼지 계산하기 쉽다.

EVALnumkeys를 먼저 읽어야 한다.

EVAL "return redis.call('GET', KEYS[1])" 1 user:1
                                          ^ ^
                                          | └─ 첫 번째 key
                                          └── key 개수

안전하게 중계하려면 Proxy는 적어도 이런 질문에 답해야 한다.

  • numkeys와 실제 key 개수가 맞는가?
  • 여러 key가 같은 hash slot에 있는가?
  • 쓰기 가능성이 있다면 master로 보내고 있는가?
  • numkeys가 0이면 어느 node로 보낼 것인가?
  • 저장된 스크립트를 해시만으로 실행할 때 성격을 어떻게 판단할 것인가?
  • MOVED, ASK, READONLY 같은 응답을 어떻게 처리할 것인가?

그러니 Cluster는 EVAL 지원을 어렵게 만드는 중요한 조건이다.

다만 순서를 구분해야 한다.

명령이 Proxy의 지원 범위 검사에서 먼저 차단됐다면 Cluster는 현재 실패의 원인이 아니다. 앞으로 기능을 설계할 때 풀어야 할 다음 문제다.

원인과 미래의 구현 과제를 섞지 않는 것이 좋은 디버깅의 시작이다.

category 한 줄을 바꾸면 왜 끝나지 않을까

코드를 보다가 이런 유혹을 받을 수 있다.

EVAL = NOT_SUPPORTED

“이걸 WRITE로 바꾸면 되는 것 아닌가?”

문은 열릴 수 있다.
하지만 문 뒤의 복도까지 안전해졌다는 뜻은 아니다.

서버사이드 스크립트를 제대로 지원하려면 최소 네 묶음을 함께 봐야 한다.

1. 권한

스크립트 안에서 GET, SET, DEL, SCAN을 부를 수 있다면 기존 명령별 권한과 어떤 관계를 맺어야 할까?

EVAL_RO는 읽기 전용으로 분리할 수 있을까?
일반 EVAL은 별도의 고위험 권한이 필요할까?

겉 명령 하나만 허용했는데 내부에서 더 강한 작업을 할 수 있다면 접근 제어 경계가 우회될 수 있다.

2. 감사

감사 로그에는 무엇을 남겨야 할까?

스크립트 원문 전체를 저장하면 너무 길거나 민감한 값이 들어갈 수 있다. 반대로 EVAL 한 단어만 남기면 실제로 무엇을 했는지 알기 어렵다.

script, key, argument를 어떻게 나눠 기록하고 무엇을 마스킹할지 설계가 필요하다.

3. 실행 안전성

최대 실행 시간은 얼마인가?
누가 긴 스크립트를 중단할 수 있는가?
timeout 뒤의 Redis connection은 계속 써도 안전한가?

“실행을 허용한다”는 말에는 실패했을 때 안전하게 돌아오는 방법까지 포함돼야 한다.

4. 결과 표현

Lua Script는 숫자, 문자열, 배열, nil, 오류처럼 다양한 값을 반환한다.

클라이언트 응답만 맞으면 끝이 아니다. 웹 화면, Workflow 결과, 감사 기록도 같은 결과를 일관되게 이해해야 한다.

이 네 가지를 빼고 category만 바꾸는 건 현관문을 열면서 비상구, CCTV, 안내 표지판은 아직 설치하지 않은 것과 비슷하다.

실제 문의에서는 명령보다 목적을 먼저 묻자

누군가 “EVAL을 열어주세요”라고 요청하면 곧바로 명령 허용 여부만 볼 필요는 없다.

먼저 왜 필요한지 물어보는 편이 빠르다.

분산 락인가?
rate limit인가?
여러 key의 원자적 갱신인가?
cache 삭제인가?
사용하는 client library가 내부적으로 Lua를 호출하는가?
Standalone인가, Cluster인가?

목적을 알면 대안도 검토할 수 있다.

단순 카운터라면 INCR와 만료 명령 조합이 맞을 수 있다. 명시적인 몇 개의 key를 지우는 일이라면 일반 DEL이나 UNLINK가 더 투명할 수 있다. 어떤 작업은 MULTIEXEC로 표현할 수 있다.

물론 대체 명령이 원래 Lua Script와 같은 원자성이나 성능을 보장한다고 단정하면 안 된다. 그래서 더더욱 “어떤 명령을 쓰고 싶은가?”보다 “어떤 동시성 문제를 풀고 싶은가?”를 먼저 물어야 한다.

요구사항은 종종 명령어의 옷을 입고 찾아온다.
좋은 엔지니어는 옷보다 그 안의 목적을 본다.

지원 여부는 문 하나가 아니라 길 전체다

EVAL이 막히는 장면은 Redis의 특이한 예외처럼 보인다.

하지만 여기에는 Proxy와 보안 제품을 설계할 때 계속 만나는 원칙이 들어 있다.

첫째, 서버의 지원과 중간 계층의 지원은 다르다.

둘째, 명령을 인식하는 것과 안전하게 실행하는 것은 다르다.

셋째, 관리 기능의 존재와 end-to-end 실행 경로의 완성은 다르다.

넷째, 강력한 기능일수록 권한, 감사, 실행 안전성, 결과 표현을 함께 설계해야 한다.

그래서 EVAL 앞에서 문이 닫혔다면 이렇게 질문해보자.

Redis가 이 명령을 아는가?
Proxy가 이 명령을 인식하는가?
현재 지원 범위에 들어가는가?
사용자 권한으로 허용되는가?
Cluster에서 올바른 node로 보낼 수 있는가?
결과와 감사 기록을 끝까지 표현할 수 있는가?

명령 한 줄이 Redis에 도착하기까지 생각보다 많은 문을 지난다.

특히 그 한 줄 안에 작은 프로그램이 들어 있다면, 문이 많은 건 겁이 많아서가 아니다.

강력한 기능을 열 때는 실행 버튼만이 아니라, 그 기능이 지나갈 길 전체를 함께 열어야 하기 때문이다.