๐Ÿ’ป ๊ฐœ๋ฐœ/Spring

Spring Cloud Gateway์—์„œ JWT ์ธ์ฆ ํ•„ํ„ฐ ๊ตฌํ˜„ํ•˜๊ธฐ

thrownewme 2026. 1. 13. 13:39

๐Ÿค” ์™œ Gateway์—์„œ ์ธ์ฆ์„ ์ฒ˜๋ฆฌํ–ˆ๋ƒ๋ฉด

์•„ํ‚คํ…์ฒ˜๋ฅผ MSA ๋กœ ๋ณ€๊ฒฝํ•˜๋ฉด์„œ ์ธ์ฆ์„ ์–ด๋””์„œ ํ• ์ง€ ์€๊ทผ ๊ณ ๋ฏผ์ด ๋งŽ์•˜๋‹ค

๊ฐ๊ฐ ์„œ๋น„์Šค์—์„œ ์ฒ˜๋ฆฌํ• ์ง€ ๊ฒŒ์ดํŠธ์›จ์ด์—์„œ ํ•œ ๋ฒˆ์— ์ฒ˜๋ฆฌํ• ์ง€;;;

 

๋งˆ์ดํฌ๋กœ์„œ๋น„์Šค ์•„ํ‚คํ…์ฒ˜์—์„œ ์ธ์ฆ ์ฒ˜๋ฆฌ ์œ„์น˜๋Š” ํฌ๊ฒŒ ๋‘ ๊ฐ€์ง€๋‹ค:

๋ฐฉ์‹ ์žฅ์  ๋‹จ์ 
๊ฐ ์„œ๋น„์Šค์—์„œ ์ฒ˜๋ฆฌ ์„œ๋น„์Šค๋ณ„ ์„ธ๋ฐ€ํ•œ ์ œ์–ด ์ค‘๋ณต ์ฝ”๋“œ, ์ธ์ฆ ๋กœ์ง ์‚ฐ์žฌ
Gateway์—์„œ ์ฒ˜๋ฆฌ ๋‹จ์ผ ์ง„์ž…์ , ์ค‘์•™ ๊ด€๋ฆฌ Gateway ๋ถ€ํ•˜ ์ง‘์ค‘

 

๋‚˜๋Š” Gateway์—์„œ ์ธ์ฆ์„ ์ฒ˜๋ฆฌํ•˜๋Š” ๋ฐฉ์‹์„ ์„ ํƒํ–ˆ๋‹ค.

์™œ๋ƒ๋ฉด?

  • ์ธ์ฆ ๋กœ์ง ํ•œ ๊ณณ์—์„œ ๊ด€๋ฆฌ
  • ๊ฐ ๋งˆ์ดํฌ๋กœ์„œ๋น„์Šค๋Š” ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์—๋งŒ ์ง‘์ค‘
  • ์ธ์ฆ ์ •์ฑ… ๋ณ€๊ฒฝ ์‹œ Gateway๋งŒ ์ˆ˜์ •

๐Ÿ› ๏ธ GlobalFilter๋กœ ์ธ์ฆ ํ•„ํ„ฐ ๋งŒ๋“ค๊ธฐ

Spring Cloud Gateway์—์„œ๋Š” GlobalFilter๋ฅผ ๊ตฌํ˜„ํ•˜๋ฉด ๋ชจ๋“  ์š”์ฒญ์— ๋Œ€ํ•ด ํ•„ํ„ฐ๊ฐ€ ์‹คํ–‰๋œ๋‹ค.

@Slf4j
@Component
public class JwtAuthenticationFilter implements GlobalFilter, Ordered {

    private final JwtTokenValidator jwtValidator;
    private final AntPathMatcher pathMatcher = new AntPathMatcher();

    @Value("${app.auth.enabled:true}")
    private boolean authEnabled;

    @Value("${app.auth.public-paths:}")
    private String publicPathsConfig;

