Spring Boot 다중 요청 처리 방법(Feat. Tomcat)

최민길(Gale)·2023년 7월 3일
1

Spring Boot 적용기

목록 보기
35/46

안녕하세요 오늘은 스프링 부트에서 어떻게 다중 요청을 처리하는지에 대해 알아보겠습니다. 이번에 이펙티브 자바의 동시성 항목을 읽으면서 스프링 부트에서는 어떤 식으로 다중 요청을 처리하는지, 그리고 스프링 부트는 과연 thread-safe한지에 대해 궁금해져 이번 포스팅을 남기게 되었습니다.

결론부터 말씀드리자면 스프링 부트는 thread-safe하며, 스프링 부트가 직접 다중 요청을 처리하는 것이 아니라 스프링 부트에 내장되어 있는 서블릿 컨테이너, 즉 톰캣이 다중 요청을 처리합니다. 그렇다면 톰캣은 어떤 식으로 다중 요청을 처리하기 때문에 스프링 부트가 thread-safe할 수 있을까요?

아래 그림은 톰캣의 기본 구조입니다. 톰캣은 하나의 JVM 위에서 하나의 인스턴스가 하나의 프로세스로 동작합니다. 톰캣 인스턴스 내부에는 여러 개의 서비스가 존재합니다. 여러 프로토콜의 리퀘스트들을 캐치하기 위해 각 서비스들은 1개의 엔진과 여러 개의 커넥터로 구성되며(디폴트 커넥터 : HTTP 프로토콜 처리하는 Coyote), 서비스는 여러 개의 커넥터를 하나의 엔진에 연결해주는 역할을 담당합니다. 하나의 엔진 내에는 여러 개의 호스트가 존재하며, 호스트는 요청 url에 매핑됩니다. 마지막으로 하나의 호스트 내에는 여러 개의 컨텍스트가 존재합니다. 즉 저희가 스프링 부트에서 로직을 구현하는 부분은 컨텍스트 레벨이며, 하나의 컨텍스트 내의 디스패쳐 서블릿이 넘겨받은 요청을 처리하는 방식으로 동작합니다.


출처 : https://kbss27.github.io/2017/11/16/tomcatarchitecture/

그럼 톰캣은 어떻게 여러 요청을 thread-safe하게 처리할 수 있을까요? 왜냐하면 톰캣은 각 요청을 처리하기 위해 독립적인 스레드를 할당하기 때문에 요청 간 상호 간섭이나 충돌이 발생하지 않기 때문입니다. 이를 이해하기 위해선 먼저 커넥터의 종류와 동작 방식에 알아보아야 합니다.

커넥터는 네트워크 상의 클라이언트와의 연결을 처리하는 구성 요소로, 클라이언트로부터의 요청을 받아들이고 이를 처리하여 적절한 서블릿 객체에 전달하는 역할을 담당합니다. 커넥터 내의 Acceptor에서 port listen을 통해 소켓을 획득한 후 Worker 스레드 풀에서 소켓을 처리하기 위한 유휴 상태의 스레드를 할당해 Http11Processor를 얻습니다. 이를 이용해 CoyoteAdapter에서 HttpServletRequest 객체를 생성하여 디스패쳐 서블릿으로 넘겨주면 디스패쳐 서블릿에서 알맞은 서블릿을 찾아 요청을 넘겨줍니다.

커넥터는 톰캣 8.0을 기점으로 크게 변화합니다. 톰캣 8.0 이전에는 BIO 커넥터를 사용했습니다. BIO 커넥터는 자바의 기본적인 I/O 기술, 즉 스트림 기반의 입출력 방식을 사용하여 Worker 스레드 풀에서 할당받은 스레드가 커넥션을 받고 요청 처리 후 커넥션이 종료되면 다시 스레드를 반환하는 방식으로 동작합니다. 이 때 커넥션이 닫힐 때까지 하나의 스레드는 하나의 커넥션에 계속 할당되어 동시 사용 스레드 수가 곧 동시 접속자 수가 됩니다.

