3/16(월) 코드카타, API Gateway, Gateway JWT인증

dev_joo·2026년 3월 16일

코드카타 (오백년만)

부족한 금액 계산하기

원본 훼손 + 오버플로 위험

처음에 이 코드를 사용했는데 일부 테스트케이스에서 실패를 했다.

class Solution {
    public long solution(int price, int money, int count) {
        for(int i = 1; i <= count; i++) {
            money -= price * i;
        }
        return money < 0 ? -1*money : 0;
    }
}


원본 잔액인 money를 훼손하고 또 계산 과정에 오버플로우가 날 수 있을 것 같아 long 변수로 뺐더니 테스트케이스는 통과되었다.
(괜히 solution 반환 타입이 long 타입인게 아니었구나)

class Solution {
    public long solution(int price, int money, int count) {
        long answer = money;
        for(int i = 1; i <= count; i++) {
            answer -= price * i;
        }
        return answer < 0 ? -1*answer : 0 ;
    }
}

등차수열의 합

count 만큼 놀이기구를 타는 비용을 먼저 계산해서 빼는 방법도 있다.
이 경우 등차수열의 합 공식을 활용하면 반복문 없이도 값을 구할 수 있다.
(연산 식에서 long 타입 캐스팅을 해야 함에 주의하자)

class Solution {
    public long solution(int price, int money, int count) {
        long total = (long) price * count * (count + 1) / 2;
        return total > money ? total - money : 0;
    }
}

ASCII 코드 활용 if(!(c>=48 && c<=57))

class Solution {
    public boolean solution(String s) {
        if(s.length() != 4 && s.length() != 6) return false;
        
        for(char c : s.toCharArray()){
            if(!(c>=48 && c<=57)) return false;
        }
        
        return true;
    }
}

Character.isDigit(c)

class Solution {
    public boolean solution(String s) {
        if(s.length() != 4 && s.length() != 6) return false;
        
        for(char c : s.toCharArray()){
            if(!Character.isDigit(c)) return false;
        }
        
        return true;
    }
}

정규식 활용

숫자 4개, 숫자 6개 정규식으로 풀어낼 수도 있다.

class Solution {
    public boolean solution(String s) {
        return s.matches("\\d{4}|\\d{6}");
    }
}

MSA

게이트웨이

API Gateway란 클라이언트의 요청을 받아 백엔드 서비스로 라우팅하고, 다양한 부가 기능을 제공하는 중간 서버이다.

API Gateway는 클라이언트와 서비스 간의 단일 진입점 역할을 하며 단순 전달 및 보안, 로깅, 모니터링, 요청 필터링 등의 공통 기능을 처리한다.

MSA 구조에서 Gateway를 사용하지 않으면 다음과 같은 문제가 있다.

  • 클라이언트가 모든 서비스 주소를 알아야 함
  • 인증 로직을 모든 서비스에 중복 구현
  • CORS, 로깅 등 중복 코드를 작성

각 서비스는 자기 도메인 책임만 가지게 하며
클라이언트는 Gateway만 알고 요청을 하고
Gateway가 각 마이크로서비스로 요청을 전달하도록 한다.

Client
   │
   ▼
API Gateway
   ├── User Service
   ├── Order Service
   └── Payment Service

라우팅 역할을 한다고 진짜 라우터랑 헷갈리지 말기

계층이 다르다.

Client
   │
   ▼
Internet Router
   │
   ▼
Load Balancer
   │
   ▼
API Gateway
   │
   ├── User Service
   ├── Order Service
   └── Payment Service

주요 기능

라우팅:

클라이언트 요청을 적절한 서비스로 전달

인증 및 권한 부여:

요청의 인증 및 권한을 검증

로드 밸런싱:

여러 서비스 인스턴스 간의 부하 분산

모니터링 및 로깅:

요청 및 응답을 로깅하고 모니터링

요청 및 응답 변환:

요청과 응답을 변환하거나 필터링

Spring Cloud Gateway

Spring Cloud Gateway는 Spring 프로젝트의 일환으로 개발된 API 게이트웨이로, 클라이언트 요청을 적절한 서비스로 라우팅하고 다양한 필터링 기능을 제공한다.

