Web/Spring 2021. 11. 17. 18:21

웹플럭스에서 블록킹 연산을 발생시키는 채널이 있다면, 이벤트 채널을 관리하는 이벤트 루프 자체에 블럭킹이 발생하기 때문에 전체적으로 요청 처리를 하나도 못하는 문제가 발생할 수 있다. 그렇기 때문에 블럭킹을 발생시키는 연산이 있을 경우 스케쥴을 분리시켜주는 것이 좋고, 실제로 리액터에서도 이러한 것을 고려해 스케쥴러 생성 팩토리 메서드를 제공한다.

위 두개의 팩토리 메서드는 non-blocking 연산을 위한 스케쥴러 팩토리 메서드이다. 오래 걸리는 연산 등을 이벤트 루프 쓰레드에서 분리하고 싶을 때 사용하며, 블럭킹 연산이 포함되지 않은 연산에서만 사용해야한다. 만약 블록킹 연산에 대해 스케쥴을 분리하고 싶다면 boundedElastic()을 이용하면 된다.

 

사용법은 아래와 같다.

 

 

그런데, 블록킹 연산에 대해 스케쥴 분리하면 된다는 것은 알겠는데 이걸 어떻게 일일이 찾아서 분리해줄까? 이것은 메서드 시그니쳐로 약속하자.

 

 

위 두개의 메서드는 동기 메서드이며, 첫번째 메서드는 동기코드이며 블럭킹을 유발시키는 코드이다.(InterruptedException 발생) 세번째 메서드는 블록킹 콜이 없는 비동기 메서드이다. 첫번째 두번째 메서드는 사용하는 쪽에서 스케쥴 분리를 신경쓰면 되고 세번째 메서드는 해당 메서드 안에서 스케쥴 분리 처리를 해주어야한다. 근데 진짜 위 메서드 시그니처를 지키면 블록킹 콜이 발생하지 않을까?.. 이것을 찾기위한 도구로 BlockHound가 있다.

 

 

BlockHound는 운영환경에 같이 띄우면 안된다.(실제 바이트 코드등의 영향을 줄 수 있어서) 그렇기에 테스트 코드와 결합하여 사전에 블록킹 연산을 찾아내는 용도로만 사용하자 !(비효율적인 연산 등을 잡는 역할로는 사용 불가)

 

출처: IfKakao

posted by 여성게
:

오늘 다루어볼 내용은 자바에서 Stream(java 8 stream, reactor ...)을 사용할때 유용한 팁이다. 많은 사람들이 아는 해결법일 수도 있고, 혹은 필자와 같은 스타일을 선호하지 않는 사람들도 있을 것이다. 하지만 필자가 개발할때 이러한 상황에서 조금 유용했던 Stream pipeline Tip을 간단히 소개한다.

 

중첩이 많고, 이전 스트림보다 더 이전의 스트림의 결과 값을 사용해야 할때

상황은 아래와 같은데, 간단히 바로 이전 스트림의 결과가 아닌, 더 전의 스트림 원자를 로직에서 사용하려면 대게 아래와 같이 스트림 파이프 라인을 이어나간다.

 

Mono.just("id")
	.flatMap(id -> 
		return Mono.just(service.getById(id))
				.map(entity -> {
					...
				})
	)

 

파이프라인의 시작인 id 값을 파이프라인의 중간에서 사용하려면 Stream의 pipeline을 점점 안으로 중첩해 나가면서 사용해야한다. 이렇게 된다면 복잡한 로직일 수록 점점 안쪽으로 파고드는 파이프라인이 될 것이다. 그렇다면 훨씬 가독성 좋은 코드는 어떻게 작성해 볼 수 있을까?

 

Tuple을 사용해서 넘겨주자.

reactor에 있는 Tuple을 사용해서 이전 파이프라인의 값을 뒤로 넘겨줘보자.

 

