[Spring Data Redis] Redis repository에서 existBy로 올바르게 값이 검색되지 않는 문제 해결

2024. 11. 7. 16:14·Java

배경

서버에서 refresh token을 저장하는 DB를 mySql에서 Redis로 변경하면서 기존에 Redis를 사용하고 있는 방식대로 Spring Data Redis Repository를 적용하게 되었습니다.

 

토큰 재발급 요청이 들어오는 경우 Redis에 해당 refresh token이 존재하는지 검증이 필요하기 때문에, 레포지토리에 다음과 같이 `existsByToken` 메서드를 정의해 두었습니다.

@Repository
public interface RefreshTokenRepository extends KeyValueRepository<RefreshToken, String> {
    boolean existsByToken(String token);
}
@RedisHash(value = "refresh", timeToLive = 3600L)
public class RefreshToken {
    @Id
    private String token;
    private Long userId;
    private String expiration;
}

문제 상황

문제없이 잘 작동하리라는 예상과 달리, 분명히 DB 내부에 토큰이 저장되어 있는데도 해당 `existsByToken`의 반환값이 계속해서 `false`가 나오는 상황이 발생했습니다 🤯

원인 파악

원인 파악을 위해 Redis 내부 로그를 모니터링해보았는데요. 우선 토큰 존재 여부를 확인하는 상황이 아닌, 처음에 토큰을 저장하는 상황을 먼저 확인해 보겠습니다.

 

현재 토큰을 저장하는 경우에는 커스텀 쿼리 메서드가 아닌, `Repository` 인터페이스 구현체에서 디폴트로 제공하는 `save()` 메서드로 저장하고 있습니다.

refreshTokenRepository.save(token);

그러면 쿼리가 다음과 같이 실행되며, hash에 값이 저장됩니다.

1729238594.198590 [0 192.168.65.1:31310] "DEL" "refresh:eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJST0xFX1VTRVIiLCJ0b2tlblR5cGUiOiJSRUZSRVNIIiwiaWF0IjoxNzI5MjM4NTkzLCJleHAiOjE3Mjk4NDMzOTN9.uK2UP-JLjBRseKk8Orno60YSoNLtGIckS3_5RNXjmzo"
1729238594.201177 [0 192.168.65.1:31310] "HMSET" "refresh:eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJST0xFX1VTRVIiLCJ0b2tlblR5cGUiOiJSRUZSRVNIIiwiaWF0IjoxNzI5MjM4NTkzLCJleHAiOjE3Mjk4NDMzOTN9.uK2UP-JLjBRseKk8Orno60YSoNLtGIckS3_5RNXjmzo" "_class" "buddyguard.mybuddyguard.jwt.entity.RefreshToken" "expiration" "Fri Oct 25 17:03:13 KST 2024" "token" "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJST0xFX1VTRVIiLCJ0b2tlblR5cGUiOiJSRUZSRVNIIiwiaWF0IjoxNzI5MjM4NTkzLCJleHAiOjE3Mjk4NDMzOTN9.uK2UP-JLjBRseKk8Orno60YSoNLtGIckS3_5RNXjmzo" "userId" "1"
1729238594.202504 [0 192.168.65.1:31310] "SADD" "refresh" "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJST0xFX1VTRVIiLCJ0b2tlblR5cGUiOiJSRUZSRVNIIiwiaWF0IjoxNzI5MjM4NTkzLCJleHAiOjE3Mjk4NDMzOTN9.uK2UP-JLjBRseKk8Orno60YSoNLtGIckS3_5RNXjmzo"
1729238594.203385 [0 192.168.65.1:31310] "EXPIRE" "refresh:eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJST0xFX1VTRVIiLCJ0b2tlblR5cGUiOiJSRUZSRVNIIiwiaWF0IjoxNzI5MjM4NTkzLCJleHAiOjE3Mjk4NDMzOTN9.uK2UP-JLjBRseKk8Orno60YSoNLtGIckS3_5RNXjmzo" "3600"

보면 HMSET 명령어를 통해 객체를 저장하는데 이때 `@RedisHash` 에 설정해 둔 값이 추가되어 쿼리가 만들어지기 때문에, 키 값(토큰 값) 앞에 `refresh:`라는 네임스페이스 값이 추가되고 있습니다.

 

