지난 [생각정리] Redis도 비용일까? 에서 작성하였듯이 Refresh Token만을 위해 Redis를 사용하는 것은 과비용이다. 그래서 이번 글에서는 기존 코드에서 Redis 대신 RDB를 사용하여 Refresh Token을 관리하는 과정과 로직의 문제점을 수정하는 과정을 다루겠다.
이번 과정을 통해 수정된 코드의 링크를 아래에 첨부하겠다.
@Transactional
public SignInResponse signIn(SignInRequest req) {
Member member = memberRepository.findWithRolesByEmail(req.getEmail()).orElseThrow(LoginFailureException::new);
validatePassword(req, member);
TokenHandler.PrivateClaims privateClaims = createPrivateClaims(member);
String accessToken = userAccessTokenHandler.createToken(privateClaims);
String refreshToken = userRefreshTokenHandler.createToken(privateClaims);
redisHandler.setValues(String.valueOf(member.getId()), refreshToken);
return new SignInResponse(accessToken, refreshToken);
}
- 기존의 signIn 메서드는 사용자가 로그인 할 때마다 Refresh Token을 생성하고 이를 Redis에 저장하는 로직을 포함하고 있다.
- Redis를 사용함으로써 비용이 비효율적이고 관리가 복잡하다.
@Transactional
public UserRefreshTokenResponse refreshToken(String userRefreshToken) {
TokenHandler.PrivateClaims userClaims = userRefreshTokenHandler.parse(userRefreshToken)
.orElseThrow(RefreshTokenFailureException::new);
String storedToken = Optional.ofNullable(redisHandler.getValues(userClaims.getId()))
.orElseThrow(RefreshTokenFailureException::new);
if (!storedToken.equals(userRefreshToken)) {
redisHandler.deleteValues(String.valueOf(userClaims.getId()));
throw new RefreshTokenFailureException();
}
String newUserAccessToken = userAccessTokenHandler.createToken(userClaims);
String newUserRefreshToken = userRefreshTokenHandler.createToken(userClaims);
redisHandler.setValues(String.valueOf(userClaims.getId()), newUserRefreshToken);
return new UserRefreshTokenResponse(newUserAccessToken, newUserRefreshToken);
}
}
- 기존의 refresh 메서드도 Redis를 사용하여 Token을 저장하고 검증하는 로직이 분리되어있지 않다.
- Redis의 휘발성과 비용 문제, 그리고 관리의 복잡성이라는 측면에서 비효율적이다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class RefreshUserTokenService {
private final RefreshUserTokenRepository refreshUserTokenRepository;
private final PasswordEncoder passwordEncoder;
@Transactional
public void save(String memberId, String refreshToken) {
String encryptedRefreshToken = passwordEncoder.encode(refreshToken);
RefreshUserToken refreshUserToken = refreshUserTokenRepository.findByMemberId(memberId)
.orElseGet(() -> RefreshUserToken.builder().memberId(memberId).build());
refreshUserToken.updateToken(encryptedRefreshToken);
refreshUserTokenRepository.save(refreshUserToken);
}
@Transactional
public void delete(String memberId) {
RefreshUserToken refreshUserToken = find(memberId);
refreshUserTokenRepository.delete(refreshUserToken);
}
public RefreshUserToken find(String memberId) {
return refreshUserTokenRepository.findByMemberId(memberId).orElseThrow(RefreshUserTokenNotFoundException::new);
}
public boolean validate(String token, String memberId){
RefreshUserToken storedToken = find(memberId);
if (!passwordEncoder.matches(token, storedToken.getToken())) {
delete(memberId);
return false;
}
return true;
}
}
Refresh Token의 관리를 RDB로 전환하기 위해 RefreshUserTokenService 클래스를 도입하였다. 또한 RefreshUserTokenService의 도입으로 책임의 분리 원칙에 더 집중할 수 있었다.
- 이전에는 Redis 관련 로직이 인증 로직 내에 혼재되어 있었으나, 이제 Refresh Token의 저장 및 검증 책임은 RefreshUserTokenService가 전담한다.
- 서비스의 단일 책임을 명확히 하여, 각 컴포넌트가 자신의 역할에만 집중할 수 있게 한다.
- RefreshUserTokenService에서 PasswordEncoder 통해 암호화된 Token값을 저장하도록 하였다.
@Transactional
public void signIn(SignInRequest req, HttpServletResponse response) {
Member member = memberRepository.findWithRolesByEmail(req.getEmail()).orElseThrow(LoginFailureException::new);
validatePassword(req, member);
TokenHandler.PrivateClaims privateClaims = createPrivateClaims(member);
String accessToken = userAccessTokenHandler.createToken(privateClaims);
String refreshToken = userRefreshTokenHandler.createToken(privateClaims);
saveRefreshToken(String.valueOf(member.getId()), refreshToken);
TokenStorageUtil.addAccessTokenInHeader(response, accessToken);
TokenStorageUtil.addRefreshTokenInCookie(response, refreshToken, 24 * 60 * 60);
}
signIn 메서드의 수정은 사용자 인증 과정을 더욱 명확하게 만들었다.
- 인증 로직이 토큰의 생성과 저장의 구체적인 세부 사항으로부터 분리되도록 구혔하였다.
@Transactional
public void refresh(String userRefreshToken, HttpServletResponse response) {
TokenHandler.PrivateClaims userClaims = userRefreshTokenHandler.parse(userRefreshToken)
.orElseThrow(RefreshTokenFailureException::new);
if (validateRefreshToken(userRefreshToken, userClaims.getId())) {
String newUserAccessToken = userAccessTokenHandler.createToken(userClaims);
String newUserRefreshToken = userRefreshTokenHandler.createToken(userClaims);
saveRefreshToken(userClaims.getId(), newUserRefreshToken);
TokenStorageUtil.addAccessTokenInHeader(response, newUserAccessToken);
TokenStorageUtil.addRefreshTokenInCookie(response, newUserRefreshToken, 24 * 60 * 60);
}
}
public boolean validateRefreshToken(String userRefreshToken, String memberId) {
return refreshUserTokenService.validate(userRefreshToken, memberId);
마찬가지로 refresh 메서드의 수정은 토큰 재발급 과정을 더욱 명확하게 만들었다.
- 재발급 로직이 토큰의 검증과 기존 토큰의 삭제로 부터 분리되도록 구현하였다.
인터페이스를 활용하여 의존성 역전 원칙(Dependency Inversion Principle, DIP)을 적용하는 방법을 고려했다. 그러나 추상화 레이어를 도입하는 것도 그 자체로 비용이며, 현재와 미래의 요구 사항을 고려했을 때 다른 저장소로의 전환 가능성이 낮다고 판단되었다. 그래서 시스템의 복잡성과 개발 비용을 최소화하기 위해 추상화 대신 구체적인 구현체를 직접 사용하기로 결정했다.