Spring Security JWT 인증과 인가 아키텍처

· 약 9분 읽기 · 조회 1 #Spring Security#JWT#인증#보안#아키텍처

JWT 인증은 예제 코드만 보면 필터 하나로 끝나 보이지만, 운영에서 문제가 되는 것은 서명 방식, 토큰 수명, 재발급과 폐기, 브라우저 저장 위치, 권한 매핑 같은 설계 결정이다. 직접 만든 JWT 필터는 검증 누락이 생기기 쉽고, 토큰을 한 번 발급하면 되돌릴 수 없다는 점을 놓치기 쉽다. 이 글은 Spring Security 6 기준으로 JWT 인증·인가 구조를 나누고, 각 결정의 기준과 설정 예시를 정리한 것이다.

방문객이 소나무색 밀랍 봉인이 찍힌 카드를 경비 로봇에게 내밀고, 로봇이 돋보기로 봉인을 확인하며, 뒤편의 문마다 모양이 다른 출입 배지 리더가 달린 로비

역할을 나눈다

역할 하는 일 갖는 키
인증 서버 로그인 확인, Access·Refresh 토큰 발급, 재발급·폐기 서명용 개인 키
API 서버(리소스 서버) 토큰 서명·만료·발급자 검증, 권한으로 인가 검증용 공개 키(JWKS)
클라이언트 토큰 보관, 요청마다 Access 토큰 첨부, 만료 시 재발급 요청 없음
Refresh 저장소 Refresh 토큰 상태(유효·폐기·사용됨) 보관 —

인증 서버와 API 서버를 한 애플리케이션에 둘 수도 있지만, 역할은 코드에서도 나눈다. 서비스가 늘어날 때 API 서버들은 공개 키만 받으면 되기 때문이다.

클라이언트가 로그인해 Access·Refresh 토큰을 받고, API 서버가 공개 키로 Access 토큰을 검증해 권한으로 인가하며, Access 토큰이 만료되면 Refresh 토큰으로 새 토큰 쌍을 받고 쓴 Refresh 토큰은 폐기되는 흐름

결정 1: 서명 방식은 비대칭으로

방식 키 맞는 경우
HS256(대칭) 비밀키 하나로 서명·검증 발급과 검증이 한 서비스 안일 때
RS256·ES256(비대칭) 개인 키로 서명, 공개 키로 검증 API 서버가 여럿이거나 외부에서 검증할 때

대칭 키를 여러 API 서버에 나눠 주면, 그중 하나만 털려도 누구나 토큰을 만들 수 있다. 비대칭이면 API 서버에는 공개 키만 있어 위조가 불가능하다. 인증 서버는 공개 키를 JWKS 주소로 내놓고, 키를 바꿀 때는 새 키를 먼저 JWKS에 추가한 뒤 서명을 옮기고 옛 키를 빼는 순서로 교체한다(헤더의 kid로 구분).

결정 2: 토큰 수명과 재발급

JWT는 발급하면 만료 전까지 서버가 되돌릴 수 없다. 그래서 Access 토큰은 짧게, 재발급은 Refresh 토큰으로 한다.

  • Access 토큰: 수명을 짧게(예: 10~30분). 탈취돼도 피해 시간이 짧다.
  • Refresh 토큰: 길게 두되 서버 저장소에 상태를 둔다. 로그아웃, 비밀번호 변경, 계정 정지 때 즉시 폐기할 수 있다.
  • 회전(rotation): 재발급할 때마다 새 Refresh 토큰을 주고 쓴 것은 폐기한다. 이미 쓴 Refresh 토큰이 다시 들어오면 탈취로 보고 그 사용자의 토큰 묶음 전체를 폐기한다.

권한이 바뀌었는데 Access 토큰에 옛 권한이 남아 있는 시간도 Access 수명만큼이다. 권한 회수가 즉시 반영돼야 하는 기능(관리자 기능 등)은 서버에서 한 번 더 확인한다.

결정 3: 토큰을 어디에 저장하나

저장 위치 위험 대응
localStorage XSS가 있으면 스크립트로 토큰을 읽어 간다 XSS 방어, CSP, 짧은 Access 수명
HttpOnly 쿠키 스크립트로는 못 읽지만 브라우저가 자동 첨부해 CSRF 대상이 된다 SameSite, CSRF 토큰
앱 메모리 + Refresh는 HttpOnly 쿠키 새로고침하면 Access를 다시 받아야 한다 재발급 엔드포인트만 쿠키로 보호

