처음엔 아주 익숙한 장애처럼 보였다.
애플리케이션이 Redis에 붙지 못한다.
로그에는 인증 에러가 보인다.
Spring Boot는 기동하다가 멈춘다.
그리고 우리는 본능적으로 생각한다.
“비밀번호가 잘못 들어갔나?”
그런데 이번 이야기는 조금 달랐다.
비밀번호가 틀린 게 아니었다.
Redis 클라이언트가 문 앞에서 아주 공손하게 자기소개를 하고 있었는데, 중간에 서 있던 Proxy가 그 말을 못 알아들은 사건이었다.
CLIENT SETINFO lib-name Lettuce
CLIENT SETINFO lib-ver 6.x.x
한 줄로 요약하면 이렇다.
Redis 서버는 알아듣는 말인데, Redis Proxy가 아직 모르는 말이었다.
Proxy는 그냥 전선이 아니다
우리는 종종 Proxy를 이렇게 상상한다.
Client -------------- Proxy -------------- Redis
그냥 가운데서 전선처럼 데이터를 흘려보내는 존재.
하지만 접근 제어 제품의 Proxy는 그렇게 단순하지 않다.
누가 어떤 명령을 보냈는지 알아야 하고, 그 명령이 허용되는지 봐야 하고, 감사 로그도 남겨야 한다.
그래서 실제 구조는 조금 더 똑똑하다.
Client command
-> RESP decode
-> Redis command parser
-> 지원 여부 / 권한 분류 판단
-> Redis server relay
-> Redis response adapter
-> Client response
여기서 중요한 포인트가 하나 있다.
Redis 서버가 어떤 명령을 지원한다고 해서, Proxy도 자동으로 그 명령을 지원하는 것은 아니다.
Proxy가 명령을 먼저 읽고 이해해야 한다.
모르는 명령이면 서버까지 보내기 전에 “Invalid command”가 될 수 있다.
범인은 비밀번호가 아니라 “자기소개”였다
문제의 상황은 Spring Data Redis와 Lettuce 조합에서 나타났다.
Lettuce는 Redis에 연결할 때 사용자가 직접 시키지 않아도 몇 가지 초기화 명령을 보낼 수 있다.
그중 하나가 Redis 7.2에서 도입된 CLIENT SETINFO다.
쉽게 말하면 이런 자기소개다.
안녕하세요 Redis 서버님.
저는 Lettuce라는 클라이언트 라이브러리입니다.
제 버전은 6.x입니다.
Redis 입장에서는 유용한 정보다.
“아, 이 연결은 어떤 클라이언트에서 왔구나” 하고 알 수 있다.
명령도 위험해 보이지 않는다.
데이터를 지우지도 않고, 설정을 바꾸지도 않는다.
그냥 이름표를 붙이는 정도다.
그런데 Proxy 입장에서는 다르다.
Parser가 CLIENT SETINFO라는 문장을 모르면 이렇게 된다.
Client: CLIENT SETINFO lib-name Lettuce
Proxy: 그게 뭐죠?
Proxy: ERR Invalid command
Client: 연결 초기화 실패
Application: Redis 연결 실패
겉으로는 인증 실패처럼 보인다.
하지만 실제로는 연결 초기화 중 자동으로 나간 “자기소개 명령”이 막힌 것이다.
왜 인증 에러처럼 보였을까?
로그에는 이런 메시지가 섞여 있었다.
NOAUTH HELLO must be called with the client already authenticated,
otherwise the HELLO AUTH <user> <pass> option can be used...
이 문장만 보면 당연히 인증 문제처럼 보인다.
RESP3에서 HELLO 3 AUTH user pass 형태로 인증해야 하는데, 뭔가 빠진 것처럼 보이니까.
하지만 디버깅에서 중요한 건 “첫 번째로 보이는 에러”와 “진짜 원인”이 항상 같지 않다는 점이다.
이번에는 고객이 username/password를 넣고, Pub/Sub 관련 코드까지 잠시 빼고 다시 테스트했다.
그런데도 이런 메시지가 남았다.
ERR [ENGINE] Invalid command. Please check your command.
이제 질문이 바뀐다.
“인증 정보가 없나?”가 아니라,
“도대체 어떤 명령을 Invalid command라고 본 거지?”
그리고 그 끝에서 CLIENT SETINFO가 나왔다.
Redis Pub/Sub는 또 다른 이야기다
여기서 헷갈리기 쉬운 친구가 하나 더 있다.
바로 Redis Pub/Sub다.
Spring의 RedisMessageListenerContainer는 이름 그대로 Redis 메시지를 구독한다.
구독을 시작하면 내부적으로 이런 명령을 쓴다.
SUBSCRIBE
PSUBSCRIBE
SSUBSCRIBE
이 명령들은 일반적인 요청-응답과 다르다.
한 번 요청하고 한 번 응답받고 끝나는 게 아니라, 서버가 이후 메시지를 계속 밀어준다.
Client: SUBSCRIBE news
Server: OK, 구독 시작
Server: 새 메시지 왔어요
Server: 또 새 메시지 왔어요
Server: 또 왔어요
...
Proxy 입장에서는 이게 꽤 다른 세계다.
일반 명령 중계와 달리 server push stream을 계속 다뤄야 한다.
그래서 Pub/Sub 미지원과 CLIENT SETINFO 미지원은 분리해서 봐야 한다.
| 구분 | CLIENT SETINFO | Pub/Sub |
|---|---|---|
| 누가 보내나 | Lettuce가 연결 초기화 중 자동 전송 | 애플리케이션 listener가 구독 시작 시 전송 |
| 성격 | 클라이언트 정보 등록 | 서버가 계속 메시지를 push하는 구독 |
| 겉보기 증상 | 인증/연결 실패처럼 보임 | listener 시작 실패, 구독 기능 미동작 |
| 해결 범위 | parser에 명령 추가 | streaming relay 구조 지원 필요 |
이번 사건의 핵심은 Pub/Sub가 아니었다.
Pub/Sub를 잠깐 빼도 남는 Invalid command가 있었고, 그쪽이 바로 CLIENT SETINFO였다.
Redis 명령은 “서버 지원”과 “Proxy 지원”을 따로 봐야 한다
이 사건에서 가장 유익한 교훈은 이거다.
Redis 호환성을 볼 때는 질문을 두 번 해야 한다.
1. Redis 서버가 이 명령을 지원하는가?
2. 중간 Proxy도 이 명령을 이해하고 처리하는가?
서버만 보면 안 된다.
클라이언트만 봐도 안 된다.
가운데 있는 Proxy가 protocol을 해석하는 순간, Proxy도 하나의 protocol participant가 된다.
특히 Redis처럼 명령이 계속 늘어나는 시스템에서는 이 차이가 중요하다.
Redis 7.2에서 새 명령이 생긴다.
클라이언트 라이브러리가 그 명령을 자동으로 쓰기 시작한다.
서버는 문제없이 받아준다.
그런데 Proxy는 아직 그 명령을 모른다.
그러면 운영자는 이런 이상한 장면을 보게 된다.
서버: 나는 이해할 수 있어요.
클라이언트: 저는 정상적으로 말했어요.
Proxy: 저는 처음 듣는 말인데요.
애플리케이션: Redis 연결 실패!
이런 경우 “Redis가 안 된다”가 아니라 “Proxy의 command vocabulary가 최신 client behavior를 따라잡았는가?”를 봐야 한다.
고치는 방향은 의외로 작다
이런 문제를 고친다고 해서 Redis Proxy 전체를 갈아엎을 필요는 없다.
핵심은 Proxy가 그 자기소개 명령을 알아듣게 만드는 것이다.
수정의 모양은 대략 이렇다.
Lexer: SETINFO라는 단어를 안다
Parser: CLIENT SETINFO attr value 형태를 안다
CommandType: CLIENT_SETINFO라는 command type을 가진다
Command model: attr/value를 안전하게 담는다
Result adapter: 정상 응답은 OK 계열로 처리한다
Tests: Lettuce가 보내는 실제 형태를 검증한다
예를 들어 테스트 입력은 이런 모양이면 된다.
CLIENT SETINFO lib-name Lettuce
CLIENT SETINFO lib-ver 6.3.2.RELEASE/8.0.0
client setinfo lib-name lettuce
중요한 건 이것을 위험한 데이터 변경 명령처럼 다루지 않는 것이다.CLIENT SETINFO는 데이터 삭제도 아니고 설정 변경도 아니다.
클라이언트 메타데이터를 알려주는 연결 보조 명령에 가깝다.
Dangerous 명령어는 “전체 허용”이 답이 아니다
이 사건에는 또 하나의 질문이 붙어 있었다.
“Redis Dangerous 카테고리 명령어는 어떻게 허용하는 게 좋을까?”
여기서도 답은 단순하지 않다.
Redis에는 운영자가 보고 싶은 명령과, 운영자가 절대 함부로 열면 안 되는 명령이 같은 Dangerous 묶음에 들어갈 수 있다.
예를 들어 이런 명령은 운영 모니터링에 유용하다.
INFO
CONFIG GET
CLIENT LIST
CLIENT INFO
SLOWLOG GET
SLOWLOG LEN
반대로 이런 명령은 조심해야 한다.
FLUSHALL
FLUSHDB
SHUTDOWN
CONFIG SET
ACL SETUSER
CLUSTER RESET
MODULE LOAD
MONITOR
그래서 좋은 정책은 “Dangerous 전체 허용”이 아니다.
좋은 정책은 이런 모양이다.
운영 조회에 필요한 명령만 선별 허용
데이터 삭제 / 설정 변경 / 구조 변경 명령은 차단 유지
보안 제품에서 편의성과 안전성은 늘 줄다리기를 한다.
하지만 줄을 통째로 놓으면 안 된다.
필요한 만큼만 당겨야 한다.
디버깅 체크리스트
비슷한 Redis 애플리케이션 연결 장애가 오면, 나는 이제 이렇게 본다.
Redis client library가 무엇인가?
- Lettuce인가?
- Jedis인가?
- ioredis인가?
- go-redis인가?
Redis server 버전은 무엇인가?
- Redis 7.2 이상인가?
- Valkey인가?
- ElastiCache인가?
RESP 모드는 무엇인가?
- RESP2인가?
- RESP3인가?
로그에 이런 키워드가 있는가?
NOAUTH HELLO
HELLO AUTH
Invalid command
CLIENT SETINFO
SUBSCRIBE
EVAL
Pub/Sub, Lua, Redis Stack을 쓰는가?
문제가 되는 명령이 서버에서 지원되는가?
그리고 마지막으로, Proxy도 그 명령을 아는가?
마지막 질문이 이번 사건의 핵심이었다.
여기서 배운 점
이번 버그는 “Redis 인증 에러”라는 가면을 쓰고 왔다.
하지만 가면을 벗겨보니 안에는 protocol compatibility 문제가 있었다.
더 정확히는, 클라이언트 라이브러리의 자동 행동과 Proxy parser의 명령 사전이 어긋난 문제였다.
이런 문제는 앞으로 더 자주 생길 수 있다.
클라이언트 라이브러리는 점점 똑똑해지고, 서버 프로토콜은 계속 확장된다.
그 사이에 있는 Proxy는 단순한 터널이 아니라면, 그 변화의 영향을 그대로 받는다.
그래서 좋은 Proxy는 두 가지를 잘해야 한다.
첫째, 모르는 명령을 만났을 때 운영자가 원인을 좁힐 수 있게 보여줘야 한다.
둘째, 최신 client library가 자동으로 보내는 harmless한 초기화 명령은 빠르게 따라잡아야 한다.
이번 사건을 한 문장으로 남기면 이렇다.
Redis는 비밀번호를 틀린 게 아니라, 자기소개를 하고 있었다.
그리고 Proxy는 이제 그 자기소개를 알아듣는 법을 배워야 한다.