브라우저라는 프로세스 한개를 실행하면 다운로드랑 화면 로딩이랑 폼 입력 등 여러가지 작업이 동시에 실행된다. 이 각각의 작업이 모두 스레드이다.
스레드란?
프로세스를 구성하는 실행 흐름 단위
- 스레드 = CPU가 실제로 스케줄링해서 실행하는 단위
- 프로세스 = 자원을 소유하는 단위 (주소 공간, 열린 파일 등을 가짐)

프로세스는 각각 독립적으로 코드/데이터/힙/스택 영역을 가지지만, 스레드는 코드/데이터/힙/열린파일 영역을 같은 프로세스의 다른 스레드들과 공유한다. 스택만 따로 쓴다. (각 스레드는 스레드ID, 프로그램 카운터, 레지스터값, 스택을 가짐)
멀티 프로세스 vs 멀티 스레드
- 멀티 프로세스 : 여러 프로세스를 동시에 실행
- 멀티 스레드 : 한 프로세스 안에서 여러 스레드를 동시에 실행
같은 프로세스의 스레드들끼리는 프로세스 자원을 공유하므로 메모리를 덜 차지하게 된다.
또 스레드끼리 문맥 교환을 빠르게 할 수 있다. 주소 공간이 같아서 페이지 테이블을 안 바꿔도 되기 때문이다.

멀티스레드의 동시성과 병렬성
- 동시성: 코어가 하나여도, 여러 스레드를 아주 빠르게 번갈아 실행해서 동시에 도는 것처럼 보이게 하는 것
- 병렬성: 코어가 여러 개라서 스레드들이 진짜 같은 순간에 돌아가는 것, 멀티코어가 필요함
사용자 수준 스레드 vs 커널 수준 스레드
스레드의 관리 주체에 따라 나뉨
- 커널 수준 스레드 : 운영체제 커널이 스레드의 존재를 알고 직접 스케줄링 한다
- 병렬 실행이 되고, 한 스레드가 막혀도 다른 스레드는 계속 돈다
- 스레드 생성/전환에 커널 개입이 필요해서 무거움
- 사용자 수준 스레드: 라이브러리가 사용자 공간에서 스레드를 관리
- 커널은 여러개의 사용자 스레드를 1개의 프로세스로 인식함
- 생성·전환이 커널 개입 없이 빠름
- 한 스레드가 블로킹 시스템 호출(예: 파일 읽기)을 하면, 커널은 프로세스 전체가 멈춘 걸로 인식하고 나머지 스레드들도 다 멈춘다
멀티 스레딩 모델
사용자 수준 스레드와 커널 수준 스레드를 연결하는 방식

- 다대일 (M:1) : 사용자 스레드 여러 개를 커널 스레드 1개에 몰아넣는 방식
- 전환이 빠르지만, 멀티코어여도 병렬 실행이 안 되고 한 스레드가 블로킹되면 전부 멈춤
- 일대일 (1:1) : 사용자 스레드마다 커널 스레드를 하나씩 붙이는 방식
- 병렬실행이 가능하고 하나가 막혀도 나머지가 실행됨
- 커널 스레드 자체가 무거운 자원이라(스택 메모리 + 커널 관리 비용) 수만 개씩 만들면 한계에 부딪힌다
- 리눅스·윈도우의 기본 스레드
- 다대다 (M:N) : 사용자 스레드 m개를 커널 스레드 n개에 유연하게 다중화
- 둘의 장점을 취하지만 스케줄러를 두 겹으로 짜야 해서 구현이 어려움
🎈 Java의 멀티스레드 모델
- 그린 스레드 (M:1) : 초창기(~1.2x)에 사용하다가 성능 한계로 제거되었다.
- 네이티브 스레드 (1:1) : JVM의 플랫폼 스레드가 OS 커널 스레드와 1:1로 매핑된다. 스레드 하나당 비용이 꽤 크고(스택 메모리 보통 ~1MB), Spring에서 요청마다 스레드를 하나씩 잡으므로 동시 접속이 수만 건으로 가면 한계에 부딪힌다.
- 가상 스레드 (M:N) : 수많은 가상 스레드를 소수의 플랫폼 스레드 위에 다중화한다. 가상 스레드가 블로킹 I/O를 만나면 JVM은 그걸 플랫폼 스레드에서 떼어내고(unmount) 다른 가상 스레드를 올린다. 커널 스레드는 코어 수만큼만 쓰면서 높은 동시성을 낸다. Java 21의 가상 스레드에 적용된다.
스레드 풀
스레드는 문맥 교환이 가볍지만 생성·소멸 비용은 비싸다. 1:1 모델에서 스레드 하나 만들 때마다 커널 스레드 생성 + 스택 메모리 할당이 일어난다. 또한 너무 많은 스레드가 생성되면 메모리가 고갈되고 과도한 문맥 교환으로 느려진다 (스레싱)
따라서 요청마다 스레드를 새로 만들지 않고, 스레드를 미리 정해진 개수만큼 만들어두고 재사용한다.
풀의 구조
- 작업을 제출하는 쪽 = 생산자
- 작업 큐 : 제출된 작업들이 쌓이는 곳. = 공유 버퍼
- 워커 스레드 : 큐에서 작업을 하나 꺼내 실행하고, 끝나면 다시 큐에서 다음 걸 꺼냄 = 소비자
제출하는 쪽은 스레드를 신경 안 쓰고 큐에 넣으면 되고, 워커는 자기 수명 동안 큐를 계속 비우기만 한다.
풀 크기 설정
작업 속도를 CPU 속도가 결정하는지, I/O 속도가 결정하는지에 따라 다르다.
- CPU 바운드 : 코어 수(N) 또는 N+1 정도로 설정
- 코어보다 스레드를 많이 둬봐야 진짜 병렬은 코어 수만큼만 되고, 나머지는 문맥 교환 오버헤드만 늘림
- 복잡한 연산, 랜더링, 게임, 대규모 머신러닝 등
- I/O 바운드 :
N × (1 + 대기시간/계산시간) 정도로 코어보다 훨씬 많이 설정
- 스레드가 I/O를 기다리며 블로킹된 동안 CPU가 놀면 안 되니까 그 빈 시간을 다른 스레드로 채운다
- DB나 외부 API 요청으로 인한 대기가 많은 경우
가상 스레드에서 스레드 풀
- 가상 스레드는 생성 비용이 거의 공짜라서 풀링할 이유가 사라진다
- Java 21+/Spring Boot 3.2+에선 작업마다 가상 스레드를 새로 만드는 게 권장됨 (스레드풀 사용은 안티패턴)