JWT란?

HelloPong·2025년 8월 5일

공부

목록 보기
13/39
post-thumbnail

🔐 JWT 개념과 구조

✅ 1. JWT란?

JWT(Json Web Token)는 사용자의 인증 정보를 안전하게 클라이언트에 저장하고 전달하기 위해 만들어진 토큰 기반 인증 방식.

💡 기존의 세션 기반 인증은 서버에 상태(Session)을 저장해야 하지만, JWT는 서버에 별도 저장 없이 인증 정보를 클라이언트가 직접 보관합니다.
이 때문에 Stateless(무상태) 인증 방식이라고도 불립니다.

📦 2. JWT의 구조

JWT는 총 3개의 부분으로 구성되며, 각 부분은 Base64Url 인코딩되어 .으로 구분된 문자열입니다.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJ1c2VySWQiOiIxMjMiLCJyb2xlIjoiUk9MRV9VU0VSIn0
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
구분설명
🔖 Header토큰의 타입과 해싱 알고리즘 정보 (예: HS256)
📄 Payload토큰에 담을 클레임(Claim) - 사용자 정보, 만료시간 등
🔏 Signature위의 내용을 비밀 키로 서명한 값 - 위조 방지용

🔖 2-1. Header

{
  "alg": "HS256",
  "typ": "JWT"
}
  • alg: 사용할 암호화 알고리즘
  • typ: 토큰의 타입 (고정값 "JWT")

📄 2-2. Payload (Claims)

{
  "sub": "access-token",
  "userId": "123",
  "role": "USER",
  "exp": 1691335599
}
  • 클레임(Claim)은 JWT에 담는 실제 데이터입니다.
  • 크게 세 가지로 나뉩니다:
    - Registered Claim: 표준 지정 클레임 (iss, exp, sub 등)
    - Public Claim: 공용 클레임 (정의된 규칙에 따라)
    - Private Claim: 사용자 정의 클레임 (userId, role 등)

🔏 2-3. Signature

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)
  • 🔐 이 서명 덕분에 토큰 변조 여부를 서버에서 확인할 수 있습니다.
  • ✅ 서버는 토큰을 다시 서명해보고, 일치하지 않으면 위조된 토큰으로 판단하고 차단합니다.

🔄 3. JWT 인증 흐름

[로그인 요청]
 → [서버: 유효한 사용자 확인]
   → [AccessToken + RefreshToken 발급]
     → [클라이언트 저장 및 이후 요청 시 AccessToken 전송]
       → [서버에서 토큰 검증 후 자원 제공]
  • Access Token은 보통 HTTP Header의 Authorization에 담아 전송합니다:
Authorization: Bearer <AccessToken>

🆚 4. Access Token vs Refresh Token

구분Access TokenRefresh Token
⏳ 만료 시간짧음 (예: 15분)김 (예: 2주)
🔐 저장 위치클라이언트 (localStorage 등)클라이언트 or 서버 (Redis 등)
🌀 사용 목적매 요청마다 인증용만료된 AccessToken 재발급
❌ 유출 시 위험낮음 (짧은 유효기간)높음 (재발급 가능, 관리 중요!)

📌 Refresh Token은 보통 Redis에 저장하고 유효성 검사에 사용합니다.

⚠️ 5. JWT의 장점과 주의사항

✅ 장점

  • 서버가 상태를 기억할 필요 없음 (Stateless)
  • 빠른 인증 처리 (서버 DB 접근 없이 토큰 검증 가능)
  • 다양한 클라이언트(모바일, 웹 등)와 호환성 높음

⚠️ 단점 / 주의사항

  • 토큰 탈취 시 보안 위협
  • 토큰 자체가 커질 수 있음 (Payload 포함)
  • 토큰 만료 전 강제 만료 불가 (→ Refresh Token + Redis 구조 필요)
  • 서버 측 로그아웃 구현이 어려움 (→ Redis로 보완 가능)

🔧 Spring Boot에 JWT 인증 적용하기

✅ 목표

Spring Boot 환경에서 JWT 기반 인증 로직을 적용하여,

  • 로그인 시 Access Token / Refresh Token 발급
  • 요청마다 Access Token 검증
  • 토큰 만료 시 Refresh Token을 통한 재발급
  • 예외 처리와 필터 적용까지 흐름을 정리합니다.

🧩 1. 의존성 추가

// 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'

🛠️ 2. JWT 토큰 유틸 클래스

@Component
public class JwtTokenProvider {

    private final String secretKey = "your-secret-key";
    private final long accessTokenValidity = 1000L * 60 * 15; // 15분
    private final long refreshTokenValidity = 1000L * 60 * 60 * 24 * 7; // 7일

    public String createAccessToken(String userId, String role) {
        return createToken(userId, role, accessTokenValidity, "access");
    }

    public String createRefreshToken(String userId) {
        return createToken(userId, null, refreshTokenValidity, "refresh");
    }

    private String createToken(String userId, String role, long validity, String type) {
        Claims claims = Jwts.claims().setSubject(userId);
        if (role != null) claims.put("role", role);
        claims.put("type", type);

        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + validity))
                .signWith(SignatureAlgorithm.HS256, secretKey.getBytes())
                .compact();
    }

    public String getUserId(String token) {
        return Jwts.parser()
                .setSigningKey(secretKey.getBytes())
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parser().setSigningKey(secretKey.getBytes()).parseClaimsJws(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }
}

🔐 3. 인증 필터 구현 (Access Token 검증)

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider tokenProvider;

    public JwtAuthenticationFilter(JwtTokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
                                    throws ServletException, IOException {

        String authHeader = request.getHeader("Authorization");

        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);
            if (tokenProvider.validateToken(token)) {
                String userId = tokenProvider.getUserId(token);

                UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(userId, null, List.of());

                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        filterChain.doFilter(request, response);
    }
}