void stream() {
	Mono.just("id")
		.flatMap(str -> {
			...
			return Mono.just(Tuples.of(str, "str2"));
		})
		.flatMap(tuple -> {
			final String str = tuple.getT1();
			...
			return Mono.just("result"); 
		})
}

 

위처럼 튜플을 이용하여 해당 스트림의 결과와 이전 스트림의 결과가 나중에 쓰일 수 있도록 튜플에 값을 넣어서 넘겨준다. 이렇게 파이프라이닝하면 같은 depth로 파이프라인을 이어나갈 수 있기 때문에 가독성이 훨씬 좋다. 만약 튜플을 사용하지 않았다면 점점 depth가 깊어지는 중첩 파이프라인을 이어나가야하기 때문에 가독성도 훨씬 떨어지게 될 것이다.

 

여기까지 중첩이 많고, 이전 스트림의 결과를 미래 파이프라인에서 사용할 때 이용할 수 있는 팁이었다.

posted by 여성게
:
Web/Spring 2020. 3. 20. 21:44

 

이전 시간에 DB관련 테스트 작성하는 법을 다루어 봤는데, 이번 시간에는 Webflux handler 테스트 코드를 한번 작성해보려고 한다. 이전까지는 service 단까지만 테스트를 모듈별로 작성하였지만, 핸들러로 인입하여 한번에 모든 로직을 돌려보는 테스트는 직접 넣어보지 않았던 것 같다.(사실 핸들러, 컨트롤러 테스트가 이것저것 설정해야할 것들이 많아서..)

 

간단히 바로 예제를 다루어본다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Configuration
public class RouteConfig {
 
    @Bean
    public RouterFunction<ServerResponse> routeFunction(TestHandler testHandler) {
        return route()
                .nest(path("/"), builder -> builder
                        .GET("/test", accept(APPLICATION_JSON), testHandler::testHandler)
                ).build();
    }
}
 
@Component
public class TestHandler {
 
    @Autowired
    private TestService testService;
 
    public Mono<ServerResponse> testHandler(ServerRequest serverRequest) {
        return ServerResponse.ok().body(BodyInserters.fromProducer(testService.testService(), String.class));
    }
}
 
@Service
public class TestService {
    public Mono<String> testService() {
        return Mono.just("testResponse");
    }
}
cs

 

간단하게 handler와 service 클래스를 구현하였다. 단순히 String을 반환하고 있다. 해당 핸들러를 테스트하는 코드는 아래와 같다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@SpringBootTest(classes = {
        TestHandler.class,
        RouteConfig.class,
        TestService.class
})
@MockBeans({
})
class HandlerTest {
 
    private WebTestClient client;
    @Autowired
    private RouteConfig routeConfig;
    @Autowired
    private TestHandler testHandler;
 
    private static final String ENDPOINT = "/test";
 
    @BeforeEach
    public void beforeTest() {
        client = WebTestClient
                .bindToRouterFunction(routeConfig.routeFunction(testHandler))
                .build();
    }
 
    @Test
    public void handlerTest() {
        client.get()
                .uri(ENDPOINT)
                .exchange()
                .expectStatus().isOk()
                .expectBody(String.class)
                .value(value -> {
                    assertEquals(value, "testResponse");
                });
    }
 
}
cs

 

spring MVC와는 조금 다를 수 있다. 해당 테스트 코드를 간단하게 설명하면 우리가 작성한 routeConfig를 WebTestClient에 바인딩해준다. 그 이후에 해당 클라이언트를 이용하여 routeConfig 내에 존재하는 라우팅 엔드포인트에 요청을 날리면 핸들러 로직을 수행후 응답을 내려준다. 그리고 해당 응답을 이용하여 원하는 응답이 나왔는지 assert 해볼 수 있다. 위는 정상적인 경우만 다루어보았고, 한번 없는 요청을 보내보자.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@SpringBootTest(classes = {
        TestHandler.class,
        RouteConfig.class,
        TestService.class
})
@MockBeans({
})
class HandlerTest {
 
