프로젝트/기업 일정 관리 웹

JWT 인증 시스템 구현

yoon4360 2025. 4. 2. 00:24

프로젝트에 JWT를 도입하는 이유 부터 작성해보려 한다.

기존 세션 기반 인증 방식은 다음과 같은 단점이 있다.

 

  • 서버에서 사용자 상태(세션)를 직접 관리해야 한다.
  • 서버가 여러 대일 경우, 세션을 공유할 중앙 저장소(Redis 등)가 필요하다.
  • 이로 인해 수평 확장(Scale-out)에 어려움이 발생한다.

이를 해결하기 위해 JWT기반 인증 시스템을 도입했다.
JWT는 무상태(stateless) 인증 방식으로, 사용자 인증 정보를 토큰에 담아 클라이언트 측에서 관리한다.

 

 


 

 

구현 흐름

 

1. 로그인 시 Access Token + Refresh Token을 발급받는다.

2. Access Token은 HTTP 헤더, Refresh Token은 HttpOnly 쿠키에 저장한다.

3. Access Token 만료 시, Refresh Token으로 새 토큰을 재발급 받는다.

4. Redis에 Refresh Token 저장 → 토큰 탈취 방지 및 세션 만료 관리

 


 

 

Access Token & Refresh Token 생성

// JwtService.java
public String generateAccessToken(User user) {
    return Jwts.builder()
        .setSubject(user.getEmail())
        .claim("userId", user.getId())  // 커스텀 정보 추가 가능
        .setIssuedAt(new Date())
        .setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRATION_MS))
        .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
        .compact();
}

public String generateRefreshToken() {
    return Jwts.builder()
        .setIssuedAt(new Date())
        .setExpiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRATION_MS))
        .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
        .compact();
}

 

주의할 점

 

  • ACCESS_TOKEN_EXPIRATION_MS: 일반적으로 5~15분 정도로 설정한다. 
  • REFRESH_TOKEN_EXPIRATION_MS: 1주 ~ 30일 정도로 설정한다.
  • SECRET_KEY: 반드시 강력하고, 외부 노출되지 않도록 환경 변수로 관리해야 한다.

 


 

로그인 시 토큰 발급 & 쿠키 설정

// LoginController.java
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request, HttpServletResponse response) {
    User user = userService.authenticate(request);

    String accessToken = jwtService.generateAccessToken(user);
    String refreshToken = jwtService.generateRefreshToken();

    redisService.saveRefreshToken(user.getId(), refreshToken);  // Redis 저장

    // Refresh Token은 HttpOnly 쿠키로 저장
    ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken)
        .httpOnly(true)            // 자바스크립트 접근 불가
        .secure(true)              // HTTPS 환경에서만 사용
        .sameSite("Strict")        // CSRF 방지
        .path("/")
        .maxAge(REFRESH_TOKEN_EXPIRATION_MS / 1000)
        .build();

    response.setHeader(HttpHeaders.SET_COOKIE, cookie.toString());

    return ResponseEntity.ok(new TokenResponse(accessToken));
}

 

 

HttpOnly Cookie란?

  • HttpOnly: 자바스크립트에서 쿠키에 접근할 수 없다. → XSS 공격을 차단한다.
  • Secure: HTTPS에서만 쿠키를 전송한다. → 네트워크 가로채기 방지한다.
  • SameSite: 다른 사이트에서 오는 요청을 제한한다. → CSRF 방지한다.

Refresh Token은 장기 저장하므로 쿠키 보안 설정은 필수 이다.

 

 


 

Access Token 재발급 처리

// TokenController.java
@PostMapping("/token/refresh")
public ResponseEntity<?> refreshToken(@CookieValue("refreshToken") String refreshToken) {
    if (!jwtService.isValidToken(refreshToken)) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid Refresh Token");
    }

    Long userId = jwtService.extractUserId(refreshToken);
    String redisToken = redisService.getRefreshToken(userId);

    if (!refreshToken.equals(redisToken)) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Token Mismatch");
    }

    User user = userService.findById(userId);
    String newAccessToken = jwtService.generateAccessToken(user);

    return ResponseEntity.ok(new TokenResponse(newAccessToken));
}

 

중요한점

  • Refresh Token 검증 로직을 꼭 넣어야 한다.
  • Redis에 저장한 값과 비교해서 탈취 여부 확인해야 한다.
  • 불일치 시 로그인 만료 처리 → 보안 강화한다.

 


 

프론트엔드 - Axios Interceptor로 자동 갱신

 
api.interceptors.response.use(
  (res) => res,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        const { data } = await axios.post("/api/token/refresh", {}, { withCredentials: true });
        localStorage.setItem("accessToken", data.accessToken);
        originalRequest.headers["Authorization"] = `Bearer ${data.accessToken}`;
        return api(originalRequest);
      } catch {
        localStorage.removeItem("accessToken");
        window.location.href = "/login";
      }
    }

    return Promise.reject(error);
  }
);

 

 

핵심포인트

  • 401 에러 발생 시 자동으로 /token/refresh 요청한다.
  • 성공 시 Access Token 갱신하고 기존 요청 재시도 한다.
  • withCredentials: true → 쿠키 전송 필수
  • 실패 시 로그인 페이지로 이동한다.

 


 

마무리

 

  • JWT를 활용하면 서버가 인증 상태를 관리하지 않아도 되므로 수평 확장에 유리하다.
  • Refresh Token을 Redis + HttpOnly 쿠키로 안전하게 관리하면 보안성도 확보할 수 있다.
  • 프론트엔드에서 Axios Interceptor를 활용하면 사용자 경험을 해치지 않고 자동 재인증 처리가 가능하다.
  • 다만, Access Token은 만료를 빠르게 설정하고, Refresh Token 탈취나 중복 사용 여부는 꼭 검증해야 한다.