Spring Cloud Netflix 패키지의 일부로, 마이크로서비스 아키텍처에서 널리 사용된다.

필터링

필터 종류

Global Filter: 모든 요청에 대해 작동하는 필터
Gateway Filter: 특정 라우트에만 적용되는 필터

필터링 구현

GlobalFilter 또는 GatewayFilter 인터페이스를 구현하고, filter 메서드를 오버라이드한다.

필터 주요 객체

  • Mono
    리액티브 프로그래밍에서 0 또는 1개의 데이터를 비동기적으로 처리
    Mono<Void>는 아무 데이터도 반환하지 않음을 의미

  • ServerWebExchange
    HTTP 요청과 응답을 캡슐화한 객체
    exchange.getRequest()로 HTTP 요청을 가져옴
    exchange.getResponse()로 HTTP 응답을 가져옴

  • GatewayFilterChain
    여러 필터를 체인처럼 연결
    chain.filter(exchange)는 다음 필터로 요청을 전달

필터 시점별 종류

  • Pre 필터
    Pre 필터는 요청이 처리되기 전에 요청을 가로채 작업을 수행한 다음, 체인의 다음 필터로 요청을 전달한다. (이때, 추가적인 비동기 작업을 수행할 필요가 없기 때문에 then 메서드를 사용할 필요가 없음)

@Component
public class PreFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 요청 로깅
        System.out.println("Request: " + exchange.getRequest().getPath());
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {  // 필터의 순서를 지정합니다.
        return -1;  // 필터 순서를 가장 높은 우선 순위로 설정합니다.
    }
}

  • Post 필터
    Post 필터는 요청이 처리된 후, 응답이 반환되기 전에 실행된다.
    즉, 체인의 다음 필터가 완료된 후에 실행되어야 하는 추가적인 작업을 수행한다.
    따라서 chain.filter(exchange)를 호출하여 다음 필터를 실행한 후, then 메서드를 사용하여 응답이 완료된 후에 실행할 작업을 정의하게 된다.

@Component
public class PostFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        return chain.filter(exchange).then(Mono.fromRunnable(() -> {
            // 응답 로깅
            System.out.println("Response Status: " + exchange.getResponse().getStatusCode());
        }));
    }

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

Spring Cloud Gateway의 주요 특징

  • 동적 라우팅: 요청의 URL 패턴에 따라 동적으로 라우팅
  • 필터링: 요청 전후에 다양한 작업을 수행할 수 있는 필터 체인 제공
  • 모니터링: 요청 로그 및 메트릭을 통해 서비스 상태 모니터링
  • 보안: 요청의 인증 및 권한 검증

의존 설정

Spring Cloud Gateway는 Reactive 기반(WebFlux) 이기 때문에 보통은 Reactive Gateway 의존을 사용한다.

                       Client
                          │
                          ▼
                    API Gateway
          (spring-cloud-starter-gateway)
        (spring-cloud-starter-gateway-server-webmvc)
                          │
                          │ 서비스 위치 조회
                          ▼
                Service Discovery
        (spring-cloud-starter-netflix-eureka-client)
                       Eureka
                 /          |          \
                ▼           ▼           ▼
          User Service   Order Service   Pay Service
       (spring-boot-starter-web)   (spring-boot-starter-web)
       (spring-boot-starter-actuator) (spring-boot-starter-actuator)

실습에서는 서블릿 기반


dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
	  implementation 'org.springframework.boot:spring-boot-starter-actuator'
	  implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
	  implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
}

application.yml 라우팅 설정 정의


