JWT with Spring Security

JWT 인증 시스템 : 이론부터 Spring Security 구현까지

🚀 들어가며: 왜 JWT가 필요할까?

현대 웹 애플리케이션은 점점 복잡해지고 있다. 하나의 서버에서 모든 걸 처리하던 시대는 지났고, 이제는 여러 서버, 마이크로서비스, 모바일 앱까지 고려해야 한다. 이런 환경에서 기존의 세션 기반 인증은 한계를 드러낸다.

세션 방식의 한계점들

Scale-Out의 딜레마

서버를 여러 대로 늘려야 하는 상황을 생각해보자. 사용자가 1번 서버에서 로그인했는데, 다음 요청이 2번 서버로 간다면? 2번 서버는 사용자의 세션 정보를 모르기 때문에 다시 로그인하라고 할 것이다.

사용자 A → 1번 서버 로그인 ✅
         ↓ (로드밸런싱)
사용자 A → 2번 서버 요청 ❌ (세션 정보 없음)

세션 서버의 새로운 문제

이를 해결하기 위해 Redis 같은 세션 서버를 도입하면, 이번엔 세션 서버가 단일 장애점(SPOF) 이 된다. 또한 매번 세션 서버를 조회해야 하므로 네트워크 지연이 발생한다.

MSA 환경에서의 복잡성

마이크로서비스 아키텍처에서는 각 서비스마다 인증을 확인해야 한다. 세션 방식이라면 서비스 간 통신할 때마다 세션 서버를 조회해야 하므로, 성능이 급격히 저하된다.


🎯 JWT: 모든 문제의 해결책

JWT(JSON Web Token)는 이런 문제들을 우아하게 해결한다. 토큰 자체에 모든 정보를 담고 있어서 서버에 상태를 저장할 필요가 없다.

JWT의 핵심 특징

  • Self-contained: 토큰 자체에 사용자 정보가 모두 포함
  • Stateless: 서버에 세션 정보 저장 불필요
  • Portable: 어떤 서버든 토큰만으로 검증 가능

JWT 구조 이해하기

JWT는 세 부분으로 구성된다:

Header.Payload.Signature
{
  "header": {
    "alg": "HS256",
    "typ": "JWT"
  },
  "payload": {
    "sub": "user123",
    "name": "홍길동", 
    "role": "ADMIN",
    "iat": 1516239022,
    "exp": 1516242622
  },
  "signature": "암호화된_서명"
}

중요한 깨달음: JWT는 암호화가 아닌 인코딩이다! 누구든 디코딩해서 내용을 볼 수 있으므로, 민감한 정보(비밀번호 등)는 절대 넣으면 안 된다.


🔐 JWT 로그아웃의 딜레마와 해결책

JWT를 공부하면서 가장 혼란스러웠던 부분이 바로 로그아웃 처리였다. Stateless의 장점이 동시에 단점이 되는 순간이다.

문제의 핵심

JWT의 장점 = Stateless (서버가 상태를 기억 안함)
JWT의 단점 = Stateless (서버가 로그아웃을 기억 못함)

세션 방식에서는 서버에서 세션을 삭제하면 끝이지만, JWT는 토큰이 클라이언트에 있어서 서버가 직접 무효화할 수 없다.

실용적인 해결책들

1. 클라이언트 사이드 삭제 (개발용)

가장 간단하지만 보안상 위험한 방법. 토큰을 localStorage에서 삭제하는 것만으로는 이미 탈취된 토큰을 막을 수 없다.

2. Refresh Token 방식 (추천)

실무에서 가장 많이 사용하는 방식이다:

Access Token: 15분 (짧은 만료시간)
Refresh Token: 7일 (긴 만료시간, 서버에서 관리)

로그아웃할 때는 Refresh Token을 서버에서 삭제하면, 15분 후에는 자연스럽게 접근이 차단된다.


🛠 Spring Security와 JWT 구현

이론을 실제 코드로 구현하는 과정에서 많은 것을 배웠다. Spring Security의 필터 체인을 이해하는 것이 핵심이었다.

필터 체인 구조

사용자 요청
    ↓
