🏅오늘의 목표
- Refresh Token 도입
- 로그아웃
✅ 진행한 작업
- Refresh Token 도입
- 로그아웃
📃 개발내용
Refresh Token 도입 이유
- 현재 Access Token 만 있고 , Refresh Token 은 없음
- Refresh Token 이 없으면, 사용자는 자주 로그인 해야 함
- 보안성과 사용자 경험을 둘 다 챙길 수 있음
Access Token vs Refresh Token
| 구분 | Access Token | Refresh Token |
|---|---|---|
| 목적 | API 요청 인증 | Access Token 재발급 |
| 사용 위치 | 매 API 요청 시 | Access Token 만료 시 |
| 유효 기간 | 짧음 (보통 5~30분) | 김 (보통 7일~30일) |
| 노출 위험 | 상대적으로 높음 | 상대적으로 낮아야 함 |
| 저장 위치 | 클라이언트 메모리 / 쿠키 | 서버(DB/Redis) + HttpOnly 쿠키 |
| 서버 저장 여부 | ❌ 보통 저장 안 함 | ✅ 저장하는 경우 많음 |
| 탈취 시 피해 | 제한적 (짧은 시간) | 큼 (토큰 재발급 가능) |
| 폐기 방식 | 만료 대기 | 서버에서 즉시 무효화 가능 |
Redis 설정
Refresh Token 을 Redis 에 저장하는 이유
Refresh Token은 다음과 같은 요구사항이 있다.
- 로그아웃 시 즉시 무효화
- 토큰 탈취 시 서버에서 강제 차단
- 만료 시간을 서버 기준으로 명확히 관리
이 요구사항을 만족하기 위해 Redis + TTL 구조를 선택했다.
Redis Template 설정
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String,String> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String,String> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
return template;
}
}
- Spring Boot 는 RedisTemplate 을 기본으로 자동 구성해줌
- 그럼에도 직접 설정한 이유
- 제네릭 타입 지정 가능 : 명확하게 타입 지정해 형변환 실수 방지
- 문자열뿐 아니라 객체 저장 시 Jackson,Json 등 지정 가능
- 커스텀 설정을 분리 관리 : 나중에 Redis 구성을 중앙에서 관리 가능
opsForValue()
Redis 에서 opsForValue() 는 String 자료구조를 다루기 위한 API 이다.
redisTemplate.opsForValue() 를 사용하면 Redis 의 String 타입 (key-value) 전용 작업 객체를 꺼낸다.
즉, 이제부터 String 타입 Redis 값을 다룬다는 뜻이다.
구현
JwtRefreshTokenService
@Service
@Slf4j
@RequiredArgsConstructor
public class JwtRefreshTokenService {
private final RedisTemplate<String, String> redisTemplate;
private final JWTUtil jwtUtil;
private final String PREFIX = "refresh:user"; // Redis Key 충돌 방지용
// Refresh Token 을 Redis 에 저장하면서 TTL 설정
public void save(UUID userUuId, String token, long duration, TimeUnit timeUnit) {
redisTemplate.opsForValue().set(PREFIX + userUuId, token, duration, timeUnit);
}
// 특정 사용자에게 저장된 Refresh Token 조회
public String get(UUID userUuId) {
return redisTemplate.opsForValue().get(PREFIX+userUuId);
}
// Refresh Token 삭제
public void delete(UUID userUuId) {
redisTemplate.delete(PREFIX+userUuId);
}
// 현재 유효한 토큰인지 검사
public boolean isValid(UUID userUuId, String token) {
String saved = get(userUuId);
return saved != null && saved.equals(token) && jwtUtil.validateToken(saved);
}
}
JwtRefreshTokenService
- Refresh Token 의 저장, 조회, 삭제, 유효성 검증 담당
Redis Key 설계
- 사용자 단위로 Refresh Token 1개만 유지
- 중복 로그인 시 이전 Refresh Token 자동 무효화
- Key 충돌 방지 및 관리 용이
save() - Refresh Token 을 Redis 에 저장 + TTL 설정
- 흐름
- 로그인 성공
- Refresh Token 생성
- Redis 에 저장
- TTL 설정
- 장점
- 별도의 만료 스케줄러 불필요
- Redis가 자동으로 만료 처리
get() - Refresh Token 조회
- 재발급 요청이 들어오면 Redis 에 저장된 토큰과 비교하기 위해 사용
- 흐름
- 값이 null → 이미 만료되었거나 로그아웃
- 값이 존재 → 다음 검증 단계 진행
delete() - Refresh Token 삭제
- 로그아웃 시 Redis 에서 해당 사용자의 Refresh Token 삭제
isValid() - Refresh Token 유효성 검증
- 흐름
- Redis 에 토큰 존재 여부
- 없으면 → 만료 또는 로그아웃
- Redis 에 저장된 토큰과 요청 토큰이 일치하는지
- 다르면 → 탈취 가능성
- JWT 자체가 유효한지
- 만료되었으면 → 재로그인
- Redis 에 토큰 존재 여부
JwtUtil - RefreshToken 생성 메서드 추가
// Refresh 토큰 생성
public String createRefreshToken(){
return Jwts.builder()
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + 1000L * 60 * 60 * 24 * 7)) // 7일
.signWith(secretKey)
.compact();
}
- Access Token 을 재발급 받기 위해 사용하므로 , Access Token 보다 수명이 길어야 한다.
- Redis 에서 1차 검증, JWT 자체에서 2차 검증 구조
흐름
- 발급시간 설정
- 만료시간 설정
- 1000L(밀리초)
- 606024(하루)
- 7(7일)
- 위조 방지 : 서버만 알고 있는 secretKey 로 서명
CustomUsernamePasswordAuthenticationFilter - 로그인 시 RefreshToken 발급 및 저장
// 로그인 성공 시 JWT 토큰 발급
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
try {
// 사용자 정보 추출
CustomUserDetails customUserDetails = (CustomUserDetails)authResult.getPrincipal();
UUID userUuid = customUserDetails.getUserUuid();
String email = customUserDetails.getEmail();
String name = customUserDetails.getName();
Role role = customUserDetails.getRole();
// jwt 토큰 생성
JWTUserDto user = new JWTUserDto(userUuid, email, name, role);
String accessToken = jwtUtil.createAccessToken(user);
String refreshToken = jwtUtil.createRefreshToken();
jwtRefreshTokenService.save(userUuid, refreshToken, REFRESH_EXPIRATION_DAYS, TimeUnit.DAYS);
// AccessToken 응답
response.addHeader("Authorization", "Bearer " + accessToken);
// RefreshToken 응답
ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken)
.httpOnly(true)
.secure(false)
.path("/")
.maxAge(60L * 60 * 24 * REFRESH_EXPIRATION_DAYS)
.sameSite("Lax")
.build();
response.addHeader("Set-Cookie", cookie.toString());
log.info("로그인 성공 - name: {}, role: {}", name, role);
} catch (Exception e) {
log.error("JWT 토큰 생성 중 오류 발생", e);
}
}
- Spring Security 에서 로그인 인증이 성공하면 successfulAuthentication() 이 호출됨
흐름
- 인증 객체에서 사용자 정보 꺼내기
- JWT 에 담을 사용자 DTO 구성
- AccessToken 에 들어갈 정보
- AccessToken / RefreshToken 생성
- RefreshToken 을 Redis 에 저장
- 클라이언트에만 맡기지 않고, Redis 에 저장해서 서버가 유효성을 관리
- AccessToken 은 Authorization 헤더로 반환
- RefreshToken 은 HttpOnly Cookie 로 반환
- 프론트 JS 에서 접근할 수 없는 HttpOnly 쿠키에 넣어 XSS 위험을 줄임
- httpOnly(true) : JS 로 읽기 불가
- samSite(”Lax”) : 기본적인 CSRF 완화
- maxAge() : 쿠키 만료
Refresh Token 재발급 API - AuthController
private final JwtRefreshTokenService jwtRefreshTokenService;
private final UserService userService;
private final JWTUtil jwtUtil;
private static final long REFRESH_EXPIRATION_DAYS = 7;
@PostMapping("/refresh")
public ResponseEntity<ApiResponse<LoginResponse>> refresh(@Valid @RequestBody RefreshTokenRequest request){
// Refresh 검증
if(!jwtRefreshTokenService.isValid(request.userUuid(),request.refreshToken())){
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ApiResponse<>("Refresh Token 인증 실패"));
}
// 사용자 조회
UserDto user = userService.findById(request.userUuid());
// 재발급
JWTUserDto jwtUserDto = new JWTUserDto(user.userUuId(),user.email(),user.name(),user.role());
String newAccessToken = jwtUtil.createAccessToken(jwtUserDto);
String newRefreshToken = jwtUtil.createRefreshToken();
// Redis 저장소 교체
jwtRefreshTokenService.save(user.userUuId(),newRefreshToken,REFRESH_EXPIRATION_DAYS, TimeUnit.DAYS);
LoginResponse response = new LoginResponse(newAccessToken,newRefreshToken,user);
return ResponseEntity.ok(new ApiResponse<>("Refresh Token 인증 성공",response));
}
흐름
- Refresh 재발급 API 엔드포인트
- 클라이언트는 userUuid 와 refreshToekn 을 바디로 보냄
- 서버는 검증 후 새 토큰을 내려줌
- Refresh Token 검증
- isValid()
- Redis 에 해당 사용자 키가 존재하는지
- Redis 에 저장된 토큰과 요청 토큰이 일치하는지
- JWT 자체가 만료,위조 되지 않았는지
- 실패하면 401 Unauthorized 처리
- isValid()
- 사용자 조회
- Refresh Token 이 유효하다고 판단하면, 토큰을 담을 사용자 정보를 가져옴
- 새 Access Token 발급
- 기존 토큰과 별개로 생성
- 새 Refresh Token 발급 + Redis 교체
- 재발급이 한 번 일어난 시점부터 이전 토큰은 사실상 무효
- 최종 응답 구성
- 새 Access Token,Refresh Token 사용자 정보
그 외 코드
LoginResponse
public record LoginResponse(
String accessToken,
String refreshToken,
UserDto user
) {
}
RefreshTokenRequest
public record RefreshTokenRequest(
@NotNull UUID userUuid,
@NotBlank String refreshToken
) {
}
로그아웃 시 RefreshToken 삭제 - AuthController
// 로그아웃
@PostMapping("/logout")
public ResponseEntity<ApiResponse<Void>> logout(
@AuthenticationPrincipal CustomUserDetails customUserDetails
){
jwtRefreshTokenService.delete(customUserDetails.getUserUuid());
return ResponseEntity.ok(new ApiResponse<>("로그아웃 성공"));
}
흐름
- @AuthenticationPrincipal 로 인증 사용자 식별
- Refresh Token 삭제 = 로그아웃
- Access Token 은 삭제하지 않는 이유
- Access Token 의 짧은 수명
- stateless 특성상 서버에서 개별 폐기가 어려움
📝 테스트

🤙추후 보완할 점
- Refresh Token 전달 방식 개선
- Body → HttpOnly Bookie
- Refresh Token 에 토큰 ID 부여
- Refresh Token Rotation + 재사용 탐지
- 멀티 디바이스/멀티 세션 지원
참고자료
'런닝 코스 공유 서비스' 카테고리의 다른 글
| [런닝 코스 공유 서비스] - 17. Oauth 로그인 (1) | 2026.01.12 |
|---|---|
| [런닝 코스 공유 서비스] - 16. AccessToken 블랙리스트 (0) | 2026.01.11 |
| [런닝 코스 공유 서비스] - 14. 회원가입 전, 이메일 인증 (0) | 2026.01.11 |
| [런닝 코스 공유 서비스] - 13. 회원 정보 조회 및 수정 (0) | 2026.01.11 |
| [런닝 코스 공유 서비스] - 12. 회원 , 프로필 도메인 및 회원가입 API 구현 (0) | 2026.01.11 |