동시성, 병렬처리, 비동기 - 개념이 헷갈린다면 읽어보세요
처음에는 나도 헷갈렸다
개발하다 보면 '동시성', '병렬처리', '비동기'라는 용어를 정말 자주 마주칩니다. 저도 처음에는 이 셋이 비슷해 보여서 거의 같은 뜻으로 섞어 쓰곤 했어요. 그런데 운영과 아키텍처 자리에 앉아 시스템을 들여다보다 보니, 이 셋은 출발점만 비슷할 뿐 사는 동네가 완전히 다른 개념이더라고요.
이 차이를 모르면 성능 문제를 엉뚱한 방향으로 잡습니다. I/O 병목인데 코어를 늘리고, 정작 늘려야 할 곳은 그대로 두는 식이죠. 면접에서도 이 셋을 구분 못 하면 바로 티가 납니다. 그래서 오늘은 세 개념을 한 번 차근차근 정리해보려고 합니다.

왜 이렇게 헷갈릴까?
근본적인 이유는 세 개념이 모두 **"프로그램이 여러 작업을 어떻게 처리할 것인가?"**라는 비슷한 질문에서 출발하기 때문입니다. 질문은 같은데, 각자 내놓는 해답의 층위가 다릅니다.
요리하는 상황으로 비유하면 감이 잡힙니다. 요리사 혼자서 3코스 저녁을 준비한다고 해보죠. 파스타 물을 올려두고, 끓는 동안 채소를 다듬는 것이 동시성입니다. 두 번째 요리사를 고용해서 샐러드를 같은 시각에 따로 만드는 것이 병렬처리고요. 오븐에 타이머를 맞춰두고 완성될 때까지 멍하니 서 있는 대신 다른 테이블에 서빙하러 가는 것이 비동기의 핵심입니다.
같은 주방인데 전략이 완전히 다른 거죠. 그리고 이건 단순한 비유가 아니라, 실제 시스템 설계에서 어떤 자원을 어떻게 쓸지 결정하는 사고의 틀이기도 합니다.
동시성: 빠른 전환의 마술
동시성(Concurrency)은 여러 작업이 동시에 진행되는 것처럼 보이지만, 실제로는 매우 빠르게 번갈아가며 처리되는 것을 말합니다. 단일 CPU 코어에서 프로세서는 작업을 아주 빠르게 전환하기 때문에 마치 동시에 일어나는 것처럼 느껴지지만, 실제로는 주어진 클럭 사이클에 하나의 명령어만 실행됩니다.
이를 타임 슬라이싱(Time Slicing) 또는 **컨텍스트 스위칭(Context Switching)**이라고 부릅니다. 운영체제가 각 작업에 짧은 시간 슬롯을 할당하고, 작업을 일시 중지한 뒤 상태를 저장하고 다음 작업으로 넘어가는 방식이죠.
중요한 건 두 작업이 정확히 같은 순간에 실행되는 일은 없다는 점입니다. 번갈아 진행될 뿐이에요. 전체 소요 시간이 줄어들지는 않지만, 한 작업이 다른 작업의 완료를 기다리며 멈춰 있지 않기 때문에 시스템의 반응성이 훨씬 좋아집니다.
동시성이 빛나는 순간
동시성은 I/O 중심 작업에서 진가를 발휘합니다. 파일 읽기, 데이터베이스 쿼리, 네트워크 응답 대기 같은 작업들이죠. 이런 작업에서는 CPU가 응답을 기다리는 동안 그냥 놀고 있는데, 동시성으로 이 유휴 시간을 다른 일에 쓸 수 있습니다.
예를 들어 데이터베이스 쿼리가 100ms 걸린다면, 그 시간 동안 CPU는 다른 요청을 처리합니다. 결과적으로 전체 처리량이 크게 올라가죠. 인터넷뱅킹처럼 요청 하나하나가 결국 뒷단 DB나 외부 시스템 응답을 기다리는 구조에서, 이 부분을 제대로 다루느냐가 동시 사용자 수를 좌우합니다.
병렬처리: 진짜 동시 실행
병렬처리(Parallelism)는 여러 작업이 정확히 같은 순간에 각기 다른 CPU 코어에서 실행되는 것을 의미합니다. 작업 순서가 바뀌는 게 아니라, 물리적으로 별개의 처리 단위에서 동시에 일어나는 거죠.
코어가 두 개면 클럭 사이클당 명령어 두 개를 실행할 수 있다는 뜻입니다. 사람들이 "멀티스레딩 쓰면 빨라지겠지"라고 말할 때 실제로 기대하는 게 바로 이겁니다.
하지만 조건이 붙습니다. 물리적인 CPU 코어가 두 개 이상이어야 하고, 작업들이 서로 의존적이지 않아야 합니다. 한쪽 결과를 다른 쪽이 기다려야 한다면, 코어를 아무리 늘려도 결국 줄 서서 기다리게 되거든요.
병렬처리가 강력한 영역
병렬처리는 CPU 집약적인 작업에서 탁월합니다. 이미지 처리, 비디오 인코딩, 행렬 곱셈, 머신러닝 추론 같은 작업이 대표적이죠. 이런 문제는 독립적인 단위로 잘게 쪼개 동시에 돌릴 수 있어서, 처리 속도가 선형(또는 거의 선형)으로 올라갑니다.
병렬처리의 대가
다만 병렬처리에는 공유 상태 문제가 따라옵니다. 두 코어가 같은 메모리 위치에 동시에 쓰기를 시도하면 결과가 정의되지 않는데, 이를 **경쟁 조건(Race Condition)**이라고 합니다.
이걸 막으려면 뮤텍스(Mutex), 세마포어(Semaphore), 원자적 연산(Atomic Operation) 같은 동기화 장치가 필요합니다. 문제는 이런 장치들이 코드를 복잡하게 만들고, 그 자체가 또 다른 병목(락 경합, Lock Contention)이 될 수 있다는 점이에요.
이것이 병렬 코드를 동시성 코드보다 제대로 작성하기 어려운 이유입니다. 멀티스레드 시스템의 버그가 유독 미묘하고 비결정적인 것도 같은 맥락이고요. 운영하다 보면 가장 머리 아픈 장애가 바로 이런 종류입니다. 재현이 안 되거든요. 부하가 특정 패턴으로 몰릴 때만 한 번씩 터지는데, 로그만 봐서는 원인이 안 잡힙니다.
비동기: 블로킹 없는 대기의 예술
비동기 프로그래밍(Asynchronous Programming)은 하드웨어 속성이 아니라 프로그래밍 모델입니다. 단일 스레드가 노는 시간 없이 어떻게 여러 작업을 효율적으로 굴리는가에 대한 답이죠. 여기서 한 번 층위를 정리하고 가는 게 좋습니다. 동시성·병렬처리가 "무엇이 어떻게 실행되는가"의 이야기라면, 비동기는 그걸 단일 스레드 위에서 풀어내는 한 가지 방식입니다.
핵심 아이디어는 **이벤트 루프(Event Loop)**입니다. 응답(예: 데이터베이스 쿼리)을 기다리는 동안 스레드를 붙잡아두는 대신, 콜백이나 연속 작업을 등록하고 스레드를 풀어준 다음, 응답이 도착하면 중단된 지점부터 다시 시작합니다.
비동기의 효율성
예를 들어 사용자 정보와 주문 정보를 각각 가져와야 한다고 해보죠. 동기로 처리하면 사용자 정보(1초) + 주문 정보(1초) = 총 2초입니다. 하지만 비동기로 처리하면 두 쿼리가 거의 같은 시점에 출발해서, 총 대기 시간은 대략 max(사용자_시간, 주문_시간) ≈ 1초가 됩니다.
스레드가 하나뿐인데도 말이죠. 이게 핵심적인 효율성 향상입니다.
비동기의 구현
대부분의 언어에서 비동기 코드는 전용 구문을 씁니다. JavaScript, Python, Rust는 async/await를, Ruby는 파이버(Fiber)를, Go는 고루틴(Goroutine)을 사용하죠. 런타임은 선형적으로 보이는 코드를 특정 지점(await)에서 멈췄다 다시 시작하는 상태 기계로 변환합니다.
Ruby에서 파이버를 사용한 예제를 보면:
require 'fiber'
fetch_user = Fiber.new do
puts "사용자 가져오는 중..."
sleep(1) # 데이터베이스 대기 시뮬레이션
Fiber.yield "사용자: Alice"
end
fetch_orders = Fiber.new do
puts "주문 가져오는 중..."
sleep(1) # 데이터베이스 대기 시뮬레이션
Fiber.yield "주문: [#1, #2, #3]"
end
# 두 파이버는 협력적으로 실행되며 서로를 차단하지 않습니다
user = fetch_user.resume
orders = fetch_orders.resume
puts user
puts orders
실제 Rails 애플리케이션에서는 Async gem이나 Falcon 웹서버가 파이버 기반 모델로 진짜 비동기 I/O를 구현합니다. 단일 Rails 프로세스가 수천 개의 스레드를 만들지 않고도 여러 동시 요청을 처리할 수 있게 해주죠.
세 개념의 관계
이 세 가지는 서로 배타적이지 않습니다. 실제 시스템에서는 셋을 다 섞어 씁니다.
- 동시성은 구조에 관한 것입니다. 프로그램을 여러 작업을 다루도록 설계하는 방법이죠.
- 병렬처리는 실행에 관한 것입니다. 그 작업들이 물리적으로 동시에 도는지 여부와 관계있습니다.
- 비동기는 여러 스레드를 전혀 쓰지 않고도 동시성을 구현하는 특정한 기술입니다.
Go 언어의 공동 개발자인 롭 파이크(Rob Pike)가 이걸 깔끔하게 정리했습니다: "동시성은 여러 가지 일을 동시에 처리하는 것이고, 병렬성은 여러 가지 일을 동시에 수행하는 것이다."
이 한 문장이 헷갈릴 때마다 기준점이 돼줍니다. '처리'와 '수행'의 차이라는 거죠.

