[MSA] 모놀리식 to MSA 전환기 - API Gateway 적용하기

주현·2026년 1월 26일

MSA

목록 보기
4/5

Eureka를 통해서 Client-Side Discovery를 구현하여 각 서비스들을 관리하는 환경을 만들었습니다.

해당 글에서는 각각의 서비스로 호출하는 것이 아닌, 외부에서 애플리케이션에 진입하는 API Gateway를 구현하는 내용을 작성해보겠습니다.


📌 1. API Gateway란?

API Gateway는 마이크로서비스 아키텍처에서 외부 요청의 단일 진입점 역할을 수행합니다.
클라이언트는 각 서비스의 위치나 구조를 직접 알 필요 없이 API Gateway를 통해 요청을 전달하며, Gateway는 요청을 적절한 내부 서비스로 라우팅합니다.

또한 인증·인가, 로깅, 모니터링과 같은 공통 관심사를 중앙에서 처리할 수 있어 각 마이크로서비스는 비즈니스 로직에만 집중할 수 있습니다.


📌 2. API Gateway 라이브러리 종류

| 대표적으로 2가지가 있습니다.

  • Netflix Zuul
  • Spring Cloud Gateway

Zuul은 Netflix에서 만든 1세대 API Gateway입니다.
Servlet 기반 (동기 방식)이며, Spring MVC 위에서 동작합니다.
작은 트래픽 환경에서는 충분히 사용 가능합니다.
하지만, Blocking I/O 기반이기 떄문에, 동시 요청 많아지면 스레드 고갈이될 수 있습니다.

Spring Cloud Gateway는 Spring 팀이 Zuul의 한계를 보완해서 만든 2세대 API Gateway입니다.
비동기 / 논블로킹 (Reactive),Netty + WebFlux기반입니다.

높은 동시성 처리에 유리,적은 리소스로 많은 요청 처리 가능합니다.
webFlux 기반의 라이브러리이므로 추가 기능을 커스텀하기 위해서는 webFlux에 대한 이해가 추가적으로 필요합니다.
하지만, Spring측에서 Netflix Zuul에 관한 지원을 중지하고 Spring Cloud Gateway를 권장하고 있습니다.

따라서 레퍼런스들을 참조해서 Spring Cloud Gateway를 사용해보려고 합니다.


📌 3. Spring Cloud Gateway 동작 과정

  1. Client의 HTTP 요청이 Gateway로 들어옵니다.
  2. Gateway Handler Mapping에서 라우팅 기능을 사용하여 이 요청을 어떤 라우트로 보낼지 결정합니다.
  3. 해당 라우트에 대응하는 WebHandler가 실행된다. 이는 필터 체인을 실행하여 요청 전송, 응답 생성 등의 작업을 수행하도록 합니다.
  4. 라우트에 정의 된 작업과 필터를 완료한 후 서비스에 요청을 보냅니다.
  5. 서비스는 응답을 생성하고 다시 API Gateway로 보냅니다.
  6. 같은 방식으로 필터를 거치고 라우팅 되어 Client에게 응답이 돌아갑니다.

📌 4.Spring Cloud Gateway 구현

📍 build.gradle 의존성 추가


	ext {
		set('springCloudVersion', "2023.0.0")
	}

	dependencyManagement {
		imports {
			mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
		}
	}
    
    dependencies {
    implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
    implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    }
    

spring-cloud-starter-gateway 의존성을 추가하고,
Eureka Client 등록을 위한 Eureka Client 의존성, 스프링 actuator 의존성도 추가했습니다.

📍 Eureka Client 활성화

Eureka Client를 활성화하기 위해 Application에 @EnableDiscoveryClient를 선언하여 활성화해줬습니다.

📍 application.yml 라우팅 설정

server:
  port: 9001

