STOVE DEVCAMP 3๊ธฐ/MSA

[MSA] Spring Cloud Gateway๋กœ API ๊ฒŒ์ดํŠธ์›จ์ด ๊ตฌ์ถ•ํ•˜๊ธฐ

sw_develop 2023. 3. 10. 03:10

๐Ÿ“Œ Spring Cloud Gateway

1) ์Šคํ”„๋ง ํด๋ผ์šฐ๋“œ ๊ฒŒ์ดํŠธ์›จ์ด๋ž€

  • ์Šคํ”„๋ง์—์„œ ์ œ๊ณตํ•˜๋Š” API ๊ฒŒ์ดํŠธ์›จ์ด ๊ตฌ์ถ•์„ ์œ„ํ•œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์ค‘ ํ•˜๋‚˜์ž„
  • API ๊ฒŒ์ดํŠธ์›จ์ด๊ฐ€ ์ถ”์ƒ์ ์ธ ๊ฐœ๋…์ด๋ผ๋ฉด, ์Šคํ”„๋ง ํด๋ผ์šฐ๋“œ ๊ฒŒ์ดํŠธ์›จ์ด๋Š” ๊ตฌํ˜„์ฒด ๋А๋‚Œ

 

2) ๊ตฌ์„ฑ ์š”์†Œ

ํฌ๊ฒŒ 3๊ฐ€์ง€ ๊ตฌ์„ฑ์š”์†Œ๊ฐ€ ์กด์žฌํ•จ

  • Route
    • ๊ณ ์œ ID, ๋ชฉ์ ์ง€ URI, Predicate, Filter๋กœ ๊ตฌ์„ฑ๋จ
    • ๊ฒŒ์ดํŠธ์›จ์ด๋กœ ์š”์ฒญ๋œ URI์˜ ์กฐ๊ฑด์ด ์ฐธ์ผ ๊ฒฝ์šฐ, ๋งคํ•‘๋œ ํ•ด๋‹น ๊ฒฝ๋กœ๋กœ ์š”์ฒญ์„ ์ „๋‹ฌํ•ด์คŒ
  • Predicate
    • ์ฃผ์–ด์ง„ ์š”์ฒญ์ด ์กฐ๊ฑด์„ ์ถฉ์กฑํ•˜๋Š”์ง€ ํ…Œ์ŠคํŠธํ•˜๋Š” ๊ตฌ์„ฑ์š”์†Œ
    • ๊ฐ ์š”์ฒญ ๊ฒฝ๋กœ์— ๋Œ€ํ•ด ์ถฉ์กฑํ•ด์•ผ ํ•˜๋Š” 1๊ฐœ ์ด์ƒ์˜ ์กฐ๊ฑด์ž๋ฅผ ์ •์˜ํ•  ์ˆ˜ ์žˆ์Œ
    • ๋งŒ์•ฝ ์ •์˜๋œ ๋ชจ๋“  Predicate์— ๋งค์นญ๋˜์ง€ ์•Š๋Š”๋‹ค๋ฉด HTTP 404 Not Found๋ฅผ ๋ฐ˜ํ™˜ํ•จ
  • Filter
    • ์š”์ฒญ ๋ฐ ์‘๋‹ต์— ๋Œ€ํ•œ ์ „์ฒ˜๋ฆฌ, ํ›„์ฒ˜๋ฆฌ๋ฅผ ๋‹ด๋‹นํ•˜๋Š” ๊ตฌ์„ฑ์š”์†Œ
  • ์ด๋Ÿฌํ•œ ์„ค์ •์€ Java DSL๋กœ ์ •์˜ํ•˜๊ฑฐ๋‚˜ application.yml ๋“ฑ์˜ ์„ค์ • ํŒŒ์ผ์— ์ •์˜ํ•  ์ˆ˜ ์žˆ์Œ

 

