문제 상황
이전 글 에서 언급했던 것과 같이, API 템플릿에서 Refresh Token에 대해서 개발한 부분이 불편했다. 템플릿을 본격적으로 사용하기 전에 고쳐야 마음 편하게 API 템플릿을 사용할 수 있을 거라고 생각해서 이번에는 맛있는 Refresh Token을 만들고자 노력해봤다.
해결 과정
이번 글에서는 변경에 대한 고민 과정을 담는 것이 의미있을 것 같아서 Refresh Token 변경 과정을 모두 기록하고자 한다.
github repo: https://github.com/y2gcoder/spring-api-backend-template
1. Refresh Token을 따로 분리하기
Refresh Token을 맛있게 바꾸기 위해서 제일 먼저 한 것은 Refresh Token을 분리하는 것이었다.
기존 Member Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Member extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, length = 50, nullable = false)
private String email;
@Column(length = 200)
private String password;
private String nickname;
private String profile;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 10)
private MemberRole role;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 10)
private AuthProvider provider;
private String refreshToken;
private LocalDateTime tokenExpirationTime;
@Builder
public Member(
String email, String password, String nickname, String profile, MemberRole role, AuthProvider provider
) {
this.email = email;
this.password = password;
this.nickname = nickname;
this.profile = profile;
this.role = role;
this.provider = provider;
}
public void updateRefreshToken(String refreshToken, LocalDateTime tokenExpirationTime) {
this.refreshToken = refreshToken;
this.tokenExpirationTime = tokenExpirationTime;
}
}
기존에는 편의를 위해 Member Entity에서 Refresh Token을 저장하고 있었다. 그래서 Refresh Token을 변경하면 결국 Member Entity와 관련한 부분들을 전부 수정해줘야 했다. Refresh Token 기능을 변경하기 위해 Member 쪽을 변경해야 한다는 것은 나쁜 설계라고 생각했다. 그래서 후의 변경을 위해 Refresh Token을 Member에서 독립해주기로 했다.
변경 후 Member Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Member extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, length = 50, nullable = false)
private String email;
@Column(length = 200)
private String password;
private String nickname;
private String profile;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 10)
private MemberRole role;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 10)
private AuthProvider provider;
@Builder
public Member(
String email, String password, String nickname, String profile, MemberRole role, AuthProvider provider
) {
this.email = email;
this.password = password;
this.nickname = nickname;
this.profile = profile;
this.role = role;
this.provider = provider;
}
}
Refresh Token과 관련된 부분을 제거하고 나니 코드가 더 깔끔해졌다.
Refresh Token Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class RefreshToken extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long memberId;
private String refreshToken;
private LocalDateTime tokenExpirationTime;
@Builder
public RefreshToken(Long memberId, String refreshToken, LocalDateTime tokenExpirationTime) {
this.memberId = memberId;
this.refreshToken = refreshToken;
this.tokenExpirationTime = tokenExpirationTime;
}
public void updateRefreshToken(String refreshToken, LocalDateTime tokenExpirationTime) {
this.refreshToken = refreshToken;
this.tokenExpirationTime = tokenExpirationTime;
}
}
큰 변경 없이 Member Entity에 존재하던 Refresh Token 관련 부분을 분리해 독립된 Entity로 만들었다.
Q. Member Entity와 연관관계를 맺어주지 않은 이유는?
A. 비용을 따져봤을 때 연관관계 없이 설정해주는 것이 맞을 것 같다고 결론을 내렸다. 연관관계를 맺어준다면 Refresh Token Entity를 조회할 때 편하게 Member Entity의 정보도 같이 불러올 수 있겠지만, 그럴 일이 많지 않을 거라 생각했고, 또 Refresh Token을 변경할지도 모르기 때문에 최대한 독립적인 엔티티로 만들고자 했다.
그 후 RefreshToken에 대한 Service, Repository를 만들고 AuthService에서 로그인, 토큰 재발급, 로그아웃 로직을 변경했다.
AuthService
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class AuthService {
private final JwtTokenProvider jwtTokenProvider;
private final MemberService memberService;
private final PasswordEncoder passwordEncoder;
private final RefreshTokenService refreshTokenService;
...
@Transactional
public JwtTokenDto signIn(SignInDto.Request request) {
Member member = memberService.findMemberByEmail(request.getEmail());
validateMemberAuthProvider(member.getProvider());
validatePassword(request.getPassword(), member.getPassword());
// 토큰 만들기(access, refresh)
JwtTokenDto jwtTokenDto = jwtTokenProvider.createJwtToken(String.valueOf(member.getId()), member.getRole());
// refresh token 저장 (DB)
refreshTokenService.updateRefreshToken(
member.getId(),
jwtTokenDto.getRefreshToken(),
jwtTokenDto.getRefreshTokenExpireTime()
);
return jwtTokenDto;
}
...
public TokenRefreshResponse refreshToken(String refreshToken) {
validateRefreshToken(refreshToken);
RefreshToken refreshTokenEntity = refreshTokenService.findTokenByRefreshToken(refreshToken);
Member member = memberService.findMemberById(refreshTokenEntity.getMemberId());
Date accessTokenExpireTime = jwtTokenProvider.createAccessTokenExpireTime();
String accessToken =
jwtTokenProvider.createAccessToken(String.valueOf(member.getId()), member.getRole(), accessTokenExpireTime);
return TokenRefreshResponse.builder()
.grantType(GrantType.BEARER.getType())
.accessToken(accessToken)
.accessTokenExpireTime(DateTimeUtils.convertToLocalDateTime(accessTokenExpireTime))
.build();
}
...
@Transactional
public void signOut(Long memberId) {
refreshTokenService.removeRefreshToken(memberId);
}
}
- signIn(): 로그인할 때 Member가 아닌 RefreshToken에 저장하도록 변경했다.
- refreshToken(): 기존에는 refreshToken으로 바로 해당 회원을 찾을 수 있었지만, 이제는 RefreshToken에 있는 memberId를 통해 회원을 찾을 수 있도록 변경했다.
- signOut(): 기존처럼 해당 Member Entity를 조회해서 값을 update할 필요없이, memberId에 해당하는 RefreshToken을 삭제해주도록 변경했다.
테스트 부분도 변경한 부분에 맞게 수정한 뒤 테스트를 시행해서 검증했다.

