[미니프로젝트] 토큰 재발급 구현

김동혁·2025년 12월 7일

미니프로젝트

목록 보기
5/16

기능들을 구현하면서 토큰에 대한 작업이 반복되는 것을 느꼈다
그래서 토큰 검증, 삭제, 저장 같은 메서드들을 한 곳에서 관리해서 중복을 좀 없애고 싶었다
TokenService를 만들어서 토큰을 관리해보자

토큰 관리 클래스

TokenService

@Service
@RequiredArgsConstructor
public class TokenService {

    private final RefreshTokenRepository refreshTokenRepository;
    private final JwtUtil jwtUtil;

    // DB에 Refresh 토큰 저장
    @Transactional
    public void addRefreshToken(User user, String refresh) {
        RefreshToken refreshToken = RefreshToken.builder()
                .user(user)
                .tokenValue(refresh)
                .build();

        refreshTokenRepository.save(refreshToken);
    }

    // 쿠키 생성
    public Cookie createCookie(String key, String value) {
        Cookie cookie = new Cookie(key, value);
        cookie.setMaxAge(24 * 60 * 60);
        cookie.setHttpOnly(true);
        return cookie;
    }

    // DB에 Refresh 토큰이 존재하는지 확인
    public Boolean existsByTokenValue(String tokenValue) {
        return refreshTokenRepository.existsByTokenValue(tokenValue);
    }

    // DB에서 Refresh 토큰을 삭제
    @Transactional
    public void deleteByTokenValue(String tokenValue) {
        refreshTokenRepository.deleteByTokenValue(tokenValue);
    }

    // 리프레시 토큰 검증
    public void validateRefreshToken(String token) {
        if (token == null) {
            throw new InvalidTokenException("리프레시 토큰이 없습니다.");
        }

        try {
            jwtUtil.isExpired(token);
        } catch (ExpiredJwtException e) {
            throw new InvalidTokenException("만료된 리프레시 토큰입니다.");
        }

        String category = jwtUtil.getCategory(token);
        if (!category.equals("refresh")) {
            throw new InvalidTokenException("유효하지 않은 카테고리의 토큰입니다.");
        }

        if (!existsByTokenValue(token)) {
            throw new InvalidTokenException("DB에 존재하지 않는 리프레시 토큰입니다.");
        }
    }

    // 액세스 토큰 검증
    public void validateAccessToken(String token) {
        if (token == null) {
            throw new InvalidTokenException("액세스 토큰이 없습니다.");
        }

        try {
            jwtUtil.isExpired(token);
        } catch (ExpiredJwtException e) {
            throw new InvalidTokenException("만료된 액세스 토큰입니다.");
        }

        String category = jwtUtil.getCategory(token);
        if (!category.equals("access")) {
            throw new InvalidTokenException("유효하지 않은 카테고리의 토큰입니다.");
        }

    }

}

이제 토큰과 관련된 작업들은 TokenService를 사용하도록 바꿔주었다

토큰 재발급 구현

Refresh 토큰이 있는 이유가 이제 나타난다
Access 토큰만 구현이 되어 있다면 생명 주기가 짧기 때문에 만료시 다시 로그인해야 하는 상황이 벌어진다

  • Access 토큰: 권한이 필요한 모든 요청 헤더에 사용되고, 탈취 위험을 낮추기 위해 짧은 생명 주기를 가진다
  • Refresh 토큰: Access 토큰이 만료되었을 때 재발급을 위한 용도로만 사용되고 상대적으로 긴 생명 주기를 가진다

다중 토큰 구현 포인트

  1. 로그인이 완료되면 Access/Refresh 토큰을 발급해준다
  2. Access 토큰 요청을 검증하는 로직에서 Access 토큰이 만료된 경우는 프론트 개발자와 협의된 상태 코드와 메시지를 응답한다
  3. 프론트 측 API 클라이언트(axios, fetch) 요청 시 Access 토큰 만료 요청이 오면 예외문을 통해 Refresh 토큰을 서버측으로 전송하고 Access 토큰을 발급받는 로직을 수행한다 (기존 Access는 제거)
  4. 서버 측에서는 Refresh 토큰을 받을 엔드포인트(/api/auth/reissue)를 구성하여 Refresh 토큰을 검증하고 Access 토큰 응답

