이 글에서 얻는 것

  • OAuth2 Authorization Code 흐름을 실제 Spring Security 설정으로 연결할 수 있습니다.
  • JWT 발급/검증을 Resource Server 설정으로 구현할 수 있습니다.
  • 실무에서 자주 터지는 토큰 저장/만료/리프레시 정책의 함정을 예방할 수 있습니다.
  • Multi-tenant JWT 검증, 커스텀 클레임 매핑, 테스트 전략까지 실전 구현을 다룹니다.

1) 전체 구조 한 장으로 이해하기

sequenceDiagram
    participant User as 사용자
    participant Client as 클라이언트(웹/앱)
    participant AS as Authorization Server
    participant RS as Resource Server(API)

    User->>Client: 로그인 시도
    Client->>AS: /authorize (Authorization Code 요청 + PKCE code_challenge)
    AS->>User: 로그인/동의 화면
    User->>AS: 인증 완료
    AS-->>Client: Authorization Code (redirect)
    Client->>AS: /token (Code + code_verifier → Access Token 교환)
    AS-->>Client: Access Token (JWT) + Refresh Token
    Client->>RS: API 요청 (Authorization: Bearer <JWT>)
    RS->>RS: JWT 서명 검증 (JWK), 클레임 추출
    RS-->>Client: 응답

핵심은 Authorization Server(AS)에서 발급한 JWT를 Resource Server(RS)에서 검증하는 구조입니다.


2) Authorization Server 구성 (Spring Authorization Server)

실제 운영에서는 AS와 RS를 분리하는 것이 일반적입니다.

2-1. 기본 클라이언트 등록

@Configuration
public class AuthorizationServerConfig {

    @Bean
    public RegisteredClientRepository registeredClientRepository(PasswordEncoder encoder) {
        RegisteredClient webClient = RegisteredClient.withId(UUID.randomUUID().toString())
            .clientId("web-client")
            .clientSecret(encoder.encode("secret"))
            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
            .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
            .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
            .redirectUri("https://app.example.com/login/oauth2/code")
            .redirectUri("http://localhost:3000/login/oauth2/code")  // 개발용
            .scope("read")
            .scope("write")
            .scope("admin")
            .clientSettings(ClientSettings.builder()
                .requireProofKey(true)                    // PKCE 강제
                .requireAuthorizationConsent(true)        // 동의 화면 표시
                .build())
            .tokenSettings(TokenSettings.builder()
                .accessTokenTimeToLive(Duration.ofMinutes(15))    // Access Token 15분
                .refreshTokenTimeToLive(Duration.ofDays(7))       // Refresh Token 7일
                .reuseRefreshTokens(false)                        // Rotation 활성화
                .idTokenSignatureAlgorithm(SignatureAlgorithm.RS256)
                .build())
            .build();

        return new InMemoryRegisteredClientRepository(webClient);
    }
}

2-2. JWT 커스텀 클레임 추가

토큰에 사용자 역할, 테넌트 정보 등 비즈니스 클레임을 추가하는 것은 실무에서 거의 필수입니다.

@Component
public class CustomTokenCustomizer implements OAuth2TokenCustomizer<JwtEncodingContext> {

    private final UserRepository userRepository;

    public CustomTokenCustomizer(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public void customize(JwtEncodingContext context) {
        if (context.getTokenType().equals(OAuth2TokenType.ACCESS_TOKEN)) {
            Authentication principal = context.getPrincipal();
            String username = principal.getName();

            // DB에서 사용자 정보 조회
            User user = userRepository.findByUsername(username)
                .orElseThrow();

            context.getClaims().claims(claims -> {
                claims.put("user_id", user.getId());
                claims.put("roles", user.getRoles().stream()
                    .map(Role::getName)
                    .collect(Collectors.toList()));
                claims.put("tenant_id", user.getTenantId());

                // 민감 정보는 절대 넣지 않음
                // ❌ claims.put("email", user.getEmail());
                // ❌ claims.put("phone", user.getPhone());
            });
        }
    }
}

2-3. JWK 키 관리

@Bean
public JWKSource<SecurityContext> jwkSource() {
    // 운영 환경: 키 저장소에서 로드 (Vault, KMS, HSM)
    // 개발 환경: 자동 생성
    KeyPair keyPair = generateRsaKey();
    RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
    RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();

    RSAKey rsaKey = new RSAKey.Builder(publicKey)
        .privateKey(privateKey)
        .keyID(UUID.randomUUID().toString())    // kid: 키 로테이션 시 구분용
        .build();

    JWKSet jwkSet = new JWKSet(rsaKey);
    return new ImmutableJWKSet<>(jwkSet);
}

키 로테이션 전략: kid(Key ID)를 사용하여 새 키 발급 → 이전 키로 서명된 토큰도 검증 가능 → 이전 키 만료 후 제거. JWK 엔드포인트에 신/구 키를 동시에 노출합니다.


3) Resource Server에서 JWT 검증