언제 어떤 것을 선택할까?
성능이나 확장성 문제에 부딪혔을 때, 저는 다음 네 가지 질문을 순서대로 던져봅니다.
1. 병목은 CPU인가, I/O인가?
먼저 프로파일링부터 합니다. 추측으로 시작하면 거의 틀려요. 대부분의 웹 애플리케이션은 I/O 바운드입니다. 데이터베이스, 캐시, 외부 API가 응답 시간의 80~95%를 차지하거든요. I/O 바운드 문제에 병렬처리를 들이부어도 별 변화가 없는 경우가 많습니다. 코어만 비싸게 쓰고 끝나죠.
2. 동시에 실행되는 작업 수는 몇 개인가?
수십 개의 스레드는 괜찮지만, 수천 개의 스레드는 메모리가 부담됩니다. Ruby나 Java 스레드 하나는 스택 메모리를 약 1~8MB 먹습니다. 수천 개의 동시 연결이 예상된다면 비동기 방식이 메모리 효율 면에서 훨씬 유리합니다.
3. 작업들이 상태를 공유하는가?
공유한다면 모든 선택지가 더 복잡해집니다. 단일 이벤트 루프를 쓰는 비동기 방식은 이 문제를 자연스럽게 피해 갑니다. 반면 병렬처리는 신중한 락킹이나 불변 데이터 구조가 필요하죠. 여기서 설계를 대충 하면 앞서 말한 비결정적 버그가 운영 단계에서 튀어나옵니다.
4. 쓰는 런타임이 무엇을 잘 지원하는가?
Ruby의 GVL(Global VM Lock)은 Ruby 스레드의 진짜 병렬처리를 막지만, Ruby 3.0에서 도입된 Ractor는 격리된 상태를 유지하며 진짜 병렬 실행을 가능하게 합니다. Node.js는 설계상 단일 스레드 + 비동기 I/O이고요. Go는 처음부터 저렴한 스레드형 기본 요소로 동시 고루틴을 굴리도록 설계됐습니다. 도구가 깔아둔 길을 거슬러서 억지로 패턴을 끼워 맞추면 대개 고생합니다.
Ruby로 보는 실제 사례
Ruby는 이 개념들을 이해하기에 좋은 교재입니다. Ruby의 발전 과정 자체가 업계가 이 개념들을 어떻게 받아들여 왔는지를 그대로 보여주거든요.
클래식 Ruby와 GVL
클래식 Ruby(MRI)는 **GVL(Global VM Lock, GIL이라고도 함)**을 씁니다. 멀티코어 시스템에서도 한 번에 하나의 Ruby 스레드만 실행되죠. 대부분의 경우 경쟁 조건을 막아주지만, 동시에 Ruby 스레드가 병렬처리가 아닌 동시성만 제공한다는 뜻이기도 합니다.
I/O 작업이 많은 Rails 앱이라면 이게 문제가 안 됩니다. I/O 작업 중에는 GVL이 풀리므로, 데이터베이스를 기다리는 동안 스레드들이 실제로 동시에 진행되거든요.
Ruby 3.x의 Ractor
Ruby 3.x에서는 액터 모델 격리를 통한 진짜 병렬처리를 위해 Ractor가 도입됐습니다. 각 Ractor는 자체 힙을 가지며 메시지 전달로 통신합니다. 공유 상태를 아예 없애는 대신, Ractor 경계를 넘나드는 객체에 대한 제약이 훨씬 엄격해지죠.
# Ruby 3.x Ractor 예제 — 진정한 병렬 실행
ractor1 = Ractor.new { (1..10_000).reduce(:+) }
ractor2 = Ractor.new { (10_001..20_000).reduce(:+) }
result = ractor1.take + ractor2.take
puts result # => 200_010_000
# 두 Ractor는 각각 별도의 OS 스레드에서 실행되므로 진정한 병렬 실행이 가능합니다
한편 async 젬은 Ruby에 협력적 동시성(이벤트 루프 방식)을 들여와서, 동기식처럼 보이는 비동기 코드를 작성할 수 있게 해줍니다. Rails에 익숙한 개발자라면 자연스럽게 받아들일 수 있는 패턴이죠.
암달의 법칙: 현실적인 한계
모든 걸 병렬화하기 전에 짚어둘 불편한 진실이 하나 있습니다. 바로 **암달의 법칙(Amdahl's Law)**입니다.
프로그램의 일부만 병렬화할 수 있을 때, N개 프로세서로 얻을 수 있는 이론상 최대 속도 향상은:
최대_속도향상 = 1 / (순차_비율 + (병렬_비율 / N))
코드의 50%가 본질적으로 순차(직렬) 실행이라면, 코어를 무한대로 투입해도 최대 속도 향상은 2배에 불과합니다. 100배도, 10배도 아닙니다.
이게 최적화 전에 프로파일링이 중요한 또 하나의 이유입니다. 실행 시간의 90%를 직렬 병목에서 까먹고 있다면, 코어를 아무리 들이부어도 병렬처리의 이점을 제대로 누릴 수 없거든요. 인프라 증설로 성능을 해결하려는 시도가 비용만 늘리고 끝나는 경우, 대개 이 법칙을 외면한 결과입니다.
흔한 오해들 바로잡기
"멀티스레딩은 항상 속도를 향상시킨다"
작업이 CPU 집약적이고, 작업들이 완전히 독립적이며, 여유 코어가 있을 때만 맞는 말입니다. 제대로 구성된 비동기 서버에서 I/O 집약적인 코드라면, 멀티스레딩은 이점 없이 오버헤드만 얹습니다.
"비동기(Async)는 병렬처리를 의미한다"
아닙니다. Node.js나 Ruby의 비동기 서버는 단일 스레드를 씁니다. 두 요청이 동시에 진행될 수는 있어도(번갈아가며), 절대 같은 순간에 실행되지는 않아요. CPU 집약적인 작업에는 비동기가 아무 도움이 안 됩니다.
"동시성은 위험하다"
구현 방식에 따라 다릅니다. 단일 이벤트 루프를 쓰는 비동기는 의외로 안전합니다. 위험은 상태를 공유하는 멀티스레딩에서 옵니다. 액터 모델(예: Ractor나 Erlang 프로세스)은 상태 공유 자체를 없애서 동시 시스템을 훨씬 안전하게 만들죠.
"GVL 때문에 Ruby 스레드는 쓸모없다"
대부분의 Rails 앱처럼 I/O가 많은 환경에서는 스레드가 아주 유용합니다. GVL은 I/O 대기 중에 풀리므로, 데이터베이스 쿼리나 HTTP 호출 시에는 스레드가 실제로 동시에 진행됩니다. 이 제약이 발목을 잡는 건 CPU 작업이 많은 환경뿐이에요.

실제 시스템에서의 조화
결국 이 세 개념은 계층적인 사고 모델을 이룹니다. 비동기는 단일 스레드에서 최대 I/O 효율을 끌어내는 프로그래밍 기법이고, 동시성은 비동기든 시간 분할 스레드든 여러 작업이 함께 진행되도록 하는 더 넓은 설계 접근입니다. 병렬처리는 CPU 집약적인 문제를 독립적인 조각으로 나눠 동시에 푸는 하드웨어 수준의 처리 능력이고요.
현장의 시스템은 대부분 이 셋을 한꺼번에 씁니다. 웹서버는 비동기 I/O로 수많은 동시 연결을 받아내고, 비동기로 풀 수 없는 블로킹 작업을 위해 스레드 풀을 두며, CPU를 많이 먹는 작업(이미지 리사이징, PDF 생성)은 백그라운드 워커 풀로 넘겨 가용한 모든 코어에 분산시킵니다. 한 계층에서 다 해결하려는 순간 어딘가가 비틀어지죠.
어떤 도구가 어느 계층에 속해야 하는지, 그리고 왜 그런지를 이해하는 것. 저는 이게 시스템이 부하 앞에서 매끄럽게 늘어나느냐, 아니면 어느 한 지점에서 툭 끊기느냐를 가르는 핵심이라고 봅니다. 성능 문제의 답은 보통 새로운 기술이 아니라, 지금 가진 작업이 CPU 바운드인지 I/O 바운드인지를 정직하게 보는 것에서 시작하더라고요.