    private WebTestClient client;
    @Autowired
    private RouteConfig routeConfig;
    @Autowired
    private TestHandler testHandler;
 
    private static final String ENDPOINT = "/test";
    private static final String INVALID_ENDPOINT = "/invalid";
 
    @BeforeEach
    public void beforeTest() {
        client = WebTestClient
                .bindToRouterFunction(routeConfig.routeFunction(testHandler))
                .build();
    }
 
    @Test
    public void handlerTest() {
        client.get()
                .uri(ENDPOINT)
                .exchange()
                .expectStatus().isOk()
                .expectBody(String.class)
                .value(value -> {
                    assertEquals(value, "testResponse");
                });
    }
 
    @Test
    public void notFoundHandlerMappingTest() {
        client.get()
                .uri(INVALID_ENDPOINT)
                .exchange()
                .expectStatus().is4xxClientError();
    }
 
}
cs

 

테스트를 하나더 추가하였다. 이 요청 url은 존재하지 않는 handler mapping이므로 정상적이라면 404 not found를 응답코드로 내려줄 것이라고 기대하고 테스트코드를 작성하였다.

 

실행해보면 테스트가 정상적으로 통과한다.

 

여기까지 간단하게 웹플럭스 핸들러 테스트를 작성해보았다. 아주 간단하게만 작성하였지만 테스트에 고려할만한 TC(Test Case)는 아주 많은 것같다. 400 예외를 고려한 테스트, 혹은 특정 예외를 고려한 테스트 혹은 모든 로직이 잘 동작하는 테스트 마지막으로 파라미터마다 동작이 다른것을 테스트 해보는 경우등 아주 많은 경우가 있을 것이다. 모든 테스트케이스를 잘 고려해 테스트를 짜보자.

 

마지막으로 만약 애플케이션에 ErrorHandler가 적용되어있다면 위와 같이 짠 테스트 코드는 ErrorHandler가 내뱉는 응답을 받지 못한다. 왜냐 테스트 컨텍스트 빈으로 등록하지 않았기 때문이다. 만약 파라미터가 잘못들어와 로직상에서 예외를 던졌고, 만약 그 예외를 ErrorHandler가 받아서 400이라는 HttpStatusCode로 리턴하고 있는데 그것을 기대하고 파라미터가 잘못들어온 경우를 is4xxClientError()로 잡으면 테스트는 통과하지 못한다. 그 이유는 WebTestClient는 500에러를 내뿜고 있기 때문이다. 그 이유는 위에 말한 것과 같이 ErrorHandler를 테스트 컨텍스트 빈으로 등록하지 않아서 4xx로 변환되지 못하고 애플리케이션 로직상에서 나는 예외(500)로 인식하기 때문이다. 

posted by 여성게
:
Web/Spring 2020. 2. 20. 00:46

 

오늘 다루어볼 내용은 Webflux에서 사용되는 WebClient가 onCancel이 호출되는 시점이다.  이번에 애플리케이션을 개발하면서 많은 서비스가 통신을 하는데, 필자가 개발한 애플리케이션의 jaeger 로그를 보니 WebClient가 cancelled("The subscription was cancelled") 되었다는 로그가 찍혀있었다. 무슨 이유로 이러한 로그가 남는지 확인했더니, 아래와 같은 이유였다.

 

"AServer -> BServer ->CServer"

 

이렇게 3개의 서버가 통신하는 상황인데, AServer의 Client는 ReadTimeOut이 3초이고, BServer는 ReadTimeOut이 5초이다. 그런데 CServer가 응답을 주는데 4초가 걸렸다면? 

 

이런 상황에서 발생하는 것이 cancelled 상황이다. AServer는 이미 Readtimeout이 나버려서 500 에러가 발생하였고, 이제 더이상 BServer는 응답을 받아올 필요가 없어졌으므로 subscribe를 cancel을 해버리는 것이다. 간단히 위와 같은 상황을 샘플 앱으로 작성해 보았다.

 