3-1. 기본 설정

@Configuration
@EnableMethodSecurity
public class ResourceServerConfig {

    @Bean
    public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**", "/actuator/health/**").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/**").authenticated()
                .anyRequest().denyAll()     // 명시되지 않은 경로는 차단
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwkSetUri("https://auth.example.com/.well-known/jwks.json")
                    .jwtAuthenticationConverter(jwtAuthenticationConverter())
                )
            );

        return http.build();
    }
}

3-2. JWT → Spring Security 권한 매핑

기본적으로 Spring Security는 scope 클레임만 SCOPE_xxx 권한으로 변환합니다. 커스텀 roles 클레임을 사용하려면 변환기가 필요합니다.

@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter =
        new JwtGrantedAuthoritiesConverter();
    grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
    grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");

    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(jwt -> {
        // 1. roles 클레임 → ROLE_xxx
        Collection<GrantedAuthority> roleAuthorities =
            grantedAuthoritiesConverter.convert(jwt);

        // 2. scope 클레임 → SCOPE_xxx (기본 변환 유지)
        JwtGrantedAuthoritiesConverter scopeConverter =
            new JwtGrantedAuthoritiesConverter();
        Collection<GrantedAuthority> scopeAuthorities =
            scopeConverter.convert(jwt);

        // 3. 합치기
        List<GrantedAuthority> combined = new ArrayList<>();
        combined.addAll(roleAuthorities);
        combined.addAll(scopeAuthorities);

        return combined;
    });

    return converter;
}

3-3. 컨트롤러에서 JWT 정보 사용

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping
    @PreAuthorize("hasRole('USER') and #jwt.claims['tenant_id'] == #tenantId")
    public List<Order> getOrders(
            @AuthenticationPrincipal Jwt jwt,
            @RequestHeader("X-Tenant-Id") String tenantId) {

        String userId = jwt.getClaimAsString("user_id");
        String tokenTenantId = jwt.getClaimAsString("tenant_id");

        // 테넌트 검증 (Defense in Depth)
        if (!tokenTenantId.equals(tenantId)) {
            throw new AccessDeniedException("Tenant mismatch");
        }

        return orderService.findByTenantAndUser(tokenTenantId, userId);
    }

    @PostMapping
    @PreAuthorize("hasRole('USER') and hasAuthority('SCOPE_write')")
    public Order createOrder(@RequestBody CreateOrderRequest request,
                              @AuthenticationPrincipal Jwt jwt) {
        return orderService.create(request, jwt.getClaimAsString("user_id"));
    }
}

4) Multi-Tenant JWT 검증

여러 Authorization Server(또는 테넌트)에서 발급한 JWT를 하나의 Resource Server에서 검증해야 하는 상황.

4-1. Issuer 기반 동적 검증

@Bean
public JwtDecoder jwtDecoder() {
    // 허용된 issuer 목록
    Map<String, String> issuerJwkUris = Map.of(
        "https://auth.tenant-a.com", "https://auth.tenant-a.com/.well-known/jwks.json",
        "https://auth.tenant-b.com", "https://auth.tenant-b.com/.well-known/jwks.json",
        "https://accounts.google.com", "https://www.googleapis.com/oauth2/v3/certs"
    );

    return new DelegatingJwtDecoder(issuerJwkUris);
}

/**
 * issuer별로 적절한 JwtDecoder를 선택하는 커스텀 디코더
 */
public class DelegatingJwtDecoder implements JwtDecoder {

    private final Map<String, JwtDecoder> decoders = new ConcurrentHashMap<>();
    private final Map<String, String> issuerJwkUris;

    public DelegatingJwtDecoder(Map<String, String> issuerJwkUris) {
        this.issuerJwkUris = issuerJwkUris;
    }

    @Override
    public Jwt decode(String token) throws JwtException {
        // 1. 서명 검증 없이 issuer만 추출 (헤더 + 페이로드는 Base64)
        String issuer = extractIssuer(token);

        // 2. 허용된 issuer인지 확인
        String jwkUri = issuerJwkUris.get(issuer);
        if (jwkUri == null) {
            throw new JwtException("Unknown issuer: " + issuer);
        }

        // 3. issuer별 JwtDecoder 캐싱 후 검증
        JwtDecoder decoder = decoders.computeIfAbsent(issuer,
            iss -> NimbusJwtDecoder.withJwkSetUri(jwkUri)
                .jwsAlgorithm(SignatureAlgorithm.RS256)
                .build());

        return decoder.decode(token);
    }