그렇기 때문에 우리가 검색할 때 찾아야 하는 키도 id로 설정한 토큰값이 아니라, 앞에 `refresh:` 까지 붙인 `refresh:eyJhbGciOiJIUzI1NiJ~` 이 됩니다. 이 저장된 키 값 검색은 직접 Redis 내부에서 EXISTS 명령으로도 확인할 수 있습니다.

refresh: 를 붙여서 검색하는 경우 존재, 그렇지 않은 경우 존재하지 않음

그러면 다음으로 토큰을 검색하기 위해 쿼리 메서드인 `existsByToken`를 호출하게 되면 어떤 쿼리가 발생하는지도 확인해 보겠습니다.

현재 상황에서 정상적으로 검색을 할 수 있으려면, 쿼리 앞에 `refresh:` 가 붙어야 할 것으로 보입니다.

refresh:token: 이라는 네임스페이스 값이 붙어 있다

주어진 모든 set의 교집합 값을 반환하는 명령어인 SINTER가 사용되었는데, 키 값을 보면 예상과 달리 `refresh:` 뒤에 `token:`이라는 네임스페이스가 추가되어 있습니다.

앞서 언급했던 것처럼 이렇게 되면 스프링은 `refresh:token:토큰값`이라는 키로 값을 확인하고 있다는 것인데, 우리가 처음에 저장한 값은 `refresh:토큰값` 이므로 당연히 일치하는 값을 찾을 수 없게 됩니다.

따라서 이것이 바로 `save`한 값을 `existsByToken`로 검색을 시도했을 때 false 값이 나오는 이유였습니다.

 

그렇다면 갑자기 검색 쿼리에 왜 `token:` 값이 붙은 걸까요?

지금 상황과 같이 커스텀 쿼리 메서드를 사용하는 경우, Spring Data Redis는 쿼리문을 만들 때 메서드 이름에서 해당 필드를 추출해서 키 값의 맨 앞에 붙이게 됩니다. 따라서 `existsByToken`라는 메서드 이름 때문에 token 필드가 추출되어 쿼리에 추가가 된 것입니다.

 

이와 관련된 내부 코드를 살펴보면 쿼리를 날리기 전에 `RedisQueryEngine` 클래스 내부에서 `count` 메서드가 쿼리의 맨 앞에 추출된 필드의 키스페이스 값을 추가하는데요. 여기에 `refresh:`값이 기본으로 붙기 때문에 최종적으로 `refresh:token:토큰값` 형태로 쿼리가 만들어지게 됩니다.

public long count(RedisOperationChain criteria, String keyspace) {

	if (criteria == null || criteria.isEmpty()) {
		return this.getRequiredAdapter().count(keyspace);
	}

	return this.getRequiredAdapter().execute(connection -> {

		long result = 0;

		if (!criteria.getOrSismember().isEmpty()) {
			result += connection.sUnion(keys(keyspace + ":", criteria.getOrSismember())).size();
		}

		if (!criteria.getSismember().isEmpty()) {
			result += connection.sInter(keys(keyspace + ":", criteria.getSismember())).size();
		}

		return result;
	});
}

어떤 의미가 있나요?

처음 위와 같은 내용을 알았을 때, 왜 굳이 이런 식으로 쿼리가 생성되는지에 대해 의문이 생겼습니다. 커스텀 쿼리를 만들 때 이렇게 필드 값을 가져와서 사용하는 것에 어떠한 의미가 있을까요? 저는 그 의미에 대해 다음과 같이 생각해 보았습니다.

 

어떤 필드를 대상으로 하는 쿼리 메서드를 사용한다는 것의 의의는 해당 필드에 인덱스가 걸려 있어서 빠르게 해당 값을 찾아서 가져올 수 있다는 것에 있다고 볼 수 있습니다. (물론 경우에 따라 다르지만, 일반적으로 빠른 검색을 위해 인덱스를 사용하니까요)

 

이해를 돕기 위해 검색을 위한 세컨더리 인덱스를 사용하는 상황을 한 번 만들어 보겠습니다.

 