브라우저 기반 서비스라면 Refresh 토큰은 HttpOnly; Secure; SameSite 쿠키에, Access 토큰은 메모리에 두는 구성이 균형이 좋다. 쿠키로 인증하는 엔드포인트가 있다면 CSRF 보호를 끄지 않는다.

결정 4: 직접 필터를 만들지 말고 리소스 서버를 쓴다

Spring Security는 JWT 검증을 리소스 서버 기능으로 제공한다. 서명, 만료(exp), 발급자(iss) 검증을 직접 구현할 필요가 없다.

# application.yml (API 서버)
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com   # 공개 키(JWKS)와 발급자 검증을 이 주소 기준으로
@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())   // Authorization 헤더로만 인증하는 API일 때
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .oauth2ResourceServer(o -> o.jwt(jwt -> jwt.jwtAuthenticationConverter(rolesConverter())));
        return http.build();
    }

    JwtAuthenticationConverter rolesConverter() {
        JwtGrantedAuthoritiesConverter authorities = new JwtGrantedAuthoritiesConverter();
        authorities.setAuthoritiesClaimName("roles");   // 기본값은 scope/scp 클레임
        authorities.setAuthorityPrefix("ROLE_");        // hasRole("ADMIN")과 맞추기 위해
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(authorities);
        return converter;
    }
}

여러 서비스가 같은 인증 서버를 쓰면 대상(aud) 클레임도 검증해, 다른 서비스용으로 발급된 토큰을 받지 않게 한다.

결정 5: 인가는 두 층으로

  • URL 단위(authorizeHttpRequests): "관리자 경로는 ADMIN만"처럼 거친 규칙.
  • 메서드 단위(@PreAuthorize): 데이터 소유권까지 보는 세밀한 규칙.
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.name")
public UserProfile getProfile(String userId) { ... }

권한 이름은 역할(ROLE_ADMIN)과 행위(order:read 같은 스코프)를 섞지 않도록 표준을 정한다. "남의 주문을 조회할 수 있는가" 같은 소유권 검사는 URL 규칙으로는 표현할 수 없으니 메서드나 서비스 계층에서 한다.

토큰에 무엇을 넣나

넣는다 넣지 않는다
sub(사용자 식별자), iss, aud, exp, iat 비밀번호, 주민등록번호 같은 민감 정보
권한(roles 또는 scope) 자주 바뀌는 상세 프로필
토큰 ID(jti, 폐기 추적용) 화면 표시용 데이터 묶음

JWT 본문은 Base64로 인코딩됐을 뿐 암호화되지 않는다. 누구나 열어 볼 수 있다고 가정한다.

자주 하는 실수

  • 직접 만든 필터에서 검증을 빼먹는다. 서명만 보고 만료나 발급자를 안 보는 식의 구멍이 생긴다. 리소스 서버 기능을 쓴다.
  • Access 토큰 수명을 길게 둔다. 탈취·권한 변경에 대응할 수 없다.
  • Refresh 토큰을 서버에서 추적하지 않는다. 로그아웃해도 토큰이 계속 쓸모 있다.
  • 쿠키 인증인데 CSRF 보호를 끈다. 예제의 csrf.disable()을 그대로 복사한 경우가 많다.
  • 토큰에 개인정보를 넣는다. 로그, 브라우저 저장소, 프록시 어디서든 그대로 보인다.

점검표

  • 인증 서버와 API 서버의 역할과 키가 나뉘어 있다
  • 서명은 비대칭(RS256·ES256)이고 JWKS와 kid로 키를 교체할 수 있다
  • Access 토큰 수명이 짧다
  • Refresh 토큰은 서버에서 상태를 관리하고 회전·재사용 감지를 한다
  • 브라우저 저장 위치와 그에 맞는 XSS·CSRF 대응이 정해졌다
  • API 서버는 리소스 서버 기능으로 서명·exp·iss·aud를 검증한다
  • 클레임과 권한 매핑(roles → ROLE_)이 표준으로 정해졌다
  • 소유권 검사는 메서드·서비스 계층에서 한다
  • 토큰에 민감 정보가 없다

JWT 아키텍처의 핵심은 토큰을 만드는 코드가 아니라, 발급한 토큰을 되돌릴 수 없다는 사실을 어떻게 다룰지에 있다. 짧은 Access, 추적되는 Refresh, 비대칭 서명, 검증된 프레임워크 기능을 조합하면 그 약점을 감당할 수 있는 크기로 줄일 수 있다.

댓글 0개

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.

댓글 쓰기

← 목록으로 돌아가기