예전에 백엔드 프레임워크들과 특징을 찾아볼 때 Node.js는 싱글 스레드, 스프링은 멀티 스레드로 동작한다는 특징을 보고 음, 그렇구나! 하고 넘어갔었다.
그런데 스프링을 사용하는 지금도 멀티 스레딩 개념이 당장의 기능 구현에 필요하지는 않다 보니 어디에서 어떤 작업이 멀티 스레드로 처리되는지, thread-safe한 프로그램을 위해 어떻게 개발하는지 전혀 신경쓰지 않고 있었다.
하지만 Spring Bean, 의존성 주입 등 스프링 구조에 대해 찾다 보니 스레딩 내용도 계속 나오다 보니 모른다고 계속 넘기면 안될 것 같다.
멀티 스레딩 관련 내용을 정리하며 스프링에 대해 더 알아보자!

출처: Thread per request VS EventLoop Model in Spring
스프링 부트는 기본적으로 웹서버+웹 컨테이너의 역할을 하는 톰캣이 내장되어있다. 톰캣은 다중 HTTP 요청이 들어오면 각각 별도의 스레드에서 처리하는 멀티 스레딩 구조로 동작한다.
스레드는 생성 비용이 높고 너무 많은 스레드가 생성되면 문제가 되기에 톰캣은 스레드 풀을 생성하여 일정 수의 스레드를 미리 생성해 놓는다.
작업이 모두 수행되면 스레드는 스레드 풀로 반환된다.
출처: Spring-Controller-Service-Repository를-알아보자
스프링 부트는 일반적으로 사용자의 요청을 컨트롤러에서 받아 서비스에서 비즈니스 로직을 처리하고 리포지터리에서 DB 작업을 처리한다.
요청-컨트롤러-서비스-리포지터리로 이어지는 작업 흐름은 하나의 스레드에서 순차적으로 처리된다.
사용자는 @Acync 어노테이션을 사용해 메소드를 비동기적으로 실행할 수 있다.
비동기 메소드는 스프링이 관리하는 별도 스레드 풀에서 스레드를 할당받아 메소드를 실행하고, 원래 요청을 처리하던 스레드는 메소드 실행을 기다리지 않고 즉시 반환된다.
비동기 메소드를 이용해 비즈니스 로직을 여러 개의 스레드로 분산시켜 프로그램의 속도, 반응성을 향상할 수 있다.
어떤 함수, 변수 혹은 객체에 여러 스레드가 동시에 접근하더라도 프로그램의 실행에 문제가 없는 상태
멀티 스레드를 사용할 때는 Thread-safe 상태를 유지해야 한다.
구체적으로는 하나의 함수가 한 스레드로부터 호출되어 실행 중일 때, 다른 스레드가 그 함수를 호출하여 동시에 함께 실행되더라도 각 스레드에서의 함수의 수행 결과가 올바르게 나오는 상태가 thread-safe다.
스프링 부트에서는 톰캣을 사용해 요청을 멀티 스레드로 처리한다 했다.
스프링+톰캣 으로 HTTP 요청 처리하는 과정을 살펴보고 언제 스레드 할당이 이루어지는지 알아보자.
톰캣(Tomcat)은 본래 대표적인 WAS 미들웨어 서비스였지만, 현재는 웹서버 미들웨어 아파치(Apache)의 기능 일부분을 가져와 함께 사용하기 때문에 웹서버와 WAS의 기능을 모두 포함하고 있다.
출처: Tomcat, Spring MVC의 동작 과정
기본적으로 웹 서버는 HTTP 프로토콜을 기반으로 클라이언트의 요청을 서비스한다.
웹 서버의 기능은 정적, 동적 컨텐츠 제공으로 나눌 수 있다.
정적 컨텐츠
html, css , js와 같은 정적 컨텐츠
동적 컨텐츠
DB 조회나 다양한 로직 처리를 요구한다.
WAS와 함께 사용하는 경우 웹서버는 동적 컨텐츠 제공을 WAS에 위임하고 정적 컨텐츠에 대한 요청을 처리한다.
또한 클라이언트와의 연결을 WAS에 전달하여 WAS가 클라이언트와 직접 통신하지 않게 하여 독립성과 보안을 보장한다.
WAS는 데이터 베이스 조회, 다양한 로직 처리가 필요한 동적인 컨텐츠를 제공하기 위해 만들어진 서버다. 서블릿 컨테이너(Servlet container) 라고도 불린다.
서블릿은 웹 프로그래밍에 있어 클라이언트의 요청에 대한 웹페이지, 혹은 결과값을 동적으로 생성해주기 위한 자바 프로그램을 말한다.
model-view-controller로 구성되는 스프링 MVC에서는 각 요청에 대한 서블릿을 직접 구현할 필요 없이 디스패쳐 서블릿(DispatcherServlet) 이라는 모든 요청을 받아들이는 서블릿을 두고, 요청에 따라 매핑된 컨트롤러에 요청 처리를 요청한다.

