ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring Boot]JWT 토큰을 통한 보안 구성 완전 정복
    Spring 2025. 4. 9. 15:34

     

     

     

     

    들어가며

     

    API 보안을 어떻게 구성하냐에 따라 서비스의 신뢰도와 안정성이 갈립니다. 그중에서도 JWT(Json Web Token)는 많은 서비스에서 인증 방식으로 채택하고 있는 강력한 도구입니다. 특히 MSA(Microservices Architecture) 환경이나 모바일 앱과 서버가 분리된 구조에서는 더욱 빛을 발합니다.

     

    이번 글에서는 JWT 토큰이 무엇인지부터, 어떻게 Spring Boot 프로젝트에 적용하는지까지 정리해보겠습니다.

     

    JWT란 무엇인가?

    JWT(Json Web Token)는 인증 정보를 JSON 형식으로 담아 인코딩한 토큰입니다. 보통 다음과 같은 구조로 구성되어 있어요.

     

    JWT는 아래와 같이 .으로 구분된 세 부분으로 이루어져 있습니다.

    각 파트를 하나씩 살펴볼게요.

    [Header].[Payload].[Signature]

     

     

     

     

    1. Header

    {
      "alg": "HS256",
      "typ": "JWT"
    }

    alg: signature을 생성할 때 사용할 알고리즘을 지정합니다. 일반적으로는 HS256 (HMAC SHA-256)이 많이 사용되며, 비대칭키 기반의 RS256도 사용 가능합니다.

    typ: 토큰의 타입을 명시합니다. 대부분 JWT로 고정되어 있습니다.

     

    이 정보는 Base64로 인코딩되어 토큰의 첫 번째 부분이 됩니다.

     

     

    2. Payload (Claims)

    {
      "sub": "1234567890",
      "name": "Suvelop",
      "email": "freeing3355@naver.com",
      "role": "USER",
      "iat": 1712630000,
      "exp": 1712633600
    }

    sub (Subject): 토큰의 주제, 보통 사용자 ID 같은 고유값을 사용합니다.

    name, email, role: 서비스에 필요한 사용자 정보(클레임)들을 자유롭게 포함할 수 있습니다.

    iat (Issued At): 토큰이 발급된 시간 (Unix timestamp)

    exp (Expiration Time): 토큰의 만료 시간

    그 외에도 aud(Audience), iss(Issuer), nbf(Not Before) 등 다양한 표준 클레임과 커스텀 클레임을 포함할 수 있습니다.

     

    Payload는 인코딩은 되어 있지만 암호화되어 있지 않기 때문에 누구나 내용 확인이 가능합니다. 민감한 정보는 절대 담지 마세요!

     

    3. Signature

    HMACSHA256(
      base64UrlEncode(header) + "." + base64UrlEncode(payload),
      secret
    )

    Signature는 HeaderPayload를 결합한 후 비밀 키(secret)로 서명합니다.

    이 서명을 통해 토큰이 위조되지 않았는지 검증할 수 있습니다.

     

    서버는 비밀 키(secret)를 통해 이 Signature를 다시 계산해 비교함으로써, 토큰이 도중에 변경되었는지 확인합니다.

     

    전체 JWT 예시

    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
    .
    eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlN1dmVsb3AiLCJlbWFpbCI6ImZyZWVpbmczMzU1QG5hdmVyLmNvbSIsInJvbGUiOiJVU0VSIiwiaWF0IjoxNzEyNjMwMDAwLCJleHAiOjE3MTI2MzM2MDB9
    .
    SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

     

     

    요약

     

    Header 토큰의 타입과 서명 알고리즘 공개 정보
    Payload 사용자 정보 및 클레임 데이터 암호화 X, 민감한 정보 금지
    Signature 위변조 방지를 위한 서명 비밀 키로 생성, 위조 여부 확인 가능

     

     

     

     

    JWT를 사용하는 이유

     

    기존의 세션 기반 인증

     

    웹 애플리케이션 초창기에는 세션 기반 인증이 일반적이었습니다.

    1. 사용자가 로그인하면 서버는 세션 정보를 생성해 서버 메모리에 저장합니다.

    2. 클라이언트는 응답으로 받은 세션 ID를 쿠키에 저장합니다.

    3. 이후 요청마다 세션 ID를 쿠키로 서버에 보내면,

    4. 서버는 해당 세션 ID로 사용자의 상태를 확인하고 리소스를 제공합니다.

     

    하지만 이 방식은 아래와 같은 한계가 있습니다:

     

    단점

    상태 기반(Stateful) 서버가 사용자의 로그인 상태를 메모리에 저장해야 하므로, 서버가 상태를 관리해야 함
    서버 확장 어려움 여러 대의 서버(로드밸런싱)에서 세션 동기화 필요 → Redis 같은 외부 저장소 필요
    모바일·SPA에 부적합 모바일 앱이나 Vue/React 기반의 SPA에서는 쿠키보다 토큰 기반 인증이 더 유리

     

     

    JWT 기반 인증의 등장

     

    JWT 기반 인증은 Stateless(무상태) 인증 방식입니다.

    1. 사용자가 로그인하면 서버는 사용자 정보를 기반으로 JWT를 생성하여 클라이언트에 전달합니다.

    2. 클라이언트는 JWT를 저장(LocalStorage, SecureStorage 등)해두고,

    3. 이후 요청마다 JWT를 Authorization 헤더에 담아 보냅니다.

    4. 서버는 매 요청마다 토큰을 검증하여 사용자를 식별합니다.

    5. 서버는 별도의 상태 저장 없이, 토큰만으로 인증/인가 로직을 처리합니다.

     

    장점

    Stateless 서버가 로그인 상태를 저장할 필요 없음 → 확장성과 유지보수성 증가
    MSA 친화적 마이크로서비스 간 인증 정보 공유가 쉬움 (토큰 전달만 하면 됨)
    프론트엔드 유연성 모바일, SPA 등 다양한 클라이언트에 적용 용이
    확장성 서버 수가 늘어나도 인증 관리가 간단함 (토큰만 검증하면 되니까!)
    보안 설정 자유로움 유효기간, 사용자 권한, 토큰 위치 등 클레임을 통해 세부 보안 설정 가능

     

     

     

    🔍 직접 비교해보기

    항목세션 기반 인증JWT 기반 인증

    인증 방식 세션 ID를 통해 상태 추적 토큰 자체에 인증 정보 포함
    서버 상태 저장 O (메모리 or Redis) ❌ (Stateless)
    확장성 낮음 (세션 공유 필요) 높음 (토큰 기반)
    보안 고려사항 세션 탈취(CSRF), 세션 만료 처리 토큰 탈취(XSS), 토큰 만료 처리
    저장 위치 브라우저 쿠키 헤더 / 로컬 스토리지 / 보안 저장소
    만료 처리 서버에서 세션 삭제 토큰에 exp로 내장됨

     

     

    그럼에도 불구하고 JWT가 만능일까?

     

    그건 아닙니다. JWT도 단점이 존재합니다:

    JWT의 단점 설명

    토큰 길이 JWT는 Payload에 다양한 정보를 담기 때문에 일반적인 세션 ID보다 토큰 길이가 길다. 이로 인해 네트워크 전송 비용이 증가할 수 있다.
    갱신 어려움 JWT는 서버에서 상태를 저장하지 않는 무상태 방식이기 때문에, 한 번 발급된 토큰을 서버에서 강제로 무효화하기 어렵다. 따라서 사용자 로그아웃, 탈퇴, 권한 변경 시 문제가 생길 수 있다.
    탈취 시 치명적 JWT는 누구나 Payload를 읽을 수 있기 때문에, HTTPS가 적용되지 않은 환경에서는 탈취 시 큰 보안 위협이 될 수 있다. 탈취된 토큰은 유효기간이 남아 있는 동안 계속 사용할 수 있기 때문이다.

     

     

    이를 보완하는 방식: Access Token + Refresh Token 조합

     

    이러한 단점을 해결하기 위해 일반적으로 Access Token과 Refresh Token을 함께 사용하는 방식을 채택한다.

    Access Token

    짧은 만료 기간 (예: 15~30분)을 가짐

    사용자 인증 후 대부분의 API 요청에 사용

     짧을 만료 기간을 통해 탈취 시 피해를 최소화할 수 있음

    Refresh Token

    긴 만료 기간을 가짐

    • DB/Redis에 저장하여 관리

    Access Token이 만료되었을 때, 재발급 용도로만 사용

    로그아웃이나 탈퇴 등의 처리 시 서버에서 삭제하여 즉시 무효화 가능

     

    이 구조를 통해 JWT의 무상태성과 성능 이점을 유지하면서도, 실질적인 제어력과 보안성을 확보할 수 있다.

     

    JWT가 적합한 상황은 언제일까?

     

    다음과 같은 환경에서는 JWT 기반 인증이 매우 효과적이다:

    서버 확장이 빈번한 클라우드 환경 또는 MSA 구조

    세션을 서버마다 공유할 필요 없이 JWT로 인증을 처리할 수 있음

    REST API 기반의 백엔드 시스템

    HTTP 요청마다 인증 정보를 헤더에 담아 전송하는 구조에 잘 맞음

    SPA(Vue, React), Flutter 앱 등 다양한 클라이언트 대응

    클라이언트가 자체적으로 토큰을 저장하고 API 요청에 활용 가능

    서버가 사용자 상태를 저장하지 않아야 하는 경우

    Stateless한 서버 구조에 적합

     

    JWT 적용 흐름 정리

    1. 로그인 요청

    사용자가 email, password로 로그인 요청

    2. JWT 발급

    서버가 사용자의 정보를 확인하고 JWT(Access + Refresh 토큰)를 발급

    3. 요청 시 토큰 포함

    클라이언트는 이후 요청에 Authorization: Bearer <AccessToken> 헤더 포함

    4. 서버에서 토큰 검증

    서버는 토큰의 유효성, 만료 여부, 위변조 여부를 검증

    5. 인증 통과 후 리소스 접근 허용

    6. Access 토큰 만료 시 Refresh 토큰으로 재발급

     

     

    Spring Boot에서 JWT 적용하기

    //application.yml
    jwt:
      secret: ${JWT_SECRET}		 # 자신의 secret 입력
      access-expiration: 60      # 1시간
      refresh-expiration: 3000   # 50시간

     

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

    1. JWT 생성 유틸리티

    @Component
    public class JwtTokenProvider {
    
        private final int accessExpiration;     // 분 단위
        private final int refreshExpiration;    // 분 단위
        private final Key SECRET_KEY;
    
        public JwtTokenProvider(
                @Value("${jwt.secret}") String secretKey,
                @Value("${jwt.access-expiration}") int accessExpiration,
                @Value("${jwt.refresh-expiration}") int refreshExpiration
        ) {
            this.secretKey = secretKey;
            this.accessExpiration = accessExpiration;
            this.refreshExpiration = refreshExpiration;
            this.SECRET_KEY = new SecretKeySpec(
                    java.util.Base64.getDecoder().decode(secretKey),
                    SignatureAlgorithm.HS512.getJcaName()
            );
        }
        // Access Token
        public String createAccessToken(String email, Long userId, String role) {
            Claims claims = Jwts.claims().setSubject(email);
            claims.put("user_id", userId);
            claims.put("role", role);
            Date now = new Date();
            return Jwts.builder()
                    .setClaims(claims)
                    .setIssuedAt(now)
                    .setExpiration(new Date(now.getTime() + accessExpiration * 60 * 1000L))
                    .signWith(SECRET_KEY)
                    .compact();
        }
    
        // Refresh Token
        public String createRefreshToken(String email, Long userId, String role) {
            Claims claims = Jwts.claims().setSubject(email);
            claims.put("user_id", userId);
            claims.put("role", role);
            Date now = new Date();
            return Jwts.builder()
                    .setClaims(claims)
                    .setIssuedAt(now)
                    .setExpiration(new Date(now.getTime() + refreshExpiration * 60 * 1000L))
                    .signWith(SECRET_KEY)
                    .compact();
        }
    
    
    
    }

    2. JWT 인증 필터

    @Component
    public class JwtTokenFilter extends GenericFilter {
    
        @Value("${jwt.secret}")
        private String secretKey;
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
            HttpServletRequest httpServletRequest = (HttpServletRequest) request;
            HttpServletResponse httpServletResponse = (HttpServletResponse) response;
            String token = httpServletRequest.getHeader("Authorization");
            try {
                if (token != null) {
                    if (!token.substring(0, 7).equals("Bearer ")) {
                        throw new AuthenticationServiceException("Bearer 형식이 아닙니다.");
                    }
                    String jwtToken = token.substring(7);
                    //token 검증 및 claims(payload) 추출
                    Claims claims = Jwts.parserBuilder()
                            .setSigningKey(secretKey)
                            .build()
                            .parseClaimsJws(jwtToken)
                            .getBody();
                    //Authentication 객체 생성
                    List<GrantedAuthority> authorities = new ArrayList<>();
                    authorities.add(new SimpleGrantedAuthority("ROLE_" + claims.get("role")));
                    UserDetails userDetails = new User(claims.getSubject(), "", authorities);
                    Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, jwtToken, userDetails.getAuthorities());
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
                chain.doFilter(request, response);
            }catch (Exception e){
                e.printStackTrace();
                httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
                httpServletResponse.setContentType("application/json");
                httpServletResponse.getWriter().write("invalid token");
            }
        }
    }

     

     

    JWT 사용 시 주의할 점

    비밀키는 절대 노출 금지 (환경변수나 AWS Secrets Manager 등으로 관리)

    토큰 탈취 시 큰 보안 문제 → HTTPS는 필수

    Access 토큰 만료 기간은 짧게, Refresh 토큰 만료 기간은 길게 유지

    Refresh 토큰 저장소 분리 → Redis 등에서 관리 권장

    JWT는 완전한 보안 솔루션이 아님 → 사용자 인증 + 권한 검증 함께 구성

     

     

    마치며

     

    처음엔 JWT를 적용하는 것이 복잡해 보일 수 있습니다. 하지만, 구조를 이해하고 필터 흐름을 익히면, 서비스에 딱 맞는 보안 구조를 만들 수 있어요. 특히 Stateless 구조와 MSA 환경에서는 필수적인 요소이기도 하죠.

     

     

     

     

Designed by MSJ.