레디스 메모리 개선 : 멱등성 보장을 위한 키 저장 구조 변경 본문

레디스 메모리 개선 : 멱등성 보장을 위한 키 저장 구조 변경

JinHwan Kim 2026. 3. 3. 00:20

배경

팀의 알림 서비스 이야기이다.

카프카로부터 메시지를 수신하고 이를 사용자에게 전달하는 서비스를 운영하고 있다.

네트워크 통신 비용 절감을 위해 메시 지 배치 수신, 처리 결과를 그룹 단위로 전달한다.

그룹 단위 처리 중 예외가 발생하면 이미 처리한 내용을 잊기에, 복구 시 메시지를 중복 수신하게 된다.

알림 메시지 중복 전달을 피하기 위해, 레디스에 수신한 메시지 ID를 기록하고 비교하는 것으로 중복 처리를 피한다.

간단한 구조 개선으로 레디스 메모리 사용률을 33% 개선한 경험을 소개한다.

 

String vs Hash 

기존 구조는 수신한 각 메시지 ID를 레디스 String 타입으로 저장했다.

양이 많고, 짧은 보관 기간의 데이터이기에, 각 메시지 ID를 레디스 Key로 하여 TTL을 명확하게 지정했다.

대신 각 데이터마다 메타 데이터가 필요하여 메모리 사용량이 많다.

 

레디스 Hash는 해시테이블 하나를 레디스 Key로 하고, 그 안에서 Key:Value 구조로 저장하는 구조이다.

레디스 Key를 하나만 사용하기에 필요한 메타데이터를 최소화하고, Hash 내부에서 키 검색도 O(1)이면 된다.

대신 각 Hash 키가 아닌, 해시 테이블 자체를 하나의 레디스 Key로 하여, 메시지 ID 각각의 TTL 설정은 불가능하다.

 

구간 기록

알림 서비스에선 이벤트 양이 많다.

그래서 단순 String 저장보다, Hash를 이용해서 레디스 Key를 줄이는 것이 메모리 절감에 효과적일 것이라 생각했다.

일정 구간 동안 수신한 메시지를 한 해시테이블에 담고, 그 구간에 TTL을 지정하는 것이 개선 아이디어였다.

예를 들어 TTL이 10분이라고 하자.

10시 10분부터 10시 15분까지의 수신 메시지 ID를 담는 테이블을 만들고 TTL을 10분으로 한다.

그 기간 동안에 수신한 메시지는 해당 해시테이블에서 비교하고, 없다면 추가하는 것으로 중복 여부를 확인할 수 있다.

 

 

아래는 기존 String 방식의 setIfAbsentWithString와 개선한 Hash 방식의 setIfAbsentWithHash 의 코드 비교이다.

Hash 사용 코드에선 구간별 레디스 Key를 생성하고, 해시 테이블에 메시지 ID를 기록하는 것을 볼 수 있다.

이때 현재 그 해시 테이블이 존재하는지 여부를 확인하지 않고 TTL을 지정하는 방법으로 처리했다.

public boolean setIfAbsentWithString(String msgId, long timeoutMs) {
    var wasSet = redisTemplate.opsForValue().setIfAbsent(msgId, "", timeoutMs, TimeUnit.MILLISECONDS);
    return wasSet != null && wasSet;
}

public boolean setIfAbsentWithHash(String msgId, long timeoutMs) {
    var hashKey = generateHourlyKey();
    var isNewField = redisTemplate.opsForHash().putIfAbsent(hashKey, msgId, "");
    if (isNewField) {
        redisTemplate.expire(hashKey, timeoutMs, TimeUnit.MILLISECONDS);
        return true;
    }
    return false;
}

 

메모리 개선

이렇게 구간별 키 기록으로 개선한 메모리 사이즈를 확인하자.

현재 운영 기준 초당 6천 개의 메시지가 발생하고 있다.

TTL 10분을 기준으로 약 360만 개의 메시지 ID를 String과 Hash 타입으로 기록하고 각각의 사용 메모리를 비교했다.

String으로 저장했을 때는 레디스 키 360만 개가 생성, 약 390MB의 메모리가 사용되었고,

Hash로 저장했을 때는 레디스 키가 1개 생성, 약 260MB의 메모리가 사용되었다.

간단한 구조 개선만으로 약 약 33.3%가 개선된 것이다.

한 환경에서만 멱등 처리를 하진 않으니, 운영 환경 혹은 내 경우보다 훨씬 더 큰 규모의 서비스에선 더 많은 메모리 변화가 있을 것이다.

 

 

HASH 타입의 두 가지 데이터 저장 방법

사실 레디스 Hash 타입에서 데이터를 저장하는 방식은 두 가지이다.

HashTable 방식은 우리가 익히 아는 Key:Value 방법이고,

