스레드는 왜 있는가?
스레드는 기본적으로 프로세스 하나 안에서 병렬처리를 하고싶어서 만들어졌습니다. 무거운 하나의 일을 처리하는데 여러 개의 프로세스를 이용하는 것은 효율적이지 못하기 때문입니다.
현대의 모든 OS에서 스케줄링의 기본 단위는 프로세스가 아니라 스레드입니다.
스레드란 무엇인가?
운영체제는 프로그램을 실행시키기 위해 프로그램의 코드와 데이터를 메모리에서 가져오고, 프로세스 제어 블록(PCB)을 생성하고, 작업에 필요한 메모리를 확보한 후, 준비된 프로세스를 준비 큐에 삽입합니다. 이렇게 프로세스가 생성되면 CPU 스케줄러는 CPU에게 해야 하는 일을 전달하고, CPU가 그 일을 하게 됩니다. 이때 CPU가 받는 일을 스레드라고 합니다. 즉, 스레드는 실행 중인 프로그램인 프로세스 내에서 실제로 작업을 수행하는 실행 단위이면서, CPU 입장에서는 작업의 단위가 됩니다.
스레드는 아래와 같은 역할을 담당합니다.
- 프로세스가 자원(메모리, 주소 공간 등)을 담는 컨테이너 역할을 한다면, 스레드는 그 안에서 순차적인 실행 흐름(PC, Stack, Register 등)을 정의합니다.
- 같은 프로세스 내의 스레드들은 메모리와 자원(코드, 데이터, 힙 등)을 공유합니다. 이로 인해서 스레드 간의 통신은 별도의 시스템 호출 없이도 메모리를 통해 효율적으로 이루어집니다.
- 엄청난 비용을 들이지 않고도 (RAM 증설 등) 수천 개의 연결을 유지 가능.
- 수많은 동시 접속 처리에 매우 유리함.
- 프로세스 간의 전환은 CPU Cache등을 비우고 다시 채워야 하므로 오버헤드가 큼. 프로세스 간 전환은 3~5um이지만, 스레드 간 전환은 1~2um 정도 소요됨.
- 프로세스를 새로 생성(Fork)하는 것보다 스레드를 생성하는 것이 메모리 복사가 필요 없어서 훨씬 가볍고 빠릅니다.
- MySQL과 같은 프로그램은 이러한 스레드 기반 구조를 사용하여 적은 메모리로 수천 개의 연결을 처리합니다.
- Fork의 경우 많으면 수십 ms까지 소요되지만 스레드는 많아야 수십 um 소요.
아래는 실제로 스레드와 프로세스의 생성 오버헤드를 비교한 Java 코드입니다. 실제로도 굉장한 차이가 나옴을 확인해 볼 수 있습니다!
import java.io.IOException;
public class benchmark {
private static final int COUNT = 1000;
public static void main(String[] args) throws InterruptedException, IOException {
System.out.println("=== 벤치마크 시작 (개수: " + COUNT + ") ===");
// 스레드 테스트
long threadStart = System.nanoTime();
testThreads();
long threadEnd = System.nanoTime();
double threadDuration = (threadEnd - threadStart) / 1_000_000.0; // ms로 변환
System.out.println(String.format("Thread %d개 생성 및 실행 시간: %.2f ms", COUNT, threadDuration));
// 프로세스 테스트
long processStart = System.nanoTime();
testProcesses();
long processEnd = System.nanoTime();
double processDuration = (processEnd - processStart) / 1_000_000.0; // ms로 변환
System.out.println(String.format("Process %d개 생성 및 실행 시간: %.2f ms", COUNT, processDuration));
// 결과 비교
System.out.println(String.format("프로세스가 스레드보다 약 %.1f배 더 느립니다.", processDuration / threadDuration));
}
private static void testThreads() throws InterruptedException {
for (int i = 0; i < COUNT; i++) {
Thread t = new Thread(() -> {});
t.start();
t.join();
}
}
private static void testProcesses() throws IOException, InterruptedException {
ProcessBuilder pb = new ProcessBuilder("true");
for (int i = 0; i < COUNT; i++) {
Process p = pb.start(); // 프로세스 생성 (Fork + Exec 유사 비용)
p.waitFor();
}
}
}