    private String extractIssuer(String token) {
        // JWT의 payload 부분만 Base64 디코딩하여 iss 추출
        String[] parts = token.split("\\.");
        if (parts.length != 3) throw new JwtException("Invalid JWT format");

        String payload = new String(Base64.getUrlDecoder().decode(parts[1]));
        // JSON 파싱하여 "iss" 필드 추출
        return JsonPath.read(payload, "$.iss");
    }
}

4-2. Spring Security 5.x+ 내장 멀티 이슈어 지원

# application.yml — 더 간단한 방법 (Spring Boot 3.x)
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com
          # 단일 issuer만 지원. 멀티 issuer는 위 DelegatingJwtDecoder 방식 권장.

5) Refresh Token 전략 심화

5-1. 저장 위치별 보안 비교

저장 위치XSS 방어CSRF 방어탈취 난이도권장 환경
HttpOnly Secure Cookie⚠️ SameSite 필요높음웹 (SPA, SSR)
localStorage낮음 (XSS 시 즉시 탈취)비권장
sessionStorage중간 (탭 닫으면 삭제)비권장
Keychain/Keystore매우 높음네이티브 모바일
BFF(Backend For Frontend)매우 높음최선 (서버 측 토큰 관리)

5-2. Refresh Token Rotation 구현

@Service
@Transactional
public class TokenRotationService {

    private final RefreshTokenRepository tokenRepository;
    private final TokenBlacklistService blacklistService;

    /**
     * Refresh Token 사용 시:
     * 1. 기존 토큰 검증
     * 2. 기존 토큰 무효화
     * 3. 새 Access Token + 새 Refresh Token 발급
     */
    public TokenPair rotate(String refreshToken) {
        RefreshTokenEntity entity = tokenRepository
            .findByToken(refreshToken)
            .orElseThrow(() -> new InvalidTokenException("Token not found"));

        // 이미 사용된 토큰이 다시 들어옴 → 탈취 의심
        if (entity.isUsed()) {
            // 해당 사용자의 모든 Refresh Token 무효화 (Replay Attack 대응)
            tokenRepository.revokeAllByUserId(entity.getUserId());
            blacklistService.addToBlacklist(entity.getUserId());
            throw new SecurityException("Token reuse detected — all sessions revoked");
        }

        // 만료 확인
        if (entity.isExpired()) {
            throw new InvalidTokenException("Refresh token expired");
        }

        // 기존 토큰 사용 처리
        entity.markAsUsed();
        tokenRepository.save(entity);

        // 새 토큰 쌍 발급
        return issueNewTokens(entity.getUserId(), entity.getScopes());
    }
}

5-3. BFF(Backend For Frontend) 패턴 — 최선의 토큰 관리

┌─────────────┐     ┌─────────────┐     ┌──────────────────┐
│   Browser   │────>│  BFF Server │────>│ Resource Server   │
│ (쿠키만 전송) │     │ (토큰 보관)  │     │ (JWT 검증)        │
└─────────────┘     └─────────────┘     └──────────────────┘

장점:
- 브라우저에 토큰이 노출되지 않음 (XSS 무력화)
- Refresh Token이 서버에만 존재
- CSRF는 SameSite + CSRF Token으로 방어

단점:
- BFF 서버 운영 비용
- 세션 관리 복잡도 증가

6) JWT 보안 강화 체크리스트

6-1. 클레임 설계 원칙

{
  "iss": "https://auth.example.com",
  "sub": "user-abc-123",
  "aud": "https://api.example.com",
  "exp": 1710003600,
  "iat": 1710000000,
  "nbf": 1710000000,
  "jti": "unique-token-id-for-replay-detection",
  "roles": ["ROLE_USER"],
  "scope": "read write",
  "tenant_id": "tenant-xyz",
  "user_id": "user-abc-123"
}

절대 넣지 말아야 할 것:

  • 이메일, 전화번호, 주소 (개인정보)
  • 비밀번호 해시
  • 내부 DB ID (외부 노출 가능한 식별자 사용)
  • 과도한 권한 목록 (토큰 크기 증가 → 매 요청 헤더 비용)

6-2. 토큰 크기 관리

JWT 크기 = Header(~36B) + Payload + Signature(~342B for RS256)

