리액티브 어플리케이션 테스트 하기
리액티브 스트림을 테스트하기 어려운 이유
- 테스트 피라미드 제안을 따라야 모든 것을 제대로 검증할 수 있다.
- 테스트 피라미드?
- Google Test Automation Conference 에서 제안된 테스트 피라미드
- 전체 테스트 비중을 아래와 같은 수치로 구현하는 것이 권장됨
- End-To-End Testing (UI Testing) - 10%
- Integrating Testing - 20%
- Unit Testing - 70%
- 테스트 피라미드?
- 그런데 리액티브 프로그래밍 기법으로 작성한 코드는 테스트 하기가 쉽지가 않다.
- 우선 코드가 비동기식이라 반환된 값이 올바른지 확인하는 간단한 방법이 없다.
- 다행히 이에 대한 솔루션을 제공하고 있다.
StepVerifier를 이용한 리액티브 스트림 테스트
- 테스트 목적으로 리액터는
StepVerifier
가 포함된 reactor-test 모듈을 제공한다.testImplementation 'io.projectreactor:reactor-test'
StepVerifier
가 제공하는 연쇄형 API를 사용하면 어떤 종류의 Publisher라도 스트림 검증을 위한 프로우를 만들 수 있다.**StepVerifier
의 핵심 요소**- Publisher를 검증하는 기본적인 방법
create()
메소드가 중요하다.StepVerifier .create(Flux.just("A", "B")) // 플로 생성 .expectSubscription() .expectNext("A") .expectNext("B") .expectComplete() // 종료 시그널 존재 여부 검증 (ex, onComplete 등.. ) .verify(); // 검증을 실행하려면 (즉, 다른 말로 플로 생성을 구독하기 위해서) // 블로킹 호출 -> 실행 차단
- 엄청난 규모의 스트림 검증하기
- 위 방법 대로 하면 간단하지만 엄청난 규모의 스트림을 검증하는 것은 매우 어려울 것이다.
- 특정 값이 아닌 특정 양의 원소를 생성했는지 양의 개수를 검증하는 방법이 있다. →
expectNextCount()
- 하지만 count를 검증하는 것 만으로는 충분하지 않은 경우가 많다.
- 그리서 필터링 규칙과 일치하는지 확인하는 방법을 사용해도 된다.
StepVerifier .create(users) .expectSubscription() .recordWith(ArrayList::new) // 기록이 저장될 컬렉션 클래스를 정의 // recordWith 를 먼저 사용해야만 consumeNextWith가 작동할 수 있다. .expectNextCount(1) .consumeNextWith( // 지정된 Publisher가 게시한 모든 원소를 검증할 수 있다. user -> assertThat( user, everyItem(hasProperty("name", equalTo("jongin"))) ) ) .expectComplete() .verify();
- 다만 주의해야할 점은 멀티 스레드 Publisher의 경우에는 이벤트를 기록할 때 사용하는 컬렉션이 동시 액세스를 지원해야 하므로
ArrayList
대신ConcurrentLinkedQueue
를 사용하는게 더 스레드 세이프 하다. (동시성 이슈 방지)
- 다만 주의해야할 점은 멀티 스레드 Publisher의 경우에는 이벤트를 기록할 때 사용하는 컬렉션이 동시 액세스를 지원해야 하므로
- 조금더 유연하게 검증하기
expectNextMatches()
메소드를 사용해 matcher를 사용자가 직접 정의해 더 유연하게 테스트 코드를 작성할 수 도 있다.assertNext()
도 마찬가지로 사용자가 직접 assertion을 직접 작성할 수 있다.expectNextMatches()
과assertNext()
의 차이점은 전자는 참 또는 거짓을 반환해야 하는 조건이라면, 후자는 예외를 발생시키는 Consumer를 허용하고, 해당 Consumer에서 발생한 모든 AssertionError는 verify() 메소드에 의해 캡쳐되어 다시 예외를 발생시킨다.
- 오류에 대한 검증
.expectError()
를 사용하기StepVerifier .create(Flux.just("A", "B")) .expectSubscription() .expectError(RuntimeException.class) // Error 타입 까지 테스트 가능 .verify();
- Publisher를 검증하는 기본적인 방법
StepVerifier를 이용한 고급 테스트
-
Publisher를 테스트 할 때 가장 중요한 것.
- 그것이 무한한지 확인한다.
- 배압을 확인한다.
-
무한한 스트림 테스트
- onComplete() 메서드를 호출하지 않는다는 것을 의미한다.
- 위에서 배웠던 테스트 기법을 더이상 사용하지 못함
- 문제는 StepVerifier가 완료신호를 무한정 기다릴 것이라는데 있다.
- 해결책
- 소스에서 구독을 취소해버린다.
.thenCanel()
- 소스에서 구독을 취소해버린다.
-
배압을 확인한다.
- 가장 단순한 방법은 Flux의
.onBackpressureBuffer()
메소드를 통해서 다운스트림을 보호하는 것 - 위 배압 전략으로 시스템이 동작하는지 보려면 → 구독자의 요청 수량을 직접 제어 해야함.
- 이때 사용하는 메소드는
.thenRequest()
이다.StepVerifier .create(websocketPublisher.onBackpressureBuffer(5), 0) // 5 : 최대 개수 (다운 스트림 보호), 0 : 초기 구독 요청 개수 .expectSubscription() .thenRequest(1) // 1개 요청 .expectNext("Connected") .thenRequest(1) // 1개 요청 .expectNext("Price : 12.00") .expectError(RuntimeException.class) .verify();
- 이때 사용하는 메소드는
- 가장 단순한 방법은 Flux의
- 검증 후에 추가 작업을 실행 할 수 있는 기능이 필요할 때?
- ex) 프로세스를 생성하는 원소가 추가적인 외부 상호 작용을 필요로 하는 경우 →
.then()
메소드 사용StepVerifier .create(userRepository.findAllById(idsPublisher)) /** * ID들을 스트림에 게시를 하고 기 후에 ID가 userRepository.findAllById(idsPublisher)를 * 통해 구독이 된 후 userRepository가 예상대로 동작했는지 확인할 수 있다. */ .expectSubscription() .then(() -> idsPublisher.next("1")) .assertNext(user -> assertThat(user, hasProperty("id", equalTo("1")))) // 바로 윗 라인 검증 .then(() -> idsPublisher.next("2")) .assertNext(user -> assertThat(user, hasProperty("id", equalTo("2")))) // 바로 윗 라인 검증 .then(idsPublisher::complete) .expectComplete() // 바로 윗 라인 검증 .verify();
- 이 방법은 구독이 실제로 발생한 이후의 이벤트를 테스트할 수 있어서 매우 의미가 있다.
- ex) 프로세스를 생성하는 원소가 추가적인 외부 상호 작용을 필요로 하는 경우 →
가상 시간 다루기
-
코드를 작성하다 보면 오랜 지연시간을 가지는 비즈니스 로직들이 있다
-
그런데 단순히 위에 처럼 테스트 코드를 작성하다 보면 테스트 하는 시간이 엄청 오래 걸린다
-
최근 CI/CD 트렌드에 적합하지 않는다.
-
이 문제를 해결하기 위해 리액터 테스트 모듈은 실제 시간을 가상 시간으로 대체하는 기능을 제공한다.
StepVerifier.withVirtualTime(() -> sendWithInterval()) // 시나리오 검증 로직 생략
withVirtualTime()
메서드를 사용하면VirtualTimeScheduler
를 통해 리액터의 모든 스케줄러를 명시적으로 대체가 가능하다.- 이런 대체 방법은 Flux.interval 이 해당 스케줄러에서 실행됨을 의미한다.
- 예시
StepVerifier .withVirtualTime(() -> sendWithInterval()) .expectSubscription() .then( () -> { VirtualTimeScheduler // VirtualTimeScheduler이 스케줄러를 사용해야함 .get() .advanceTimeBy(Duration.ofMinutes(3)) // 3분 후로 이동 } ) .expectNext("a", "b", "c") .expectComplete() .verify();
.then( () -> { VirtualTimeScheduler // VirtualTimeScheduler이 스케줄러를 사용해야함 .get() .advanceTimeBy(Duration.ofMinutes(3)) // 3분 후로 이동 } ) 이 부분을 .thenAwait(Duration.ofMillis(3)) 이렇게 간소화 할 수 있다.
- 이런 경우
.verify()
메소드는 실제로 검증 프로세스가 실행된 시간을 반환하고, 첫번째 파라미터로는 검증에 소요되는 시간을 제한하는 값을 넣을 수 도 있다.- 시간 내에 완료하지 못하면 AssertionError가 발생한다.
- 이런 경우
-
주의점
StepVerifier
가 시간을 충분히 앞당기지 못한다면 테스트가 영원히 중단될 수 있다.
-
지정된 대기 시간 동안 이벤트가 없을을 확인하는 것이 중요하다면
expectNoEvents()
라는 메서드를 사용하면 된다.
리액티브 컨텍스트 검증하기
- 리액터 컨덱스트를 검증하는 일도 중요하다.
- 일단 접근 가능한 Context 인스턴스가 있는지 검증하는게 중요하다.
.expectAccessibleContext()
이 메소드로 검증이 가능하다. - 그 후
hasKey()
와 같은 메서드로 현재 컨텍스트에 대한 자세한 검증을 해야하고 - 컨텍스트 검증을 종료하려면 빌더에
.then()
메서드를 추가하면 된다.StepVerifier .create(securityService.login("admin", "admin")) .expectSubscription() .expectAccessibleContext() .hasKey("security") .then() .expectComplete() .verify()
웹플럭스 테스트
- 이제 부터는 단위 테스트가 아니라 컴포넌트 테스트 혹은 통합 테스트가 된다.
WebTestClient를 이용해 컨트롤러 테스트 하기
-
구현은 다음과 같다.
-
결제에 대한 컨트롤러가 아래와 같이 있다고 가정하고
@RestController
@RequestMapping("/payments")
public class PaymentController {
private final PaymentService paymentService;
public PaymentService getPaymentService(PaymentService paymentService) {
this.paymentService = paymentService;
}
@GetMapping("/")
public Flux<Payment> list() {
return paymentService.list();
}
@PostMapping("/")
public Mono<String> send(Mono<Payment> payment) {
return paymentService.send(payment);
}
}
- 검증의 첫번째 단계는 서비스의 결과로 웹 엔드포인트에서 발생하는 모든 기댓값을 다 작성하는 것
- spring-test 모듈에는 웹플럭스 엔드포인트와의 상호 작용을 위한
WebTestClient
클래스가 추가됨
- 우리가 자주 쓰는
MockMvc
와 유사함
-
그럼 다음은 검증하는 코드이다.
PaymentService paymentService = Mockito.mock(PaymentService.class);
PaymentController paymentController = new PaymentController(paymentService);
prepareMockResponse(paymentService);
WebTestClient
.bindToController(paymentController)
.build()
.get()
.uri("/payments/")
.exchange()
.expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)
.expectStatus().is2xxSuccessful()
.returnResult(Payment.class)
.getResponseBody() // 여기서 부터 Flux 가 생겼으니
.as(StepVerifier::create) // 이전에 StepVerifier로 검증한게 똑같다
.expectNextCount(5)
.expectComplete()
.verify()
- 헤더 및 상태를 검증했고
getResponseBody()
를 통해 Flux를 얻어 StepVerifier
로 검증이 가능하다
paymentService
를 목킹했고, paymentController
를 테스트할 때 실제로 외부 서비와 통신하지 않는다.
-
그러나 시스템 무결성을 확인하려면 컨트롤러 레이어만이 아니라 전체 컴포넌트를 실행해봐야 한다.
- 또한 이러한 통합 테스트를 실행하려면 전체 어플리케이션을 시작해야한다.
- 즉 서비스 로직도 테스트 해봐야한다는 뜻 (외부 통신도 테스트 해봐야한다)
-
그래서 이러한 용도로 @AutoConfigureWebTestClient
와 @SpringBootTest
를 사용해야한다.
-
결제 서비스의 PaymentService
비즈니스 로직을 살펴보고 테스트 해보자
서비스 로직
@Service
public class DefaultPaymentService implements PaymentService {
private final PaymentRepository paymentRepository;
private final WebClient webClient;
public PaymentRepository getPaymentRepository(
PaymentRepository paymentRepository,
WebClient webClient
) {
this.paymentRepository = paymentRepository;
this.webClient = webClient;
}
@Override
public Mono<String> send(Mono<Payment> payment) {
return payment
.zipWith(
ReactiveSecurityContextHolder.getContext(),
(p, c) -> p.withUser(c.getAuthentication().getName())
)
.flatMap(
p -> webClient
.post()
.syncBody(p)
.retrieve()
.bodyToMono(String.class)
.then(paymentRepository.save(p))
)
.map(Payment::getId);
}
@Override
public Flux<Payment> list() {
return ReactiveSecurityContextHolder
.getConext()
.map(SecurityContext::getAuthentication)
.map(Principal::getName)
.flatMapMany(paymentRepository::findAllByUser);
}
}
- 중요한건 리스트를 가져오는 메소드는 DB와만 상호작용한다. 다만 결제를 처리하는 로직은 DB 뿐 아니라 WebClient를 통해 외부 시스템과의 통신이 필요하다.
- DB는 테스트를 위한 임베디드 모드를 지원하는 리액티브 스프링 데이터 MongoDB 모듈을 사용해서 괜찮은데
- 외부 연동은 WireMock과 같은 도구로 외부 서비스를 모킹하거나 발신 HTTP 요청을 모킹해야한다.
- WireMock를 이용한 목 서비스는 WebMVC와 웹플럭스 모두에서 사용가능하다.
-
HTTP를 이용한 외부 호출에 대한 응답을 모킹해보자
- 개발자가 WebClient.Builder를 통해 외부요청 코드를 작성했다면 요청 처리에 필수적인 역할을 하는
ExchangeFunction
을 모킹하면 된다.public interface ExchangeFunction {
Mono<ClientRequest> exchange(ClientRequest request);
...
}
-
다음 코드와 같은 테스트 설정을 사용하면 WebClient.Builder
를 커스터마이즈할 수 있을 뿐 아니라 목을 만들거나 ExchangeFunction에 대한 스텁 객체를 만들 수도 있다.
- 종종 헷갈리는 Stub과 Mock의 차이?
```java
Mock
- 가짜
- 실제와와 동일한 기능을 하진 않지만 대략 이렇게 생겼고 크기는 대충 이렇다, 대충 이런 기능이
이렇게 동작할 것이라고 알려주는 용도
- 테스트에서는 호출시 동작이 잘 되었는지를 확인하는데 쓰인다.
Stub
- 전체 중 일부라는 뜻
- 모든 기능 대신 일부 기능에 집중해 임의로 구현한다.
- 일부 기능이라 하면 내가 지금 테스트하고자 하는 기능을 의미
Stub 기반의 코드는 상태기반 테스트
Mock 기반의 테스트는 행위 기반 테스트
```
@TestConfiguration
public class TestWebClientBuilderConfiguration {
@Bean
public WebClientCustomizer testWebClientCustomizer(
ExchangeFunction exchangeFunction
) {
return webClientBuilder -> webClientBuilder.exchangeFunction(exchangeFunction);
}
}
-
이렇게 하면 ClientRequest의 정확성을 검증할 수 있게 된다.
-
아울러 ClientResponse를 적절히 구현함으로써 네트워크 활동 및 외부 서비스와 상호 작용을 테스트 할 수 있다.
-
코드는 다음과 같다.
@ImportAutoConfiguration({
TestWebClientBuilderConfiguration.class
})
@RunWuth(SpringRunner.class)
@WebFluxTest
@AutoConfigureWebTestClient
public class PaymentControllerTests {
@Autowired
WebTestClient client;
@MockBean
ExchangeFunction exchangeFunction; // 모킹
@Test
@WithMockUser
public void verifyPaymentsWasSendAndStored() {
// stub
Mockito
.when(exchangeFunction.exchange(Mockito.any()))
.thenReturn(Mono.just(MockClientResponse.create(201, Mono.empty())));
client.post()
.uri("/payments/")
.syncBody(new Payment())
.exchange() // stub 동작
.expectStatus().is2xxSuccessful()
.returnResult(String.class)
.getResponseBody()
.as(StepVerifier::create) // 여기서 부터는 원래 했던 테스트
.expectNextCount(1)
.expectComplete()
.verify();
Mockito.verify(exchangeFunction).exchange(Mockito.any());
}
}
-
위 코드의 한계 WebClient를 통해서 외부연동한다는 전제하에 테스트 코드를 작성한것 Http Client를 바꾸면 테스트 코드가 동작 안할 수 있음
- 그러므로 WireMock 같은 모듈을 사용해 외부 서비스를 모킹하는 것이 바람직하다고 함
-
이 방식은 실제 Http 클라리언트로 통신을 시도하고 요청-응답 값들을 테스트할 수 있다.
실전! 스프링 5를 활용한 리액티브 프로그래밍 책을 보고 정리한 내용입니다.
Author And Source
이 문제에 관하여(리액티브 어플리케이션 테스트 하기), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다
https://velog.io/@manofbell/리액티브-어플리케이션-테스트-하기
저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념
(Collection and Share based on the CC Protocol.)
구현은 다음과 같다.
결제에 대한 컨트롤러가 아래와 같이 있다고 가정하고
@RestController
@RequestMapping("/payments")
public class PaymentController {
private final PaymentService paymentService;
public PaymentService getPaymentService(PaymentService paymentService) {
this.paymentService = paymentService;
}
@GetMapping("/")
public Flux<Payment> list() {
return paymentService.list();
}
@PostMapping("/")
public Mono<String> send(Mono<Payment> payment) {
return paymentService.send(payment);
}
}
- 검증의 첫번째 단계는 서비스의 결과로 웹 엔드포인트에서 발생하는 모든 기댓값을 다 작성하는 것
- spring-test 모듈에는 웹플럭스 엔드포인트와의 상호 작용을 위한
WebTestClient
클래스가 추가됨- 우리가 자주 쓰는
MockMvc
와 유사함
- 우리가 자주 쓰는
그럼 다음은 검증하는 코드이다.
PaymentService paymentService = Mockito.mock(PaymentService.class);
PaymentController paymentController = new PaymentController(paymentService);
prepareMockResponse(paymentService);
WebTestClient
.bindToController(paymentController)
.build()
.get()
.uri("/payments/")
.exchange()
.expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)
.expectStatus().is2xxSuccessful()
.returnResult(Payment.class)
.getResponseBody() // 여기서 부터 Flux 가 생겼으니
.as(StepVerifier::create) // 이전에 StepVerifier로 검증한게 똑같다
.expectNextCount(5)
.expectComplete()
.verify()
- 헤더 및 상태를 검증했고
getResponseBody()
를 통해 Flux를 얻어StepVerifier
로 검증이 가능하다 paymentService
를 목킹했고,paymentController
를 테스트할 때 실제로 외부 서비와 통신하지 않는다.
그러나 시스템 무결성을 확인하려면 컨트롤러 레이어만이 아니라 전체 컴포넌트를 실행해봐야 한다.
- 또한 이러한 통합 테스트를 실행하려면 전체 어플리케이션을 시작해야한다.
- 즉 서비스 로직도 테스트 해봐야한다는 뜻 (외부 통신도 테스트 해봐야한다)
그래서 이러한 용도로 @AutoConfigureWebTestClient
와 @SpringBootTest
를 사용해야한다.
결제 서비스의 PaymentService
비즈니스 로직을 살펴보고 테스트 해보자
서비스 로직
@Service
public class DefaultPaymentService implements PaymentService {
private final PaymentRepository paymentRepository;
private final WebClient webClient;
public PaymentRepository getPaymentRepository(
PaymentRepository paymentRepository,
WebClient webClient
) {
this.paymentRepository = paymentRepository;
this.webClient = webClient;
}
@Override
public Mono<String> send(Mono<Payment> payment) {
return payment
.zipWith(
ReactiveSecurityContextHolder.getContext(),
(p, c) -> p.withUser(c.getAuthentication().getName())
)
.flatMap(
p -> webClient
.post()
.syncBody(p)
.retrieve()
.bodyToMono(String.class)
.then(paymentRepository.save(p))
)
.map(Payment::getId);
}
@Override
public Flux<Payment> list() {
return ReactiveSecurityContextHolder
.getConext()
.map(SecurityContext::getAuthentication)
.map(Principal::getName)
.flatMapMany(paymentRepository::findAllByUser);
}
}
- 중요한건 리스트를 가져오는 메소드는 DB와만 상호작용한다. 다만 결제를 처리하는 로직은 DB 뿐 아니라 WebClient를 통해 외부 시스템과의 통신이 필요하다.
- DB는 테스트를 위한 임베디드 모드를 지원하는 리액티브 스프링 데이터 MongoDB 모듈을 사용해서 괜찮은데
- 외부 연동은 WireMock과 같은 도구로 외부 서비스를 모킹하거나 발신 HTTP 요청을 모킹해야한다.
- WireMock를 이용한 목 서비스는 WebMVC와 웹플럭스 모두에서 사용가능하다.
HTTP를 이용한 외부 호출에 대한 응답을 모킹해보자
- 개발자가 WebClient.Builder를 통해 외부요청 코드를 작성했다면 요청 처리에 필수적인 역할을 하는
ExchangeFunction
을 모킹하면 된다.public interface ExchangeFunction { Mono<ClientRequest> exchange(ClientRequest request); ... }
-
다음 코드와 같은 테스트 설정을 사용하면
WebClient.Builder
를 커스터마이즈할 수 있을 뿐 아니라 목을 만들거나 ExchangeFunction에 대한 스텁 객체를 만들 수도 있다.
- 종종 헷갈리는 Stub과 Mock의 차이?```java Mock - 가짜 - 실제와와 동일한 기능을 하진 않지만 대략 이렇게 생겼고 크기는 대충 이렇다, 대충 이런 기능이 이렇게 동작할 것이라고 알려주는 용도 - 테스트에서는 호출시 동작이 잘 되었는지를 확인하는데 쓰인다. Stub - 전체 중 일부라는 뜻 - 모든 기능 대신 일부 기능에 집중해 임의로 구현한다. - 일부 기능이라 하면 내가 지금 테스트하고자 하는 기능을 의미 Stub 기반의 코드는 상태기반 테스트 Mock 기반의 테스트는 행위 기반 테스트 ```
@TestConfiguration public class TestWebClientBuilderConfiguration { @Bean public WebClientCustomizer testWebClientCustomizer( ExchangeFunction exchangeFunction ) { return webClientBuilder -> webClientBuilder.exchangeFunction(exchangeFunction); } }
-
이렇게 하면 ClientRequest의 정확성을 검증할 수 있게 된다.
-
아울러 ClientResponse를 적절히 구현함으로써 네트워크 활동 및 외부 서비스와 상호 작용을 테스트 할 수 있다.
-
코드는 다음과 같다.
@ImportAutoConfiguration({ TestWebClientBuilderConfiguration.class }) @RunWuth(SpringRunner.class) @WebFluxTest @AutoConfigureWebTestClient public class PaymentControllerTests { @Autowired WebTestClient client; @MockBean ExchangeFunction exchangeFunction; // 모킹 @Test @WithMockUser public void verifyPaymentsWasSendAndStored() { // stub Mockito .when(exchangeFunction.exchange(Mockito.any())) .thenReturn(Mono.just(MockClientResponse.create(201, Mono.empty()))); client.post() .uri("/payments/") .syncBody(new Payment()) .exchange() // stub 동작 .expectStatus().is2xxSuccessful() .returnResult(String.class) .getResponseBody() .as(StepVerifier::create) // 여기서 부터는 원래 했던 테스트 .expectNextCount(1) .expectComplete() .verify(); Mockito.verify(exchangeFunction).exchange(Mockito.any()); } }
-
위 코드의 한계 WebClient를 통해서 외부연동한다는 전제하에 테스트 코드를 작성한것 Http Client를 바꾸면 테스트 코드가 동작 안할 수 있음
- 그러므로 WireMock 같은 모듈을 사용해 외부 서비스를 모킹하는 것이 바람직하다고 함
-
이 방식은 실제 Http 클라리언트로 통신을 시도하고 요청-응답 값들을 테스트할 수 있다.
-
실전! 스프링 5를 활용한 리액티브 프로그래밍 책을 보고 정리한 내용입니다.
Author And Source
이 문제에 관하여(리액티브 어플리케이션 테스트 하기), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@manofbell/리액티브-어플리케이션-테스트-하기저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)