REST API 서비스와 Stateless 아키텍처
앞서 7장까지는 세션을 활용하는 브라우저 기반 웹 서비스에 대해 Spring Security를 어떻게 활용하는지 확인해보았다. 지금부터는 SPA(Single Page Application), 안드로이드/아이폰 모바일 앱 등을 위해 API를 제공하는 API 서버에서 Spring Security를 어떻게 활용할 수 있는지 알아본다.
REST API
- API 서비스 개발을 위한 가장 일반적인 접근 방법이며 (사실상) 표준
- 엄격하게 REST API 디자인 원칙을 따르는 것는 쉽지 않음
- 어쨋든 통상적으로 REST API 라고 부르고 있음 — 본 과정에서는 사회통념을 따르도록 함
- Java, Python, Go 등 다양한 언어로 풍부한 레퍼런스가 존재 — 구현이 쉬움
- HTTP 프로토콜 기반만 따르면 어떤 기술이든 응용 가능(XML, JSON)
- 앞에서 알아본 3-Tier Architecture 구조와 REST API 서비스를 궁합이 좋음
- 3-Tier Architecture의 장점 대부분은 REST API 서비스를 개발/운영할때 여전히 유요함
- REST API 서비스에서 Session을 꼭 써야 하는가에 대해서는 서비스 성격에 따라 고민이 필요함

Stateful vs Stateless
- Stateful 아키텍처의 장단점
- 일단 Session을 사용하고 있으면, Stateful 하다고 할 수 있음
- 앞서 살펴본 것처럼 수평확장(Scale-Out) 과정이 쉽지 않음 — Session Cluster 반드시 필요
- Session Cluster의 장애 또는 성능 병목이 서비스 전체에 큰 영향을 줄수 있음
- 단일 사용자의 다중 로그인 컨트롤, 사용자 유효성 체크, 강제 로그아웃 기능 구현이 쉬움
- Stateless 아키텍처의 장단점
- Session을 전혀 사용하지 않아야함 — 사실 HTTP 프로토콜 자체가 Stateless
- 수평확장이 매우 쉬움 — Session Cluster 필요 없음
- 단일 사용자의 다중 로그인 컨트롤, 사용자 유효성 체크, 강제 로그아웃 기능 구현이 어려움
- 무엇보다 완전한 Stateless 아키텍처 기반으로 유의미한 서비스 개발이 어려움
- 완전한 Stateless 서비스는 정적 리소스(html, css, javascript, 이미지 등)를 서비스 하는데 적합함 — 예: AWS S3
- 서버는 어떤식으로는 사용자를 식별할 수 있어야 함 (단, Session 사용 금지)
JWT (Json Web Token)
JWT는 Stateless 상태를 유지하며, 서버에서 사용자를 식별할 수 있는 수단을 제공한다.
- 서버에서 사용자가 성공적으로 인증되면 JWT를 반환함
- 클라이언트는 JWT를 로컬 영역에 저장하고, 이후 서버에 요청을 보낼 때 JWT를 HTTP 헤더에 포함시킴
- 서버는 클라이언트가 전달한 JWT를 통해 사용자를 식별할 수 있음

JWT 개요
- JSON 포맷을 사용하여 데이터를 만들기 위한 웹 표준 (RFC 7519)
- JWT는 자체적으로 필요한 모든 정보를 지니고 있다. (self-contained)
- 토큰에 대한 메타정보 (토큰타입, 사용된 해시 알고리즘)
- 사용자 정의 데이터
- 토큰 유휴성 검증을 위한 데이터
- 인터넷상에서 쉽게 전달할 수 있음
- URL-Safe 텍스트로 구성되기 때문에 HTTP 프로토콜의 어느 위치에든 들어갈 수 있음 (보통 HTTP 헤더에 들어감)
- 위변조 검증 가능
- 토큰이 위변조되지 않았음을 증명하는 서명(Signature) 포함
JWT 구조
- Header, Payload, Signature 세 부분으로 구성됨
- Header, Payload, Signature 세 부분을 Base64 Url-Safe 방식으로 인코딩하고 dot(.)을 구분자로 결합함
- Header — JWT를 검증하는데 필요한 정보를 담고 있음 (토큰 타입, 사용된 알고리즘)
- 알고리즘은 HMAC, RSA 방식을 지원
- 아래 그림에서 HS512는 HMAC using SHA-512를 의미
- HMAC 알고리즘에서 비밀키는 최소한 알고리즘의 서명 길이만큼의 비트를 가지고 있어야함 (HS512 — 64byte)
- Payload
- JWT를 통해 전달하고자 하는 데이터 — Claim-Set 이라고 함
- Claim 자체는 쉽게 말해 Key-Value 데이터 쌍을 의미함
- JWT 자체는 암호화되는 것이 아니기 때문에 민감정보를 포함해서는 안됨
- Reserved Claims, Public Claims, Custom Claims 으로 구분됨
- Reserved Claims — 미리 등록된 Claims 필수적으로 사용할 필요는 없지만 사용을 권고함
- iss — 토큰을 발급한 발급자 (Issuer)
- exp — 만료시간이 지난 토큰은 사용불가
- nbf — Not Before의 의미로 해당 시간 이전에는 토큰 사용불가
- iat — 토큰이 발급된 시각
- jti — JWT ID로 토큰에 대한 식별자
- Public Claims — 사용자 마음대로 쓸 수 있으나 충돌 방지를 위해 미리 정의된 이름으로 사용을 권고함
- Custom Claims — 사용자 정의 Claims (Reserved, Public 에 정의된 이름과 중복되지 않도록함)
- Signature
- 토큰 생성 주체만 알고 있는 비밀키를 이용해 헤더에 정의된 알고리즘으로 서명된 값
- 토큰이 위변조 되지 않았음을 증명함

