멱등성 보장 : 저장 구조 변경으로 레디스 메모리 사용률 33% 개선 본문
배경
알림 서비스 이야기이다.
카프카로부터 메시지를 수신하고 이를 사용자에게 전달하는 서비스를 운영하고 있다.
네트워크 통신 비용 절감을 위해 카프카에서 메시지를 배치 수신, 그룹 단위로 처리한다.
그룹 단위 처리 중 예외가 발생하면 이미 처리한 내용을 잊기에 복구 시 메시지를 중복 수신하게 된다.
이에 수신한 메시지 ID를 레디스에 기록하고, 처리 전 비교하는 것으로 이전에 수신했던 메시지 여부를 검증한다.
간단한 메시지 ID 저장 구조 변경으로, 레디스 메모리 사용률을 33% 개선한 경험을 소개한다.
String vs Hash
기존 구조는 수신한 각 메시지 ID를 레디스 String 타입으로 저장했다.
양이 많고, 짧은 보관 기간의 데이터이기에, 각 메시지 ID를 레디스 Key로 하여 TTL을 명확하게 지정했다.
대신 각 데이터마다 메타 데이터가 필요하여 메모리 사용량이 많다.
레디스 Hash는 해시테이블 하나를 레디스 Key로 하고, 그 안에서 Key:Value 구조로 저장하는 구조이다.
레디스 Key를 하나만 사용하기에 필요한 메타데이터를 최소화하고, Hash 내부에서 키 검색도 O(1)이면 된다.
대신 해시 테이블 자체를 하나의 레디스 Key로 하여, 메시지 ID 각각의 TTL 설정은 불가능하다.
구간 기록
알림 서비스에선 이벤트 양이 많다.
레디스 Key를 줄이는 것만으로도 메모리 절감에 효과적일 것이라 생각했다.
모든 메시지 ID를 String 저장이 아닌, '일정 구간 동안의 메시지를 한 해시테이블로 담자'가 그 개선 아이디어였다.
예를 들어 TTL이 10분이라고 하면, 10시 10분부터 10시 15분까지의 메시지 ID를 담는 테이블을 만들어 적재한다.
수신한 메시지를 구간에 따라 해시테이블 내부에서 비교하는것으로 중복 여부를 확인할 수 있다.

아래는 기존 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만 개의 메시지가 쌓일 것이다.
360만 개의 메시지 ID를 기존 구조와 개선 구조로 기록하고 각각의 사용 메모리를 비교했다.
기존 구조에선 레디스 키 360만 개가 생성, 약 390MB의 메모리가 사용되었고,
개선 구조에선 레디스 키가 2개 생성, 약 260MB의 메모리가 사용되었다.
간단한 구조 개선만으로 약 약 33.3%가 개선된 것이다.
지금보다 더 큰 규모의 서비스에선 더 많은 메모리 변화가 있을 것이다.

HASH 타입의 두 가지 데이터 저장 방법
레디스 Hash 타입에서 데이터를 저장하는 방식은 두 가지이다.
'HashTable'는 익히 아는 Key:Value 방법이고, 'Listpack' 방식은 {Key:Value} 자체를 연속된 메모리에 저장하는 식이다.
HashTable 방식은 O(1)의 탐색 비용이 들지만, Listpack은 순차탐색(O(N))이 필요하다.
대신 Listpack 방식은 HashTable 방식보다 훨씬 더 적은 메모리를 사용한다.
그렇기에 Listpack은 메모리도 적으면서도 O(N)의 비용이 적을 때, 즉 탐색할 엔트리가 적을 때 유리하다.
레디스 Hash는 512 개 이하의 엔트리에선 Listpack으로, 그 이상은 HashTable를 사용한다.
이 기준 값은 설정 가능하다.
엔트리가 늘어 Listpack에서 HashTable로 변경된 데이터는 다시 돌아가지 않는다.
"OBJECT ENCODING $REDIS_KEY_NAME" 을 커멘드로 사용하여 어떤 방법으로 저장하는지 확인할 수 있다.
아쉽지만 512개를 훌쩍 넘은 데이터를 저장하는 내 경우엔, Listpack의 공간 효율적인 메모리 관리는 불가능했다.

성능 개선과 Lua 스크립트
앞서 구간별 기록에 레디스 키를 위한 계산하고, 매번 Hash에 TTL을 설정하는 두 커멘드를 전달했다.
현재 구간의 해시 테이블이 존재하는지 여부를 확인할 수 없어 매번 해당 레디스 키에 TTL을 지정하는 것이다.
요청량이 많지 않은 경우에선 가시적인 영향을 주지 않겠지만, 요청량이 많은 경우는 다르다.
레디스를 한번 더 요청하는 것만으로도 큰 성능 저하가 보였다.
기존의 방식으로 레디스를 한 번만 요청할 때는 10초 동안 14만 개의 메시지를 처리할 수 있었고,
개선 방식으로 HASH를 사용해 두 번의 커멘드를 요청할 때는 10초 동안 11만 개의 메시지를 처리할 수 있음을 테스트했다.
이에 아래와 같이 Lua 스크립트를 사용하여 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 스크립트를 사용하여 앞선 두 가지 쿼리를 하나의 요청으로 처리
- 동일 성능 테스트
구간 사이 유실
- 레디스 조회 및 적재 시점을 기준으로 커멘드를 처리하면 구간 사이에서 중복 처리에 노출 위험
- 메시지 별 고유 시간, 예를 들어 메시지 발생 시간을 기준으로 구간을 지정하는 것으로 매번 동일한 구간 확인 처리
- 구간 사이의 저장과 조회 시에도 중복 처리 노출 위험 제거
'Architecture > Application' 카테고리의 다른 글
| 카프카 사용 전략 : 수신부의 재시도, 복구 정책 고민 (1) | 2025.12.24 |
|---|---|
| 데이터 적재 처리량 개선 : 단건 처리에서 배치 처리로 (0) | 2025.10.30 |
| OOM 문제 해결 : 스레드 풀, 요청량이 처리량을 넘어설 때 (2) | 2025.10.09 |
| 논 블록킹 처리 : 이벤트 처리량, 스레드 사용 효율 개선 경험 (0) | 2025.09.04 |
| 기기 이벤트 파이프라인 처리량 개선 : 캐시 처리 (1) | 2025.08.28 |