이번 프로젝트에서도 역시나 jwt를 사용한 프로젝트가 될 것이고, 이번 기회에 자신은 없지만
인증인가 파트를 맡고싶다고하여 이것을 하게되었습니다. jwt란 무엇일까요?
현대 웹 애플리케이션에서 사용자 인증은 마치 공항 보안 검색대를 통과하는 것과 같습니다. 한 번 신원을 확인받으면 탑승권(토큰)을 받게 되고, 이후에는 그 탑승권만으로도 여러 곳을 자유롭게 이동할 수 있죠. JWT(JSON Web Token)는 바로 이러한 디지털 탑승권의 역할을 하는 기술입니다.
하지만 모든 보안 시스템이 그렇듯, JWT도 완벽하지 않습니다. 특히 Access Token만을 사용하는 구조는 여러 취약점을 내포하고 있으며, 이를 보완하기 위해 Refresh Token이라는 개념이 등장했습니다. 이 글에서는 Access Token의 근본적인 문제점들을 살펴보고, Refresh Token이 어떻게 이러한 문제를 해결하는지 자세히 알아보겠습니다.
Access Token이란 무엇인가?
Access Token은 사용자가 로그인에 성공한 후 서버로부터 발급받는 인증 증명서입니다. 이 토큰에는 사용자의 신원 정보(user ID, 권한 등)가 암호화되어 담겨 있으며, 클라이언트는 이후 모든 요청에 이 토큰을 포함시켜 자신이 인증된 사용자임을 증명합니다.
JWT 형태의 Access Token은 세 부분으로 구성됩니다. Header는 토큰의 타입과 알고리즘 정보를 담고, Payload는 실제 사용자 데이터를 포함하며, Signature는 토큰의 무결성을 보장하는 서명입니다. 이 구조 덕분에 서버는 매번 데이터베이스를 조회하지 않고도 토큰만으로 사용자를 식별할 수 있습니다.
Access Token의 치명적인 취약점들
1. 탈취 시 무방비 상태
Access Token의 가장 큰 문제는 일단 탈취당하면 만료될 때까지 속수무책이라는 점입니다. XSS(Cross-Site Scripting) 공격이나 중간자 공격(Man-in-the-Middle)을 통해 토큰이 노출되면, 공격자는 토큰의 유효기간 동안 정당한 사용자인 것처럼 행동할 수 있습니다. 서버 입장에서는 토큰 자체가 유효하므로 정상적인 요청과 악의적인 요청을 구분할 방법이 없습니다.
전통적인 세션 방식이라면 서버에서 해당 세션을 즉시 무효화할 수 있지만, JWT는 상태를 서버에 저장하지 않는(stateless) 특성 때문에 이러한 긴급 조치가 불가능합니다. 마치 도난당한 신용카드를 정지시키고 싶어도 카드사와 연락이 닿지 않는 상황과 같습니다.
2. 유효기간의 딜레마
이 문제를 해결하려면 Access Token의 유효기간을 짧게 설정하면 됩니다. 예를 들어 5분이나 15분으로 설정하면 설사 토큰이 탈취되더라도 피해를 최소화할 수 있습니다. 하지만 여기서 또 다른 문제가 발생합니다.
유효기간이 짧으면 사용자는 매우 자주 다시 로그인해야 합니다. 유튜브를 보다가 15분마다 로그인 화면으로 돌아간다고 상상해보세요. 이는 사용자 경험을 심각하게 해칩니다. 반대로 유효기간을 길게(예: 24시간, 7일) 설정하면 편리하지만 보안이 취약해집니다. 이것이 바로 Access Token만으로는 해결할 수 없는 근본적인 딜레마입니다.
3. 토큰 크기와 민감정보 노출
JWT는 base64로 인코딩되어 있을 뿐 암호화되지 않았습니다. 누구나 디코딩하여 내용을 볼 수 있습니다. 따라서 민감한 개인정보를 담기에는 부적절하며, 많은 정보를 담을수록 토큰 크기가 커져 네트워크 부담이 증가합니다. 매 요청마다 토큰을 전송해야 하므로, 큰 토큰은 성능 저하의 원인이 됩니다.
4. 갱신의 어려움
사용자의 권한이나 정보가 변경되었을 때, 이미 발급된 Access Token에는 이 변경사항이 반영되지 않습니다. 토큰이 만료될 때까지 기다려야만 새로운 정보가 적용됩니다. 예를 들어 관리자가 특정 사용자의 권한을 즉시 제거하고 싶어도, 해당 사용자의 토큰이 유효한 동안에는 여전히 이전 권한으로 접근할 수 있습니다.
Refresh Token: 슬기로운 토큰생활
Refresh Token은 Access Token의 이러한 문제들을 해결하기 위해 고안된 보완 메커니즘입니다. 핵심 아이디어는 매우 단순하면서도 효과적입니다. Access Token은 매우 짧은 유효기간(10~30분)으로 발급하고, Refresh Token은 훨씬 긴 유효기간(몇 주에서 몇 달)으로 발급하는 것입니다.
Refresh Token의 동작 원리
사용자가 로그인하면 서버는 두 개의 토큰을 발급합니다. 짧은 수명의 Access Token과 긴 수명의 Refresh Token입니다. 클라이언트는 일반적인 API 요청에 Access Token을 사용하며, Access Token이 만료되면 Refresh Token을 서버에 제시하여 새로운 Access Token을 발급받습니다. 이 과정은 사용자가 전혀 인지하지 못하는 사이에 자동으로 이루어집니다.
보안성 향상
Refresh Token은 Access Token보다 훨씬 민감하게 관리됩니다. 일반적으로 HttpOnly 쿠키에 저장하여 JavaScript로 접근할 수 없게 하거나, 안전한 저장소에 보관합니다. 또한 Refresh Token은 오직 토큰 갱신 엔드포인트에만 사용되므로, 노출 위험이 크게 줄어듭니다.
더 중요한 점은 Refresh Token은 서버의 데이터베이스나 Redis 같은 저장소에 보관할 수 있다는 것입니다. 이는 필요할 때 즉시 무효화할 수 있음을 의미합니다. 사용자가 로그아웃하거나, 보안 이슈가 발견되거나, 의심스러운 활동이 감지되면 서버는 해당 Refresh Token을 즉시 무효화하여 새로운 Access Token 발급을 차단할 수 있습니다.
사용자 경험 개선
사용자 관점에서는 한 번 로그인하면 Refresh Token의 유효기간 동안 재로그인할 필요가 없습니다. Access Token은 백그라운드에서 자동으로 갱신되므로, 서비스를 중단 없이 계속 사용할 수 있습니다. 보안과 편의성이라는 두 마리 토끼를 동시에 잡는 것입니다.
실제 구현: Java 코드로 살펴보기
이론을 이해했다면 이제 실제로 어떻게 구현하는지 살펴보겠습니다. 다음은 JWT 기반의 토큰 관리 시스템을 Java로 구현한 예제입니다.
JWT 토큰 관리 서비스
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
public class JwtTokenProvider {
// JWT 서명에 사용할 비밀 키 (실제 환경에서는 환경변수나 보안 저장소에서 관리)
private final Key secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS512);
// Access Token 유효기간: 15분
private final long ACCESS_TOKEN_VALIDITY = 15 * 60 * 1000;
// Refresh Token 유효기간: 7일
private final long REFRESH_TOKEN_VALIDITY = 7 * 24 * 60 * 60 * 1000;
/**
* Access Token 생성
* 사용자의 기본 정보만 포함하여 짧은 유효기간으로 발급
* 이렇게 짧은 유효기간을 설정함으로써 토큰 탈취 시 피해를 최소화
*/
public String generateAccessToken(Long userId, String username, String role) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + ACCESS_TOKEN_VALIDITY);
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("username", username);
claims.put("role", role);
claims.put("tokenType", "ACCESS");
return Jwts.builder()
.setClaims(claims)
.setSubject(username)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(secretKey)
.compact();
}
/**
* Refresh Token 생성
* 최소한의 정보만 포함하고 긴 유효기간으로 발급
* 오직 새로운 Access Token을 발급받는 용도로만 사용
*/
public String generateRefreshToken(Long userId) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + REFRESH_TOKEN_VALIDITY);
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("tokenType", "REFRESH");
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(secretKey)
.compact();
}
/**
* 토큰에서 사용자 ID 추출
* JWT를 파싱하여 Payload에 담긴 userId를 반환
*/
public Long getUserIdFromToken(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
return claims.get("userId", Long.class);
}
/**
* 토큰 유효성 검증
* 서명 검증, 만료 시간 확인 등을 수행
* 토큰이 변조되었거나 만료되었다면 예외 발생
*/
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token);
return true;
} catch (SecurityException e) {
// 서명이 유효하지 않음
return false;
} catch (ExpiredJwtException e) {
// 토큰이 만료됨
return false;
} catch (UnsupportedJwtException e) {
// 지원하지 않는 토큰 형식
return false;
} catch (IllegalArgumentException e) {
// 토큰이 비어있거나 null
return false;
}
}
/**
* 토큰 타입 확인
* Access Token인지 Refresh Token인지 구분
* 잘못된 토큰 타입 사용을 방지
*/
public String getTokenType(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
return claims.get("tokenType", String.class);
}
}
Refresh Token 저장소 관리
import java.util.Optional;
public interface RefreshTokenRepository {
/**
* Refresh Token 저장 메소드 필요
* 사용자 ID와 토큰을 매핑하여 데이터베이스나 Redis에 저장
* 토큰 만료 시간도 함께 저장하여 자동 정리 가능하도록 구성
*/
void save(Long userId, String refreshToken, Date expiryDate);
/**
* 사용자 ID로 Refresh Token 조회 메소드 필요
* 토큰 갱신 요청 시 저장된 토큰과 비교하여 유효성 검증
*/
Optional<String> findByUserId(Long userId);
/**
* Refresh Token 삭제 메소드 필요
* 로그아웃 시 또는 보안상 이유로 토큰을 즉시 무효화
* 이것이 Refresh Token을 서버에 저장하는 핵심 이유
*/
void deleteByUserId(Long userId);
/**
* 토큰 존재 여부 확인 메소드 필요
* 토큰 갱신 전 해당 Refresh Token이 유효한지 빠르게 검증
*/
boolean existsByUserIdAndToken(Long userId, String refreshToken);
/**
* 만료된 토큰 일괄 삭제 메소드 필요
* 스케줄러를 통해 주기적으로 실행하여 저장소 관리
*/
void deleteExpiredTokens();
}
인증 서비스 구현
public class AuthService {
private final JwtTokenProvider tokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
// 생성자
public AuthService(JwtTokenProvider tokenProvider,
RefreshTokenRepository refreshTokenRepository) {
this.tokenProvider = tokenProvider;
this.refreshTokenRepository = refreshTokenRepository;
}
/**
* 로그인 처리
* 사용자 인증 후 Access Token과 Refresh Token을 동시에 발급
* Refresh Token은 안전하게 저장하여 이후 검증에 사용
*/
public TokenResponse login(String username, String password) {
// 실제 환경에서는 사용자 인증 로직 필요
// 비밀번호 해시 비교, 계정 상태 확인 등
// 인증 성공 후 사용자 정보 조회
Long userId = getUserIdFromDatabase(username);
String role = getUserRoleFromDatabase(userId);
// 두 종류의 토큰 발급
String accessToken = tokenProvider.generateAccessToken(userId, username, role);
String refreshToken = tokenProvider.generateRefreshToken(userId);
// Refresh Token을 데이터베이스에 저장
// 이후 토큰 갱신 시 저장된 값과 비교하여 유효성 검증
Date refreshExpiryDate = new Date(System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000);
refreshTokenRepository.save(userId, refreshToken, refreshExpiryDate);
return new TokenResponse(accessToken, refreshToken);
}
/**
* Access Token 갱신
* 만료된 Access Token을 새로운 것으로 교체
* Refresh Token의 유효성을 철저히 검증하여 보안 유지
*/
public String refreshAccessToken(String refreshToken) {
// 1단계: Refresh Token 자체의 유효성 검증 (서명, 만료시간)
if (!tokenProvider.validateToken(refreshToken)) {
throw new InvalidTokenException("유효하지 않은 Refresh Token입니다");
}
// 2단계: Refresh Token 타입 확인
if (!"REFRESH".equals(tokenProvider.getTokenType(refreshToken))) {
throw new InvalidTokenException("Access Token 갱신에는 Refresh Token을 사용해야 합니다");
}
// 3단계: 사용자 ID 추출
Long userId = tokenProvider.getUserIdFromToken(refreshToken);
// 4단계: 데이터베이스에 저장된 토큰과 일치하는지 확인
// 이 검증이 핵심! 탈취된 토큰이나 무효화된 토큰 사용 방지
if (!refreshTokenRepository.existsByUserIdAndToken(userId, refreshToken)) {
throw new InvalidTokenException("저장되지 않았거나 무효화된 Refresh Token입니다");
}
// 5단계: 모든 검증 통과 시 새로운 Access Token 발급
String username = getUsernameFromDatabase(userId);
String role = getUserRoleFromDatabase(userId);
return tokenProvider.generateAccessToken(userId, username, role);
}
/**
* 로그아웃 처리
* Refresh Token을 데이터베이스에서 삭제하여 더 이상 토큰 갱신 불가능하게 만듦
* Access Token은 짧은 유효기간이므로 자연스럽게 만료됨
*/
public void logout(Long userId) {
refreshTokenRepository.deleteByUserId(userId);
}
/**
* 전체 기기에서 로그아웃 (보안 조치)
* 비밀번호 변경, 계정 탈취 의심 등의 상황에서 사용
* 모든 기기의 Refresh Token을 무효화하여 즉시 재로그인 요구
*/
public void logoutAllDevices(Long userId) {
refreshTokenRepository.deleteByUserId(userId);
// 추가로 블랙리스트에 현재 Access Token들을 등록할 수도 있음
}
// 데이터베이스 조회 메소드들 (실제 구현 필요)
private Long getUserIdFromDatabase(String username) {
// 데이터베이스에서 username으로 userId 조회 로직 구현 필요
return null;
}
private String getUsernameFromDatabase(Long userId) {
// 데이터베이스에서 userId로 username 조회 로직 구현 필요
return null;
}
private String getUserRoleFromDatabase(Long userId) {
// 데이터베이스에서 userId로 사용자 권한 조회 로직 구현 필요
return null;
}
}
// 토큰 응답 DTO
class TokenResponse {
private String accessToken;
private String refreshToken;
public TokenResponse(String accessToken, String refreshToken) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
// Getter, Setter 메소드 필요
}
// 커스텀 예외 클래스
class InvalidTokenException extends RuntimeException {
public InvalidTokenException(String message) {
super(message);
}
}
API 요청 시 토큰 검증 필터
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class JwtAuthenticationFilter implements Filter {
private final JwtTokenProvider tokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
// Authorization 헤더에서 토큰 추출
// 일반적으로 "Bearer {token}" 형식으로 전송됨
String bearerToken = httpRequest.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
String token = bearerToken.substring(7);
/**
* Access Token 검증 로직
* 1. 토큰 서명 유효성 확인
* 2. 만료 시간 확인
* 3. 토큰 타입이 ACCESS인지 확인
*/
if (tokenProvider.validateToken(token) &&
"ACCESS".equals(tokenProvider.getTokenType(token))) {
// 토큰이 유효하면 사용자 정보를 요청에 설정
Long userId = tokenProvider.getUserIdFromToken(token);
httpRequest.setAttribute("userId", userId);
// 다음 필터로 진행
chain.doFilter(request, response);
return;
}
}
// 토큰이 없거나 유효하지 않으면 401 Unauthorized 응답
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
httpResponse.getWriter().write("유효하지 않거나 만료된 토큰입니다");
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
// 필터 초기화 로직 필요시 구현
}
@Override
public void destroy() {
// 필터 종료 시 정리 로직 필요시 구현
}
}
추가 보안 강화 전략
코드 구현만으로 충분하지 않습니다. 다음과 같은 추가 보안 조치를 고려해야 합니다.
Refresh Token Rotation
Refresh Token을 사용할 때마다 새로운 Refresh Token을 발급하고 이전 토큰을 무효화하는 방법입니다. 이렇게 하면 토큰이 탈취되더라도 정상 사용자가 먼저 사용하는 순간 공격자의 토큰은 무효화됩니다. 또한 동일한 Refresh Token으로 여러 번 갱신을 시도하면 탈취로 간주하고 해당 사용자의 모든 토큰을 무효화할 수 있습니다.
IP 주소 및 User-Agent 추적
Refresh Token을 발급할 때 사용자의 IP 주소와 User-Agent 정보를 함께 저장합니다. 토큰 갱신 요청 시 이 정보가 일치하지 않으면 의심스러운 활동으로 판단하여 추가 인증을 요구하거나 토큰을 무효화할 수 있습니다.
Rate Limiting
토큰 갱신 엔드포인트에 Rate Limiting을 적용하여 무차별 대입 공격을 방지합니다. 특정 시간 내에 과도한 갱신 요청이 발생하면 일시적으로 차단합니다.
HTTPS 사용 필수
모든 토큰 전송은 반드시 HTTPS를 통해 이루어져야 합니다. HTTP를 사용하면 중간자 공격에 토큰이 노출될 수 있습니다.
결론: 균형잡힌 보안 설계
JWT와 토큰 기반 인증은 현대 웹 애플리케이션의 표준이 되었습니다. 하지만 Access Token만으로는 보안과 사용자 경험 사이에서 만족스러운 균형을 찾기 어렵습니다. 짧은 유효기간은 안전하지만 불편하고, 긴 유효기간은 편리하지만 위험합니다.
Refresh Token은 이 딜레마를 우아하게 해결합니다. Access Token은 짧게 유지하여 탈취 위험을 최소화하고, Refresh Token은 안전하게 관리하여 편의성을 제공합니다. 더 중요한 것은 Refresh Token을 서버에 저장함으로써 필요할 때 즉시 무효화할 수 있는 통제권을 확보한다는 점입니다.
보안은 단순히 기술을 적용하는 것이 아니라, 위협을 이해하고 적절한 대응책을 마련하는 것입니다. Access Token의 취약점을 인식하고 Refresh Token으로 보완하는 것은 그러한 균형잡힌 사고의 좋은 예시입니다. 완벽한 보안은 존재하지 않지만, 지속적인 개선과 다층적인 방어를 통해 충분히 안전한 시스템을 구축할 수 있습니다.
여러분의 애플리케이션에 이러한 토큰 전략을 적용할 때는 단순히 코드를 복사하는 것을 넘어, 각 구성 요소가 왜 필요한지, 어떤 위협을 방어하는지 깊이 이해하시기 바랍니다. 그것이 진정으로 안전한 시스템을 만드는 첫걸음입니다.
'Spring' 카테고리의 다른 글
| JWT + Redis, 그리고 피할 수 없는 SPOF 문제 (0) | 2025.10.22 |
|---|---|
| 스프링 부트 Test(어노테이션) (0) | 2024.11.16 |
| 스프링 부트 3의 특징(이전 버전과 비교) (0) | 2024.11.16 |
| 지연로딩,AOP,Mockito에 대하여 (0) | 2024.11.11 |
| entity 연관관계 (0) | 2024.11.11 |
