동기/비동기 & NonBlocking Blocking과 Spring의 관계

엄태권·2023년 10월 3일
10

Netty

목록 보기
1/3
post-thumbnail

많은 글들에서 봐서 알겠지만 동기식/비동기식 & blocking / non-blocking 두 그룹은 서로 다른 개념이며, 어느정도 설명들이 되어 있습니다.

그러나 사실 그 구분을 명확하게 이해하기란 쉽지 않았고 웹 프로젝트에선 더 더욱 혼동을 야기하는 것 같아 정리가 필요했습니다.

흔히 reactive processing에서 말하는 Asynchronouse non-blocking은 뭐가 Asynchronouse 하고 뭐가 non-blocking 하다는 건지 내가 개발하는 WebServer에선 어떻게 이해해야 할지 이해가 가지않아 정리해봤습니다.

용어정리

Synchronouse & Asynchronouse

사전적인 용어의 풀이는 이미 너무 많은 곳에서 설명을 하고 있기에 따로 정의 하진 않겠습니다.
동기비동기의 관계를 설명하는 가장 대표적인 사례는 특정 작업이 수행중이고 종료되지 않은 상태여도, 대기하지 않고 다른 작업을 실행하느냐 못하느냐의 차이가 있습니다.

물론 동기와 비동기를 나타낼 수 있는 대표적인 사례가 맞습니다. 그러나 제가 말하고 싶은 동기/비동기의 가장 중요한 차이는 작업의 순차(작업이 완료되고 요청하는 순서)라는 생각이 듭니다.

동기의 경우 작업의 순서가 보장되며(순차적으로 진행됨), 비동기의 경우 작업의 순서는 보장되지 않습니다. 그러다 보니, 비동기는 동기와 다르게 각 작업들을 독립적으로 실행이 가능합니다.

이러한 비동기 작업은 싱글스레드인 자바스크립트에서 많이 사용이 되고 있습니다. 가령 화면을 랜더링 하는데 하나의 작업에 걸리는 시간이 길어지게 된다면, 사용자는 다른 기능의 사용에 제한이 있을수 있습니다.

그렇기 때문에 여러 요청을 비동기적으로 동작하도록 하여, 이를 극복할 수 있습니다.(EventLoop를 사용) 하나의 작업이 끝날때까지 기다렸다 순차적으로 다른 요청을할 필요가 없는것이죠.

Blocking / Non-blocking

기본적으로 블로킹은 말그대로 작업을 시작한 후 해당 작업이 종료될 때까지 아무런 작업을 수행하지 않는 것을 의미하며, 넌-블로킹의 경우 작업을 시작한 후 다른 작업이 종료될 때까지 기다리지 않고 다른 작업을 계속할 수 있는 것을 의미합니다.

블로킹과 넌-블로킹의 가장 큰 차이는 제어권에 초점을 두면 좋을거 같습니다.
즉 A가 B와 C를 호출할경우 B의 작업이 끝날때까지 제어권이 A에게 없어 A->C를 호출이 B작업완료 이후 진행된다면 블로킹 B의 결과와 상관없이 C를 호출할 수 있으면 넌-블로킹입니다. 즉 A에게 제어권이 있느냐 없느냐의 차이입니다. 간단한 코드로 보면 아래와 같습니다.

