일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- prg 패턴
- 수신자 대상 다르게
- spring event
- branch 전략
- jwt 표준
- 백준
- 이분탐색
- 알람 시스템
- 레디스 동시성
- 트랜잭샨
- 좋은 코드 나쁜 코드
- 프로그래머스
- AWS
- BFS
- 코드 계약
- 구현
- 깊게 생각해보기
- 숫자 블록
- 셀러리
- 누적합
- gRPC
- 객체지향패러다임
- 결제서비스
- 쿠키
- 완전탐색
- piplining
- docker
- 디버깅
- 카카오
- 검색어 추천
- Today
- Total
코딩관계론
Redis 객체가 소멸될 때 DB에 영속화하자 - 꿀팁있음 본문
서론
서비스에 많은 트래픽이 발생하지 않는 경우, 사용자의 요청에 따라 DB에 데이터를 저장하고 불러오는 방식은 큰 문제가 되지 않습니다. 그러나 트래픽이 증가하면 DB에서 데이터를 읽고 수정한 후 다시 저장하는 과정에서 성능 저하가 발생할 수 있습니다.
따라서, 많은 트래픽을 처리해야 하는 시스템에서는 데이터를 중간 서버에 임시로 저장한 후, 일정 주기로 DB에 영속화하는 방식으로 성능을 최적화하는 것이 일반적입니다.
저는 Redis의 Keyspace Notifications 기능을 활용하여 이 방법을 구현했습니다. 이제 이 기능의 동작 원리와 사용법을 설명하고, 이를 사용해 구현하는 과정에서 발생한 문제와 그 해결 과정을 공유하겠습니다.
동작 방식
Redis의 Keyspace Notifications 구현 방식은 특정 이벤트(예: DEL)가 발생할 때 두 개의 명령어가 생성되는 것입니다. 하나는 키(mykey)에서 발생한 모든 이벤트(del)를 구독하는 형태이고, 다른 하나는 del 명령어에 의해 발생한 모든 이벤트를 구독하는 채널입니다.
키를 기반으로 구독하는 채널은 "Keyspace Notification"이라고 하며, 명령어를 기반으로 구독하는 채널은 "Keyevent Notification"이라고 불립니다.
PUBLISH __keyspace@0__:mykey del
PUBLISH __keyevent@0__:del mykey
Redis는 PUBLISH 명령어를 사용해 이벤트를 채널에 전송합니다. 이 명령어의 동작 속도는 O(N+M)입니다. 여기서 N은 해당 채널에 구독된 클라이언트 수, M은 패턴 구독의 총 수를 의미합니다. 이 속도는 이벤트가 발생할 때 Redis가 모든 패턴 구독(M)을 먼저 검사하고, 그다음 해당 채널에 구독된 모든 클라이언트(N)에게 메시지를 전송해야 하기 때문에 O(N+M)의 시간이 소모됩니다.
따라서 모든 채널을 구독하게 되면 성능상의 이슈가 발생할 수 있습니다.
사용 방법
이 기능을 사용하려면 먼저 Redis 설정 파일에서 채널 구독 기능을 활성화해야 합니다. 기본 설정에서는 어떤 값도 구독하지 않는데, 이는 채널을 구독함으로써 CPU 자원을 소모하여 Redis의 성능이 저하될 수 있기 때문입니다.
//redis-config
notify-keyspace-events ""
아래는 Redis가 지원하는 채널 이벤트 목록입니다. 중요한 점은 반드시 K 또는 E 중 하나를 포함해야 하며, 나머지는 사용자의 필요에 따라 조정할 수 있습니다.
예를 들어, 만료 이벤트를 모두 구독하고 싶다면 redis.conf 파일에 notify-keyspace-events Ex 명령어를 추가하면 됩니다.
K Keyspace events, published with __keyspace@<db>__ prefix.
E Keyevent events, published with __keyevent@<db>__ prefix.
g Generic commands (non-type specific) like DEL, EXPIRE, RENAME, ...
$ String commands
l List commands
s Set commands
h Hash commands
z Sorted set commands
t Stream commands
d Module key type events
x Expired events (events generated every time a key expires)
e Evicted events (events generated when a key is evicted for maxmemory)
m Key miss events (events generated when a key that doesn't exist is accessed)
n New key events (Note: not included in the 'A' class)
A Alias for "g$lshztxed", so that the "AKE" string means all the events except "m" and "n".
구현 방법
우선, DB에서 값을 불러와 Redis에 저장한 후, TTL(Time-To-Live)을 설정하여 Redis의 키가 만료되면 해당 데이터를 DB에 영속화하는 방식을 사용했습니다.
Redis에서는 TTL이 설정된 키를 만료시키는 두 가지 방식이 있습니다. 첫 번째는 키가 접근될 때 만료 여부를 확인하는 방식이고, 두 번째는 백그라운드 프로세스가 주기적으로 키를 검사하여 삭제하는 방식입니다. 두 가지 방식 모두 TTL이 0이 되는 순간에 만료 이벤트가 생성되는 것은 보장되지 않습니다.
그러나 이 문제는 치명적이지 않습니다. TTL이 만료되면 첫 번째 방식이나 두 번째 방식으로 항상 만료가 보장되기 때문입니다.
저는 아래와 같은 방식으로 만료된 키를RedisListenerService에서 감지하도록 했습니다:
public Object setupKeyExpirationListener(RedissonClient redissonClient, RedisListenerService service) {
RPatternTopic patternTopic = redissonClient.getPatternTopic("__keyevent@0__:expired", StringCodec.INSTANCE);
int i = patternTopic.addListener(String.class, service);
return null;
}
import java.util.Optional;
import org.redisson.api.listener.PatternMessageListener;
import org.springframework.stereotype.Service;
import com.bjcareer.stockservice.timeDeal.domain.event.Event;
import com.bjcareer.stockservice.timeDeal.repository.EventRepository;
import com.bjcareer.stockservice.timeDeal.repository.InMemoryEventRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Service
@RequiredArgsConstructor
@Slf4j
public class RedisListenerService implements PatternMessageListener<String> {
@Override
public void onMessage(CharSequence pattern, CharSequence channel, String key) {
log.info("Received message on pattern: {}, channel: {}, key: {}", pattern, channel, key);
//do-something
}
}
하지만 여기서 문제가 발생했습니다. 만료 이벤트가 감지되면 버킷에 저장된 내용들이 삭제되면서 DB에 영속화되지 않는 상황이 발생한 것입니다.
이 문제를 해결하기 위해, Redisson 라이브러리에서 SET 명령어를 사용하는 방식을 먼저 파악해야 했습니다. Redisson의 getBucket 메서드를 보면 RedissonBucket을 생성하고, 최종적으로 다음 명령어가 실행되는 것을 확인할 수 있습니다:
this.commandExecutor.writeAsync(this.getRawName(), RedisCommands.DEL_VOID, new Object[]{this.getRawName()}) : this.commandExecutor.writeAsync(this.getRawName(), this.codec, RedisCommands.PSETEX, new Object[]{this.getRawName(), timeUnit.toMillis(timeToLive), this.encode(value)});
Redisson 클래스를 상속받아 특정 함수를 재정의하거나, RedissonBucket을 상속받아 커스텀 클래스를 사용하는 방법이 있지만, Redisson 클래스는 final로 선언되어 있어 상속이 불가능했습니다:
public final class Redisson implements RedissonClient
그래서 저는 메모리를 조금 더 사용하지만 훨씬 간편하게 문제를 해결할 수 있는 방법으로 백업 버킷을 사용하는 방식으로 전환했습니다. 이 방법은 원본 데이터를 저장할 때 백업 데이터를 함께 저장하고, 원본 데이터의 TTL이 만료될 때 백업 데이터를 DB에 영속화한 후 Redis에서 삭제하는 방식으로 문제를 해결했습니다.
@Override
public void onMessage(CharSequence pattern, CharSequence channel, String key) {
log.info("Received message on pattern: {}, channel: {}, key: {}", pattern, channel, key);
memoryEventRepository.findBackupObject(backupKey).ifPresentOrElse(
event -> {
eventRepository.save(event);
memoryEventRepository.deleteKey(backupKey);
log.info("Successfully persisted and deleted backup for event: {}", event);
},
() -> log.warn("No backup found for key: {}", backupKey)
);
}
[꿀팁]
위 코드를 보면 deleteKey 메서드를 호출하면서 Redis의 DEL 명령어를 사용하여 키를 삭제하게 됩니다. DEL 명령어는 Redis 공식 문서에서 "SLOW"로 분류되며, 이는 N개의 키를 삭제하거나 복잡한 자료구조를 삭제할 때 시간 복잡도가 O(N) 또는 O(M)이 될 수 있기 때문입니다.
- O(N): 여기서 N은 삭제할 키의 수입니다.
- O(M): 삭제할 키가 리스트, 세트, 해시, 정렬된 세트와 같은 복잡한 자료구조를 가지고 있을 때, 해당 자료구조의 모든 요소를 제거하는 데 걸리는 시간에 비례합니다.
제가 사용하는 Event 객체는 Redis에 저장될 때 직렬화되어 하나의 문자열로 변환됩니다. 이 문자열은 Redis에서 단순한 키-값 쌍으로 저장되며, DEL 명령어로 삭제할 때 시간 복잡도는 O(1)이 됩니다. 이는 문자열 데이터는 Redis에서 삭제할 때 단일 메모리 블록을 해제하는 것과 동일하기 때문입니다.
직렬화된 객체(예: Event 객체)와 같이 Redis에 단순 문자열로 저장된 데이터는 DEL 명령어로 삭제할 때 O(1)의 시간 복잡도를 가집니다. 그러나, 복잡한 자료구조(리스트, 세트, 해시 등)를 삭제할 때는 해당 자료구조의 크기에 비례하여 O(M)의 시간이 걸릴 수 있으므로, 성능 영향을 고려해야 합니다.
따라서, 객체를 삭제할 때는 데이터가 어떻게 저장되었는지(단순 문자열인지, 복잡한 자료구조인지)를 파악하고, 그에 따라 DEL 명령어가 성능에 미치는 영향을 이해하는 것이 중요합니다.
결국에는 이 방법식에서 다른 방식으로 변경했는데 그 이유는 key들이 동시에 만료되면서 레디스에 부하를 주게 되었고, 레디스 죽었을 때, 어플리케이션이 죽었을 때의 상태처리가 힘들었기 때문입니다
[참고자료]
https://redis.io/docs/latest/commands/publish/
https://redis.io/docs/latest/develop/use/keyspace-notifications/
https://javadoc.io/doc/org.redisson/redisson/latest/index.html
https://redis.io/docs/latest/commands/del/
'개발' 카테고리의 다른 글
레디스 동시성 개선 및 성능 향상 포인트 (0) | 2024.08.25 |
---|---|
TPS 2에서 TPS 10,000까지의 험난한 과정 (0) | 2024.08.22 |
검색어 추천 서비스 V4(Sharding) (0) | 2024.08.13 |
CAP 이론 - Consistency (일관성), Availability (가용성), Partition Tolerance (파티션 감내) (0) | 2024.08.13 |
검색어 추천 서비스 V3(Redis + NoSQL) (0) | 2024.08.12 |