[MSA] Gateway Service

C_Mungi·2024년 9월 26일

MSA

목록 보기
5/8
post-thumbnail

Spring Cloud Gateway란

Spring Framwork가 제공하는 라이브러리로 Spring WebFlux 또는 Spring WebMVC에 API Gateway를 구축하기 위해 사용됩니다.
Spring Cloud Gateway는 API로 라우팅하고 보안, 모니터링/메트릭, 복원성과 같은 횡단적 관심사를 제공합니다.

이미지 출처 : https://kyhslam.tistory.com/entry/Spring-Cloud-Gateway-Load-Balancer

Spring Cloud Gateway 구축

클라이언트가 서비스를 이용하게 될 때 해당 유저가 인증된 유저인지 또는 특정 서비스를 이용할 권한을 가졌는지 판단을 하고 그 이후 서비스로 연결해주게 됩니다.

그래서 저는 Filter를 구현하고 Cookie로 내려보낸 Access Token을 가져와 유효성 검사를 실시 후 정상인 경우 memberId를 추출합니다. 추출한 memberId는 header에 member-id라는 이름으로 저장해 회원 정보가 필요한 서비스에서 해당 member-id를 이용할 수 있게 했습니다.

Gateway service에서 작성할 내용은 다음과 같습니다.

  • 필터 관련

    • Global Filter
    • AuthorizationFilter
  • Cookie & JWT 관련

    • CookieProvider
    • JwtProvider
  • 예외 관련

    • GlobalExceptionHandler
    • ExceptionHandlerConfig
    • AuthException
  • API Limiter 관련

    • TokenKeyConfig

1. 의존성 주입

build.gradle

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

dependencies {
	
    // jwt
    implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
    implementation 'io.jsonwebtoken:jjwt-impl:0.11.2'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.11.2'

    // spring cloud config
    implementation 'org.springframework.cloud:spring-cloud-starter-config'

    // spring cloud gateway
    implementation 'org.springframework.cloud:spring-cloud-starter-gateway'

    // spring cloud eureka
    implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
    
    // dev
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

}

dependencyManagement {
    imports {
        mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
    }
}

tasks.named('test') {
    useJUnitPlatform()
}

tasks.register("prepareKotlinBuildScriptModel") {}

bootJar {
    enabled = true
}

jar {
    enabled = false
}

application.yml 설정

spring:
  application:
    name: ${GATEWAY_APP_NAME}
  profiles:
    active: ${APP_PROFILE}
  config:
    import: optional:configserver:${CONFIG_SERVER_URI}

3. 필터 관련

3-1. Global Filter

솔직히 Global Filter의 경우는 이번 프로젝트에선 없어도 되는 기능이었지만 전체적인 기능을 파악하자는 의미로 억지로 추가해보았습니다. 그래서 특별히 무언가를 한다기 보단 어떠한 리퀘스트가 발생했는지 리스폰스의 스테터스는 어땠는지 로그로 남기는 정도로만 사용했습니다.

@Slf4j
@Component
public class GlobalFilter extends AbstractGatewayFilterFactory<Config> {

    public GlobalFilter() {
        super(Config.class);
    }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();
            ServerHttpResponse response = exchange.getResponse();

            log.info("Global Base Message: {}", config.getBaseMessage());

            if (config.isPreLogger()) {
                log.info("Global Filter Start. request id : {}, request path : {}", request.getId(),
                    request.getPath());
            }
            return chain.filter(exchange).then(Mono.fromRunnable(() -> {

                if (config.isPostLogger()) {
                    log.info("Global Filter End: response status code -> {}",
                        response.getStatusCode());
                }
            }));
        };
    }

    @AllArgsConstructor
    @NoArgsConstructor
    @Data
    public static class Config {

        private String baseMessage;
        private boolean preLogger;
        private boolean postLogger;
    }
}

3-2. AuthorizationFilter

@Component
public class AuthorizationFilter extends AbstractGatewayFilterFactory<Config> {

    private final CookieProvider cookieProvider;
    private final JwtProvider jwtProvider;

