동시성과 병렬성 마스터하기: 스레드, 프로세스, 그리고 동기화의 모든 것

개념 소개

우리가 사용하는 대부분의 소프트웨어는 단순히 순차적으로 작동하지 않습니다. 웹 브라우저는 여러 탭을 동시에 열 수 있고, 운영체제는 수많은 애플리케이션을 동시에 실행하며, 고성능 서버는 초당 수천 개의 요청을 처리합니다. 이 모든 것이 가능하게 하는 핵심 개념이 바로 **동시성(Concurrency)**과 **병렬성(Parallelism)**입니다.
하지만 이 두 용어는 종종 혼용되거나 잘못 이해되곤 합니다. 단순히 "여러 작업을 동시에 처리하는 것"이라고 생각할 수 있지만, 이 둘 사이에는 중요한 차이가 있습니다.
동시성(Concurrency)이란? 동시성은 여러 작업을 번갈아 가면서 처리하여 동시에 진행되는 것처럼 보이게 하는 능력입니다. 이는 단일 코어(CPU)에서도 가능하며, 운영체제가 짧은 시간 동안 여러 프로세스나 스레드를 번갈아 실행하는 "시분할(Time-sharing)" 방식으로 구현됩니다. 마치 한 명의 바리스타가 여러 손님의 주문을 동시에 받고, 커피를 내리면서 다른 손님의 계산을 하는 것처럼, 실제로 한 번에 한 가지 작업만 하지만, 빠른 전환으로 모든 작업이 동시에 진행되는 듯한 착각을 줍니다.
병렬성(Parallelism)이란? 병렬성은 여러 작업을 진정으로 동시에 실행하는 능력입니다. 이는 여러 개의 코어(CPU)나 프로세서가 동시에 다른 작업을 처리할 때 발생합니다. 마치 여러 명의 바리스타가 각자 다른 손님의 주문을 동시에 처리하는 것처럼, 물리적으로 여러 작업이 한 순간에 이루어지는 것입니다.
왜 중요한가요? 현대의 컴퓨터 시스템은 대부분 멀티코어 CPU를 탑재하고 있습니다. 이러한 하드웨어의 잠재력을 최대한 활용하여 애플리케이션의 성능을 높이고, 사용자에게 더 빠르고 반응성 좋은 경험을 제공하기 위해서는 동시성과 병렬성을 이해하고 적절히 활용하는 것이 필수적입니다. 특히, I/O 작업(네트워크 통신, 파일 읽기/쓰기)이 많거나, 대량의 데이터를 처리해야 하는 경우, 또는 UI가 백그라운드 작업으로 인해 멈추는 것을 방지해야 하는 경우에 이러한 개념들이 빛을 발합니다.
핵심 원리 설명