문제: 클레임이 많아지면 토큰이 커짐
     → 매 API 요청 헤더에 포함 → 대역폭 낭비
     → Nginx/ALB의 max_header_size 초과 가능 (기본 8KB)

권장:
- Access Token: 1KB 이하 유지
- 상세 정보는 /userinfo 엔드포인트로 분리
- 권한이 복잡하면 role → permission 매핑을 서버 측에서 수행

7) 테스트 전략

7-1. 단위 테스트 — JWT 모킹

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Test
    @DisplayName("인증된 사용자가 주문 목록을 조회할 수 있다")
    void getOrders_authenticated_success() throws Exception {
        mockMvc.perform(get("/api/orders")
                .header("X-Tenant-Id", "tenant-xyz")
                .with(jwt()                                   // Spring Security Test
                    .jwt(builder -> builder
                        .claim("user_id", "user-123")
                        .claim("tenant_id", "tenant-xyz")
                        .claim("roles", List.of("ROLE_USER"))
                    )
                    .authorities(new SimpleGrantedAuthority("ROLE_USER"))
                ))
            .andExpect(status().isOk());
    }

    @Test
    @DisplayName("테넌트 불일치 시 403을 반환한다")
    void getOrders_tenantMismatch_forbidden() throws Exception {
        mockMvc.perform(get("/api/orders")
                .header("X-Tenant-Id", "tenant-other")
                .with(jwt()
                    .jwt(builder -> builder
                        .claim("user_id", "user-123")
                        .claim("tenant_id", "tenant-xyz")    // 토큰의 테넌트
                        .claim("roles", List.of("ROLE_USER"))
                    )
                    .authorities(new SimpleGrantedAuthority("ROLE_USER"))
                ))
            .andExpect(status().isForbidden());
    }

    @Test
    @DisplayName("인증 없이 요청하면 401을 반환한다")
    void getOrders_unauthenticated_401() throws Exception {
        mockMvc.perform(get("/api/orders")
                .header("X-Tenant-Id", "tenant-xyz"))
            .andExpect(status().isUnauthorized());
    }
}

7-2. 통합 테스트 — 실제 JWT 발급/검증 흐름

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OAuth2IntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private JwtEncoder jwtEncoder;

    @Test
    @DisplayName("실제 JWT로 API 호출이 성공한다")
    void fullFlow_withRealJwt() {
        // 1. 테스트용 JWT 생성
        JwtClaimsSet claims = JwtClaimsSet.builder()
            .issuer("https://auth.example.com")
            .subject("test-user")
            .audience(List.of("https://api.example.com"))
            .issuedAt(Instant.now())
            .expiresAt(Instant.now().plusSeconds(300))
            .claim("user_id", "user-123")
            .claim("roles", List.of("ROLE_USER"))
            .claim("tenant_id", "tenant-xyz")
            .build();

        String jwt = jwtEncoder.encode(
            JwtEncoderParameters.from(claims)).getTokenValue();

        // 2. API 호출
        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(jwt);
        headers.set("X-Tenant-Id", "tenant-xyz");

        ResponseEntity<String> response = restTemplate.exchange(
            "/api/orders", HttpMethod.GET,
            new HttpEntity<>(headers), String.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
    }

    @Test
    @DisplayName("만료된 JWT로 요청하면 401이 반환된다")
    void expiredJwt_returns401() {
        JwtClaimsSet claims = JwtClaimsSet.builder()
            .issuer("https://auth.example.com")
            .subject("test-user")
            .issuedAt(Instant.now().minusSeconds(3600))
            .expiresAt(Instant.now().minusSeconds(1800))    // 이미 만료
            .build();

        String jwt = jwtEncoder.encode(
            JwtEncoderParameters.from(claims)).getTokenValue();

        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(jwt);

        ResponseEntity<String> response = restTemplate.exchange(
            "/api/orders", HttpMethod.GET,
            new HttpEntity<>(headers), String.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
    }
}

7-3. 보안 테스트 체크리스트

[ ] 만료된 토큰 → 401
[ ] 서명이 변조된 토큰 → 401
[ ] alg: "none" 공격 → 401 (Spring Security 기본 차단)
[ ] 다른 issuer의 토큰 → 401
[ ] scope 부족한 요청 → 403
[ ] 테넌트 불일치 → 403
[ ] ADMIN 전용 API에 USER 토큰 → 403
[ ] Refresh Token Replay → 전체 세션 무효화
[ ] 과도하게 큰 토큰 (> 8KB) → 413 또는 적절한 에러

8) 운영 모니터링과 장애 대응

8-1. JWT 관련 메트릭

# 필수 모니터링 지표

