[ 13주차 - 1110 ]
금일 커리큘럼
├ 09:00 ~ 12:00 backend 프로그래밍 (JWT 인증, 세션 관리의 한계와 JWT의 필요성)
└ 13:00 ~ 18:00 backend 프로그래밍 (JWT 구조와 동작 원리, JWT 심플 구현, JWT tokenizer 구현)
1. JWT 인증
JWT (JSON Web Token) : 사용자를 인증하고 식별하기 위한 토큰 기반 인증 방식
1.1 JWT 인증 개념
JWT란?
- JSON Web Token
- 사용자 인증/식별을 위해 사용하는 토큰 기반 인증 방식
- RFC 7519(표준 규격)
- JSON 형식 데이터를 안전하게 전송하기 위한 개방형 표준
등장 배경
- 기존 쿠키와 세션 기반 인증의 확장성 문제 해결 필요하였고, Stateless 인증 방식이 요구되었음
- Stateless ? : 서버가 클라이언트 상태를 저장하지 않는 구조
- 쿠키와 세션에 민감한 정보가 저장되어 있는 경우, 노출되면 보안상 문제가 생김
- 세션에서 인증정보를 서버에 저장해놓고 요청이 오면 계속 인증정보를 확인해야 하는 비효율성 존재
JWT의 특징
- 자체 포함(Self-contained) : 토큰 자체에 사용자 정보와 권한 정보 포함
- 서명(Signature) : 토큰 위변조 방지를 위해 서명 포함
- 확장성(Scalability) : 서버에 세션 정보를 저장하지 않아 확장성 뛰어남
- 다양한 환경 지원 : 웹, 모바일, 마이크로서비스 등 다양한 환경에서 사용 가능
2. 세션 관리의 한계와 JWT의 필요성
2.1 세션 기반 인증 문제점
- 서버의 메모리나 디스크에 세션 정보를 저장해야 하므로 서버 메모리 사용량 증가
- 여러대의 서버를 운영할 경우 세션 동기화 문제 발생
- 다중 서버 환경에서의 세션 문제
서버 A에서 로그인한 사용자가서버 B로 요청을 보내면 세션 정보가 없어 인증 실패- 로드 밸런서가 요청을 분산하면서 세션 불일치 문제 발생
- 세션 복제(replication) 성능 저하와 네트워크 부하 유발
sequenceDiagram
participant Client as 클라이언트
participant LB as 로드밸런서
participant ServerA as 서버 A
participant ServerB as 서버 B
Client->>LB: 로그인 요청
LB->>ServerA: 로그인 요청 전달
ServerA->>ServerA: 세션 생성(SESSION123)
ServerA-->>Client: Set-Cookie: SESSION123(세션 발급)
Client->>LB: 다음 요청(SESSION123 포함)
LB->>ServerB: 요청을 서버 B로 배분
ServerB->>ServerB: SESSION123 없음
ServerB-->>Client: ❌ 403 Forbidden (세션 불일치)
클라이언트가 로그인 요청을 보냄로드 밸런서가 요청을서버 A로 전달서버 A가 세션을 생성하고클라이언트에 세션 쿠키를 발급클라이언트가 다음 요청을 보낼 때 세션 쿠키를 포함로드 밸런서가 요청을서버 B로 배분서버 B는 세션 정보를 알 수 없어 인증 403 실패 응답 반환
SESSION123 포함해서 요청했는데 서버B 에서 왜 인식 못할까?
- 세션은 서버 메모리에 저장되기 때문에
서버 A와서버 B가 서로 다른 메모리를 사용 서버 B는서버 A에서 생성된 세션 정보를 알 수 없음
세션기반 Redis를 통한 세션 클러스터링
세션 기반 인증의 "분산 환경 문제"를 해결하기 위해 Redis를 이용한 세션 클러스터링 방식을 사용됨
- 여러 서버(A, B, C)에서 세션을 공유하기 위해 중앙 세션 저장소를 사용
- Redis(인메모리 DB)에 세션을 저장하여 모든 서버가 같은 세션 데이터를 읽을 수 있음
- 확장성 문제는 어느 정도 해결되지만, 여전히 Stateful 구조임
Redis 동작 방식
flowchart LR
Client --> ServerA
ServerA --> Redis[(Redis)]
ServerB --> Redis
ServerC --> Redis
- 클라이언트가 로그인
- 서버에서 세션 생성 후 Redis에 저장
- 클라이언트는 세션쿠키 전달
- 모든 API 서버는 Redis에서 세션을 조회하여 인증 처리
✅ 장점
- 세션 동기화 문제 해결 (모든 서버가 Redis에서 세션 조회)
- 기존 세션 기반 인증 방식 유지 가능
❌ 단점
- Redis 장애 → 전체 인증 망가짐 (단일 장애점, Single Point of Failure)
- Redis 운영 비용 및 인프라 구성이 필요
- 여전히 Stateful 인증 (서버가 세션 상태를 관리)
2.2 JWT 필요성
- 기존 세션 쿠키 방식은 보안 취약하고, 확장성에 한계가 있음
- JWT는 서버에 세션 정보를 저장하지 않아도 되어 확장성 높음
- 클라이언트가 JWT를 저장하고 요청 시마다 전송하여 인증 처리
JWT를 통한 Stateless 인증
JWT의 핵심은 서버가 세션을 저장하지 않는 것으로, 이를 Stateless 인증이라고 함
Stateless 인증 동작 방식
flowchart LR
Client --> API1
Client --> API2
Client --> API3
API1 -->|JWT Signature 검증| SecretKey
API2 -->|JWT Signature 검증| SecretKey
API3 -->|JWT Signature 검증| SecretKey
- 사용자 인증 성공 → 서버가 JWT 발급
- 클라이언트가 JWT를 저장 후 모든 요청에서 헤더로 전송
- API 서버는 JWT Signature만 검증하면 인증 가능
- 세션 저장 필요 없음
✅ 장점
- 서버 부하 감소 (세션 저장 없음)
- 서버 개수가 늘어나도 인증 문제 없음(확장성 최강)
- 중앙 세션 저장소 불필요
- 분산/마이크로서비스 환경에 최적화
❌ 단점
- 토큰 탈취 시 위험 (만료 전까지 공격자가 악용 가능)
- 토큰 무효화(revoke)가 복잡
- Refresh Token 전략 필요
3. JWT 구조와 동작 원리
3.1 JWT 구조
JWT는 3개의 부분으로 구성되어 있음
- Header(헤더) : 토큰 타입과 해싱 알고리즘 정보 포함
{
"typ": "JWT", // 토큰 타입
"alg": "HS256" // 해싱 알고리즘
}
- Payload(페이로드) : 사용자 정보 및 클레임(Claims) 포함
- Base64 방식으로 인코딩되어도 암호화는 아니기에 민감한정보는 삽입 금지
{
"id": 1, // 각 claim 정보
"email": "example@example.com",
"name": "example",
"roles": ["ROLE_USER"],
"exp": 1625097600 // 만료 시간 (Unix 타임스탬프)
}
- Signature(서명) : 헤더와 페이로드를 인코딩한 후 비밀키로 서명한 값
- 서명을 통해 토큰이 변조되지 않았음을 검증함
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secretKey
)
# JWT 토큰 구조 (.) 으로 구분하고, 각각 Base64 URL-Safe 방식 인코딩됨
Header.Payload.Signature
# 토큰본문 예시
eyJ0eXAiOi.JKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6Impva... # 엄청 길다
3.2 동작 흐름
sequenceDiagram
participant Client as 클라이언트<br/>(브라우저/앱)
participant Server as 인증 서버<br/>(Auth Server)
participant API as API 서버<br/>(Resource Server)
Client->>Server: 1) 로그인 요청 (ID/PW 전송)
Server-->>Client: 2) 인증 성공<br/>Access Token + Refresh Token 발급
Client->>Client: 3) 토큰 저장 (로컬스토리지/쿠키 등)
Client->>API: 4) API 요청<br/>Authorization: Bearer {token}
API->>Server: 5) Access Token 검증<br/>(JWT Signature/Expiration)
Server-->>API: 검증 결과 OK
API-->>Client: 5-1) 요청 처리 결과 응답
Client->>API: Access Token 만료된 상태로 요청
API->>Client: 토큰 만료 응답(401 Unauthorized)
Client->>Server: 6) Refresh Token 전송하여 액세스 토큰 재발급 요청
Server-->>Client: 새 Access Token 발급
Client->>API: 새 Access Token으로 재요청
API-->>Client: 성공 응답
- 로그인: 클라이언트가 ID/PW를 서버에 전송
- 토큰 발급: 서버가 인증 성공 시
Access Token과Refresh Token발급 - 토큰 저장: 클라이언트가 토큰을 로컬 스토리지, 쿠키 등에 저장
- API 요청: 클라이언트가 Authorization: Bearer {token} 헤더에 토큰 포함
- 토큰 검증: 서버가 토큰을 검증하고 요청 처리
- 토큰 갱신: Access Token 만료 시 Refresh Token으로 새로운 Access Token 발급
- Expriation : 만료시간
- Signature : 서명
access Token vs Refresh Token
| 구분 | Access Token | Refresh Token |
|---|---|---|
| 목적 | API 접근 권한 부여 | 만료된 Access Token 재발급 |
| 수명 | 짧음 (예: 15분 ~ 1시간) | 김 (예: 며칠 ~ 몇 달) |
| 저장 위치 | 클라이언트 (로컬스토리지/쿠키) | 클라이언트 (보안상 쿠키 권장) |
| 사용 빈도 | 자주 사용 | 필요 시에만 사용 |
- Access Token은 자주 사용되며, 만료되면 Refresh Token으로 재발급
- Refresh Token은 보안상 노출되지 않도록 주의 필요하다
4. JWT 심플 구현
4.1 JWT 의존성 추가
Build.gradle
dependencies {
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
}
4.2 JWT 관련 클래스 정리
SecretKey (비밀키)
SecretKey는 JWT의 서명을 만들고 검증하는 데 사용하는 비밀키 객체
- HMAC 계열(HS256 등)은 문자열 기반 비밀키 사용
- 반드시 충분한 길이(32바이트 이상) 필요
주요메서드
| 클래스 | 설명 |
|---|---|
Keys.secretKeyFor() |
새로운 랜덤 SecretKey 생성 (알고리즘 기반) |
Keys.hmacShaKeyFor() |
특정 문자열 기반 SecretKey 생성 |
// import 부분
import javax.crypto.SecretKey;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.SignatureAlgorithm;
// 사용 예시
// 1) secretKeyFor 랜덤기반
SecretKey secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
// 2) hmacShaKeyFor 문자열 기반
String secret = "abcdefghijklmnopqrstuvwxzy123456"; // 최소 32바이트 이상
SecretKey secretKey2 = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
Jwts (JWT 빌더/파서)
Jwts는 JWT 생성과 JWT 파싱/검증을 위한 정적 유틸리티 클래스 (JWT 처리의 중심)
- 토큰생성 (빌더) :
Jwts.builder() - 토큰파싱 (검증) :
Jwts.parser()
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.Claims;
import java.util.Date;
// JWT 생성
String jwt = Jwts.builder()
.issuer("my-auth") // iss
.subject("user123") // sub
.claim("role", "ADMIN") // custom claim
.expiration(new Date(System.currentTimeMillis() + 3600 * 2000)) // exp
.signWith(secretKey2) // 비밀키로 서명
.compact();
// JWT 파싱 및 검증
Claims claims = Jwts.parser()
.verifyWith(secretKey2) // 서명 검증
.requireIssuer("my-auth") // iss 검증
.build()
.parseSignedClaims(jwt)
.getPayload();
System.out.println(claims.getSubject()); // user123
System.out.println(claims.get("role")); // ADMIN
Claims : JWT 페이로드의 클레임(Claims) 정보를 담는 객체임
- getSubject(), getIssuer(), getExpiration() 등 메서드 제공
- get("key")로 .claim("role", "ADMIN") 커스텀 클레임 접근 가능
4.3 JWT 코드 심플 예시
public class jwtExample {
public static void main(String[] args) {
// 1) 시크릿키 (256 비트) - 실행시마다 시크릿키 생성됨
SecretKey secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
System.out.println(secretKey);
// 2) 정해준 문자열 바탕으로 생성
String secret = "abcdefghijklmnopqrstuvwxzy123456"; // 노출되면 안됨
SecretKey secretKey1 = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
System.out.println(secretKey1);
// 3) JWT 생성
String jwt =
Jwts.builder()
.issuer("star1431-auth") // 토큰 누가 발급
.subject("star1431") // 토큰 주인
.audience() // 토큰을 사용할 수 있는 대상 서버 목록
.add("s-server1")
.add("s-server2")
.and() // 추가 끝
.expiration( // 토큰 만료 시간 (지금부터 2시간 뒤에 만료)
new Date(System.currentTimeMillis() + 3600 * 2000) // 2시간
)
.notBefore( // 토큰 발급 후 {설정값} 이후 사용가능
// new Date(System.currentTimeMillis() + 3600 * 1000) // 1시간
new Date() // 즉시
)
.claim("role", "ADMIN") // 표준 외 사용자정의 데이터 추가
.claim("name", "user1")
.signWith(secretKey1) // 비밀키 사용 JWT에 생성
.compact(); /// (헤더.페이로드.서명) 문자열로
System.out.println("jwt 토큰 :: " + jwt);
// 4) JWT 파싱 및 검증
try {
Claims claims =
Jwts.parser()
// .setSigningKey(secretKey1)
.verifyWith(secretKey1)
.requireIssuer("star1431-auth")
.requireAudience("s-server1")
.build()
.parseSignedClaims(jwt)
.getPayload();
System.out.println(claims.getIssuer());
System.out.println(claims.getSubject());
System.out.println(claims.get("role", String.class));
System.out.println(claims.get("name", String.class));
System.out.println(claims.getExpiration());
} catch (IncorrectClaimException e) {
System.out.println("클레임 값 오류 :: " + e.getMessage());
} catch (JwtException e) {
System.out.println("토큰 검증 실패 :: " + e.getMessage());
}
// ExpiredJwtException : 토큰 만료 (expiration)
// PrematureJwtException : 토큰 사용 가능한 상태아님 (notBefore)
}
}
# 출력 결과
jwt 토큰 :: eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdGFyMTQzMS1hdXRoIiwic3ViIjoic3RhcjE0MzEiLCJhdWQiOlsicy1zZXJ2ZXIxIiwicy1zZXJ2ZXIyIl0sImV4cCI6MTc2Mjc2ODczNiwibmJmIjoxNzYyNzYxNTM2LCJyb2xlIjoiQURNSU4iLCJuYW1lIjoidXNlcjEifQ.h6wC0aLJ0IfGZoOCvS2Ib0ROGMPghBCHomDNtws4yTQ
star1431-auth
star1431
ADMIN
user1
Mon Nov 10 18:58:56 KST 2025
jwt.io 디코더에서 확인시
jwt.io에서 토큰본문 내용을 디코딩하여 클레임 정보 확인 가능함
- 필요한 정보
- 토큰본문
- 사용한 비밀키 문자열