    public JwtAuthenticationFilter(JwtTokenValidator jwtValidator) {
        this.jwtValidator = jwtValidator;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        String path = request.getPath().value();

        // 1. CORS Preflight ์š”์ฒญ์€ ํ†ต๊ณผ
        if (CorsUtils.isPreFlightRequest(request)) {
            return chain.filter(exchange);
        }

        // 2. ์ธ์ฆ ๋น„ํ™œ์„ฑํ™” (๊ฐœ๋ฐœ ํ™˜๊ฒฝ)
        if (!authEnabled) {
            return chain.filter(exchange);
        }

        // 3. ๊ณต๊ฐœ ๊ฒฝ๋กœ๋Š” ์ธ์ฆ ์ƒ๋žต
        if (isPublicPath(path)) {
            log.debug("Public path - skip auth: {}", path);
            return chain.filter(exchange);
        }

        // 4. ํ† ํฐ ์ถ”์ถœ
        String token = extractToken(request);
        if (!StringUtils.hasText(token)) {
            return sendUnauthorized(exchange, "No token provided");
        }

        // 5. ํ† ํฐ ๊ฒ€์ฆ
        if (!jwtValidator.validateToken(token)) {
            return sendUnauthorized(exchange, "Invalid token");
        }

        // 6. ํด๋ ˆ์ž„ ์ถ”์ถœ → ๋‹ค์šด์ŠคํŠธ๋ฆผ ์„œ๋น„์Šค๋กœ ์ „๋‹ฌ
        Claims claims = jwtValidator.getClaims(token);
        ServerHttpRequest modifiedRequest = addUserHeaders(request, claims);

        return chain.filter(exchange.mutate().request(modifiedRequest).build());
    }

    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;  // ๊ฐ€์žฅ ๋จผ์ € ์‹คํ–‰
    }
}

ํ•ต์‹ฌ ํฌ์ธํŠธ

  1. GlobalFilter + Ordered: ๋ชจ๋“  ์š”์ฒญ์— ๋Œ€ํ•ด ํ•„ํ„ฐ ์‹คํ–‰, ์ˆœ์„œ ์ œ์–ด
  2. HIGHEST_PRECEDENCE: ๋‹ค๋ฅธ ํ•„ํ„ฐ๋ณด๋‹ค ๋จผ์ € ์‹คํ–‰๋˜์–ด์•ผ ์ธ์ฆ์ด ์˜๋ฏธ ์žˆ์Œ
  3. WebFlux ๊ธฐ๋ฐ˜: Mono<Void> ๋ฐ˜ํ™˜ (๋ฆฌ์•กํ‹ฐ๋ธŒ)

๐Ÿ”‘ ํ† ํฐ ์ถ”์ถœ: ํ—ค๋” vs ์ฟ ํ‚ค

์‹ค๋ฌด์—์„œ๋Š” ํ† ํฐ์ด ์—ฌ๋Ÿฌ ๊ณณ์— ์žˆ์„ ์ˆ˜ ์žˆ๋‹ค:

  • SPA: Authorization: Bearer xxx ํ—ค๋”
  • SSR/์›น์‚ฌ์ดํŠธ: HttpOnly ์ฟ ํ‚ค

๋‘˜ ๋‹ค ์ง€์›ํ•˜๋ ค๋ฉด:

private String extractToken(ServerHttpRequest request) {
    // 1์ˆœ์œ„: Authorization ํ—ค๋”
    String bearerToken = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
    if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
        return bearerToken.substring(7);
    }

    // 2์ˆœ์œ„: ์ฟ ํ‚ค
    HttpCookie cookie = request.getCookies().getFirst("access_token");
    if (cookie != null && StringUtils.hasText(cookie.getValue())) {
        return cookie.getValue();
    }

    return null;
}

๐Ÿ’ก Tip: ์ฟ ํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด XSS ๊ณต๊ฒฉ์— ๋” ์•ˆ์ „ํ•˜๋‹ค (HttpOnly ์„ค์ • ์‹œ).
๋‹ค๋งŒ CSRF ๊ณต๊ฒฉ์—๋Š” ์ทจ์•ฝํ•ด์ง€๋ฏ€๋กœ CSRF ํ† ํฐ์„ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜์ž.

 