테스트를 실행해서 모두 통과할 때 오는 안정감은 최고인 것 같다.
소셜 로그인 쪽은 테스트를 만들지 못해 그냥 직접 로그인해봤다.
이제 변경을 위한 준비는 모두 마쳤다. 다음 단계로 넘어가도록 하겠다.
2. Cookie로 반환하지 말고 응답으로 보내기
그 다음으로 고민한 것은 정말 Refresh Token을 Cookie로만 클라이언트에 제공해야 하는지에 대한 것이었다. Refresh Token을 Cookie에 담아서 보내게 된 것은 클라이언트 사이드에서 저장할 것을 생각했기 때문이다. 그런데 클라이언트에서 저장하는 곳을 미리 서버쪽에서 정해도 될까하는 의문이 들었다. 클라이언트에서는 local storage에 저장할 수도 있고 아니면 또 다른 곳에 저장할 수도 있을 것이다. 심지어 구글은 크롬에서 쿠키를 퇴출하겠다고 한다.
서버 사이드에서 할 역할은 어쩌면 로그인했을 때 딱 한 번만 Refresh Token을 전달해주는 것으로 충분하다는 생각이 들었다. 이미 서버 사이드에서 Refresh Token을 저장하고 있기 때문에, 더 서버 사이드에서 클라이언트 사이드에서의 refresh token 저장소를 임의로 정해줄 필요가 없다는 생각이 들었다. 또한 부수적인 이유로 DB에도 refresh token을 저장하고 있는데 쿠키로 refresh token을 저장하려고 하니, 똑같은 저장 작업, 수정 작업, 삭제 작업을 중복으로 하는 느낌이었다.
그래서 서버 사이드에서 Refresh Token을 Cookie와 분리해줬다.
OAuth2AuthenticationSuccessHandler
@Slf4j
@RequiredArgsConstructor
@Component
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final OAuth2Config oAuth2Config;
private final RefreshTokenService refreshTokenService;
private final CustomAuthorizationRequestRepository authorizationRequestRepository;
private final JwtTokenProvider jwtTokenProvider;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
if (response.isCommitted()) {
log.debug("Response has already been committed!!!!");
return;
}
String targetUrl = determineTargetUrl(request, response, authentication);
clearAuthenticationAttributes(request, response);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
protected String determineTargetUrl(
HttpServletRequest request, HttpServletResponse response, Authentication authentication
) {
...
return UriComponentsBuilder.fromUriString(targetUrl)
.queryParam("grant", jwtTokenDto.getGrantType())
.queryParam("access", jwtTokenDto.getAccessToken())
.queryParam("refresh", jwtTokenDto.getRefreshToken())
.build().toUriString();
}
...
}
소셜 로그인 시에 refresh token도 반환하도록 변경했다.
AuthController
@RequiredArgsConstructor
@RequestMapping("/api/auth/")
@RestController
public class AuthController {
private final AuthService authService;
...
@PostMapping("/sign-in")
public ResponseEntity<SignInDto.Response> signIn(@Valid @RequestBody SignInDto.Request req) {
SignInDto.Response result = authService.signIn(req);
return ResponseEntity.ok().body(result);
}
@PostMapping("/refresh")
public ResponseEntity<TokenRefreshDto.Response> refreshToken(@Valid @RequestBody TokenRefreshDto.Request request) {
return ResponseEntity.ok(authService.refreshToken(request));
}
@PostMapping("/sign-out")
public ResponseEntity<Void> signOut(@SignInMember SignInMemberDto signInMemberDto) {
authService.signOut(signInMemberDto.getMemberId());
return ResponseEntity.ok().build();
}
}
Cookie 관련 로직이 사라지니 코드가 굉장히 깔끔해졌다.
MemberController
@RequiredArgsConstructor
@RequestMapping("/api/members")
@RestController
public class MemberController {
private final MemberService memberService;
@PreAuthorize("@memberGuard.check(#id)")
@DeleteMapping("/{id}")
public ResponseEntity<Void> withdrawMember(@PathVariable Long id) {
memberService.withdrawMember(id);
return ResponseEntity.ok().build();
}
}
본인이 직접 회원 탈퇴를 할 때, 로그아웃과 같은 처리(Refresh Token 쿠키 삭제)를 해줬는데, 그걸 제거해줬다.

