서론
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. |