Ian's Archive 🏃🏻

thumbnail
Spring Cloud Gateway 도입과 인가로직 적용하기
Spring
2025.08.25.

데이터 플랫폼을 개발하며 인증과 인가 기능이 필요했다.

Next.js로 구성된 프론트엔드와 여러 Spring API 서버로 나뉘어 있었고, 인증은 스프링 시큐리티의 세션 방식을 사용했다.

문제는 ‘인가’였는데 관리자가 사용자의 역할(Role)에 따라 접근 가능한 URL 패턴을 동적으로 설정하고, 이를 기반으로 API 요청을 제어하는 유연한 인가 정책이 필요했다.

자연스럽게 API Gateway의 필요성을 느끼게 되었고, 최종적으로 Spring Cloud Gateway를 선택하게 되었다.

이 글에서는 API Gateway의 역할부터 시작해, Spring Cloud Gateway를 도입하고 시스템에 맞는 커스텀 인가 로직을 어떻게 구현했는지 그 과정을 공유한다.

1. API Gateway 주요 기능

마이크로서비스 아키텍처(MSA)에서 API Gateway는 시스템의 ‘대문’ 역할을 수행한다. 클라이언트의 모든 요청을 단일 진입점(Single Entry Point)에서 받아 처리하며, 다음과 같은 핵심 기능을 담당한다.

  • 인증과 인가: 모든 요청에 대해 공통 인증/인가 로직을 적용
  • API 라우팅: 요청 경로, 헤더 등을 분석하여 가장 적절한 마이크로서비스로 요청을 전달
  • QoS (Quality of Service): Rate Limiting(요청 수 제한), 서킷 브레이커 등을 통해 서비스의 안정성과 품질을 관리
  • 로깅 및 모니터링: 모든 트래픽이 게이트웨이를 통과하므로, 중앙에서 로그를 수집하고 요청을 모니터링하기 용이

2. API Gateway 구축 방법

  • 상용 솔루션 사용: 검증된 성능과 다양한 기능을 제공하지만, 라이선스 비용이 발생하고 커스터마이징에 제약
  • 오픈소스 솔루션 사용: 저렴한 비용으로 강력한 기능을 활용할 수 있지만, 기술 지원이 부족하거나 특정 요구사항을 반영하기 어려울 수 있다.
  • 오픈소스 기반으로 직접 개발: 우리 환경에 최적화된 게이트웨이를 구축할 수 있지만, 개발 및 유지보수 비용이 높고 경험 부족으로 인한 시행착오를 겪을 수 있다.

Spring 생태계와의 높은 통합성, 유연한 커스터마이징 가능성을 고려하여 Spring Cloud Gateway를 기반으로 직접 개발하는 방식을 선택

3. Spring Cloud Gateway

Spring Cloud Gateway는 Spring 생태계 위에서 동작하는 API Gateway 프로젝트

  • Spring 생테계를 기반으로 하는 API Gateway를 제공해주는 프로젝트
  • Netty 기반의 비동기(Asynchronous) 요청 처리 제공
  • Spring에서 제공하는 다양한 Component를 조합하여 효율적인 개발이 가능
  • security, monitoring/metrics등과 같은 공통 관심사 처리

3.1 Spring Cloud Gateway 동작 순서

1

  • Client가 Spring Cloud Gateway로 요청
  • Gateway Handler Mapping은 요청이 사전 정의된 라우팅 규칙(Route)과 일치하는지 확인
  • 일치하는 규칙이 있다면, 요청을 Gateway Web Handler로 전달
  • Gateway Web Handler는 해당 요청에 적용될 필터 체인(Filter Chain)을 통해 요청을 처리. 필터들은 요청을 수정하거나, 인증/인가를 수행하고, 최종적으로 다운스트림 서비스로 요청을 프록시

3.2 Spring Cloud Gateway 장, 단점

  • 장점
    • 라이선스 투자비용 없음
    • WebFlux기반의 Non-Blocking 방식으로 빠른 응답 속도 확보
    • Spring에서 제공하는 다양한 서비스와 조합 가능
    • 요청을 다른 서버로 라우팅시키는 부분을 간단한 yaml 파일로 완성할 수 있음
    • 라우티 전/후에 손쉽게 부가 기능을 추가할 수 있음(GatewayFilter를 등록할 수 있고, 이미 많은 필터를 제공해주고 있음)
      • ex) 헤더 추가 필터, RateLimit 필터 등
  • 단점
    • 서비스 구현에서 다양한 테스트 및 문제 해결을 통한 최적화 필요
    • GUI기반에 관리 기능 미제공
    • 추가 기능의 구현을 WebFlux 기반(리액티브)으로 작성해야 하므로 어려울 수 있음
    • 라우팅 대상 서버가 많아진다면 관리가 어려울 수 있음