결과 사진을 보면, 그 차이가 매우 심함을 확인할 수 있습니다.
프로세스와 스레드의 차이는 뭔데?
프로세스는 OS로부터 메모리, 파일 핸들, 보안 권한 등을 할당받는 컨테이너입니다. 서로 철저하게 격리되어 있습니다.
스레드는 CPU를 차지하고, 코드를 실행하는 실질적인 실행 단위입니다. 같은 프로세스 내의 스레드끼리는 메모리를 공유합니다.
스케줄러 입장에서는 프로세스의 구분은 있으나, 실행의 단위로서가 아니라 자원 소유의 단위(컨테이너)로서 존재합니다.
- 리눅스는 이를 효율적으로 처리하기 위해서 프로세스용 구조체를 따로 만들지 않고 메모리를 공유하는 스레드 그룹을 프로세스라고 정의함.
- 다른 OS에는 프로세스와 스레드를 자료구조 레벨에서부터 엄격하게 분리하지만 결국 CPU를 줄 세우는 것은 스레드라는 점은 다 같음.
- 커널이 스레드 개개인을 식별함. 커널이 스레드를 구분 못하면 결국 프로세스 전체를 멈출 수 밖에 없음.
이러한 이유들 안에서 언어와 런타임마다 전략이 다르지만 전통적인 언어들(Java, C/C++, Rust, Python 등)은 User와 Kernel thread가 1:1로 매핑됩니다. 다만, 나아가서 최근의 언어 및 기술들은 M:N 전략을 취하기 시작했습니다.
User vs Kernel thread
User space/Kerenl space의 구분이 없으면 일반적인 애플리케이션이 OS의 핵심 메모리를 건드릴 수 있게되고, 이는 컴퓨터 자체가 멈추는(블루 스크린 등) 불상사를 초래합니다.
이러한 영역 하에 스레드는 User space 안의 논리 실행의 단위인 유저 스레드와 Kernel 안의 커널 스레드로 나눌 수 있습니다.
| Kernel Thread | OS가 스레드 존재 인식, 개별 스레드 스케줄링 가능, 문맥 교환 오버헤드 발생 |
| User Thread | 스레드 라이브러리가 관리, OS 커널 개입 없음, 하나의 스레드 차단 시 프로세스 전체 차단 |
Kernel thread
- 커널 레벨에서 생성되는 스레드입니다. OS 내에서 생성되어 동작하는 스레드로 커널이 직접 관리합니다.
- 커널에서는 프로세스가 주 기억 장치(RAM 등) 여러 개가 적재되고 CPU 할당을 기다리며(Ready Queue에서 기다리는 등) CPU를 할당 받아야 동작합니다.
- 여기서 실행 중인 프로세스를 A에서 B로 변경하려고 하면, CPU 안에 있는 레지스터 내용을 다음에 실행할 프로세스 정보들로 교체하고 캐시를 비웁니다. (Context Switching)
- Context Switching이 일어날 때는 CPU가 일을 못합니다.
User thread
- 스레드를 관리하는 라이브러리로 인해 사용자 단에서 생성 및 관리되는 스레드입니다.
- 커널이 따로 관리하지 않습니다. 따라서 이 스레드에 대해서 알지도 못합니다.
- 순수 유저 스레드는 커널에게 허락을 받지 않고 스레드 끼리 상호작용 하므로 Context switching 비용이 없음.
- 프로세스 하나로만 보이니 스레드 하나가 인터럽트를 요구하면 프로세스 전체가 Block됨.
❓ Kernel Level Thread와 User Level Thread가 1 대 1 매핑이라며...
컴퓨터 공학 이론서에서는 개념적인 분류를 위해 둘을 엄격하게 나눕니다.
사용자 수준 스레드: 커널이 모르는 스레드 (라이브러리가 관리)
커널이 모르는 스레드: 커널이 아는 스레드 (OS가 관리)
현대 OS에서는 이 관계가 많이 복잡해져서, Java에서 짜는 코드는 User space에 있으니 User thread라고 부르지만, 실제 동작은 OS의 요청에 의해서 kernel thread와 1 대 1 매핑해버립니다. (껍데기는 사용자 스레드, 알맹이는 커널 스레드인 상태)
이렇게 되면 인터럽트 발생 시에 Context switching이 발생하지만, 해당 스레드만 정지되고 멀티 코어를 활용할 수 있게 됩니다.
❓ 왜 프로세스 혼자서는 Block을 할수 없을까? 프로세스 내부 스레드의 관리는 프로세스의 담당이 아닌가?
사용자 스레드가 파일 읽기나 네트워크 통신을 하려면 반드시 커널의 System call을 요청해야 합니다. 프로세스는 HW를 직접 건드리지 못하기 때문입니다.
커널이 스레드를 모르면 어떻게 될까요?
A 프로세스 안에 스레드 a, b, c가 있는데, A가 디스크에서 파일을 읽고 싶다고 커널에게 요청합니다.
-> 커널은 A가 스레드인지 모릅니다. 즉, 프로세스 A가 요청한 것으로 보입니다.
-> 커널이 프로세스 통째로 Block을 걸고 다른 프로세스를 실행시킵니다. 이 때문에 b, c는 돌아가지 못합니다.
cf. 그냥 파일 하나 읽고 싶다고 했다고 Context switching까지 할 일이야?
Context Switching을 안 한다: 디스크를 읽어오는데 10ms 걸린다 -> 10ms면 CPU는 1초에 수십억 번 계산을 할 수 잇는 시간인데, Disk는 달팽이마냥 느려서 그거 하나밖에 못한다. -> Wait Queue 뒤에 가서 기다리라고 명령하는 정책은 필수!
Ready Queue: CPU만 할당받으면 바로 실행될 수 있는 프로세스들이 있는 큐.
Wait Queue: CPU를 할당받아도 데이터가 올 때 까지 아무것도 못하는 프로세스들이 있는 큐.
- 우리가 짜는 코드(애플리케이션)는 유저 스레드로 동작한다.
- 이 유저 스레드는 반드시 커널 스레드와 연결(매핑)되어야만 CPU를 사용할 수 있다.
- OS(커널)는 커널 스레드만 보고 스케줄링을 한다.
- 그래서 커널 스레드 하나가 멈춰도 OS가 다른 커널 스레드는 일하라고 명령을 내릴 수 있어서, 독립적인 작업이 가능하다.
계층의 용어 정리
- User Space (사용자 영역):
- 일반 애플리케이션 (카카오톡, 자바 프로그램)
- ⊂ 프로세스 (애플리케이션이 실행된 껍데기/공간)
- ⊂ 유저 스레드 (그 공간 안에서 돌아가는 코드 조각들)
- Kernel Space (커널 영역):
- OS (운영체제)
- ⊂ 커널 스레드 (OS가 부리는 실제 일꾼)