์œ„ ์„ค์ •๋“ค์€ RouteDefinition ๊ฐ์ฒด๋กœ ๋งคํ•‘๋˜๊ณ , ๋งคํ•‘๋œ ์„ค์ •์ •๋ณด๋“ค์„ ๋ฐ”ํƒ•์œผ๋กœ ๊ฒŒ์ดํŠธ์›จ์ด๊ฐ€ ๋™์ž‘ํ•จ

public class RouteDefinition {  
  
   private String id;  
  
   @NotEmpty  
   @Valid   private List<PredicateDefinition> predicates = new ArrayList<>();  
  
   @Valid  
   private List<FilterDefinition> filters = new ArrayList<>();  
  
   @NotNull  
   private URI uri;  
  
   private Map<String, Object> metadata = new HashMap<>();  
  
   private int order = 0;  
  
   public RouteDefinition() {  
   }

	...
}

 

3) ๋™์ž‘ ๋ฐฉ์‹

API ๊ฒŒ์ดํŠธ์›จ์ด๋กœ์„œ ์ž‘๋™ํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” API ๊ฒŒ์ดํŠธ์›จ์ด๊ฐ€ ์ œ๊ณตํ•˜๋Š” ๊ธฐ๋Šฅ๋“ค์„ ์ œ๊ณตํ•ด์ค˜์•ผ ํ•˜๋Š”๋ฐ, ์ด๋ฅผ ์œ„ํ•ด ์Šคํ”„๋ง ํด๋ผ์šฐ๋“œ ๊ฒŒ์ดํŠธ์›จ์ด ์–ด๋–ป๊ฒŒ ๊ตฌํ˜„๋˜์–ด ๋™์ž‘ํ•˜๋Š”๊ฐ€?

 

ํ๋ฆ„

  1. ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์Šคํ”„๋ง ํด๋ผ์šฐ๋“œ ๊ฒŒ์ดํŠธ์›จ์ด๋กœ ์š”์ฒญํ•จ
  2. Gateway Handler Mapping๋Š” Route๋งˆ๋‹ค ์„ค์ •๋œ Predicate์„ ์ ์šฉํ•ด ์•Œ๋งž์€ Route Instance๋ฅผ ์ฐพ๊ณ , ์š”์ฒญ์„ Gateway Web Handler๋กœ ์ „๋‹ฌํ•จ
  3. Gateway Web Handler๊ฐ€ Route์— ์ •์˜๋œ Filter๋“ค๋กœ Filter Chain์„ ์ƒ์„ฑํ•˜์—ฌ ์‹คํ–‰ํ•จ
  4. ๋ชจ๋“  Pre-Filter ๋กœ์ง์ด ์ˆ˜ํ–‰๋˜๋ฉด, Proxy ์š”์ฒญ์ด ๋งŒ๋“ค์–ด์ง€๊ณ , ์ด์— ๋Œ€ํ•œ ์‘๋‹ต์ด ์˜ค๋ฉด, Post-Filter ๋กœ์ง์ด ์ˆ˜ํ–‰๋˜์–ด ์ตœ์ข…์ ์œผ๋กœ ํด๋ผ์ด์–ธํŠธ์—๊ฒŒ ๋ฐ˜ํ™˜๋จ

 

์ฆ‰, Filter๋ฅผ ์ •์˜ํ•ด ์ธ์ฆ/์ธ๊ฐ€ ๋ฐ ๋กœ๊น…, ๋ชจ๋‹ˆํ„ฐ๋ง ๋ฐ ๋ฉ”ํŠธ๋ฆญ ์ˆ˜์ง‘ ๋“ฑ์„ ์ˆ˜ํ–‰ํ•  ์ˆ˜ ์žˆ์Œ

 

๐Ÿ“Œ ํด๋ฃจํ„ฐ์— ๋„์ž…ํ•˜๊ธฐ - API ๋ผ์šฐํŒ… ๋ฐ ๊ณตํ†ต ์ธ์ฆ ์ˆ˜ํ–‰

1) API ๋ผ์šฐํŒ…

  • yaml ํŒŒ์ผ์— API ์—”๋“œํฌ์ธํŠธ๋ณ„ Route, Predicate, Filter๋ฅผ ์ •์˜ํ•จ