๐Ÿšช ๊ณต๊ฐœ ๊ฒฝ๋กœ ์„ค์ •

๋กœ๊ทธ์ธ, ํšŒ์›๊ฐ€์ž…, ํ—ฌ์Šค์ฒดํฌ ๊ฐ™์€ API๋Š” ์ธ์ฆ ์—†์ด ์ ‘๊ทผ ๊ฐ€๋Šฅํ•ด์•ผ ํ•œ๋‹ค.

 

application.yml:

app:
  auth:
    enabled: true
    public-paths: >
      /api/auth/**,
      /api/public/**,
      /actuator/health,
      /swagger-ui/**,
      /v3/api-docs/**

 

ํ•„ํ„ฐ์—์„œ ํ™•์ธ:

private boolean isPublicPath(String path) {
    if (!StringUtils.hasText(publicPathsConfig)) {
        return false;
    }

    List<String> publicPaths = Arrays.stream(publicPathsConfig.split(","))
            .map(String::trim)
            .filter(StringUtils::hasText)
            .toList();

    return publicPaths.stream()
            .anyMatch(pattern -> pathMatcher.match(pattern, path));
}

 

 

AntPathMatcher๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด /** ๊ฐ™์€ ์™€์ผ๋“œ์นด๋“œ ํŒจํ„ด์„ ์ง€์›ํ•œ๋‹ค.

 


๐Ÿ“ค ๋‹ค์šด์ŠคํŠธ๋ฆผ ์„œ๋น„์Šค๋กœ ์ธ์ฆ ์ •๋ณด ์ „๋‹ฌ

Gateway์—์„œ ์ธ์ฆ์„ ์ฒ˜๋ฆฌํ•˜๋ฉด ๋‹ค์šด์ŠคํŠธ๋ฆผ ์„œ๋น„์Šค๋Š” ํ† ํฐ์„ ๋‹ค์‹œ ํŒŒ์‹ฑํ•  ํ•„์š”๊ฐ€ ์—†๋‹ค.
HTTP ํ—ค๋”๋กœ ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ์ „๋‹ฌํ•˜๋ฉด ๋œ๋‹ค:

private ServerHttpRequest addUserHeaders(ServerHttpRequest request, Claims claims) {
    return request.mutate()
            .header("X-User-Id", claims.getSubject())
            .header("X-User-Name", claims.get("name", String.class))
            .header("X-User-Roles", String.join(",", getRoles(claims)))
            .header("X-Token-Issued-At", String.valueOf(claims.getIssuedAt().getTime()))
            .header("X-Token-Expiration", String.valueOf(claims.getExpiration().getTime()))
            .build();
}

 

๋‹ค์šด์ŠคํŠธ๋ฆผ ์„œ๋น„์Šค์—์„œ๋Š” ํ—ค๋”๋งŒ ์ฝ์œผ๋ฉด ๋จ:

// ๊ฐ ๋งˆ์ดํฌ๋กœ์„œ๋น„์Šค์—์„œ
@GetMapping("/me")
public UserInfo getCurrentUser(
        @RequestHeader("X-User-Id") String userId,
        @RequestHeader("X-User-Name") String userName) {
    return new UserInfo(userId, userName);
}

 


๐Ÿ”„ ํ† ํฐ ์ž๋™ ๊ฐฑ์‹  (Refresh Token)

Access Token์ด ๊ณง ๋งŒ๋ฃŒ๋  ๋•Œ ์ž๋™์œผ๋กœ ๊ฐฑ์‹ ํ•˜๋ฉด UX๊ฐ€ ์ข‹์•„์ง„๋‹ค.

@Slf4j
@Component
public class TokenRefreshFilter implements GlobalFilter, Ordered {

    private final JwtTokenValidator jwtValidator;
    private final WebClient webClient;

    @Value("${app.auth.token-uri}")
    private String tokenUri;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String accessToken = extractAccessToken(exchange.getRequest());

        // Access Token์ด 5๋ถ„ ์ด๋‚ด ๋งŒ๋ฃŒ ์˜ˆ์ •์ด๋ฉด ๊ฐฑ์‹  ์‹œ๋„
        if (StringUtils.hasText(accessToken) && jwtValidator.isExpiringSoon(accessToken)) {
            String refreshToken = extractRefreshToken(exchange.getRequest());

            if (StringUtils.hasText(refreshToken)) {
                return refreshTokens(refreshToken)
                        .flatMap(newTokens -> {
                            setTokenCookies(exchange.getResponse(), newTokens);
                            return chain.filter(exchange);
                        })
                        .onErrorResume(e -> {
                            log.warn("Token refresh failed: {}", e.getMessage());
                            return chain.filter(exchange);  // ์‹คํŒจํ•ด๋„ ์ง„ํ–‰
                        });
            }
        }

        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE + 1;  // ์ธ์ฆ ํ•„ํ„ฐ ๋‹ค์Œ์— ์‹คํ–‰
    }
}

๊ฐฑ์‹  ๋กœ์ง ํฌ์ธํŠธ

  1. ๋งŒ๋ฃŒ 5๋ถ„ ์ „์— ๊ฐฑ์‹ : ์š”์ฒญ ์ค‘ ํ† ํฐ ๋งŒ๋ฃŒ ๋ฐฉ์ง€
  2. ์‹คํŒจํ•ด๋„ ์ง„ํ–‰: ๊ฐฑ์‹  ์‹คํŒจ ์‹œ ๊ธฐ์กด ํ† ํฐ์œผ๋กœ ๊ณ„์† (๋‹ค์Œ ์š”์ฒญ์— 401)
  3. ์ฟ ํ‚ค๋กœ ์ƒˆ ํ† ํฐ ์ „๋‹ฌ: ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์•Œ์•„์„œ ๋‹ค์Œ ์š”์ฒญ์— ์‚ฌ์šฉ

๐Ÿšจ 401 ์‘๋‹ต ์ฒ˜๋ฆฌ

ํ† ํฐ์ด ์—†๊ฑฐ๋‚˜ ์œ ํšจํ•˜์ง€ ์•Š์œผ๋ฉด 401์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค:

private Mono<Void> sendUnauthorized(ServerWebExchange exchange, String message) {
    ServerHttpResponse response = exchange.getResponse();
    response.setStatusCode(HttpStatus.UNAUTHORIZED);
    response.getHeaders().add("X-Auth-Error", message);

    log.debug("Unauthorized: {}", message);
    return response.setComplete();
}

๐Ÿ’ก Tip: X-Auth-Error ํ—ค๋”์— ๊ตฌ์ฒด์ ์ธ ์ด์œ ๋ฅผ ๋‹ด์œผ๋ฉด ๋””๋ฒ„๊น…์ด ํŽธํ•˜๋‹ค.
(ํ”„๋กœ๋•์…˜์—์„œ๋Š” ๋ณด์•ˆ์ƒ ์ผ๋ฐ˜์ ์ธ ๋ฉ”์‹œ์ง€๋กœ ๋Œ€์ฒดํ•  ์ˆ˜๋„ ์žˆ์Œ)


๐Ÿ˜ต ์‚ฝ์งˆ & ํ•ด๊ฒฐ

1. CORS Preflight ์š”์ฒญ์— 401์ด ๋–จ์–ด์ง

๋ฌธ์ œ: ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ๋ณด๋‚ด๋Š” OPTIONS ์š”์ฒญ์—๋„ ์ธ์ฆ ํ•„ํ„ฐ๊ฐ€ ์ ์šฉ๋จ

ํ•ด๊ฒฐ: Preflight ์š”์ฒญ์€ ๋ฌด์กฐ๊ฑด ํ†ต๊ณผ์‹œํ‚ค๊ธฐ

if (CorsUtils.isPreFlightRequest(request)) {
    return chain.filter(exchange);
}

2. ํ•„ํ„ฐ ์ˆœ์„œ๊ฐ€ ๊ผฌ์ž„

๋ฌธ์ œ: ๋กœ๊น… ํ•„ํ„ฐ๊ฐ€ ์ธ์ฆ ํ•„ํ„ฐ๋ณด๋‹ค ๋จผ์ € ์‹คํ–‰๋˜์–ด ์‚ฌ์šฉ์ž ์ •๋ณด๊ฐ€ ๋กœ๊ทธ์— ์•ˆ ์ฐํž˜

ํ•ด๊ฒฐ: getOrder() ๋ฉ”์„œ๋“œ๋กœ ๋ช…์‹œ์  ์ˆœ์„œ ์ง€์ •

// ์ธ์ฆ ํ•„ํ„ฐ: HIGHEST_PRECEDENCE (๊ฐ€์žฅ ๋จผ์ €)
// ํ† ํฐ ๊ฐฑ์‹ : HIGHEST_PRECEDENCE + 1
// ๋กœ๊น… ํ•„ํ„ฐ: HIGHEST_PRECEDENCE + 100

 

3. WebFlux์—์„œ Request Body๋ฅผ ๋‘ ๋ฒˆ ์ฝ์„ ์ˆ˜ ์—†์Œ

๋ฌธ์ œ: ๋กœ๊น… ํ•„ํ„ฐ์—์„œ body๋ฅผ ์ฝ์œผ๋ฉด ๋‹ค์šด์ŠคํŠธ๋ฆผ์—์„œ ๋ชป ์ฝ์Œ

ํ•ด๊ฒฐ: ServerHttpRequestDecorator๋กœ body ์บ์‹ฑํ•˜๊ฑฐ๋‚˜, ์ธ์ฆ ํ•„ํ„ฐ์—์„œ๋Š” ํ—ค๋”/์ฟ ํ‚ค๋งŒ ์‚ฌ์šฉ (body ์ ‘๊ทผ X)


 

๐Ÿ“‹ ์ •๋ฆฌ

๊ตฌ์„ฑ ์š”์†Œ ์—ญํ• 
JwtAuthenticationFilter ํ† ํฐ ๊ฒ€์ฆ, ํด๋ ˆ์ž„ ์ถ”์ถœ, ํ—ค๋” ์ „๋‹ฌ
TokenRefreshFilter ๋งŒ๋ฃŒ ์ž„๋ฐ• ์‹œ ์ž๋™ ๊ฐฑ์‹ 
JwtTokenValidator ํ† ํฐ ํŒŒ์‹ฑ/๊ฒ€์ฆ ์œ ํ‹ธ
application.yml ๊ณต๊ฐœ ๊ฒฝ๋กœ, ํ† ํฐ ์„ค์ •

 

Gateway์—์„œ ์ธ์ฆ์„ ์ฒ˜๋ฆฌํ•˜๋ฉด:

  • ๊ฐ ์„œ๋น„์Šค๋Š” ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์—๋งŒ ์ง‘์ค‘
  • ์ธ์ฆ ์ •์ฑ… ๋ณ€๊ฒฝ์ด ํ•œ ๊ณณ์—์„œ ๋
  • ํ† ํฐ ๊ฐฑ์‹ ๋„ ์ค‘์•™์—์„œ ์ฒ˜๋ฆฌ

๋‹จ, Gateway๊ฐ€ SPOF(๋‹จ์ผ ์žฅ์• ์ )๊ฐ€ ๋  ์ˆ˜ ์žˆ์œผ๋‹ˆ ์ด์ค‘ํ™”๋Š” ํ•„์ˆ˜๋‹ค.


๐Ÿ“š ์ฐธ๊ณ 

'๐Ÿ’ป ๊ฐœ๋ฐœ > Spring' ์นดํ…Œ๊ณ ๋ฆฌ์˜ ๋‹ค๋ฅธ ๊ธ€

DTO์™€ Response๋ฅผ ๋ถ„๋ฆฌํ•ด์•ผ ํ•˜๋Š” ์ด์œ   (0) 2026.01.13