[Spring Boot] Redis 적용기 - 1 JWT Refresh Token 저장 및 갱신

박철현·2023년 11월 24일
0

스프링부트

목록 보기
1/8
  • 개인 프로젝트 중 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);
	}
  • 너무 길어질까봐 주요 로직만 가져왔습니다.
    • AT 검사 -> 만료 시 RT 검사 -> AT 재발행의 과정을 거쳤습니다.
  • 기존 코드에서의 개선이 필요한 부분은 Member 객체를 가져와서 저장된 RT와 비교하는 로직인데 이 과정에서 DB에 접속합니다.
  • 하지만 이를 Redis를 활용한다면, Key를 Member id로 하고, Value를 RT값으로 한다면 충분히 개선 될 것 같습니다.
    • RT, AT 내부에 Member id값을 넣고 있습니다.
    • getMemberFromToken을 바꿔서
      • String refreshTokenByCache = getRefreshTokenByCached(refreshToken); 형태로 받고
      • refreshTokenByCache.equals(refreshToken) 가 참인지 확인하면 될 것 같습니다.
    // RT 유효한지 검사
    			if (isTokenValid(refreshToken)) {
    				Member member = getMemberFromToken(refreshToken);
    				// 사용자 DB에 저장된 RT와 같다면
    				if (member.getRefreshToken().equals(refreshToken)) {
  • 하지만 위 사항만 적용한다면 Member객체를 통해 AT를 만들어야 하는데, member를 찾기 위해 DB에 접속하는 문제는 여전히 존재합니다!
    • 이를 해결하기 위해 어차피 RT검증이 끝났으니, RT에서 얻은 정보로 Member 객체를 만들어줘서 해결합니다!
  • 또한 재로그인을 통해 토큰을 재발행 받는 경우 RT값이 초기화 되기 때문에, 이때는 명시적으로 Redis에서 토큰을 비워주도록 바꾸면 좋을 것 같습니다.

의존성 추가 및 yml 설정하기

  • Redis Window 설치하기

  • 의존성 추가

    implementation 'org.springframework.boot:spring-boot-starter-cache' // 스프링 캐시 추상화
    implementation 'org.springframework.boot:spring-boot-starter-data-redis' // redis 사용
  • yml 설정하기

    • cache 추상화를 이용했기 때문에 구체적인 타입 명시
    • redis 포트 및 호스트 설정하기
    spring:
      cache:
        type: redis
      redis:
        host: 127.0.0.1
        port: 6379
  • application에 어노테이션 적용

@SpringBootApplication
@EnableCaching
public class MoneywayApplication {

개선하기 1 : Redis에 RT정보 저장 및 RT에서 얻은 정보로 Member 객체 만들어주기

		// 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 가져옴

    • isTokenValid 메서드로 서버에서 시크릿키로 서명했고, 유효기간이 남아있는지 검증이 끝났습니다. (동일)
    • 일단 검증은 된 것이니 사용자의 RT와 동일한지 확인 해야합니다.
    • 프록시 객체로 활용해야 @Cacheable 어노테이션이 적용된 메서드가 사용 가능하니 redisService를 별도로 만들어, getRefreshTokenByCached 메서드를 호출합니다.
      • Cacheable : 캐시 저장함을 의미
    	@Cacheable(value = "Refresh", key = "#targetId")
    		public String getRefreshTokenByCached(long targetId) {
    			Member member = memberService.get(targetId);
    			return member.getRefreshToken();
    		}
    • 그 다음은 AT용 claim을 생성하고, 인증 처리를 위해 Member 객체를 생성해주는 MemberService 메서드를 추가하였습니다.
      • Refresh Token 검증이 끝났기 때문에 토큰에 있는 정보를 추출하여 Member 객체를 생성합니다.
      • Member 객체의 정보를 Redis에 저장할 수도 있지만 굳이 저장하지 않아도 될 것 같아 저장하지 않았습니다.(어차피 검증은 끝났고, Redis 저장공간만 낭비하는 것 같은 생각이 듭니다)
      public Member createByClaims(Map<String, Object> claims) {
    			return Member.builder()
    				.id((long)(int)claims.get("id"))
    				.userName((String)claims.get("userName"))
    				.build();
    		}
  • 개선 결과 확인

    • Swagger 문서에서 "/api/v1/category" 로 카테고리 목록 반환에서 Refresh Token으로 테스트합니다.(해당 API 엔드포인트는 인증 필요)
    • Redis에 데이터가 없는 최초에는 사용자 DB 조회
    • 이후 요청에는 카테고리 조회부터
    • Redis 내 "keys *"로 조회
    • 잘나온다 ㅎ
  • 코드 보러가기

개선하기 2 : Redis에 RT정보 갱신하기

  • 사용자 ID/PW 로그인을 통해 토큰 발급 받을 시 RT가 갱신되도록 코드를 짰었습니다.
  • 이 과정 시 Redis에 있는 RT값도 업데이트 해주도록 수정하였습니다.
	@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를 참조하고 있고
    • RedisService가 현재 MemberService를 참조하고 있습니다.
    • 위 두개가 bean으로 등록될 때 무한참조..
  • 위를 해결하기 위해 MemberService에서 RedisService 객체를 주입받지 않는 방식으로 해결했습니다.

  • ApplicationContext에서 Bean으로 등록된 MemberService 객체를 가져옵니다.

    • MemberService는 프록시 객체로 등록되어 있으며, 어노테이션이 적용된 메서드를 실행하면 부가 기능이 적용된 메서드가 호출됩니다. 예를 들어, @CachePut 어노테이션이 적용된 updateRefreshToken__Cached 메서드는 캐시에 값을 저장하는 기능을 수행합니다.
    • applicationContext.getBean("memberService", MemberService.class)를 사용하여 ApplicationContext에서 "memberService"라는 이름의 빈을 가져옵니다.
    • 이렇게 가져온 MemberService 빈을 사용하여 updateRefreshToken__Cached 메서드를 호출합니다.
    • 따라서 ApplicationContext를 통해 MemberService의 프록시 객체를 활용하여 캐시 기능이 적용된 메서드를 실행할 수 있습니다.
    • 만일 ApplicationContext를 통하지 않고 바로 호출한다면, 프록시 객체가 적용되지 않은 메서드를 호출하여 캐시 기능을 사용할 수 없습니다.
      • 캐시 기능 : Redis에 해당 값이 있는지 확인, 갱신 등의 작업
  • 해결한 코드

@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가 적용된 메서드로 호출합니다.

  • 결과 확인

    • Swagger를 통해 로그인 2번 했을 때 값 조회 결과입니다.
    • 토큰의 값이 변하는 것을 확인하였습니다!
  • 코드 보러가기

개선하기 3 - Access Token으로 인증 시 DB 접속 수정하기

  • 여태 위에서 Refresh Token만 신경썼는데, Access Token 인증을 생각못했습니다.
  • 계속 DB에서 조회하길래 왜인가 봤더니
	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;
	}

잡았다 요녀석..!

  • 이 메서드 호출 전 서버에서 시크릿키로 서명한지, 유효기간이 유효한지 검사하니 여기서 memberService의 get 메서드 대신에 claims로 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에서 조회하지 않도록 수정할 수 있었고 처음으로 다뤄봤습니다!

profile
비슷한 어려움을 겪는 누군가에게 도움이 되길

4개의 댓글

comment-user-thumbnail
2024년 5월 19일

레디스 서버는 어떻게 만드셨나요?

1개의 답글