테스트도 통과했다.
3. Refresh Token에 대한 보안을 강화하기
위에서 Refresh Token을 Cookie에서 제거한 이후에도 여전히 Refresh Token에 대해 불편한 점이 있었다. 현재 Refresh Token은 JWT이고, 동시에 서버(정확히는 DB)에서 보관하고 있는 형태이다. 그래서 Refresh Token을 이용한 Access Token 재발급 프로세스에서 Refresh Token에 대한 유효성 검사를 JWT에서 한 번, 서버에서 한 번을 해줘 총 2번 씩 해주고 있었다. 처음에 코딩할 때는 유효성 검사를 두 번 하니까 보안적인 측면에서 좋을 것이라 생각했는데, 점점 불필요한 과정이라는 생각이 들었다.
그 때 hudi님의 블로그 글을 보고 내 API Template에 RTR(Refresh Token Rotation)을 적용하기로 했다. RTR은 간단하게 말하자면 Refresh Token을 일회용으로 만들어 Refresh Token에 대한 보안을 강화하는 기법이다. 사실 이전에 적용해놓고 Access Token 만료될 때마다 Refresh Token을 재생성하는 게 낭비처럼 느껴져 토큰 재발급 시 Access Token만 재발급하는 것으로 바꿨던 부분이다. 그런데 어차피 토큰 재발급 시에 API 요청을 통해 서버로 요청이 들어오는 것은 확정이기도 하고 글에서도 보안을 강화한다는 장점을 말해주고 있으니 이전에 했던 대로 토큰 재발급 시 Refresh Token도 재발급해주기로 했다.
AuthService
@RequiredArgsConstructor
@Service
public class AuthService {
private final JwtTokenProvider jwtTokenProvider;
private final MemberService memberService;
private final PasswordEncoder passwordEncoder;
private final RefreshTokenService refreshTokenService;
...
public TokenRefreshDto.Response refreshToken(TokenRefreshDto.Request request) {
validateRefreshToken(request.getRefreshToken());
RefreshToken refreshTokenEntity = refreshTokenService.findTokenByRefreshToken(request.getRefreshToken());
Member member = memberService.findMemberById(refreshTokenEntity.getMemberId());
// Access Token, Refresh Token 재발급
JwtTokenDto jwtTokenDto = jwtTokenProvider.createJwtToken(String.valueOf(member.getId()), member.getRole());
// 재발급한 Refresh Token DB 저장
refreshTokenService.updateRefreshToken(
member.getId(),
jwtTokenDto.getRefreshToken(),
jwtTokenDto.getRefreshTokenExpireTime()
);
return TokenRefreshDto.Response.from(jwtTokenDto);
}
...
}