๊ฐœ์„  ํฌ์ธํŠธ

  • ์„œ๋น„์Šค์˜ ๊ฐœ์ˆ˜๊ฐ€ ๋งŽ์•„์งˆ ๊ฒฝ์šฐ, yaml ํŒŒ์ผ์˜ ์‚ฌ์ด์ฆˆ๊ฐ€ ์ปค์ง€๊ณ , API ๋ผ์šฐํŒ… ์กฐ๊ฑด ๊ด€๋ฆฌ๊ฐ€ ์–ด๋ ค์›Œ์ง
  • API ์—”๋“œํฌ์ธํŠธ ๊ด€๋ฆฌ์ž ํŽ˜์ด์ง€๋ฅผ ๋งŒ๋“ค์–ด ๋ผ์šฐํŒ… ์กฐ๊ฑด ์ถ”๊ฐ€, ์‚ญ์ œ๊ฐ€ ์‰ฝ๋„๋ก ๊ฐœ์„ ํ•  ์ˆ˜ ์žˆ์„ ๊ฒƒ์œผ๋กœ ์˜ˆ์ƒ๋จ

 

2) ๊ณตํ†ต ์ธ์ฆ ์ˆ˜ํ–‰

์ˆ˜ํ–‰ ์ „

์ˆ˜ํ–‰ ํ›„

  • Authorization ํ•„์š”ํ•˜์ง€ ์•Š์€ ์š”์ฒญ - ๋กœ๊ทธ์ธ/ํšŒ์›๊ฐ€์ž…

  • Authorization ํ•„์š”ํ•œ ์š”์ฒญ

 

๊ตฌํ˜„ ๋ฐฉ์‹

  • ์œ„์˜ ์Šคํ”„๋ง ํด๋ผ์šฐ๋“œ ๊ฒŒ์ดํŠธ์›จ์ด ๋™์ž‘๋ฐฉ์‹์„ ๋ณด๋ฉด, ๊ณตํ†ต ์ธ์ฆ์„ ์ˆ˜ํ–‰ํ•  Filter๋ฅผ ์ •์˜ํ•ด ์ธ์ฆ์ด ํ•„์š”ํ•œ API์˜ ๊ฒฝ์šฐ ํ•ด๋‹น Filter๋ฅผ ๊ฑฐ์น˜๋„๋ก ์„ค์ •ํ•  ์ˆ˜ ์žˆ์Œ

 

1) Filter ์ •์˜

//๊ณตํ†ต ์ธ์ฆ์„ ์ˆ˜ํ–‰ํ•˜๋Š” AuthorizationHeaderFilter

@Component
public class AuthorizationHeaderGatewayFilterFactory extends AbstractGatewayFilterFactory<AuthorizationHeaderGatewayFilterFactory.Config> {

    private static final Logger logger = LoggerFactory.getLogger(AuthorizationHeaderGatewayFilterFactory.class);

    private final FilterUtils filterUtils;
    private final JwtUtils jwtUtils;

    private static final Pattern BEARER_TOKEN_PATTERN = Pattern.compile("Bearer (.*)");

    public AuthorizationHeaderGatewayFilterFactory(FilterUtils filterUtils, JwtUtils jwtUtils) {
        super(Config.class);
        this.filterUtils = filterUtils;
        this.jwtUtils = jwtUtils;
    }

    @Override
    public GatewayFilter apply(AuthorizationHeaderGatewayFilterFactory.Config config) {
        return (exchange, chain) -> {
            String token = resolveToken(exchange);
            String userId = getUserId(token);
            return chain.filter(filterUtils.setRequestHeader(exchange, JwtUtils.USER_ID, userId));
        };
    }

    private String resolveToken(ServerWebExchange exchange) {
        String token = filterUtils.getHeaderValue(exchange.getRequest().getHeaders(), JwtUtils.AUTHORIZATION);
        if (!StringUtils.hasText(token)) throw new GatewayErrorException(GatewayError.AUTHENTICATION_FAIL);
        Matcher matcher = BEARER_TOKEN_PATTERN.matcher(token);
        if (!matcher.matches()) throw new GatewayErrorException(GatewayError.AUTHENTICATION_FAIL);
        return matcher.group(1);
    }