Redis에서도 Id 어노테이션을 붙이는 기본 키 이외에 Index를 이용해서 보조 인덱스를 생성할 수 있는데요. 예를 들면 토큰 값이 아니라 유저 id를 가지고서도 refresh token을 검색하고 싶은 경우 다음과 같이 할 수 있습니다.

@RedisHash(value = "refresh", timeToLive = 3600L)
public class RefreshToken {
    @Id
    private String token;
    @Indexed // 세컨더리 인덱스 생성
    private Long userId;
    private String expiration;
}
1729254832.375867 [0 192.168.65.1:26673] "DEL" "refresh:eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJST0xFX1VTRVIiLCJ0b2tlblR5cGUiOiJSRUZSRVNIIiwiaWF0IjoxNzI5MjU0ODMyLCJleHAiOjE3Mjk4NTk2MzJ9.OhpcDzEDdrcI_nTES9zUDkjqeWOLeTkR6dfHufTXFws"
1729254832.379311 [0 192.168.65.1:26673] "HMSET" "refresh:eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJST0xFX1VTRVIiLCJ0b2tlblR5cGUiOiJSRUZSRVNIIiwiaWF0IjoxNzI5MjU0ODMyLCJleHAiOjE3Mjk4NTk2MzJ9.OhpcDzEDdrcI_nTES9zUDkjqeWOLeTkR6dfHufTXFws" "_class" "buddyguard.mybuddyguard.jwt.entity.RefreshToken" "expiration" "Fri Oct 25 21:33:52 KST 2024" "token" "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJST0xFX1VTRVIiLCJ0b2tlblR5cGUiOiJSRUZSRVNIIiwiaWF0IjoxNzI5MjU0ODMyLCJleHAiOjE3Mjk4NTk2MzJ9.OhpcDzEDdrcI_nTES9zUDkjqeWOLeTkR6dfHufTXFws" "userId" "1"
1729254832.380361 [0 192.168.65.1:26673] "SADD" "refresh" "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJST0xFX1VTRVIiLCJ0b2tlblR5cGUiOiJSRUZSRVNIIiwiaWF0IjoxNzI5MjU0ODMyLCJleHAiOjE3Mjk4NTk2MzJ9.OhpcDzEDdrcI_nTES9zUDkjqeWOLeTkR6dfHufTXFws"
1729254832.381289 [0 192.168.65.1:26673] "EXPIRE" "refresh:eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJST0xFX1VTRVIiLCJ0b2tlblR5cGUiOiJSRUZSRVNIIiwiaWF0IjoxNzI5MjU0ODMyLCJleHAiOjE3Mjk4NTk2MzJ9.OhpcDzEDdrcI_nTES9zUDkjqeWOLeTkR6dfHufTXFws" "3600"
1729254832.382699 [0 192.168.65.1:26673] "SADD" "refresh:userId:1" "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJST0xFX1VTRVIiLCJ0b2tlblR5cGUiOiJSRUZSRVNIIiwiaWF0IjoxNzI5MjU0ODMyLCJleHAiOjE3Mjk4NTk2MzJ9.OhpcDzEDdrcI_nTES9zUDkjqeWOLeTkR6dfHufTXFws"
1729254832.383243 [0 192.168.65.1:26673] "SADD" "refresh:eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJST0xFX1VTRVIiLCJ0b2tlblR5cGUiOiJSRUZSRVNIIiwiaWF0IjoxNzI5MjU0ODMyLCJleHAiOjE3Mjk4NTk2MzJ9.OhpcDzEDdrcI_nTES9zUDkjqeWOLeTkR6dfHufTXFws:idx" "refresh:userId:1"

이렇게 엔티티를 변경한 후 저장 쿼리를 확인해 보면, SADD 명령어를 통해 추가적인 키들이 set에 저장됩니다.

 

`refresh:userId:1`을 통해 userId가 1인 경우 검색을 빠르게 할 수 있도록 세컨더리 인덱스가 생성되었음을 볼 수 있는데요. 이 상태에서 쿼리 메서드 `existsByUserId` 로 1번 유저를 검색한 쿼리를 보겠습니다.

refresh:userId:1 로 검색된다

