한 줄 정의

프로세스 내에서 실행되는 독립적인 제어 흐름. CPU 스케줄링의 기본 단위이며, 같은 프로세스의 스레드들은 메모리를 공유한다.

쉽게 말하면

프로세스가 “식당”이라면, 스레드는 “그 식당의 요리사”입니다. 요리사들은 같은 주방(Heap, Code, Data)을 공유하면서 각자 다른 요리(작업)를 동시에 진행하되, 자기 도마(Stack)만큼은 독립적으로 갖고 있습니다. 주방을 공유하는 덕분에 재료 전달은 빠르지만, 같은 냄비를 동시에 쓰려면 순서를 정해야 합니다 — 이것이 바로 동기화입니다.

왜 이걸 알아야 하는가?

  • 서버 성능 튜닝의 핵심 변수: 톰캣 스레드 풀 200개가 전부 DB I/O 대기 중이면 201번째 요청은 큐에서 대기하게 되는데, 이 현상을 이해하고 해결하려면 “스레드가 블로킹 I/O에서 커널 스레드를 점유한다”는 사실을 알아야 합니다.
  • thread dump 분석: 장애 상황에서 jstack으로 thread dump를 뜨면 RUNNABLE, WAITING, BLOCKED 상태의 스레드 목록이 나옵니다. 이 상태가 무엇을 의미하는지, 어떤 스레드가 어떤 lock을 잡고 있는지 해석하려면 스레드의 동작 원리를 이해하고 있어야 합니다.
  • 동시성 모델 선택의 기준: 플랫폼 스레드, Virtual Thread, Coroutine, 리액티브 — 선택지가 많아진 만큼 각각의 스케줄링 방식과 트레이드오프를 이해해야 프로젝트에 맞는 결정을 내릴 수 있습니다.

왜 이렇게 설계했는가?

  • 해결하는 문제: 하나의 프로그램 안에서 여러 작업을 동시에 처리하고 싶다는 것이 핵심입니다. 웹 서버가 요청 1000개를 처리할 때 프로세스 1000개를 fork하면 메모리만 수십 GB가 필요하기 때문입니다.
  • 이게 없다면: 동시 처리마다 프로세스를 생성해야 하므로, fork 비용(PCB 복제, 페이지 테이블 복사), IPC 통신 오버헤드, 메모리 낭비가 막대합니다. Apache의 prefork MPM이 바로 이 방식이었고, 동시 접속 10K 문제(C10K Problem)에 부딪혔습니다.
  • 핵심 이점: 스레드는 같은 주소 공간을 공유하므로 생성 비용이 낮고 데이터 공유에 IPC가 필요 없습니다. 대신 공유 자원에 대한 동기화 책임이 프로그래머에게 넘어옵니다.

어떻게 동작하는가?

스레드가 공유하는 것 vs 독립인 것

공유 (프로세스 레벨)독립 (스레드 레벨)
Code 영역 (Text)Stack (함수 호출 프레임, 지역변수)
Data 영역 (전역변수)Program Counter (PC)
Heap 영역 (동적 할당)CPU 레지스터 세트
파일 디스크립터 테이블스레드 ID (TID)
시그널 핸들러에러 번호 (errno)
프로세스 ID (PID)스케줄링 우선순위

왜 Stack만 독립인가? 각 스레드가 독립적인 함수 호출 체인을 갖기 때문입니다. Thread 1이 methodA() → methodB()를 실행하는 동안 Thread 2는 methodC() → methodD()를 실행합니다. 호출 스택이 섞이면 리턴 주소가 엉망이 됩니다.

스레딩 모델

스레딩 모델의 핵심 질문은 하나입니다: 유저 공간의 스레드를 커널이 어떻게 인식하고 스케줄링하는가? 이 매핑 방식에 따라 블로킹 동작, 멀티코어 활용, 생성 비용이 완전히 달라집니다.

1. Many-to-One (유저 레벨 스레드)

여러 유저 스레드가 하나의 커널 스레드에 매핑되며, 커널은 유저 스레드의 존재 자체를 모릅니다.

  • 스레드 전환: 유저 공간의 라이브러리가 처리하며, 시스템 콜 없이 레지스터만 교체하면 되므로 전환 자체는 매우 빠릅니다 (~100ns).
  • 치명적 문제 — 블로킹 I/O: 커널 입장에서는 스레드가 하나뿐이기 때문에, 그 안의 유저 스레드 하나가 read() 같은 블로킹 시스템 콜을 호출하면 커널이 유일한 커널 스레드를 WAITING으로 전환합니다. 결과적으로 같은 프로세스의 모든 유저 스레드가 멈추며, 유저 라이브러리가 아무리 스케줄링을 잘해도 커널이 CPU를 안 주면 아무것도 할 수 없습니다.
  • 멀티코어 활용 불가: 커널이 보는 스케줄링 단위가 1개이므로, 코어가 8개여도 이 프로세스에는 1개만 할당됩니다.
  • 왜 이렇게 만들었나: 1990년대에는 커널 스레드 생성 비용이 극도로 높았기 때문에, 유저 공간에서 경량 스레드를 돌리는 것이 현실적인 선택이었습니다.
  • 예시: 초기 Java의 Green Thread (Solaris), GNU Portable Threads

