[Non-Blocking] Block Hound와 함께 삽질을!

2 minute read

Blocking Call & Webflux

Spring Webflux로 만든 애플리케이션을 Production에 배포하기 전엔, 의도치 않은 Blocking Call이 있는지 확인해야한다.

Webflux는 Non-Blocking을 기반으로 동작하기 때문에, 소량의 스레드로 모든 요청을 처리한다. (코어당 하나의 스레드라고 함.)

이 때문에 어느 한 곳 이라도 차단 호출이 존재한다면, 안그래도 적은 수의 가용스레드가 더 적어지게 되고,

나아가 적은 수의 스레드만 사용하는 차단-프레임워크(Spring MVC web같은)처럼 동작하게 된다.

Spring MVC가 보통 약 천개의 스레드로 동작하는 것에 비해, 차단 호출 웹플럭스는 기껏해야 1~8개 정도의 스레드만 사용하므로,

성능차이는 어림잡아 백배 이상 날 것이라 추측 해 볼수 있다.

비 차단 프레임 워크에서 차단 호출을 막는것은 이렇게나 중요한데, 이 차단 호출을 감지하는데에 도움을 주는것이 바로 BlockHound이다.

BlockHound

필자는 BlockHound를 그냥 붙이기만 하면 알아서 차단 호출을 감지해 Error를 발생시켜 줄테니, 그 BlockingError만 확인하고 고쳐주면 될 것이라고 쉽게 생각했다.

그리고 보기좋게 다시 삽질을 또 하게 되었다. 이 글은 삽질을 기억하기 위한 몸부림이다.(공병 아니랄까봐..)

BlockHound 설정

Gradle에 BlockHound 의존성을 추가한다.

...
dependencies {
    implementation 'io.projectreactor.tools:blockhound:1.0.6.RELEASE'
  ...
  }

main메소드에 BlockHound.install() 을 작성한다. 참고로 BlockHound는 실행중 바이트코드 수정이 가능하게 설정된 자바 에이전트이기에, Application을 띄운 다음에 install()을 호출해도 상관 없다.

public static void main(String[] args) {
    SpringApplication.run(MyApplication.class, args);
    BlockHound.install();
}

차단 호출 확인하기&처리하기

이제 어디서 차단-호출이 일어나는지 확인할 일만 남았는데, 막상 Application 이곳저곳을 실행해보면 오만상 BlockingOperationError가 발생한다.

reactor.blockhound.BlockingOperationError: Blocking call! java.io.RandomAccessFile#readBytes
	at java.base/java.io.RandomAccessFile.readBytes(RandomAccessFile.java) ~[na:na]
	...

저 java.io.RandomAccessFile#readBytes 는 BlockHound로 점검을 시작하면 가장 친해질 녀석인데, 처음에 이녀석이 나오면 당황스럽다. 내가 호출한게 아닌데 왜 나오는거지? 하며 말이다.

사실 완전한 Non-Blocking은 존재하기 어렵다. Call stack을 타고타고 깊이 가면 항상 끝에는 read()나 unsafe같은 같은 차단 호출과 관련된 녀석들이 기다리고 있다.

정말 중요한건 차단을 피하는게 아니라, 차단으로 인해 CPU 사이클이 낭비되지 않도록 하는것이다. (https://blog.frankel.ch/blockhound-how-it-works/)

이 때문에 Non-Blocking을 지원하는 많은 Spring Modules가 BlockHound Integration에 자신들의 차단 호출이 어느 정도까지 허용될지 통합해 놓는다.

미처 통합되지 못한 차단 호출은, 직접 BlockHound.install(BlockHoundIntegration) 을 통해 허용할 수 있다.

BlockHound.install(b -> b.allowBlockingCallsInside(InflaterInputStream.class.getName(), "read") // OpenApi Blocking Call 허용
        .allowBlockingCallsInside(OpenAPIService.class.getName(), "build")); // OpenApi Blocking Call 허용

이렇게 하면 직접 차단 호출을 허용할 수 있지만, 너무 남발 했다가는 진짜 막아야 할 차단 호출을 놓칠수도 있으니 주의해서 사용해야 한다.

필자는 이렇게 해서 Jwt를 생성하는 부분과 RandomUUID 생성하는 부분에 차단이 있음을 알아냈고, Mono.create통해 Operation을 비차단 식으로 나누거나 Schedulers를 통해 다른 스레드에서 연산 되도록 수정하였다. 예를 들면,

UUID.randomUUID().toString(); // BlockingOperationError!

Mono.<String>create(sink -> {  // OK
  String uuid = UUID.randomUUID().toString();
  sink.success(uuid);
});

Mono.<String>create(sink -> {  // OK
  String enc = doEncrypt(); // 무거운 암호화 로직
  sink.success(enc);
}).subscribeOn(Schedulers.boundedElastic());

이런식으로 말이다.

완성된 Application.java 코드


@SpringBootApplication
@ConfigurationPropertiesScan
public class MyApiApplication {

    private static final String ARG_ENABLE_BLOCKHOUND = "ENABLE_BLOCKHOUND";

    public static void main(String[] args) {
        Locale.setDefault(Locale.KOREA);
        SpringApplication.run(MyApiApplication.class, args);
        enableBlockHoundIfArgsExists(args);
    }

    public static void enableBlockHoundIfArgsExists(String[] args) {
        if (Objects.isNull(args)) {
            return;
        }
        for (String arg : args) {
            if (Objects.nonNull(arg) && arg.contains(ARG_ENABLE_BLOCKHOUND)) {
                enableBlockHound();
                return;
            }
        }
    }

    public static void enableBlockHound() {
        System.out.println("BlockHound Enabled.");
        BlockHound.install(b -> b.allowBlockingCallsInside(InflaterInputStream.class.getName(), "read")
                .allowBlockingCallsInside(OpenAPIService.class.getName(), "build");
    }
}

끝.

Leave a comment