본문 바로가기

(11.10) Spring Boot - JWT 인증, 세션 관리의 한계와 JWT의 필요성, JWT 구조와 동작 원리, JWT 심플 구현, JWT tokenizer 구현

@starweb2025. 11. 10. 18:39

[ 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 (세션 불일치)
  1. 클라이언트가 로그인 요청을 보냄
  2. 로드 밸런서가 요청을 서버 A로 전달
  3. 서버 A가 세션을 생성하고 클라이언트세션 쿠키를 발급
  4. 클라이언트가 다음 요청을 보낼 때 세션 쿠키를 포함
  5. 로드 밸런서가 요청을 서버 B로 배분
  6. 서버 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
  1. 클라이언트가 로그인
  2. 서버에서 세션 생성 후 Redis에 저장
  3. 클라이언트는 세션쿠키 전달
  4. 모든 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
  1. 사용자 인증 성공 → 서버가 JWT 발급
  2. 클라이언트가 JWT를 저장 후 모든 요청에서 헤더로 전송
  3. API 서버는 JWT Signature만 검증하면 인증 가능
  4. 세션 저장 필요 없음

✅ 장점

  • 서버 부하 감소 (세션 저장 없음)
  • 서버 개수가 늘어나도 인증 문제 없음(확장성 최강)
  • 중앙 세션 저장소 불필요
  • 분산/마이크로서비스 환경에 최적화

❌ 단점

  • 토큰 탈취 시 위험 (만료 전까지 공격자가 악용 가능)
  • 토큰 무효화(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: 성공 응답
  1. 로그인: 클라이언트가 ID/PW를 서버에 전송
  2. 토큰 발급: 서버가 인증 성공 시 Access TokenRefresh Token 발급
  3. 토큰 저장: 클라이언트가 토큰을 로컬 스토리지, 쿠키 등에 저장
  4. API 요청: 클라이언트가 Authorization: Bearer {token} 헤더에 토큰 포함
  5. 토큰 검증: 서버가 토큰을 검증하고 요청 처리
  6. 토큰 갱신: 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 빌더/파서)

JwtsJWT 생성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
starweb
@starweb :: starweb 님의 블로그

starweb 님의 블로그 입니다.

공감하셨다면 구독도 환영합니다!

목차