스레드
스레드(Thread)는 프로세스 내부의 실행 단위입니다. 하나의 프로세스는 여러 스레드를 가질 수 있으며, 스레드들은 Code·Data·Heap 영역을 공유하고 Stack과 레지스터만 독립적으로 유지합니다. 이 구조 덕분에 프로세스보다 훨씬 가볍게 생성·전환할 수 있습니다.
핵심 개념
스레드 구성 요소
각 스레드는 독립적인 Stack(지역변수·함수 호출), PC(Program Counter), 레지스터 세트를 가집니다. 반면 Code·Data·Heap·열린 파일 등은 같은 프로세스의 스레드끼리 모두 공유합니다.
핵심 개념
멀티스레딩의 장점
응답성(Responsiveness) — 한 스레드가 I/O 블로킹 중에도 다른 스레드가 실행됩니다. 자원 공유 — 프로세스 간 IPC 없이 메모리를 직접 공유합니다. 경제성 — 프로세스 생성보다 스레드 생성이 훨씬 빠릅니다.
핵심 개념
커널 스레드 vs 유저 스레드
커널이 지원하는 스레드(1:1 모델)는 OS가 직접 스케줄링하며 멀티코어를 활용할 수 있습니다. 유저 스레드(N:1)는 라이브러리가 관리해 컨텍스트 스위치 비용이 낮지만, 하나가 블로킹되면 전체가 멈춥니다. Linux의 NPTL은 1:1 모델입니다.
핵심 개념
스레드 안전(Thread Safety)
여러 스레드가 공유 자원에 동시 접근하면 Race Condition이 발생합니다. Mutex·Lock으로 임계 구역(Critical Section)을 보호하거나, 불변 객체(Immutable Object)·스레드 로컬 저장소(TLS)를 활용해 공유를 피합니다.
핵심 개념
스레드 풀(Thread Pool)
미리 스레드를 생성해 두고 작업을 큐에 넣어 재사용하는 패턴입니다. 매 요청마다 스레드를 생성·삭제하는 비용을 없애고, 최대 스레드 수를 제한해 자원 고갈을 방지합니다. Java의 ExecutorService, .NET의 ThreadPool, Tomcat connector pool이 대표적입니다.
핵심 개념
프로세스 vs 스레드
프로세스는 독립된 주소 공간을 가져 하나가 죽어도 다른 프로세스에 영향이 없습니다. 스레드는 주소 공간을 공유하므로 하나가 잘못된 메모리에 접근하면 같은 프로세스의 모든 스레드가 죽습니다. 컨텍스트 스위치 비용은 보통 스레드가 더 작습니다.
스레드는 왜 필요한가
프로세스마다 주소 공간이 따로라서 프로세스 사이에서 데이터를 나누려면 IPC가 필요합니다. 스레드는 같은 프로세스 안에서 주소 공간을 공유하는 실행 흐름이라, 같은 메모리를 직접 읽고 쓰며 협력할 수 있습니다. 대신 공유하는 만큼 동기화 문제가 따라옵니다. 리눅스의 POSIX 스레드 사용법은 pthreads(7) 매뉴얼에 정리되어 있습니다.
숫자로 확인하기
count++는 읽기, 더하기, 쓰기의 세 단계입니다. count가 5일 때 두 스레드가 모두 5를 읽고 각자 6을 쓰면 두 번 증가시켰는데 결과가 7이 아니라 6입니다. 락 없이 각각 100,000번 증가시키면 결과가 200,000보다 작을 수 있습니다.
maxThreads 기본값이 200이라고 설명하고, Spring Boot에서는 server.tomcat.threads.max로 조정합니다. 동시에 처리하는 요청 수가 이 값으로 제한됩니다(가상 스레드를 켜면 이 값은 적용되지 않습니다).
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
프로세스와 스레드의 차이는 무엇인가요?
프로세스는 독립된 주소 공간과 자원을 가지고, 스레드는 같은 프로세스의 코드, 데이터, 힙, 열린 파일을 공유하며 스택과 레지스터만 따로 가집니다. 스레드가 생성과 전환이 가볍지만 서로의 오류와 경쟁 상태에 영향을 받습니다.
면접 질문
스레드 풀을 쓰는 이유는 무엇인가요?
요청마다 스레드를 만들고 없애는 비용을 줄이고, 스레드 수의 상한을 두어 자원 고갈과 과도한 컨텍스트 스위칭을 막기 위해서입니다. 작업은 큐에 쌓였다가 가용한 스레드가 가져갑니다.
면접 질문
경쟁 상태는 어떻게 막나요?
공유 데이터에 접근하는 임계 구역을 락이나 뮤텍스로 보호하거나, 공유 자체를 없애도록 불변 객체와 스레드 로컬 저장소를 씁니다. 원자적 연산을 제공하는 자료구조를 쓰는 방법도 있습니다.
면접 질문
가상 스레드는 기존 스레드와 어떻게 다른가요?
플랫폼 스레드는 운영체제 스레드와 1:1이라 수를 많이 늘리기 어렵습니다. 가상 스레드는 JVM이 소수의 플랫폼 스레드 위에 얹어 스케줄링해서, 블로킹 I/O가 많은 서버에서 요청마다 스레드 하나를 쓰는 단순한 모델을 유지하며 동시성을 크게 늘릴 수 있습니다.
실제 시스템에서는
ps -eLf는 스레드를 한 줄씩 보여 주고, top -H는 스레드 단위로 CPU 사용을 보여 줍니다. NPTL 구현은 pthreads(7) 매뉴얼이 설명합니다.
spring.threads.virtual.enabled=true 속성으로 가상 스레드를 켤 수 있다고 문서가 설명합니다(JDK 21 이상).
함께 보면 좋은 토픽
참고 자료
C — pthread 생성 / 조인 / 뮤텍스
#include <stdio.h> #include <pthread.h> // 공유 자원 — 여러 스레드가 동시에 접근하면 Race Condition 발생 static int counter = 0; static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; void *worker(void *arg) { int id = *(int *)arg; for (int i = 0; i < 100000; i++) { // 임계 구역(Critical Section) 진입 전 Lock 획득 pthread_mutex_lock(&mtx); counter++; // 원자적이지 않은 read-modify-write → Lock 필수 pthread_mutex_unlock(&mtx); } printf("Thread %d done\n", id); return NULL; } int main() { const int N = 4; pthread_t threads[N]; int ids[N]; // 스레드 생성: 각 스레드는 독립 Stack + PC 를 가지고 // counter / mtx 를 공유함 (Code·Data·Heap 공유) for (int i = 0; i < N; i++) { ids[i] = i; pthread_create(&threads[i], NULL, worker, &ids[i]); } // join(): 스레드 종료 대기 + 자원 회수 (프로세스의 wait()에 해당) for (int i = 0; i < N; i++) { pthread_join(threads[i], NULL); } printf("Final counter: %d (expected %d)\n", counter, N * 100000); pthread_mutex_destroy(&mtx); return 0; }
pthread_create()는 새 스레드를 생성합니다. 각 스레드는 독립된 Stack과 PC를 가지지만 counter·mtx 등 전역 변수는 공유합니다.
counter++ 연산은 읽기(load) → 증가 → 쓰기(store) 3단계로 이루어져 원자적이지 않습니다. Mutex 없이 실행하면 최종값이 N×100000보다 작게 나오는 Race Condition을 확인할 수 있습니다.
기본 attribute(PTHREAD_CREATE_JOINABLE)로 생성된 스레드는 pthread_join()을 호출하지 않으면 좀비 스레드로 남아 시스템 자원이 누수됩니다(누적되면 새 스레드 생성 불가).
join이 필요 없는 경우에는 pthread_detach() 또는 PTHREAD_CREATE_DETACHED 속성으로 생성해 종료 시 자동으로 회수되도록 합니다.
Java — Thread / ExecutorService / volatile
import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; public class ThreadDemo { // AtomicInteger: CAS(Compare-And-Swap) 기반 락-프리 원자 연산 private static final AtomicInteger counter = new AtomicInteger(0); // volatile: happens-before 관계 보장 (가시성 + 재정렬 금지) // 단, 복합 연산(i++)의 원자성은 보장하지 않음 private static volatile boolean running = true; public static void main(String[] args) throws Exception { // 스레드 풀: 최대 4개 스레드를 재사용 ExecutorService pool = Executors.newFixedThreadPool(4); for (int i = 0; i < 4; i++) { final int id = i; pool.submit(() -> { // Callable / Runnable 제출 for (int j = 0; j < 100_000; j++) { counter.incrementAndGet(); // 원자적 증가 } System.out.println("Worker " + id + " done"); }); } // shutdown(): 새 작업 제출 거부 + 기존 작업 완료 후 종료 pool.shutdown(); pool.awaitTermination(10, TimeUnit.SECONDS); System.out.println("Final: " + counter.get()); // 400000 // 가상 스레드 (Java 21, Project Loom, JEP 444) // JVM이 carrier thread 위에 M:N으로 스케줄링 — 수백만 개 생성 가능 try (var vtPool = Executors.newVirtualThreadPerTaskExecutor()) { Future<String> future = vtPool.submit(() -> { Thread.sleep(100); // I/O·sleep 블로킹 시 carrier에서 unmount return "virtual thread result"; }); System.out.println(future.get()); } } }
Java의 스레드 풀(ExecutorService)은 미리 생성된 스레드를 재사용해 생성·삭제 비용을 없앱니다. 제출된 작업은 내부 큐에 쌓이고 유휴 스레드가 꺼내 실행합니다.
AtomicInteger는 CAS 명령어를 활용해 Mutex 없이도 원자적 증가를 보장합니다. 경쟁이 낮을 때는 synchronized 블록보다 오버헤드가 적지만, 경쟁이 매우 높으면 CAS 재시도 비용으로 성능이 떨어질 수 있습니다.
volatile은 메모리 배리어를 삽입해 레지스터 캐싱·명령어 재정렬을 금지하고 happens-before 관계를 보장합니다. 한 스레드의 쓰기가 이후 다른 스레드의 읽기에 즉시 보이므로 단순 플래그 변수에 적합합니다. 단, read-modify-write 복합 연산의 원자성은 보장하지 않습니다.
Java 21의 가상 스레드(Virtual Thread, JEP 444)는 OS 스레드 1:1 매핑이 아닌 JVM 스케줄러(ForkJoinPool 기반 carrier thread)에 M:N으로 마운트됩니다.
I/O·Thread.sleep 등 블로킹 시 carrier에서 unmount되어 다른 가상 스레드가 실행되므로, 수백만 개의 동시 작업을 적은 OS 스레드로 처리할 수 있습니다.
다만 Java 21~23에서는 synchronized 블록 내부에서 블로킹하면 carrier가 pinning되어 unmount되지 않으므로 이런 경우 ReentrantLock 사용이 권장됩니다(JEP 491에서 Java 24부터 해소).
가상 스레드의 스택도 힙에 저장되므로 메모리는 소모하지만 OS 스레드보다 훨씬 가볍습니다.
Q1
같은 프로세스 내의 스레드들이 공유하는 것은?
Q2
여러 스레드가 동시에 같은 변수에 접근할 때 락(Lock)이 필요한 이유는?
Q3
멀티스레딩의 주요 장점은?