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 ๊ฒ์ดํธ์จ์ด๊ฐ ์ ๊ณตํ๋ ๊ธฐ๋ฅ๋ค์ ์ ๊ณตํด์ค์ผ ํ๋๋ฐ, ์ด๋ฅผ ์ํด ์คํ๋ง ํด๋ผ์ฐ๋ ๊ฒ์ดํธ์จ์ด ์ด๋ป๊ฒ ๊ตฌํ๋์ด ๋์ํ๋๊ฐ?
ํ๋ฆ
- ํด๋ผ์ด์ธํธ๊ฐ ์คํ๋ง ํด๋ผ์ฐ๋ ๊ฒ์ดํธ์จ์ด๋ก ์์ฒญํจ
- Gateway Handler Mapping๋ Route๋ง๋ค ์ค์ ๋ Predicate์ ์ ์ฉํด ์๋ง์ Route Instance๋ฅผ ์ฐพ๊ณ , ์์ฒญ์ Gateway Web Handler๋ก ์ ๋ฌํจ
- Gateway Web Handler๊ฐ Route์ ์ ์๋ Filter๋ค๋ก Filter Chain์ ์์ฑํ์ฌ ์คํํจ
- ๋ชจ๋ 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("์ธ์ฆ์ ์คํจํ์ต๋๋ค.");
}
}