데이터 클라우드 플랫폼(NDC)에 사용하는 Spring Cloud Gateway 동적 라우팅 처리 가자가자가자구!

생성일
2023/08/08 04:01
태그
테크리포트
기술블로그
kt NexR
Spring cloud gateway
글쓴이
hani.jang
속성
속성 1

서론

NDC에서는 연동하는 서비스들의 모든 요청/응답에 인증, 인가, 보안을 적용하기 위해 API Gateway로 Spring Cloud Gateway 를 사용하고 있습니다.
개인형 서비스와 구축형 서비스를 제공했던 v1.0 에서는 NDC 설치 시, 고정된 namespace와 서비스 pod 주소로 라우팅 url을 지정하고 있습니다.
v2.0 부터 kt cloud에 NDC 서비스를 생성/삭제 할 수 있도록 연동을 제공하게 되면서 동적으로 namespace를 생성/삭제 할 수 있게 되었고 namespace 별 서비스 pod로 동적 라우팅이 가능해야 되므로 Custom Gateway Filter 개발이 필요합니다.

관련 기술/연구, 선행작업

spring cloud gateway란?

Spring Reactive 생태계 위에 구현된 API Gateway 이다.
Non-blocking, Asynchronous 방식의 Netty Server를 사용한다.
Netty 는 비동기식 이벤트 기반의 WAS이기 때문에 기존의 멀티 쓰레드 방식보다 더 많은 요청을 처리할 수 있다.
클라이언트의 요청을 받고 적절한 backend 서버로 라우팅 해주는 Reverse Proxy이다.

라우팅 처리 프로세스

[그림 출처] Spring Cloud Gateway
Gateway Client : Spring Cloud Gateway에 요청
Gateway Handler Mapping : 요청이 경로와 일치하는지 판단
Gateway Web Handler : 요청과 관련된 필터 체인을 통해 요청을 전송
Filter : 프록시 요청이 전송되기 전/후로 나누어 로직 수행
Proxy Filter : 프록시 요청이 처리될 때 수행

핵심 요소

route

클라이언트 요청을 어느 서버로 라우팅 할 것 인지에 대한 내용이며, 목적지 URI, Predicates, Filter 들로 이루어져 있다.
라우팅 할 정보를 식별하기 위한 고유 ID이다.
정의된 모든 조건들을 충족했을 때만 매칭 된다.

predicate

요청이 어떤 path인지, 어떤 헤더를 가지고 있는지 등 다양한 HTTP 요청이 정의된 기준에 맞는지 조건 검사를 할 수 있다.

filter

Spring Framework에서 WebFilter의 인스턴스이다.
Filter에서는 요청 전, 후로 요청/응답을 추가 또는 수정 할 수 있다.

Custom Gateway Filter 구분

Global Filter

공통적으로 실행될 수 있는 Filter
gateway의 default-filter로 설정하여 모든 라우팅에 적용된다.

Pre Filter

요청에 대한 라우팅 전 사용자 정의 작업을 추가, 변경할 수 있다.
필요한 서비스에 각각 등록해야 한다.

Post Filter

