도입
프로젝트에서 @RedisHash를 사용하여 기능을 구현했다. Spring Data Redis의 @RedisHash는 자바 객체를 레디스의 Hash 데이터구조에 자동으로 매핑한다. 그리고 CrudRepository를 확장한 인터페이스를 사용하면, 데이터의 CRUD 작업을 쉽게 구현할 수 있다.
하지만 인덱싱을 위한 데이터는 만료시간이 지나도 삭제되지 않아 데이터가 계속 쌓이고 예상하지 못한 명령어가 발생한다. 예제를 통해 주의할 점을 자세히 알아보자.
@RedisHash 주의점
예제 코드
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@RedisHash(value = "member")
public class Member {
@Id
private Long id;
@Indexed
private String email;
@TimeToLive
private Long expiresAt;
private String name;
@Builder
public Member(Long id, String email, Long expiresAt, String name) {
this.id = id;
this.email = email;
this.expiresAt = expiresAt;
this.name = name;
}
}
이 객체를 CrudRepository를 확장한 인터페이스를 통해 레디스에 저장했다.
@DisplayName("멤버를 저장한다.")
@Test
void saveMember() {
// given
Member member = Member.builder()
.id(1L)
.email("abc@abc.com")
.expiresAt(100L)
.name("memberA")
.build();
memberRepository.save(member);
}
그리고 아래와 같이 네 개의 데이터가 레디스에 생성됐다.
Member 정보를 가지고 있는 것은 Hash 타입의 member:1 이다. HGETALL로 모든 필드를 확인해보면, 클래스 정보가 나타난다. 그리고 TTL은 100초로 설정되어 있다.
그 외 데이터(member, member:1:idx, member:email:abc@abc.com)는 Set 타입이다. @Id와 @Indexed 어노테이션이 붙은 필드에 대해선 Set 데이터가 생성된다. 레디스의 Hash 데이터는 특정 필드에 대한 검색이 제한적이다. 따라서 필드에 대한 Set 데이터를 생성해 이 데이터를 통해 필드 검색을 빠르게 수행한다.
하지만 인덱싱을 위해 생성된 Set 데이터는 TTL(Time-To-Live)은 -1로 만료시간이 없다. 그리고 레디스는 Set의 요소들에 대해 TTL을 지정할 수 없다. 따라서 Hash 데이터는 만료되어 삭제되었어도 Set 데이터 내에 키 값이 남아있다. 이렇게 불필요한 데이터가 계속 쌓이면 비싼 메모리 공간을 낭비한다.
그렇다면, 이 문제를 어떻게 해결할 수 있을까? Hash 데이터가 만료되면 스프링에서 Set의 요소를 삭제하는 명령어를 호출하면 된다.
레디스 설정 클래스에서 RedisKeyValueAdapter.EnableKeyspaceEvents.ON_DEMAND로 한다.
RedisKeyValueAdapter에서 put 메서드가 호출되면서 ON_DEMAND일 경우 initKeyExpirationListener를 호출한다.
그리고 KeyExpirationEventMessageListener에서 __keyevent@*__:expired를 구독한다.
그리고 저장할 때 phantom 이라는 suffix가 붙은 Member의 사본 데이터를 함께 저장한다. 이 사본 데이터의 TTL은 원본 데이터의 TTL보다 조금 더 길다. 스프링은 키 만료 이벤트를 수신받아 Set에서 만료된 키의 데이터를 삭제시킬 때 사본 데이터를 이용해 삭제한다.
코드보다 레디스에 어떤 데이터가 저장되는지, 상황에 따라 어떤 명령어가 실행되는지 확인하면 이해가 빠르다.
먼저, 레디스에 저장된 데이터는 아래와 같다. 사본 데이터인 member:1:phantom이 저장됐다.
그리고 monitor 명령어를 이용해 레디스에서 실행되는 명령어를 확인했다. 이를 통해 키 만료 이벤트가 발생하면 phantom suffix가 붙은 사본 데이터로 @Id와 @Indexed 정보를 가져온 뒤, Set 내 키를 삭제함을 알 수 있다.
127.0.0.1:6379> monitor
OK
# 이벤트 구독
"HELLO" "3"
"HELLO" "3"
"PSUBSCRIBE" "__keyevent@*__:expired"
# Member 저장
"DEL" "member:1"
"HMSET" "member:1" "_class" "com.spring.study.member.Member" "email" "abc@abc.com" "expiresAt" "100" "id" "1" "name" "memberA"
"SADD" "member" "1"
"EXPIRE" "member:1" "100"
"DEL" "member:1:phantom"
"HMSET" "member:1:phantom" "_class" "com.spring.study.member.Member" "email" "abc@abc.com" "expiresAt" "100" "id" "1" "name" "memberA"
"EXPIRE" "member:1:phantom" "400"
"SADD" "member:email:abc@abc.com" "1"
"SADD" "member:1:idx" "member:email:abc@abc.com"
"PUNSUBSCRIBE"
"PING"
# 키 만료되어 Set에서 삭제
"HGETALL" "member:1:phantom"
"DEL" "member:1:phantom"
"SREM" "member" "1"
"SMEMBERS" "member:1:idx"
"TYPE" "member:email:abc@abc.com"
"SREM" "member:email:abc@abc.com" "1"
"DEL" "member:1:idx"
레디스에서 실행되는 명령어를 보면 굉장히 많은 명령어가 실행되고 있음을 알 수 있다.
Member를 저장할 때 발생하는 명령어를 살펴보자. Id가 1번인 Member 데이터가 레디스 내에 존재하지 않음에도 불필요하게 DEL 명령어를 수행한 다음, HMSET으로 데이터를 저장하고 있다.
이렇게 레디스에 저장된 데이터를 애플리케이션에서 조회할 때 어떤 명령어를 호출하는지 살펴보자. 객체로 매핑하기 위해 Hash 데이터의 필드 값를 모두 가져오는 HGETALL 명령어가 실행된다. HGETALL은 필드 - 값 개수에 비례하여 실행 시간이 증가한다. 따라서 Hash의 크기가 커질 수록 주의해야 한다.
그렇다면 이렇게 의도하지 않은 불필요한 오버헤드를 어떻게 제거할 수 있을까? RedisTemplate로 변경하면 된다. RedisTemplate를 이용하면 CrudRepository와 달리 Low 레벨에서 레디스를 다루기 때문에 코드량이 증가하지만, 명령어가 예측 가능하여 안정적인 작업 수행이 가능하다.
처음엔 프로젝트에서 @RedisHash를 사용했지만, 이러한 주의점을 인지한 뒤엔 데이터를 String형으로 구현해도 괜찮았기 때문에 RedisTemplate으로 변경했다.
참고
'프로젝트 > 트러블슈팅' 카테고리의 다른 글
[트러블 슈팅] GitHub Actions를 활용해 슬랙봇으로 PR 자동화하기 (1) | 2024.10.13 |
---|---|
[트러블 슈팅] 인덱스 컨디션 푸시다운, 인덱스를 이용한 정렬, 커버링 인덱스로 슬로우 쿼리 튜닝하기 (1) | 2024.02.13 |
[트러블 슈팅] n + 1 문제를 IN절로 해결하기 (0) | 2024.02.12 |
[트러블 슈팅] 리팩토링을 통한 복잡했던 모듈 구조를 단순화 (0) | 2024.02.06 |
[트러블 슈팅] 레이어별 멀티 모듈 적용 (과도한 모듈 분리로 실패🤪) (0) | 2024.01.15 |