본문 바로가기
SpringBoot/트러블 슈팅

[Spring] JPA와 Redis 충돌 및 @RedisHash 간과한 부분

by rla124 2024. 8. 13.

이전 게시글에서 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와 거의 동일하게 작동하는 이점이 있다. 

 

Redis에 refreshToken이 @RedisHash에서 정의한 클래스의 필드에 맞게 저장이 되었다.


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를 적용해볼일만 남았다~