6. Spring Boot JWT 인증 구현기 - AuthController, Redis, Refresh Token

yoon·2026년 3월 30일

스프링 인증/인가

목록 보기
5/6

개요

회원가입 서비스 구현 이후 AuthController, Redis 연동, Refresh Token 재발급 API까지 구현한 과정을 기록해요.


1. AuthController 구현

@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));
    }
}

@RequestHeader가 뭐냐

요청 헤더에서 값을 꺼내는 어노테이션이에요.

Authorization: Bearer eyJhbGc...

클라이언트가 이렇게 헤더에 Refresh Token을 담아서 보내면 @RequestHeader로 꺼낼 수 있어요.

ResponseEntity

회원가입은 반환할 데이터가 없어서 Void를 써요.

ResponseEntity

로그인과 재발급은 토큰 2개를 반환해야 해서 LoginResponseDto를 써요.


2. LoginRequestDto / 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;
}

3. Redis 연동

의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

application.yaml 설정

spring:
  data:
    redis:
      host: ${REDIS_HOST}
      port: ${REDIS_PORT}

RedisConfig가 필요없는 이유

Spring Boot는 application.yaml에 Redis 설정만 있으면 StringRedisTemplate자동으로 Bean으로 등록해줘요.

// 그냥 주입해서 쓰면 됨
private final StringRedisTemplate redisTemplate;

4. 로그인 시 Refresh Token Redis 저장

// Redis에 저장
redisTemplate.opsForValue().set(
        "refresh:" + user.getEmail(),  // key
        refreshToken,                   // value
        jwtUtil.getRefreshExpiration(), // 만료시간 (7일)
        TimeUnit.MILLISECONDS
);

저장 구조

key   → "refresh:user@email.com"
value → "eyJhbGc..."
TTL   → 7일

이메일을 키로 해서 유저별로 각각 저장해요. 같은 이메일로 다시 로그인하면 덮어써요 → 한 기기에서만 로그인 가능해요.


5. Refresh Token 재발급 API (Rotation 적용)

핵심 목적

Access Token이 만료됐을 때 Refresh Token으로 새 Access Token을 발급받아요.

Rotation이 뭐냐

재발급 요청 시 Access Token + 새 Refresh Token 총 2개를 새로 발급해요.

클라이언트가 Refresh Token A로 재발급 요청
    ↓
새 Access Token 발급
새 Refresh Token B 발급 (Rotation)
    ↓
Redis에 A → B로 덮어씀
    ↓
클라이언트한테 둘 다 반환

왜 Rotation을 하냐

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);
}

JwtErrorCode에 추가한 에러코드

INVALID_REFRESH_TOKEN(401, "유효하지 않은 Refresh Token입니다."),
REFRESH_TOKEN_NOT_FOUND(401, "Refresh Token이 존재하지 않습니다.");

6. 전체 인증 흐름 정리

회원가입 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 구현 과정을 다룰 예정이에요.

0개의 댓글