본문 바로가기

MicroService Architecture

[API Gateway] API Gateway Filter

JwtUtil.class

@Component
public class JwtUtil {
    public static final String BEARER_PREFIX = "Bearer ";
    // Base64 Encode 한 SecretKey
    @Value("${jwt.secret.key}")
    private String secretKey;
    private Key key;

    // 로그 설정
    public static final Logger logger = LoggerFactory.getLogger("JWT 관련 로그");

    @PostConstruct
    public void init() {
        byte[] bytes = Base64.getDecoder().decode(secretKey);
        key = Keys.hmacShaKeyFor(bytes);
    }

    // JWT 토큰 substring
    public String substringToken(String tokenValue) {
        if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
            return tokenValue.substring(7);
        }
        logger.error("Not Found Token");
        throw new NullPointerException("Not Found Token");
    }

    // 토큰 검증
    public void validateToken(String token) throws SecurityException, MalformedJwtException, SignatureException, ExpiredJwtException, UnsupportedJwtException, IllegalArgumentException {
        Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
    }

    // 토큰에서 사용자 정보 가져오기
    public Claims getUserInfoFromToken(String token) {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
    }
}

API Gateway Filter

  • API Gateway에서 클라이언트 요청에 따라 라우팅을 하기 전Filter를 사용해서 요청에 대한 검사 가능
  • API Gateway Filter는 AbstractGatewayFilterFactory<C>를 상속
  • AbstractGatewayFilterFactory를 상속한 필터 클래스별다른 매개 변수가 존재하지 않을지라도 반드시 구성 클래스가 필요
Config Class
@Component
@Slf4j(topic = "API Gateway-GlobalFilter")
public class GlobalFilter extends AbstractGatewayFilterFactory<GlobalFilter.Config> {
    public static class Config{}
}
  • 현재는 비어있는 상태지만 필터의 동작을 커스터마이징할 때, 예를 들어 어떤 설정값을 받아 필터의 동작을 제어하고자 할 때, 이 클래스에 필요한 필드를 추가 가능
super(Config.class)
@Component
@Slf4j(topic = "API Gateway-GlobalFilter")
public class GlobalFilter extends AbstractGatewayFilterFactory<GlobalFilter.Config> {
    private final JwtUtil jwtUtil;

    public static class Config{}

    public GlobalFilter(JwtUtil jwtUtil){
        super(Config.class);
        this.jwtUtil = jwtUtil;
    }
}
  • super(Config.class)는 AbstractGatewayFilterFactory(부모)를 호출하면서 Config 클래스를 전달
  • AbstractGatewayFilterFactory는 이 필터(GlobalFilter)가 Config 클래스를 설정 클래스로 사용됨을 Spring Cloud Gateway에 알림
  • super(Config.class)를 반드시 작성해주어야만 함

 


super(Config.class)가 없을 때 발생 에러
java.lang.ClassCastException: class java.lang.Object cannot be cast to class io.spring.apigateway.filter.GlobalFilter$Config (java.lang.Object is in module java.base of loader 'bootstrap'; io.spring.apigateway.filter.GlobalFilter$Config is in unnamed module of loader 'app')

apply 메서드
@Override
public GatewayFilter apply(Config config) {
}

 

  • AbstractGatewayFilterFactory를 상속 받게 되면 apply 메서드를 반드시 구현
  • 람다 방식으로 간단히 구현할 수 있지만, 구조를 직관적으로 보기 위해 익명 클래스도 확인
@Override
public GatewayFilter apply(Config config) {
    return new GatewayFilter() {
        @Override
        public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
            // 필터 로직
            return chain.filter(exchange);
        }
    };
}
@Override
public GatewayFilter apply(Config config) {
    return ((exchange, chain) -> {
        // 필터 로직
    });
}

 

로직 구현
@Override
public GatewayFilter apply(Config config) {
    return ((exchange, chain) -> {
    	// ServerWebExchange에서 Request를 추출
        // ServerHttpRequest = HttpServletRequest와 비슷
        ServerHttpRequest request = exchange.getRequest();
        
        // request에서 Header 추출
        HttpHeaders headers = request.getHeaders();
        
        // 헤더명에 Authorization(JWT)인 것은 추출
        String authorization = headers.getFirst("Authorization");
        
        // 로그인 없이 접속할 수 있는 경로 whiteList 선언
        List<String> whiteList = Arrays.asList(
          "/member-service/api/member/regist",
          "/member-service/api/member/login"
        );

		// 클라이언트 요청 경로 추출
        String path = request.getURI().getPath();
        log.info("Request Path = {}", path);
        
        // 요청 경로가 whiteList에 해당할 경우 다음 필터로 요청 전달
        if(whiteList.contains(path)){
            log.info("Routing login or regist");
            return chain.filter(exchange);
        }
		
        // 헤더에 Authorization이 없다면 로그인되지 않은 회원으로 에러 발생
        if(authorization == null){
            log.info("UNAUTHORIZED, Required Login");
            return onError(exchange, "로그인 후 이용 가능합니다.", HttpStatus.UNAUTHORIZED);
        }
		
        // 헤더에서 'Bearer ' 제거
        String token = jwtUtil.substringToken(authorization);
		
        // JWT 검증 시 에러가 발생할 경우 try ~ catch
        try {
            jwtUtil.validateToken(token);
        } catch (SecurityException | MalformedJwtException | SignatureException e) {
            return onError(exchange, "유효하지 않는 JWT 서명 입니다.", HttpStatus.UNAUTHORIZED);
        } catch (ExpiredJwtException e) {
            return onError(exchange, "만료된 JWT 토큰 입니다.", HttpStatus.UNAUTHORIZED);
        } catch (UnsupportedJwtException e) {
            return onError(exchange, "지원되지 않는 JWT 토큰 입니다.", HttpStatus.UNAUTHORIZED);
        } catch (IllegalArgumentException e) {
            return onError(exchange, "잘못된 JWT 토큰 입니다.", HttpStatus.UNAUTHORIZED);
        }
		
        // JWT 검증에 문제가 없었다면 다음 필터로 요청 전달
        return chain.filter(exchange);
    });
}

 

