프로세스와 스레드, 그리고 나의 프로젝트

프로세스와 스레드를 처음 배웠을 때, 나는 아래와 같이 이해를 했었다.

  • 프로세스: 운영체제(OS) 관점에서 하나의 프로그램이 실행되는 단위.
  • 스레드: 실행된 프로세스 안에서 동작하는 더 작은 실행 단위.

개념은 어렴풋이 알았지만, 내가 만드는 스프링 부트 프로젝트와 이 개념들이 실질적으로 어떻게 연결되는지 명확하게 짚고 넘어가고 싶어 정리가 필요했다.



✅ 프로세스와 스레드의 관계: JVM, 톰캣, 스프링

java -jar myapp.jar 명령어로 스프링 애플리케이션을 실행하면, 운영체제는 JVM(Java Virtual Machine)을 하나의 프로세스로 인식하고 실행한다.

그리고 이 JVM 프로세스 내부에서는 수많은 스레드들이 각자의 역할을 수행하며 동작한다. 이 스레드들은 역할에 따라 관리 주체가 나뉘는데, 예를 들어 main 메소드를 실행하는 스레드, JIT 컴파일러 스레드 등 핵심적인 스레드는 JVM이 직접 관리한다.

특히, 웹 요청과 관련된 스레드들은 내장 웹 서버인 톰캣(Tomcat)이 관리 한다. 스프링 웹 개발자로서 우리가 주로 다루게 되는 스레드가 바로 이 톰캣의 스레드 풀에 있는 스레드들이다.

최종 정리

  • 프로세스(Process): JVM 자체가 하나의 프로세스입니다.
  • 스레드(Thread): JVM 프로세스 안에서 실행되는 작은 실행 단위입니다.
  • 관리 주체: 스레드는 역할에 따라 JVM, 톰캣, 스프링 등에 의해 관리됩니다.
관리 주체 관리 대상 스레드
JVM main 스레드, 가비지 컬렉터(GC) 스레드, JIT 컴파일러 스레드 등
톰캣 클라이언트의 웹 요청을 처리하는 HTTP 요청 처리 스레드
스프링 @Async 등 비동기 작업을 처리하는 별도의 스레드 풀 스레드

❓ 질문 1: 웹 요청이 오면 스레드가 계속 새로 생기나요?

“웹 요청이 오면 톰캣이 스레드를 하나 만들고, 그 요청이 내 스프링 애플리케이션 로직을 타면 스프링이 또 다른 스레드를 만드는 건가요?”

결론부터 말하면, 아닙니다.

대부분의 경우 톰캣으로부터 할당받은 스레드 하나가 요청의 시작부터 끝까지를 전담합니다. 하지만 @Async 같은 특정 상황에서는 새로운 스레드가 사용되기도 합니다.

일반적인 요청 처리 과정

  1. 클라이언트로부터 HTTP 요청이 들어오면, 톰캣은 미리 준비해둔 스레드 풀(Thread Pool)에서 스레드를 하나 할당합니다. 이 스레드가 요청 처리의 주인공이 됩니다.
  2. 이 스레드는 스프링의 DispatcherServlet을 호출하고, DispatcherServlet은 요청에 맞는 컨트롤러 메서드를 찾아 실행합니다.
  3. 이 모든 과정은 처음에 할당된 톰캣 스레드 위에서 순차적으로 실행됩니다.

@Async를 사용할 경우

만약 컨트롤러가 @Async 어노테이션이 붙은 메서드를 호출하면 상황이 달라집니다. @Async 메서드는 기존 톰캣 스레드에서 바로 실행되지 않고, 스프링이 관리하는 별도의 스레드 풀로 작업이 위임됩니다.

스프링은 이 스레드 풀에서 새로운 스레드를 꺼내 @Async 메서드를 실행시키고, 기존 톰캣 스레드는 자신의 다음 작업(예: 클라이언트에게 최종 응답 전송)을 계속 진행합니다.


❓ 질문 2: 스프링의 스레드 풀도 직접 설정할 수 있나요?

네, 맞습니다.

톰캣의 스레드 풀 설정을 application.yml 등에서 제어할 수 있듯이, 스프링이 @Async에 사용하는 스레드 풀도 직접 설정할 수 있습니다. ThreadPoolTaskExecutorBean으로 등록하여 세밀하게 제어해 보겠습니다.

1단계: @EnableAsync 어노테이션 추가

먼저, 스프링 부트 애플리케이션에 비동기 기능을 활성화하기 위해 @EnableAsync 어노테이션을 추가합니다.

@SpringBootApplication
@EnableAsync
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}

2단계: ThreadPoolTaskExecutor 빈(Bean) 등록

다음으로, 스레드 풀의 상세 설정을 담은 ThreadPoolTaskExecutor를 설정 클래스에 Bean으로 등록합니다.

@Configuration
public class AsyncConfig {

    @Bean(name = "asyncThreadPool")
    public Executor asyncThreadPool() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        
        // 🛠️ 스레드 풀 핵심 설정
        executor.setCorePoolSize(5);        // 기본 스레드 수
        executor.setMaxPoolSize(10);        // 최대 스레드 수
        executor.setQueueCapacity(50);      // 대기 큐 사이즈
        executor.setThreadNamePrefix("async-task-"); // 스레드 이름 접두사

        executor.initialize(); // 스레드 풀 초기화
        return executor;
    }
}
  • corePoolSize: 평상시 유지되는 최소 스레드 수 입니다.
  • maxPoolSize: 요청이 몰릴 때 최대로 생성할 수 있는 스레드 수입니다.
  • queueCapacity: corePoolSize의 스레드가 모두 바쁠 때, 새로운 작업을 대기시키는 큐의 크기입니다. 큐가 가득 차면 maxPoolSize까지 스레드를 추가로 생성합니다.

3단계: @Async에서 커스텀 스레드 풀 사용

이제 @Async 어노테이션에 위에서 정의한 Bean의 이름을 명시하여, 우리가 직접 설정한 스레드 풀을 사용하도록 지정할 수 있습니다.

@Service
public class EmailService {

    @Async("asyncThreadPool") // 위에서 정의한 빈 이름을 사용
    public void sendEmail(String to, String subject, String body) {
        // 이메일 전송 로직 (새로운 스레드에서 실행됨)
        System.out.println("메일 전송 스레드: " + Thread.currentThread().getName());
        // ...
    }
}