5. JWT tokenizer 구현
- JWT 토큰 생성/검증을 담당하는
JwtTokenizer클래스 구현하여 재사용성 높임 - JwtTokenizer 생성자에서 시크릿키와 토큰 만료시간 변수 설정을 주입받아 사용
변수 설정
주의사항
- 시크릿키는 충분히 길고 복잡하게 설정해야 함 (32바이트 이상)
- 노출되지 않도록 주의 (퍼블릭으로 소스공유 x. 이그노어 처리)
- 환경변수로 관리하거나, 별도 설정파일로 관리 권장
application.yaml 예시
jwt:
secretKey: 123456789a123456789b123456789c12
refreshKey: 123456789a123456789b123456789c34
access-expiration-ss: 1800 # 30분 ( 30 * 60 )
refresh-expiration-ss: 604800 # 7일 ( 7 * 24 * 60 * 60 )
application.properties 예시
jwt.secretKey=123456789a123456789b123456789c12
jwt.refreshKey=123456789a123456789b123456789c34
jwt.access-expiration-ss=1800 # 30분 ( 30 * 60 )
jwt.refresh-expiration-ss=604800 # 7일 ( 7 * 24 * 60 * 60 )
JwtTokenizer 클래스 구현
@Component
public class JwtTokenizer {
// 시크릿 바이트
private final byte[] accessSecret;
private final byte[] refreshSecret;
// 토큰시간 지정
private final long accessTokenExpireMs;
private final long refreshTokenExpireMs;
public JwtTokenizer(
// application 설정 값 주입
@Value("${jwt.secretKey}") String accessSecret,
@Value("${jwt.refreshKey}") String refreshSecret,
@Value("${jwt.access-expiration-ss}") long accessExpSeconds,
@Value("${jwt.refresh-expiration-ss}") long refreshExpSeconds
) {
this.accessSecret = accessSecret.getBytes(StandardCharsets.UTF_8);
this.refreshSecret = refreshSecret.getBytes(StandardCharsets.UTF_8);
this.accessTokenExpireMs = accessExpSeconds * 1000L;
this.refreshTokenExpireMs = refreshExpSeconds * 1000L;
}
// 시크릿 키
private SecretKey getSignatureKey(byte[] secretKey) {
return Keys.hmacShaKeyFor(secretKey);
// return new SecretKeySpec(secretKey, "HmacSHA256");
}
// 토큰 파싱
private Claims parseToken(String token, byte[] secret) {
return Jwts.parser()
.verifyWith(getSignatureKey(secret))
.build()
.parseSignedClaims(token)
.getPayload();
}
// 액세스 토큰
public String createAccessToken(
Long id,
String email,
String name,
String userId,
List<String> roles
) {
return createToken(id,email,name,userId,roles,accessTokenExpireMs,accessSecret );
}
// 리플래시 토큰
public String refreshAccessToken(
Long id,
String email,
String name,
String userId,
List<String> roles
) {
return createToken(id,email,name,userId,roles,refreshTokenExpireMs,refreshSecret );
}
// 토큰 생성
public String createToken(
Long id,
String email,
String name,
String userId,
List<String> roles,
Long expire,
byte[] secret
) {
Date now = new Date();
Date exp = new Date(now.getTime() + expire);
return Jwts.builder()
.subject(userId)
.claim("id", id)
.claim("email", email)
.claim("name", name)
.claim("userId", userId)
.claim("roles", roles)
.issuedAt(now)
.expiration(exp)
.signWith(getSignatureKey(secret))
.compact();
}
// 액세스 파싱
public Claims parseAccessToken(String token) {
return parseToken(token, this.accessSecret);
}
// 리플래시 파싱
public Claims parseRefreshToken(String token) {
return parseToken(token, this.refreshSecret);
}
// userId 파싱
public Strnig getUserIdFromToken(String token){
// 토큰 값은 "Bearer <토큰본문>" 형태
if(token.startsWith("Bearer ")){
token = token.substring(7);
}
Claims claims = parseToken(token, accessSecret);
return claims.get("userId", Strnig.class);
}
}
메인 클래스에서 테스트
@SpringBootApplication
public class BasicJwtApplication {
public static void main(String[] args) {
SpringApplication.run(BasicJwtApplication.class, args);
}
@Bean
public CommandLineRunner commandLineRunner(JwtTokenizer jwtTokenizer) {
return args -> {
String accessToken = jwtTokenizer.createAccessToken(
1L,
"test@example.com",
"홍길동",
"user123",
Arrays.asList("ROLE_USER", "ROLE_ADMIN")
);
String refreshToken = jwtTokenizer.refreshAccessToken(
1L,
"test@example.com",
"홍길동",
"user123",
Arrays.asList("ROLE_USER", "ROLE_ADMIN")
);
System.out.println("Access Token = " + accessToken);
System.out.println("Refresh Token = " + refreshToken);
// 파싱 테스트
Claims accessClaims = jwtTokenizer.parseAccessToken(accessToken);
System.out.println("---- Access Claims ----");
System.out.println("subject : " + accessClaims.getSubject());
System.out.println("email : " + accessClaims.get("email", String.class));
System.out.println("roles : " + accessClaims.get("roles", List.class));
System.out.println("exp : " + accessClaims.getExpiration());
Claims refreshClaims = jwtTokenizer.parseRefreshToken(refreshToken);
System.out.println("---- Refresh Claims ----");
System.out.println("subject : " + refreshClaims.getSubject());
System.out.println("email : " + refreshClaims.get("email", String.class));
System.out.println("roles : " + refreshClaims.get("roles", List.class));
System.out.println("exp : " + refreshClaims.getExpiration());
// getUserIdFromToken
System.out.println("---- getUserIdFromToken() ----");
System.out.println("accessToken userId : " + jwtTokenizer.getUserIdFromToken(accessToken));
};
}
}
# 출력 결과
---- Access Claims ----
subject : user123
email : test@example.com
roles : [ROLE_USER, ROLE_ADMIN]
exp : Mon Nov 10 17:52:16 KST 2025
---- Refresh Claims ----
subject : user123
email : test@example.com
roles : [ROLE_USER, ROLE_ADMIN]
exp : Mon Nov 17 17:22:16 KST 2025
---- getUserIdFromToken() ----
accessToken userId : user123