역시 앞선 예제와 동일하게 userId 필드를 추출해서 쿼리가 만들어지기 때문에, `refresh:userId:1`로 검색을 시도하는 것을 확인할 수 있습니다. 그리고 이 값은 세컨더리 인덱스를 설정한 엔티티를 저장할 때 만들어진 쿼리 형태와 완전히 똑같다는 것을 알 수 있습니다. 따라서 정상적으로 검색도 잘 될 것이고요.

 

즉, Spring Data Redis는 커스텀 쿼리 메서드에서 By~뒤의 해당 필드 값을 인덱스로 사용하고 있을 것임을 고려해서 쿼리를 이런 식으로 자동 생성한다는 것입니다.

 

Spring Date Redis에서의 세컨더리 인덱스 관련된 더 상세한 내용은 공식 문서를 참고하세요.

 

Secondary Indexes :: Spring Data Redis

Assume the Address type contains a location property of type Point that holds the geo coordinates of the particular address. By annotating the property with @GeoIndexed, Spring Data Redis adds those values by using Redis GEO commands, as shown in the follo

docs.spring.io

해결

다시 처음 겪었던 문제로 돌아와 보겠습니다.

저장된 키값(refresh:)과 검색하려는 키값(refresh:token:)이 다르기 때문에 생기는 문제였기 때문에, 키값을 맞춰 주면 되는데요.

 

기본 제공되는 메서드(save, findById, deleteById 등등)들은 쿼리가 만들어질 때 `@RedisHash`값만 추가되고, 키스페이스 값이 추가적으로 붙지 않기 때문에 다음과 같이 `refresh:`로 검색이 가능합니다.

existsById를 이용한 검색 시 쿼리

따라서 저는 쿼리 메서드를 따로 만들어 사용하지 않고, 기본적으로 제공되는 메서드인 `existsById`를 사용해서 정상적으로 검색이 되도록 문제를 해결했습니다!

정리

이번에는 spring date redis Repository에서 실제 쿼리를 어떻게 날리는지에 대해 이해가 부족한 상황에서 코드를 작성하게 되어 문제가 발생하게 되었는데요.

 

Repository 인터페이스 구현체들에서 기본 제공하는 메서드(save, findById, deleteById)들이 아닌 사용자의 커스텀 쿼리 메서드로 인해 쿼리 생성이 되는 경우에는, By~ 뒤에 붙는 검색하고자 하는 키값의 키스페이스가 한번 더 붙는 방식으로 쿼리가 생성된다는 점을 기억하고 사용해야 하겠습니다.

'Java' 카테고리의 다른 글

[Java Spring] 초대 링크를 통한 가입 로직 트러블슈팅 (1) : Redis 동시성 문제 해결  (1) 2025.01.28
[Spring Data Redis] 불필요한 Redis 메모리 점유 현상 개선하기 : Spring Data Redis에서 TTL 미적용 문제 발생  (2) 2025.01.15
[Java Spring] Swagger에서 multipart/form-data 테스트 시 Content-Type 'application/octet-stream' is not supported 예외 발생  (1) 2024.11.06
[Spring Security] anyRequest().permitAll() 설정 변경 후, login 요청 시 404 예외 발생 문제  (1) 2024.10.15
스레드 풀 사용시 vs 직접 스레드 사용시 시스템 리소스 비교해보기  (1) 2024.08.05
'Java' 카테고리의 다른 글
  • [Java Spring] 초대 링크를 통한 가입 로직 트러블슈팅 (1) : Redis 동시성 문제 해결
  • [Spring Data Redis] 불필요한 Redis 메모리 점유 현상 개선하기 : Spring Data Redis에서 TTL 미적용 문제 발생
  • [Java Spring] Swagger에서 multipart/form-data 테스트 시 Content-Type 'application/octet-stream' is not supported 예외 발생
  • [Spring Security] anyRequest().permitAll() 설정 변경 후, login 요청 시 404 예외 발생 문제
seondays
seondays
  • seondays
    Maybe seondays
    seondays
  • 전체
    오늘
    어제
    • 분류 전체보기 (38)
      • python (5)
      • Java (16)
      • Dart (0)
      • 문제정리 (13)
      • etc (4)
  • 태그

    트러블슈팅
    Python
    buddyguard
  • 인기 글

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
seondays
[Spring Data Redis] Redis repository에서 existBy로 올바르게 값이 검색되지 않는 문제 해결
상단으로

티스토리툴바