본문 바로가기
TYLER SONGBlog
블로그 목록
CS학습

Parallelism: 여러 코어에서 동시에 실행되는 진짜 동시성

병렬성(Parallelism)은 여러 개의 CPU 코어가 실제로 동시에 여러 작업을 처리하는 것을 말한다. 동시성(Concurrency)과 혼동하기 쉽지만 둘은 다른 개념이며, Python에서는 전역 인터프리터 락(GIL) 때문에 스레드로는 진짜 병렬성을 얻기 어렵다. 이 글에서는 병렬성의 원리와 Python의 multiprocessing을 이용한 실제 구

송민성6분 읽기

1. 개념

병렬성(Parallelism)은 물리적으로 여러 개의 실행 단위(CPU 코어)가 같은 순간에 서로 다른 작업을 처리하는 것이다. 반면 동시성(Concurrency)은 하나의 코어가 여러 작업을 짧은 시간 단위로 번갈아 처리해서 마치 동시에 실행되는 것처럼 보이게 하는 것이다.

핵심 차이는 이렇다.

  • 동시성: 싱글 코어에서도 가능하다. 작업 전환(context switching)으로 동시에 실행되는 것처럼 보인다.
  • 병렬성: 반드시 멀티 코어(또는 멀티 CPU)가 필요하다. 실제로 같은 시각에 여러 명령이 처리된다.

"동시성은 여러 일을 동시에 다루는 것(dealing with), 병렬성은 여러 일을 동시에 하는 것(doing)"이라는 Rob Pike의 표현이 이 차이를 잘 설명한다.

2. 왜 사용하는가

CPU 코어 수가 계속 늘어나는 하드웨어 환경에서, 단일 스레드로 실행되는 프로그램은 코어를 하나만 쓰고 나머지는 놀린다. CPU 집약적(CPU-bound) 작업—이미지 처리, 수치 연산, 데이터 압축, 머신러닝 전처리 같은 작업—은 여러 코어에 나눠서 처리하면 이론적으로 처리 시간을 코어 수에 비례해서 줄일 수 있다.

I/O 대기가 많은 작업(네트워크 요청, 파일 읽기)은 병렬성보다 동시성(비동기 처리)이 더 효율적이다. 병렬성은 CPU 자체가 병목일 때 효과를 낸다.

3. 동작 원리

운영체제 스케줄러는 여러 코어에 프로세스나 스레드를 배분한다. 각 코어는 독립적인 실행 파이프라인을 가지고 있어서 서로 다른 명령어 스트림을 동시에 처리할 수 있다.

Python에서는 여기에 특수한 제약이 있다. CPython 인터프리터는 GIL(Global Interpreter Lock)이라는 락을 가지고 있어서, 한 프로세스 안에서는 한 순간에 하나의 스레드만 Python 바이트코드를 실행할 수 있다. 즉 Python 스레드(threading 모듈)로는 CPU 집약적 작업에서 진짜 병렬성을 얻을 수 없다.

이 문제를 우회하는 방법이 멀티프로세싱(multiprocessing)이다. 각 프로세스는 독립된 메모리 공간과 독립된 GIL을 가지므로, 프로세스를 여러 개 띄우면 각 프로세스가 서로 다른 코어에서 진짜로 동시에 실행된다. 대신 프로세스 간에는 메모리를 공유하지 않기 때문에 데이터를 주고받으려면 파이프나 큐, 공유 메모리 같은 별도의 통신 수단이 필요하다.

4. 코드 예제

CPU 집약적 작업을 스레드와 프로세스로 각각 처리했을 때의 차이를 비교한다.

python
import time import threading import multiprocessing def cpu_bound_work(n: int) -> int: # 의도적으로 CPU를 많이 쓰는 연산 total = 0 for i in range(n): total += i * i return total def run_with_threads(n: int, count: int) -> float: start = time.perf_counter() threads = [threading.Thread(target=cpu_bound_work, args=(n,)) for _ in range(count)] for t in threads: t.start() for t in threads: t.join() return time.perf_counter() - start def run_with_processes(n: int, count: int) -> float: start = time.perf_counter() with multiprocessing.Pool(processes=count) as pool: pool.map(cpu_bound_work, [n] * count) return time.perf_counter() - start if __name__ == "__main__": N = 10_000_000 COUNT = 4 thread_time = run_with_threads(N, COUNT) process_time = run_with_processes(N, COUNT) print(f"스레드 방식: {thread_time:.2f}초") print(f"프로세스 방식: {process_time:.2f}초")

4코어 이상의 환경에서 실행하면 프로세스 방식이 스레드 방식보다 유의미하게 빠르다. GIL 때문에 스레드 방식은 4개 스레드를 띄워도 실질적으로 코어 하나 분량의 작업만 처리하는 것과 큰 차이가 없다.

