이전 게시글에서 SpringBoot에 Redis를 적용하는 방법(링크)에 대해서 정리한 적이 있었다. 이슈 링크에서 내가 해보려는 것은 기존에 적용했던 redisTemplate를 이용하는 방법이 아니라 아직 해보지 않은 @RedisHash를 이용해 별도의 클래스를 설정하고 레디스 캐시가 필요한 곳에 대해서 @Cacheable 어노테이션을 적용하는 것이다.
전자의 경우를 진행하면서 겪은 상황을 기록하고자 한다.
기존에 이 프로젝트에서는 MySQL로 로그인 시 발급받은 RefreshToken을 저장하고 있었으며 기존 코드는 아래와 같다.
package com.sejong.sejongpeer.domain.auth.entity;
import com.sejong.sejongpeer.domain.member.entity.Member;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.MapsId;
import jakarta.persistence.OneToOne;
import lombok.Builder;
import lombok.NoArgsConstructor;
@Entity
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
public class RefreshToken {
@Id
@Column(columnDefinition = "char(36)")
private String memberId;
@MapsId
@OneToOne(fetch = FetchType.LAZY)
private Member member;
@Column(nullable = false)
private String token;
@Builder
private RefreshToken(Member member, String token) {
this.member = member;
this.token = token;
}
public void renewToken(String token) {
this.token = token;
}
}
이 링크에 왜 Mysql이 아니라 Redis에 토큰을 저장하는 것이 좋은지 정리를 해두었던 적이 있다.
성능 개선을 위해서 인메모리 방식의 db로 관리하게 되면서 레디스 템플릿이 정말 구현상 간단하지만 @RedisHash를 도입해보고자 했고 이 과정에서 겪게된 트러블은 아래와 같았다.
문제 상황 및 Redis에 RefreshToken을 저장할 때 주의할 점
1. 위의 RefreshToken 엔티티 클래스를 아래와 같이 바꾸었었다. (문제있는 코드)
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.index.Indexed;
import lombok.Builder;
import lombok.NoArgsConstructor;
@Entity
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
@RedisHash(value = "refreshToken", timeToLive = 259200000L / 1000)
public class RefreshToken {
@Id
private String memberId;
@Indexed
private String token;
@Builder
private RefreshToken(String memberId, String token) {
this.memberId = memberId;
this.token = token;
}
public void renewToken(String token) {
this.token = token;
}
}
2. 이어서 바꾼 RefreshTokenRepository는 아래와 같았다. (문제 없는 코드)
기존 JpaRepository를 extends하는 것이 아니라 CrudRepository로 바꾸었다. 그리고 findBy 메소드는 @RedisHash를 붙인 클래스에 정의한 필드 중 @Id 어노테이션을 붙인 필드였다.
package com.sejong.sejongpeer.domain.auth.repository;
import java.util.Optional;
import com.sejong.sejongpeer.domain.auth.entity.RefreshToken;
import org.springframework.data.repository.CrudRepository;
public interface RefreshTokenRepository extends CrudRepository<RefreshToken, String> {
Optional<RefreshToken> findByMemberId(String memberId);
}
하지만 애플리케이션을 실행하면 아래와 같은 에러가 뜬다. 결론적으로 Redis와JPA Repository가 충돌해서 발생한 문제였다.
Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled.
2024-08-13T21:44:19.637+09:00 ERROR 23300 --- [ main] o.s.b.d.LoggingFailureAnalysisReporter :
***************************
APPLICATION FAILED TO START
***************************
Description:
The bean 'refreshTokenRepository', defined in com.sejong.sejongpeer.domain.auth.repository.RefreshTokenRepository defined in @EnableRedisRepositories declared on RedisRepositoriesRegistrar.EnableRedisRepositoriesConfiguration, could not be registered. A bean with that name has already been defined in com.sejong.sejongpeer.domain.auth.repository.RefreshTokenRepository defined in @EnableJpaRepositories declared on JpaRepositoriesRegistrar.EnableJpaRepositoriesConfiguration and overriding is disabled.
Action:
Consider renaming one of the beans or enabling overriding by setting spring.main.allow-bean-definition-overriding=true
Process finished with exit code 1
삽질 내역
왜 JpaRepository와 CrudRepository가 충돌할까에 대한 의문을 해결하기 위해 아래와 같은 작업을 했다.
1. interface 분리 확인
로그인 Auth 관련 도메인에서 Service layer에 의존성을 주입받는 repository는 MemberRepository와 RefreshTokenRepository이다. 이 두 인터페이스는 현재 서로 다른 패키지 경로에 분리가 되어있으므로 이에 대해서는 문제가 없었다.
2. 오류 내역에 보이는 Bean Definition Overriding 설정 추가?
아래와 같이 application.yml 파일에 추가를 할 수도 있었지만 나중 개발 환경에 따라서 예측 불가능한 실행 오류가 생길 것을 염려하여 지양하였다.
spring:
main:
allow-bean-definition-overriding: true
3. JpaConfig 추가 및 RedisConfig 수정
에러 내역에 추천해주는 어노테이션을 적용하고자 아래와 같이 Jpa를 적용해야 하는 패키지를 명시하고
package com.sejong.sejongpeer.global.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@Configuration
@EnableJpaRepositories(basePackages = "com.sejong.sejongpeer.domain.member.repository")
public class JpaConfig {
}
RedisConfig에 레디스를 적용해야 할 RefreshTokenRepository가 위치한 패키지 경로를 @EnableRedisRepositories를 선언하였으나
@Configuration
@EnableRedisRepositories(basePackages = "com.sejong.sejongpeer.domain.auth.repository") // 추가
public class RedisConfig {
@Value("${spring.data.redis.port}")
public int port;
@Value("${spring.data.redis.host}")
public String host;
@Autowired
public ObjectMapper objectMapper;
// 이하 생략
부질없었다ㅋㅋㅜㅠ 그 이유는 아레 에러가 비로소 알려준다.
3번 과정을 거치고 서버를 실행해보니 이제는 다른 결정적인 문제 원인을 알려주는 구체적인 에러가 떴고 비로소 정확히 어디가 문제였는지 파악할 수 있었다.
Entity com.sejong.sejongpeer.domain.auth.entity.RefreshToken requires to have an explicit id field; Did you forget to provide one using @Id
RefreshToken class가 갖고 있던 문제점
jpa와 redis import 및 annotation 혼재
JPA는 관계형 데이터베이스를 위한 것이고 Redis는 NoSQL이므로 이 둘을 분리 및 관리하는 import는 달랐어야했는데 처음에 바꾼 코드에는 RDB와NoSQL 설정이 섞여 있어서 문제였다.
1. @Id 어노테이션의 경우 문제있는 코드에서 불러왔던 import는 jakarta.persistence.Id이며 실제 redis를 위해서는 의존성에 추가한대로 data의 org.springframework.data.annotation.Id를 사용해야 했다. (이전에 application/json request 요청 인식 못하는 이유가 swagger랑 springframwork.web.bind가 같은 @RequestBody 어노테이션을 지원하는데 swagger import 써가지고 삽질한 경험도 같이 떠오른다... 상황만 다르지 같은 문제 핵심 포인트를 가지고 있다... import 주의...)
2. @Entity의 경우도 JPA 엔티티를 나타내며 RDB 어노테이션이고 @RedisHash와 같이 쓸 경우 충돌이 날 수 밖에 없었던 상황이었음을 깨달았다.
해결 방법 : JPA와 Redis를 분리
아래와 같이 Redis 관련 import와 annotation만을 사용하여 JPA와의 충돌 오류를 해결하였다.
package com.sejong.sejongpeer.domain.auth.entity;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.index.Indexed;
@Getter
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
@RedisHash(value = "refreshToken", timeToLive = 259200000L / 1000)
public class RefreshToken {
@Id
private String memberId;
@Indexed
private String token;
@Builder
private RefreshToken(String memberId, String token) {
this.memberId = memberId;
this.token = token;
}
public void renewToken(String token) {
this.token = token;
}
}
이제 기존의 refreshToken 관련 JPA Repository 대신 Redis Repository를 사용하여 자유롭게 findBy 메소드를 정의해 로그인 시 리프레시 토큰을 저장하는 로직으로 리펙토링 할 수 있었다. CrudRepository를 통해 JPA Repository와 거의 동일하게 작동하는 이점이 있다.
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
첫번째 줄은 jpa, 둘째줄은 redis이고 이 둘이 제공하는 import를 섞어서 entity를 구성할 경우 jpa repository와 crud repository bean 충돌이 일어나므로 주의해야 한다. 배운점...
그리고 @RedisHash, CrudRepository vs RedisTemplate 중에서 후자를 해봤었으니까 전자를 안해봤을 때는 어렵게 느껴졌는데 전자가 더 편한 것 같다는 생각이 들었다. 역시 뭐든지 직접 해봐야 함!!!
refreshToken을 redis에 저장하는 건은 해결했으니!!
이미 redis를 이용한 CacheConfig까지 구현을 해둔 상황이라
이제 @Cacheable를 적용해볼일만 남았다~
'SpringBoot > 트러블 슈팅' 카테고리의 다른 글
[Spring] 양방향 맵핑 순환 참조 문제 해결 (0) | 2025.04.05 |
---|---|
[Spring] JPA LazyInitializationException 발생 원인 및 해결 방법 (0) | 2024.08.07 |
[Spring] @RequestBody json DTO null 트러블 슈팅 (0) | 2024.05.22 |
[Spring] @EnableJpaAuditing과 createdAt의 연결고리 (0) | 2024.05.08 |
[Spring] kotlin + security + jwt 구현 과정 트러블 슈팅 (0) | 2024.04.28 |