spring:
  cloud:
    gateway:
      discovery:
        locator:
          enabled: true  # 서비스 디스커버리를 통해 동적으로 라우트를 생성하도록 설정
      routes:
        - id: users-service  # 라우트 식별자
          uri: lb://users-service # 'users-service'라는 이름으로 로드 밸런싱된 서비스로 라우팅
          predicates:
            - Path=/users/** # /users/** 경로로 들어오는 요청을 이 라우트로 처리
        - id: orders-service  # 라우트 식별자
          uri: lb://orders-service  # 'orders-service'라는 이름으로 로드 밸런싱된 서비스로 라우팅
          predicates:
            - Path=/orders/** #/orders/** 경로로 들어오는 요청을 이 라우트로 처리

eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka/

Spring Gateway 실습

Spring Gateway 실습기존 로드밸런서 실습
19091 port: order -> gateway
나는 설정을 최소화하기 위해 gateway port를 18091로 설정해 추가해주기로 했다.

Order

기존 product의 요청을 보내던 코드를 없애준다.

@RestController
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;

    @GetMapping("/order/{orderId}")
    public String getOrderWithProduct(@PathVariable String orderId) {
        return orderService.getOrder(orderId);
    }

    @GetMapping("/order")
    public String getOrder() {
        return "Order dtail";
    }
}

Product

Product id를 받지 않게 한다. (괴랄한 이름이 되었다.)

@RestController
public class ProductController {

    @Value("${server.port}") // 애플리케이션이 실행 중인 포트를 주입받습니다.
    private String serverPort;

    @GetMapping("/product/{id}")
    public String getProduct(@PathVariable String id) {
        return "Product " + id + " info!!!!! From port : " + serverPort ;
    }

    @GetMapping("product")
    public String getProductWithOutProduct() {
        return "Product info!!!! From port : " + serverPort;
    }

}

중간 실행

server
order
p2

p3
p4

gatewaty

initializr

Filter

import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.util.logging.Logger;

@Component
public class CustomPreFilter implements GlobalFilter, Ordered {

    private static final Logger logger = Logger.getLogger(CustomPreFilter.class.getName());

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest response = exchange.getRequest();
        logger.info("Pre Filter: Request URI is " + response.getURI());
        // Add any custom logic here

        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;
    }
}

@Component
public class CustomPostFilter implements GlobalFilter, Ordered {

    private static final Logger logger = Logger.getLogger(CustomPostFilter.class.getName());

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, org.springframework.cloud.gateway.filter.GatewayFilterChain chain) {
        return chain.filter(exchange).then(Mono.fromRunnable(() -> {
            ServerHttpResponse response = exchange.getResponse();
            logger.info("Post Filter: Response status code is " + response.getStatusCode());
            // Add any custom logic here
        }));
    }

    @Override
    public int getOrder() {
        return Ordered.LOWEST_PRECEDENCE;
    }
}

Servlet Filter vs Spring Cloud Gateway Filter 비교

기존의 서블릿 필터와 구현 방식이 비슷해 보여 한 번 비교하고 넘어가기로 했다.

@Component
public class CustomServletFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) 
            throws IOException, ServletException {
        
        // 1. PRE 처리 (요청 전 로직)
        System.out.println("Servlet Filter: Request logic");

        // 2. 다음 필터 또는 서블릿으로 전달 (동기 방식)
        chain.doFilter(request, response);

        // 3. POST 처리 (응답 후 로직)
        System.out.println("Servlet Filter: Response logic");
    }
}
@Component
public class CustomGlobalFilter implements GlobalFilter, Ordered {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        
        // 1. PRE 처리 (요청 전 로직)
        System.out.println("Gateway Filter: Request logic");

        // 2. 비동기 체인 실행 및 POST 처리 (응답 후 로직)
        return chain.filter(exchange).then(Mono.fromRunnable(() -> {
            System.out.println("Gateway Filter: Response logic");
        }));
    }

    @Override
    public int getOrder() {
        return -1; // 필터 실행 순서 지정
    }
}

두 필터의 가장 큰 차이는 목적에 따른 실행 위치와 동기 / 비동기 차이라고 볼 수 있다.

구분Servlet 필터 (Spring MVC)SCG 필터 (Spring Cloud Gateway)
기반 엔진Tomcat (서블릿 컨테이너)Netty (비동기 이벤트 루프)
처리 모델동기 & 블로킹 (Thread-per-request)비동기 & 논블로킹 (Event Loop)
반환 타입void (명령형 스타일)Mono<Void> (리액티브 스타일)
핵심 객체HttpServletRequest / ResponseServerWebExchange
필터 연결chain.doFilter() (직접 호출)chain.filter().then() (체이닝)
실행 위치개별 애플리케이션 서비스 내부마이크로서비스 최전방 (진입점)
주요 역할인코딩, 데이터 검증, 로컬 인증라우팅, 로드밸런싱, 전역 공통 보안
예외 처리try-catch / ExceptionResolver리액티브 연산자 / WebExceptionHandler

application.yml

server:
  port: 18091  # 게이트웨이 서비스가 실행될 포트 번호

spring:
  main:
    web-application-type: reactive  # Spring 애플리케이션이 리액티브 웹 애플리케이션으로 설정됨
  application:
    name: gateway-service  # 애플리케이션 이름을 'gateway-service'로 설정
  cloud:
    gateway:
      routes:  # Spring Cloud Gateway의 라우팅 설정
        - id: order-service  # 라우트 식별자
          uri: lb://order-service  # 'order-service'라는 이름으로 로드 밸런싱된 서비스로 라우팅
          predicates:
            - Path=/order/**  # /order/** 경로로 들어오는 요청을 이 라우트로 처리
        - id: product-service  # 라우트 식별자
          uri: lb://product-service  # 'product-service'라는 이름으로 로드 밸런싱된 서비스로 라우팅
          predicates:
            - Path=/product/**  # /product/** 경로로 들어오는 요청을 이 라우트로 처리
      discovery:
        locator:
          enabled: true  # 서비스 디스커버리를 통해 동적으로 라우트를 생성하도록 설정

eureka:
  client:
    service-url:
      defaultZone: http://localhost:19090/eureka/  # Eureka 서버의 URL을 지정

Spring Cloud Gateway가 업데이트되면서, Servlet(MVC) 기반 게이트웨이와 WebFlux(비동기) 기반 게이트웨이의 설정을 더 명확히 구분하기 시작했다고 한다.
web-application-type: reactive를 사용 중이므로, server.webflux하위로 설정을 모으는 최신 스펙에 맞게 강의 예제 설정 파일을 바꿔주었다.

server:
  port: 18091  # 게이트웨이 서비스가 실행될 포트 번호

spring:
  main:
    web-application-type: reactive  # Spring 애플리케이션이 리액티브 웹 애플리케이션으로 설정됨
  application:
    name: gateway-service  # 애플리케이션 이름을 'gateway-service'로 설정
  cloud:
    gateway:
      server:
        webflux:
          discovery:
            locator:
              enabled: true  # 서비스 디스커버리를 통해 동적으로 라우트를 생성하도록 설정
          routes:  # Spring Cloud Gateway의 라우팅 설정
            - id: order-service  # 라우트 식별자
              uri: lb://order-service  # 'order-service'라는 이름으로 로드 밸런싱된 서비스로 라우팅
              predicates:
                - Path=/order/** # /order/** 경로로 들어오는 요청을 이 라우트로 처리
            - id: product-service  # 라우트 식별자
              uri: lb://product-service  # 'product-service'라는 이름으로 로드 밸런싱된 서비스로 라우팅
              predicates:
                - Path=/product/** # /product/** 경로로 들어오는 요청을 이 라우트로 처리

eureka:
  client:
    service-url:
      defaultZone: http://localhost:19090/eureka/  # Eureka 서버의 URL을 지정

Gateway 동작 확인

Gateway의 주소 하나로 분리된 서비스 요청에 대한 응답을 받아 올 수 있다.




보안 구성 (게이트웨이의 Pre 필터에서 JWT 인증)

OAuth2

OAuth2는 토큰 기반의 인증 및 권한 부여 프로토콜이다.
클라이언트 애플리케이션이 리소스 소유자의 권한을 얻어 보호된 리소스에 접근할 수 있도록 한다.

OAuth2 주요 역할

리소스 소유자, 클라이언트, 리소스 서버, 인증 서버

주요 역할(Google 시퀀스 다이어그램 기준)

OAuth2 역할그림에서의 명칭설명
리소스 소유자 (Resource Owner)User데이터의 실소유자입니다. (예: 구글 계정을 가진 사용자 본인)
클라이언트 (Client)Your App사용자의 권한을 빌려 리소스(데이터)를 이용하려는 어플리케이션입니다.
인증 서버 (Authorization Server)Google Servers (상단)사용자를 인증하고 클라이언트에게 권한 증표(Code, Token)를 발급하는 서버입니다.
리소스 서버 (Resource Server)Google Servers (하단)Access Token을 확인하고 실제 사용자 데이터(프로필, 이메일 등)를 제공하는 서버입니다.

OAuth2의 주요 개념

Authorization Code Grant: 인증 코드를 사용하여 액세스 토큰을 얻는 방식
Implicit Grant: 클라이언트 애플리케이션에서 직접 액세스 토큰을 얻는 방식
Resource Owner Password Credentials Grant: 사용자 이름과 비밀번호를 사용하여 액세스 토큰을 얻는 방식
Client Credentials Grant: 클라이언트 애플리케이션이 자신의 자격 증명을 사용하여 액세스 토큰을 얻는 방식

JWT 개요

JWT(JSON Web Token)는 JSON 형식의 자가 포함된 토큰으로, 클레임(claim)을 포함하여 사용자에 대한 정보를 전달한다.
JWT는 세 부분으로 구성: 헤더, 페이로드, 서명
JWT는 암호화를 통해 데이터의 무결성과 출처를 보장한다.

JWT 특징

  • 자가 포함: 토큰 자체에 모든 정보를 포함하고 있어 별도의 상태 저장이 필요 없습니다.
  • 간결성: 짧고 간결한 문자열로, URL, 헤더 등에 쉽게 포함될 수 있습니다.
  • 서명 및 암호화: 데이터의 무결성과 인증을 보장합니다.

Auth

프로젝트 생성

수동 추가:

dependencies {
	implementation 'io.jsonwebtoken:jjwt:0.12.6' // jjwt 라이브러리
	testImplementation 'io.projectreactor:reactor-test' // 비동기/논블로킹 스트림(Reactive Streams)을 테스트 도구
	
}

AuthConfig

@Configuration
@EnableWebSecurity
public class AuthConfig {

    // SecurityFilterChain 빈을 정의합니다. 이 메서드는 Spring Security의 보안 필터 체인을 구성합니다.
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                // CSRF 보호를 비활성화합니다. CSRF 보호는 주로 브라우저 클라이언트를 대상으로 하는 공격을 방지하기 위해 사용됩니다.
                .csrf(csrf -> csrf.disable())
                // 요청에 대한 접근 권한을 설정합니다.
                .authorizeRequests(authorize -> authorize
                        // /auth/signIn 경로에 대한 접근을 허용합니다. 이 경로는 인증 없이 접근할 수 있습니다.
                        .requestMatchers("/auth/signIn").permitAll()
                        // 그 외의 모든 요청은 인증이 필요합니다.
                        .anyRequest().authenticated()
                )
                // 세션 관리 정책을 정의합니다. 여기서는 세션을 사용하지 않도록 STATELESS로 설정합니다.
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                );

        // 설정된 보안 필터 체인을 반환합니다.
        return http.build();
    }
}

// 요청에 대한 접근 권한을 설정합니다.
.authorizeHttpRequests(authorize -> authorize
	// /auth/signIn 경로에 대한 접근을 허용합니다. 이 경로는 인증 없이 접근할 수 있습니다.
	.requestMatchers("/auth/signIn").permitAll()
	// 그 외의 모든 요청은 인증이 필요합니다.
	.anyRequest().authenticated()
)

예제의 메서드 이름을 authorizeRequests()authorizeHttpRequests()로 변경

AuthService

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import javax.crypto.SecretKey;
import java.util.Date;

@Service
public class AuthService {

    @Value("${spring.application.name}")
    private String issuer;

    @Value("${service.jwt.access-expiration}")
    private Long accessExpiration;

    private final SecretKey secretKey;

    public AuthService(@Value("${service.jwt.secret-key}") String secretKey) {
        this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretKey));
    }

    public String createAccessToken(String user_id) {
        return Jwts.builder()
                // 사용자 ID를 클레임으로 설정
                .claim("user_id", user_id)
                .claim("role", "ADMIN")
                // JWT 발행자를 설정
                .issuer(issuer)
                // JWT 발행 시간을 현재 시간으로 설정
                .issuedAt(new Date(System.currentTimeMillis()))
                // JWT 만료 시간을 설정
                .expiration(new Date(System.currentTimeMillis() + accessExpiration))
                // SecretKey를 사용하여 HMAC-SHA512 알고리즘으로 서명
                .signWith(secretKey, io.jsonwebtoken.SignatureAlgorithm.HS512)
                // JWT 문자열로 컴팩트하게 변환
                .compact();
    }
}

비밀키 준비 (SecretKey)

public AuthService(@Value("${service.jwt.secret-key}") String secretKey) {
    this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretKey));
}

Decoders.BASE64URL.decode: 문자열로 된 비밀키를 컴퓨터가 읽을 수 있는 바이트 배열로 바꾼다.
Keys.hmacShaKeyFor: 이 바이트들을 사용해서 HMAC-SHA라는 암호화 알고리즘에 쓸 수 있는 진짜 '열쇠' 객체를 만듭니다. 이 열쇠가 있어야 토큰에 도장을 찍고(서명), 나중에 검증할 수 있습니다.

토큰 제작 (Jwts.builder())

return Jwts.builder()
    .claim("user_id", user_id) // "이 토큰 주인 ID는 이거야" (데이터)
    .claim("role", "ADMIN")    // "이 사람 권한은 관리자야" (데이터)
    .issuer(issuer)            // "누가 발급했어? 내가 했어"
    .issuedAt(new Date(...))   // "언제 만들었어? 지금"
    .expiration(new Date(...)) // "언제까지 써? 이때까지"
    .signWith(secretKey, ...)  // "내 비밀키로 도장 찍을게" (서명)
    .compact();                // "다 쌓았으니 문자열로 합쳐줘!"

AuthController

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

    /**
     * 사용자 ID를 받아 JWT 액세스 토큰을 생성하여 응답합니다.
     *
     * @param user_id 사용자 ID
     * @return JWT 액세스 토큰을 포함한 AuthResponse 객체를 반환합니다.
     */
    @GetMapping("/auth/signIn")
    public ResponseEntity<?> createAuthenticationToken(@RequestParam String user_id){
        return ResponseEntity.ok(new AuthResponse(authService.createAccessToken(user_id)));
    }

    /**
     * JWT 액세스 토큰을 포함하는 응답 객체입니다.
     */
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    static class AuthResponse {
        private String access_token;

    }
}
@GetMapping("/auth/signIn")
public ResponseEntity<?> createAuthenticationToken(@RequestParam String user_id){
	return ResponseEntity.ok(new AuthResponse(authService.createAccessToken(user_id))); 
}