2. One-to-One (커널 레벨 스레드)

유저 스레드 1개가 커널 스레드 1개에 대응하며, 현재 대부분의 OS와 런타임이 사용하는 모델입니다.

  • 블로킹 해결: 스레드 A가 I/O로 블록되면 커널이 스레드 A만 WAITING으로 바꾸고 스레드 B에 CPU를 할당합니다. 각 스레드가 독립적인 스케줄링 단위이기 때문에 가능한 방식입니다.
  • 멀티코어 활용: 커널 스케줄러가 각 스레드를 서로 다른 코어에 배치할 수 있으므로, 4개 스레드를 4개 코어에서 진짜 병렬로 실행할 수 있습니다.
  • 비용 문제: 스레드 생성이 곧 커널 오브젝트 생성이라서, task_struct 할당, 커널 스택(보통 8~16KB) 할당, 스케줄러 등록이 필요합니다. 스레드 하나당 유저 스택도 기본 1MB (Linux 기준 ulimit -s)이므로, 10,000개를 생성하면 스택만 10GB에 달합니다.
  • 컨텍스트 스위칭 비용: 유저 모드 → 커널 모드 전환이 필요하므로 Many-to-One보다 스위칭이 느리지만 (~1-10μs), 같은 프로세스 내 스레드 간 전환은 주소 공간 교체가 없어서 프로세스 간 전환보다는 훨씬 가볍습니다.
  • 한계점: 바로 이 모델이 C10K Problem의 원인입니다. 동시 접속 10,000개를 thread-per-connection으로 처리하면 스레드 10,000개가 필요한데, 대부분은 I/O 대기 중에도 커널 스레드를 점유하므로 메모리 낭비와 스케줄러 부하가 발생합니다.
  • 예시: Linux NPTL, Windows, macOS, 현재 Java HotSpot JVM. new Thread()는 내부적으로 pthread_create()clone() 시스템 콜을 호출해 커널 스레드를 생성합니다.

3. Many-to-Many

M개 유저 스레드를 N개 커널 스레드에 매핑합니다 (M ≥ N). One-to-One과 Many-to-One의 장점을 모두 취하려는 시도입니다.

  • 이론적 장점: 유저 스레드 수와 관계없이 커널 스레드 수를 코어 수에 맞출 수 있습니다. 블로킹 I/O가 발생하면 해당 유저 스레드를 다른 커널 스레드로 재매핑합니다.
  • 왜 실패했나: 유저 스레드 스케줄러와 커널 스케줄러 두 개가 동시에 동작하면서 서로의 결정을 방해합니다. 커널이 커널 스레드를 선점했는데 그 위에서 돌던 유저 스레드가 lock을 잡고 있었다면? 다른 유저 스레드가 그 lock을 기다리며 전부 멈춥니다. 이런 priority inversion 문제를 해결하려면 유저 스케줄러와 커널 스케줄러 간 통신이 필요한데, 이 복잡도가 성능 이점을 상쇄했습니다.
  • Solaris의 시도: LWP(Lightweight Process)라는 중간 계층을 둬서 유저 스레드 ↔ LWP ↔ 커널 스레드 3단 매핑을 구현했으나, 결국 Solaris 9부터 One-to-One으로 회귀했습니다.

4. M:N의 부활 — 런타임 레벨 경량 스레드

Many-to-Many가 실패한 이유는 “범용 스케줄러 두 개의 충돌”이었습니다. 현대의 경량 스레드 모델들은 런타임이 I/O 시점을 정확히 알고 있다는 조건에서 M:N을 다시 구현하며, 핵심은 “블로킹 시점에 스택을 통째로 떼어내고 캐리어 스레드를 반환”하는 것입니다.

각 구현체의 접근 방식과 트레이드오프는 별도 문서에서 다룹니다:

  • Java Virtual Thread — JVM 레벨 M:N, Continuation 기반, 비선점 스케줄링
  • Kotlin Coroutine — 컴파일러 레벨 CPS 변환, CoroutineDispatcher, Structured Concurrency

