회원가입 서비스 구현 이후 AuthController, Redis 연동, Refresh Token 재발급 API까지 구현한 과정을 기록해요.
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/signup")
public ResponseEntity<Void> signUp(@Valid @RequestBody SignUpRequestDto request) {
authService.signUp(request);
return ResponseEntity.ok().build();
}
@PostMapping("/login")
public ResponseEntity<LoginResponseDto> login(@Valid @RequestBody LoginRequestDto request) {
return ResponseEntity.ok(authService.login(request));
}
@PostMapping("/reissue")
public ResponseEntity<LoginResponseDto> reissue(@RequestHeader("Authorization") String authHeader) {
String refreshToken = authHeader.substring(7); // "Bearer " 제거
return ResponseEntity.ok(authService.reissue(refreshToken));
}
}
요청 헤더에서 값을 꺼내는 어노테이션이에요.
Authorization: Bearer eyJhbGc...
클라이언트가 이렇게 헤더에 Refresh Token을 담아서 보내면 @RequestHeader로 꺼낼 수 있어요.
회원가입은 반환할 데이터가 없어서 Void를 써요.
로그인과 재발급은 토큰 2개를 반환해야 해서 LoginResponseDto를 써요.
@Getter
@NoArgsConstructor
public class LoginRequestDto {
@NotBlank(message = "이메일은 필수입니다.")
@Email(message = "이메일 형식이 올바르지 않습니다.")
private String email;
@NotBlank(message = "비밀번호는 필수입니다.")
private String password;
}
@Getter
@AllArgsConstructor
public class LoginResponseDto {
private String accessToken;
private String refreshToken;
}
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
spring:
data:
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT}
Spring Boot는 application.yaml에 Redis 설정만 있으면 StringRedisTemplate을 자동으로 Bean으로 등록해줘요.
// 그냥 주입해서 쓰면 됨
private final StringRedisTemplate redisTemplate;
// Redis에 저장
redisTemplate.opsForValue().set(
"refresh:" + user.getEmail(), // key
refreshToken, // value
jwtUtil.getRefreshExpiration(), // 만료시간 (7일)
TimeUnit.MILLISECONDS
);
key → "refresh:user@email.com"
value → "eyJhbGc..."
TTL → 7일
이메일을 키로 해서 유저별로 각각 저장해요. 같은 이메일로 다시 로그인하면 덮어써요 → 한 기기에서만 로그인 가능해요.
Access Token이 만료됐을 때 Refresh Token으로 새 Access Token을 발급받아요.
재발급 요청 시 Access Token + 새 Refresh Token 총 2개를 새로 발급해요.
클라이언트가 Refresh Token A로 재발급 요청
↓
새 Access Token 발급
새 Refresh Token B 발급 (Rotation)
↓
Redis에 A → B로 덮어씀
↓
클라이언트한테 둘 다 반환
Rotation 없으면
해커가 Refresh Token 탈취 → 7일 내내 Access Token 계속 발급 가능 ❌
Rotation 있으면
정상 유저가 재발급 요청 → Refresh Token이 B로 바뀜
해커가 탈취한 A로 요청 → Redis에 B가 있어서 불일치 → 차단 ✅
Refresh Token을 1회용으로 만드는 보안 패턴이에요.
public LoginResponseDto reissue(String refreshToken) {
// 1. JWT 서명 검증 및 이메일 추출
jwtUtil.validateToken(refreshToken);
String email = jwtUtil.getEmail(refreshToken);
// 2. Redis에서 Refresh Token 조회
String savedRefreshToken = redisTemplate.opsForValue().get("refresh:" + email);
// 3. Redis에 없으면 (로그아웃 or 만료)
if (savedRefreshToken == null) {
throw new CustomException(JwtErrorCode.REFRESH_TOKEN_NOT_FOUND);
}
// 4. 클라이언트가 보낸 토큰이랑 Redis에 있는 토큰 비교
if (!savedRefreshToken.equals(refreshToken)) {
throw new CustomException(JwtErrorCode.INVALID_REFRESH_TOKEN);
}
// 5. 유저 조회 (role 가져오기 위해)
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND));
// 6. 새 토큰 발급
String newAccessToken = jwtUtil.generateAccessToken(email, user.getRole().name());
String newRefreshToken = jwtUtil.generateRefreshToken(email);
// 7. Redis Refresh Token 덮어쓰기 (Rotation)
redisTemplate.opsForValue().set(
"refresh:" + email,
newRefreshToken,
jwtUtil.getRefreshExpiration(),
TimeUnit.MILLISECONDS
);
return new LoginResponseDto(newAccessToken, newRefreshToken);
}
INVALID_REFRESH_TOKEN(401, "유효하지 않은 Refresh Token입니다."),
REFRESH_TOKEN_NOT_FOUND(401, "Refresh Token이 존재하지 않습니다.");
회원가입 POST /api/auth/signup
↓
로그인 POST /api/auth/login
↓ Access Token (15분) + Refresh Token (7일) 발급
↓ Redis에 Refresh Token 저장
API 요청
↓ Authorization: Bearer {accessToken}
↓ JwtFilter에서 검증
Access Token 만료
↓
재발급 POST /api/auth/reissue
↓ Authorization: Bearer {refreshToken}
↓ 새 Access Token + 새 Refresh Token 발급 (Rotation)
다음 포스팅에서는 로그아웃 API 구현 과정을 다룰 예정이에요.