SecurityExceptionHandlingFilter (예외 처리)
    ↓
JWTVerificationFilter (토큰 검증)
    ↓
JWTAuthenticationFilter (로그인 처리)
    ↓
기타 Spring Security 필터들

핵심 컴포넌트들

1. JWTUtil - 토큰 생성/검증 유틸리티

// 토큰 생성
public String createAccessToken(Map<String, Object> claims) {
    return Jwts.builder()
        .setClaims(claims)
        .setIssuedAt(new Date())
        .setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRE_TIME))
        .signWith(getSigningKey())
        .compact();
}

// 토큰 검증
public Claims getClaims(String token) {
    return Jwts.parserBuilder()
        .setSigningKey(getSigningKey())
        .build()
        .parseClaimsJws(token)
        .getBody();
}
  • secret key 만들기, 최근에는 랜덤을 권장(탈취 고려)
  • Map에 해당하는 것을 claim으로 만들어서 토큰에 저장한다.

2. JWTAuthenticationFilter - 로그인 처리

역할과 목적

  • UsernamePasswordAuthenticationFilter를 확장하여 JWT 기반 인증 구현
  • 사용자의 초기 로그인 요청을 처리하는 필터

UsernamePasswordAuthenticationFilter를 상속받아 JWT 방식으로 변경:

@Override
protected void successfulAuthentication(HttpServletRequest request, 
                                      HttpServletResponse response,
                                      FilterChain chain, 
                                      Authentication authResult) {
    // 사용자 정보 추출
    MemberDetails member = (MemberDetails) authResult.getPrincipal();
    
    // JWT 토큰 생성
    String accessToken = jwtUtil.createAccessToken(
        Map.of("email", member.getEmail(), "role", member.getRole())
    );
    
    // JSON 응답 생성
    handleResult(response, 200, accessToken);
}

인증 성공 처리 - JWT 토큰 생성

 

처리 과정

  1. 사용자 정보 추출: 인증된 Authentication 객체에서 사용자 정보 가져오기 .getPrincipal()
  2. JWT 토큰 생성: JWTUtil을 사용하여 액세스 토큰 생성
  3. 응답 구성: 성공 상태와 토큰을 포함한 JSON 응답 생성
  4. 응답 전송: 클라이언트에게 토큰 반환

3. JWTVerificationFilter - 토큰 검증

모든 후속 요청에서 토큰을 검증하는 필터:

@Override
protected void doFilterInternal(HttpServletRequest request, 
                               HttpServletResponse response, 
                               FilterChain filterChain) {
    String token = getTokenFromHeader(request);
    
    if (token != null) {
        try {
            Claims claims = jwtUtil.getClaims(token);
            String email = claims.get("email", String.class);
            
            // 사용자 정보 로드 및 SecurityContext 설정
            UserDetails userDetails = memberService.loadUserByUsername(email);
            Authentication auth = new UsernamePasswordAuthenticationToken(
                userDetails, null, userDetails.getAuthorities()
            );
            SecurityContextHolder.getContext().setAuthentication(auth);
            
        } catch (JwtException e) {
            throw e; // 예외는 SecurityExceptionHandlingFilter에서 처리
        }
    }
    
    filterChain.doFilter(request, response);
}

통합 예외 처리 시스템.

예외 분류 및 처리

  • JwtException: JWT 토큰 관련 오류 → 401 UNAUTHORIZED
  • BadCredentialsException: 로그인 실패 → 401 UNAUTHORIZED
  • 기타 예외: 서버 내부 오류 → 500 INTERNAL_SERVER_ERROR

시나리오 3가지

📊 정상 로그인 시나리오

1. 사용자가 POST /api/auth/login 요청
   ↓
2. JWTAuthenticationFilter가 요청 가로채기
   ↓
3. AuthenticationManager가 인증 처리
   ↓
4. 인증 성공 → successfulAuthentication 호출
   ↓
5. JWT 토큰 생성 및 JSON 응답 반환

❌ 인증 실패 시나리오

1. 잘못된 로그인 정보로 요청
   ↓
2. JWTAuthenticationFilter에서 인증 실패
   ↓