라우팅 한 응답에 대한 사용자 정의 작업을 추가, 변경할 수 있다.
필요한 서비스에 각각 등록해야 한다.
@Component @Slf4j public class CustomFilter extends AbstractGatewayFilterFactory<CustomFilter.Config> { public CustomFilter() {super(Config.class);} @Override public GatewayFilter apply(Config config) { //Custom Pre Filter return (exchange, chain) ->{ ServerHttpRequest request = exchange.getRequest(); ServerHttpResponse response = exchange.getResponse(); log.info("Custom Pre filter: request id -> {}", request.getId()); //Custom Post Filter return chain.filter(exchange).then(Mono.fromRunnable(()->{ log.info("Custom Post filter: response code -> {}", response.getStatusCode()); })); }; } public static class Config{ //Put the configuration properties } }
Plain Text
복사

filter 설정 방법

Global Filter
Spring: cloud: gateway: default-filters: - name: GlobalFilter
Plain Text
복사
Custom Filter
Spring: cloud: gateway: routes: - id: service1 filters: - CustomPreFilter - id: service2 filters: - CustomPostFilter
Plain Text
복사

Custom filter 우선 순위

여러 개의 필터를 적용하거나 특정 필터의 순서가 로직 상 필수적일 경우, 필터 별 우선 순위를 지정할 수 있다.
우선 순위는 숫자가 작을 수록 빨리 수행한다.
application.yml
Spring: cloud: gateway: routes: - id: service1 - name: CustomFilter args: order: 10001 #우선 순위, 숫자 높을수록 늦게
Plain Text
복사
CustomFilter.java
@Slf4j @Component public class CustomFilter extends AbstractGatewayFilterFactory<CustomFilter.Config> { public CustomFilter() { super(Config.class); } @Override public GatewayFilter apply(Config config) { return new OrderedGatewayFilter((exchange, chain) -> { //user logic return chain.filter(exchange); }, config.order); } @Getter @Setter public static class Config { private int order; } }
Plain Text
복사

가설

라우트 설정 정보의 추가, 변경 없이 입력된 도메인에 맞는 서비스 pod 주소로 동적 라우팅 할 수 있다.

해결 방법

사전 합의 내용

기업 별 namespace 가 생성되고 namespace 별로 서비스 pod가 생성된다.
NDC 서비스 신청 시 접속 도메인은 신청 시 입력한 이름과 지정 도메인이 합쳐진 형태이다. (ex. 신청 이름 : abc → https://abc.nexr.kr)
기업 별 NDC 제공 서비스 접속 주소는 서브 도메인에 '-' 와 서비스 타입이 합쳐진 형태이다. (ex. NE Model Flow → https://abc-mlflow.nexr.kr)

요청 사항

OSS 중 mlflow, felice는 NDC Gateway의 인증을 체크하여 접속할 수 있어야 한다.
kt cloud와의 연동 시 동적으로 namespace를 생성/삭제할 수 있으며 namespace 별 서비스 pod 주소로 라우팅이 동적으로 처리되어야 한다.

개발 내용

application.yml

동적 서비스 라우팅이 필요한 라우트에 Custom Gateway Filter (UriHostPlaceholder) 를 추가한다.
라우팅 시 mlflow, felice 서비스 요청인지 체크를 하기 위해 args로 patterns 를 설정한다.
라우팅 될 uri 중 동적으로 변경이 되어야 하는 부분을 Custom Gateway Filter 내에서 치환 처리 할 수 있도록 dynamic-service, dynamic-realm 이름으로 지정한다.
해당 필터를 라우팅 직전에 처리하기 위해 우선 순위를 낮게 설정한다. (높은 숫자로 설정 시 늦게 처리)
Spring: cloud: gateway: routes: - filters: - SetRequestHeader=ndc-x-forwarded-host, {segment} - args: roles: - service-mlflow-user name: Security - StripPrefix=1 - args: order: 10001 #우선 순위, 숫자 높을수록 늦게 patterns: - ([a-z0-9A-Z]+)-mlflow.nexr.kr name: UriHostPlaceholder id: dynamic-routing-mlflow predicates: - Path=/** - Host={segment}-mlflow.nexr.kr uri: http://dynamic-service.dynamic-realm.svc.cluster.local:5000 #dynamic-service, dynamic-realm 고정 값, 변경 금지 - filters: - SetRequestHeader=ndc-x-forwarded-host, {segment} - args: roles: - service-felice-user name: Security - StripPrefix=1 - args: order: 10001 #우선 순위, 숫자 높을수록 늦게 patterns: - ([a-z0-9A-Z]+)-felice.nexr.kr name: UriHostPlaceholder id: dynamic-routing-felice predicates: - Path=/** - Host={segment}-felice.nexr.kr uri: http://dynamic-realm-dynamic-service-dynamic-service-cs.dynamic-realm.svc.cluster.local:5000 #dynamic-service, dynamic-realm 고정 값, 변경 금지
Plain Text
복사

UriHostPlaceholderGatewayFilterFactory.java

필터의 우선 순위를 지정하기 위해 apply 내에서 OrderedGatewayFilter 로 재 구현을 한다.
exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR) 로 yml 에 설정된 라우팅 될 uri 정보를 가져온다.
exchange.getRequiredAttribute(GATEWAY_ORIGINAL_REQUEST_URL_ATTR) 로 입력 받은 실제 요청 uri 정보를 가져온다.
입력 받은 요청 uri와 yml 에서 설정한 patterns가 일치하는지 체크한다.
입력 받은 요청 uri 중 치환 처리할 realm과 service type을 추출한다.
yml 에 지정한 dynamic-realm 부분에는 입력 uri에서 추출한 realm 정보로, dynamic-service 부분에는 service type 정보로 치환한다.
입력 받은 요청 path 와 parameter 를 체크하여 입력 받은 동일한 형태로 uri 를 만들어준다.
exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, newUri) 로 변경한 uri 정보로 라우팅 할 수 있도록 적용한다.
@Slf4j @Component public class UriHostPlaceholderGatewayFilterFactory extends AbstractGatewayFilterFactory<UriHostPlaceholderGatewayFilterFactory.Config> { public UriHostPlaceholderGatewayFilterFactory() { super(Config.class); } @Override public GatewayFilter apply(Config config) { return new OrderedGatewayFilter((exchange, chain) -> { URI uri = exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR); LinkedHashSet<URI> originalURI = exchange.getRequiredAttribute(GATEWAY_ORIGINAL_REQUEST_URL_ATTR); String requestHost = originalURI.stream().findFirst().get().getHost(); if(requestHost == null) { return chain.filter(exchange); } for (String pattern : config.getPatterns()) { if (requestHost.matches(pattern)) { String[] splitDomain = requestHost.split("\\."); String realm = splitDomain[0].split("-")[0]; String serviceType = splitDomain[0].split("-")[1]; UriComponents uriEncode = UriComponentsBuilder.fromUri(originalURI.stream().findFirst().get()).build(true); String convParams = ""; for(String key : uriEncode.getQueryParams().keySet()) { if(uriEncode.getQueryParams().get(key).stream().filter(d -> d != null).findFirst().orElse("").equals("")) { convParams = (convParams.equals("") ? "?" : convParams + "&") + key; } else { convParams = (convParams.equals("") ? "?" : convParams + "&") + key + "=" + uriEncode.getQueryParams().get(key).stream().findFirst().get(); } } String convUri = uri.getScheme() + "://" + ((uri.getAuthority().replaceAll("dynamic-realm", realm)).replaceAll("dynamic-service", serviceType)) + uriEncode.getPath() + convParams; URI newUri = null; try { newUri = new URI(convUri); } catch (URISyntaxException e) { // TODO Auto-generated catch block e.printStackTrace(); } log.debug(String.format("****** Encode URI: %s => %s", uri, newUri)); exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, newUri); return chain.filter(exchange); } } return chain.filter(exchange); },config.order); } @Getter @Setter public static class Config { private int order; private List<String> patterns = new ArrayList<>(); public void setPatterns(List<String> patterns) { this.patterns = patterns; } } }
Plain Text
복사

결과

mlflow 동적 라우팅 처리 로그

## Method GET 2022-12-12 13:39:29.255 DEBUG 1 --- [ parallel-26] g.h.PassCorsRoutePredicateHandlerMapping : Mapping [Exchange: GET https://kkk-mlflow.nexr.kr/ajax-api/2.0/preview/mlflow/registered-models/search?filter=name%20ilike%20%27%25%25%27&max_results=10&order_by=name%20ASC] to Route{id='dynamic-routing-mlflow', uri=http://dynamic-service.dynamic-realm.svc.cluster.local:5000, order=0, predicate=(Paths: [/**], match trailing slash: true && Hosts: [{segment}-mlflow.nexr.kr]), gatewayFilters=[[[SetRequestHeader ndc-x-forwarded-host = '{segment}'], order = 1], [nexr.cloud.gateway.filter.SecurityGatewayFilterFactory$$Lambda$1208/0x0000000840795440@4fdb099, order = 2], [[StripPrefix parts = 1], order = 3], [nexr.cloud.gateway.filter.UriHostPlaceholderGatewayFilterFactory$$Lambda$1210/0x0000000840794c40@1e36960f, order = 10001]], metadata={}} 2022-12-12 13:39:29.255 INFO 1 --- [ parallel-26] g.h.PassCorsRoutePredicateHandlerMapping : [33859c94-44, L:/10.233.98.142:8080 - R:/127.0.0.6:48015] Mapped to org.springframework.cloud.gateway.handler.FilteringWebHandler@24033f1e 2022-12-12 13:39:29.255 DEBUG 1 --- [ parallel-26] o.s.c.g.handler.FilteringWebHandler : Sorted gatewayFilterFactories: [[GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.RemoveCachedBodyFilter@1de6932a}, order = -2147483648], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.AdaptCachedBodyGlobalFilter@7a1f8def}, order = -2147482648], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.NettyWriteResponseFilter@7a8136b3}, order = -1], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.ForwardPathFilter@64412d34}, order = 0], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.GatewayMetricsFilter@38c2c309}, order = 0], [[SetRequestHeader ndc-x-forwarded-host = '{segment}'], order = 1], [nexr.cloud.gateway.filter.SecurityGatewayFilterFactory$$Lambda$1208/0x0000000840795440@4fdb099, order = 2], [[StripPrefix parts = 1], order = 3], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.RouteToRequestUrlFilter@14dc3f89}, order = 10000], [nexr.cloud.gateway.filter.UriHostPlaceholderGatewayFilterFactory$$Lambda$1210/0x0000000840794c40@1e36960f, order = 10001], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.config.GatewayNoLoadBalancerClientAutoConfiguration$NoLoadBalancerClientFilter@1a480135}, order = 10150], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.WebsocketRoutingFilter@4d178d55}, order = 2147483646], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.NettyRoutingFilter@bd1111a}, order = 2147483647], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.ForwardRoutingFilter@1706a5c9}, order = 2147483647]] 2022-12-12 13:39:29.255 DEBUG 1 --- [ parallel-26] f.UriHostPlaceholderGatewayFilterFactory : ****** Encode URI: http://dynamic-service.dynamic-realm.svc.cluster.local:5000/2.0/preview/mlflow/registered-models/search?filter=name%20ilike%20%27%25%25%27&max_results=10&order_by=name%20ASC => http://mlflow.kkk.svc.cluster.local:5000/ajax-api/2.0/preview/mlflow/registered-models/search?filter=name%20ilike%20%27%25%25%27&max_results=10&order_by=name%20ASC ## Method POST 2022-12-12 13:38:02.948 DEBUG 1 --- [ parallel-12] g.h.PassCorsRoutePredicateHandlerMapping : Route matched: dynamic-routing-mlflow 2022-12-12 13:38:02.948 DEBUG 1 --- [ parallel-12] g.h.PassCorsRoutePredicateHandlerMapping : Mapping [Exchange: POST https://kkk-mlflow.nexr.kr/ajax-api/2.0/preview/mlflow/runs/search] to Route{id='dynamic-routing-mlflow', uri=http://dynamic-service.dynamic-realm.svc.cluster.local:5000, order=0, predicate=(Paths: [/**], match trailing slash: true && Hosts: [{segment}-mlflow.nexr.kr]), gatewayFilters=[[[SetRequestHeader ndc-x-forwarded-host = '{segment}'], order = 1], [nexr.cloud.gateway.filter.SecurityGatewayFilterFactory$$Lambda$1208/0x0000000840795440@4fdb099, order = 2], [[StripPrefix parts = 1], order = 3], [nexr.cloud.gateway.filter.UriHostPlaceholderGatewayFilterFactory$$Lambda$1210/0x0000000840794c40@1e36960f, order = 10001]], metadata={}} 2022-12-12 13:38:02.948 INFO 1 --- [ parallel-12] g.h.PassCorsRoutePredicateHandlerMapping : [33859c94-31, L:/10.233.98.142:8080 - R:/127.0.0.6:48015] Mapped to org.springframework.cloud.gateway.handler.FilteringWebHandler@24033f1e 2022-12-12 13:38:02.948 DEBUG 1 --- [ parallel-12] o.s.c.g.handler.FilteringWebHandler : Sorted gatewayFilterFactories: [[GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.RemoveCachedBodyFilter@1de6932a}, order = -2147483648], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.AdaptCachedBodyGlobalFilter@7a1f8def}, order = -2147482648], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.NettyWriteResponseFilter@7a8136b3}, order = -1], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.ForwardPathFilter@64412d34}, order = 0], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.GatewayMetricsFilter@38c2c309}, order = 0], [[SetRequestHeader ndc-x-forwarded-host = '{segment}'], order = 1], [nexr.cloud.gateway.filter.SecurityGatewayFilterFactory$$Lambda$1208/0x0000000840795440@4fdb099, order = 2], [[StripPrefix parts = 1], order = 3], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.RouteToRequestUrlFilter@14dc3f89}, order = 10000], [nexr.cloud.gateway.filter.UriHostPlaceholderGatewayFilterFactory$$Lambda$1210/0x0000000840794c40@1e36960f, order = 10001], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.config.GatewayNoLoadBalancerClientAutoConfiguration$NoLoadBalancerClientFilter@1a480135}, order = 10150], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.WebsocketRoutingFilter@4d178d55}, order = 2147483646], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.NettyRoutingFilter@bd1111a}, order = 2147483647], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.ForwardRoutingFilter@1706a5c9}, order = 2147483647]] 2022-12-12 13:38:02.948 DEBUG 1 --- [ parallel-12] f.UriHostPlaceholderGatewayFilterFactory : ****** Encode URI: http://dynamic-service.dynamic-realm.svc.cluster.local:5000/2.0/preview/mlflow/runs/search => http://mlflow.kkk.svc.cluster.local:5000/ajax-api/2.0/preview/mlflow/runs/search
Plain Text
복사

felice 동적 라우팅 처리 로그

2022-12-12 13:42:50.934 DEBUG 1 --- [ parallel-26] g.h.PassCorsRoutePredicateHandlerMapping : Route matched: dynamic-routing-felice 2022-12-12 13:42:50.934 DEBUG 1 --- [ parallel-26] g.h.PassCorsRoutePredicateHandlerMapping : Mapping [Exchange: GET https://kkk-felice.nexr.kr/api/ver] to Route{id='dynamic-routing-felice', uri=http://dynamic-realm-dynamic-service-dynamic-service-cs.dynamic-realm.svc.cluster.local:8080, order=0, predicate=(Paths: [/**], match trailing slash: true && Hosts: [{segment}-felice.nexr.kr]), gatewayFilters=[[[SetRequestHeader ndc-x-forwarded-host = '{segment}'], order = 1], [nexr.cloud.gateway.filter.SecurityGatewayFilterFactory$$Lambda$1208/0x0000000840795440@3202d7c3, order = 2], [[StripPrefix parts = 1], order = 3], [nexr.cloud.gateway.filter.UriHostPlaceholderGatewayFilterFactory$$Lambda$1210/0x0000000840794c40@77004fe7, order = 10001]], metadata={}} 2022-12-12 13:42:50.934 INFO 1 --- [ parallel-26] g.h.PassCorsRoutePredicateHandlerMapping : [7d83a3e6-1, L:/10.233.98.142:8080 - R:/127.0.0.6:54054] Mapped to org.springframework.cloud.gateway.handler.FilteringWebHandler@24033f1e 2022-12-12 13:42:50.934 DEBUG 1 --- [ parallel-26] o.s.c.g.handler.FilteringWebHandler : Sorted gatewayFilterFactories: [[GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.RemoveCachedBodyFilter@1de6932a}, order = -2147483648], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.AdaptCachedBodyGlobalFilter@7a1f8def}, order = -2147482648], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.NettyWriteResponseFilter@7a8136b3}, order = -1], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.ForwardPathFilter@64412d34}, order = 0], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.GatewayMetricsFilter@38c2c309}, order = 0], [[SetRequestHeader ndc-x-forwarded-host = '{segment}'], order = 1], [nexr.cloud.gateway.filter.SecurityGatewayFilterFactory$$Lambda$1208/0x0000000840795440@3202d7c3, order = 2], [[StripPrefix parts = 1], order = 3], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.RouteToRequestUrlFilter@14dc3f89}, order = 10000], [nexr.cloud.gateway.filter.UriHostPlaceholderGatewayFilterFactory$$Lambda$1210/0x0000000840794c40@77004fe7, order = 10001], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.config.GatewayNoLoadBalancerClientAutoConfiguration$NoLoadBalancerClientFilter@1a480135}, order = 10150], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.WebsocketRoutingFilter@4d178d55}, order = 2147483646], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.NettyRoutingFilter@bd1111a}, order = 2147483647], [GatewayFilterAdapter{delegate=org.springframework.cloud.gateway.filter.ForwardRoutingFilter@1706a5c9}, order = 2147483647]] 2022-12-12 13:42:50.934 DEBUG 1 --- [ parallel-26] f.UriHostPlaceholderGatewayFilterFactory : ****** Encode URI: http://dynamic-realm-dynamic-service-dynamic-service-cs.dynamic-realm.svc.cluster.local:8080/ver => http://kkk-felice-felice-cs.kkk.svc.cluster.local:8080/api/ver
Plain Text
복사

결론

Spring Cloud Gateway는 기본적으로 엔드 포인트가 다른 경우 각각의 서비스로 라우팅 되도록 설정 파일(yml)에 라우트 정보를 입력해야 하는데, Custom Gateway Filter 를 사용하여 설정 변경 없이도 동적으로 라우팅 할 수 있도록 해결 했습니다.
추후에 Custom Gateway Filter나 Global Filter를 사용하여 요청/응답 전후 처리가 필요한 경우나 모니터링을 위한 Logging 처리를 적용해도 유용할 것이라 생각합니다.
다만, 하나의 라우트에 여러 개의 Custom Filter를 적용할 때 필터 처리 우선 순위에 따라 결과가 달라질 수 있는지 파악 후 order 설정을 필수로 해야 합니다.
또한 요청/응답의 내용을 추가하거나 변경하는 Custom Filter를 구현한다면 잘못된 결과 정보로 인해 디버깅 시 Custom Filter를 함께 디버깅을 해야 될 수 있으므로 프로세스와 관련된 로직 처리는 자제하는 것이 좋을 것 같습니다.
참고자료
본 기술블로그에 게재되는 모든 컨텐츠의 저작권은 케이티넥스알(kt NexR)에서 가지고 있으며, 동의 없는 컨텐츠 수정 및 무단 복제는 금하고 있습니다. 컨텐츠(글/사진/영상 등)를 공유하실 경우 반드시 출처를 밝혀주시기 바랍니다. Copyright(c) kt NexR, Inc. All Rights Reserved.