5. 구현

4.1 yml 파일 작성

copyButtonText
spring:
  # Redis 및 세션 설정
  data:
    redis:
      host: 192.168.50.248
      port: 6379
      password: # password

  # Spring Cloud Gateway 설정
  cloud:
    gateway:
      server:
        webflux:
          # ==================================
          # 1. 라우팅 규칙 (Routing Rules)
          # ==================================
          routes:
            # mng-service route
            - id: mng-service-route
              uri: http://localhost:8090
              predicates:
                - Path=/dataplatform/mng/**
              filters:
                - AuthorizationFilter

            # clct-service route
            - id: clct-service-route
              uri: http://localhost:8085
              predicates:
                - Path=/dataplatform/clct/**
              filters:
                - AuthorizationFilter

            # geoserver route
            - id: geoserver-route
              uri: http://localhost:8200
              predicates:
                - Path=/dataplatform/geoserver/**
              filters:
                # /dataplatform/geoserver 라는 경로 세그먼트 2개를 제거하고 전달
                - StripPrefix=2
                # filters:
                # - AuthorizationFilter # TODO: GeoServer는 자체 인증/인가를 사용하거나 공개될 수 있으므로, 적용 여부 결정해야 함

            # api server route
            - id: apiserver-route
              uri: http://localhost:8200
              predicates:
                - Path=/dataplatform/api/**
              filters:
                # /dataplatform/geoserver 라는 경로 세그먼트 2개를 제거하고 전달
                - StripPrefix=2
                # filters:
                # - AuthorizationFilter # TODO: GeoServer는 자체 인증/인가를 사용하거나 공개될 수 있으므로, 적용 여부 결정해야 함


          # ==================================
          # 전역 CORS 설정 (Global CORS)
          # ==================================
          globalcors:
            cors-configurations:
              '[/**]': # 모든 경로(/**)에 대해 적용
                allowed-origins:
                  - "http://localhost:3000" # 프론트엔드 개발 서버 주소
                #              - "https://your-frontend-domain.com" # 실제 서비스 도메인
                allowed-methods:
                  - "GET"
                  - "POST"
                  - "PUT"
                  - "DELETE"
                  - "OPTIONS"
                  - "PATCH"
                allowed-headers: "*"
                allow-credentials: true

먼저 게이트웨이의 핵심인 라우팅 규칙을 정의

Spring Cloud Gateway에는 크게 3가지 구성요소가 존재

Route란?

  • 고유ID, 목적지 URI, Predicate, Filter로 구성된 구성요소.
  • GATEWAY로 요청된 URI의 조건이 참일 경우, 매핑된 해당 경로로 매칭

Predicate란?

  • 주어진 요청이 주어진 조건을 충족하는지 테스트하는 구성요소
  • 각 요청 경로에 대해 충족하게 되는 경우 하나 이상의 조건자를 정의
  • 만약 Predicate에 매칭되지 않는다면 HTTP 404 not found를 응답
  • ex) Path, Cookie, Header, IP 등등

Filter란?

  • GATEWAY 기준으로 들어오는 요청 및 나가는 응답에 대하여 수정을 가능하게 해주는 구성요소
  • Request/Response 에 대한 수정(Header 추가)
  • 적절한 요청인지 검사 및 응답 거부
  • 인증, 로깅 등과 같은 공통관심사 해결

4.2 인가 로직 코드 작성

copyButtonText
package com.egis.dataplatformgateway.filter;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.data.redis.core.ReactiveRedisTemplate;
import org.springframework.http.HttpCookie;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

@Component
@Slf4j
public class AuthorizationFilter extends AbstractGatewayFilterFactory<AuthorizationFilter.Config> implements Ordered {

    // 공개 경로 패턴 목록
    private static final List<String> PUBLIC_PATTERNS = List.of(
            "/dataplatform/mng/user/**",
            // ...
    );

    private final AntPathMatcher pathMatcher;
    private final ReactiveRedisTemplate<String, String> redisTemplate;
    private final ObjectMapper objectMapper;
    private final String activeProfile;

    public AuthorizationFilter(ReactiveRedisTemplate<String, String> redisTemplate,
                               ObjectMapper objectMapper,
                               @Value("${spring.profiles.active}") String activeProfile) {
        super(Config.class);
        this.redisTemplate = redisTemplate;
        this.objectMapper = objectMapper;
        this.activeProfile = activeProfile;
        this.pathMatcher = new AntPathMatcher();
    }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();
            String path = request.getURI().getPath();

            // Preflight 요청(OPTIONS)을 게이트웨이에서 직접 처리하고 즉시 성공 응답을 보냄
            if (request.getMethod() == HttpMethod.OPTIONS) {
                ServerHttpResponse response = exchange.getResponse();
                response.setStatusCode(HttpStatus.OK);
                return response.setComplete();
            }

            if (isPublicPath(path)) {
                return chain.filter(exchange);
            }

            HttpCookie sessionCookie = request.getCookies().getFirst("SESSION");
            if (sessionCookie == null) {
                return unauthorizedResponse(exchange, "인증 정보가 없습니다.");
            }

            String redisSessionKey = activeProfile + ":sessions:" + sessionCookie.getValue();

            return redisTemplate.opsForHash().entries(redisSessionKey)
                    .collectMap(entry -> entry.getKey().toString(), entry -> entry.getValue().toString())
                    .flatMap(sessionData -> {
                        if (sessionData.isEmpty()) {
                            return unauthorizedResponse(exchange, "세션이 만료되었거나 유효하지 않습니다.");
                        }
                        // JSON 문자열로 저장된 값을 원래의 String 값으로 변환
                        String userId = convertJsonToString(sessionData.get("sessionAttr:ENCRYPTED_USER_ID"));
                        List<String> roles = convertJsonToList(sessionData.get("sessionAttr:ROLES"));

                        if (!StringUtils.hasText(userId) || CollectionUtils.isEmpty(roles)) {
                            log.warn("세션에 필수 정보 누락. UserID: {}, Roles: {}", userId, roles);
                            return unauthorizedResponse(exchange, "세션 정보가 올바르지 않습니다.");
                        }

                        return checkPermissions(exchange, chain, path, userId, roles);
                    });
        };
    }

    private String convertJsonToString(String jsonString) {
        if (jsonString == null || jsonString.isEmpty()) {
            return null;
        }
        try {
            return objectMapper.readValue(jsonString, String.class);
        } catch (Exception e) {
            log.error("Redis 데이터(JSON)를 String으로 변환하는데 실패했습니다. Data: {}", jsonString, e);
            return null;
        }
    }

    private List<String> convertJsonToList(String jsonString) {
        if (jsonString == null || jsonString.isEmpty()) {
            return Collections.emptyList();
        }
        try {
            return objectMapper.readValue(jsonString, new TypeReference<List<String>>() {});
        } catch (Exception e) {
            log.error("Redis 데이터(JSON)를 List<String>으로 변환하는데 실패했습니다. Data: {}", jsonString, e);
            return Collections.emptyList();
        }
    }

    private Mono<Void> checkPermissions(ServerWebExchange exchange, GatewayFilterChain chain, String path, String userId, List<String> roles) {
        Mono<Set<String>> authoritiesMono = Flux.fromIterable(roles)
                .map(role -> role.startsWith("ROLE_") ? role.substring(5).toLowerCase() : role.toLowerCase())
                .flatMap(role -> redisTemplate.opsForHash().get(AUTH_URL_KEY, role))
                .cast(String.class)
                .map(this::convertJsonToList)
                .flatMap(Flux::fromIterable)
                .collect(Collectors.toSet());

        return authoritiesMono.flatMap(allPermissions -> {
            if (allPermissions.isEmpty()) {
                log.warn("인가 규칙 없음: User [{}], Roles {}에 대한 권한 정보가 Redis에 없습니다.", userId, roles);
                return forbiddenResponse(exchange, "접근 권한이 설정되지 않았습니다.");
            }

            if (hasPermission(path, allPermissions)) {
                log.info("인가 성공: User [{}], Roles {}, Path {}", userId, roles, path);
                ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
                        .header("X-User-Id", userId)
                        .header("X-User-Roles", String.join(",", roles))
                        .build();
                return chain.filter(exchange.mutate().request(mutatedRequest).build());
            } else {
                log.warn("인가 최종 실패: User [{}], Roles {}, Path {}. Accessible Patterns: {}", userId, roles, path, allPermissions);
                return forbiddenResponse(exchange, "접근 권한이 없습니다.");
            }
        });
    }

    @Override
    public int getOrder() {
        return -1;
    }

    private boolean isPublicPath(String path) {
        return PUBLIC_PATTERNS.stream().anyMatch(pattern -> pathMatcher.match(pattern, path));
    }

    private boolean hasPermission(String requestPath, Set<String> accessibleUrlPatterns) {
        log.info("===== [권한 검사] 요청 경로: '{}'", requestPath);
        for (String pattern : accessibleUrlPatterns) {
            if (pathMatcher.match(pattern, requestPath)) {
                log.info("=====>> [권한 검사] 성공! 패턴: '{}'", pattern);
                return true;
            }
        }
        log.warn("=====>> [권한 검사] 실패. 어떤 패턴과도 일치하지 않음.");
        return false;
    }

    private Mono<Void> unauthorizedResponse(ServerWebExchange exchange, String message) {
        return createErrorResponse(exchange, HttpStatus.UNAUTHORIZED, message);
    }

    private Mono<Void> forbiddenResponse(ServerWebExchange exchange, String message) {
        return createErrorResponse(exchange, HttpStatus.FORBIDDEN, message);
    }

    private Mono<Void> createErrorResponse(ServerWebExchange exchange, HttpStatus status, String message) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(status);
        response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
        Map<String, String> errorBody = Map.of("status", String.valueOf(status.value()), "message", message);
        try {
            byte[] bytes = objectMapper.writeValueAsBytes(errorBody);
            DataBuffer buffer = response.bufferFactory().wrap(bytes);
            return response.writeWith(Mono.just(buffer));
        } catch (Exception e) {
            log.error("오류 응답 생성 실패", e);
            // 오류 응답 생성에 실패하면 최소한 상태 코드라도 보내도록 처리
            return response.setComplete();
        }
    }

    public static class Config {}
}

로직은 다음과 같다

  • 공개 경로 확인: 로그인 페이지처럼 누구나 접근 가능해야 하는 경로는 PUBLIC_PATTERNS에 등록하여 먼저 통과
  • 세션 검증: 요청 쿠키에서 SESSION ID를 추출하여 Redis에 해당 세션이 존재하는지 확인
  • 사용자 정보 추출: Redis에 저장된 세션 데이터에서 사용자 ID와 역할(Role) 목록을 가져온다. (Spring Session Redis는 세션 속성을 JSON 문자열로 저장하므로, ObjectMapper를 이용한 변환이 필요)
  • 권한 정보 조회: 사용자의 역할들을 기반으로, 접근 가능한 URL 패턴 목록을 Redis에서 조회 (예: AUTH_URL_KEY 라는 해시 키 아래에 admin: [“/dataplatform/mng/**”] 와 같이 저장)
  • 권한 검증: 조회된 URL 패턴 목록과 현재 요청 경로를 AntPathMatcher로 비교하여 접근 가능 여부를 판단
  • 요청 변조 및 전달: 인가에 성공하면, 다운스트림 서비스들이 사용자 정보를 활용할 수 있도록 요청 헤더에 X-User-Id 와 X-User-Roles를 추가한 뒤 필터 체인의 다음 단계로 전달

6. 마치며

Spring Cloud Gateway를 이용해 API 서버 앞단에서 공통 인가 로직을 처리함으로써, 각 마이크로서비스는 비즈니스 로직에만 집중할 수 있는 환경을 만들었다.

Redis에 역할별 URL 패턴을 저장함으로써, 관리 페이지에서 권한을 변경하면 애플리케이션 재배포 없이도 즉시 인가 정책이 반영되는 유연성도 확보 가능해졌다.

물론 권한 없는 사용자가 애초에 특정 페이지에 접근하지 못하도록 프론트엔드 단에서도 제어가 필요했습니다.

이는 Next.js의 middleware.ts에서 쿠키의 세션 정보를 인증 서버로 보내 권한을 확인하고, 페이지 접근을 제어하는 방식으로 보완했다.

Reference

Spring Cloud Gateway docs
[Spring] Spring Cloud Gateway(스프링 클라우드 게이트웨이) 공식 문서 간단히 살펴보기 및 리서치 후기 - 망나니개발자
Spring Cloud Gateway 기반의 API 게이트웨이 구축 - 에스코어
MSA 아키텍쳐 구현을 위한 API 게이트웨이의 이해 #1
SPRING CLOUD GATEWAY를 이용한 API GATEWAY 구축기

Thank You for Visiting My Blog, I hope you have an amazing day 😆
© 2023 Ian, Powered By Gatsby.