동시성과 병렬성을 구현하는 주요 메커니즘은 **스레드(Threads)**와 **프로세스(Processes)**입니다. 이 둘은 작업의 독립성과 자원 공유 방식에서 큰 차이를 보입니다.
프로세스 (Process)
프로세스는 실행 중인 프로그램의 인스턴스입니다. 각 프로세스는 운영체제로부터 독립적인 메모리 공간(힙, 스택, 코드 영역 등)을 할당받으며, 다른 프로세스와 자원을 직접 공유하지 않습니다. 마치 독립된 아파트에 사는 여러 가족과 같습니다. 각 가족은 자신만의 공간과 가구를 가지고 있으며, 다른 가족의 물건에 직접 접근할 수 없습니다.
- 장점: 독립적인 메모리 공간 덕분에 한 프로세스에 문제가 생겨도 다른 프로세스에 영향을 주지 않아 안정성이 높습니다.
- 단점: 프로세스 간 통신(IPC: Inter-Process Communication)이 복잡하고, 새로운 프로세스를 생성하거나 프로세스 간 전환(컨텍스트 스위칭) 비용이 스레드보다 훨씬 높습니다.
스레드 (Thread)
스레드는 프로세스 내에서 실행되는 작업의 단위입니다. 하나의 프로세스는 여러 개의 스레드를 가질 수 있으며, 이 스레드들은 해당 프로세스의 메모리 공간(코드, 데이터, 힙 영역)을 공유합니다. 마치 한 아파트 안에 사는 여러 형제자매와 같습니다. 이들은 같은 집(메모리 공간)을 공유하고, 부엌이나 거실 같은 공용 공간(자원)을 함께 사용합니다.
- 장점: 스레드 간 자원 공유가 용이하고, 생성 및 컨텍스트 스위칭 비용이 프로세스보다 낮아 더 효율적입니다.
- 단점: 메모리 공간을 공유하기 때문에 한 스레드에 문제가 생기면 전체 프로세스에 영향을 줄 수 있습니다. 특히, 공유 자원에 대한 동시 접근 시 **경쟁 조건(Race Condition)**과 같은 문제가 발생할 수 있으며, 이를 해결하기 위한 동기화(Synchronization) 메커니즘이 필수적입니다.
동기화 (Synchronization): 공유 자원의 수문장
여러 스레드나 프로세스가 공유하는 자원(변수, 파일, 데이터베이스 연결 등)에 동시에 접근하여 수정하려고 할 때, 예상치 못한 결과가 발생할 수 있습니다. 이를 **경쟁 조건(Race Condition)**이라고 합니다. 경쟁 조건은 결과의 일관성을 해치고 디버깅하기 어려운 버그를 유발합니다.
예를 들어, 은행 계좌 잔고를 여러 스레드가 동시에 업데이트한다고 상상해 보세요.
- 스레드 A가 잔고를 읽습니다 (예: 1000원).
- 스레드 B가 잔고를 읽습니다 (예: 1000원).
- 스레드 A가 200원을 추가하여 1200원으로 업데이트합니다.
- 스레드 B가 300원을 추가하여 1300원으로 업데이트합니다. 최종 잔고는 1500원이 되어야 하지만, 스레드 B가 스레드 A의 변경 사항을 덮어쓰면서 1300원이 되어버렸습니다.
이러한 문제를 해결하기 위해 동기화 메커니즘을 사용합니다. 가장 일반적인 동기화 도구는 **뮤텍스(Mutex)**와 **세마포어(Semaphore)**입니다.
- 뮤텍스 (Mutex): "Mutual Exclusion"의 약자로, 상호 배제를 의미합니다. 공유 자원에 대한 접근을 한 번에 하나의 스레드/프로세스만 허용하는 잠금(Lock) 메커니즘입니다. 마치 화장실에 하나의 열쇠만 있어서, 열쇠를 가진 사람만 들어갈 수 있는 것과 같습니다. 스레드 A가 화장실에 들어가려면 열쇠를 잠그고, 나오면서 열쇠를 풀어야 다른 스레드가 들어갈 수 있습니다.
- 세마포어 (Semaphore): 뮤텍스와 비슷하지만, 여러 개의 공유 자원에 대한 접근을 제어할 수 있습니다. 특정 자원에 동시에 접근할 수 있는 스레드/프로세스의 수를 제한합니다. 마치 주차장에 빈자리가 5개 있다면, 최대 5대의 차(스레드)만 동시에 주차(접근)할 수 있도록 관리하는 것과 같습니다.
Python의 GIL (Global Interpreter Lock)
Python에서는 스레드를 사용할 때 특별한 주의가 필요합니다. CPython(가장 일반적인 Python 인터프리터)에는 **GIL(Global Interpreter Lock)**이라는 메커니즘이 존재합니다. GIL은 한 번에 하나의 스레드만이 Python 바이트코드를 실행할 수 있도록 강제하는 락입니다.
이는 멀티코어 CPU 환경에서도 Python 스레드들이 실제로 병렬적으로 CPU를 사용하는 것을 방해합니다. 즉, Python 스레드는 동시성을 제공하지만, CPU-바운드(CPU 연산이 많은) 작업에서는 병렬성을 얻기 어렵습니다. I/O-바운드(네트워크 통신, 파일 입출력) 작업에서는 스레드가 I/O 대기 중에 GIL을 놓아주므로, 다른 스레드가 CPU를 사용할 수 있어 동시성 이점을 얻을 수 있습니다.
따라서 Python에서 진정한 병렬성을 얻으려면 multiprocessing 모듈을 사용하여 여러 프로세스를 활용해야 합니다. 각 프로세스는 독립적인 Python 인터프리터를 가지므로, GIL의 영향을 받지 않고 각 코어에서 동시에 실행될 수 있습니다.
코드 예제
Python을 사용하여 스레드와 프로세스의 작동 방식, 그리고 동기화의 필요성을 살펴보겠습니다.
예제 1: 스레드를 이용한 동시성 (Race Condition 및 GIL)
이 예제는 스레드를 사용하여 I/O 바운드 작업과 CPU 바운드 작업을 수행하고, 경쟁 조건이 어떻게 발생하는지 보여줍니다.
import threading
import time
import os
# 공유 자원 (카운터)
shared_counter = 0
# 뮤텍스 락 (경쟁 조건 방지용)
lock = threading.Lock()
def increment_counter_unsafe():
"""안전하지 않게 카운터를 증가시키는 함수 (경쟁 조건 발생 가능)"""
global shared_counter
for _ in range(100_000):
# 이 세 라인은 원자적이지 않아 경쟁 조건 발생
current_value = shared_counter
current_value += 1
shared_counter = current_value
def increment_counter_safe():
"""뮤텍스를 사용하여 안전하게 카운터를 증가시키는 함수"""
global shared_counter
for _ in range(100_000):
# 락을 획득하여 임계 영역 보호
with lock: # 'with' 문은 락을 자동으로 획득하고 해제합니다.
shared_counter += 1
def io_bound_task(name):
"""I/O 바운드 작업 (GIL을 놓아줄 수 있음)"""
print(f"[{name}] I/O 작업 시작...")
time.sleep(1) # I/O 대기를 흉내냅니다. (GIL이 해제될 수 있음)
print(f"[{name}] I/O 작업 완료.")
def cpu_bound_task(name):
"""CPU 바운드 작업 (GIL의 영향을 받음)"""
print(f"[{name}] CPU 작업 시작...")
# 매우 큰 숫자를 반복하여 CPU를 많이 사용합니다.
_ = sum(i * i for i in range(10_000_000))
print(f"[{name}] CPU 작업 완료.")
if __name__ == "__main__":
print(f"현재 시스템의 CPU 코어 수: {os.cpu_count()}")
print("\n--- 1. 스레드와 경쟁 조건 ---")
threads = []
num_threads = 5
shared_counter = 0 # 초기화
print(f"안전하지 않은 카운터 증가 (스레드 {num_threads}개)")
for i in range(num_threads):
thread = threading.Thread(target=increment_counter_unsafe)
threads.append(thread)
thread.start()
for thread in threads:
thread.join() # 모든 스레드가 종료될 때까지 기다립니다.
# 예상 결과: 100_000 * num_threads = 500_000
# 실제 결과: 500_000보다 훨씬 작은 값이 나올 수 있습니다.
print(f"최종 공유 카운터 (안전하지 않음): {shared_counter}. 예상 값: {100_000 * num_threads}")
print("\n안전한 카운터 증가 (뮤텍스 사용)")
threads = []
shared_counter = 0 # 초기화
for i in range(num_threads):
thread = threading.Thread(target=increment_counter_safe)
threads.append(thread)
thread.start()
for thread in threads:
thread.join()
# 뮤텍스를 사용했으므로 예상 결과와 일치해야 합니다.
print(f"최종 공유 카운터 (안전함): {shared_counter}. 예상 값: {100_000 * num_threads}")
print("\n--- 2. 스레드와 GIL의 영향 (I/O vs CPU 바운드) ---")
print("I/O 바운드 작업 (Thread)")
start_time = time.time()
io_threads = [threading.Thread(target=io_bound_task, args=(f"Thread-{i}",)) for i in range(3)]
for t in io_threads:
t.start()
for t in io_threads:
t.join()
print(f"I/O 바운드 작업 총 소요 시간: {time.time() - start_time:.2f}초 (예상: 약 1초)")
print("\nCPU 바운드 작업 (Thread - GIL 영향)")
start_time = time.time()
cpu_threads = [threading.Thread(target=cpu_bound_task, args=(f"Thread-{i}",)) for i in range(3)]
for t in cpu_threads:
t.start()
for t in cpu_threads:
t.join()
# 단일 스레드로 실행했을 때와 거의 비슷한 시간이 소요될 수 있습니다.
print(f"CPU 바운드 작업 총 소요 시간: {time.time() - start_time:.2f}초 (예상: 단일 스레드와 비슷)")
코드 해설:
increment_counter_unsafe: 여러 스레드가shared_counter를 동시에 읽고 쓰는 과정에서 경쟁 조건이 발생하여 최종 결과가 예상과 다르게 나옵니다.increment_counter_safe:threading.Lock을 사용하여shared_counter에 접근하는 코드를 **임계 영역(Critical Section)**으로 보호합니다.with lock:문은 해당 블록이 실행되는 동안 오직 하나의 스레드만 접근하도록 보장하여 경쟁 조건을 방지합니다.io_bound_task:time.sleep()은 I/O 대기를 시뮬레이션하며, 이 시간 동안 GIL이 다른 스레드에게 양보될 수 있어 동시성 이점을 얻습니다. 3개의 스레드가 각각 1초씩 자더라도 총 소요 시간은 약 1초에 가깝게 나옵니다.cpu_bound_task: 순수 CPU 연산으로 GIL을 놓아주지 않습니다. 여러 스레드가 CPU 바운드 작업을 해도 GIL 때문에 실제로 병렬로 실행되지 못하고, 단일 스레드로 순차 실행하는 것과 비슷한 시간이 소요됩니다.
예제 2: 프로세스를 이용한 병렬성 (CPU 바운드 작업 해결)
Python의 multiprocessing 모듈을 사용하여 GIL의 제약을 우회하고 CPU 바운드 작업을 병렬로 처리하는 방법을 보여줍니다.
import multiprocessing
import time
import os
def cpu_bound_task_process(name):
"""프로세스에서 실행되는 CPU 바운드 작업"""
print(f"[{name}] CPU 작업 시작 (PID: {os.getpid()})...")
_ = sum(i * i for i in range(10_000_000))
print(f"[{name}] CPU 작업 완료.")
return f"{name} 완료"
# 프로세스 간 공유 자원 (예: 카운터)도 Lock을 사용해야 합니다.
# multiprocessing.Value나 multiprocessing.Array를 사용하지만, 여기서는 간단히 설명합니다.
def increment_counter_process_safe(counter_value, lock_obj):
"""프로세스 간 공유되는 카운터를 안전하게 증가시키는 함수"""
for _ in range(100_000):
with lock_obj:
counter_value.value += 1 # Value 객체의 .value 속성을 사용
if __name__ == "__main__":
print(f"현재 시스템의 CPU 코어 수: {os.cpu_count()}")
print("\n--- 1. 프로세스를 이용한 CPU 바운드 작업 (진정한 병렬성) ---")
start_time = time.time()
num_processes = 3 # 코어 수에 맞게 조절하는 것이 일반적입니다.
processes = [multiprocessing.Process(target=cpu_bound_task_process, args=(f"Process-{i}",)) for i in range(num_processes)]
for p in processes:
p.start() # 프로세스 시작
for p in processes:
p.join() # 모든 프로세스가 종료될 때까지 기다립니다.
print(f"CPU 바운드 작업 (프로세스) 총 소요 시간: {time.time() - start_time:.2f}초 (예상: 단일 스레드/프로세스보다 빠름)")
print("\n--- 2. 프로세스 간 공유 자원 동기화 ---")
# multiprocessing의 Value를 사용하여 프로세스 간 공유되는 정수 값 생성
manager = multiprocessing.Manager()
shared_counter_mp = manager.Value('i', 0) # 'i'는 signed int를 의미
lock_mp = manager.Lock() # 프로세스 간 공유될 Lock 객체
mp_processes = []
num_mp_processes = 5
print(f"프로세스 간 공유 카운터 증가 (프로세스 {num_mp_processes}개, 뮤텍스 사용)")
for i in range(num_mp_processes):
process = multiprocessing.Process(target=increment_counter_process_safe, args=(shared_counter_mp, lock_mp))
mp_processes.append(process)
process.start()
for process in mp_processes:
process.join()
# 뮤텍스를 사용했으므로 예상 결과와 일치해야 합니다.
print(f"최종 공유 카운터 (프로세스, 안전함): {shared_counter_mp.value}. 예상 값: {100_000 * num_mp_processes}")
코드 해설:
multiprocessing.Process: 새로운 프로세스를 생성합니다. 각 프로세스는 독립적인 메모리 공간과 Python 인터프리터를 가집니다.cpu_bound_task_process: 예제 1의cpu_bound_task와 동일한 작업을 수행합니다. 하지만 여러 프로세스로 실행되므로 GIL의 영향을 받지 않아 실제로 병렬로 실행되고, 총 소요 시간이 단일 실행 시간보다 훨씬 짧아집니다.multiprocessing.Manager().Value: 프로세스 간에 공유될 수 있는 값을 생성합니다. 일반 Python 변수는 프로세스 간에 공유되지 않습니다.multiprocessing.Manager().Lock: 프로세스 간에 공유될 수 있는 락 객체를 생성합니다. 이를 통해 프로세스 간 공유 자원 접근 시 경쟁 조건을 방지할 수 있습니다.
실무 적용 사례
동시성과 병렬성 개념은 소프트웨어 개발의 다양한 영역에서 활용됩니다.
- 웹 서버 및 API 서버:
- 문제: 단일 스레드 서버는 한 번에 하나의 요청만 처리할 수 있어, 요청이 많아지면 응답 속도가 느려집니다.
- 해결: Nginx 같은 웹 서버는 다중 프로세스/스레드를 사용하여 수많은 동시 요청을 효율적으로 처리합니다. 각 요청을 별도의 스레드나 프로세스에 할당하여 병렬로 처리함으로써, 전체 시스템의 처리량(Throughput)과 응답성(Responsiveness)을 극대화합니다. Python의 Django나 Flask 애플리케이션도 Gunicorn, uWSGI 같은 WSGI 서버를 통해 멀티 프로세스/스레드 방식으로 운영됩니다.
- 데이터 처리 및 분석:
- 문제: 대용량 데이터를 처리하는 작업(예: 이미지 처리, 머신러닝 모델 학습, 복잡한 통계 계산)은 단일 프로세스에서 수행하기에 시간이 오래 걸립니다.
- 해결:
multiprocessing을 사용하여 데이터를 여러 조각으로 나누고, 각 조각을 별도의 프로세스에서 병렬로 처리합니다. 예를 들어, Spark나 Hadoop 같은 빅데이터 프레임워크는 분산 환경에서 데이터를 병렬 처리하여 대규모 연산을 빠르게 완료합니다.
- GUI 애플리케이션:
- 문제: 사용자 인터페이스(UI) 스레드에서 시간이 오래 걸리는 작업을 수행하면 UI가 멈추거나 "응답 없음" 상태가 됩니다.
- 해결: 파일 다운로드, 데이터베이스 쿼리, 복잡한 계산 등 시간이 오래 걸리는 작업은 별도의 백그라운드 스레드에서 실행합니다. 백그라운드 스레드가 작업을 처리하는 동안 UI 스레드는 계속해서 사용자 입력에 반응하고 UI를 업데이트하여 부드러운 사용자 경험을 제공합니다.
- 백그라운드 작업 및 스케줄러:
- 문제: 특정 작업을 주기적으로 실행하거나, 사용자 요청과 무관하게 비동기적으로 처리해야 할 때, 메인 애플리케이션의 성능에 영향을 주지 않아야 합니다.
- 해결: Celery와 같은 태스크 큐 시스템이나 별도의 스케줄러(예: APScheduler)를 사용하여 작업을 다른 프로세스나 스레드에 위임하고 백그라운드에서 실행합니다. 이는 메인 애플리케이션의 자원을 확보하고, 작업을 비동기적으로 처리하여 전체 시스템의 효율성을 높입니다.
자주 하는 실수와 해결법
- Python에서 스레드가 CPU 바운드 작업을 병렬로 처리할 것이라는 오해 (GIL 오해)
- 실수: "Python도 멀티스레딩이 되니까 CPU 연산이 많은 작업도 스레드를 많이 만들면 빨라지겠지?"
- 해결: Python의 GIL 때문에 CPython 인터프리터에서는 한 번에 하나의 스레드만 Python 바이트코드를 실행할 수 있습니다. 따라서 CPU 바운드 작업에는 스레드가 병렬성 이점을 제공하지 못합니다. 진정한 병렬성을 원한다면
multiprocessing모듈을 사용하거나, C/C++ 확장 모듈을 사용하여 GIL을 우회해야 합니다. I/O 바운드 작업에는 스레드가 효과적입니다.
- 공유 자원 접근 시 동기화 누락 (Race Condition 무시)
- 실수: 여러 스레드/프로세스가 공유 변수나 파일에 동시에 접근하여 값을 변경하는데, 락(Lock)을 걸지 않습니다.
- 해결: 공유 자원에 접근하여 데이터를 읽거나 수정하는 부분(임계 영역)에는 반드시
threading.Lock이나multiprocessing.Lock과 같은 동기화 메커니즘을 사용해야 합니다.with lock:구문을 사용하면 락 획득 및 해제를 안전하게 관리할 수 있습니다.
- 데드락 (Deadlock) 발생
- 실수: 여러 개의 락을 사용하는데, 스레드/프로세스들이 락을 획득하는 순서가 서로 달라 무한정 대기하는 상황. (예: 스레드 A는 락1을 잡고 락2를 기다리고, 스레드 B는 락2를 잡고 락1을 기다림)
- 해결:
- 락 획득 순서 통일: 모든 스레드/프로세스가 동일한 순서로 락을 획득하도록 규칙을 정합니다.
- 락 타임아웃:
lock.acquire(timeout=...)과 같이 타임아웃을 설정하여 무한 대기를 방지하고, 실패 시 적절한 예외 처리를 합니다. - 락 사용 최소화: 필요한 최소한의 범위에서만 락을 사용하고, 락을 잡고 있는 시간을 최소화합니다.
- 과도한 동기화로 인한 성능 저하
- 실수: 모든 공유 자원 접근에 대해 무조건 락을 걸어버립니다.
- 해결: 락은 오버헤드를 발생시킵니다. 꼭 필요한 곳에만 락을 사용하고, 락의 범위를 최소화해야 합니다. 예를 들어, 읽기만 하는 작업에는 락이 필요 없을 수 있으며,
ReadWriteLock과 같은 더 정교한 락을 고려할 수도 있습니다.Queue와 같은 동시성 안전(thread-safe)한 자료구조를 활용하여 명시적인 락 사용을 줄이는 것도 좋은 방법입니다.
더 공부할 리소스 추천
- 책:
- "Concurrency in Python" by Rafe Kettler: Python의 동시성 및 병렬성에 대한 깊이 있는 이해를 돕습니다.
- "Operating System Concepts" (공룡책): 운영체제 수준에서 프로세스, 스레드, 동기화 메커니즘이 어떻게 작동하는지 이해하는 데 필수적인 고전입니다.
- 온라인 강좌 및 문서:
- Python 공식 문서 -
threading모듈: https://docs.python.org/3/library/threading.html - Python 공식 문서 -
multiprocessing모듈: https://docs.python.org/3/library/multiprocessing.html - Real Python - Python Concurrency Articles: Python의 동시성 관련 다양한 주제를 초중급 수준으로 잘 설명해 줍니다. (예: "An Intro to Threading in Python")
- Python 공식 문서 -
- 블로그 및 튜토리얼:
- GIL에 대한 이해를 돕는 글들을 찾아보세요. "What is Python GIL?", "Why Python has a GIL?" 등으로 검색하면 많은 자료를 찾을 수 있습니다.
- 데드락, 세마포어, 뮤텍스 등에 대한 다양한 비유와 예시를 제공하는 자료들을 참고하면 개념 이해에 도움이 됩니다.
