Spring/강의

[📙 숙련 Spring] 2-3. Token, JWT(JSON Web Token)

가지코딩 2025. 5. 16. 21:51

📙 목차

  1. Token
  2. Access Token, Refresh Token
  3. JWT(JSON Web Token)
  4. JWT 사용 간단 예제

1. Token

Token (토큰)

  • 사용자의 신원을 증명하거나 권한을 부여하기 위해 발급되는 디지털 증명서 같은 문자열
  • 주로 인증(Authentication)과 인가(Authorization) 과정에서 사용된다.
  • 토큰은 보통 암호화되어 안전하게 정보를 담고 있으며, 만료시간이 설정되어 있다.
  • 대표적인 예, JWT(JSON Web Token)

 

Token 동작 순서

  • 서버가 사용자 로그인 후 고유한 토큰을 생성해 클라이언트에게 전달한다.
    • 서버가 아닌 클라이언트에 저장되어 서버의 부담을 덜 수 있다.
  • 클라이언트는 이후 요청 시 이 토큰을 함께 보내어 서버가 사용자를 식별하고 권한을 확인할 수 있게 한다.

 

 

Token 의 단점

  • Cookie/Session 방식보다 Token 자체의 데이터 용량이 많다.
    • 요청이 많아지면 그만큼 트래픽이 증가한다.
  • Payload(전송되는 데이터)는 암호화되지 않아서 중요한 데이터를 담을 수 없다.
  • Token을 탈취당하면 대처하기 어려워 만료 시간(30분)을 설정한다.

2. Access Token, Refresh Token

Access Token과 Refresh Token은 주로 사용자 인증(Authentication) 및 **인가(Authorization)**를 다룰 때 사용하는 토큰 기반 인증 시스템의 핵심 개념이다.

 

보통 JWT(JSON Web Token) 기반 인증 시스템이나 OAuth2.0에서 사용된다.

 

 

Access Token

  • 사용자의 인증 정보를 담고 있는 토큰이다.
  • 클라이언트가 서버에 요청할 때 이 토큰을 HTTP 헤더에 넣어 전송하여, 해당 요청이 인증된 사용자에 의한 것임을 증명한다.
  • 짧은 유효 기간을 가진다 (예: 15분~1시간).
  • 보통 다음 정보를 포함한다:
    • 사용자 ID
    • 만료 시간
    • 발급자 정보
    • 권한 정보
{
  "sub": "user123",
  "exp": 1715900000,
  "role": "USER"
}

 

 

Refresh Token

 

  • Access Token이 만료되었을 때, 새로운 Access Token을 재발급받기 위해 사용하는 토큰이다.
  • 일반적으로 Access Token보다 더 긴 유효 기간을 가진다 (예: 7일~30일).
  • 보안상 Refresh Token은 서버나 안전한 저장소에만 보관되어야 한다.
  • Refresh Token도 보통 JWT나 난수(Random String) 형태이다.

 

Access Token, Refresh Token 인증

 

  1. 사용자가 로그인하면:
    • Access Token + Refresh Token 발급
  2. 사용자가 API 호출 시:
    • Access Token을 헤더에 담아서 전송
  3. Access Token이 만료되면:
    • Refresh Token을 이용해 새로운 Access Token 재발급 요청
  4. Refresh Token도 만료되면:
    • 다시 로그인 필요

 

 

* 보안 팁

구분 저장 위치 특징
Access Token 메모리 / 쿠키 유효기간 짧음, 요청마다 포함
Refresh Token 서버 DB / Secure HttpOnly Cookie 길게 유지되며, 보안 저장 필수

 

 


3. JWT(JSON Web Token)

JWT(JSON Web Token)

  • 인증에 필요한 정보들을 암호화시킨 JSON 형태의 Token
  • JSON 데이터 포맷을 사용하여 정보를 효율적으로 저장하고 암호화로 서버의 보안성을 높였다.

 

JWT 구조