    @Autowired
    public AuthorizationFilter(CookieProvider cookieProvider, JwtProvider jwtProvider) {
        super(Config.class);
        this.cookieProvider = cookieProvider;
        this.jwtProvider = jwtProvider;
    }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {

            ServerHttpRequest request = exchange.getRequest();
            String token = cookieProvider.getTokenFromCookies(request.getCookies());

            jwtProvider.validateToken(token, TokenType.ACCESS);

            Long memberId = jwtProvider.getMemberIdByToken(token, TokenType.ACCESS);

            ServerHttpRequest newRequest = request.mutate()
                .header("member-id", String.valueOf(memberId)).build();

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

    public static class Config {

    }
}

request가 가지고 있는 Cookie에서 token을 추출해 해당 token에 문제 없는지 체크를 합니다.
문제가 없다면 token에 저장된 memberId를 추출해 header에 저장하고 request로 재생성합니다.ㅣ
이후 다음 filter에 재생성한 request를 담아 보내줍니다.

4-1. CookieProvider

@Component
public class CookieProvider {

    public String getTokenFromCookies(MultiValueMap<String, HttpCookie> cookies) {
        if (cookies.isEmpty()) {
            return null;
        }
        return cookies.get(TokenType.ACCESS.getName()).stream().map(HttpCookie::getValue)
            .findAny()
            .orElse(null);
    }
}

CookieProvider에서는 Cookie의 이름을 특정해 해당 토큰을 반환하는 로직만 작성했습니다.

4-2. JwtProvider

@Component
@Slf4j
@RequiredArgsConstructor
public class JwtProvider {

    @Value("${jwt.access-secret}")
    private String ACCESS_SECRET;
    @Value("${jwt.refresh-secret}")
    private String REFRESH_SECRET;

    private Key accessKey;
    private Key refreshKey;

    @PostConstruct
    public void init() {
        byte[] accessKeyBytes = Decoders.BASE64.decode(ACCESS_SECRET);
        byte[] refreshKeyBytes = Decoders.BASE64.decode(REFRESH_SECRET);
        this.accessKey = Keys.hmacShaKeyFor(accessKeyBytes);
        this.refreshKey = Keys.hmacShaKeyFor(refreshKeyBytes);
    }

    public void validateToken(String token, TokenType type) {
        Key key = type.equals(TokenType.ACCESS) ? accessKey : refreshKey;
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
        } catch (ExpiredJwtException | UnsupportedJwtException | MalformedJwtException |
                 SignatureException | IllegalArgumentException ex) {
            throw new AuthException(ErrorType.TOKEN_AUTHORIZATION_FAIL);
        }

        validateTokenExpired(token, type);
    }

    private void validateTokenExpired(String token, TokenType type) {
        Date expiredDate = getExpired(token, type);
        if (expiredDate.before(new Date())) {
            throw new AuthException(ErrorType.TOKEN_EXPIRED);
        }
    }

    public Date getExpired(String token, TokenType type) {
        return getClaimsFromJwtToken(token, type).getExpiration();
    }

    public Long getMemberIdByToken(String token, TokenType type) {
        return getClaimsFromJwtToken(token, type).get("memberId", Long.class);
    }

    private Claims getClaimsFromJwtToken(String token, TokenType type) {
        Key key = type.equals(TokenType.ACCESS) ? accessKey : refreshKey;
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
    }
}

JwtProvider에서는 Token의 내용을 가져오거나 Token의 유효성 검사를 실시합니다.

5. 예외 관련

5-1. GlobalExceptionHandler