Mono<Void> 구현
  • 리액티브 프로그래밍에서는 에러를 던지는 throw 대신 Mono와 Flux를 통해 예외를 관리하고 처리
private Mono<Void> onError(ServerWebExchange exchange, String err, HttpStatus httpStatus){
	// Response 객체 추출
    ServerHttpResponse response = exchange.getResponse();
    
    // 응답의 HTTP 상태 코드 설정
    response.setStatusCode(httpStatus);

    log.info(err);
    
    // 응답 완료 -> Mono<Void>를 반환
    // WebFlux의 리액티브 체인에서 오류 발생 시 후속 처리를 
    // 중단하고 클라이언트에 응답을 반환하는 데 사용
    return response.setComplete();
}

 

  • 이 메서드는 상태 코드만 설정하고 응답을 완료
  • 클라이언트에게 구체적인 에러 메세지나 JSON 형태의 응답을 반환하려면 응답 본문을 설정하는 추가 작업이 필요
onError 확장
private Mono<Void> onError(ServerWebExchange exchange, String err, HttpStatus httpStatus){
    ServerHttpResponse response = exchange.getResponse();
    response.setStatusCode(httpStatus);

    log.info(err);
    byte[] bytes = err.getBytes(StandardCharsets.UTF_8);
    DataBuffer buffer = response.bufferFactory().wrap(bytes);
    response.getHeaders().setContentType(MediaType.TEXT_PLAIN);

    return response.writeWith(Mono.just(buffer)).doOnError(error -> DataBufferUtils.release(buffer));
}
  • byte[] bytes = err.getBytes(StandardCharsets.UTF_8)
    • 오류 메시지(err)를 UTF-8 인코딩된 바이트 배열로 변환
    • 이 변환은 응답 본문에 이 메시지를 텍스트로 포함시키기 위해 필요
  • DataBuffer buffer = response.bufferFactory().wrap(bytes)
    • 바이트 배열을 DataBuffer로 래핑
    • DataBuffer는 Spring WebFlux에서 데이터 처리를 위한 기본 단위
  • response.getHeaders().setContentType(MediaType.TEXT_PLAIN)
    • 응답의 콘텐츠 유형을 text/plain으로 설정
    • 이 설정은 클라이언트가 응답 본문을 어떻게 해석해야 하는지를 결정
  • return response.writeWith(Mono.just(buffer))
    • 변환된 데이터 버퍼를 사용해 비동기적으로 응답 본문을 작성
    • 이 메서드는 Mono<Void>를 반환하는데, 이는 응답 쓰기가 완료되었음을 나타냄
  • .doOnError(error -> DataBufferUtils.release(buffer))
    • 응답 쓰기 도중 오류가 발생하면 DataBuffer를 해제
    • DataBuffer는 메모리를 차지하므로 명시적으로 해제해 메모리 누수를 방지
Application.yml 설정
spring:
  application:
    name: api-gateway
  cloud:
    gateway:
      default-filters:
        - name : GlobalFilter
      routes:
        - id: member-service
          uri: lb://member-service
          predicates:
            - Path=/member-service/**
          filters:
            - StripPrefix=1

        - id: order-service
          uri: lb://order-service
          predicates:
            - Path=/order-service/**
          filters:
            - StripPrefix=1

        - id: game-service
          uri: lb://game-service
          predicates:
            - Path=/game-service/**
          filters:
            - StripPrefix=1
  • 각 route마다의 filters에 Filter를 추가하여 개별 Filter를 적용할 수도 있지만, 모든 요청에 대해접근이 가능한 경로(Whitelist)를 제외한 나머지 경로에 대해 JWT를 확인하기 때문에 모든 서비스에 적용

Postman 테스트

로그인 되지 않은 상태 - JWT가 필요한 API 요청

 

로그인 되지 않은 상태 - 누구나 접근 가능한 API (로그인, 회원가입)

 

로그인 성공 → JWT가 필요한 API 호출