String concatenated = encodedHeader + '.' + encodedClaims String token = base64URLEncode(hmacSha512(concatenated, key))
JWT 장단점 — Stateful vs. Stateless
- 장점
- 사용자 인증에 필요한 모든 정보는 토큰 자체에 포함하기 때문에 따로 스토리지가 필요 없음
- 수평확장이 매우 쉬움 — Session Cluster 필요 없음
- 따라서, Active User가 많은 서비스에서 JWT사용이 유리함
- Session을 사용할 경우 Active User 수 만큼 Session을 저장해야 하기 때문에 스토리지 관리가 어려워짐
- 단점
- 토큰 크기를 가능한 작게 유지해야 함
- 토큰 자체가 항상 HTTP 요청에 포함되어야 하기 때문에 토큰이 커질수록 불리함
- 유효기간이 남아 있는 정상적인 토큰에 대해 강제적으로 만료 처리가 어려움
- Session을 사용할 경우 동시 Session 제어, Session 만료 처리 등 보안상 이점이 있음
REST API with JWT 맛보기
앞서 7장까지와는 완전히 다른 REST API 서비스를 제공할 수 있는 웹 프로젝트를 구성해본다.
8장부터는 더이상 HTTPS 프로토콜을 사용하지 않는다.
- 불필요한 의존성 및 구현 제거
- thymeleaf 템플릿 및 spring-session 의존성 제거
- spring-boot-starter-thymeleaf, thymeleaf-extras-springsecurity5, spring-session-jdbc
- WebMvcConfigure 클래스 삭제
- Spring Security 설정 변경
- csrf, headers, formLogin, http-basic, rememberMe, logout filter 비활성화 처리
- Session 관련 정책은 STATELESS 변경

- JWT 의존성 추가 및 설정
- java-jwt — java 기반 JWT 모듈
<dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.18.1</version> </dependency>
- header — HTTP Header 이름
- issuer — 토큰 발급자
- client-secret — HS512 알고리즘으로 서명을 수행할 것이기 때문에 키 길이를 64바이트로 해야함
- expiry-seconds — 토큰 만료 시간 (초)
jwt: header: token issuer: prgrms client-secret: EENY5W0eegTf1naQB2eDeyCLl5kRS2b8xa5c4qLdS0hmVjtbvo8tOyhPMcAmtPuQ expiry-seconds: 60
@Component @ConfigurationProperties(prefix = "jwt") public class JwtConfigure { private String header; private String issuer; private String clientSecret; private int expirySeconds; public String getHeader() { return header; } public void setHeader(String header) { this.header = header; } public String getIssuer() { return issuer; } public void setIssuer(String issuer) { this.issuer = issuer; } public String getClientSecret() { return clientSecret; } public void setClientSecret(String clientSecret) { this.clientSecret = clientSecret; } public int getExpirySeconds() { return expirySeconds; } public void setExpirySeconds(int expirySeconds) { this.expirySeconds = expirySeconds; } }
- JWT 발행을 위한 sign 메소드
- JWT 검증을 위한 verify 메소드
@Bean public Jwt jwt(JwtConfigure jwtConfigure) { return new Jwt( jwtConfigure.getIssuer(), jwtConfigure.getClientSecret(), jwtConfigure.getExpirySeconds() ); }
public final class Jwt { private final String issuer; private final String clientSecret; private final int expirySeconds; private final Algorithm algorithm; private final JWTVerifier jwtVerifier; public Jwt(String issuer, String clientSecret, int expirySeconds) { this.issuer = issuer; this.clientSecret = clientSecret; this.expirySeconds = expirySeconds; this.algorithm = Algorithm.HMAC512(clientSecret); this.jwtVerifier = com.auth0.jwt.JWT.require(algorithm) .withIssuer(issuer) .build(); } public String sign(Claims claims) { Date now = new Date(); JWTCreator.Builder builder = com.auth0.jwt.JWT.create(); builder.withIssuer(issuer); builder.withIssuedAt(now); if (expirySeconds > 0) { builder.withExpiresAt(new Date(now.getTime() + expirySeconds * 1_000L)); } builder.withClaim("username", claims.username); builder.withArrayClaim("roles", claims.roles); return builder.sign(algorithm); } public Claims verify(String token) throws JWTVerificationException { return new Claims(jwtVerifier.verify(token)); } // ... 생략 ... static public class Claims {/*생략*/} }
- GET /api/user/{username}/token — 주어진 username으로 사용자정보를 가져와서 JWT 토큰을 만들고 반환하는 API
- GET /api/user/token/verify — HTTP 헤더를 통해 JWT 토큰을 전달받고, 토큰의 Claims을 Map으로 변환하여 반환하는 API


미션
- JWT 필터 (JwtAuthenticationFilter) 만들어보기
- HTTP 요청 헤더에서 JWT 토큰이 있는지 확인
- JWT 토큰에서 username, roles을 추출하여 UsernamePasswordAuthenticationToken을 생성
- 앞서 만든 UsernamePasswordAuthenticationToken를 SecurityContext에 넣어줌
- JWT 필터를 Spring Security 필터 체인에 추가 (어디에 추가하면 좋을지 고민)
- 필터를 추가한 후 HTTP 요청에 JWT 토큰을 추가하면, GET /api/user/me API 호출이 성공해야 함
- UserRestControllerTest 테스트를 통과해야 함