다양한 프레임워크를 통해서 서버를 개발하다 보면 자바는 멀티스레드라는 사실을 어디선가 듣게 될 것이다. 나 또한 인프런에서 김영한 선생님의 스프링 강의를 들으며, 스프링은 50개의 스레드풀을 관리하고 있고, 200개까지 늘릴 수도 있다는 이야기를 들었다.
그 당시에는 스프링으로 API를 구현하는게 급급했기 때문에, 그냥 그렇구나 하고 넘어갔지만 지금 생각해 보면 자바스크립트와 비교했을 때, 싱글 스레드 언어인 자바스크립트와 대체 어떤 부분이 다르기 때문에 스프링은 스레드풀을 지원하는 걸까? 자바는 대체 뭐길래 스프링이 이렇게 멀티 스레드를 사용할 수 있게 도와주는 걸까?
이번 포스팅에서는 스프링부트가 어떻게 멀티스레딩을 지원하는 지에 대해서 조사한 내용을 포스팅해보려고 한다. CS에 대한 내용이 꽤나 많이 들어가 있는데, 아무래도 스레드를 다루다 보니 그렇게 된 것 같다. 이해를 돕기 위해 간단한 그림 자료와 함께 설명을 첨부하였으니, 설명이 부실하다면 다른 레퍼런스와 함께 보는 것을 추천한다.
본론
먼저, 프로세스와 스레드에 대해서 이야기하기 위해서 간단한 CS 지식을 첨부한다. 운영체제와 컴퓨터구조에 대해서 빠삭한 이해가 있다면 금방 톺아볼 수 있을 것이다.
프로세스 vs 프로그램
프로그램은 개발자가 아닌 사람이라도 모두 알 것이다. 윈도우 바탕화면에 있는 리그 오브 레전드의 바로가기가 프로그램에 해당한다. 해당 프로그램을 더블클릭하면 리그 오브 레전드 프로그램이 실행된다는 것은 누구나 알 것이다.
컴퓨터공학적으로 보면, 프로그램은 디스크에 저장되어있는 데이터를 의미한다. 다음과 같이 chrome을 설치하면 컴퓨터의 디스크에 저장되게 된다. 이를 실행시키게 되면, 메모리에 적재되며 이 때의 상태는 프로세스라고 부르게 된다.
마지막으로 CPU는 메모리에 있는 다양한 프로세스들을 아주 빠르게 훑으며, 다양한 프로세스들이 동시에 돌아가는 것처럼 '보이게' 해준다. 위 예시는 docker와 vscode와 chrome을 동시에 실행시켜 프로세스로 만든 상태이고, 위 상황을 그려보면 세 프로그램이 전부 실행되고 있을 화면이 떠오른다. 하지만 동시에 실행되는 것이 아닌, 동시에 실행되는 것처럼 보인다.
이에 대해서 엄청나게 깊게 살펴보았던 전공 수업이 떠오르지만, 우리가 집중할 부분은 프로세스이다. 이제 싱글스레드와 멀티스레드에 대해서 알아보자.
싱글스레드 vs 멀티스레드
싱글스레드와 멀티스레드에서 대해서 이해하기 전에 다음의 프로세스의 구조에 대해서 알아야 한다. 프로세스는 네 가지의 영역으로 이루어져 있다. code, data, heap, stack의 구조로 이루어져 있고, 각각 다음과 같은 역할을 한다.
프로그램을 실행시키면 위와 같이 네 가지 영역으로 나뉜 프로세스가 생기게 되는데, 이렇게 하나의 stack을 가지고 있는 경우를 싱글스레드(single thread)라고 부른다. 여기서 말하는 스레드(thread)란 프로세스 안에 들어있는 더 작은 작업의 단위를 말한다. 멀티스레드의 경우에는 다음과 같은 구조를 가진다.
멀티스레드(multi thread)는 위와 같은 구조로 하나의 프로세스 안에서 다양한 작업을 동시에 하는 것처럼 보이게 할 수 있다. 먼저, 앞서 설명했던 CPU의 특성상 모든 프로세스를 스케줄링을 통해서 돌아가면서 처리하기 때문에 동시에 돌아가는 것처럼 보인다. 이러한 특징을 동시성(Concurrency)이라고 한다. 이와 함께 나오는 개념인 동시에 처리하는 것은 병렬성(Parallelism)이라고 부른다.
멀티스레드를 가장 큰 이점은 code, data, heap 영역을 스레드들이 함께 공유하기 때문에, 싱글스레드인 여러 프로세스를 실행해서 다양한 작업을 처리하는 것보다 멀티스레드를 사용하는 것이 오버헤드가 덜하다는 것이다.
이렇게 생각해보면 멀티스레드는 굉장히 좋은 존재가 아닌가? 세상에 존재하는 온갖 싱글스레드를 처분하고 멀티스레드의 시대를 열어야 할 것 같다고 생각했었지만, 멀티스레드에는 제약사항이 있으며, 장단점이 있다. 리그 오브 레전드를 예를 들어서 설명해 보자.
리그 오브 레전드에서는 다양한 기능이 상호작용한다. 간단하게 화면을 보여주는 부분과 소리가 나오는 부분을 생각해 보자. 누가 적에게 죽었을 때에는 "적에게 당했습니다"라는 소리가 나오지만, 특수한 상황(적이 세 명 이상의 연속킬을 올리는 경우, 혹은 게임 내에서 처음 킬을 달성하는 경우)에는 다른 소리가 나온다. 게임 내에서 처음 킬을 달성하면 "퍼스트 블러드"라는 소리가 나온다.
퍼스트 블러드(선취점)를 들려주려면 어떻게 해야 할까? 간단하게 생각해 보면 if문을 통해서 양쪽의 킬 스코어가 모두 0일 때, 누가 죽었다면 "퍼스트 블러드"라는 소리를 들려주면 될 것이다. 아니면 "적에게 당했습니다"일 것이다. 그와 동시에 화면에 있는 킬 스코어가 1 증가하게 된다. 멀티스레드의 관점에서 보면, 킬스코어라는 data 영역을 스크린 스레드와 보이스 스레드가 동시에 접근하여 각각 스레드의 역할을 수행한다. 킬스코어를 화면으로 보여주거나, 킬스코어에 따라서 특정한 음성을 들려주는 등의 각각의 역할을 수행하는 것이다.
하지만 여기서 큰 단점이 있는데, 스레드들이 동시에 접근하여 code, data, heap 영역과 상호작용을 하기 때문에, 이 부분에 있는 데이터가 함부로 변경되면 스레드들이 큰 영향을 받는다. 만약 두 개의 스레드가 하나의 데이터를 동시에 변경하려고 하면 어떻게 될까? 이러한 문제를 운영체제에서는 동시성 문제라고 부른다. 이러한 동시성 문제라는 제약사항 때문에, 멀티스레드의 구현을 아무렇게나 할 수 없으며, 멀티스레드를 지원하는 경우에는 동시성 문제를 예방하기 위한 다양한 알고리즘(스핀락, 세마포어) 등을 사용한다.
자바의 멀티 스레드 구현 방식
자바는 두 가지 방식을 통해서 멀티스레드를 구현한다.
Thread 클래스를 사용하는 방식
자바에는 Thread라고 하는 클래스가 존재하는데, 이를 extends를 통해 상속받아서 클래스를 구현할 수 있다. Thread 클래스 안에 있는 run()이라고 하는 메서드를 오버라이딩하면 손쉽게 멀티스레드를 사용할 수 있다.
public class MyThread extends Thread {
public void run() {
System.out.println("Thread 실행!");
}
}
new MyThread().start();
또한, 멀티스레드를 실행하기 위해서는 start() 메서드를 사용하면 된다.
Runnable 인터페이스를 사용하는 방식
이 방법 또한 결론적으로는 Thread라는 클래스를 사용한다. 이 방법은 Runnable이라고 하는 인터페이스를 implements를 통해 구현체로 받아서 run()을 정의한다. 그리고 Thread라는 클래스에 파라미터로 전달해 줌으로써 멀티스레드를 하나 만든다.
public class MyRunnable implements Runnable {
public void run() {
System.out.println("Runnable 실행!");
}
}
new Thread(new MyRunnable()).start();
이렇게 직접 멀티스레드를 구현하는 경우에는 동시에 같은 데이터에 값을 쓰지 않도록, Lock 및 Synchronized를 통해서 잠금과 동기화와 같은 운영체제의 개념을 구현해야 한다. 상상만 해도 어지러운 내용이지만, Springboot로 넘어가게 되면 스레드풀(Thread pool)이라는 개념을 통해서 손쉽게 멀티 스레드를 사용할 수 있게 해 준다.
스프링부트의 스레드풀
스레드풀(Thread Pool)은 애플리케이션에서 멀티스레딩을 효율적으로 관리하고 성능을 최적화하기 위한 메커니즘을 말한다. 스레드풀은 주로 비동기 작업이나 멀티스레드 처리와 같은 작업을 효율적으로 처리하기 위해 이러한 일을 처리하는 스레드를 미리 만들어둔 것을 의미한다.
새로운 작업이 요청되면 스레드풀에 있는 스레드가 작업을 수행하고, 작업이 끝난 후 스레드는 다시 풀(Pool)에 반환되게 된다. 이를 통해서 스레드 생성 및 제거에 드는 오버헤드를 줄일 수 있고, 응답 시간을 줄일 수 있다.
스프링부트에서의 스레드풀의 관리는 정확히 말하면, 스프링부트가 하는 것이 아니라 스프링부트에 내장된 Tomcat이라고 하는 서블릿 컨테이너(Servlet Container)에서 스레드풀을 관리하며 멀티스레드로 처리하는 역할을 하게 된다.
Tomcat?
Tomcat은 스프링부트에 내장되어 있으며, 서블릿의 역할을 하는 애플리케이션이다. 여기서 서블릿(Servlet)이란 HTTP 요청을 처리하는 역할을 한다. 우리가 HTTP Request를 받게 되면, 실제로 대부분 사용하게 되는 PathVariable이나 Parameter, Body 외에도 다양한 부분을 받게 된다.
위와 같이 복잡한 구조로 되어 있다. 저 HTTP Request를 직접 파싱 해서 쓰는 것은 굉장히 귀찮고 힘든 일이지만, 그러한 일들을 서블릿이 대신해 주게 된다. Tomcat이라는 서블릿 컨테이너가 위 과정을 도와주며, 스레드풀을 통해서 위와 같은 요청을 멀티스레드로 처리하게 된다.
Tomcat 3.2 이전 버전에서는 유저의 요청이 들어올 때마다 Servlet을 만들어 실행할 Thread를 하나씩 생성하고, 작업이 끝나면 destroy 하는 방식을 사용했으나, 스레드를 만드는 데 필요한 컴퓨팅 리소스가 꽤나 컸기 때문에, 동시다발적으로 들어오는 요청에 스레드를 만드는 것이 꽤나 부하를 많이 걸었다. 그렇기 때문에 스레드를 미리 만들어둔 후, 스레드풀에 저장해 두는 방식으로 변환이 되었다.
위 방식이 Tomcat이 스레드풀을 사용하는 방법이다. HTTP Request 요청이 들어오면 Queue에 Task를 순차적으로 전달하고, idle 한 스레드가 있다면 작업을 할당한다. 다음과 같이 스레드풀을 application.yaml에서 설정할 수 있다.
server:
tomcat:
threads:
max: 200 # 생성할 수 있는 thread의 총 개수
min-spare: 10 # 항상 활성화 되어있는(idle) thread의 개수
accept-count: 100 # 작업 큐의 사이즈
위와 같이 스레드풀을 설정함으로써 서블릿 컨테이너가 적절하게 멀티스레드로 처리할 수 있도록 설정할 수 있다. 이에 관해 어느 정도가 적절한 지는 직접 CPU 사용량 등을 조회하면서 확인할 수 있다. 또한 스레드풀에 대해서 굉장히 자세히 설명되어 있는 레퍼런스를 첨부한다.
- [daell] java는 멀티쓰레드를 어떤식으로 지원하나요? : https://velog.io/@daelkdev/Java-java는-멀티쓰레드를-어떤-식으로-지원하나요
[Java] java는 멀티쓰레드를 어떤 식으로 지원하나요?
멀티쓰레드를 지원함으로써, 하나의 자바 프로그램에서 여러 개의 쓰레드를 동시에 실행할 수 있습니다.
velog.io
DB Connection Pool
서블릿 컨테이너인 tomcat으로 인해서 요청 자체를 멀티스레드로 처리할 수 있다는 것은 알았다. 하지만 여기서 끝나면 뭔가 허전하다. 요청과 함께 그 이후의 과정은 어떻게 진행되는 지까지도 조사를 해보았다. 결론부터 말하면, 서블릿 컨테이너에서 스레드풀을 사용하는 것처럼 데이터베이스 커넥션에 사용하는 것 또한 커넥션풀(Connection Pool)이라는 개념을 사용한다. 스레드풀과 같은 이유로 DB에 데이터를 I/O 할 때마다 커넥션을 새롭게 생성하는 것 자체가 엄청난 오버헤드를 갖고 있기 때문에 한 번에 커넥션 풀을 만들어 둔 후에 필요할 때마다 가져다 쓴다.
스프링 진영에서 데이터베이스 연결에 사용하는 툴을 JDBC(Java DataBase Connector)라고 부른다. 초기에는 tomcat-jdbc를 사용했는데, 2.0 이후에서부터는 HikariCP라는 툴을 기본적으로 사용하도록 되어있다. 위와 같이 JDBC의 인터페이스로 사용할 수 있으며, HiKariCP를 사용함으로써 데이터베이스에 커넥트 하는 과정을 돕는다.
HikariCP
HikariCP는 Hikari Connection Pool이라는 뜻이다. tomcat과 함께 기본적으로 스프링부트에 내장되어 있으며, 스레드풀과 비슷한 개념으로 커넥션풀을 관리한다. application.yaml과 같은 환경 설정 파일에서 이러한 내용을 관리해 줄 수 있으며 다음과 같이 설정한다.
spring:
datasource:
url: jdbc:mysql://localhost:3306/multi-thread
username: multi-thread
password: multi-thread
hikari:
maximum-pool-size: 10
connection-timeout: 5000
connection-init-sql: SELECT 1
validation-timeout: 2000
minimum-idle: 10
idle-timeout: 600000
max-lifetime: 1800000
위와 같이 커넥션풀을 설정해 줄 수 있으며, DB에 I/O 하는 작업은 해당 HikariCP를 통해서 멀티스레드로 처리하게 된다. 여기서 한 가지, 앞에서 말했듯이 데이터를 쓰는 작업을 멀티스레드로 처리하게 되면 동시성 문제를 피할 수 없다고 이야기했는데, 그 부분은 HikariCP에서 알아서 처리해 주게 된다.
위와 같이 커넥션풀을 사용해서 데이터가 요청하는 과정에서는 스레드풀을 사용해, DB에 I/O 하는 과정에서는 커넥션풀을 사용해서 멀티스레드로 처리할 수 있다. 스프링부트가 멀티스레드 애플리케이션이라고 불리는 이유는 이러한 과정이 처음부터 끝까지 녹아있기 때문일 것이다.
또한, Java에서 지원하는 Thread라는 클래스와, 자바를 컴파일하여 자바 바이트코드로 변환한 후, 이를 실행시키는 JVM에서도 이를 멀티스레드로써 관리한다.
마치며
대규모의 트래픽 처리는 스프링부트가 가장 잘한다.
개발자들과 함께 이야기를 하다가 위와 같은 말을 들은 적이 있다. 맨 처음에는 무거운 JVM의 특성상 뭔가가 있나 보구나. 하는 생각이었고, 직접 스프링부트를 공부하면서는 스레드풀이라는 개념이 있다는 것을 알았다.
그리고 최근에는 node.js가 싱글스레드인지 멀티스레드인지 관점에 따라 사람마다 다르게 답변이 나오는 경우가 있었는데, 그에 대해서 궁금해서 직접 조사를 하게 되었다. 그러던 와중에 Java에 대해서 먼저 조사를 해보게 되었다. Java도 꽤나 재미있는 언어이다...
참고
- [velog] 스프링부트는 어떻게 다중 요청을 처리할까? : https://velog.io/@sihyung92/how-does-springboot-handle-multiple-requests
- [velog] Spring-DB커넥션풀과 Hikari CP 알아보기 : https://velog.io/@miot2j/Spring-DB커넥션풀과-Hikari-CP-알아보기
감사합니다.
'Language > Java' 카테고리의 다른 글
Java 진영의 빌드 툴인 Maven과 Gradle에 대해서 (2) | 2024.11.27 |
---|---|
자바(Java)의 역사에 대해서 (1) | 2024.01.06 |