컨트롤러 구성

AuthController

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

	private final AuthService authService;

	@PostMapping("/signup")
	public ResponseEntity<String> signup(@RequestBody SignupDto signupDto) {
		authService.signup(signupDto);
		return ResponseEntity.ok("회원가입 성공");
	}

	// 토큰 재발급
    @PostMapping("/reissue")
    public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {
        return authService.reissue(request, response);
    }

	@PostMapping("/login")
	public ResponseEntity<String> login(@RequestBody LoginDto loginDto, HttpServletResponse response) {
		authService.login(loginDto, response);
		return ResponseEntity.ok("로그인 성공");
	}

}

reissue 로직 만들기

우선 재발급 요청이 온다면 Access 토큰이 헤더에 담겨 왔는지 검증을 해야 한다
Access 토큰이 유효하다면 Refresh 토큰 또한 검증한다

Access 토큰이 만료되었기 때문에 만료된 토큰에서 username을 꺼내는 메서드를 사용해서 username을 꺼낸다

username으로 DB에서 회원을 찾아 새로운 Access/Refresh 토큰을 발급해준다
기존 Refresh 토큰은 DB에서 삭제 후 새로운 토큰을 DB에 저장한다

헤더와 쿠키에 새 토큰을 세팅한다

AuthService

@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService  {

	private final UserRepository userRepository;
	private final PasswordEncoder passwordEncoder;
    private final JwtUtil jwtUtil;
    private final TokenService tokenService;
	private final AuthenticationManager authenticationManager;
    
    // ... 이전 코드 ... //

    // 토큰 재발급
    public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {

        String authorization = request.getHeader("Authorization");
        // Access Token 검증
        if (authorization == null || !authorization.startsWith("Bearer ")) {
            throw new InvalidTokenException("액세스 토큰이 없습니다.");
        }
        String accessToken = authorization.split(" ")[1];

        // Refresh 토큰 검증
        String refresh = null;
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals("refresh")) {
                    refresh = cookie.getValue();
                    break;
                }
            }
        }

        tokenService.validateRefreshToken(refresh);

        // --- 검증 통과 --- //

        // 기존 토큰에서 username 꺼냄
        String username = jwtUtil.getUsernameFromExpirationToken(accessToken);

        User foundUser = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("회원을 찾을 수 없습니다."));

        // 새로운 access/refresh 토큰 생성
        String newAccess = jwtUtil.generateAccessToken("access", username, foundUser.getRoleName());
        String newRefresh = jwtUtil.generateRefreshToken("refresh");

        // 기존 Refresh 토큰 DB에서 삭제 후 새 Refresh 토큰 저장
        tokenService.deleteByTokenValue(refresh);
        tokenService.addRefreshToken(foundUser, newRefresh);

        // 응답 설정
        response.setHeader("Authorization", "Bearer " + newAccess);
        response.addCookie(tokenService.createCookie("refresh", newRefresh));

        return new ResponseEntity<>(HttpStatus.OK);
    }

}

테스트

Access 토큰이 만료됐다고 가정하고 /api/auth/reissue로 요청을 해보자
Postman에서 헤더에 Authorization 값에 기존 Access 토큰 값을 담아서 요청하면
새로운 토큰으로 발급해준다

  • 기존 Access: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsInJvbGUiOiJST0xFX1VTRVIiLCJjYXRlZ29yeSI6ImFjY2VzcyIsImlhdCI6MTc2NTA5NDMwNCwiZXhwIjoxNzY1MDk2MTA0fQ.TglDXiwybiJKLreQAOdvlEN9jjpg-L_vFvMDu6jW7e8
  • 새로운 Access: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsInJvbGUiOiJST0xFX1VTRVIiLCJjYXRlZ29yeSI6ImFjY2VzcyIsImlhdCI6MTc2NTA5Njg2OCwiZXhwIjoxNzY1MDk4NjY4fQ.CrehsQH6AojJOhMaVYLO4j53-ojjlk_kitQBmIleEfQ

Refresh 토큰 또한 바뀌는 것을 확인했다

다음은 로그아웃을 구현하겠다

0개의 댓글