    private String getUserId(String token) {
        String userId = jwtUtils.getUserId(token);
        if (!StringUtils.hasText(userId)) throw new GatewayErrorException(GatewayError.NO_USERID_IN_TOKEN);
        return userId;
    }

    static class Config { }
}

์ฐธ๊ณ 

 

2) ์ธ์ฆ์ด ํ•„์š”ํ•œ URI์— Filter ์ ์šฉ

์ฐธ๊ณ 

 

3) Filter ์ ์šฉ ํ…Œ์ŠคํŠธ

  • WireMock์„ ์‚ฌ์šฉํ•ด ์„ค์ • ์กฐ๊ฑด์— ๋”ฐ๋ผ Filter๊ฐ€ ์ ์šฉ๋˜๋Š”์ง€ ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•จ
@SpringBootTest(
        webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
        classes = AuthorizationHeaderGatewayFilterFactoryTest.AuthorizationHeaderFilterTestConfig.class)
@AutoConfigureWebTestClient
public class AuthorizationHeaderGatewayFilterFactoryTest {

    @TestConfiguration
    static class AuthorizationHeaderFilterTestConfig {

        @Autowired
        AuthorizationHeaderGatewayFilterFactory authorizationHeaderGatewayFilterFactory;

        @Bean(destroyMethod = "stop")
        WireMockServer wireMockServer() {
            WireMockConfiguration options = WireMockConfiguration.wireMockConfig().dynamicPort();
            WireMockServer wireMock = new WireMockServer(options);
            wireMock.start();
            return wireMock;
        }

        @Bean
        RouteLocator testRoutes(RouteLocatorBuilder builder, WireMockServer wireMock) {
            AuthorizationHeaderGatewayFilterFactory.Config config = new AuthorizationHeaderGatewayFilterFactory.Config();
            GatewayFilter gatewayFilter = authorizationHeaderGatewayFilterFactory.apply(config);
            return builder.routes()
                    .route(predicateSpec -> predicateSpec
                            .path("/api/user/health")
                            .uri(wireMock.baseUrl()))
                    .route(predicateSpec -> predicateSpec
                            .path("/api/user/**")
                            .filters(filterSpec -> filterSpec.filter(gatewayFilter))
                            .uri(wireMock.baseUrl()))
                    .build();
        }
    }

    @Autowired
    WebTestClient webTestClient;

    @Autowired
    WireMockServer wireMockServer;

    @AfterEach
    void afterAll() {
        wireMockServer.resetAll();
    }

    @Value("${jwt.secret}")
    String secretKey;

    @Test
    @DisplayName("health check api๋Š” ์ธ๊ฐ€ ํ•„ํ„ฐ๋ฅผ ๊ฑฐ์น˜์ง€ ์•Š์Œ")
    void healthCheck() {
        //given
        wireMockServer.stubFor(WireMock.get("/api/user/health").willReturn(WireMock.ok()));

        //when, then
        webTestClient.get().uri("/api/user/health")
                .exchange()
                .expectStatus()
                .isEqualTo(HttpStatus.OK);
    }

    @Test
    @DisplayName("ํ† ํฐ ๊ฒ€์ฆ ์„ฑ๊ณต ์‹œ ์š”์ฒญ ํ—ค๋”์— userId ๊ฐ’์ด ํฌํ•จ๋จ")
    void tokenValidationSuccess() {
        //given
        //ํ† ํฐ
        long now = new Date().getTime();
        Date exp = new Date(now + 1000 * 60 * 30);

        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        Key signingKey = Keys.hmacShaKeyFor(keyBytes);

        String token = Jwts.builder()
                .setSubject("clutter")
                .claim("auth", "ROLE_USER")
                .claim("userId", "1")
                .signWith(signingKey, SignatureAlgorithm.HS512)
                .setExpiration(exp)
                .compact();
        //์š”์ฒญ
        String expectedUserId = "1";
        wireMockServer.stubFor(WireMock.get("/api/user/1").willReturn(WireMock.ok()));

        //when, then
        webTestClient.get().uri("/api/user/1")
                .headers(httpHeaders -> httpHeaders.add("Authorization", "Bearer " + token))
                .exchange()
                .expectStatus()
                .isEqualTo(HttpStatus.OK);

        wireMockServer.verify(WireMock.getRequestedFor(WireMock.urlEqualTo("/api/user/1"))
                .withHeader("userId", equalTo(expectedUserId)));
    }