Linux에서의 스레드 구현

Linux 커널은 프로세스와 스레드를 구분하지 않으며, 둘 다 task_struct로 표현됩니다. 차이는 clone() 시스템 콜에 넘기는 플래그뿐입니다.

  • fork() = clone(SIGCHLD): 새 주소 공간, 새 파일 테이블, 새 시그널 핸들러를 갖는 완전한 복제입니다.
  • 스레드 생성 = clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD): 주소 공간, 파일 테이블, 시그널 핸들러를 모두 공유하고 Stack만 새로 할당됩니다.
  • 스케줄러(CFS) 입장에서는 둘 다 동일한 task_struct이므로, 스레드든 프로세스든 같은 방식으로 CPU 시간을 배분합니다.
  • 이러한 설계 덕분에 Linux에서 “스레드 생성”은 “프로세스 생성의 경량 버전”에 불과하며, 별도의 스레드 서브시스템이 존재하지 않습니다.

시각화

멀티스레드 프로세스 구조

graph TD
    subgraph Process["프로세스 (PID: 1234)"]
        subgraph Shared["공유 영역"]
            Code["Code — 실행 코드"]
            Data["Data — 전역변수"]
            Heap["Heap — 동적 할당"]
            FD["파일 디스크립터 테이블"]
        end
        subgraph T1["Thread 1 (TID: 1234)"]
            S1["Stack 1"]
            PC1["PC + Registers"]
        end
        subgraph T2["Thread 2 (TID: 1235)"]
            S2["Stack 2"]
            PC2["PC + Registers"]
        end
        subgraph T3["Thread 3 (TID: 1236)"]
            S3["Stack 3"]
            PC3["PC + Registers"]
        end
    end

스레딩 모델 비교

graph TB
    subgraph ManyToOne["Many-to-One"]
        U1["User Thread 1"] --> K1["Kernel Thread"]
        U2["User Thread 2"] --> K1
        U3["User Thread 3"] --> K1
    end
    subgraph OneToOne["One-to-One"]
        U4["User Thread 1"] --> K2["Kernel Thread 1"]
        U5["User Thread 2"] --> K3["Kernel Thread 2"]
        U6["User Thread 3"] --> K4["Kernel Thread 3"]
    end
    subgraph Virtual["Virtual Thread (M:N)"]
        U7["Virtual Thread 1"] --> K5["Carrier Thread 1"]
        U8["Virtual Thread 2"] --> K5
        U9["Virtual Thread 3"] --> K6["Carrier Thread 2"]
        U10["Virtual Thread 4"] --> K6
    end

정리

기준프로세스플랫폼 스레드Virtual ThreadKotlin Coroutine
생성 비용~10ms, ~MB 메모리~1ms, ~1MB Stack~1μs, ~수 KB~수십 ns, ~수백 바이트
최대 생성 수수백~수천수천~수만수백만수백만
격리 수준완전 격리공유 메모리공유 메모리공유 메모리
블로킹 I/O 처리해당 프로세스만커널 스레드 점유자동 unmountsuspend로 양보
스케줄링OS 선점OS 선점비선점 (JVM)비선점 (Dispatcher)
멀티코어 활용OOO (캐리어 스레드)O (Dispatchers.Default)
적합한 상황격리 필요CPU-bound 병렬I/O-bound 대량 동시성I/O-bound + 구조화된 동시성

실무에서 만난 사례

  • Tomcat 스레드 풀: 기본 max 200 스레드로, 요청마다 스레드 하나를 할당하는 thread-per-request 모델입니다. 스레드가 모두 DB I/O 대기 중이면 201번째 요청부터 큐에 쌓이는데, 이것이 “스레드 풀 고갈” 장애의 전형적인 패턴입니다.
  • Spring WebFlux: 스레드를 블로킹하지 않는 리액티브 모델로, 소수의 이벤트 루프 스레드로 수만 동시 요청을 처리할 수 있습니다. 다만 코드가 복잡해진다는 단점이 있습니다.
  • Spring Boot 3.2 + Virtual Thread: spring.threads.virtual.enabled=true 한 줄로 톰캣이 가상 스레드를 사용하게 되며, 기존 thread-per-request 코드를 그대로 쓰면서도 동시성이 대폭 향상됩니다. WebFlux의 복잡성 없이 높은 동시성을 얻는 실용적인 선택입니다.

관련 개념

출처

  • Abraham Silberschatz, “Operating System Concepts” (공룡책) Chapter 4
  • Brian Goetz, “Java Concurrency in Practice”
  • JEP 444: Virtual Threads (Project Loom)