동시성, 병렬처리, 비동기 - 개념이 헷갈린다면 읽어보세요
처음에는 나도 헷갈렸다
개발하다 보면 '동시성', '병렬처리', '비동기'라는 용어를 자주 마주치게 됩니다. 처음에는 이 세 개념이 비슷해 보여서 혼용해서 쓰곤 했는데요. 사실 이 셋은 엄연히 다른 개념이더라고요.
특히 성능 최적화나 시스템 설계를 논의할 때 이런 혼동이 생기면, 잘못된 방향으로 구현하게 되거나 면접에서 곤란한 상황이 될 수도 있습니다. 그래서 오늘은 이 세 개념을 차근차근 정리해보려고 합니다.

왜 이렇게 헷갈릴까?
근본적인 이유는 세 개념 모두 **"프로그램이 여러 작업을 어떻게 처리할 것인가?"**라는 비슷한 질문에서 출발하기 때문입니다. 하지만 각자가 제시하는 해답은 완전히 다릅니다.
요리하는 상황으로 비유해보면 이해하기 쉽습니다. 요리사가 혼자서 3코스 저녁을 준비한다고 해보죠. 파스타 물을 끓이기 시작하고, 물이 데워지는 동안 채소를 다듬는 것이 동시성입니다. 레스토랑에서 두 번째 요리사를 고용해서 샐러드를 동시에 준비하게 하는 것이 병렬처리고요. 오븐에 타이머를 맞춰두고 요리가 완성되길 기다리지 않고 다른 테이블에 음식을 서빙하러 가는 것이 비동기의 핵심입니다.
같은 주방이지만 전략이 완전히 다른 거죠.
동시성: 빠른 전환의 마술
동시성(Concurrency)은 여러 작업이 동시에 진행되는 것처럼 보이지만, 실제로는 매우 빠르게 번갈아가며 처리되는 것을 말합니다. 단일 CPU 코어에서 프로세서는 작업을 아주 빠르게 전환하기 때문에 마치 동시에 일어나는 것처럼 느껴지지만, 실제로는 주어진 클럭 사이클에 하나의 명령어만 실행됩니다.
이를 타임 슬라이싱(Time Slicing) 또는 **컨텍스트 스위칭(Context Switching)**이라고 부릅니다. 운영체제가 각 작업에 짧은 시간 슬롯을 할당하고, 작업을 일시 중지한 후 상태를 저장하고 다음 작업으로 넘어가는 방식입니다.
중요한 점은 두 작업이 정확히 같은 순간에 실행되는 일은 없다는 것입니다. 번갈아가며 진행될 뿐이죠. 전체 소요 시간이 줄어들지는 않지만, 한 작업이 다른 작업의 완료를 기다리지 않기 때문에 시스템의 반응성이 훨씬 좋아집니다.
동시성이 빛나는 순간
동시성은 I/O 중심 작업에서 진가를 발휘합니다. 파일 읽기, 데이터베이스 쿼리, 네트워크 응답 대기 같은 작업들 말이죠. 이런 작업들에서는 CPU가 응답을 기다리는 동안 유휴 상태로 있는데, 동시성을 통해 이 유휴 시간을 생산적으로 활용할 수 있습니다.
예를 들어, 데이터베이스 쿼리가 100ms가 걸린다면, 그 시간 동안 CPU는 다른 요청을 처리할 수 있습니다. 결과적으로 전체 처리량이 크게 향상되죠.
병렬처리: 진짜 동시 실행
병렬처리(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에 익숙한 Ruby 개발자라면 자연스럽게 느낄 수 있는 패턴이죠.
암달의 법칙: 현실적인 한계
모든 것을 병렬화하기 전에 알아둬야 할 불편한 진실이 하나 있습니다. 바로 **암달의 법칙(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를 통해 10,000개의 동시 연결을 처리하고, 비동기 처리가 불가능한 블로킹 작업을 위해 스레드 풀을 생성하며, CPU 사용량이 많은 작업(이미지 크기 조정, PDF 생성)은 백그라운드 워커 풀로 보내 사용 가능한 모든 코어에 작업을 분산시킵니다.
어떤 도구가 어떤 계층에 속해야 하는지, 그리고 그 이유를 이해하는 것이 시스템이 원활하게 확장되는지 아니면 부하에 취약한지 여부를 결정짓는 핵심 요소라고 생각합니다.