파일 200개를 하나씩 처리하는 스크립트가 있습니다. 작업 관리자를 열어 보면 코어 16개 중 하나만 바쁘고 나머지는 놀고 있습니다. 파이썬 병렬 처리를 쓰면 이 남는 코어를 일에 동원할 수 있습니다. 로그 200개(415MB)의 단어를 세는 스크립트로 직접 재 보니 직렬 13.06초가 작업자 14개에서 1.92초가 됐습니다.
다만 그 숫자까지 가는 길이 곧지는 않았습니다. 처음 쓴 코드는 오류를 내고 끝나는 대신 멈춰 버렸고, 스레드로 바꾼 측정은 16개를 써도 1.0배였으며, 아주 작은 작업은 병렬로 돌리자 직렬보다 약 495배 느려졌습니다. 이 글에서는 multiprocessing을 안전하게 쓰는 형태, 스레드와 프로세스를 고르는 기준, 작업자 수를 정하는 법을 측정값과 함께 정리합니다.
실행 환경은 Windows 11, Python 3.12.10, AMD Ryzen 9 6900HX(8코어 16스레드)입니다. 외부 패키지는 쓰지 않았습니다.
처음 코드는 왜 멈췄나
소수를 세는 함수를 4개 프로세스에 나눠 주는, 예제로 흔히 보는 코드부터 돌렸습니다. 아래는 그때의 첫 버전입니다. 호출부가 파일 맨 바깥에 그대로 있습니다.
"""처음 시도: if __name__ 가드 없이 Pool을 바로 쓴 코드 (윈도우에서 실패)"""
from multiprocessing import Pool
def count_primes(limit):
count = 0
for n in range(2, limit):
for d in range(2, int(n ** 0.5) + 1):
if n % d == 0:
break
else:
count += 1
return count
with Pool(4) as pool:
print(pool.map(count_primes, [200_000] * 8))
혹시 몰라 15초 제한을 걸어 실행했는데, 제한 시간까지 끝나지 않아 강제로 종료했습니다. 그동안 같은 오류가 783번 찍혔고 출력은 약 271만 자가 쌓였습니다.
15초 안에 끝나지 않아 강제 종료
출력 크기 2712096 자, RuntimeError 783 회
RuntimeError:
An attempt has been made to start a new process before the
current process has finished its bootstrapping phase.
윈도우의 multiprocessing은 자식 프로세스를 만들 때 파이썬을 새로 띄우고 내 스크립트를 다시 import합니다(spawn 방식). 호출부가 맨 바깥에 있으면 자식도 그 줄을 실행해 또 풀을 만들려다 오류로 죽습니다. 풀은 죽은 작업자를 새로 채우므로 이 일이 계속 되풀이됩니다. 그래서 프로그램이 끝나지 않고 오류만 쌓였습니다.
고치는 법은 한 가지입니다. 풀을 쓰는 코드를 if __name__ == "__main__": 아래로 옮깁니다.
import time
from multiprocessing import Pool
def count_primes(limit):
count = 0
for n in range(2, limit):
for d in range(2, int(n ** 0.5) + 1):
if n % d == 0:
break
else:
count += 1
return count
if __name__ == "__main__":
start = time.perf_counter()
with Pool(4) as pool:
results = pool.map(count_primes, [200_000] * 8)
print(results)
print(f"{time.perf_counter() - start:.2f}초")
[17984, 17984, 17984, 17984, 17984, 17984, 17984, 17984]
0.65초
20만 미만의 소수는 17,984개라 결과도 맞고, 8건이 0.65초에 끝났습니다. 윈도우에서 multiprocessing을 쓸 때 이 가드는 선택이 아니라 필수입니다. 같은 오류 문구를 만나면 가드부터 확인하세요.
스레드와 프로세스, 같은 일을 시켜 보니
소수 세기 32건을 직렬, 스레드, 프로세스로 돌렸습니다. 조건마다 3번 재서 중앙값을 냈고, 결과값이 직렬과 같은지도 코드에서 확인했습니다.
| 방식 | 작업자 수 | 시간 | 직렬 대비 |
|---|---|---|---|
| 직렬 | 1 | 14.15초 | – |
| 스레드 | 2 | 15.00초 | 0.9배 |
| 스레드 | 4 | 14.43초 | 1.0배 |
| 스레드 | 16 | 14.21초 | 1.0배 |
| 프로세스 | 2 | 9.64초 | 1.5배 |
| 프로세스 | 4 | 4.40초 | 3.2배 |
| 프로세스 | 8 | 2.37초 | 6.0배 |
| 프로세스 | 16 | 1.96초 | 7.2배 |
스레드는 16개를 써도 빨라지지 않았습니다. 일반 파이썬에는 GIL이라는 잠금이 있어서, 한 프로세스 안에서 파이썬 코드를 실행하는 스레드는 한 번에 하나뿐이기 때문입니다. 프로세스는 각자 따로 파이썬을 띄우므로 이 제약을 받지 않습니다.
그럼 스레드는 쓸모없을까요? 기다리는 작업에서는 결과가 뒤집힙니다. 네트워크 응답을 기다리는 상황을 0.2초 sleep 100건으로 흉내 냈습니다. 실제 네트워크가 아니라 대기만 재현한 측정입니다.
직렬 20.04초
스레드 16개 1.41초
스레드 50개 0.41초
프로세스 16개 1.58초
기다리는 동안에는 GIL을 내놓기 때문에 스레드가 잘 동작합니다. 50개면 0.41초로 프로세스 16개보다 빠르고, 작업자를 늘리는 비용도 스레드 쪽이 작습니다.
| 작업 성격 | 예 | 고를 것 |
|---|---|---|
| 계산이 대부분 | 파일 파싱, 집계, 이미지 처리 | multiprocessing |
| 기다림이 대부분 | API 호출, 다운로드, 크롤링 | 스레드 |
| 작업 1건이 아주 짧음 | 단순 변환, 곱셈 | 직렬 또는 묶어서 전달 |
실전 스크립트: 로그 200개 단어 집계
이제 현실에 가까운 일을 시켜 봤습니다. 파일당 25,000줄인 로그 200개, 합계 415MB를 읽어 단어별 개수를 합칩니다. 입력은 시드를 고정해 만든 가짜 로그입니다. 작업 함수는 별도 파일에 둡니다.
"""측정에 쓰는 작업 함수 모음. 자식 프로세스가 import할 수 있도록 파일로 분리한다."""
import re
import time
from collections import Counter
from pathlib import Path
WORD = re.compile(r"[a-z]+")
def count_words(path):
"""현실에 가까운 작업: 로그 파일 하나를 읽어 단어별 개수 집계"""
counter = Counter()
with Path(path).open(encoding="utf-8") as f:
for line in f:
counter.update(WORD.findall(line.lower()))
return path, counter
본 스크립트는 파일 경로만 작업자에게 넘기고, 각자 돌려준 집계를 부모가 합칩니다. imap_unordered는 먼저 끝난 파일부터 결과를 돌려주므로, 합산처럼 순서가 상관없는 일에 맞습니다.
"""로그 파일 여러 개의 단어를 세는 스크립트. 사용법: python word_count.py <폴더> [작업자 수]
작업자 수를 1로 주면 풀을 띄우지 않고 직렬로 돈다.
"""
import sys
import time
from collections import Counter
from multiprocessing import Pool, cpu_count
from pathlib import Path
from work import count_words
def main():
folder = Path(sys.argv[1])
workers = int(sys.argv[2]) if len(sys.argv) > 2 else max(cpu_count() - 2, 1)
files = sorted(str(p) for p in folder.glob("*.log"))
start = time.perf_counter()
total = Counter()
if workers == 1:
for _, counter in map(count_words, files):
total.update(counter)
else:
with Pool(workers) as pool:
for _, counter in pool.imap_unordered(count_words, files):
total.update(counter)
elapsed = time.perf_counter() - start
print(f"작업자 {workers}개 / 파일 {len(files)}개 / {elapsed:.2f}초")
print(total.most_common(3))
if __name__ == "__main__":
main()
작업자 수를 바꿔 가며 재 봤습니다. 상위 단어 집계는 모든 경우에 같았습니다.
| 작업자 수 | 시간 | 직렬 대비 |
|---|---|---|
| 1 (풀 없음) | 13.06초 | – |
| 2 | 9.11초 | 1.4배 |
| 4 | 4.02초 | 3.2배 |
| 8 | 2.21초 | 5.9배 |
| 14 | 1.92초 | 6.8배 |
| 16 | 1.90초 | 6.9배 |
1개와 14개는 다시 재서 13.15초, 1.89초를 얻었으니 오차는 크지 않습니다. 다만 파일을 막 만든 직후라 디스크 캐시가 도왔을 수 있어서, 느린 디스크라면 수치가 달라질 수 있습니다.
작업자 수는 몇 개가 좋을까
두 측정 모두 8개까지는 잘 빨라지다가 그 뒤로는 크게 늘지 않았습니다. 8개에서 16개로 두 배 늘려도 로그 집계는 2.21초가 1.90초가 됐을 뿐입니다. 이 CPU는 물리 코어가 8개이고, 16은 논리 코어 수입니다.
그래서 cpu_count()가 돌려주는 값을 그대로 쓰기보다, 위 스크립트처럼 2개쯤 남겨 두는 편을 권합니다. 이 측정에서 14개와 16개의 차이는 0.02초였고, 남겨 둔 코어로 다른 프로그램을 쓸 수 있습니다.
한 가지 풀지 못한 점도 적어 둡니다. 작업자 2개일 때는 두 측정 모두 1.4~1.5배에 그쳤는데, 4개에서는 3.2배가 나왔습니다. 원인은 확인하지 못했습니다. 작업자 수는 내 PC에서 직접 재서 정하는 것이 가장 정확합니다.
병렬이 오히려 손해인 경우
프로세스끼리는 데이터를 직렬화해서 주고받습니다. 그래서 작업 하나가 너무 작으면 일보다 전달 비용이 커집니다. 숫자 하나를 제곱하는 일 20만 건으로 재 봤습니다. Pool(8) 세 줄은 이미 띄운 풀에서 잰 값이라 시작 비용은 빠져 있습니다.
직렬 for문 0.013초
빈 풀 4개 띄우고 닫기 0.089초
빈 풀 16개 띄우고 닫기 0.116초
Pool(8) chunksize=1 6.431초
Pool(8) chunksize=100 0.209초
Pool(8) 기본값 0.018초
chunksize=1은 작업을 하나씩 건네는 설정이고, 6.431초로 직렬의 약 495배가 걸렸습니다. 100개씩 묶으면 0.209초로 줄고, 기본값은 전체를 작업자 수의 4배 덩어리로 나눠서 0.018초까지 내려갑니다. 그래도 직렬 0.013초를 이기지는 못했습니다. 이 규모의 일은 for문이 답입니다.
기본값 덕을 본 것은 map이기 때문입니다. 앞서 쓴 imap_unordered는 기본 chunksize가 1입니다. 파일처럼 건당 시간이 긴 작업에는 문제가 없지만, 작은 작업을 대량으로 돌릴 때는 직접 지정해야 합니다.
직접 걸려 본 함정 3가지
lambda를 넘길 수 없습니다. 작업 함수는 이름으로 전달되기 때문에 파일 최상위에 def로 정의해야 합니다.
PicklingError : Can't pickle <function <lambda> at 0x000002F459E499E0>:
attribute lookup <lambda> on __main__ failed
전역 변수는 공유되지 않습니다. 전역 리스트 seen에 작업자가 값을 쌓게 했더니, 작업자 쪽에서는 길이가 6까지 늘었는데 부모의 seen은 빈 리스트 그대로였습니다. 각 프로세스가 자기 복사본을 쓰기 때문입니다. 결과는 반드시 반환값으로 돌려받으세요.
예외 하나가 전체 결과를 날립니다. 6건 중 입력 3에서만 예외를 내게 했더니 pool.map이 부모에서 ValueError를 올렸고, 성공한 나머지 5건의 결과도 받지 못했습니다. 오래 걸리는 작업이라면 함수 안에서 잡아 결과로 돌려주는 편이 낫습니다.
def fail_on_three(x):
if x == 3:
raise ValueError(f"입력 {x} 처리 실패")
return x
def safe(x):
try:
return x, fail_on_three(x), None
except Exception as e:
return x, None, repr(e)
(2, 2, None)
(3, None, "ValueError('입력 3 처리 실패')")
(4, 4, None)
이렇게 하면 실패한 입력만 골라 다시 돌릴 수 있습니다.
적용 순서
- 작업이 계산 위주인지 기다림 위주인지 먼저 구분합니다. 기다림 위주라면 스레드를 씁니다.
- 작업을 함수 하나로 분리하고, 입력과 반환값만으로 동작하게 고칩니다.
- 직렬로 시간을 재 둡니다. 1초 안에 끝나는 일이라면 병렬이 필요 없을 수 있습니다.
if __name__ == "__main__":아래에서Pool을 씁니다.- 작업자 2, 4, 8개로 재 보고 더 빨라지지 않는 지점에서 멈춥니다.
마무리
지금 가진 스크립트 중 for문 하나가 대부분의 시간을 쓰는 것이 있다면 거기부터 시작해 보세요. 반복문 안쪽을 함수로 빼면 나머지는 word_count.py의 with Pool 세 줄이 전부입니다. 제 측정에서는 그 세 줄로 13초가 2초 아래로 내려갔습니다.
다음 글에서는 AI에 보내는 프롬프트를 코드 안에 흩어 두지 않고, 템플릿 파일로 관리하면서 변수만 채워 쓰는 방법을 다룹니다.