[AServer:9090]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Configuration
public class WebClientConfig {
 
    @Bean
    public WebClient.Builder webClientFactory(){
        TcpClient tcpClient = TcpClient.create()
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2000)
                .doOnConnected(connection -> connection.addHandlerLast(new ReadTimeoutHandler(3000, TimeUnit.MILLISECONDS)));
 
        return WebClient.builder()
                .clientConnector(new ReactorClientHttpConnector(HttpClient.from(tcpClient)));
    }
 
    @Bean
    public WebClient webClient() {
        return webClientFactory().build();
    }
 
}
 
@Slf4j
@RestController
@SpringBootApplication
@RequiredArgsConstructor
public class ExamApplication {
 
    public static void main(String[] args) {
        SpringApplication.run(ExamApplication.class, args);
    }
 
    private final WebClient client;
 
    @GetMapping("/")
    public Mono<String> hello(){
        log.info("exam server accept request");
        return client.get("http://localhost:8080/")
                .retrieveBodyToMono(String.class);
}
 
 
 
cs

 

[BServer:8080]

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Configuration
public class WebClientConfig {
 
    @Bean
    public WebClient.Builder webClientFactory(){
        TcpClient tcpClient = TcpClient.create()
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2000)
                .doOnConnected(connection -> connection.addHandlerLast(new ReadTimeoutHandler(5000, TimeUnit.MILLISECONDS)));
 
        return WebClient.builder()
                .clientConnector(new ReactorClientHttpConnector(HttpClient.from(tcpClient)));
    }
 
    @Bean
    public WebClient webClient() {
        return webClientFactory().build();
    }
 
}
 
@Slf4j
@RestController
@SpringBootApplication
@RequiredArgsConstructor
public class ClientApplication {
 
    public static void main(String[] args) {
        SpringApplication.run(ClientApplication.class, args);
    }
 
    private final WebServiceClient client;
 
    @GetMapping("/")
    public Mono<String> hello(){
        log.info("client server accept request");
        return client.get("http://localhost:7070/")
                .retrieveBodyToMono(String.class);
    }
 
}
 
 
cs

 

[CServer:7070]

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@SpringBootApplication
public class ServerApplication {
 
    public static void main(String[] args) {
        SpringApplication.run(ServerApplication.class, args);
    }
 
    @GetMapping("/")
    public String hello() throws InterruptedException {
        Thread.sleep(10000);
        return "hello";
    }
 
}
cs

 

이제 "http://localhost:9090/" 으로 요청을 보내보자!

 

 

보면 AServer(exam)은 ReadTimeOut Exception이 발생하였고, subscription was cancelled라는 메시지를 남기고 있다. 혹시나 이러한 문제가 발생한 사람이 있다면.. 적절히 서버들의 WebClient 옵션을 조정해줄 필요가 있거나 혹은 비정상적으로 응답이 느린 구간이 있을 수 있다. 하지만 때때로 클라이언트 애플리케이션에서 커낵션 풀을 사용하지 않아 과도하게 로컬 포트를 많이 개방한다거나 등의 문제로 cancelled되는 경우가 있으니, 여러가지 상황을 고려해보아야 한다.

 

마지막으로 ConnectionTimeOut, ReadTimeOut, SocketTimeOut의 차이점을 모른다면 아래 링크를 확인하자.

 

2019/02/12 - [프로그래밍언어/Java&Servlet] - Java - ConnectionTimeout,ReadTimeout,SocketTimeout 차이점?

 

Java - ConnectionTimeout,ReadTimeout,SocketTimeout 차이점?

Java - ConnectionTimeout,ReadTimeout,SocketTimeout 차이점? 사실 지금까지 웹개발을 해오면서 ConnectionTimeout,ReadTimeout,SocketTimeout에 대해 대략적으로만 알고있었지 사실 정확히 설명해봐라 혹은 차이..