실습편의를 위해 Post대신 Get 매핑을 만든다.
ResponseEntity의 타입을? (wildcard) 타입으로 정해 어떤 응답이라도 타입에 관계 없이 ResponseEntity로 반환 가능하게 만든다.

auth서비스가 SiginIn 경로로 UserId를 담아 요청하면
JWT AccessToken을 발행해 줌을 볼 수 있다.

Gateway + Auth

jwt의존성 추가

Gateway에서도 jwt를 활용할 수 있도록 의존성을 추가해준다.

dependencies {
	implementation 'io.jsonwebtoken:jjwt:0.12.6'

Auth 서비스와 동일한 키 값을 가지도록 설정에 추가해준다.

application.yaml

service:
  jwt:
    secret-key: "401b09eab3c013d4ca54922bb802bec8fd5318192b0a75f201d8b3727429080fb337591abd3e44453b954555b7a0812e1081c39b740293f765eae731f5a65ed1"
    


Auth 서비스에 대한 라우팅도 추가해준다.

필터 추가

이전 단계에서 AuthService가 통행증(JWT)을 발급해주는 곳이었다면, 이번 LocalJwtAuthenticationFilter는 게이트웨이 입구에서 그 통행증이 진짜인지 확인하는 곳이다.

GlobalFilter를 상속받았다는 것은, 게이트웨이로 들어오는 모든 요청이 이 클래스를 거쳐가야 한다는 뜻으로, 사용자가 상품 목록을 보든, 주문을 하든 상관없이 게이트웨이는 이 필터를 먼저 실행하여 "너, 통행증(JWT) 있어?"라고 물어본다.

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import javax.crypto.SecretKey;
@Slf4j
@Component
public class LocalJwtAuthenticationFilter implements GlobalFilter {

    @Value("${service.jwt.secret-key}")
    private String secretKey;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String path = exchange.getRequest().getURI().getPath();
        if (path.equals("/auth/signIn")) {
            return chain.filter(exchange);  // /signIn 경로는 필터를 적용하지 않음
        }

        String token = extractToken(exchange);

        if (token == null || !validateToken(token)) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }

        return chain.filter(exchange);
    }
	/*
    JWT는 HTTP 헤더의 Authorization 필드에 Bearer [토큰값] 형식으로 실려 옵니다.
    이 메서드는 약속된 규격에서 실제 데이터인 토큰만 분리해내는 역할을 합니다.
    */
    private String extractToken(ServerWebExchange exchange) {
        String authHeader = exchange.getRequest().getHeaders().getFirst("Authorization");
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            return authHeader.substring(7); ; // "Bearer " 뒷부분인 토큰 값만 쏙 빼옴
        }
        return null;
    }

    private boolean validateToken(String token) {
        try {
            SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretKey));
            Jws<Claims> claimsJws = Jwts.parser()
                    .verifyWith(key) // 서버들만이 가진 secretKey로 서버가 발행한 것인지 확인
                    .build().parseSignedClaims(token);
            log.info("#####payload :: " + claimsJws.getPayload().toString());

            // 추가적인 검증 로직 (예: 토큰 만료 여부 확인 등)을 여기에 추가할 수 있습니다.
            return true;
        } catch (Exception e) {
            return false;
        }
    }



}

