
프로세스 vs 스레드: 공장과 일꾼 (완전정복)
서비스 장애 현장에서 반드시 알아야 할 핵심. '프로세스 안의 스레드'만으로는 부족합니다. 공장과 일꾼 비유, 크롬의 멀티 프로세스 구조, fork()와 pthread, 그리고 그린 스레드와 고루틴까지 심층 분석합니다.

서비스 장애 현장에서 반드시 알아야 할 핵심. '프로세스 안의 스레드'만으로는 부족합니다. 공장과 일꾼 비유, 크롬의 멀티 프로세스 구조, fork()와 pthread, 그리고 그린 스레드와 고루틴까지 심층 분석합니다.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

동시 접속이 많아지면 "스레드를 더 늘리면 되는 거 아닌가?"라는 생각이 자연스럽게 든다. 그런데 스레드 풀 크기를 늘렸더니 오히려 응답 속도가 더 느려지는 경우가 있다. "더 많은 스레드가 더 빠를 것 같은데, 왜 이런 거지?"
이 질문이 핵심을 짚는다. "지금 병목이 CPU 바운드인가요, I/O 바운드인가요? 그리고 이 작업에는 프로세스가 나을까요, 스레드가 나을까요?"
단순한 정의 암기로는 이 질문에 답할 수 없다. 개발자로서 꼭 알아야 할 "메모리 공유", "안정성", 그리고 "비용(Cost)"의 관점을 이해해야 한다. 오늘은 이 차이를 공장(Factory)과 일꾼(Worker)의 비유로 정리해본다.
이 개념을 가장 직관적으로 이해하는 방법은 "삼성전자 반도체 공장"을 상상하는 것입니다.
이 비유가 머릿속에 확 들어왔다. 이후로는 "프로세스는 격리, 스레드는 공유"라는 핵심이 자연스럽게 이해됐다.
실제로 "메모리 구조에서 무슨 차이가 있죠?"라는 질문을 받으면 이걸 화이트보드에 그려서 설명하면 명확합니다.
각 프로세스는 운영체제로부터 4가지 영역을 모두 독립적으로 할당받습니다. 누구랑도 나누지 않습니다.
malloc, new).스레드는 프로세스 안에 살고 있기 때문에, 효율성을 위해 "Stack만 따로 쓰고 나머지는 다 공유"합니다.
핵심 질문: "스레드는 왜 Stack을 따로 갖나요?" 답변: 스레드는 독립적인 실행 흐름이기 때문에, 독립적인 함수 호출 기록(Call Stack)이 필요하기 때문입니다. Stack을 공유하면 A 스레드가 함수를 호출했는데 B 스레드가 리턴받는 말도 안 되는 상황이 벌어집니다.
처음엔 "왜 Stack만 따로 쓰지?"가 이해가 안 갔는데, 함수 호출 스택이라는 개념을 다시 생각해보니까 완전히 받아들였다. 각자의 실행 흐름을 추적하려면 독립된 Stack이 필수였던 거다.
"컨텍스트 스위칭이 뭔가요?" CPU가 실행 중인 프로세스/스레드를 멈추고 다른 녀석으로 갈아타는 과정입니다. 여기서 비용 차이가 발생합니다.
결국 성능 차이는 이 컨텍스트 스위칭 비용에서 나왔다. 웹 서버에서 수천 개의 요청을 처리할 때, 프로세스 기반으로 하면 CPU가 캐시 비우느라 정신없고, 스레드 기반으로 하면 레지스터만 바꾸면 되니까 훨씬 가볍다는 게 와닿았다.
여러분이 매일 쓰는 브라우저에도 이 철학이 담겨 있습니다.
옛날 익스플로러(IE)는 브라우저 전체가 하나의 거대한 프로세스였습니다.
구글 크롬은 "탭 하나하나를 별도의 프로세스로 만들자!"는 혁신을 도입했습니다.
크롬이 메모리를 왜 그렇게 먹는지 몰랐는데, 이 아키텍처를 이해하고 나니까 "아, 트레이드오프구나"라는 생각이 들었다. 안정성과 보안을 위해 메모리를 희생한 선택이었다는 걸 받아들였다.
파이썬 코드로 직접 확인해본다.
import threading
counter = 0 # 공유 자원
def worker():
global counter
for _ in range(100000):
counter += 1 # 같은 메모리 접근
threads = []
for _ in range(4):
t = threading.Thread(target=worker)
threads.append(t)
t.start()
for t in threads:
t.join()
print(f"Result: {counter}") # 400000이 아닐 수 있음! (Race Condition)
결과: 예상과 다른 값이 나옵니다. 여러 스레드가 같은 메모리를 동시에 건드려서 Race Condition 발생.
import multiprocessing
def worker(shared_value):
for _ in range(100000):
with shared_value.get_lock(): # 명시적 Lock 필요
shared_value.value += 1
if __name__ == '__main__':
shared_value = multiprocessing.Value('i', 0) # 공유 메모리 생성
processes = []
for _ in range(4):
p = multiprocessing.Process(target=worker, args=(shared_value,))
processes.append(p)
p.start()
for p in processes:
p.join()
print(f"Result: {shared_value.value}") # 정확히 400000
핵심 차이점:
이 코드를 직접 짜보고 나서야 "아, 그래서 멀티스레드에는 항상 Lock이 필요했구나"가 와닿았다.
여기서 더 깊게 들어가면 현대 웹 개발의 핵심인 비동기 프로그래밍이 나옵니다.
Node.js, Python asyncio는 싱글 스레드로 수천 개의 요청을 처리합니다. 어떻게?
import asyncio
async def task(name, delay):
print(f"{name} 시작")
await asyncio.sleep(delay) # I/O 대기 (CPU 안 씀)
print(f"{name} 완료")
async def main():
await asyncio.gather(
task("작업1", 2),
task("작업2", 1),
task("작업3", 3)
)
asyncio.run(main())
출력:
작업1 시작
작업2 시작
작업3 시작
작업2 완료 # 1초 후
작업1 완료 # 2초 후
작업3 완료 # 3초 후
핵심: CPU를 쓰지 않는 I/O 대기 시간에는 다른 작업으로 전환합니다. 컨텍스트 스위칭 없이 협력적으로 작동합니다.
처음엔 "비동기가 뭐가 빠르지?"라고 생각했는데, I/O 대기 중에도 CPU를 놀리지 않는다는 개념이 이해됐을 때 "결국 이거였다"는 생각이 들었다.
import asyncio
async def fetch_user(user_id):
print(f"Fetching user {user_id}...")
await asyncio.sleep(1) # DB 조회 시뮬레이션
return f"User{user_id}"
async def main():
users = await asyncio.gather(
fetch_user(1),
fetch_user(2),
fetch_user(3)
)
print(users)
asyncio.run(main())
특징:
import kotlinx.coroutines.*
suspend fun fetchUser(userId: Int): String {
delay(1000) // 비동기 대기
return "User$userId"
}
fun main() = runBlocking {
val users = listOf(1, 2, 3).map { userId ->
async { fetchUser(userId) }
}.awaitAll()
println(users) // [User1, User2, User3]
}
코틀린 코루틴의 마법:
안드로이드 앱에서 네트워크 요청에 코루틴을 쓰면 스레드보다 훨씬 직관적이다. "일시정지"라는 개념이 코드로 표현된다는 게 흥미롭다.
여기서 더 깊게 들어가면 "고수" 소리를 듣습니다. 스레드라고 다 같은 스레드가 아닙니다.
std::thread).프로세스는 서로 남남(독립적)이라 대화하기가 어렵습니다. 그래서 OS가 제공하는 특별한 전화기들이 필요합니다. 이를 IPC라고 합니다.
| 생각하면 됨).반면 스레드는? IPC 따위 필요 없습니다.
그냥 전역 변수 int global_data 선언해놓고 같이 쓰면 됩니다. 통신 비용 Zero.
백문이 불여일견. 코드로 직접 확인해봤다.
fork) - 복사본 만들기#include <unistd.h>
#include <stdio.h>
int main() {
int x = 10;
pid_t pid = fork(); // 프로세스 복제 (공장 하나 더 건설!)
if (pid == 0) {
// 자식 프로세스
x = 20;
printf("Child: x = %d\n", x); // 20
} else {
// 부모 프로세스
sleep(1);
printf("Parent: x = %d\n", x); // 10
}
}
결과: 자식이 x를 20으로 바꿔도, 부모는 여전히 10입니다. 메모리가 복사되어 분리되었기 때문입니다.
pthread_create) - 공유하기#include <pthread.h>
#include <stdio.h>
int x = 10; // 전역 변수 (공유 자원)
void* worker(void* arg) {
x = 20; // 다 같이 쓰는 x를 바꿈!
return NULL;
}
int main() {
pthread_t t;
pthread_create(&t, NULL, worker, NULL); // 일꾼 고용
pthread_join(t, NULL); // 일꾼 끝날 때까지 대기
printf("Main: x = %d\n", x); // 20
}
결과: 일꾼이 x를 20으로 바꾸면, 사장님(Main)도 20을 봅니다. 메모리를 공유하기 때문입니다.
| 특성 | 프로세스 (Process) | 스레드 (Thread) |
|---|---|---|
| 비유 | 독립된 공장 | 공장 안의 일꾼 |
| 메모리 | 완전 분리 (Code/Data/Heap/Stack) | 공유 (Code/Data/Heap) + 독립 (Stack) |
| 통신 | 어려움 (IPC 필요: 파이프, 소켓) | 쉬움 (메모리 직접 접근) |
| 생성 비용 | 비쌈 (운영체제 오버헤드 큼) | 저렴 (숟가락만 얹으면 됨) |
| 문맥 전환 | 매우 느림 (TLB/Cache Flush) | 빠름 (Register만 교체) |
| 안정성 | 높음 (하나 죽어도 됨) | 낮음 (하나 죽으면 다 죽음) |
| 대표 예시 | 크롬 탭, Nginx 워커 | 파이어폭스 탭, 게임 로직 |
"무조건 멀티 스레드가 좋은 거 아냐?" 아닙니다. 상황에 따라 다릅니다.
개발자는 이 트레이드오프(Trade-off)를 이해하고 도구를 선택하는 사람입니다. 내 서비스에 어떤 방식을 적용할지는 "얼마나 격리가 필요한가"와 "얼마나 빠른 통신이 필요한가"를 저울질해서 결정합니다.
When concurrent connections grow, the instinct is to increase the thread pool size. But sometimes, adding more threads makes response times worse.
"More threads should be faster, right? Why is this getting slower?"
This question cuts to the core of the issue. The right question to ask is: "Is this CPU-bound or I/O-bound? And for this workload—would a process or a thread be the right choice?"
Simply knowing that "a thread runs inside a process" isn't enough. The real engineering principles—Memory Sharing, Isolation, and Cost Trade-offs—are what determine the answer in high-concurrency environments.
Today, I'll break this down using the Factory vs Worker analogy that finally made it all click.
After months of confusion, I stumbled upon this comparison: Samsung Semiconductor Manufacturing Plant. Suddenly, everything made sense.
This analogy saved me. Once I visualized it this way, the memory architecture, context switching costs, and design decisions behind Chrome's multi-process model became crystal clear.
Understanding this memory structure is key to mastering concurrency.
Each process gets 4 independent memory regions from the OS:
malloc, new).Critical Point: These are completely isolated per process. Process A cannot read Process B's memory without OS intervention (IPC).
Threads are parasitic to processes. They share most resources to maximize efficiency:
The Million-Dollar Question: "Why does each thread need its own Stack?"
Answer: Because each thread executes functions independently. If they shared a Stack, Thread A calling
functionX()would overwrite Thread B's return address—causing catastrophic corruption.
I struggled with this for weeks. But once I realized the Stack holds function call frames, it became obvious why isolation was necessary.
"What is Context Switching?"
It's when the CPU saves the current execution state and loads a different one. But the cost varies wildly depending on whether you're switching processes or threads.
This is why web servers (Nginx, Apache) prefer threads over processes for handling thousands of concurrent connections. The context switching cost for threads is 10-100x lower.
I used to curse Chrome for eating 8GB of RAM with just 10 tabs open. Then I learned why.
Internet Explorer used to run one process with multiple threads for tabs.
Google Chrome made a radical decision in 2008: One process per tab.
evil.com cannot read memory from yourbank.com because they're in separate processes with separate address spaces.Now when my MacBook fans spin up with 20 Chrome tabs, I don't complain. I understand the trade-off: Isolation and security in exchange for memory.
Running these examples makes the difference concrete.
import threading
counter = 0 # Shared global variable
def worker():
global counter
for _ in range(100000):
counter += 1 # Multiple threads touch same memory
threads = []
for _ in range(4):
t = threading.Thread(target=worker)
threads.append(t)
t.start()
for t in threads:
t.join()
print(f"Result: {counter}") # Expected 400000, got 312451 (Race Condition!)
What Went Wrong: Multiple threads read-modify-write the same memory location without coordination. Classic race condition.
import multiprocessing
def worker(shared_value):
for _ in range(100000):
with shared_value.get_lock(): # Explicit lock required
shared_value.value += 1
if __name__ == '__main__':
shared_value = multiprocessing.Value('i', 0) # Shared memory segment
processes = []
for _ in range(4):
p = multiprocessing.Process(target=worker, args=(shared_value,))
processes.append(p)
p.start()
for p in processes:
p.join()
print(f"Result: {shared_value.value}") # Exactly 400000
Key Insight:
The race condition here is subtle but real—and in a production data pipeline, this kind of bug can silently corrupt data before anyone notices.
Modern languages introduced a game-changer: Asynchronous programming with event loops.
Here's how you handle thousands of concurrent operations on a single thread:
import asyncio
async def task(name, delay):
print(f"{name} started")
await asyncio.sleep(delay) # Yield control during I/O
print(f"{name} completed")
async def main():
await asyncio.gather(
task("Task1", 2),
task("Task2", 1),
task("Task3", 3)
)
asyncio.run(main())
Output:
Task1 started
Task2 started
Task3 started
Task2 completed # After 1s
Task1 completed # After 2s
Task3 completed # After 3s
Magic: No threads. No context switching. Just cooperative multitasking. When one task waits for I/O (database query, HTTP request), the event loop switches to another task.
| Workload | Best Choice |
|---|---|
| CPU-bound (image processing, ML training) | Multiprocessing |
| I/O-bound (web APIs, databases, file I/O) | Async/await |
| Mixed or Legacy | Threading (middle ground) |
A common mistake is reaching for threading on CPU-bound work like image processing—threading won't help much there, and multiprocessing is the right tool instead.
import asyncio
async def fetch_user(user_id):
print(f"Fetching user {user_id}...")
await asyncio.sleep(1) # Simulating database query
return f"User{user_id}"
async def main():
users = await asyncio.gather(
fetch_user(1),
fetch_user(2),
fetch_user(3)
)
print(users)
asyncio.run(main())
Characteristics:
awaitimport kotlinx.coroutines.*
suspend fun fetchUser(userId: Int): String {
delay(1000) // Non-blocking delay
return "User$userId"
}
fun main() = runBlocking {
val users = listOf(1, 2, 3).map { userId ->
async { fetchUser(userId) }
}.awaitAll()
println(users) // [User1, User2, User3]
}
Kotlin's Magic:
Kotlin coroutines for network calls in Android apps let code look synchronous while running asynchronously—no callback hell, much easier to follow.
Java 21 introduced a revolutionary feature that changes everything.
// Traditional Java threads (1:1 with OS threads)
for (int i = 0; i < 10000; i++) {
new Thread(() -> {
// Each thread costs ~1MB of memory
// Creating 10,000 threads = 10GB RAM!
}).start();
}
Problem: OS threads are expensive. Java couldn't scale to millions of concurrent tasks.
// Java 21 Virtual Threads (Project Loom)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000_000; i++) { // 10 MILLION
executor.submit(() -> {
// Virtual thread: only a few KB
// OS sees just a handful of platform threads
});
}
}
Magic: Virtual threads are scheduled by the JVM, not the OS. They're M:N threads—millions of virtual threads multiplexed onto a few platform threads.
Impact: Java can now handle the same scale as Go's goroutines without rewriting codebases.
Benchmarks show significant scaling improvements. With platform threads, JVM services start struggling well before tens of thousands of concurrent connections. With virtual threads, the same hardware can handle far higher concurrency—in large-scale traffic environments, this difference is substantial.
Here's where Rust blew my mind: compile-time thread safety.
int* ptr = malloc(sizeof(int));
*ptr = 42;
// Thread 1
*ptr = 100;
// Thread 2 (simultaneously)
*ptr = 200;
free(ptr);
// Thread 3 (simultaneously)
free(ptr); // Double free! Segfault!
Result: Data race, memory corruption, undefined behavior. Good luck debugging.
use std::thread;
fn main() {
let mut data = vec![1, 2, 3];
thread::spawn(move || {
data.push(4); // Takes ownership
});
println!("{:?}", data); // Compile error!
// Error: value borrowed after move
}
Rust's Rule: Either one mutable reference OR multiple immutable references—never both.
Impact: The compiler prevents data races at compile time. No runtime locks needed for many cases.
This is Rust's core promise: the learning curve is steep, but once it compiles, thread safety is guaranteed at the language level—no race conditions, no segfaults caused by concurrent memory access.
Not all threads are created equal. This distinction separates junior from senior engineers.
Thread, C++ std::thread, Python threading.Thread.Go's Goroutines Example:
func main() {
for i := 0; i < 1_000_000; i++ {
go func() {
// Goroutine: only 2KB
time.Sleep(time.Hour)
}()
}
time.Sleep(time.Hour)
}
This code creates 1 million goroutines and uses only ~2GB of RAM. Try that with OS threads—your machine would explode.
Processes are isolated. Great for safety. Terrible for communication.
| operator).Threads don't need IPC. They just read global variables. Zero overhead.
Theory is nice. Let's prove it with code.
fork) - Copy Everything#include <unistd.h>
#include <stdio.h>
int main() {
int x = 10;
pid_t pid = fork(); // Duplicate process
if (pid == 0) {
// Child process
x = 20;
printf("Child: x = %d\n", x); // 20
} else {
// Parent process
sleep(1);
printf("Parent: x = %d\n", x); // 10
}
}
Result: Child modifying x doesn't affect parent. Memory is copied, not shared.
pthread_create) - Share Memory#include <pthread.h>
#include <stdio.h>
int x = 10; // Global (shared)
void* worker(void* arg) {
x = 20; // Modifies shared memory
return NULL;
}
int main() {
pthread_t t;
pthread_create(&t, NULL, worker, NULL);
pthread_join(t, NULL);
printf("Main: x = %d\n", x); // 20
}
Result: Worker thread modifies x, main thread sees the change. Memory is shared.
| Feature | Process | Thread |
|---|---|---|
| Analogy | Entire factory | Worker inside factory |
| Memory | Isolated (Code/Data/Heap/Stack) | Shared (Code/Data/Heap) + Private (Stack) |
| Communication | Hard (IPC required) | Easy (direct memory access) |
| Creation Cost | Expensive (OS overhead) | Cheap |
| Context Switch | Slow (cache/TLB flush) | Fast (register swap) |
| Stability | Isolated failures | One crash kills all |
| Use Case | Chrome tabs, Nginx workers | Firefox tabs, game engines |