[Blocking Code]

    @GetMapping
    public void syncBlockTest() {
        log.info("syncBlockTest start Thread : {}", Thread.currentThread().getName());
        syncBlockService.callTimeOutLog();
        log.info("syncBlockTest finish Thread : {}", Thread.currentThread().getName());
    }
    
        public void callTimeOutLog() {
        try {
            Thread.sleep(10000);
            log.info("callTimeOutLog for 10 second Thread : {}", Thread.currentThread().getName());
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
[Non-blocking code]

    public void syncNonBlockTest() {
        log.info("syncNonBlockTest start Thread : {}", Thread.currentThread().getName());
        syncNonBlockService.callTimeOutLog();
        log.info("syncNonBlockTest finish Thread : {}", Thread.currentThread().getName());
    }

    @Async(value = "nonblockTaskExecutor")
    public void callTimeOutLog() {
        try {
            Thread.sleep(10000);
            log.info("callTimeOutLog for 10 second Thread : {}", Thread.currentThread().getName());
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

Spring MVC에서 @Async를 사용하여 Non-blocking하게 동작하도록 작성한 코드입니다.
실제 스레드의 이름을 보면 @Async 메소드의 경우 다른 스레드에서 동작 하는 것을 확인 할 수 있습니다.

Asynchronouse 와 Non-blocking 비슷해 보이는데 무슨 차이일까?
용어 정리를 했음에도 불구하고 언뜻보면 async와 non-blocking은 같은 의미라고 생각됩니다. 일반적으로 우리는 동기/비동기에 더 익숙해져 있고, 비동기를 자연스럽게 병렬 실행이라는 의미로 해석해 혼동이 생길 수 있습니다.

사실상 동기/비동기는 앞선 용어정리에서와 마찬가지로 작업의 순서(순차적 실행)의 관점이며, 논블로킹이 우리가말하는 병렬 실행과 관련된 개념이라고 이해하는 것이 맞습니다. 위에서 말한 자바스크립트의 예시 또한 결국 Asynchronous / Non-blocking 한 작업인 것이죠.

Spring의 동기 & 비동기 / 블로킹 & 논-블로킹

Spring MVC의 동작 방식과 Non-blocking의 등장

그럼 우리가 사용하는 스프링 MVC에선 어떤지 알아보겠습니다. 먼저 MVC의 기동부터 사용자의 요청이 스프링까지 요청이 들어오는 동작방식을 알아보겠습니다.

먼저 스프링 프로젝트 기동시 SpringApplication이 기동되면서 TomcatServletWebServerFactory를 통해 protocoal에 맞는 Connector를 가져오게 됩니다. 이때 기본 protocal은 NioProtocol 입니다.

public static final String DEFAULT_PROTOCOL = "org.apache.coyote.http11.Http11NioProtocol"; 

이후 해당 Connector를 통해 CoyoteAdapter를 생성하고 protocolHandler에 지정합니다.

여기서 우리는 NioProtocol을 DEFAULT로 사용한다는 것을 볼 수 있습니다. 실제 Tomcat에는 BIONIO 2종류의 Connector가 있습니다.

쉽게 말해 Blocking(BIO)Non-Blocking(NIO) 방식이 존재했습니다. 그러나 Tomcat9 버전부턴 BIO Connector가 drop 되었고, 이후 부터 NIO Connector가 DEFAULT Connector로 사용되고 있습니다.
이는 불특정 다수의 클라이언트 연결을 Async/Non-Blocking하게 처리할 수 있음을 의미합니다.

다음으로 아래의 그림은 사용자의 요청이 실제 스프링까지 들어오기의 흐름도입니다.

실제 클라이언트의 요청이 톰캣 서버로 들어올 경우 Connector를 통해 WebServer(Tomcat)으로 들어오게 되며, 이때 Coyote는 HTTP 1.1 및 2 프로토콜을 웹 서버로 지원하는 Tomcat용 커넥터 구성 요소를 통해 서블릿컨테이너로 들어오게 됩니다.

Tomcat은 CoyoteAdapter를 통해 그에 맞는 Connector(NIO)를 사용하여 사용자의 요청을 Servlet에 전달합니다.

여기 까지만 보면 뭔가 Non-Blocking 하게 사용자의 요청을 받고있다고 생각되지만, 사실 Connector가 NIO로 동작하더라도 기존의 Servlet3.0까지에선 Non-blocking한 동작을 지원하지 않았으며, 결국 요청 Thread들이 Servlet에 도착해 requestresponse를 서빙하는 I/O가 Tradition I/O만을 허용하며 결국엔 Blocking 한 동작방식이었습니다.

그러나, 이후 Servelt3.1이 발표되며 Non-blocking I/O를 지원하게 되었고, 3.0에서의 tradition I/O에 대한 개선과 Write/Read 의 이벤트 리스너를 추가하며 non-blocking으로 동작할 수 있게 되었습니다.

그럼 MVC도 Asynchronouse/Non-blocking이 가능할지도?

그럼여기서 Connector에서 사용자의 요청을 Non-blocking하게 받도록 Tomcat이 제공하며, Servlet 또한 Non-blocking I/O를 지원하니 MVC에서도 Asynchronouse/Non-blocking 하게 제공할 수 있지 않을까? 라는 의문점이 들었습니다.

실제 NIO의 경우 Tomcat5 부터 지원이되었으며, Servlet 3.1의 경우 Java EE 7부터 추가 되었는데 왜 Spring Reactive stack이 새로나왔을까 의구심이 들었고, MVC에선 처리가 불가능한 것일까 하는 생각이 들었습니다.

사실 이문제는 Srping에서 사용하는 DispatcherServelt에 문제가 있습니다. Spring MVC 에서 사용하는 DispatcherServlet의 request(HttpServletRequest), response(HttpServletResponse)가 결과적으로 ServletInputStreamServletOutputStream을 사용하며 이는 결국 In/OutputStream 을상속받는 구조입니다.

[DispatcherServlet.java]

	@Override
	protected void doService(HttpServletRequest request, HttpServletResponse response) throws Exception {
		logRequest(request);

		// Keep a snapshot of the request attributes in case of an include,
		// to be able to restore the original attributes after the include.
		Map<String, Object> attributesSnapshot = null;
		if (WebUtils.isIncludeRequest(request)) {
			attributesSnapshot = new HashMap<>();
			Enumeration<?> attrNames = request.getAttributeNames(); ....
[ServletInputStream.java]

public abstract class ServletInputStream extends InputStream {

    /**
     * Does nothing, because this is an abstract class.
     */
    protected ServletInputStream() {
        // NOOP
    }

이 두가지는 모두 Blocking하게 동작하고 있으며, 이로 인해 실제 Non-blocking하게 요청이 들어와도 결국 DispatcherServlet에서 다시 blocking하게 동작하게 됩니다. 즉 사용자의 요청에 대해 Synchronouse 하게 동작하는 것이죠.

Thread per Request
여담으로 이러한 MVC의 동작모델을 Thread per Request라고 합니다. 결국 요청 하나에 하나의 스레드이며, 해당 스레드로 응답을 받는 구조입니다.

그럼 위의 내용들을 토대로 실제 MVC에서의 동작 방식을 테스트 해보겠습니다. 먼저 스레드의 갯수를 1개로 설정하여 테스트 했으며, Thread Per Reuquest의 특성상 하나의 작업에 하나의 Thread가 소모될 것입니다.

테스트 시나리오 : 톰캣의 스레드 수를 1개로 설정하여 테스트 합니다. PostMan과 브라우저를 사용하여 거의 동시에 가까운 시간에 요청을 보냅니다. 요청한 메소드는 10초의 Thread Sleep이 있습니다.

테스트 결과 예상 : 가장 먼저 들어간 요청(PostMan or 브라우저)이 Thread를 점유하고 Thread per Request로 해당 Thread에 응답을 주기 전까지 뒤의 요청은 대기합니다. 요청 자체가 5초에 한번씩 들어갑니다.(Synchronouse)

테스트 결과 : 실제 두개의 요청전달시 찍히는 로그의 시간차이가 10초의 텀이 있는것을 볼 수 있습니다.

관련 코드는 모두 GitHub에 올라가있습니다(깃허브 링크)

Spring Reactive의 탄생

Thread Per Request의 한계점은 여러 요청이 들어오지만 가용 가능한 Thread 수만큼만 요청이 전달되고 Block된 상태에서는 해당 Thread가 Waiting 상태라는 특성에 있습니다.

급증하는 요청에 대해 Thread를 늘릴순 있지만 이는 많은 Context Switching 비용을 야기하고 한계가 있으며, Thread의 waiting 상태가 지속되면 CPU의 성능을 온전히 사용하지 못한다는 아쉬움이 있습니다.

그 대안으로 Spring Reactive Stack이 탄생했으며, 이는 EventLoop 방식을 통해 Asynchronose/Non-blocking한 Web Application운용이 가능해졌습니다. 해당 내용은 이후의 글에서 더 자세히 다루도록 하겠습니다.

Reactive의 탄생은 DispathcerServlet 때문만은 아닙니다
Spring Reactive의 탄생 배경글을 보면 DispatcherServlet 때문만은 아니겠구나 하는 생각이 듭니다.

위 글에서 보면 FilterServlet의 동기처리 방식 또는 getParameter, getPart의 Blocking적인 방식도 영향이 있을 겁니다.

실제 서블릿 필터는 사용자의 요청을 Block 방식으로 동작하고 Servlet Filter를 상속받는 Spring Security Filter도 이에 영향이 있습니다.

그래서 Spring Reactive Stack에선 Spring Security Reactive라는 기존 MVC와는 다른 Security 방식이 도입되었습니다.

그럼 실제 Reactive로 사용했을 경우의 동작방식도 봐야 알 수 있기에 사용해보진 않았지만 간단한 코드로 사용자 요청에 대한 비동기 처리에 대해 알아보겠습니다.

이전 MVC의 테스트와 마찬가지로 Reactive의 Worker Thread를 1로 설정하여 동일한 테스트를 해보겠습니다.

테스트 시나리오 :WorkerThread 수를 1개로 설정하며, PostMan과 브라우저를 사용하여 거의 동시에 가까운 시간에 요청을 보냅니다. 마찬가지로 요청한 스레드는 10초의 Delay가 있습니다.
테스트 결과 예상 : EventLoop 방식에 의하여 WorkerThread가 하나지만 요청을 동일한 수준으로 받아냅니다.

테스트 결과 : 실제 두개의 요청 전달시 찍히는 로그의 시간차가 없으며, 응답또한 동일한 시간에 내려갑니다. 즉, 비동기적 요청이 가능합니다.(Asynchronouse)

관련 코드는 모두 GitHub에 올라가있습니다(깃허브 링크)

정리

이번글의 중요한 점은 Synchronouse/Asynchronouse 그리고 NonBlocking/Blocking의 구분을 이해하는 것이었으며, 동기/비동기는 작업의 순서 그리고 블로킹/논블로킹은 제어권의 기준으로 이해를 하는 것이 제일 좋을거 같습니다.

이러한 개념을 이해하고 Spring Reactive를 보니 Reactive에서 말하는 Asynchronouse/NonBlocking이 웹 애플리케이션의 어떤 관점에서 말하는 것인지 더 혼동이 왔습니다.

실제 문서들을 찾아보고 실제 테스트 요청을 보내보면서 제가 생각하고 결론내른 Reactive에서 말하는 Asynchronouse/NonBlocking 는 사용자의 요청(Request)까지 묶어 생각해야 한다는 것입니다.

즉, 적은 Thread 수(실제 Core *2 수준이라고 함) 로 사용자의 요청을 EventLoop를 사용하여 Asynchronouse(비동기) 하게 받아내며, 그 뒤의 로직은 Non-Blocking 한 방식으로 처리가 이루어 진다. 라고 이했습니다.

테스트에서 보았듯이 기존의 Spring MVC의 경우 아무리 Connector의 지원과 Servlet 3.1의 등장으로도 여러 사유로 인해 결국은 사용자의 요청을 Synchronouse(동기) 하게 처리할 수 밖에 없는것이죠.

물론 Non-Block하게 동작할 수 있습니다.(@Async 또는 completedfuture 처럼) 그러나 실제 사용자의 요청에 대해선 Synchronouse 즉 동기적인 처리가 한계라는 것입니다.

참조

https://www.geeksforgeeks.org/blocking-methods-in-java/
https://docs.oracle.com/javase/8/docs/api/?java/util/stream/Stream.html
https://medium.com/@nikhilmanikonda/tomcat-who-i-am-and-what-i-do-e91ff72fb2ea
https://tomcat.apache.org/tomcat-10.0-doc/architecture/requestProcess/request-process.png
https://www.oracle.com/webfolder/technetwork/tutorials/obe/java/HTML5andServlet31/HTML5andServlet%203.1.html#overview
https://spring.io/reactive

profile
https://github.com/Eom-Ti

4개의 댓글

comment-user-thumbnail
2024년 4월 2일

와 NIO여도 왜 동기적으로 작동하는지 엄청 찾아다녔는데 잘 정리해주셨네요 ㅜㅜ 감사합니다

1개의 답글
comment-user-thumbnail
2024년 4월 29일

결국 톰캣의 NIO 는 쓰레드 가용범위 내의 사용자들의 요청 그 자체만 비동기로(1번 사용자의 요청이 처리 안되어도, 2번 사용자의 요청 처리시도 가능) 처리하고, 들어온 사용자의 요청 자체는 Servlet 과 Filter 등 동기로 동작하는 메소드로 인해 동기로 동작한다. 라고 이해가 되는데 맞을까요?
그러면 spring mvc의 경우 코드레벨에서 비동기 처리를 하면 dispatcherservlet 으로 인해 요청한 http method를 동기로 처리하면서 그 내부(비즈니스 로직)는 비동기로 부분부분 동작하게 되겠다 라고 예상이 되는데 맞게 이해한건지 궁금하네요.

요약 - 여러 사용자들의 요청 접수 자체는 비동기로 동작, 하나의 사용자에 대한 요청 자체는 동기로 동작, 만약 코드레벨에서 비동기로 구현된 부분이 있다면 비즈니스 로직에서 부분부분 비동기로 동작하지만, 하나의 사용자가 요청을 2개 한 경우 처음 요청이 끝나야(내부에서 비동기로 동작하든 어떻든) 두번째 요청을 처리.

1개의 답글