+ 누가 보낸 요청인지" 정보를 다음 서비스에 전달해줘야 할 때

// 예시: 다음 서비스로 사용자 ID 전달하기
exchange.getRequest().mutate()
    .header("X-User-Id", String.valueOf(claimsJws.getPayload().get("user_id")))
    .build();

보통은 검증이 끝난 후 claimsJws에서 뽑아낸 user_id를 헤더에 다시 담아서 보내주는 로직을 추가하곤 합니다.

Talend로 요청하기


Gateway 인증 처리시 주의사항

Gateway에서 인증을 사용하더라도 서비스에 직접 요청하면 인증 정보 없이도 응답을 받을 수 있다.
서비스는 게이트웨이의 요청만을 받도록 방화벽 처리를 해야한다.

1. 네트워크 레벨의 방화벽

서비스를 물리적으로 또는 가상 네트워크(VPC) 내부로 숨기는 방식

주로 클라우드(AWS 등) 환경에서 이 방식을 사용한다.

보안 그룹(Security Group) 설정:

- 개별 서비스(Order, Product 등): 자신의 포트(예: 19091)에 대해 Gateway의 IP 주소에서 오는 요청만 허용하도록 인바운드 규칙을 설정
- 그 외의 IP는 모두 차단(Drop)

Gateway: 