    @Test
    @DisplayName("ํ† ํฐ ์œ ํšจ๊ธฐ๊ฐ„ ๋งŒ๋ฃŒ ์‹œ ์˜ˆ์™ธ ๋ฐ˜ํ™˜ํ•จ")
    void tokenInvalidThrowException() {
        //given
        //ํ† ํฐ
        long now = new Date().getTime();
        Date exp = new Date(now - 1000 * 60 * 30);

        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        Key signingKey = Keys.hmacShaKeyFor(keyBytes);

        String token = Jwts.builder()
                .setSubject("clutter")
                .claim("auth", "ROLE_USER")
                .claim("userId", "1")
                .signWith(signingKey, SignatureAlgorithm.HS512)
                .setExpiration(exp)
                .compact();
        //์š”์ฒญ
        wireMockServer.stubFor(WireMock.get("/api/user/1").willReturn(WireMock.ok()));

        //when, then
        webTestClient.get().uri("/api/user/1")
                .headers(httpHeaders -> httpHeaders.add("Authorization", "Bearer " + token))
                .exchange()
                .expectStatus()
                .isEqualTo(HttpStatus.UNAUTHORIZED)
                .expectBody()
                .jsonPath("$.code").isEqualTo("-521");
    }

    @Test
    @DisplayName("ํ† ํฐ์— userId๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ ์˜ˆ์™ธ ๋ฐ˜ํ™˜ํ•จ")
    void withoutUserIdThrowException() {
        //given
        //ํ† ํฐ
        long now = new Date().getTime();
        Date exp = new Date(now + 1000 * 60 * 30);

        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        Key signingKey = Keys.hmacShaKeyFor(keyBytes);

        String token = Jwts.builder()
                .setSubject("clutter")
                .claim("auth", "ROLE_USER")
//                .claim("userId", "1")
                .signWith(signingKey, SignatureAlgorithm.HS512)
                .setExpiration(exp)
                .compact();
        //์š”์ฒญ
        wireMockServer.stubFor(WireMock.get("/api/user/1").willReturn(WireMock.ok()));

        //when, then
        webTestClient.get().uri("/api/user/1")
                .headers(httpHeaders -> httpHeaders.add("Authorization", "Bearer " + token))
                .exchange()
                .expectStatus()
                .isEqualTo(HttpStatus.UNAUTHORIZED)
                .expectBody()
                .jsonPath("$.code").isEqualTo("-520")
                .jsonPath("$.message").isEqualTo("ํ† ํฐ์— userId ๊ฐ’์ด ์—†์Šต๋‹ˆ๋‹ค.");
    }

    @Test
    @DisplayName("์š”์ฒญ์— Authorization ํ—ค๋”๊ฐ€ ์—†์œผ๋ฉด ์˜ˆ์™ธ ๋ฐ˜ํ™˜ํ•จ")
    void noAuthorizationThrowException() {
        //given
        wireMockServer.stubFor(WireMock.get("/api/user/1").willReturn(WireMock.ok()));

        //when, then
        webTestClient.get().uri("/api/user/1")
                .exchange()
                .expectStatus()
                .isEqualTo(HttpStatus.UNAUTHORIZED)
                .expectBody()
                .jsonPath("$.code").isEqualTo("-522")
                .jsonPath("$.message").isEqualTo("์ธ์ฆ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.");
    }
}

์ฐธ๊ณ