24. 동시성 기초

🎯 이 장의 목표
  • 동시성이 왜 필요한지, 어떤 작업에 효과적인지 이해한다.
  • 스레드와 프로세스의 차이를 안다.
  • GIL이 무엇이고 왜 중요한지 이해한다.
  • ThreadPoolExecutor로 간단히 병렬 처리한다.

먼저: 동시성이 왜 필요한가

식당에서 주문 5개를 처리한다고 합시다. 한 요리사가 1번 요리를 완전히 끝내고 2번을 시작하면 느립니다. 하지만 1번을 오븐에 넣어두고(대기) 그동안 2번을 손질하면, 여러 일이 겹쳐서 진행돼 전체가 빨라집니다.

동시성(concurrency)이 바로 이것입니다. 여러 작업을 겹쳐 처리해 전체 시간을 줄입니다. 특히 "기다리는 시간"이 많은 작업에서 효과가 큽니다.

직접 효과를 봅시다. 0.1초씩 걸리는 작업 5개를 순차로 하면 0.5초, 동시에 하면 0.1초입니다.

PYTHON
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 모듈로 스레드를 직접 만들 수 있습니다.

PYTHON
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 때문에 스레드가 거의 도움이 안 됩니다. 직접 측정해 봅시다.

PYTHON
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 스레드는 한 번에 하나만 계산한다. I/O 바운드는 스레드(대기 중 GIL 양보), CPU 바운드는 프로세스(독립 인터프리터)로 처리한다. 이 선택이 동시성의 핵심이다.
📎 GIL은 Python(정확히는 CPython 구현)의 오랜 특징입니다. 최근 Python에는 GIL을 끄는 실험적 모드가 도입되고 있지만, 입문 단계에서는 "스레드는 I/O에, 프로세스는 CPU에"라는 원칙을 기억하면 충분합니다.

ThreadPoolExecutor: 쉬운 병렬 처리

스레드를 직접 만들고 join하는 건 번거롭습니다. concurrent.futuresThreadPoolExecutor가 이를 깔끔하게 처리합니다.

PYTHON
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로 바꾸면 됩니다. 사용법이 동일해 편리합니다.

PYTHON
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). 두 스레드가 같은 변수를 거의 동시에 읽고 쓰면 한쪽 변경이 사라질 수 있습니다.

PYTHON
# ⚠️ 위험: 여러 스레드가 같은 변수를 증가 (값이 어긋날 수 있음)
counter = 0
def increment():
    global counter
    for _ in range(100000):
        counter += 1    # 이 동작이 원자적이지 않아 충돌 가능

해결책으로 잠금(Lock)을 써서 "한 번에 한 스레드만" 접근하게 합니다.

PYTHON
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·ProcessPoolExecutorexecutor.map을 통해 쉽게 병렬 처리한다. with로 자원을 관리한다.
  • 스레드가 공유 상태를 동시에 바꾸면 위험하다(경쟁 상태). Lock을 쓰거나, 공유를 피한다.

🧪 실습 문제

문제 1. 다음 작업들이 I/O 바운드인지 CPU 바운드인지 구분하세요.

TEXT
(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.

PYTHON
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