- 외부 클라이언트(0.0.0.0/0)의 요청을 받을 수 있도록 포트(예: 18091)를 개방

2. 애플리케이션 레벨의 필터링

모든 서비스가 게이트웨이가 보낸 '비밀 표식'을 확인하게 만드는 방식

Gateway 설정:

모든 요청을 보낼 때 특정 헤더를 강제로 추가하도록 설정한다.

spring:
  cloud:
    gateway:
      default-filters:
        - AddRequestHeader=X-Gateway-Token, MySecretKey123

개별 서비스 설정:

모든 API 요청에서 X-Gateway-Token 헤더가 MySecretKey123과 일치하는지 검사하는 Interceptor나 Filter를 구현한다.
일치하지 않으면 바로 403 Forbidden으로 처리한다.

3. 내부 서비스 전용 Security 설정

개별 서비스에도 Spring Security를 적용하되, 게이트웨이와 같은 JWT 비밀키를 공유하게 하는 방법

동작 원리: 사용자가 게이트웨이를 거치지 않고 서비스 포트로 직접 요청을 보내더라도, 서비스 자체에 걸려있는 Security가 인증 토큰(JWT)을 검증한다.

장점: 게이트웨이를 통하든 직접 요청하든 보안이 유지된다.

profile
풀스택 연습생. 끈기있는 삽질로 무대에서 화려하게 데뷔할 예정 ❤️🔥

0개의 댓글