Listpack 방식은 Key:Value를 하나의 연속된 메모리에 저장하는 방법이다.

 

HashTable 방식은 O(1)의 탐색 비용이 들지만, Listpack은 연속된 메모리 공간 안에서 순차탐색(O(N))이 필요하다.

대신 Listpack 방식은 HashTable 방식보다 훨씬 더 적은 메모리를 사용한다.

그렇기에 Listpack은 메모리도 적으면서도 O(N)의 비용이 적을 때, 즉 순차탐색할 엔트리가 적을 때 유리하다.

레디스는 기본 값으로 512 개 이하의 엔트리를 갖는 Hash는 Listpack으로, 그 이상은 HashTable를 사용한다.

 

"OBJECT ENCODING $REDIS_KEY_NAME" 을 커멘드로 사용하여 어떤 방법으로 저장하는지 확인할 수 있다.

아쉽지만 512개를 훌쩍 넘은 데이터를 저장하는 내 경우엔, Listpack의 공간 효율적인 메모리 관리는 불가능하다.

 

 

성능 개선과 Lua 스크립트

앞서 구간별 기록에 레디스 키를 위한 계산하고, 매번 Hash에 TTL을 설정했다.

현재 구간의 해시 테이블이 존재하는지 여부를 확인할 수 없어 매번 해당 레디스 키에 TTL을 지정하는 것이다.

요청량이 많지 않은 경우에선 이 두 커맨드 전달이 큰 영향을 주지 않겠지만, 요청량이 많은 이벤트 파이프라인의 경우는 다르다.

String 사용으로 레디스를 한 번만 요청할 때는 10초 동안 14만 개의 메시지를 처리할 수 있었고,

HASH 사용으로 두 번의 커멘드를 요청할 때는 10초 동안 11만 개의 메시지를 처리할 수 있음을 테스트했다.

 

아래처럼 LuaScript를 작성하여 한 번의 요청으로 HSETNX와 TTL을 처리할 수 있도록 수정했다.

테스트 결과 기존 String으로 단일 커멘드를 전달하는 것과 동일한 성능으로 처리할 수 있었다.

local wasSet = redis.call('HSETNX', KEYS[1], ARGV[1], ARGV[2])
if wasSet == 1 then
  redis.call('PEXPIRE', KEYS[1], ARGV[3])
end
return wasSet

 

구간 사이의 유실

성능 말고도 또 하나의 놓칠 수 있는 포인트가 있다.

만약 구간 사이에서 메시지 중복이 발생하면 이를 확인할 수 없다는 것이다.

예를 들어 10시 10분 ~ 10시 15분 구간에 A라는 메시지 ID를 적재했음을 가정하자.

그 구간 사이의 오류로 A를 재수신했고, 다음 구간에서 중복 검사를 수행한다면, 이는 미처리로 조회될 것이다.

 

 

이런 구간 사이 문제를 피하기 위해 수신 시간이 아닌, 메시지 고유 시간을 기준으로 구간을 정한다.

메시지 고유의 시간을 기준으로 한다면, 수신 시점에 상관없이 저장하고 확인할 구간이 동일히게 된다.

내 경우에는 이벤트 최초 발행된 시각을 기준으로 하여, 기준 사이의 오차 위험을 제거했다.

 

정리

이슈

- 카프카 Batch 수신 시, 레디스 중복 처리 문제와 레디스를 사용한 멱등성 보장

- 수신한 메시지 ID를 레디스에 String으로 저장하고 비교하는 것으로, 이미 처리한 메시지 여부 확인

- 각 메시지 ID가 레디스 Key로 저장되기에, 이벤트 파이프라인처럼 많은 메시지 보관 시 메모리 비효율적 사용

 

구간 적재

- Hash를 사용하여 레디스 Key 사용 최소화, O(1) 조회

- 시간을 기준으로 구간별 여러 메시지 ID를 한 Hash로, 레디스 키를 하나만 사용하여 저장

- 메시지 사용 33.3% 절감

 

성능 개선

- Hash 사용 시 HSETNX와 TTL 설정 쿼리, 레디스 요청 필요

- 매우 적은 레디스 사용 비용이지만, 이벤트 파이프라인처럼 이벤트가 많을 경우 유의미한 성능 차이 발생

- Lua 스크립트를 사용하여 앞선 두 가지 쿼리를 하나의 요청으로 처리

- 동일 성능 테스트

 

구간 사이 유실 

- 레디스 조회 및 적재 시점을 기준으로 커멘드를 처리하면 구간 사이에서 중복 처리에 노출 위험

- 메시지 별 고유 시간, 예를 들어 메시지 발생 시간을 기준으로 구간을 지정하는 것으로 매번 동일한 구간 확인 처리

- 구간 사이의 저장과 조회 시에도 중복 처리 노출 위험 제거

 

Comments