하지만 이 방식은 한계가 존재합니다. 스트림 기반의 입출력 방식으로 인해 입출력 작업을 순차적으로 처리하게 되어 입출력 작업이 느려질 수 있는 상황에서 Worker 스레드 풀에 유휴 스레드가 없다면 완료될 때까지 블로킹 이슈가 발생할 수 있습니다. 또한 하나의 스레드가 하나의 커넥션에 계속 할당되기 때문에 스레드들이 충분히 사용되지 않고 유휴 상태로 낭비되는 시간이 많이 발생하게 됩니다.


출처 : https://velog.io/@jihoson94/BIO-NIO-Connector-in-Tomcat

이를 개선할 수 있는 방안이 톰캣 8.0 이후에 디폴트로 적용된 NIO(Non-blocking I/O) 커넥터입니다. NIO 커넥터는 기존 I/O 대신 Http11NioProtocol을 사용합니다. Http11NioProtocol란 자바 NIO를 이용하여 HTTP 프로토콜로 구현한 것으로, 이벤트 기반으로 동작하며 비동기 입출력 작업을 지원하고, I/O 멀티플렉싱을 지원합니다.


출처 : https://velog.io/@jihoson94/BIO-NIO-Connector-in-Tomcat

I/O 멀티플렉싱이란 여러 개의 I/O 작업을 단일 스레드 또는 프로세스로 처리하는 기술로서 여러 입출력 장치에서 입출력 요청을 받고 입출력 장치에서 오는 이벤트를 감지하여 감지된 이벤트에 따라 해당하는 입출력 요청을 처리하여 반환하는 특징을 가지고 있습니다.

NIO는 기존 스트림 기반의 입출력 방식에서 블로킹으로 인한 성능 저하를 방지하기 위해 채널과 버퍼, 그리고 셀렉터를 사용합니다. 단일 스레드에서 여러 개의 채널을 처리할 수 있어 비동기적인 입출력 처리가 가능하며 입출력 작업이 완료되지 않아도 다른 작업을 수행할 수 있습니다. 이로 인해 다중 클라이언트를 동시에 처리해야 하는 서버 어플리케이션에 적합합니다.

채널이란 입출력 작업의 소스나 대상을 의미합니다. 기존 스트림 방식과 차이점은 스트림의 경우 입력 스트림과 출력 스트림이 구분되어 있어 읽기와 쓰기가 하나의 스트림에서 모두 지원되지 않고 각각 읽기 전용, 쓰기 전용 스트림으로만 접근해야 하는데 채널의 경우 양방향 입출력이 가능합니다.

버퍼는 데이터를 임시로 저장하는 공간을 의미하며, 입출력 작업을 위한 데이터를 저장하는 역할을 담당합니다. 채널은 쓰기 작업 시 채널에 입력된 데이터를 버퍼에 저장하고, 읽기 작업 시 버퍼에 저장되어 있는 데이터를 출력하는 방식으로 입출력 작업을 진행합니다.

셀렉터는 비동기적인 입출력을 지원하기 위한 객체로, 여러 채널의 입출력 상태를 관찰하여 여러 채널 중에서 준비 완료된 채널을 선택하는 역할을 담당합니다. 셀렉터 객체를 사용할 채널들을 등록한 후 select() 메서드를 호출하여 반환값이 0보다 큰 경우 이벤트가 발생한 채널들을 얻을 수 있습니다.