jwt_validation_total{result="success"}     # JWT 검증 성공 수
jwt_validation_total{result="expired"}     # 만료 토큰 수
jwt_validation_total{result="invalid"}     # 서명 불일치/형식 오류
jwt_validation_total{result="unknown_issuer"}  # 알 수 없는 issuer

jwt_validation_duration_seconds{quantile="0.99"}  # 검증 소요 시간

refresh_token_rotation_total{result="success"}
refresh_token_rotation_total{result="replay_detected"}  # 탈취 의심

8-2. 흔한 장애 시나리오와 대응

장애증상원인대응
JWT 검증 실패 급증401 폭발JWK 캐시 만료 + AS 다운JWK 캐시 TTL 연장, 로컬 fallback 키
만료 오류 급증401 증가 (exp 관련)서버/클라이언트 NTP 불일치NTP 동기화, clockSkew 허용 설정
로그아웃 후 접근 가능보안 이슈Access Token 만료가 김만료 5~15분으로 단축
토큰 너무 큼Nginx 502클레임 과다클레임 정리, /userinfo 분리
키 로테이션 장애모든 JWT 검증 실패새 키만 JWK에 노출, 구 키 제거신구 키 동시 노출 후 점진 제거

8-3. JWK 캐시 설정

@Bean
public JwtDecoder jwtDecoder() {
    NimbusJwtDecoder decoder = NimbusJwtDecoder
        .withJwkSetUri("https://auth.example.com/.well-known/jwks.json")
        .jwsAlgorithm(SignatureAlgorithm.RS256)
        .build();

    // 시계 오차 허용 (NTP 미세 차이 대응)
    OAuth2TokenValidator<Jwt> withClockSkew = new DelegatingOAuth2TokenValidator<>(
        new JwtTimestampValidator(Duration.ofSeconds(30)),    // 30초 허용
        new JwtIssuerValidator("https://auth.example.com")
    );
    decoder.setJwtValidator(withClockSkew);

    return decoder;
}

9) 보안 강화: 추가 방어 계층

9-1. Token Binding (DPoP — Demonstrating Proof-of-Possession)

일반 Bearer Token:
  - 탈취 시 누구나 사용 가능 (Bearer = 소지자)

DPoP Token:
  - 토큰 + 클라이언트 키 증명이 함께 필요
  - 탈취해도 키 없이는 사용 불가

DPoP 헤더 예시:
  Authorization: DPoP <access_token>
  DPoP: <proof_jwt_signed_with_client_key>

지원: Spring Authorization Server 1.2+, OAuth 2.0 DPoP (RFC 9449)

9-2. Audience 검증 강화

// Resource Server별 audience 검증
OAuth2TokenValidator<Jwt> audienceValidator = jwt -> {
    List<String> audiences = jwt.getAudience();
    if (audiences == null || !audiences.contains("https://api.example.com")) {
        return OAuth2TokenValidatorResult.failure(
            new OAuth2Error("invalid_audience", "Token not intended for this API", null));
    }
    return OAuth2TokenValidatorResult.success();
};

✅ 운영 체크리스트

  • Access Token 만료 시간 ≤ 15분
  • Refresh Token Rotation 활성화 (reuseRefreshTokens(false))
  • Refresh Token 재사용 감지 → 전체 세션 무효화 로직 구현
  • JWK 엔드포인트 가용성 모니터링 + 캐시 TTL 설정
  • 키 로테이션 시 신구 키 동시 노출 확인
  • JWT 클레임에 개인정보 미포함 확인
  • 토큰 크기 1KB 이하 유지
  • Clock Skew 허용 설정 (30초 권장)
  • alg: none 공격 차단 확인 (Spring Security 기본 차단)
  • Audience(aud) 검증 활성화
  • 웹: HttpOnly Secure SameSite Cookie로 Refresh Token 저장
  • jwt_validation_total 메트릭 대시보드 구성
  • Replay Attack 탐지 알람 설정
  • 보안 테스트(만료/변조/scope부족/issuer불일치) 자동화

요약

  • OAuth2의 핵심은 Authorization Server 발급 + Resource Server 검증 분리
  • JWT는 짧은 만료(15분) + Refresh Token Rotation이 기본 전략
  • 커스텀 클레임은 비즈니스 식별자만 최소로, 민감정보 절대 포함 금지
  • Multi-tenant 환경에서는 issuer 기반 동적 검증 또는 DelegatingJwtDecoder 사용
  • 테스트: @WithMockUser 대신 jwt() 모킹으로 실전에 가까운 테스트
  • BFF 패턴이 브라우저 환경에서 가장 안전한 토큰 관리 방식

관련 글