Header.Payload.Signature세 부분이 점(.)으로 구분된 문자열 형태

  • Header(헤더)
    • 토큰의 타입과 해싱 알고리즘을 정의한다.
  • Payload(페이로드)
    • 실제로 인증과 관련된 데이터(Claims)를 담고 있다.
    • Claims의 종류
      • Registered Claims : 미리 정의된 Claims
        • iss(issuer) : 발행자
        • exp(expiration time) : 만료시간
        • sub(subject) : 제목
        • iat(issued At) : 발행 시간
        • jti(JWT ID) : 토큰의 고유 식별자
      • Public Claims : 사용자가 정의할 수 있는 클레임, 공개용 정보 전달 목적
      • Private Claims : 사용자 지정 클레임, 당사자들 간에 정보를 공유하기 위한 목적
  • Signature(서명): 헤더와 페이로드를 합친 뒤, 비밀 키를 이용해 서명한 값으로 토큰 위변조를 방지한다.
    • Header와 Payload를 서버의 Secret Key로 서명하여 암호화 한다.
    • 암호화는 Header에서 정의한 알고리즘(alg)을 활용한다.
    • 서명을 통해 서버는 Token이 변조되지 않았음을 확인할 수 있다.
XXXXXX.YYYYYY.ZZZZZZ
(Header).(Payload).(Signature)

 

https://jwt.io

 

 

JWT 인증 과정

  1. 클라이언트의 로그인 요청
  2. 로그인에 성공했다면 Header, Payload에 Secret Key를 사용하여 Signature를 만든다.
    • 이후 Base64로 Encoding 한다.
    • 일반적으로 Cookie에 담아 클라이언트에게 JWT를 발급한다.
  3. 발급받은 JWT를 저장 후 서버에 요청할 때 Authorization Header에 JWT를 담아 보낸다.
  4. 서버에서 JWT의 유효성 검사를 통해 통과한다면 인증에 성공하여 요청을 처리해준다.
    • JWT 만료, 위변조 여부를 검사한다.

 

JWT의 유효성 검사

  1. A의 JWT를 B가 탈취
  2. B가 탈취한 JWT를 임의로 수정
  3. B가 수정한 JWT로 Server에 요청
  4. 서버는 Signature를 사용하여 유효성 검사(Signature 불일치)
    • Header, Payload를 서버의 Secret Key값을 이용해 Signature를 다시 만들어 비교한다.
    • 임의로 조작된 데이터를 판별할 수 있다.

 

 

JWT 장점

  • Signature로 서버의 보안성이 증가한다.
  • Token 자체가 필요한 정보(유저 및 검증 정보)들을 모두 가지고 있다.
  • 서버는 인증 정보와 관련된 별도의 저장소를 사용하지 않는다.
  • 서버의 수평 확장성(Scale Out)이 높아진다.
  • Cookie가 없는 다른 환경에서도 인증/인가를 적용할 수 있다.
  • DB를 조회하지 않아도 된다.

 

JWT 단점

  • Payload는 암호화 된 것이 아니라 민감한 정보를 다루지 못한다.
  • Token의 길이가 길어서 트래픽이 증가하면 네트워크에 부하가 증가한다.
  • 클라이언트 측에서 Token을 관리하기 때문에 탈취당하면 대처하기 어렵다.

4. JWT 사용 간단 예제

의존성 추가 (build.gradle)

implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

 

 

JwtUtil 클래스 만들기

@Component
public class JwtUtil {
    private final String secret = "my-secret-key-1234567890";

    public String createToken(String username) {
        return Jwts.builder()
                .setSubject(username)
                .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 15)) // 15분
                .signWith(Keys.hmacShaKeyFor(secret.getBytes()), SignatureAlgorithm.HS256)
                .compact();
    }

    public String extractUsername(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(secret.getBytes())
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

    public boolean validate(String token) {
        try {
            extractUsername(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }
}

 

 

Controller

@RestController
public class AuthController {

    private final JwtUtil jwtUtil;

    public AuthController(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

    @PostMapping("/login")
    public String login(@RequestParam String username, @RequestParam String password) {
        if ("user".equals(username) && "pass".equals(password)) {
            return jwtUtil.createToken(username);
        }
        throw new RuntimeException("Invalid login");
    }

    @GetMapping("/secure")
    public String secure(@RequestHeader("Authorization") String authHeader) {
        String token = authHeader.replace("Bearer ", "");
        if (jwtUtil.validate(token)) {
            String username = jwtUtil.extractUsername(token);
            return "Hello, " + username;
        }
        return "Invalid token";
    }
}