| API문서 | 요청 |
|---|---|
![]() | ![]() |
public record CredentialVerificationRequest(
String loginId,
String password
) {
}
public record CredentialVerificationResponse(
UUID userId,
Role role
) {
}
/**
* User Service 의 내부 API 호출용 Feign 클라이언트.
*/
@FeignClient(name = "userservice")
public interface UserServiceClient {
@PostMapping("/internal/users/credential-verifications")
CredentialVerificationResponse verifyCredentials(@RequestBody CredentialVerificationRequest request);
}
@Slf4j
@Component
@RequiredArgsConstructor
public class UserCredentialVerifyProviderAdapter implements UserCredentialProvider {
private final UserServiceClient userServiceClient;
@Override
public Result verify(String loginId, String password) {
try {
CredentialVerificationResponse response = userServiceClient.verifyCredentials(
new CredentialVerificationRequest(loginId, password)
);
return new Result(response.userId(), response.role());
} catch (FeignException.Unauthorized e) {
log.debug("User Service 자격 검증 실패 — loginId={}", loginId);
throw new BusinessException(AuthErrorCode.LOGIN_FAILED);
} catch (FeignException e) {
log.error("User Service 통신 오류 — status={}, message={}", e.status(), e.getMessage());
throw new BusinessException(AuthErrorCode.USER_SERVICE_UNAVAILABLE);
}
}
}
/**
* <p>로그인</p>
*
* <ol>
* <li>User Service 에서 자격 검증 (Feign)</li>
* <li>AccessToken(JWT) 및 RefreshToken(Opaque) 생성</li>
* <li>RefreshToken 을 Repository(Redis) 에 저장</li>
* </ol>
*/
@Transactional
public AuthenticationResult login(LoginCommand command) {
// 자격검증 (현재 User 조회)
// 토큰 발급
// RT 화이트리스트 저장
새 토큰을 발급받기 위해서는 유저 서비스에 요청을 통해 현재 유저의 정보를 가져와야 했다.
하지만 Opaque Token 방식의 RT는 그 자체로 아무 정보가 없다. 갱신을 위해선 RT -> userId를 찾아야 하는데,
처음에 RT에 Opaque 토큰을 도입 할 때엔, AT쪽에 담긴 JWT 정보를 사용해야겠다고 생각했는데,
"클라이언트가 refresh API를 호출하는 시점은 보통 기존 AT가 만료(Expired)되어 더 이상 사용할 수 없을 때" 라는것을 간과했다.
그래서 API 서버에서 "만료된 토큰은 아예 거절"하도록 설정되어 있다면, refresh 요청에 실려온 AT를 파싱해서 유저 ID를 꺼내는 로직 자체가 작동하지 않을 수 있다.
물론 "만료된 토큰이라도 서명만 맞으면 파싱해서 ID를 꺼내겠다"라고 예외 로직을 짤 수는 있지만, 이는 보안 필터링 설계를 복잡하게 한다. (jjwt - ExpiredJwtException 처리를 커스텀해야함)
그래서 지금처럼 RT 자체에 정보가 없을 때는, 토큰값만 가지고 주인을 찾기 위해서 Redis의 SCAN 명령어를 사용해 등록된 모든 세션 키를 하나하나 확인해가며 저장된 토큰 해시값이 내가 찾는 것과 일치하는지 대조해야 한다. Redis는 싱글 스레드라 이 작업이 진행되는 동안 다른 유저의 로그인, 토큰 검증 요청이 모두 줄줄이 대기(Blocking)하게 된다.
위 성능 저하(SCAN) 문제를 막으면서도 보안을 챙기기 위해 Redis 저장 구조를 개선했다.
기존: auth:rt:{userId} -> tokenHash (유저로 토큰 찾기 가능, 반대는 불가)
개선: auth:rt:index:{tokenHash} -> userId 추가 (인덱스 생성)
이제 RT만 들어와도 의 속도로 유저를 식별할 수 있고, 식별된 ID로 User Service에서 최신 권한 정보를 가져와 AT를 안전하게 갱신할 수 있다.
단, 기존 Refresh 토큰 구조보다 Redis 공간을 두 배로 차지하게 되는 단점이 있다.
public interface RefreshTokenRepository {
/**
* RT 저장. 같은 userId 의 기존 RT 가 있으면 덮어씀.
*
* @param userId 대상 사용자
* @param refreshToken 발급된 RT (평문)
*/
void save(UUID userId, RefreshToken refreshToken);
/**
* RT 검증.
*
* <p>전달된 RT의 해시로 저장된 userId가 있는지 확인 </p>
*/
Optional<UUID> findUserIdByToken(String rawToken);
/**
* 사용자의 RT 삭제 (로그아웃 / 강제 무효화).
*
* @return 삭제 성공 여부
*/
boolean delete(UUID userId);
}
바뀐 형태에 맞게 Redis 인덱스도 함께 비워줘야한다.