출처: https://taes-k.github.io/2020/02/16/servlet-container-spring-container/
서블릿 라이프 사이클 관리
서블릿 클래스의 로드, 초기화, 호출, 소멸까지의 라이프 사이클을 직접적으로 관리한다.
서블릿으로 구현된 디스패쳐 서블릿 역시 서블릿 컨테이너에서 수행된다.
통신을 위한 소켓 생성
웹서버와의 통신 소켓을 생성해 클라이언트의 요청을 전달받고 동적 서비스를 반환하다.
request 처리 위한 쓰레드 할당
서블릿 컨테이너에서 관리하는 쓰레드풀에서 리퀘스트에 대한 쓰레드를 할당한다.
빈(Bean)은 스프링 컨테이너가 관리하는 자바 객체다.
빈으로 등록된 객체는 사용자가 객체를 생성하고 관리할 필요 없이 스프링 컨테이너에서 빈을 주입, 관리하게 된다.
이렇게 사용자가 아니라 스프링 컨테이너가 객체를 관리하는 것이 스프링의 핵심 특징 중 하나인 DI(의존성 주입)이다.
빈에는 기본적으로 싱글톤 패턴이 적용된다.
싱글톤 패턴은 디자인 패턴의 일종으로 여러 차레 생성자가 호출되더라도 최초 생성된 객체만을 리턴하여 클래스에 단 하나의 객체만 존재하도록 한다.
그렇다면 하나의 객체만 사용되는 빈이 여러 개의 스레드에서 호출될 때 Thread-safe 할까?
우선 동시에 여러 스레드가 어떻게 하나의 빈 객체에 접근할 수 있는지 알아보자.
자바 힙 메모리
스택 메모리
Java는 각 스레드에 별도의 스택 메모리를 생성한다. 스택 메모리는 메서드 내부의 로컬 변수 등이 저장되며 스레드들은 다른 스레드의 스택 메모리에 접근할 수 없다.
따라서 스레드들이 빈 객체의 메서드를 동시에 실행하더라도 메서드의 지역변수는 스택 메모리에 저장되어 병렬로 실행되는 스레드가 서로의 변수를 덮어쓰지 않는다.
힙 수준 잠금 없음
힙 수준 잠금이 없어 모든 스레드가 빈에 접근할 수 있지만, 여러 스레드가 공유하는 영역에서 빈 객체를 변경할 수 있다면 문제가 발생한다.
상태가 존재하는(Stateful) 싱글톤 빈은 thread-safe하지 않다.
객체의 인스턴스 변수나 정적 변수는 Heap 메모리에 저장된다. 따라서 빈 객체에 변경 가능한 인스턴스/정적 변수가 존재한다면 여러 스레드에 의해 변경될 수 있고, 예기치 못한 예외를 일으킬 수 있다.
Stateful Bean의 예시
인스턴스 변수productName이 존재하고, getProductById에서 변수를 변경한다.
@Service
public class ProductService {
private String productName = null;
// ...
public Optional getProductById(int id) {
// ...
productName = product.map(Product::getName).orElse(null);
// ...
}
}
서비스를 실행하고 출력을 살펴보자.
Thread: pool-2-thread-2; bean instance: com.baeldung.concurrentrequest.ProductService@7352a12e; product id: 2 has the name: Product 2
Thread: pool-2-thread-1; bean instance: com.baeldung.concurrentrequest.ProductService@7352a12e; product id: 1 has the name: Product 2
두 번째 요청에서 "Product 1"이 출력되어야 하지만 "Product 2"가 출력된다.
실행중인 모든 스레드가 동일한 productName 변수를 공유하기 떄문에 발생하는 문제다.
따라서 객체를 빈으로 등록할 때는 객체가 멀티 스레드 환경에서 동작함을 유의하고 값이 변경되는 변수는 인스턴스 변수 대신 지역 변수로 정의해 stateless를 지켜야 한다.
https://www.baeldung.com/spring-singleton-concurrent-requests
https://velog.io/@sihyung92/how-does-springboot-handle-multiple-requests
https://velog.io/@tritny6516/Spring-Thread-Pool