spring:
  application:
    name: api-gateway
  cloud:
    gateway:
      routes:
        - id: member-api
          uri: lb://MEMBERSERVER
          predicates:
            - Path=/api/v1/members/**

        - id: member-internal
          uri: lb://MEMBERSERVER
          predicates:
            - Path=/internal/members/**

        # ===============================
        # COUPON SERVICE
        # ===============================
        - id: coupon-api
          uri: lb://COUPONSERVER
          predicates:
            - Path=/api/v1/coupons/**,/api/v1/promotions/**


        - id: coupon-internal
          uri: lb://COUPONSERVER
          predicates:
            - Path=/internal/coupon-issues/**

        # ===============================
        # STORE SERVICE
        # ===============================
        - id: store-api
          uri: lb://STORESERVER
          predicates:
            - Path=/api/v1/menus/**,/api/v1/stores/**

        - id: store-internal
          uri: lb://STORESERVER
          predicates:
            - Path=/internal/menus/**
            - Path=/internal/stores/**

        # ===============================
        # ORDER SERVICE
        # ===============================
        - id: order-api
          uri: lb://ORDERSERVER
          predicates:
            - Path=/api/v1/orders/**

        # ===============================
        # PAYMENT SERVICE (internal only)
        # ===============================
        - id: payment-internal
          uri: lb://PAYMENTSERVER
          predicates:
            - Path=/internal/payments/**

eureka:
  instance:
    prefer-ip-address: true
  client:
    register-with-eureka: true
    fetch-registry: true
    serviceUrl:
      defaultZone: http://eureka-server:8761/eureka

management:
  endpoints:
    web:
      exposure:
        include: "*"
  • spring.cloud.gateway.routes.id : 라우팅을 구분하기 위한 route id를 지정합니다.
  • spring.cloud.gateway.routes.uri : 요청이 라우팅되는 경로 URI를 의미합니다. -> lb 프로토콜(lb://)의 경로를 지정하면 기본적으로 Eureka Server에서 호스트에 해당하는 서비스를 찾고 로드밸런싱을 수행합니다.
  • spring.cloud.gateway.routes.predicates : 해당 라우팅을 진행할 URI 조건으로, Gateway로 들어오는 요청 URI를 지정합니다.
    위 설정에 예를 들었을때, /api/v1/orders 에 요청이 들어온다면,해당 라우팅이 진행되어 경로 URI로 지정한 lb://ORDERSERVER가 호출됩니다.

📍 실행결과

api-gateway를 localhost:9001으로 띄웠기에, 해당 url로 요청을 보냈을시,order서버로 요청을 보내서, 데이터를 조회해옵니다.

✔️ 인증필터 추가 구현
기존 모놀리식 구조에서는 인증 로직을 global 패키지에서 전역 필터로 처리하고 있었습니다.
이후 멀티 모듈 구조로 전환하면서, 공통으로 사용되는 인증 로직을 common 모듈로 분리하여 관리하게 되었습니다.

Spring Cloud Gateway에서 구현한 인증 필터는
각 마이크로서비스 앞단에 위치하며,
API Gateway로 유입되는 모든 요청에 대한 인증(Authentication) 을 담당합니다.

📍 Global Filter 구현

기존에 사용하던 인증 필터 코드를 그대로 사용할 수는 없었습니다.

기존 모놀리식 구조에서는 OncePerRequestFilter를 상속한 Servlet 기반(Spring MVC) 필터를 사용했지만, Spring Cloud Gateway는 WebFlux 기반의 Reactive 환경에서 동작하기 때문입니다.

package org.example.security;

import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.example.common.global.exception.response.ErrorResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.example.exception.AuthErrorCode;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
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.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.util.List;

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter implements GlobalFilter, Ordered {
    private final JwtTokenProvider jwtTokenProvider;

    private static final List<String> WHITE_LIST = List.of(
            "/api/v1/members/login",
            "/api/v1/members/signup"
    );
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {

        String path = exchange.getRequest().getURI().getPath();
        if (isWhiteListed(path)) {
            return chain.filter(exchange);
        }

        ServerHttpRequest request = exchange.getRequest();
        String authHeader = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            return error(exchange, AuthErrorCode.JWT_EMPTY);
        }

        String token = authHeader.substring(7);

        try {
            jwtTokenProvider.validate(token);
            Long userId = jwtTokenProvider.getId(token);
            String role = jwtTokenProvider.getRole(token);

            ServerHttpRequest mutated =
                    request.mutate()
                            .header("X-USER-ID", String.valueOf(userId))
                            .header("X-USER-ROLE", role)
                            .build();
            return chain.filter(exchange.mutate().request(mutated).build());

        } catch (TokenExpiredException e) {
            return error(exchange, AuthErrorCode.JWT_EXPIRED);
        } catch (JWTVerificationException e) {
            return error(exchange, AuthErrorCode.JWT_BAD);
        }
    }

    private boolean isWhiteListed(String path) {
        AntPathMatcher matcher = new AntPathMatcher();
        return WHITE_LIST.stream().anyMatch(p -> matcher.match(p, path));
    }

    private Mono<Void> error(ServerWebExchange exchange, AuthErrorCode errorCode) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(HttpStatus.valueOf(errorCode.getHttpStatus()));
        response.getHeaders().setContentType(MediaType.APPLICATION_JSON);

        ErrorResponse body = ErrorResponse.of(errorCode);
        byte[] bytes;
        try {
            bytes = new ObjectMapper().writeValueAsBytes(body);
        } catch (Exception e) {
            bytes = new byte[0];
        }

        return response.writeWith(Mono.just(response.bufferFactory().wrap(bytes)));
    }

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

1️⃣ GlobalFilter를 선택한 이유

public class JwtAuthenticationFilter implements GlobalFilter, Ordered

GlobalFilter는 Gateway를 통과하는 모든 요청에 적용되는 전역 필터입니다. JWT 인증은 특정 서비스에 국한되지 않는 공통 관심사이기 때문에 라우트별 필터가 아닌 GlobalFilter로 구현했습니다.

2️⃣ 화이트리스트 처리

private static final List<String> WHITE_LIST = List.of(
    "/api/v1/members/login",
    "/api/v1/members/signup"
);

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

로그인이나 회원가입같이 인증이 필요없는 API는 JWT검증이 수행하지 않도록 화이트 리스트로 분리했습니다.

3️⃣ Authorization 헤더 검증

String authHeader = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);

if (authHeader == null || !authHeader.startsWith("Bearer ")) {
    return error(exchange, AuthErrorCode.JWT_EMPTY);
}

JWT가 없거나 형식이 잘못된 요청은 내부 서비스로 전달되지 않고 Gateway에서 예외를 터트립니다.

4️⃣ JWT 검증 및 사용자 정보 추출

jwtTokenProvider.validate(token);
Long userId = jwtTokenProvider.getId(token);
String role = jwtTokenProvider.getRole(token);

Gateway에서는 인증(Authentication)까지만 수행하며,
비즈니스 인가(Authorization)는 각 서비스에서 처리하도록 역할을 분리했습니다.

5️⃣ JWT 검증 및 사용자 정보 추출

ServerHttpRequest mutated =
    request.mutate()
        .header("X-USER-ID", String.valueOf(userId))
        .header("X-USER-ROLE", role)
        .build();

Gateway에서 검증된 사용자 정보는
X-USER-ID, X-USER-ROLE 헤더에 담아 내부 서비스로 전달합니다. 이로 내부 서비스는 JWT파싱 불필요해집니다.

6️⃣ 예외 처리 전략

catch (TokenExpiredException e) {
    return error(exchange, AuthErrorCode.JWT_EXPIRED);
}
catch (JWTVerificationException e) {
    return error(exchange, AuthErrorCode.JWT_BAD);
}

만료된 토큰, 위·변조된 토큰 모두 Gateway 레벨에서 처리하여 불필요한 서비스 호출을 방지합니다.

private Mono<Void> error(ServerWebExchange exchange, AuthErrorCode errorCode)

Reactive 환경에 맞게 Mono를 반환하며 일관된 에러 응답 포맷을 유지합니다.

7️⃣ Ordered를 구현한 이유

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

Ordered를 구현해 필터 실행 순서를 최상단으로 명시적으로 지정했습니다.

이상 API Gateway 구현을 마무리하겠습니다.


✏️ Github Repo 링크
https://github.com/Jungjuhyeon?tab=repositories

0개의 댓글