24. 동시성 기초
- 동시성이 왜 필요한지, 어떤 작업에 효과적인지 이해한다.
- 스레드와 프로세스의 차이를 안다.
- GIL이 무엇이고 왜 중요한지 이해한다.
ThreadPoolExecutor로 간단히 병렬 처리한다.
먼저: 동시성이 왜 필요한가
식당에서 주문 5개를 처리한다고 합시다. 한 요리사가 1번 요리를 완전히 끝내고 2번을 시작하면 느립니다. 하지만 1번을 오븐에 넣어두고(대기) 그동안 2번을 손질하면, 여러 일이 겹쳐서 진행돼 전체가 빨라집니다.
동시성(concurrency)이 바로 이것입니다. 여러 작업을 겹쳐 처리해 전체 시간을 줄입니다. 특히 "기다리는 시간"이 많은 작업에서 효과가 큽니다.
직접 효과를 봅시다. 0.1초씩 걸리는 작업 5개를 순차로 하면 0.5초, 동시에 하면 0.1초입니다.
import time from concurrent.futures import ThreadPoolExecutor def fetch(n): time.sleep(0.1) # 네트워크 대기를 흉내 return f"결과{n}" # 순차 실행: 하나씩 start = time.perf_counter() results = [fetch(i) for i in range(5)] print(f"순차: {time.perf_counter() - start:.2f}초") # 순차: 0.50초 # 병렬 실행: 동시에 start = time.perf_counter() with ThreadPoolExecutor(max_workers=5) as executor: results = list(executor.map(fetch, range(5))) print(f"병렬: {time.perf_counter() - start:.2f}초") # 병렬: 0.11초
5배 가까이 빨라졌습니다. 어떻게 가능했을까요? 각 작업의 "대기 시간"이 겹쳐졌기 때문입니다.
⭐ 두 가지 작업 유형: I/O 바운드 vs CPU 바운드
동시성을 이해하는 가장 중요한 구분입니다. 작업은 두 종류로 나뉩니다.
| 유형 | 무엇이 병목인가 | 예 |
|---|---|---|
| I/O 바운드 | 입출력 대기 (네트워크·파일·DB) | 웹 요청, 파일 읽기, API 호출 |
| CPU 바운드 | 계산 자체 | 수치 계산, 이미지 처리, 암호화 |
이 구분이 왜 중요할까요? 동시성 도구를 다르게 골라야 하기 때문입니다.
- I/O 바운드: 대기 시간이 많으니, 대기하는 동안 다른 일을 하면 효과적 → 스레드나 async가 잘 맞음
- CPU 바운드: 쉴 틈 없이 계산하니, 진짜 여러 코어로 나눠야 빨라짐 → 프로세스가 맞음
flowchart TD
Q{"작업이 주로<br/>무엇을 하나?"}:::proc
Q -->|기다림<br/>네트워크·파일| IO["I/O 바운드"]:::data
Q -->|계산<br/>수치·처리| CPU["CPU 바운드"]:::data
IO --> T["스레드 / async<br/>대기를 겹침"]:::result
CPU --> P["프로세스<br/>여러 코어 활용"]:::result
classDef proc fill:#fff3b0,stroke:#e0a800,color:#5c4500
classDef data fill:#a8dadc,stroke:#457b9d,color:#1d3557
classDef result fill:#b8e6c1,stroke:#34a853,color:#14532d
스레드와 프로세스
병렬 처리의 두 단위를 봅시다.
- 스레드(thread): 한 프로그램 안의 여러 실행 흐름. 메모리를 공유한다. 가볍다.
- 프로세스(process): 독립된 프로그램 실행 단위. 메모리가 분리된다. 무겁지만 진짜 병렬.
비유하면, 스레드는 "한 주방에서 일하는 여러 요리사"(같은 재료·도구 공유), 프로세스는 "각자 독립된 주방을 가진 요리사들"입니다.
기본 스레드 사용
threading 모듈로 스레드를 직접 만들 수 있습니다.
import threading import time def worker(name): time.sleep(0.1) print(f"{name} 완료") threads = [] for i in range(3): t = threading.Thread(target=worker, args=(f"작업{i}",)) threads.append(t) t.start() # 스레드 시작 for t in threads: t.join() # 모든 스레드가 끝날 때까지 대기 print("전체 완료")
start()로 스레드를 시작하고, join()으로 "그 스레드가 끝날 때까지 기다립니다". join을 빠뜨리면 메인 프로그램이 스레드보다 먼저 끝나버릴 수 있습니다.
ThreadPoolExecutor를 더 많이 씁니다. 하지만 "스레드를 시작하고 join으로 기다린다"는 기본 동작은 알아두면 좋습니다.⭐ GIL: Python 동시성의 핵심 제약
여기서 Python만의 중요한 사실을 알아야 합니다. GIL(Global Interpreter Lock, 전역 인터프리터 잠금)입니다.
GIL은 "한 순간에는 하나의 스레드만 Python 코드를 실행할 수 있다"는 제약입니다. 여러 스레드를 만들어도, 진짜 동시에 Python 계산을 하지는 못합니다. 한 번에 한 스레드만 인터프리터를 쥘 수 있죠(주방은 여럿인데 요리 도구가 하나뿐인 셈).
그럼 아까 스레드로 5배 빨라진 건 뭘까요? I/O 대기 중에는 GIL을 놓아주기 때문입니다. 스레드가 네트워크 응답을 기다리는 동안에는 다른 스레드가 일할 수 있습니다. 그래서 I/O 바운드에는 스레드가 효과적입니다.
하지만 CPU 바운드(쉬지 않고 계산)에서는 GIL 때문에 스레드가 거의 도움이 안 됩니다. 직접 측정해 봅시다.
import time from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor def cpu_heavy(n): return sum(i * i for i in range(n)) # 순수 계산 (대기 없음) N = 2_000_000 # 스레드: GIL 때문에 거의 안 빨라짐 start = time.perf_counter() with ThreadPoolExecutor(max_workers=4) as ex: list(ex.map(cpu_heavy, [N] * 4)) print(f"스레드: {time.perf_counter()-start:.2f}초") # 순차와 비슷 # 프로세스: 각자 독립 인터프리터라 진짜 병렬 start = time.perf_counter() with ProcessPoolExecutor(max_workers=4) as ex: list(ex.map(cpu_heavy, [N] * 4)) print(f"프로세스: {time.perf_counter()-start:.2f}초") # 여러 코어 활용
CPU 계산은 스레드로는 안 빨라지고, 프로세스(GIL을 각자 가짐)로 나눠야 진짜 빨라집니다.
📎 GIL은 Python(정확히는 CPython 구현)의 오랜 특징입니다. 최근 Python에는 GIL을 끄는 실험적 모드가 도입되고 있지만, 입문 단계에서는 "스레드는 I/O에, 프로세스는 CPU에"라는 원칙을 기억하면 충분합니다.
ThreadPoolExecutor: 쉬운 병렬 처리
스레드를 직접 만들고 join하는 건 번거롭습니다. concurrent.futures의 ThreadPoolExecutor가 이를 깔끔하게 처리합니다.
from concurrent.futures import ThreadPoolExecutor def download(url): # ... 다운로드 (I/O 작업) return f"{url} 완료" urls = ["site1.com", "site2.com", "site3.com"] with ThreadPoolExecutor(max_workers=3) as executor: # map: 각 항목에 함수를 병렬 적용 (결과 순서 보장) results = list(executor.map(download, urls)) print(results)
executor.map(함수, 리스트)는 리스트의 각 항목에 함수를 병렬로 적용합니다. 중급편 13장의 컴프리헨션과 비슷하지만 동시에 실행된다는 점이 다릅니다. max_workers는 동시에 돌릴 스레드 수입니다.
CPU 바운드라면 같은 코드에서 클래스 이름만 ProcessPoolExecutor로 바꾸면 됩니다. 사용법이 동일해 편리합니다.
from concurrent.futures import ProcessPoolExecutor with ProcessPoolExecutor(max_workers=4) as executor: results = list(executor.map(cpu_heavy, numbers))
with 구문(중급 16장)을 쓴 것에 주목하세요. 블록이 끝나면 모든 작업이 끝날 때까지 기다리고 자원을 정리해줍니다. Executor도 "열었으면 닫아야 하는" 자원이라 with가 잘 맞습니다.⚠️ 공유 상태의 위험
스레드는 메모리를 공유하므로, 여러 스레드가 같은 데이터를 동시에 바꾸면 문제가 생깁니다(경쟁 상태, race condition). 두 스레드가 같은 변수를 거의 동시에 읽고 쓰면 한쪽 변경이 사라질 수 있습니다.
# ⚠️ 위험: 여러 스레드가 같은 변수를 증가 (값이 어긋날 수 있음) counter = 0 def increment(): global counter for _ in range(100000): counter += 1 # 이 동작이 원자적이지 않아 충돌 가능
해결책으로 잠금(Lock)을 써서 "한 번에 한 스레드만" 접근하게 합니다.
import threading counter = 0 lock = threading.Lock() def increment(): global counter for _ in range(100000): with lock: # 잠금: 한 번에 한 스레드만 counter += 1
executor.map처럼)이 더 안전합니다.정리: 무엇을 언제 쓸까
flowchart TD
Start{"동시성이 필요한가?"}:::proc
Start -->|"I/O 바운드<br/>(대기 많음)"| IO{"방식?"}:::proc
Start -->|"CPU 바운드<br/>(계산 많음)"| CPU["ProcessPoolExecutor"]:::result
IO -->|"간단·여러 작업"| TPE["ThreadPoolExecutor"]:::result
IO -->|"많은 동시 작업"| ASYNC["async/await (25장)"]:::result
classDef proc fill:#fff3b0,stroke:#e0a800,color:#5c4500
classDef result fill:#b8e6c1,stroke:#34a853,color:#14532d
| 상황 | 도구 |
|---|---|
| I/O 바운드, 간단하게 | ThreadPoolExecutor |
| I/O 바운드, 매우 많은 동시 작업 | async/await (다음 장) |
| CPU 바운드 | ProcessPoolExecutor |
이 장에서 배운 것
- 동시성은 여러 작업을 겹쳐 전체 시간을 줄인다. I/O 바운드(대기)와 CPU 바운드(계산)를 구분하는 게 핵심이다.
- 스레드는 메모리 공유(가벼움), 프로세스는 메모리 분리(진짜 병렬).
- GIL 때문에 Python 스레드는 한 번에 하나만 계산한다. I/O는 스레드, CPU는 프로세스가 맞다.
ThreadPoolExecutor·ProcessPoolExecutor로executor.map을 통해 쉽게 병렬 처리한다.with로 자원을 관리한다.- 스레드가 공유 상태를 동시에 바꾸면 위험하다(경쟁 상태).
Lock을 쓰거나, 공유를 피한다.
🧪 실습 문제
문제 1. 다음 작업들이 I/O 바운드인지 CPU 바운드인지 구분하세요.
(a) 100개 URL에서 웹페이지 다운로드 (b) 큰 행렬의 곱셈 계산 (c) 1만 개 파일을 디스크에서 읽기 (d) 소수(prime) 100만 개 찾기
문제 2. I/O 바운드 작업과 CPU 바운드 작업에 각각 어떤 Executor가 적합한지 답하세요.
문제 3. GIL을 한 문장으로 설명하세요.
문제 4. ThreadPoolExecutor로 함수 square(n)을 리스트 [1,2,3,4,5]의 각 원소에 병렬 적용하는 코드를 작성하세요. (square = lambda n: n*n 가정)
문제 5. 여러 스레드가 같은 변수를 동시에 수정할 때 생기는 문제를 무엇이라 하며, 어떻게 해결하나요?
<details>
<summary>✅ 정답·해설 보기</summary>
1. (a) I/O 바운드 (네트워크 대기), (b) CPU 바운드 (계산), (c) I/O 바운드 (디스크 읽기), (d) CPU 바운드 (계산).
2. I/O 바운드 → ThreadPoolExecutor(또는 async), CPU 바운드 → ProcessPoolExecutor.
3. GIL은 한 순간에 하나의 스레드만 Python 코드를 실행하게 하는 잠금으로, 이 때문에 CPU 작업은 스레드로 병렬화되지 않습니다.
4.
from concurrent.futures import ThreadPoolExecutor square = lambda n: n * n with ThreadPoolExecutor(max_workers=5) as ex: results = list(ex.map(square, [1, 2, 3, 4, 5])) print(results) # [1, 4, 9, 16, 25]
5. 경쟁 상태(race condition)라고 합니다. threading.Lock으로 한 번에 한 스레드만 접근하게 하거나, 공유 자체를 피해(독립 작업 후 합치기) 해결합니다.
</details>
◀️ 이전 장: 23. 날짜와 시간 | ▶️ 다음 장: 25. async와 await