Stage 1 · 동시성 기초 — 프로세스 vs 스레드 vs 비동기
한눈에 보기
동시성(Concurrency)은 많은 엔지니어가 “스레드 몇 개 띄우는 것”으로 착각하지만, 실제로는 훨씬 넓은 개념입니다. 이 첫 단계에서는 동시성을 이해하는 데 필요한 운영체제 수준의 세 가지 실행 단위와, 왜 비용이 다른지 살펴봅니다. 이 시리즈 전체가 이 단계에서 세운 개념 위에 서 있습니다.
이번 포스트에서 다루는 핵심 질문은 세 가지입니다.
- 프로세스·스레드·비동기는 각각 무엇이며, 왜 생성·전환 비용이 다른가?
- 동시성(Concurrent)과 병렬성(Parallel)은 어떻게 다를까?
- Python과 Go는 어떤 동시성 모델을 각각 채택했을까?
프로세스(Process)와 스레드(Thread)
프로세스 — 독립적인 실행 단위
프로세스는 운영체제가 자원(메모리, 파일 디스크립터, CPU 시간)을 할당하는 기본 단위입니다. 각 프로세스는 자신만의 독립된 가상 메모리 공간을 가집니다. 코드, 힙, 스택, 데이터 세그먼트가 서로 분리되어 있어서, 한 프로세스가 다른 프로세스의 메모리를 함부로 건드릴 수 없습니다.
# Python에서 프로세스 확인 (사실상 터미널에서도 가능)
import os
print("내 프로세스 PID:", os.getpid())
// Go에서도 동일하게 확인
package main
import (
"fmt"
"os"
)
func main() {
fmt.Println("내 프로세스 PID:", os.Getpid())
}
핵심 특징:
- 메모리 격리: 프로세스 간 메모리가 분리되어 있어, 한 프로세스가 죽어도 다른 프로세스는 영향을 받지 않습니다(크래시 격리).
- 통신 필요: 값을 주고받으려면 IPC(Inter-Process Communication, 프로세스 간 통신) — 파이프, 소켓, 공유 메모리 등 — 를 거쳐야 합니다.
- 생성이 무겁다: 새 프로세스를 만들려면 운영체제가 주소 공간을 복제하고 새 자원을 초기화해야 하므로 비용이 큽니다.
스레드 — 프로세스 안의 더 가벼운 실행 단위
스레드는 하나의 프로세스 안에서 실행되는 더 작은 실행 단위입니다. 같은 프로세스에 속한 스레드들은 코드·데이터·힙을 공유합니다. 각 스레드는 실행 상태(레지스터, 스택, PC)만 따로 가집니다.
import threading
def worker(n):
print(f"스레드 {n} 실행")
threads = [threading.Thread(target=worker, args=(i,)) for i in range(3)]
for t in threads:
t.start()
for t in threads:
t.join()
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Printf("고루틴 %d 실행\n", n) // goroutine
}(i)
}
wg.Wait()
}
핵심 특징:
- 메모리 공유: 같은 힙을 보므로 데이터를 주고받기 쉬운 대신, 동시 접근(레이스)을 막을 동기화가 필요합니다.
- 생성이 프로세스보다 가볍다: 스레드 간에는 주소 공간을 공유하므로 새 주소 공간을 만들 필요가 없습니다.
왜 프로세스는 무겁고 스레드는 가벼운가 — 컨텍스트 스위치
운영체제는 여러 작업을 매우 빠르게 교대(스케줄링) 하며 실행합니다. CPU가 어떤 작업에서 다른 작업으로 넘어갈 때, 지금 하던 작업의 상태(레지스터, 스택 포인터, PC)를 저장하고 다음 작업의 상태를 복원해야 하는데, 이 과정을 컨텍스트 스위치(Context Switch) 라고 합니다.
flowchart LR
subgraph CPU["CPU 코어"]
R1["실행 중: Task A<br/>(레지스터·PC 값 보유)"]
end
SW(["컨텍스트 스위치<br/>상태 저장 → 상태 복원"])
R2["대기 중: Task B"]
R1 -->|"Task A 상태 저장"| SW
SW -->|"Task B 상태 복원"| R2
- 프로세스 간 스위치는 TLB(주소 변환 캐시)를 비우고 페이지 테이블을 전환해야 하므로 비쌉니다.
- 스레드 간 스위치는 같은 주소 공간을 공유하므로(스택과 레지스터만 바꾸면 됨) 더 저렴합니다.
- 비동기는 스위치조차 안 합니다 — 같은 스레드가 “작업 단위”만 바꾸며 실행하므로, 스케줄러 개입이 없어 가장 가볍습니다.
동시성(Concurrency) vs 병렬성(Parallelism)
이 둘을 혼동하는 경우가 많습니다. 정확히 구분해 둡시다.
| 동시성 (Concurrency) | 병렬성 (Parallelism) | |
|---|---|---|
| 의미 | 여러 작업이 교대로 진행 (논리적 동시) | 여러 작업이 동시에 실행 (물리적 동시) |
| 하드웨어 | CPU 코어 한 개만 있어도 가능 | CPU 코어가 여러 개 필요 |
| 본질 | 구조(Structure)의 문제 | 실행(Execution)의 문제 |
| 비유 | 한 사람이 여러 요리를 번갈아 가며 | 여러 사람이 동시에 각자 요리 |
Rob Pike(Go 창시자)의 유명한 말: “동시성은 구조에 관한 것이고, 병렬성은 실행에 관한 것이다. 동시성이 아닌 프로그램이 병렬일 수는 없다.”
- 동시성은 “작업을 어떻게 쪼개고 교대할 것인가”의 설계입니다.
- 병렬성은 “그 작업들이 실제로 코어에서 동시에 도는가”의 실행입니다.
이 시리즈의 각 단계는 동시성(설계)을 세우고, 하드웨어가 허락할 때 병렬성(실행)으로 끌어올리는 흐름을 다룹니다.
파이썬과 Go의 동시성 모델 개괄
이 시리즈가 두 언어를 짝지어 보는 이유는, 두 언어가 동시성을 푸는 철학이 정반대이기 때문입니다.
파이썬 — GIL이 이끄는 “스레드는 신중히, 프로세스·비동기는 적극적으로”
파이썬은 인터프리터가 한 번에 한 바이트코드만 실행하도록 보장하는 GIL(Global Interpreter Lock) 을 가집니다. 그래서:
- 순수 CPU 작업을 여러 스레드로 돌려도 GIL 때문에 한 번에 하나만 실행되어 이득이 없습니다 → CPU 작업은
multiprocessing(프로세스)이 답입니다. - I/O 작업은 GIL을 놓고 기다리는 동안 다른 스레드가 실행되므로, 스레드도 꽤 유용합니다.
- I/O가 많은 서버라면
asyncio(비동기)가 가장 확장적입니다.
GIL의 작동 원리는 별도 포스트 Python GIL에서 자세히 다뤘습니다. 이 시리즈 전체가 파이썬 면에서 그 결론(“I/O면 스레드/비동기, CPU면 프로세스”)을 전제로 합니다.
flowchart TD
subgraph PY["파이썬 동시성 선택"]
Q1{"작업이 CPU 집약인가?"}
Q1 -->|"Yes"| P["multiprocessing<br/>(프로세스, GIL 우회)"]
Q1 -->|"No · I/O 집약"| Q2{"동시 접속 수가 많은가?"}
Q2 -->|"Yes"| A["asyncio<br/>(비동기)"]
Q2 -->|"No"| T["threading<br/>(스레드)"]
end
Go — 고루틴이 이끄는 “가볍게, 그리고 통신으로”
Go는 파이썬과 달리 조금 다른 질문을 던집니다. Go의 동시성 기본 단위는 스레드가 아니라, 고루틴(Goroutine) 입니다.
- 고루틴은 운영체제 스레드보다 훨씬 가볍다 (초기 스택 수 KB, 수십만 개도 생성 가능).
- 런타임이 M:N 스케줄러로 수많은 고루틴을 소수의 OS 스레드에 배분합니다.
- Go의 철학은 “메모리를 공유함으로써 통신하지 말고, 통신함으로써 메모리를 공유하라” — 즉 채널(Channel)로 고루틴 간에 메시지를 주고받습니다.
flowchart TD
subgraph GO["Go 동시성 선택"]
G1["바로 go 루틴으로 시작<br/>(현저히 가벼움)"]
G1 --> G2{"작업 간 데이터를<br/>어떻게 주고받나?"}
G2 -->|"채널로"| CH["channel + select<br/>(메시지 전달)"]
G2 -->|"공유 메모리로"| MU["sync.Mutex / atomic<br/>(공유 상태 보호)"]
end
- CPU 집약 작업이라도 파이썬처럼 “프로세스로 올라가야” 하는 일은 거의 없습니다. 고루틴을 코어 수만큼 띄우면 그만입니다.
- 데이터 공유는 채널(통신)을 기본으로 하고, 꼭 필요한 공유 상태만
sync패키지로 보호합니다.
한눈에 보기 — 단계별 지도
| 관점 | Python | Go |
|---|---|---|
| 기본 실행 단위 | 스레드 / 프로세스 / 코루틴(비동기) | 고루틴 |
| 스레드 생성 비용 | 중간 (OS 스레드) | 매우 낮음 (고루틴) |
| CPU 병렬 | multiprocessing로 프로세스 분리 필요 |
고루틴을 코어에 분산 |
| I/O 효율 | asyncio(비동기) 권장 |
고루틴 + 블로킹 I/O로 충분 |
| 공유 상태 보호 | threading.Lock 등 |
sync.Mutex·atomic |
| 권장 통신 | Queue / Pipe / Manager | Channel + select |
다음 단계에서는 이 지도에서 경량 스레드(2단계) 부터 실제 코드로 파고듭니다.
Summary
- 프로세스는 자원의 기본 단위로 메모리가 격리돼 안전하지만 생성·전환이 무겁고 IPC가 필요합니다.
- 스레드는 프로세스 안에서 메모리를 공유하는 더 가벼운 실행 단위지만, 공유 상태를 동기화해야 합니다.
- 비동기(이벤트 루프) 는 단일 스레드가 I/O 대기 시간을 다른 작업으로 채워 스위치 비용을 아낍니다.
- 동시성(구조·교대)과 병렬성(실행·동시)은 다른 차원의 문제입니다.
- Python은 GIL 때문에 “I/O는 스레드·비동기, CPU는 프로세스(멀티프로세싱)”로 갈리고, Go는 가벼운 고루틴 + 채널을 기본으로 합니다.
다음 학습 (Next Learning)
- Stage 2 · 경량 스레드 — Python
threading과 Go 고루틴의 실제 동작을 I/O 집약 작업으로 비교합니다. - Python GIL — 파이썬이 CPU 작업에서 왜 스레드 대신 프로세스를 쓰는지 그 근본 원인을 다시 확인.
- Rust 스마트 포인터, 동시성, 그리고 프로젝트 — 같은 동시성 개념을 다른 시스템 언어에서는 어떻게 접근하는지 비교해 보기.