@Slf4j
@Component
@RequiredArgsConstructor
public class GlobalExceptionHandler implements ErrorWebExceptionHandler {

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
        exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);

        Map<String, Object> responseBody = new HashMap<>();
        if (ex instanceof HttpStatusCodeException statusEx) {
            exchange.getResponse().setStatusCode(statusEx.getStatusCode());
            responseBody.put("status", statusEx.getStatusCode());
            responseBody.put("message", statusEx.getMessage());
        } else {
            exchange.getResponse()
                .setStatusCode(ErrorType.TOKEN_AUTHORIZATION_FAIL.getStatusCode());
            responseBody.put("status", ErrorType.TOKEN_AUTHORIZATION_FAIL.getStatusCode());
            responseBody.put("message", ex.getMessage());
        }

        DataBuffer wrap = null;
        try {
            byte[] bytes = objectMapper.writeValueAsBytes(responseBody);
            wrap = exchange.getResponse().bufferFactory().wrap(bytes);
        } catch (JsonProcessingException e) {
            log.error("fatal error : {}", e.getMessage());
        }

        return exchange.getResponse().writeWith(Flux.just(Objects.requireNonNull(wrap)));
    }
}

GlobalExceptionHandler에서는 예외가 발생한 경우 responseBody를 생성해 결과를 반환하게 했습니다.

5-2. ExceptionHandlerConfig

@Configuration
public class ExceptionHandlerConfig {

    @Bean
    public ErrorWebExceptionHandler errorWebExceptionHandler() {
        return new GlobalExceptionHandler();
    }
}

5-1에서 작성한 GlobalExceptionHandler를 Configuration에 Bean으로 등록했습니다. 필터링중 예외가 발생하게 된다면 Bean으로 등록한 GlobalExceptionHandler가 해당 예외를 캐치해 ResponseBody를 생성해 클라이언트로 반환하게 됩니다.

5-3. AuthException

public class AuthException extends HttpStatusCodeException {

    public AuthException(ErrorType errorType) {
        super(errorType.getStatusCode(), errorType.getMessage());
    }

    @Override
    public String getMessage() {
        return getStatusText();
    }
}

커스터마이징한 Exception입니다. 의도적인 예외가 발생한 경우 AuthException으로 throw되고 GlobalExceptionHandler를 통해 ResponseBody로 생성됩니다.

6. API Limiter

6-1 TokenKeyConfig

@Configuration
public class TokenKeyConfig {

    @Bean
    public KeyResolver tokenKeyResolver() {
        return exchange -> {
            var cookies = exchange.getRequest().getCookies().getFirst(TokenType.ACCESS.getName());
            if (cookies != null) {
                return Mono.just(cookies.getValue());
            } else {
                String clientIp = Objects.requireNonNull(exchange.getRequest().getRemoteAddress())
                    .getAddress()
                    .getHostAddress();
                String hashedIp = DigestUtils.sha256Hex(clientIp);
                return Mono.just(hashedIp);
            }
        };
    }
}

쿠키가 있는 경우 쿠키값을 Mono로 감싸서 반환하고 없는 경우 클라이언트의 IP를 해싱처리해 Mono로 감싸 반환했습니다. 이를 통해 API Limiter에서 중복된 요청 등 과도한 트래픽을 일정 부분 제한할 수 있도록 합니다.

7. Eureka Client 활성화

@EnableDiscoveryClient
@SpringBootApplication
public class GatewayServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(GatewayServiceApplication.class, args);
    }

}

Eureka Client로 등록하기 위해 @EnableDiscoveryClient 어노테이션을 붙여줍니다.

8. Config Server의 설정 파일 작성

gateway-dev.yml

server:
  port: 8080