coding-start.tistory.com

 

posted by 여성게
:
Web/Spring 2020. 2. 3. 21:54

 

기본적으로 MongoDB는 ObjectId라는 유니크한 primary id를 갖는다. 하지만 @Id 어노테이션을 특정 Class로 매핑시키기 위한 방법은 없을까? 예를 들어 아래와 같은 상황이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Document
@Data
@AllArgsConstructor
@NoArgsConstructor
public class DocumentData {
 
    @Id
    private CustomId id;
    private String value;
 
    @Data
    @AllArgsConstructor(staticName = "of")
    @NoArgsConstructor
    public static class CustomId implements Serializable {
        private String idPrefix;
        private String idDelemeter;
    }
}
cs

 

DocumentData라는 Collection이 있고, 해당 Collection에는 ObjectId 타입이 아닌 CustomId 오브젝트 타입의 Id를 넣고 싶은 것이고, 실제로 Id는 내부적으로 String 타입이며 idPrefix + idDelemeter 라는 스트링으로 매핑하고 싶다. 즉, CustomId("prefix", "!"); 로 생성된 오브젝트가 있고 이것이 실제로 Mongodb에 들어가면 _id : "prefix!" 인 형태로 저장이 하고 싶은 것이다. 이럴때는 어떻게 해야할까?

 

1
2
3
4
5
6
7
8
9
10
11
    @Bean
    public MongoCustomConversions customConversions() {
        return new MongoCustomConversions(Collections.singletonList(new CustomIdConverter()));
    }
 
    public static class CustomIdConverter implements Converter<DocumentData.CustomId, String> {
        @Override
        public String convert(DocumentData.CustomId source) {
            return source.getIdPrefix() + source.getIdDelemeter();
        }
    }
cs

 

위와 같이 MongoCustomConversions를 Bean으로 등록하면 특정 오브젝트 타입이 @Id로 Mapping 되어 있을 때, 구현한 Converter 내용에 따라 적절히 _id 값이 들어가게 된다. 위의 구현은 CustomId 오브젝트 타입이 @Id로 매핑되어 있을 때, idPrefix와 IdDelemeter를 조합하여 String Type의 _id 값을 만들어 낸다. 그리고 기존의 ObjectId로 매핑한 @Id도 그대로 사용가능하다.

 

1
2
3
4
5
6
7
8
public interface MongoRepositoryImpl extends ReactiveMongoRepository<DocumentData, DocumentData.CustomId> {}
 
@PostMapping("/mongo")
public Mono<DocumentData> save(){
    DocumentData data = new DocumentData(DocumentData.CustomId.of("prefix","!"), "value");
    mongoRepository.save(data);
    return mongoRepository.save(data);
}
cs

 

실제로 간단히 API를 만들어 요청을 보내보면 아래와 같이 데이터가 삽입되어 있다.

 

1
2
3
_id : "prefix!"
value : "value"
_class : "com.webflux.mongoexam.DocumentData"
cs

 

실제 필자도 내부적으로 자체적인 복잡한 ID 체계를 가져가기 위해 MongoCustomConversions를 빈으로 등록하여 사용하고 있다.

 

추가적으로 하나더 이슈가 있다. 만약에 특정 커스텀 클래스를 @Id class로 사용하려면 해당 클래스가 반드시 Serializable을 구현하는 클래스이어야한다! 이 이슈를 발견하게 된 것은 ReactiveMongoRepository의 findById를 사용할때 였다. 아이디클래스가 Serializable을 구현하지 않으면 예외를 내뱉는다.

 

 

{
    logEvent: "java.lang.ClassCastException",
    errorMsg: "class xxx cannot be cast to class java.io.Serializable (xxx is in unnamed module of loader org.springframework.boot.devtools.restart.classloader.RestartClassLoader @2c4eab42; java.io.Serializable is in module java.base of loader 'bootstrap')"
}

 

posted by 여성게
: