스프링 웹플럭스(Spring WebFlux) 에서 블록하운드(BlockHound) 사용하기

아두치·2022년 12월 21일
0

스프링 웹플럭스(Spring WebFlux) + 블록하운드(BlockHound)

스프링 웹플럭스(Spring WebFlux)

스프링 웹플럭스는 Reactive Streams 를 준수해서 만들어진 Project Reactor 기반 프레임워크이다.
리액티브 프로그래밍이 화두가 되기 이전에 널리 쓰이던 전통적은 스프링 MVC 와의 가장 큰 차이점은 서버에서의 모든 처리가 논블로킹으로 이루어진다는 점이다.
물론 논블로킹을 위한 다양한 세부 구현은 웹플럭스가 대신 처리해준다.
이런 놀라운 이야기를 바꿔서 이해해보면 스프링 웹플럭스를 도입해놓고 내부 로직에서 블로킹 API 를 호출한다면 오히려 웹플럭스로 인한 오버헤드가 더해져 스프링 MVC 를 사용할 때 보다 나쁜 성능이 나올 수 있다는 이야기가 된다.

블록하운드(BlockHound)

그렇다면 리액티브 애플리케이션을 개발할 때 개발자 스스로 블로킹 코드를 작성하지 않도록 주의하는 방법 밖에 없을까?
물론 당연히 그렇게 해야하지만, 프로젝트 리액터 팀에서 이러한 상황(블로킹 코드 사용으로 인한 성능 저하가 일어나는 상황)을 초기에 방지하기 위해 블록하운드라는 툴을 만들어 놨다.

블로킹 코드 검출

블록하운드는 기본적으로 블로킹 코드를 검출해서 알려준다.
물론, 컴파일 단계에서 알려주진 않고 런타임에 블로킹 코드가 실행되면 그때 BlockingOperationError 에러를 발생시키고 블로킹 코드가 검출되었다는 메세지와 어디서 검출되었는지도 알려준다.
이는 블록하운드가 애플리케이션 런타임에 실행되는 바이트코드를 분석해서 블로킹 코드가 포함되는지 검사하기 때문에 가능한 일이다.

즉, 블록하운드를 적용하더라도 QA 단계에서 확실히 검증하지 못하면 런타임에 블로킹 코드 검출이 발견될 수 있다는 말이다.

블록하운드 사용법

의존성 추가

블록하운드를 사용하기 위해선 의존성 추가를 먼저 진행해야한다.

<dependency>
	<groupId>io.projectreactor.tools</groupId>
	<artifactId>blockhound</artifactId>
	<version>1.0.6.RELEASE</version>
	<scope>provided</scope>
</dependency>

블록하운드를 의존성에 추가했다고 해서 애플리케이션 실행 시 곧바로 적용되는 것은 아니다.
블록하운드는 자바 에이전트이기 때문에, 스프링 부트에서 자동설정을 할 순 없고
블록하운드 에이전트를 직접 코드에 설치해야한다.

나는 스프링 부트 애플리케이션의 시작 지점은 메인 메소드에 설치 코드를 작성해보겠다.

@SpringBootApplication
public class BlockHoundTestApplication {
    public static void main(String[] args) {
        BlockHound.install(); // BlockHound Install
        SpringApplication.run(BlockHoundTestApplication.class, args);
    }
}

위와 같이 SpringApplication.run 이전에 BlockHound.install(); 을 작성하면 스프링 부트 애플리케이션이 시작되는 시점부터 모든 바이트 코드를 분석해서 블로킹 코드가 존재하는지 검사한다.

논블로킹 케이스

우선 간단한 테스트를 위해 다음과 같이 숫자 1을 응답하는 Rest Controller 를 작성해보자.

@RestController
public class RestTestController {
    @GetMapping("/nonBlocking")
    public Mono<Integer> callNonBlocking(){
        return Mono.just(1);
    }
}

정말 별거 없는 내용이다.
그리고 애플리케이션을 시작하고 cURL 이나 브라우저를 이용해 localhost:8080/nonBlocking 에 요청을 해보자.
아마 1 이라는 데이터를 정상적으로 응답받고, 애플리케이션에도 에러가 없을 것이다.
즉 위 컨트롤러 코드에서 블로킹 코드는 없다는 뜻이다.

블로킹 케이스

이번엔 블로킹 코드를 포함하는 컨트롤러로 실험해보자.

@Controller
public class WebTestController {
    @GetMapping("/blocking")
    public Mono<Rendering> callBlocking(){
        return Mono.just(Rendering.view("index.html").build());
    }
}

이번에도 그냥 index.html 을 랜더링 해서 응답으로 반환하는 심플한 내용이다.
애플리케이션을 실행해서 /blocking 으로 요청을 전송해보자.

reactor.blockhound.BlockingOperationError: Blocking call! java.io.RandomAccessFile#readBytes
	at java.base/java.io.RandomAccessFile.readBytes(RandomAccessFile.java) ~[na:na]
	Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: 
Error has been observed at the following site(s):
	*__checkpoint ⇢ Handler com.example.webflux2.WebController#callBlocking() [DispatcherHandler]
	*__checkpoint ⇢ HTTP GET "/blocking" [ExceptionHandlingWebHandler]
Original Stack Trace:
		at java.base/java.io.RandomAccessFile.readBytes(RandomAccessFile.java) ~[na:na]
		at java.base/java.io.RandomAccessFile.read(RandomAccessFile.java:405) ~[na:na]
		at java.base/java.io.RandomAccessFile.readFully(RandomAccessFile.java:469) ~[na:na]
		at java.base/java.util.zip.ZipFile$Source.readFullyAt(ZipFile.java:1348) ~[na:na]
		at java.base/java.util.zip.ZipFile$ZipFileInputStream.initDataOffset(ZipFile.java:915) ~[na:na]
		at java.base/java.util.zip.ZipFile$ZipFileInputStream.read(ZipFile.java:931) ~[na:na]
		at java.base/java.util.zip.ZipFile$ZipFileInflaterInputStream.fill(ZipFile.java:448) ~[na:na]
		at java.base/java.util.zip.InflaterInputStream.read(InflaterInputStream.java:158) ~[na:na]
		at java.base/java.io.InputStream.readNBytes(InputStream.java:506) ~[na:na]
        
        ..// 생략

드디어 블로킹 코드를 검출했다.
맨 첫번째 줄의 내용을 보면 BlockingOperationError 에러가 예상대로 발생했고 java.io.RandomAccessFile#readBytes 메소드 실행이 블로킹 호출이라고 알려주고 있다.

근데 우린 코드에서 readBytes 메소드를 사용하지도 않았고 RandomAccessFile 클래스 조차도 사용한 적이 없다.
어떻게 된 일일까?
에러 메세지에서 조금 내려가보면 다음과 같은 내용을 볼 수 있다.

at org.thymeleaf.TemplateEngine.initialize(TemplateEngine.java:351) ~[thymeleaf-3.1.0.RELEASE.jar:3.1.0.RELEASE]
		at org.thymeleaf.TemplateEngine.getConfiguration(TemplateEngine.java:411) ~[thymeleaf-3.1.0.RELEASE.jar:3.1.0.RELEASE]
		at org.thymeleaf.spring6.view.reactive.ThymeleafReactiveView.render(ThymeleafReactiveView.java:306) ~[thymeleaf-spring6-3.1.0.RELEASE.jar:3.1.0.RELEASE]

그렇다. 스프링 웹플럭스가 index.html 랜더링을 위해 호출한 TemplateEngine의 initialize 메소드에서부터 시작되는 블로킹 코드가 검출된 것이다.
실제로 TemplateEngine#initalize 메소드 코드를 보면 synchronized 키워드가 사용되는 것을 확인할 수 있다.

블록하운드 커스터마이징

그렇다면 스프링 웹플럭스에서 블록하운드를 사용하려면 템플릿 엔진을 사용하지 못하는 것일까? 아니면 템플릿 엔진 사용을 위해 블록하운드라는 유용한 툴을 포기해야하는 걸까?
그렇지 않다.

나도 최근 스프링 웹플럭스와 블록하운드를 공부하면서 저런 상황을 어떻게 처리하는지 궁금해서 이것저것 찾아보다가, 정말 다행히도 블록하운드 개발팀에서 블록하운드 커스터마이징 가이드를 제공하고 있다. 블록하운드 깃허브

BlockHound.install(BlockHoundIntegration ... integrations)

가장 간단한 방법은 BlockHound.install(); 코드의 인자로 BlockHoundIntegration 객체를 전달해주는 것이다.

public interface BlockHoundIntegration extends Comparable<BlockHoundIntegration> {
    void applyTo(BlockHound.Builder var1);

    default int compareTo(BlockHoundIntegration o) {
        return 0;
    }
}

BlockHoundIntegration 은 함수형 인터페이스이기 때문에 다음과 같이 람다식으로 생성 가능하다.

BlockHound.install(builder -> builder.allowBlockingCallsInside(TemplateEngine.class.getCanonicalName(),"initialize"));

블록하운드는 내부적으로 빌더를 가지고 있다.
이 빌더는 사전에 어떤 메소드가 블로킹 메소드인지 조사해 Map 으로 목록을 가지고 있는데, 애플리케이션이 새로운 메소드를 호출할 때 블록하운드가 해당 메소드를 내부적으로 가지고 있는 이 빌더로 조회해서 Map 에 포함되어 있다면 블로킹 코드로 간주한다.
또한 블로킹 메소드 목록 뿐만 아니라 허용할 블로킹 메소드 목록도 별도로 가지고 있어서, 블로킹 메소드 목록에 포함되어 있지만 블로킹 허용 목록에도 포함되어 있다면 검출하지 않고 넘어간다.

그래서 우린 이 빌더를 이용하는 방법으로 위에서 발생한 문제를 해결할 수 있다.
allowBlockingCallsInside 메소드는 위에서 언급한 블로킹 허용 목록에 인자 메소드를 추가한다.

그런데 왜 나는 RandomAccessFile#readBytes 가 아닌 TemplateEngine#initialize 를 허용 목록에 추가해줄까?
잘 생각해보면 localhost:8080/blocking 호출 시 블로킹 에러가 발생한 이유는 웹플럭스가 index.html 을 랜더링 해야하는데 그 과정에서 TemplateEngine#initialize 가 실행되었고 이 메소드 내부적으로 RandomAccessFile#readBytes 를 호출하다보니 블로킹 검출이 발생되었다.
RandomAccessFile#readBytes 의 경우 비동기 애플리케이션에서 호출되면 안되는 코드이다.
이걸 허용할 경우 다른 동료의 실수로 RandomAccessFile#readBytes 호출 코드가 들어가더라도 검출하지 못한채로 운영 환경에 배포된다.
그리고 나빠지는 성능에 대응하기도 어려워진다.
그래서 문제의 원인인, 그리고 허용할만한 가치가 있는 TemplateEngine#initialize 를 허용 목록에 추가해준 것이다.

이제 허용했으니 실행해보자.

reactor.blockhound.BlockingOperationError: Blocking call! java.io.FileInputStream#readBytes
	at java.base/java.io.FileInputStream.readBytes(FileInputStream.java) ~[na:na]
	at java.base/java.io.FileInputStream.read(FileInputStream.java:276) ~[na:na]
	at java.base/java.io.BufferedInputStream.read1(BufferedInputStream.java:282) ~[na:na]
	at java.base/java.io.BufferedInputStream.read(BufferedInputStream.java:343) ~[na:na]
	at java.base/java.io.BufferedInputStream.read1(BufferedInputStream.java:282) ~[na:na]
	at java.base/java.io.BufferedInputStream.read(BufferedInputStream.java:343) ~[na:na]
	at java.base/sun.nio.cs.StreamDecoder.readBytes(StreamDecoder.java:270) ~[na:na]
	at java.base/sun.nio.cs.StreamDecoder.implRead(StreamDecoder.java:313) ~[na:na]

여전히 블로킹 코드가 검출되고 있다.
하지만 이번엔 java.io.FileInputStream#readBytes 이다.
메소드 이름은 같지만 클래스가 다르다.
좀 더 내려가서 원인을 파악해보자.

at org.thymeleaf.engine.TemplateManager.parseAndProcess(TemplateManager.java:649) ~[thymeleaf-3.1.0.RELEASE.jar:3.1.0.RELEASE]

찾았다 ! TemplateManager 에서도 템플릿 파싱을 위해 readBytes 메소드를 호출하고 있었던 것이다.
이것 또한 랜더링을 위한 어쩔 수 없는 선택이니 허용 목록에 추가해주는게 좋을 것 같다.

BlockHound.install(builder ->
                builder.allowBlockingCallsInside(TemplateEngine.class.getCanonicalName(),"initialize")
                        .allowBlockingCallsInside(TemplateManager.class.getCanonicalName(),"parseAndProcess"));

자 이제 실행해보면 정상적으로 화면에 템플릿이 랜더링된다 !

그런데 잘 생각해보면 이러한 종류의 블로킹 코드, 즉 내가 의도하진 않았지만 프레임워크 및 라이브러리의 기능 때문에 내부적으로 호출되는 블로킹 코드가 많을 것 같은데 왜 검출되지 않는 것일까?
정말 이제 블로킹 코드란 존재하지 않는 것일까?

BlockHound#install 코드를 보면 조금 감이 올 것이다.

public static void install(BlockHoundIntegration... integrations) {
        Builder builder = builder();
        ServiceLoader<BlockHoundIntegration> serviceLoader = ServiceLoader.load(BlockHoundIntegration.class);
        Stream.concat(StreamSupport.stream(serviceLoader.spliterator(), false), Stream.of(integrations)).sorted().forEach(builder::with);
        builder.install();
    }