spring:
  cloud:
    gateway:
      default-filters:
        - name: GlobalFilter
          args:
            baseMessage: Spring Cloud Gateway Global Filter
            preLogger: true
            postLogger: true
        - name: RequestRateLimiter
          args:
            key-resolver: "#{@tokenKeyResolver}"
            redis-rate-limiter.replenishRate: 5
            redis-rate-limiter.burstCapacity: 30
            redis-rate-limiter.requestedTokens: 3
      globalcors:
        cors-configurations:
          '[/**]':
            allowedOrigins:
              - 'http://localhost:8081'
              - 'http://localhost:8082'
              - 'http://localhost:8083'
              - 'http://localhost:8084'
            allowedMethods:
              - GET
              - POST
              - PUT
              - DELETE
              - OPTIONS
            allowedHeaders: '*'
            allow-credentials: true
      routes:
        - id: accommodation
          uri: lb://ACCOMMODATION
          predicates:
            - Path=/api/accommodations/**

        - id: member
          uri: lb://MEMBER
          predicates:
            - Path=/api/auth/**

        - id: reservation
          uri: lb://RESERVATION
          predicates:
            - Path=/api/reservation/**
          filters:
            - name: AuthorizationFilter

        - id: room
          uri: lb://room
          predicates:
            - Path=/api/accommodation/{accommodationId}/rooms/**

jwt:
  access-secret: # access secret key
  refresh-secret: # refresh secret key
  issuer: test
  access-token-expired-time: 86400000
  refresh-token-expired-time: 604800000

eureka:
  client:
    register-with-eureka: true
    fetch-registry: true
    service-url:
      defaultZone: http://${eureka-username}:${eureka-password}@localhost:8761/eureka


위에서부터 하나씩 설명을 하자면 다음과 같습니다.

      default-filters:
        - name: GlobalFilter
          args:
            baseMessage: Spring Cloud Gateway Global Filter
            preLogger: true
            postLogger: true
        - name: RequestRateLimiter
          args:
            key-resolver: "#{@tokenKeyResolver}"
            redis-rate-limiter.replenishRate: 5
            redis-rate-limiter.burstCapacity: 30
            redis-rate-limiter.requestedTokens: 1

GlobalFilter 라는 클래스를 디폴트로 등록하고, arguments로써 baseMessage와, preLogger, postLogger라는 변수에 각각의 값을 지정합니다.

RequestRateLimiter는 서버가 클라이언트의 시간당 요청 횟수를 제한하는 것으로 서버 과부하를 막기위해 사용했습니다.

replenisRate : 초당 채워지는 Token의 수
burstCapacity : 최대 허용되는 버스트 요청 수
requestedTokens : 요청을 처리할 때 소모되는 토큰의 수

      globalcors:
        cors-configurations:
          '[/**]':
            allowedOrigins:
              - 'http://localhost:8081'
              - 'http://localhost:8082'
              - 'http://localhost:8083'
              - 'http://localhost:8084'
            allowedMethods:
              - GET
              - POST
              - PUT
              - DELETE
              - OPTIONS
            allowedHeaders: '*'
            allow-credentials: true

이 부분은 CORS에 해당되는 이야기로, localhost의 8081~8084에에서 GET, POST, PUT등 메서드들의 요청을 허가 하겠다는 의미입니다.

      routes:
        - id: accommodation
          uri: lb://ACCOMMODATION
          predicates:
            - Path=/api/accommodations/**

        - id: member
          uri: lb://MEMBER
          predicates:
            - Path=/api/auth/**

        - id: reservation
          uri: lb://RESERVATION
          predicates:
            - Path=/api/reservation/**
          filters:
            - name: AuthorizationFilter

        - id: room
          uri: lb://room
          predicates:
            - Path=/api/accommodation/{accommodationId}/rooms/**

각 predicates의 값 이하의 path가 들어오면 해당 id의 uri로 라우팅해준다는 의미입니다.
그리고 reservation의 경우 filters가 추가되어 있는데 특정 서비스에만 filter를 추가하는 의미로 인증/인가 filter를 추가했습니다.
jwt와 eureka는 생략하겠습니다.

동작 확인

먼저 Config Server, Eureka Server를 실행한 뒤 Gateway Service를 실행합니다

Gateway Service가 정상 실행하고 Eureka Server에 등록이 되어있다면

http://localhost:8761 에 접속해 Eureka Server의 username과 password를 입력해 로그인합니다.

그러면 아래와 같이 GATEWAY 서비스가 등록이 되어 있는 것을 볼 수 있습니다.

이제 라우팅이 정상적으로 동작하는지 확인해보겠습니다.

저는 현재 Member Service의 port를 8082로 설정한 상태입니다.
Gateway가 없다면 Member Service를 이용하기 위해 localhost:8082로 요청을 해야하지만 Gateway가 라우팅을 해주므로 localhost:8080으로 요청을 보내면 됩니다.

마무리

다음은 OpenFeign에 대해 포스팅하겠습니다.

profile
백엔드 개발자의 수집상자

0개의 댓글