테스트도 당연히 해줬다.
마치며
여기까지 총 3번의 과정을 거쳐서 Refresh Token을 보완해나가는 과정을 기록했다. 사실 이번 변화에서 제일 의미가 있었던 부분은 1.에서 Refresh Token을 Member 로직에서 분리한 것이 아닐까 싶다. 분리하고 나니 이전보다 Member와 Refresh Token 모두 변경이 수월해진 것 같아 다음 변경을 기대하고 있다.
지금은 Refresh Token의 저장소를 변경하는 것에 대해 고민하고 있다. 현재 API 템플릿에서는 DB에 Refresh Token을 저장하고 있는데, 지금은 이걸로도 충분할 것 같다고 생각하고 있다. 개인적으로 Refresh Token 저장소에 대해 찾아봤을 때 Redis를 사용하는 방식을 적용해보고 싶었는데, 하게 되면 다음에 기록으로 남기겠다.
여담

사실 후디님 글을 보고 처음에 너무 감동해 무지성으로 후디님처럼 Refresh Token을 JWT가 아닌 UUID로 바꿔서 push까지 했다가 다시 되돌렸다. 브랜치 새로 생성해서 하지 않고, 그냥 commit, push부터 한 내 잘못이다.
Refresh Token을 JWT가 아닌 UUID로 바꿨다가 다시 롤백한 이유는 내가 감정적으로 변경하려고 시도했기 때문이다. 다시 생각해보니 지금 API 템플릿의 설계에서는 Refresh Token이 JWT더라도 DB에 Refresh Token을 저장해주고 있기 때문에 보안적인 측면에서 보완이 될 것이라고 생각했다. 그래서 일단 원래대로 사용해보고 개선해야 할 필요성을 느낄 때 다시 고민해보기로 했다.
References
'Java > Spring Boot' 카테고리의 다른 글
| Datadog Agentlesss Logging 시 service 이름 추가하기 (0) | 2023.04.10 |
|---|---|
| MultipartFile과 TeeFilter를 같이 사용하지 말자 (0) | 2023.04.04 |
| Spring Boot로 만드는 API 서버 템플릿 (1) - 시작 (0) | 2023.03.21 |