- 들어가며 — 자주 뒤섞이는 두 단어
- 카페 비유로 이해하기
- 스레드 vs 이벤트 루프 — 두 가지 실행 모델
- 경쟁 조건과 데이터 레이스
- 락과 뮤텍스 — 순서를 강제하기
- 데드락과 식사하는 철학자
- 원자적 연산 — 락 없이 안전하게
- 언제 async가 이기고, 언제 스레드가 이기는가
- 흔한 오해와 함정
- 마치며
- 참고 자료
들어가며 — 자주 뒤섞이는 두 단어
"동시성(concurrency)"과 "병렬성(parallelism)"은 개발자들이 가장 자주 혼용하는 단어 쌍입니다. 둘 다 "여러 일이 한꺼번에 일어난다"는 느낌을 주기 때문입니다. 하지만 이 둘은 다른 개념이고, 그 차이를 흐릿하게 두면 성능 문제도, 버그도 엉뚱한 방향에서 찾게 됩니다.
Go 언어의 설계자 Rob Pike가 남긴 정의가 가장 명쾌합니다.
동시성은 여러 일을 다루는(dealing with) 것이고, 병렬성은 여러 일을 동시에 하는(doing) 것이다.
이 한 문장에 핵심이 다 들어 있습니다. 동시성은 구조에 관한 것입니다. 여러 작업을 어떻게 쪼개고 조율할지의 문제입니다. 병렬성은 실행에 관한 것입니다. 실제로 같은 순간에 여러 계산이 진행되는지의 문제입니다. 동시성은 병렬성 없이도 존재할 수 있고(코어가 하나뿐인 컴퓨터에서도 여러 작업을 번갈아 다룰 수 있으니까요), 병렬성은 동시성을 실현하는 하나의 방법일 뿐입니다.
이 글은 이 구분에서 출발해 스레드와 이벤트 루프, 경쟁 조건과 락, 데드락과 원자적 연산까지, 동시 프로그래밍의 핵심 지형을 정리합니다. 이벤트 루프가 실제로 어떻게 작업을 번갈아 처리하는지 눈으로 보고 싶다면 메시지 큐 놀이터의 asyncio 탭을, 여러 연산이 병렬로 흐르는 계산 그래프를 보고 싶다면 신경망 실험실을 함께 열어보세요.
카페 비유로 이해하기
Rob Pike의 정의를 일상 장면으로 옮겨보면 이렇습니다.
카페에 바리스타가 한 명 있습니다. 주문이 세 개 들어왔습니다. 이 바리스타는 첫 주문의 에스프레소를 내리는 동안 두 번째 주문의 우유를 데우고, 그 사이 세 번째 주문의 컵을 준비합니다. 한 사람이지만 세 주문을 다루고 있습니다. 이것이 동시성입니다. 실제로 같은 순간에 두 손으로 두 가지를 하는 게 아니라, 기다리는 시간을 활용해 작업 사이를 오가는 것입니다.
이제 바리스타를 세 명 고용합니다. 각자 한 주문씩 맡아 진짜로 동시에 만듭니다. 이것이 병렬성입니다. 세 잔이 같은 순간에 실제로 만들어집니다.
핵심 통찰은 이것입니다. 동시성은 문제를 독립적으로 실행 가능한 조각으로 구조화하는 방법이고, 병렬성은 그 조각들을 실제로 여러 일꾼에게 나눠 동시에 실행하는 것입니다. 잘 설계된 동시적 구조는 일꾼이 하나면 번갈아 처리하고, 일꾼이 여럿이면 자연스럽게 병렬로 확장됩니다.
스레드 vs 이벤트 루프 — 두 가지 실행 모델
동시성을 코드로 구현하는 대표적인 방법이 두 가지 있습니다. 스레드 기반 모델과 이벤트 루프(async) 모델입니다.
스레드 모델은 운영체제가 여러 실행 흐름(스레드)을 만들고, 스케줄러가 이들을 코어에 배정합니다. 코어가 여러 개면 스레드들이 진짜 병렬로 실행됩니다. 각 스레드는 자기 스택을 가지고 독립적으로 진행하며, 운영체제가 언제든 한 스레드를 멈추고 다른 스레드로 전환할 수 있습니다(선점형 스케줄링).
이벤트 루프 모델은 단 하나의 스레드가 있고, 그 안에서 여러 작업(코루틴, 태스크)을 번갈아 처리합니다. 앞의 카페 바리스타와 같습니다. 작업이 I/O를 기다리는 순간(await) 그 작업을 잠시 내려놓고 다른 작업을 진행합니다. 전환 지점을 프로그래머가 명시적으로 표시한다는 점에서 협력형 스케줄링입니다.
스레드 모델 (선점형)
스레드 A ████░░░░████░░░░ OS가 임의 시점에 전환
스레드 B ░░░░████░░░░████ 여러 코어면 진짜 병렬 가능
이벤트 루프 모델 (협력형)
단일 스레드 A─await─B─await─A─await─C ...
작업이 스스로 양보하는 지점에서만 전환
두 모델의 근본적 차이는 "전환이 언제 일어나는가"입니다. 스레드는 운영체제가 아무 때나 끼어들어 전환합니다. 그래서 진짜 병렬 실행이 가능하지만, 바로 그 때문에 공유 데이터를 다룰 때 위험합니다. 이벤트 루프는 오직 await 같은 명시적 지점에서만 전환하므로 예측 가능하지만, 한 작업이 양보하지 않고 계산을 붙들면 나머지 전부가 멈춥니다.
경쟁 조건과 데이터 레이스
동시 프로그래밍이 어려운 이유의 핵심에 **경쟁 조건(race condition)**이 있습니다. 여러 실행 흐름이 공유 자원에 접근하는데, 그 결과가 실행 순서(타이밍)에 따라 달라지는 상황입니다.
가장 고전적인 예가 카운터 증가입니다. counter += 1이라는 한 줄은 사실 세 단계로 이뤄집니다.
1. counter 값을 읽는다 (예: 10)
2. 그 값에 1을 더한다 (11)
3. 결과를 counter에 쓴다 (11)
두 스레드가 동시에 이 세 단계를 실행하면 다음과 같은 일이 벌어질 수 있습니다.
스레드 A: 읽기(10) ....... 더하기(11) 쓰기(11)
스레드 B: ......... 읽기(10) ....... 더하기(11) 쓰기(11)
결과: counter = 11 (두 번 증가시켰는데 11!)
두 번 더했는데 결과는 11입니다. 하나의 증가가 사라졌습니다. 이렇게 여러 스레드가 동기화 없이 같은 메모리에 접근하고 그중 하나 이상이 쓰기를 하는 상황을 특히 **데이터 레이스(data race)**라고 부릅니다. 데이터 레이스는 경쟁 조건의 한 종류이며, 많은 언어에서 정의되지 않은 동작(undefined behavior)으로 취급됩니다.
파이썬으로 이 문제를 재현하면 이렇습니다.
import threading
counter = 0
def increment():
global counter
for _ in range(100_000):
counter += 1 # 원자적이지 않음 — 레이스 발생 가능
threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(counter) # 400000이 아니라 그보다 작은 값이 나올 수 있음
경쟁 조건의 무서운 점은 재현성이 낮다는 것입니다. 대부분의 경우 우연히 순서가 맞아 정상 동작하다가, 부하가 높거나 타이밍이 특정 조건일 때만 어긋납니다. 그래서 테스트에서는 안 나타나다가 운영 환경에서만 터지는 악명 높은 버그가 됩니다.
락과 뮤텍스 — 순서를 강제하기
경쟁 조건을 막는 기본 도구가 락(lock), 그중에서도 상호 배제를 뜻하는 **뮤텍스(mutex, mutual exclusion)**입니다. 아이디어는 단순합니다. 공유 자원을 만지는 코드 구간(임계 구역, critical section)에는 한 번에 하나의 실행 흐름만 들어가게 합니다.
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(100_000):
with lock: # 한 번에 한 스레드만 이 블록 안에
counter += 1
# 이제 결과는 항상 400000
with lock 블록에 들어가려면 락을 얻어야 하고, 한 스레드가 락을 쥐고 있으면 다른 스레드는 그것이 풀릴 때까지 기다립니다. 덕분에 읽기-더하기-쓰기 세 단계가 쪼개지지 않고 원자적으로 실행됩니다.
락은 강력하지만 공짜가 아닙니다. 세 가지 대가가 따릅니다.
- 성능: 락을 얻고 푸는 데 비용이 들고, 여러 스레드가 같은 락을 두고 다투면(경합, contention) 줄 서서 기다리느라 병렬성이 사라집니다.
- 입도(granularity) 선택: 락을 넓게 잡으면(coarse-grained) 안전하지만 병렬성이 줄고, 좁게 잡으면(fine-grained) 병렬성은 좋지만 복잡하고 버그가 생기기 쉽습니다.
- 데드락 위험: 락을 여러 개 쓰면 서로 상대의 락을 기다리며 영원히 멈추는 교착 상태가 생길 수 있습니다.
데드락과 식사하는 철학자
**데드락(deadlock, 교착 상태)**은 둘 이상의 실행 흐름이 서로가 쥔 자원을 기다리며 아무도 진행하지 못하는 상태입니다. 이를 가장 잘 보여주는 고전 문제가 Edsger Dijkstra가 제시한 **식사하는 철학자(dining philosophers)**입니다.
원형 식탁에 철학자 다섯 명이 앉아 있습니다. 각자 앞에는 스파게티 접시가 있고, 철학자들 사이에는 포크가 하나씩, 총 다섯 개 놓여 있습니다. 스파게티를 먹으려면 왼쪽과 오른쪽 포크 두 개가 모두 필요합니다.
철학자0
포크4 포크0
철학자4 철학자1
포크3 포크1
철학자3 포크2 철학자2
각 철학자는 양옆 포크 2개가 있어야 식사 가능
이제 모든 철학자가 동시에 "먼저 왼쪽 포크를 집고, 그다음 오른쪽 포크를 집는다"는 똑같은 규칙을 따른다고 합시다. 모두가 동시에 왼쪽 포크를 집으면, 다섯 개의 포크가 모두 한 손씩 잡힌 상태가 됩니다. 이제 모두가 오른쪽 포크를 집으려 하지만, 오른쪽 포크는 옆 사람의 왼손에 있습니다. 아무도 두 번째 포크를 얻지 못하고, 아무도 첫 번째 포크를 놓지 않습니다. 영원한 교착입니다.
데드락이 성립하려면 네 가지 조건(Coffman 조건)이 동시에 만족되어야 합니다.
- 상호 배제: 자원을 한 번에 하나만 쓸 수 있다(포크는 공유 불가).
- 점유하며 대기: 자원을 쥔 채로 다른 자원을 기다린다(왼쪽 포크를 쥐고 오른쪽을 기다림).
- 비선점: 남이 쥔 자원을 강제로 뺏을 수 없다.
- 순환 대기: 대기의 사슬이 원형으로 이어진다.
이 중 하나만 깨도 데드락을 막을 수 있습니다. 대표적인 해법이 순환 대기를 깨는 것입니다. 예를 들어 포크에 번호를 매기고 "항상 번호가 낮은 포크를 먼저 집는다"는 규칙을 두면, 마지막 철학자만 반대 순서로 집게 되어 순환이 끊어지고 데드락이 사라집니다.
원자적 연산 — 락 없이 안전하게
락은 무겁습니다. 단순히 카운터 하나를 안전하게 올리려고 락을 잡았다 푸는 것은 과합니다. 그래서 하드웨어가 직접 지원하는 **원자적 연산(atomic operation)**이 있습니다.
원자적 연산은 CPU가 "쪼갤 수 없는 하나의 동작"으로 보장하는 연산입니다. 앞서 본 읽기-더하기-쓰기 세 단계를, 중간에 다른 스레드가 끼어들 수 없는 단일 명령으로 실행합니다. 대표적인 것이 **CAS(Compare-And-Swap)**입니다.
CAS(주소, 기대값, 새값):
만약 *주소 == 기대값이면
*주소 = 새값 으로 바꾸고 성공 반환
아니면
아무것도 안 하고 실패 반환
— 이 전체가 원자적으로 일어남
CAS를 쓰면 락 없이도 안전한 증가를 만들 수 있습니다. "현재 값을 읽고, 그것이 여전히 그대로면 +1한 값으로 바꾼다. 그 사이 누가 바꿨으면 실패하니 다시 시도한다"는 방식입니다. 이런 접근을 락-프리(lock-free) 프로그래밍이라고 부릅니다.
원자적 연산의 장점은 락의 오버헤드와 데드락 위험이 없다는 것입니다. 하지만 만능은 아닙니다. 카운터나 플래그처럼 단순한 단일 값에는 훌륭하지만, 여러 자료구조를 한꺼번에 일관되게 바꿔야 하는 복잡한 경우에는 원자적 연산만으로 표현하기 어렵고, 잘못 쓰면 오히려 미묘한 버그(예: ABA 문제)를 부릅니다. 그래서 실무에서는 단순한 경우엔 원자적 연산, 복잡한 경우엔 락으로 역할을 나눕니다.
언제 async가 이기고, 언제 스레드가 이기는가
이제 가장 실용적인 질문입니다. 동시성이 필요할 때 async(이벤트 루프)를 쓸 것인가, 스레드(또는 멀티프로세싱)를 쓸 것인가? 답은 작업의 성격에 달려 있습니다.
작업은 크게 두 부류로 나뉩니다.
- I/O 바운드: 대부분의 시간을 무언가를 기다리는 데 쓰는 작업. 네트워크 응답, 디스크 읽기, 데이터베이스 쿼리. CPU는 거의 놀고 있습니다.
- CPU 바운드: 대부분의 시간을 실제 계산에 쓰는 작업. 이미지 처리, 암호화, 수치 시뮬레이션. CPU가 쉴 틈 없이 돕니다.
이 구분이 선택을 결정합니다.
I/O 바운드에는 async가 이깁니다. 기다리는 시간이 많으므로, 한 스레드가 그 기다림의 틈에 다른 작업을 처리하면 됩니다. 스레드를 수천 개 만드는 것보다 이벤트 루프 하나로 수천 개의 연결을 다루는 편이 메모리도 훨씬 적게 쓰고 전환 비용도 낮습니다. 웹 서버, 프록시, 크롤러처럼 "많이 기다리는" 작업이 여기에 딱 맞습니다.
import asyncio
async def fetch(name, delay):
await asyncio.sleep(delay) # 기다리는 동안 다른 코루틴 실행
return name
async def main():
# 세 작업을 동시에 — 총 대기 시간이 아니라 최댓값에 가깝게
results = await asyncio.gather(
fetch("a", 2), fetch("b", 1), fetch("c", 3),
)
print(results)
asyncio.run(main())
CPU 바운드에는 스레드(더 정확히는 여러 프로세스)가 필요합니다. 계산은 양보할 틈이 없으므로 이벤트 루프에서는 한 작업이 CPU를 붙들면 나머지가 다 멈춥니다. 진짜 병렬 실행이 필요하고, 그러려면 여러 코어에 작업을 분산해야 합니다. 여기서 파이썬 특유의 함정이 하나 있습니다. CPython에는 **GIL(Global Interpreter Lock)**이 있어서, 여러 스레드가 있어도 한 번에 하나의 스레드만 파이썬 바이트코드를 실행합니다. 그래서 파이썬에서 CPU 바운드 작업을 진짜 병렬로 돌리려면 스레드가 아니라 멀티프로세싱(여러 프로세스)을 써야 합니다.
정리하면 이렇습니다.
| 상황 | 좋은 선택 | 이유 |
|---|---|---|
| 많이 기다리는 I/O (네트워크, 디스크) | async / 이벤트 루프 | 기다림의 틈을 재활용, 가벼움 |
| 무거운 계산 (CPU 집약) | 멀티프로세싱 / 여러 코어 | 진짜 병렬 실행 필요 |
| I/O인데 async 라이브러리가 없음 | 스레드 풀 | 블로킹 호출을 스레드로 격리 |
| 파이썬에서 CPU 병렬 | 멀티프로세싱 | GIL 때문에 스레드로는 병렬 안 됨 |
흔한 오해와 함정
동시 프로그래밍에서 자주 걸리는 지점들을 짚습니다.
- "async가 항상 빠르다"는 오해: async는 I/O 바운드에서만 이깁니다. CPU 바운드 작업을 async로 감싸면 이벤트 루프가 막혀 오히려 더 느려집니다.
- 이벤트 루프 안의 블로킹 호출: 단일 스레드 이벤트 루프에서 동기 블로킹 함수(일반
time.sleep, 블로킹 DB 드라이버 등)를 부르면 루프 전체가 멈춥니다. async 대응 라이브러리를 쓰거나 별도 스레드로 넘겨야 합니다. - "스레드를 많이 만들면 빨라진다"는 오해: 스레드는 각자 스택 메모리를 쓰고 전환 비용이 있습니다. 코어 수를 훨씬 넘는 스레드는 오히려 전환 오버헤드로 느려집니다.
- 락을 넓게 잡아 병렬성을 죽이기: 안전하다고 임계 구역을 크게 잡으면 사실상 순차 실행이 되어 멀티코어의 이점이 사라집니다.
- 경쟁 조건을 "가끔 나는 버그"로 방치: 재현이 어렵다고 넘기면 운영 환경에서 데이터 손상으로 돌아옵니다. 공유 상태에는 반드시 동기화를 설계해야 합니다.
마치며
동시성과 병렬성은 비슷해 보이지만 층위가 다릅니다. 동시성은 여러 일을 다루도록 구조화하는 것이고, 병렬성은 그 일들을 실제로 여러 코어에서 동시에 실행하는 것입니다. 잘 설계된 동시적 프로그램은 코어가 하나면 번갈아 처리하고, 코어가 여럿이면 자연스럽게 병렬로 확장됩니다.
그리고 이 구조를 안전하게 만드는 것이 동기화의 기술입니다. 경쟁 조건을 락이나 원자적 연산으로 막고, 데드락을 순서 규칙으로 피하고, 작업의 성격(I/O냐 CPU냐)에 맞춰 async와 멀티프로세싱을 고릅니다. 이 지도를 머릿속에 두면, "왜 느린가"와 "왜 가끔 틀리는가"라는 두 종류의 문제를 훨씬 정확한 곳에서 찾을 수 있습니다.
이벤트 루프가 작업 사이를 어떻게 오가는지 메시지 큐 놀이터의 asyncio 탭에서 직접 확인하고, 여러 연산이 병렬로 흐르는 계산 그래프를 신경망 실험실에서 살펴보세요.
참고 자료
- Rob Pike, "Concurrency Is Not Parallelism": https://go.dev/blog/waza-talk
- Dining philosophers problem (Wikipedia): https://en.wikipedia.org/wiki/Dining_philosophers_problem
- Deadlock and Coffman conditions (Wikipedia): https://en.wikipedia.org/wiki/Deadlock
- Compare-and-swap (Wikipedia): https://en.wikipedia.org/wiki/Compare-and-swap
- Python asyncio 공식 문서: https://docs.python.org/3/library/asyncio.html
- Python GIL 설명: https://docs.python.org/3/glossary.html#term-global-interpreter-lock
현재 단락 (1/117)
"동시성(concurrency)"과 "병렬성(parallelism)"은 개발자들이 가장 자주 혼용하는 단어 쌍입니다. 둘 다 "여러 일이 한꺼번에 일어난다"는 느낌을 주기 때문입니...