NIO 커넥터의 동작 방식은 BIO와 약간의 차이점을 가지고 있습니다. 먼저 Acceptor에서 소켓을 가져와 톰캣의 NioChannel 객체로 변환한 후 PollerEvent 객체로 한번 더 캡슐화하여 이벤트 큐에 추가합니다. 이 때 기존의 BIO에서는 큐에 저장된 커넥션 순서대로 요청을 처리한 반면 NIO에서는 Acceptor와 Worker 스레드 풀 사이에 커넥션을 처리하는 별도의 스레드인 Poller 스레드를 사용합니다. 이 방식은 Producer-Consumer 방식으로 Acceptor가 Producer, Poller 스레드가 Consumer로서의 역할을 하여 단순히 순서대로 요청을 처리하는 것이 아닌 처리 가능한 이벤트가 발생했을 때 Poller 스레드에서 요청을 처리합니다.

Polelr 스레드는 Accpetor에서 넘겨받은 소켓들을 캐시로 들고 있다가 해당 소켓에서 데이터 처리가 가능한 순간에만 스레드를 할당합니다. 이것이 가능한 이유는 Poller 내에는 셀렉터가 존재하기 때문입니다. 이벤트 큐에 존재하는 PollerEvent 객체의 채널을 Poller 스레드의 셀렉터에 등록합니다. 그 후 Poller 스레드의 셀렉터가 select() 메소드를 실행하면 이벤트가 발생한 채널을 통해 소켓을 넘겨받게 되고 이를 Worker 스레드 풀에서 스레드를 얻어 SocketProcessor 객체로 캡슐화합니다. 이후 Http11NioProcessor 객체를 얻어 CoyoteAdapter를 호출합니다. 이 때 셀렉터 내에는 여러 채널들이 존재하기 때문에 이벤트 큐에 존재하는 이벤트들을 비동기적으로 처리할 수 있습니다.

여기서 일반 NIO 방식과 톰캣의 NIO 커넥터의 차이가 존재합니다. 일반 자바 NIO의 경우 채널 자체가 thread-safe하지 않으므로 여러 스레드에서 하나의 채널에 동시에 접근하게 되면 문제가 발생하기 때문에 적절한 동기화 기법을 사용해야 합니다. 하지만 톰캣의 NIO 커넥터의 경우 단일 스레드인 Poller 스레드에서 이벤트 큐에 저장된 PollerEvent의 채널에 접근하기 때문에 스레드 간의 동기화 문제를 피할 수 있습니다.


출처 : https://velog.io/@jihoson94/BIO-NIO-Connector-in-Tomcat

그럼 다시 처음으로 돌아와서 톰캣은 어떻게 여러 요청을 thread-safe하게 처리할 수 있는지 살펴보겠습니다. 톰캣은 커넥터를 통해 여러 요청들을 thread-safe한 비동기 방식으로 가져오기 때문에 여러 요청이 들어와도 thread-safe하게 작동할 수 있습니다. 여기에 더 나아가 스프링 부트 특성상 싱글톤 스코프로 관리되는 빈들로 인해 여러 스레드가 동시에 다른 인스턴스를 생성하지 못하고 스레드 로컬 방식으로 인해 여러 스레드에서 데이터를 참조해도 독립적으로 사용 가능합니다. 이런 이유들로 인해 스프링 부트에서 synchronized 키워드를 직접적으로 사용하는 경우는 드물며, 빈 스코프와 톰캣 관리를 통해 다중 요청을 컨트롤할 수 있습니다.

참고 자료
https://exhibitlove.tistory.com/312
https://velog.io/@sihyung92/how-does-springboot-handle-multiple-requests
https://giron.tistory.com/155
https://velog.io/@cjh8746/%EC%95%84%ED%8C%8C%EC%B9%98-%ED%86%B0%EC%BA%A3%EC%9D%98-NIO-Connector-%EC%99%80-BIO-Connector%EC%97%90-%EB%8C%80%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90
https://velog.io/@jihoson94/BIO-NIO-Connector-in-Tomcat

profile
저는 상황에 맞는 최적의 솔루션을 깊고 정확한 개념의 이해를 통한 다양한 방식으로 해결해오면서 지난 3년 동안 신규 서비스를 20만 회원 서비스로 성장시킨 Software Developer 최민길입니다.

0개의 댓글