3. unsuccessfulAuthentication에서 예외 발생
   ↓
4. SecurityExceptionHandlingFilter가 예외 처리
   ↓
5. 표준화된 오류 응답 반환

🔍 토큰 검증 시나리오

1. Authorization 헤더에 JWT 토큰 포함하여 요청
   ↓
2. JWTVerificationFilter가 토큰 검증
   ↓
3. 토큰 유효 → SecurityContext에 인증 정보 저장
   ↓
4. 토큰 무효/만료 → JwtException 발생
   ↓
5. SecurityExceptionHandlingFilter가 TOKEN_ERROR 응답

🌐 CORS 설정: 프론트엔드와의 협업

개발하면서 겪었던 또 다른 중요한 이슈가 CORS였다. 프론트엔드가 다른 포트(예: 3000)에서 실행되는데, 백엔드는 8080에서 실행되면 브라우저가 요청을 차단한다.

CORS 설정 예시

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration configuration = new CorsConfiguration();
    
    // 개발 환경
    configuration.setAllowedOrigins(Arrays.asList("*"));
    
    // 프로덕션 환경 (보안 강화)
    // configuration.setAllowedOrigins(Arrays.asList(
    //     "https://mydomain.com",
    //     "http://localhost:3000"
    // ));
    
    configuration.setAllowedMethods(Arrays.asList(
        "GET", "POST", "PUT", "DELETE", "OPTIONS"
    ));
    
    return source;
}

🎯 핵심 깨달음과 느낀 점

1. 아키텍처의 진화 이해

세션 → JWT로의 전환은 단순한 기술 변화가 아니라, 애플리케이션 아키텍처의 진화를 반영한다. 모놀리식에서 분산 시스템으로, 서버 중심에서 클라이언트 중심으로 패러다임이 바뀌었다.

2. 트레이드오프의 중요성

JWT는 만능이 아니다. 확장성과 성능을 얻는 대신, 로그아웃 처리의 복잡성과 토큰 크기 증가라는 비용을 지불한다. 무엇을 선택할지는 프로젝트의 요구사항에 따라 달라진다.

3. 보안에 대한 깊은 이해

JWT는 암호화가 아닌 인코딩이라는 사실을 깨달으면서, 보안에 대한 근본적인 이해가 필요함을 느꼈다. 토큰에 민감한 정보를 넣으면 안 되고, HTTPS 사용이 필수라는 것도 중요한 교훈이었다.

4. Spring Security의 설계 철학

필터 체인의 순서와 각 필터의 역할을 이해하면서, Spring Security의 우아한 설계를 체감했다. 각 필터가 단일 책임을 가지고 있고, 체인으로 연결되어 복잡한 인증/인가 로직을 깔끔하게 처리한다.


📚 추가 학습 포인트

앞으로 더 깊이 탐구해볼 주제들: (언젠가..)

  1. JWT 성능 최적화: 토큰 크기 최소화, 캐싱 전략
  2. 고급 보안 기법: 토큰 rotation, blacklist 최적화
  3. MSA 환경에서의 JWT: API Gateway에서의 토큰 검증, 서비스간 인증
  4. OAuth 2.0과의 연계: 소셜 로그인, 제3자 인증 서비스 통합

🎉 마무리

JWT 학습을 통해 단순히 새로운 기술을 배운 것이 아니라, 현대 웹 애플리케이션의 인증 아키텍처에 대한 근본적인 이해를 얻었다.

특히 이론과 실습을 병행하면서, 왜 이 기술이 필요하고 어떤 문제를 해결하는지 명확히 알게 되었다. 앞으로 더 복잡한 분산 시스템을 구축할 때도 이 기반 지식이 큰 도움이 될 것 같다.

핵심은 기술 자체가 아니라, 그 기술이 해결하려는 문제와 트레이드오프를 이해하는 것이다. JWT도 마찬가지로, 언제 사용하고 언제 사용하지 말아야 할지 판단할 수 있는 능력이 진정한 실력이라고 생각한다.

'Spring' 카테고리의 다른 글

Spring Security  (3) 2025.06.11