Thread 고려사항
이렇게 장점이 많은 스레드임에도 제약 조건이 있습니다.
- 결국 동일한 메모리 공간에 접근하기 때문에 동시성 문제를 피할 수 없습니다.
- 스레드가 많아지면 커널에 매핑된 스레드일 경우 Context switching 오버헤드가 너무 커져 성능이 저하될 수 있습니다.
- 이러한 문제를 방지하기 위해서 미리 일정 수의 스레드를 생성해 두고 재사용하는 스레드 풀(Thread Pool) 방식 등을 사용합니다.
- 혹은 이러한 복잡한 동시성 문제나 스위칭 오버헤드를 없애고 고성능을 내기 위해서 단일 스레드 모델(Node.js, Redis 등)을 사용하기도 합니다.
Kernel thread와 User thread의 매핑 모델
이하 User thread는 ULT, Kernel thread는 KLT로 언급하겠습니다.
N:1 Model (Many-to-One)
구조: ULT는 여러 개인데, 연결된 KLT는 하나인 경우.
작동방식: JVM에서 모든 것을 관리하고 커널은 스레드가 1개인 것으로 인식함.
예시: Java 1.1 시절의 Green thread, 초기 솔라리스 등.
장점:
- 커널을 안 거치니 스위칭이 매우 빠름.
단점:
- 하나가 멈추면 All blocking.
- 멀티코어 불가.
1:1 Model (One-to-One)
구조: ULT 하나에 KLT 하나를 배정하는 구조.
작동 방식: 스레드를 하나 생성하면 OS가 실제로 커널 스레드를 하나 만듦.
예시: 현대 Java의 new thread(), C++, Linux, Windows
장점:
- KLT가 여러 개이므로 멀티코어를 100% 활용함.
- 스레드 병렬성을 확보할 수 있음.
단점:
- ULT 하나당 KLT 하나이므로 메모리 관리가 매우 어렵고, 스케줄링 비용이 크게 발생함.


M:N Model (Many-to-One)
구조: ULT는 수만 개(M)지만, KLT는 코어 개수만큼 적당히(N) 유지함. (Two-level Model)
작동 방식: 런타임 스케줄러가 ULT와 KLT 사이에 낌 -> ULT를 대기열에 세워두고, KLT에게 빠르게 스케줄러가 배정(Dispatch)했다가 쉴 때 바로 빼내서(Unmount) 다른 ULT를 넣음.
예시: Go (Goroutine), Java 21 (Virtual thread), Kotlin (Coroutine)
장점:
- 런타임 스케줄러로 KLT의 효율을 매우 끌어올릴 수 있음.
- Blocking I/O에서 Non-blocking I/O 로 전환됨 -> 아무것도 안하는 Blocking 시간을 활용
단점:
- 구현 난이도가 매우 어려움.

참고문헌
'개발일지 > CS' 카테고리의 다른 글
| [Network] 네트워크 대역폭의 측정과 OS와의 관계 (0) | 2026.03.03 |
|---|---|
| [Util] Jackson의 특징과 POJO의 직/역직렬화 과정 (0) | 2026.02.13 |
| [Thread] Message Broker와 스레드 (feat. Kafka) (0) | 2026.01.19 |
| [Thread] 자바의 스레드 관리 전략 (0) | 2026.01.12 |
| [Memory] 자바의 메모리 관리 전략 (0) | 2026.01.06 |