프로세스 간 데이터를 주고받는 예제는 다음과 같다.

python
from multiprocessing import Process, Queue def worker(q: Queue, x: int) -> None: q.put(x * x) if __name__ == "__main__": q: Queue = Queue() processes = [Process(target=worker, args=(q, i)) for i in range(5)] for p in processes: p.start() for p in processes: p.join() results = [q.get() for _ in range(5)] print(sorted(results)) # [0, 1, 4, 9, 16]

5. 시간 복잡도 또는 성능 특성

병렬성의 이론적 한계는 암달의 법칙(Amdahl's Law)으로 설명한다.

text
speedup = 1 / (S + (1 - S) / N)

여기서 S는 프로그램에서 병렬화가 불가능한 부분의 비율, N은 코어(프로세서) 수다. 예를 들어 프로그램의 90%를 병렬화할 수 있고(S = 0.1) 코어가 4개라면

text
speedup = 1 / (0.1 + 0.9 / 4) = 1 / 0.325 ≈ 3.08배

코어 수를 아무리 늘려도 병렬화 불가능한 부분(S) 때문에 속도 향상에는 한계가 있다. S가 0.1이면 코어를 무한히 늘려도 이론상 최대 속도 향상은 10배(1/S)를 넘지 못한다.

실무에서는 이론값보다 항상 낮은 성능을 보이는데, 프로세스 생성 비용, 프로세스 간 통신(IPC) 비용, 컨텍스트 스위칭 비용이 추가로 들기 때문이다. 특히 Python의 multiprocessing.Pool은 작업을 분배하고 결과를 직렬화(pickle)해서 주고받는 과정에서 오버헤드가 발생하므로, 작업 단위(N)가 너무 작으면 프로세스를 여러 개 띄우는 비용이 실제 연산 이득을 상쇄해버릴 수 있다.

6. 실무 사용 사례

  • 이미지/비디오 배치 처리: 여러 장의 이미지를 리사이즈하거나 필터를 적용할 때 이미지 단위로 프로세스를 나눠 처리한다.
  • 데이터 전처리 파이프라인: pandas나 numpy로 큰 데이터셋을 여러 청크로 나눠 병렬로 변환한 뒤 합친다.
  • 웹 서버의 워커 프로세스: Gunicorn, uWSGI 같은 WSGI 서버는 여러 워커 프로세스를 띄워서 요청을 병렬로 처리한다(이 경우 각 요청은 I/O 대기가 섞여 있어 동시성과 병렬성이 함께 작동한다).
  • 컴파일러의 병렬 빌드: make -j4처럼 독립적인 컴파일 단위를 여러 코어에서 동시에 컴파일한다.

7. 주의할 점

  • 프로세스 생성과 종료에는 스레드보다 훨씬 큰 비용이 든다. 작업량이 적으면 오버헤드가 이득보다 커질 수 있다.
  • 프로세스 간 데이터 공유는 기본적으로 불가능하다. Queue, Pipe, shared memory(multiprocessing.shared_memory)를 명시적으로 써야 하고, 이 과정에서 직렬화 비용이 발생한다.
  • Windows에서 multiprocessing을 쓸 때는 if __name__ == "__main__": 가드가 필수다. 프로세스를 새로 만들 때 모듈을 다시 임포트하기 때문에 이 가드가 없으면 무한 재귀적으로 프로세스가 생성될 수 있다.
  • 병렬화한다고 무조건 빨라지지 않는다. 암달의 법칙에서 보듯 병렬화 불가능한 부분이 크면 코어를 늘려도 효과가 제한적이다. 병목이 CPU가 아니라 I/O나 락 경합이라면 병렬성보다 다른 최적화가 우선이다.
  • Python에서 진짜 병렬성이 필요한 CPU 집약적 작업이라면 threading이 아니라 multiprocessing, 또는 GIL의 영향을 받지 않는 C 확장 모듈(numpy 내부 연산 등)을 활용해야 한다.

8. 핵심 정리

병렬성은 여러 코어가 실제로 같은 순간에 작업을 처리하는 것이고, 동시성은 하나의 코어가 작업을 번갈아 처리해 동시에 실행되는 것처럼 보이게 하는 것이다. Python은 GIL 때문에 스레드로는 CPU 집약적 작업에서 진짜 병렬성을 얻을 수 없고, 대신 독립된 메모리 공간과 GIL을 가진 프로세스를 여러 개 띄우는 multiprocessing으로 병렬성을 구현한다. 다만 프로세스 생성과 통신에는 오버헤드가 따르고, 암달의 법칙에 따라 병렬화 불가능한 부분이 전체 속도 향상의 한계를 결정하므로 무작정 코어를 늘린다고 성능이 비례해서 좋아지지는 않는다.

© 2026 Tyler Song