개인 프로젝트 중 JWT RT부분을 Redis를 활용해 성능 개선을 해보고자 합니다.
다른 블로그들 보면 Redis 의존성만 가져와 상세 설정을 하지만, Redis와 cache의존성을 같이 사용하여 보다 간단하게 구현 해보고자 합니다.
String bearerToken = request.getHeader("Authorization");
String refreshToken = request.getHeader("RefreshToken");
if (bearerToken == null && refreshToken == null) {
throw new AuthenticationException("AT와 RT 둘 중 하나는 헤더에 포함시켜주세요");
}
// AT가 유효하다면 인증처리
if (bearerToken != null && isTokenValid(bearerToken.substring("Bearer ".length()))) {
forceAuthentication(getMemberFromToken(bearerToken.substring("Bearer ".length())));
filterChain.doFilter(request, response);
return; // 다음 필터로 전달되어도 이 메서드는 종료되지 않고 계속 실행하므로 명시적 종료
}
// AT유효하지 않은데, RT가 없다면
if (refreshToken == null) {
throw new AuthenticationException("만료된 AT입니다. RT를 포함시켜주세요.");
}
// RT 유효한지 검사
if (isTokenValid(refreshToken)) {
Member member = getMemberFromToken(refreshToken);
// 사용자 DB에 저장된 RT와 같다면
if (member.getRefreshToken().equals(refreshToken)) {
// 1일짜리 AT 재발생해서 반환
Map<String, Object> newMemberClaims = member.toClaims();
String newAccessToken = jwtProvider.genToken(newMemberClaims, 60 * 60 * 24 * 1);
response.addHeader("Authorization", "Bearer " + newAccessToken);
// 인증 처리
forceAuthentication(member);
}
// 다르다면 변경된 RT이므로 재 로그인
else {
throw new AuthenticationException("유효하지 않은 RT입니다. 재 로그인 해주세요.");
}
} else {
throw new AuthenticationException("토큰이 만료되었습니다. 재 로그인 해주세요.");
}
filterChain.doFilter(request, response);
}
// RT 유효한지 검사
if (isTokenValid(refreshToken)) {
Member member = getMemberFromToken(refreshToken);
// 사용자 DB에 저장된 RT와 같다면
if (member.getRefreshToken().equals(refreshToken)) {
Redis Window 설치하기
의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-cache' // 스프링 캐시 추상화
implementation 'org.springframework.boot:spring-boot-starter-data-redis' // redis 사용
yml 설정하기
spring:
cache:
type: redis
redis:
host: 127.0.0.1
port: 6379
application에 어노테이션 적용
@SpringBootApplication
@EnableCaching
public class MoneywayApplication {
// RT 유효한지 검사
if (isTokenValid(refreshToken)) {
// 사용자 추출
Map<String, Object> claims = jwtProvider.getClaims(refreshToken);
long targetId = (long)(int)claims.get("id");
// 캐시된 RT를 가져옴 (사용자 id 넘겨서)
String refreshTokenByCache = redisService.getRefreshTokenByCached(targetId);
// 사용자에게 저장된 RT와 같다면(캐시된 RT와 같은지 비교)
if (refreshTokenByCache.equals(refreshToken)) {
// 토큰으로부터 추출한 데이터로 Member를 생성
Member member = memberService.createByClaims(claims);
// 1일짜리 AT 재발생해서 반환
Map<String, Object> newMemberClaims = member.toClaims();
String newAccessToken = jwtProvider.genToken(newMemberClaims, 60 * 60 * 24 * 1);
response.addHeader("Authorization", "Bearer " + newAccessToken);
// 인증 처리
forceAuthentication(member);
}
개선 사항 내용 : 사용자 추출 및 캐시된 RT 가져옴
@Cacheable(value = "Refresh", key = "#targetId")
public String getRefreshTokenByCached(long targetId) {
Member member = memberService.get(targetId);
return member.getRefreshToken();
}
public Member createByClaims(Map<String, Object> claims) {
return Member.builder()
.id((long)(int)claims.get("id"))
.userName((String)claims.get("userName"))
.build();
}
개선 결과 확인
@Transactional
public RsData<TokenDTO> login(String name, String password) {
Optional<Member> _member = memberRepository.findByUserName(name);
if(_member == null)
return RsData.of("F-1", "존재하지 않는 회원입니다.");
Member member = _member.get();
RsData rsData = canGenToken(member, password);
if (rsData.isFail())
return rsData;
String accessToken = genAccessToken(member);
String refreshToken = genRefreshToken(member);
// 리프레시 토큰 갱신
member.updateRefreshToken(refreshToken);
redisService.updateRefreshToken__Cached(member); // Redis 값 갱신
return RsData.of("S-1", "토큰 발급 성공", new TokenDTO(accessToken, refreshToken));
}
@CachePut(value = "Refresh", key = "#member.id")
public String updateRefreshToken__Cached(Member member) {
return member.getRefreshToken();
}
@CachePut 어노테이션을 통한 Redis값 수정
하지만 이러면 어떻게되나? 순환참조가 발생합니다 하하
무한참조..
위를 해결하기 위해 MemberService에서 RedisService 객체를 주입받지 않는 방식으로 해결했습니다.
ApplicationContext에서 Bean으로 등록된 MemberService 객체를 가져옵니다.
@CachePut
어노테이션이 적용된 updateRefreshToken__Cached
메서드는 캐시에 값을 저장하는 기능을 수행합니다.applicationContext.getBean("memberService", MemberService.class)
를 사용하여 ApplicationContext에서 "memberService"라는 이름의 빈을 가져옵니다.updateRefreshToken__Cached
메서드를 호출합니다.해결한 코드
@RequiredArgsConstructor
@Service
@Transactional(readOnly = true)
public class MemberService {
private final ApplicationContext applicationContext;
private MemberService memberService;
@Transactional
public RsData<TokenDTO> login(String name, String password) {
Optional<Member> _member = memberRepository.findByUserName(name);
if (_member == null)
return RsData.of("F-1", "존재하지 않는 회원입니다.");
Member member = _member.get();
RsData rsData = canGenToken(member, password);
if (rsData.isFail())
return rsData;
String accessToken = genAccessToken(member);
String refreshToken = genRefreshToken(member);
// 리프레시 토큰 갱신
member.updateRefreshToken(refreshToken);
_updateRefreshToken__Cached(member); // Redis 값 갱신
return RsData.of("S-1", "토큰 발급 성공", new TokenDTO(accessToken, refreshToken));
}
private String _updateRefreshToken__Cached(Member member) {
if (memberService == null) {
// 의존성 순환 참조 때문에 RedisService를 의존성 주입 불가
// 따라서 Context에 등록된 memberService 객체 가져와서 실행
memberService = applicationContext.getBean("memberService", MemberService.class);
}
return memberService.updateRefreshToken__Cached(member);
}
@CachePut(value = "Refresh", key = "#member.id")
public String updateRefreshToken__Cached(Member member) {
return member.getRefreshToken();
}
ApplicationContext를 주입받고, memberService를 Context에서 꺼내옵니다.
이후 AOP가 적용된 메서드로 호출합니다.
결과 확인
private Member getMemberFromToken(String token) throws AuthenticationException {
Map<String, Object> claims = jwtProvider.getClaims(token);
long id = (int)claims.get("id");
Member member = memberService.get(id);
if (member == null)
throw new AuthenticationException("존재하지 않는 사용자입니다.");
return member;
}
잡았다 요녀석..!
private Member getMemberFromToken(String token){
Map<String, Object> claims = jwtProvider.getClaims(token);
Member member = memberService.createByClaims(claims);
return member;
}
위와 같이 수정하여 Access Token 확인 후 Member객체 생성하도록 변경하였습니다.
Redis를 사용해 로그인 필터 시 Refresh Token을 DB에서 조회하지 않도록 수정할 수 있었고 처음으로 다뤄봤습니다!
레디스 서버는 어떻게 만드셨나요?