⚙️ 4. Spring Security 설정

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/auth/**").permitAll()
                        .anyRequest().authenticated()
                )
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }
}

🔄 5. Refresh Token 재발급 API 설계

✅ 요청

POST /auth/refresh
Authorization: Bearer {RefreshToken}

✅ 응답

{
  "accessToken": "new-access-token",
  "refreshToken": "new-refresh-token"
}

🚫 6. 예외 처리 및 만료 대응

  • AccessToken 만료 → ExpiredJwtException → 401 반환
  • 잘못된 서명 → SignatureException → 403 반환
  • 없는 토큰 → MissingTokenException → 400 반환 등
  • → 예외는 @RestControllerAdvice에서 핸들링 가능

✅ 정리

기능설명
AccessToken 발급로그인 시 createAccessToken() 사용
RefreshToken 발급createRefreshToken()
AccessToken 인증 필터JwtAuthenticationFilter
Spring Security 연동addFilterBefore()로 필터 등록
재발급 로직 설계/auth/refresh 엔드포인트로 구현

🔄 Refresh Token을 Redis에 저장하여 보안 강화하기

✅ 1. 왜 Redis에 Refresh Token을 저장해야 할까?

문제 상황Redis 저장으로 해결할 수 있는 것
🔓 Refresh Token이 유출되었을 때서버에서 중앙 관리 → 강제 무효화 가능
🚫 로그아웃 시 토큰 무효화Redis에서 해당 토큰 삭제
🔁 다중 로그인 제어사용자별로 하나의 Refresh Token만 유지 가능
🧠 서버 재시작에도 유지Redis는 별도 인메모리 저장소이므로 세션 유지 가능

📌 Refresh Token은 Access Token을 재발급할 수 있는 열쇠이기 때문에 반드시 서버에서 관리되어야 해!

🗃️ 2. Redis 설정 (Spring Boot)

1) 의존성 추가

// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

2) application.yml 설정

spring:
  data:
    redis:
      host: localhost
      port: 6379

🛠️ 3. Refresh Token Redis 저장 로직

1) Redis 저장 서비스 구현

@Service
@RequiredArgsConstructor
public class RefreshTokenService {

    private final RedisTemplate<String, String> redisTemplate;
    private final long refreshTokenValidity = 1000L * 60 * 60 * 24 * 7; // 7일

    public void saveRefreshToken(String userId, String refreshToken) {
        redisTemplate.opsForValue().set(
            "RT:" + userId,
            refreshToken,
            refreshTokenValidity,
            TimeUnit.MILLISECONDS
        );
    }

    public String getRefreshToken(String userId) {
        return redisTemplate.opsForValue().get("RT:" + userId);
    }

    public void deleteRefreshToken(String userId) {
        redisTemplate.delete("RT:" + userId);
    }
}

🔑 키 형식은 "RT:사용자ID"로 구분해주면 여러 토큰과 혼동 방지 가능.

2) 로그인 시 저장

String refreshToken = jwtTokenProvider.createRefreshToken(userId);
refreshTokenService.saveRefreshToken(userId, refreshToken);

3) 재발급 시 검증

public TokenResponse reissue(String userId, String refreshToken) {
    String storedToken = refreshTokenService.getRefreshToken(userId);

    if (!refreshToken.equals(storedToken)) {
        throw new InvalidTokenException("Refresh Token 불일치");
    }

    // 재발급
    String newAccessToken = jwtTokenProvider.createAccessToken(userId, "ROLE_USER");
    String newRefreshToken = jwtTokenProvider.createRefreshToken(userId);
    
    // Redis 업데이트
    refreshTokenService.saveRefreshToken(userId, newRefreshToken);

    return new TokenResponse(newAccessToken, newRefreshToken);
}

🚪 4. 로그아웃 처리 (서버에서 Refresh Token 제거)

public void logout(String userId) {
    refreshTokenService.deleteRefreshToken(userId);
}

→ 사용자가 로그아웃하면 재발급이 불가능해지므로, 이후 Access Token이 만료되면 인증 실패가 일어남.

🛡️ 5. 보안 주의사항 요약

항목설명
🔒 Refresh Token은 반드시 서버에서 관리 (Redis 등)민감한 토큰이므로 DB나 Redis 같은 중앙 저장소에 저장하고, 탈취 가능성을 줄이기 위해 클라이언트 저장은 지양함
🕐 Access Token은 클라이언트 저장, 짧은 만료요청마다 사용되므로 클라이언트에 저장되며, 유효 기간은 짧게 설정해 보안 리스크를 줄임
🚫 탈취 시 즉시 Redis에서 삭제 가능토큰이 유출된 경우 서버에서 해당 Refresh Token을 Redis에서 제거하면 즉시 무효화됨
🔁 재발급 시 Refresh Token도 교체 & Redis 갱신Refresh Token 자체도 유출될 수 있기 때문에 재발급 시 항상 새로 생성하여 Redis에 갱신함
🧪 매 요청마다 Access Token 검사, 만료 시 재발급만 허용Access Token은 요청마다 검사하고, 만료되었을 때만 Refresh Token을 통해 새 토큰을 발급함

✅ 정리

구성 요소구현 위치
Redis 연결 설정application.yml
저장 로직RefreshTokenService
로그인 시 저장로그인 로직에서 saveRefreshToken()
재발급 검증 및 갱신/auth/refresh API
로그아웃 처리deleteRefreshToken() 호출

0개의 댓글