보면 우리가 인자로 전달한 BlockHoundIntegration 외에도 ServiceLoader를 통해 가져온 BlockHoundIntegration 들도 빌더에 적용시킨다.
ServiceLoader 는 예전 글에서 다룬적이 있다. (궁금하신 분은 보고 오시는 것을 추천!)
META-INF/services/reactor.blockhound.integration.BlockHoundIntegration 파일에 서비스 구현체들을 나열해놓으면 서비스 로더가 해당 구현체를 로드하는데 실제로 저 파일을 열어보면 다음과 같은 목록을 볼 수 있다.

reactor.blockhound.integration.LoggingIntegration
reactor.blockhound.integration.ReactorIntegration
reactor.blockhound.integration.RxJava2Integration
reactor.blockhound.integration.StandardOutputIntegration

블록하운드에서 기본적으로 "내가 의도하진 않았지만 프레임워크 및 라이브러리의 기능 때문에 내부적으로 호출되는 블로킹 코드" 들을 저 Integration 들에 정의해놓았고 블록하운드가 install 될때 저 Integration 들을 불러와서 빌더에 적용시킨다.
어떤 것들을 허용하는지 궁금한 사람들은 직접 저 클래스들을 열어 보면 한눈에 알 수 있다.

따라서, 우리가 별도로 허용해주지 않아도 필수적으로 허용해야하는 것들에 대해선 다 허용처리가 되어 있는 것이다.

BlockHound.builder().install()

BlockHound.builder() 를 통해 블록하운드가 내부적으로 사용할 빌더를 새로 만들고 해당 빌더에 각종 설정을 더한 뒤 마지막에 install 을 호출해서 설치할 수도 있는데 이 경우 BlockHound#install(BlockHoundIntegration ... integration) 에서 처럼 블록하운드에서 미리 만들어놓은 Integration 들은 빌더에 적용하지 않는다.

BlockHound.builder()
                .allowBlockingCallsInside(TemplateEngine.class.getCanonicalName(),"initialize")
                .allowBlockingCallsInside(TemplateManager.class.getCanonicalName(),"parseAndProcess")
                .install();

단, 주의할 점 한가지는 블록하운드가 모든 블로킹 코드를 검출하는 것은 아니고 빌더의 threadPredicate 가 true 인 스레드에 대해서만 블로킹 코드 호출을 검사한다.
블록하운드에서 미리 만들어놓은 Integration 중에서 ReactorIntegration 이 있는데 여기서 빌더의 nonBlockingThreadPredicate 메소드를 호출해 NonBlocking 인터페이스를 구현하는 스레드는 threadPredicate 가 true 가 되게끔 설정하는데 리액터의 모든 스레드는 NonBlocking 인터페이스를 구현한다.
즉, 이 방법을 사용할 경우 스프링 웹플럭스의 블록하운드 검사 자체가 무의미해질 수 있다는 뜻이다.

BlockHoundIntegration Service Loader

위 방법들은 모두 코드 레벨에서 제어할 수 있었다.
좀 더 범용적인 방법도 있는데, 아까 설명한 ServiceLoader 를 이용하는 방법이다.
"META-INF/services/reactor.blockhound.integration.BlockHoundIntegration 파일에 서비스 구현체들을 나열해놓으면 서비스 로더가 해당 구현체를 로드한다"
의 설명을 이용해 우리가 직접 범용 Integration 클래스를 만들고 META-INF/services/reactor.blockhound.integration.BlockHoundIntegration 파일에 우리가 만든 Integration 경로를 넣어주기만 하면 된다.
단, 이 방법을 사용할 경우 BlockHoundIntegration Jar에 있는 META-INF/services/reactor.blockhound.integration.BlockHoundIntegration 는 무시되며 내 프로젝트의 META-INF/services/reactor.blockhound.integration.BlockHoundIntegration 가 적용된다.
따라서, 블록하운드가 기본 제공하는 Integration 도 로드하고 싶다면 그 경로들을 추가로 적어주면 된다.

커스텀 BlockHoundIntegration 을 만드는 방법은 간단하다.
BlockHoundIntegration 인터페이스를 구현해주고 @AutoService({BlockHoundIntegration.class}) 만 붙여주면 된다.

참고로 AutoService 애너테이션은 외부 라이브러리 이기 때문에 의존성 추가는 해줘야한다.

BlockHoundIntegration 인터페이스는 아까 살펴봤든 applyTo 메소드만 구현해주면 되고, 블록하운드가 내부적으로 사용하는 빌더를 applyTo 메소드로 전달해주는데 이때 빌더에 각종 옵션을 적용해주는 방향으로 구현하면 된다.

크게 어렵진 않으니 한번 해보길 권장한다!

profile
HAVE YOU TRIED IT?

0개의 댓글