단순히 토큰이 "동작한다"는 수준을 넘어, 사용자가 탈퇴하거나 토큰이 탈취되는 등 다양한 실패/공격 시나리오에 대응할 수 있는 운영 가능한 JWT 정책을 정리한다.
1. 핵심 개념 1: subject 규약 (provider:oauthId)
1) 개념 정의
JWT의 sub(subject)는 토큰의 주인을 나타내는 식별자다. 우리 프로젝트에서는 이를 PROVIDER:OAUTH_ID 형식으로 고정한다.
- 예: GOOGLE:10923847, APPLE:000123.abcxyz
2) 왜 필요한가? (식별의 영속성)

- 공급자별 식별 체계 통합: OAuth 공급자마다 ID 체계가 다르다. 이메일은 사용자가 변경할 수 있고, 탈퇴 후 재가입 시 같은 이메일을 사용할 수도 있어 영구 식별자로 쓰기에 위험하다.
- 충돌 방지: provider를 접두어로 붙이면 다른 공급자에서 우연히 발생한 같은 숫자 ID라도 확실히 구분할 수 있다.
- 재발급 최적화: 토큰 내부에 provider 정보가 포함되어 있어, DB 조회 시 인덱스를 태우기 최적화된 키 조합을 즉시 생성할 수 있다.
2. 핵심 개념 2: Access / Refresh 분리
1) 왜 분리하는가? (보안과 UX의 절충안)
- Access 토큰 (단기): 실제 API 인가에 사용된다. 수명을 15~30분으로 아주 짧게 가져가 탈취 시 공격자가 권한을 행사할 수 있는 '기회의 창'을 최소화한다.
- Refresh 토큰 (장기): Access 토큰 재발급을 위해서만 존재한다. 수명을 7~30일로 길게 설정하여 사용자가 매번 로그인해야 하는 번거로움을 해결한다.
2) 실무 포인트: 용도 강제 (tokenType)
보안 사고의 단골 손님은 Refresh 토큰을 API 호출에 오용하는 것이다. 이를 방지하기 위해 토큰 내부에 tokenType 클레임을 박아넣고, 재발급 API 외의 모든 인가 필터에서 tokenType=ACCESS가 아닌 토큰은 거절해야 한다.
3. 핵심 개념 3: 토큰 재발급 프로세스

만료된 Access 대신 유효한 Refresh를 근거로 새 토큰을 발급하는 절차다. 여기서 가장 중요한 건 "현재 사용자 상태"를 DB에서 다시 확인하는 것이다.
[보안의 핵심] 왜 DB를 다시 조회하나?
토큰은 발행된 순간의 스냅샷이다. 만약 유저가 토큰 발행 5분 뒤에 탈퇴했거나 정지되었다면, 토큰 자체의 유효기간은 남아있더라도 재발급은 거부되어야 한다. DB 재조회는 'Stateless'한 JWT 환경에서 유일하게 실시간 제어를 할 수 있는 통로다.
검증 및 재발급 4단계 순서
- Refresh 토큰 검증: 서명 검증, 만료(exp) 체크, 용도(tokenType=REFRESH)를 확인한다.
- Subject 파싱: sub를 파싱해 규약에 맞는 provider와 oauthId를 추출한다.
- 사용자 상태 조회: DB에서 유저 존재 여부와 ACTIVE 상태를 확인한다.
- 새 Access 발급: 모든 검증 통과 시 새로운 Access 토큰을 생성해 반환한다.
4. 통합 코드 가이드
1) 서비스 레이어 구현 (AuthService.java)
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider;
/**
* OAuth 로그인 성공 직후 호출되는 초기 토큰 발급
*/
@Transactional
public TokenPair issueInitialTokens(OauthProvider provider, String oauthId) {
// [방어] 필수 값 누락 시 로직 진행 불가
if (provider == null || oauthId == null || oauthId.isBlank()) {
throw new JwtException("oauth identity is required");
}
// 유저 조회/생성 (정상 유저 컨텍스트 확보)
User user = userRepository.findByOauthProviderAndOauthId(provider, oauthId)
.orElseGet(() -> userRepository.save(User.builder()
.oauthProvider(provider)
.oauthId(oauthId)
.build()));
// 규약 생성: provider:oauthId (예: KAKAO:12345)
String subject = user.getOauthProvider().name() + ":" + user.getOauthId();
return new TokenPair(
jwtTokenProvider.generateAccessToken(subject, user),
jwtTokenProvider.generateRefreshToken(subject, user)
);
}
/**
* Refresh 토큰을 이용한 Access 토큰 재발급
*/
@Transactional(readOnly = true)
public String refreshAccessToken(String refreshToken) {
// 1. 토큰 자체 유효성 및 용도 검증
if (!jwtTokenProvider.validateToken(refreshToken) || !jwtTokenProvider.isRefreshToken(refreshToken)) {
throw new JwtException("invalid refresh token");
}
// 2. subject 규약 파싱
String subject = jwtTokenProvider.getSubject(refreshToken);
String[] parts = subject.split(":");
if (parts.length != 2) throw new JwtException("invalid subject format");
OauthProvider provider = OauthProvider.valueOf(parts[0]);
String oauthId = parts[1];
// 3. 현재 시점 사용자 상태 재조회 (실시간 보안 대응)
User user = userRepository.findByOauthProviderAndOauthId(provider, oauthId)
.orElseThrow(() -> new JwtException("user not found or inactive"));
// 4. 새 Access 토큰 발급
return jwtTokenProvider.generateAccessToken(subject, user);
}
}
2) 토큰 공급자 구현 (JwtTokenProvider.java)
@Component
public class JwtTokenProvider {
private static final String CLAIM_TOKEN_TYPE = "tokenType";
private static final String TYPE_ACCESS = "ACCESS";
private static final String TYPE_REFRESH = "REFRESH";
public String generateAccessToken(String subject, User user) {
return Jwts.builder()
.setSubject(subject)
.setExpiration(new Date(System.currentTimeMillis() + Duration.ofMinutes(30).toMillis()))
.claim(CLAIM_TOKEN_TYPE, TYPE_ACCESS)
.claim("role", user.getRole().name())
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public String generateRefreshToken(String subject, User user) {
return Jwts.builder()
.setSubject(subject)
.setExpiration(new Date(System.currentTimeMillis() + Duration.ofDays(14).toMillis()))
.claim(CLAIM_TOKEN_TYPE, TYPE_REFRESH)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public boolean isRefreshToken(String token) {
Claims claims = parseClaims(token);
return TYPE_REFRESH.equals(claims.get(CLAIM_TOKEN_TYPE, String.class));
}
}
5. 시나리오 분석: 실패 및 공격 대응
| 시나리오 검증 | 실패 지점 | 처리 결과 | 대응 논리 |
| 만료된 Refresh | Step 1 (exp) | 401 Unauthorized | 유효기간 초과. 클라이언트는 재로그인 시켜야 함. |
| Access를 재발급에 제출 | Step 1 (type) | 401 Unauthorized | 인가용 토큰을 연장용으로 오용할 수 없음. |
| 탈퇴/정지 유저 | Step 3 (DB 조회) | 401 Unauthorized | 토큰 서명은 맞지만, 유저가 유효하지 않으므로 거부. |
| 변조된 subject | Step 2 (split) | 401 Unauthorized | 규약(:)이 깨진 토큰은 즉시 폐기. |
| 토큰 탈취 시도 | Step 1 (Secret) | 401 Unauthorized | Secret Key가 없으므로 서명 검증에서 차단. |
6. 운영 보안 고도화 (Security Upgrades)
1) Refresh Token Rotation (강력 권장)
재발급 시 Access뿐만 아니라 Refresh 토큰도 새로 발급한다.
- 공격자가 Refresh 토큰을 훔쳐서 먼저 재발급을 받으면, 정당한 사용자가 재발급을 시도할 때 이미 사용된 토큰임을 서버가 감지할 수 있다. 이때 해당 사용자의 모든 세션을 만료시키는 정책을 가져가면 탈취 사고를 조기에 진단할 수 있다.
2) Revoke (폐기) 저장소 운영
로그아웃 요청 시, 해당 토큰의 jti(JWT ID)를 Redis에 저장한다. 인가 필터에서 Redis를 조회하여 폐기된 토큰인지 확인하면 무상태(Stateless) 아키텍처에서도 즉각적인 차단이 가능하다.
3) 에러 코드 정교화
클라이언트(FE)가 대응할 수 있도록 에러를 분리해야 한다.
- EXPIRED_ACCESS_TOKEN: 재발급 API 호출 필요
- EXPIRED_REFRESH_TOKEN: 로그아웃 및 로그인 페이지 이동 필요
- USER_STATUS_INACTIVE: "정지된 계정입니다" 메시지 노출 필요
7. 트러블슈팅 가이드
- Invalid Subject (401): 토큰 발급 시 buildSubject 로직과 재발급 시 split 로직이 일치하는지 확인. Enum의 name() 대소문자 불일치 주의.
- User Not Found: DB에 실제로 데이터가 있는지, 인덱스가 깨지지는 않았는지 확인. 탈퇴 로직에서 데이터를 하드 딜리트(Hard Delete) 하는지 여부 체크.
- 간헐적 서명 실패: 서버 분산 환경(L4/Gateway)에서 모든 인스턴스가 동일한 JWT Secret Key를 공유하고 있는지 확인. (NTP 시간 동기화 문제도 원인이 될 수 있음)
8. 마무리하며
재발급 정책은 "연장 규칙"이고, 초기 발급 정책은 "원본 규칙"이다.
provider:oauthId 규약을 통해 이 두 지점을 단단히 연결하고, 재발급 시 DB 재조회를 수행하는 것만으로도 운영 환경의 보안 수준을 비약적으로 높일 수 있다.
'Spring > Security' 카테고리의 다른 글
| [Spring Security] OAuth2 로그인 최종장: 토큰 정책과 앱 이동 흐름 (0) | 2026.05.11 |
|---|---|
| [Spring Security] OAuth2 로그인 성공 후: 신규와 기존 사용자를 가르는 설계의 모든 것 (0) | 2026.05.11 |
| [Spring Security] JwtAuthenticationFilter 해부: 인증 문지기의 설계와 역할 분리 (1) | 2026.05.09 |
| [Spring Security] 보안 정책의 설계도, SecurityConfig 핵심 코드 이해 (0) | 2026.05.06 |
| [Spring Security] OAuth2 로그인부터 JWT 인증까지: 전체 흐름 정리 (0) | 2026.05.06 |