[Spring] JWT 02. AccessToken / RefreshToken 구조 + 재발급 흐름

[Spring] JWT 02. AccessToken / RefreshToken 구조 + 재발급 흐름

JWT를 실제 서비스에서 쓰려면 보통 토큰을 2개로 나눕니다.
AccessToken은 짧게, RefreshToken은 길게 가져가서 보안을 올리는 방식입니다.


1. 왜 토큰을 2개로 나눌까?

AccessToken 하나만 오래 쓰면 보안이 약해집니다. (탈취되면 오래 위험)
그래서 AccessToken은 짧게(예: 10~30분), 만료되면 RefreshToken으로 재발급 받습니다.

구분 AccessToken RefreshToken
역할 API 요청 인증 AccessToken 재발급
만료 짧음 (10~30분) 김 (며칠~몇 주)
저장 프론트 메모리/스토리지 가능하면 더 안전하게(쿠키/DB)

2. 전체 인증 흐름 (가장 중요)

  1. 사용자 로그인(아이디/비밀번호)
  2. 서버가 로그인 성공 시 AccessToken + RefreshToken 발급
  3. 프론트는 요청할 때마다 AccessToken을 헤더에 실어 보냄
  4. AccessToken이 만료되면 401 발생
  5. 프론트가 RefreshToken으로 재발급 API 호출
  6. 서버가 RefreshToken을 검증하고 새 AccessToken(또는 새 RefreshToken도) 발급

3. (예제) 토큰 발급 코드 - AccessToken / RefreshToken

아래 예시는 jjwt를 사용해 Access/Refresh를 발급하는 기본 형태입니다.
실제 프로젝트에서는 RefreshToken을 DB에 저장해서 관리하는 경우가 많습니다.

import java.nio.charset.StandardCharsets;
import java.util.Calendar;
import java.util.Date;
import javax.crypto.SecretKey;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;

public class JwtTokenServiceExample {

    private static final String KEY_STR = "여기에_32자이상_랜덤키";
    private static final SecretKey KEY =
            Keys.hmacShaKeyFor(KEY_STR.getBytes(StandardCharsets.UTF_8));

    // AccessToken: 30분
    public static String generateAccessToken(String loginId, String loginLevel) {
        Calendar c = Calendar.getInstance();
        Date now = c.getTime();
        c.add(Calendar.MINUTE, 30);
        Date exp = c.getTime();

        return Jwts.builder()
                .signWith(KEY)
                .issuer("my-service")
                .issuedAt(now)
                .expiration(exp)
                .claim("loginId", loginId)
                .claim("loginLevel", loginLevel)
                .compact();
    }

    // RefreshToken: 7일(예시) = 7*24*60분
    public static String generateRefreshToken(String loginId) {
        Calendar c = Calendar.getInstance();
        Date now = c.getTime();
        c.add(Calendar.MINUTE, 7 * 24 * 60);
        Date exp = c.getTime();

        return Jwts.builder()
                .signWith(KEY)
                .issuer("my-service")
                .issuedAt(now)
                .expiration(exp)
                .claim("loginId", loginId)
                .compact();
    }
}

4. (예제) RefreshToken으로 재발급 API 설계

Refresh 재발급은 보통 아래처럼 동작합니다.

  • 프론트가 RefreshToken을 서버에 보냄
  • 서버는 RefreshToken이 유효한지 확인
  • 유효하면 새 AccessToken 발급
  • (선택) RefreshToken도 함께 교체(로테이션)하면 더 안전

아래는 컨트롤러 형태 예시입니다.

@PostMapping("/api/account/refresh")
public ResponseEntity<Map<String, String>> refresh(@RequestBody Map<String, String> body) {

    String refreshToken = body.get("refreshToken");

    // 1) refreshToken 검증(서명/만료/issuer)
    TokenVO tokenVO = tokenService.parse("Bearer " + refreshToken);

    // 2) DB에 저장된 refreshToken과 일치하는지 확인(권장)
    boolean ok = tokenService.checkRefreshToken(tokenVO, "Bearer " + refreshToken);
    if(!ok) return ResponseEntity.status(401).build();

    // 3) 새 accessToken 발급
    MemberDto member = memberDao.selectOne(tokenVO.getLoginId());
    String newAccess = tokenService.generateAccessToken(member);

    Map<String, String> result = new HashMap<>();
    result.put("accessToken", newAccess);

    return ResponseEntity.ok(result);
}

5. 프론트에서 사용하는 방법

5-1) 일반 요청

axios.get("/api/mypage", {
  headers: {
    Authorization: `Bearer ${accessToken}`
  }
});

5-2) 만료 시 재발급 후 재요청

async function requestWithRefresh() {
  try {
    return await axios.get("/api/mypage", {
      headers: { Authorization: `Bearer ${accessToken}` }
    });
  } catch (e) {
    if (e.response && e.response.status === 401) {
      // refresh 시도
      const r = await axios.post("/api/account/refresh", {
        refreshToken: refreshToken
      });

      accessToken = r.data.accessToken;

      // 재요청
      return await axios.get("/api/mypage", {
        headers: { Authorization: `Bearer ${accessToken}` }
      });
    }
    throw e;
  }
}

6. 실행 결과 (Output 예시)

로그인 성공 시 서버가 토큰을 내려주면 예시는 아래처럼 나옵니다.

{
  "accessToken": "eyJhbGciOiJIUzI1NiJ9....(생략)",
  "refreshToken": "eyJhbGciOiJIUzI1NiJ9....(생략)"
}

AccessToken이 만료되어 API 호출이 실패하면 보통 401이 발생합니다.

HTTP/1.1 401 Unauthorized

이때 refresh API를 호출하면 새로운 AccessToken을 받을 수 있습니다.

{
  "accessToken": "새로운AccessToken값"
}

7. 자주 하는 실수

  • RefreshToken을 클라이언트에 그냥 오래 보관만 하고 서버 검증(저장) 안 함 → 탈취 시 위험
  • AccessToken을 너무 길게 설정 → 보안 약해짐
  • RefreshToken 재발급 시 기존 refresh를 폐기 안 함 → 세션 무한 증가 가능

마무리

AccessToken은 짧게, RefreshToken은 길게 가져가서
만료 처리와 보안을 동시에 챙기는 게 JWT 운영의 핵심입니다.

다음 글(3편)에서는 Spring Boot에서 실제로 Interceptor 또는 Security Filter
로그인 필요한 API를 보호하는 방법을 정리해보겠습니다.