배경
프로젝트에서 새롭게 로그인을 하는 경우 리프레시 토큰을 발급하여 Redis에 저장하도록 로직이 구현되어 있고, Redis 사용은 Spring Data Redis의 `Repository` 인터페이스를 통해 저장하고 있습니다.
해당 토큰 entity의 TTL은 7일인데요. 따라서 생성된 지 7일 후면 Redis에서 토큰의 정보가 모두 사라져야 합니다.
그런데 어느 날 우연히 Redis의 sets 밑에 refresh set이 남아있는 것을 발견하게 되었는데, refresh set의 내부에는 정황상 리프레시 토큰값으로 보이는 value들이 가득했습니다.
방금 확인한 Redis는 배포 이전에 로컬에서 로그인 테스트를 진행하며 사용했던 테스트 DB로, 사용하지 않은지 7일을 넘어 꽤 되었기 때문에 모든 토큰값이 지워져야 하는 것이 정상 동작입니다.
혹시나 처음에 TTL을 설정하지 않고 작업했던 적이 있었나? 하는 마음에 운영서버 Redis를 확인해보았으나 역시 동일하게 sets 아래에 리프레시 토큰값들이 저장되어 있었습니다 🤯
문제 상황
TTL을 설정했음에도 불구하고 일정 시간이 지나도 Redis에 저장한 값이 깨끗하게 지워지지 않고 남아 있는 문제가 발생하고 있습니다. (set 타입의 값이 삭제되지 않음)
Redis는 인메모리 데이터베이스이기 때문에 지워져야 할 값들이 지워지지 않고 계속해서 쌓이게만 된다면, 메모리 누수 현상처럼 필요 없는 값으로 인해 서버의 가용 메모리가 점점 줄어드는 상황이 발생합니다. 이는 서버 성능에 좋지 않은 영향을 미칠 뿐 아니라, 결국에는 OOM 장애를 발생시킬 수 있는 위험이 있습니다.
지금처럼 규모가 작은 테스트 DB에서는 이 현상이 큰 의미가 없어보이지만, 로그인 사용자가 굉장히 많아지는 실제 운영 상황을 가정해 보면 이는 심각한 문제가 될 수 있습니다.
아래에서 설명하겠지만, 이렇게 만들어진 set 내부의 값은 인덱스 역할을 합니다. 따라서 해당 값이 제대로 정리되지 않을 경우 존재하지 않는 값에 대한 인덱스가 남아있는 상황이 되고, 데이터 정합성의 문제가 되기도 합니다.
이유 분석
먼저 첫 번째로 TTL 적용에 문제가 생겼다는 상황을 의심해볼 수 있었어요. 그래서 확인을 위해 기존 코드를 가지고 TTL 옵션 값을 3초로 조정해서 설정한 TTL 시간이 지나면 저장한 객체가 잘 지워지는지 테스트 코드를 작성했습니다.
@Test
void TTL값_3초가_지나면_토큰이_정상적으로_삭제되어_조회할수_없다() throws InterruptedException {
RefreshToken token = RefreshToken.create(1L, "tokenvalue", 3L);
refreshTokenRepository.save(token);
Thread.sleep(4000);
Optional<RefreshToken> result = refreshTokenRepository.findById("tokevalue");
assertThat(result).isEmpty();
}
테스트 코드를 실행시켜보면 정상적으로 성공하고, 동시에 `MONITOR`로 Redis 명령을 모니터링해보면 `"EXPIRE" "refresh:tokenvalue" "3”` 명령어가 날아가는 것도 확인할 수 있습니다.
또, 여기에서 그치지 않고 실행 후 4초를 기다리는 시간 동안 실시간으로 Redis에 명령을 날려 확인해 보면 hash 값은 생겼다가 사라지지만, set 타입의 refresh 값은 사라지지 않고 남아있는 것을 직접 보는 것도 가능합니다.
하지만 4초가 지나도 set 내부의 값은 여전히 남아 있습니다.
위 테스트를 통해 다음과 같은 사실을 확인할 수 있었습니다.
- hash 타입 데이터는 정상적으로 설정된 TTL 시간이 지나면 만료되어 삭제된다
- set 타입 데이터는 TTL 시간이 지나도 삭제되지 않는다
즉 TTL 시간 이후 정상적으로 데이터가 만료가 되긴 하지만, 만료되지 않고 남아있는 데이터가 있는데 그것이 바로 set인 것입니다.
그런데 조금 이상합니다. `save`를 했을 때 어떤 이유로 set 데이터가 따로 추가 저장되는 걸까요?
set이 자동으로 추가되는 이유 : Spring Data Redis Repository
테스트를 통해 set 타입의 데이터가 문제가 되고 있었다는 것이 명확해졌어요. 그렇다면 왜 save를 하는 과정에서 set이 생기는지를 먼저 파악하는 게 중요해 보입니다.
@Repository
public interface RefreshTokenRepository extends KeyValueRepository<RefreshToken, String> {}
현재 토큰 저장은 Spring Data repository에서 제공하는 `Repository` 인터페이스를 이용하고 있습니다. 따라서 관련 공식 문서를 살펴보면 단서를 찾을 수 있는데요.
Redis Repositories Anatomy :: Spring Data Redis
Redis as a store itself offers a very narrow low-level API leaving higher level functions, such as secondary indexes and query operations, up to the user. This section provides a more detailed view of commands issued by the repository abstraction for a bet
docs.spring.io
문서에 의하면 새롭게 Insert new 했을 때 다음과 같은 과정이 진행된다고 합니다.
repository.save(new Person("rand", "al'thor"));
HMSET "people:19315449-cda2-4f5c-b696-9cb8018fa1f9" "_class" "Person" "id" "19315449-cda2-4f5c-b696-9cb8018fa1f9" "firstname" "rand" "lastname" "al'thor"
SADD "people" "19315449-cda2-4f5c-b696-9cb8018fa1f9"
SADD "people:firstname:rand" "19315449-cda2-4f5c-b696-9cb8018fa1f9"
SADD "people:19315449-cda2-4f5c-b696-9cb8018fa1f9:idx" "people:firstname:rand"
==================
1. Save the flattened entry as hash.
2. Add the key of the hash written in <1> to the helper index of entities in the same keyspace.
3. Add the key of the hash written in <2> to the secondary index of firstnames with the properties value.
4. Add the index of <3> to the set of helper structures for entry to keep track of indexes to clean on delete/update.
새로운 객체를 저장하는 코드를 실행하면, hash에 해당 내용을 저장하는 `HMSET` 이후에 set에도 값을 추가하는 `SADD` 명령어가 실행됩니다.
설명의 helper index of entities in the same keyspace 라는 말로 미루어 보아, 인덱스 역할을 위해 추가적으로 set을 생성한다는 것을 알 수 있는데요.
즉, Spring Data Redis Repository의 `save` 메서드는 내부적으로 HMSET이후 SADD를 통해 set을 생성하도록 구현되어 있다는 것입니다!
실제로 모니터링을 통해 확인해보게 되면 save 과정에서 날아간 `HMSET`과 `SADD` 명령어를 볼 수 있습니다. (secondary index를 사용하지 않기 때문에 예시의 3번과 4번 줄 쿼리는 생성되지 않았습니다)
정리해보자면 set 데이터는 Spring Data Redis Repository에서 데이터 저장 시, 인덱스로 사용하기 위해 자동적으로 함께 생성하는 데이터였습니다.
그렇다면 set 값도 만료와 함께 지워주면 문제가 없을 것 같은데, 왜 hash가 삭제될 때 set은 함께 삭제되지 않는 것인지 이유를 알아보도록 해요.
set 값에 TTL이 적용되지 않는 이유
자동으로 생성된 set값은 왜 만료되면서 같이 없어지지 않을까요?
앞서 `EXPIRE`를 통해 hash값을 만료시킨다고 이야기했습니다. Redis docs에서 `EXPIRE`의 정의를 찾아보면 Set a timeout on key 라고 설명하는데, 이를 통해 `EXPIRE`는 키에 적용되는 명령어임을 알 수 있습니다.
반면 우리가 삭제하고자 하는 값은 set의 키값이 아닌 내부의 원소입니다. 따라서 특정 원소만을 만료시키도록 할 수가 없는 것입니다.
하지만 그렇다고 해서 만일 키값인 refresh를 만료시키게 된다면 전체 set이 삭제되기 때문에, 아직 만료되지 않은 내부의 원소들도 함께 사라진다는 문제점이 있습니다.
이유 정리
이렇게 해서 Spring Data Redis Repository를 사용해서 객체를 저장하는 경우 왜 위와 같은 문제가 생겼는지 이유를 파악해 보았습니다.
- Spring Data Redis는 기본적으로 @RedisHash 값에 대해 set 타입 객체를 만드는데, 이는 인덱스 역할을 위해 사용된다.
- 따라서 객체를 저장하면 hash 타입으로 저장되는 동시에, 해당 키 set에도 객체의 키값이 추가된다.
- 여기서 객체의 키값이 set의 내부 원소로 추가되는 것이기 때문에, `EXPIRE`로 동작하는 TTL 만료가 적용되지 않는다.
그렇다면 이 문제를 해결하는 방법에는 어떤 것들이 있는지 하나씩 살펴보겠습니다.
해결 방법
Redis keyspace notifications
Redis keyspace notifications
Monitor changes to Redis keys and values in real time
redis.io
Redis는 키 & 값의 변경 사항을 실시간으로 모니터링할 수 있는 Keyspace notifications 기능을 제공합니다. 특정 키에 대한 이벤트를 감지할 수도 있고, 특정 이벤트가 발생한 키에 대한 알림을 받는 것도 가능합니다.
그렇기 때문에 클라이언트는 특정 키가 만료되는 것도 감지할 수 있는데요. 이를 활용해 hash값이 만료되었을 때 어플리케이션이 이를 수신하고 해당 키와 관련된 모든 정보를 정리하도록 할 수가 있습니다!
해당 옵션은 디폴트가 비활성이기 때문에 사용을 위해서는 코드에 `@EnableRedisRepositories(enableKeyspaceEvents = EnableKeyspaceEvents.ON_STARTUP)`를 추가하여 활성화시켜 주면 됩니다.
해당 기능을 위해 Redis는 phantom hash라는 개념을 사용하는데, 이는 만료된 키의 사후 처리를 지원하기 위한 사본입니다.
만료 이벤트 발생 시, DB에서 키가 만료된 이후 클라이언트가 해당 이벤트를 수신하게 되는데요. 따라서 클라이언트는 정보가 없다면 어떤 내용을 가지고 작업을 해야 할지 알 수 없는 상황이 발생합니다.
그래서 원래 키보다 조금 더 긴 TTL을 가지는 phantom hash를 생성하여 어떤 키값과 관련된 데이터를 정리해야 할지를 클라이언트에게 알려주게 됩니다.
그럼 옵션을 활성화한 후 동일 테스트 코드를 실행했을 때의 Redis 쿼리를 살펴보겠습니다.
- 첫 번째 빨간 블럭에서는 객체 데이터를 저장하는 로직 진행
- 두 번째 노란 블럭에서는 phantom hash 값이 설정됨
- 세 번째 파란 블럭에서는 phantom hash 값을 가지고 refresh set에서 원소 tokenvalue55를 삭제하고, 나머지 인덱스 데이터도 삭제하는 작업을 진행
특히 문제가 되었던 부분을 자동으로 `SREM` 명령어를 사용해 삭제하고 있음을 볼 수 있습니다. 따라서 이 방법이 옵션 하나로 간단하게 문제를 해결할 수 있는 좋은 해결책으로 보이지만, 몇 가지 위험 요소가 존재합니다.
- 키 만료 시 이벤트를 수신하고, 남은 데이터들을 삭제하는 등 추가 작업의 오버헤드가 발생한다는 것입니다. 로그에서도 볼 수 있듯 추가로 날려야 하는 쿼리만 봐도 꽤 많은 양의 추가 작업이 필요해지는데, 이러한 오버헤드로 인해 성능에 영향을 끼칠 수 있습니다.
- 이런 변경 사항 이벤트의 감지를 위한 Redis의 Pub/Sub 메시지는 손실 가능성이 있습니다. 클라이언트와의 연결이 끊어지게 되면, 나중에 다시 연결되더라도 그 끊어진 시간 동안 전달된 변경 사항 이벤트들이 손실되는 문제점이 있습니다.
- 마지막으로는 phantom hash 같은 추가적인 값이 필요하기 때문에, 옵션을 사용하지 않았을 때보다 메모리가 더 많이 사용됩니다.
- 이 문제는 `@EnableKeyspaceEvents(shadowCopy = OFF)` 를 통해 phantom copy를 저장하지 않도록 함으로써 어느 정도 해결할 수 있기는 합니다. 다만 이 때는 `RedisKeyExpiredEvent` 클래스가 오직 id값만을 가지게 됩니다.
RedisTemplate
keyspace notifications을 이용하는 방법 이외에도 다른 해결 방법이 존재하는데요. 바로 직접 RedisTemplate로 Redis를 사용하는 것입니다.
지금까지는 Spring Data Redis의 Repository로 CRUD 작업을 진행해 왔는데, 이는 사용자가 편하게 사용할 수 있도록 높은 수준의 추상화를 제공하는 방식입니다. 우리가 인터페이스 내부에 어떻게 구현되어 있는지 상관하지 않고 쉽게 CRUD를 할 수 있도록 해주죠.
하지만 구현되어 있는 대로만 사용할 수 있다는 것이 단점이 되기도 합니다. 불필요한 인덱스를 무조건 SADD 해야 하는 것처럼요.
반면 직접적으로 RedisTemplate를 사용하게 되면 좀 더 복잡한 코드 구현이 필요해지지만, 필요한 부분들만을 선택해서 사용할 수 있는 유연성이 장점이 됩니다. 불필요한 추가 명령어 자체를 없앨 수 있기 때문에 앞서 Keyspace notifications을 사용했을 때의 문제점 또한 자연스럽게 해결이 되는 좋은 선택지가 될 수 있을 것 같아요.
마무리
Redis에 TTL을 통해 만료되지 않는 값이 생기는 현상을 발견하게 되어, 이유를 파악해 보고 해결 방법에 대해서도 알아보았습니다.
저는 이렇게 만들어진 set 타입 인덱스 값을 사용하는 로직이 존재하지 않았기 때문에 해당 값은 불필요한 값이 되었고, 최종적으로 RedisTemplate를 이용하여 SADD 명령어를 제거하는 쪽으로 개선하게 되었는데요.
하지만 해당 인덱스가 유용하게 사용되는 경우도 분명히 있기 때문에, 우선적으로 set 인덱스 값의 필요성에 대한 고민이 필요하리라고 생각합니다.
또, 이번 기회를 통해 Spring Data Redis처럼 쉽고 간편하게 사용할 수 있는 도구들도 내부 작동이 어떻게 이뤄지는지 파악하는 것이 중요하다는 것을 다시 한 번 깨닫게 되었습니다.
사실 운이 좋아서 데이터가 쌓이고 있는 것을 발견해서 다행이었지, 모르고 지나갔다면 나중에 관련해서 서버 메모리에 문제가 생기더라도 원인을 파악하는 데 어려움을 겪었을 것 같다는 생각이 들었어요. 그래서 서버의 모니터링 시스템 구축의 중요성도 크게 체감할 수 있는 계기가 되기도 했습니다.
여러 가지로 많은 생각을 할 수 있게 된 주제라서 재미있게 공부했던 것 같네요. 읽으시는 분들께도 도움이 되었으면 좋겠습니다.
reference
(본문에 포함되지 않은 참고자료들을 정리합니다)
https://docs.spring.io/spring-data/redis/reference/redis/redis-repositories/expirations.html
https://engineering.salesforce.com/lessons-learned-using-spring-data-redis-f3121f89bff9/