JWT 인증을 처음부터 직접 구현하기
모바일 앱, SPA 프런트엔드, 다른 서버의 API 호출까지 클라이언트 종류가 늘어나면 세션 쿠키는 슬슬 안 맞기 시작합니다. 서버가 여러 대면 세션을 어디에 두고 공유할지 고민해야 하고, 쿠키를 자동으로 다루지 않는 클라이언트(네이티브 앱, 서버 간 통신)는 세션 방식 자체가 번거롭습니다. JWT(JSON Web Token)는 "서버가 상태를 들고 있지 않아도 되는" 인증 방식으로 이 문제를 우회합니다.
JWT는 암호화되어 있지 않다
가장 먼저 바로잡아야 할 오해입니다. JWT는 헤더.페이로드.서명 세 부분을 점으로 이어붙인 문자열이고, 앞의 두 부분은 그냥 Base64Url로 인코딩되어 있을 뿐입니다. 암호화가 아니라 인코딩입니다. 브라우저 개발자 도구나 jwt.io에 토큰을 붙여넣으면 페이로드 내용이 그대로 보입니다.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyMTIzIiwicm9sZSI6IkFETUlOIn0.4f8s...이 토큰을 디코딩하면 페이로드는 {"sub":"user123","role":"ADMIN"} 같은 평문 JSON입니다. 그래서 JWT 페이로드에 비밀번호나 주민등록번호 같은 민감 정보를 넣으면 안 됩니다. JWT가 보장하는 건 "이 내용을 서명 이후 아무도 바꾸지 않았다"는 무결성이지, 기밀성이 아닙니다. 서명이 유효하다는 건 이 토큰을 비밀키를 가진 서버가 발급했다는 뜻이지 내용을 숨긴다는 뜻이 아닙니다.
flowchart LR A["헤더\nalg, typ"] -->|"."| B["페이로드\nsub, role, exp 등 claim"] B -->|"."| C["서명\nHMACSHA256(헤더+페이로드, 비밀키)"]
토큰 발급하기
jjwt 라이브러리로 최소한의 발급 로직을 짜보면 이렇습니다.
@Component
public class JwtProvider {
private final SecretKey key;
private final long expirationMs = 1000 * 60 * 30; // 30분
public JwtProvider(@Value("${jwt.secret}") String secret) {
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String createToken(String username, String role) {
Date now = new Date();
Date expiry = new Date(now.getTime() + expirationMs);
return Jwts.builder()
.subject(username)
.claim("role", role)
.issuedAt(now)
.expiration(expiry)
.signWith(key)
.compact();
}
}비밀키(jwt.secret)는 환경변수나 시크릿 매니저로 관리해야지, 소스코드에 하드코딩하거나 짧은 문자열을 쓰면 안 됩니다. HMAC-SHA256을 쓴다면 최소 256비트(32바이트) 이상의 무작위 값이어야 합니다. "my-secret-key" 같은 문자열을 실서비스에 쓴 사례를 실제로 본 적이 있는데, 이런 값은 사전 공격으로 몇 분 안에 뚫립니다.
검증 필터 만들기
발급한 토큰을 요청마다 검증하는 필터는 OncePerRequestFilter를 상속해 만듭니다. 2장에서 본 필터 체인에 addFilterBefore로 끼워넣을 필터가 이겁니다.
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
try {
Claims claims = jwtProvider.parseClaims(token);
String username = claims.getSubject();
String role = claims.get("role", String.class);
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role));
var authentication = new UsernamePasswordAuthenticationToken(username, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (JwtException e) {
// 서명이 틀렸거나 만료된 토큰. 인증하지 않은 채로 다음 필터로 넘긴다.
// 여기서 응답을 직접 써버리면 permitAll 경로까지 막히므로 조용히 넘긴다.
}
}
chain.doFilter(request, response);
}
}주석에 적어둔 부분이 실무에서 자주 놓치는 지점입니다. 토큰이 없거나 잘못됐다고 이 필터에서 바로 401 응답을 써버리면, permitAll()로 열어둔 회원가입이나 로그인 API까지 토큰 없이는 통과하지 못하는 상황이 됩니다. 인증 실패를 최종 판단하는 건 이 필터가 아니라 뒤에 있는 AuthorizationFilter의 몫입니다. 이 필터는 "토큰이 유효하면 인증 정보를 채워준다"까지만 하고, 최종 판단은 넘깁니다.
세션 방식과 결정적으로 다른 지점이 SessionCreationPolicy.STATELESS 설정입니다. 2장 예제 코드에도 있었지만 다시 강조할 가치가 있습니다. JWT를 쓴다면 이 옵션을 반드시 켜야 합니다. 켜지 않으면 스프링 시큐리티가 여전히 매 요청마다 세션을 만들고 관리하려 들어서, JWT의 장점인 "서버가 상태를 안 들고 있어도 된다"가 무의미해집니다.
그래서 세션과 JWT, 뭘 써야 하나
단일 서버로 운영되는 서버 렌더링 웹사이트라면 세션이 여전히 더 단순하고 안전한 선택입니다. 굳이 JWT로 바꿀 이유가 없습니다. 반대로 모바일 앱이 있거나, 여러 서비스가 같은 인증 서버를 바라봐야 하거나, 서버를 수평 확장할 계획이 있다면 JWT 쪽이 맞습니다. "요즘은 다 JWT 쓰던데요"라는 이유만으로 세션에서 JWT로 갈아타는 건 권하지 않습니다. 로그아웃 처리 하나만 봐도 세션은 서버에서 세션을 지우면 끝이지만, JWT는 만료 전까지 유효한 토큰을 무효화할 방법이 없어서 별도 장치가 필요합니다. 그 이야기는 다음 장에서 이어집니다.