동시성(Concurrency)과 병렬성(Parallelism): 복잡한 시스템의 효율적인 작업 처리 이해하기

1. 개념 소개: 정의, 탄생 배경, 왜 중요한지

현대 소프트웨어 시스템은 단일 작업을 빠르게 처리하는 것뿐만 아니라, 동시에 여러 작업을 효율적으로 처리하는 능력이 중요해졌습니다. 웹 서버는 수많은 사용자 요청을 동시에 처리해야 하고, 데이터 분석 애플리케이션은 대규모 데이터를 병렬로 처리하여 시간을 단축해야 합니다. 이러한 요구사항을 충족시키기 위해 우리는 **동시성(Concurrency)**과 **병렬성(Parallelism)**이라는 두 가지 핵심 개념을 이해하고 활용해야 합니다. 하지만 이 두 용어는 자주 혼용되거나 잘못 이해되곤 합니다.
**동시성(Concurrency)**은 여러 작업을 번갈아가며 처리함으로써 동시에 진행되는 것처럼 보이게 만드는 시스템의 능력입니다. 이는 마치 한 사람이 여러 가지 일을 동시에 처리하는 것처럼 보이지만, 실제로는 짧은 시간 간격으로 작업을 전환하며 수행하는 것과 같습니다. 예를 들어, 웹 브라우저가 여러 탭을 열어두고 사용자 입력에 응답하면서 백그라운드에서 이미지 다운로드를 진행하는 것이 동시성의 한 예입니다. 주로 I/O(입출력) 작업이 많은 환경에서 CPU가 I/O 대기 시간 동안 다른 작업을 처리하여 자원 활용 효율을 높이는 데 초점을 맞춥니다.
**병렬성(Parallelism)**은 여러 작업을 진정한 동시에 여러 처리 장치(예: CPU 코어)에서 처리하는 시스템의 능력입니다. 이는 여러 사람이 각자 다른 일을 동시에 수행하는 것과 같습니다. 병렬성은 여러 CPU 코어나 프로세서가 존재할 때만 가능하며, 주로 복잡한 계산이나 대규모 데이터 처리와 같이 CPU 사용량이 많은 작업(CPU 바운드)에서 전체 처리 시간을 단축하는 데 활용됩니다.
탄생 배경: 과거에는 단일 CPU 코어의 클럭 속도를 높여 성능을 향상시키는 것이 주된 방식이었습니다(무어의 법칙). 하지만 트랜지스터 밀도가 높아지면서 발생하는 발열 문제와 물리적 한계로 인해 클럭 속도 향상만으로는 성능 증가에 한계가 왔습니다. 이에 따라 하드웨어 제조사들은 단일 칩에 여러 개의 CPU 코어를 집적하는 멀티코어(Multi-core) 프로세서 개발로 방향을 전환했습니다.
멀티코어 시대의 도래는 소프트웨어 개발 방식에도 큰 변화를 가져왔습니다. 더 이상 단일 스레드 애플리케이션만으로는 하드웨어의 잠재력을 최대한 활용할 수 없게 된 것입니다. 이제는 여러 코어를 활용하여 작업을 분산 처리하거나, I/O 대기 시간 동안 다른 작업을 수행하여 시스템의 전반적인 처리량을 높이는 것이 필수적이 되었습니다.
왜 중요한가:
- 성능 향상: CPU 바운드 작업의 경우 병렬성을 통해 처리 시간을 크게 단축할 수 있습니다. I/O 바운드 작업의 경우 동시성을 통해 시스템의 응답성과 처리량을 높일 수 있습니다.
- 응답성 개선: 사용자 인터페이스(UI)를 멈추지 않고 백그라운드 작업을 처리하여 부드러운 사용자 경험을 제공합니다.
- 자원 활용 효율: I/O 작업으로 인해 CPU가 유휴 상태로 대기하는 시간을 줄여 시스템 자원을 더욱 효율적으로 사용합니다.
- 확장성: 시스템이 더 많은 요청이나 더 큰 작업을 처리할 수 있도록 유연하게 설계할 수 있습니다.
동시성과 병렬성을 올바르게 이해하고 적용하는 것은 현대 소프트웨어 개발자에게 필수적인 역량입니다.
2. 핵심 원리 설명 (비유와 다이어그램 활용)

동시성과 병렬성의 차이를 이해하는 가장 좋은 방법은 비유를 활용하는 것입니다.
비유: 커피숍 바리스타
-
동시성 (Concurrency): 한 명의 바리스타가 여러 손님을 응대하는 경우
- 손님 A: "아메리카노 주세요!"
- 손님 B: "라떼 주세요!"
- 바리스타는 손님 A의 아메리카노 주문을 받고, 에스프레소를 추출하기 시작합니다. 에스프레소가 추출되는 동안 (I/O 대기 시간), 바리스타는 손님 B의 라떼 주문을 받고 우유를 스팀합니다. 에스프레소 추출이 끝나면 다시 아메리카노 작업으로 돌아가 우유를 붓고 완성합니다. 그 다음 라떼를 완성합니다.
- 이 바리스타는 한 번에 한 가지 작업만 하지만, 여러 손님을 효율적으로 번갈아가며 응대하여 모든 손님이 "동시에 서비스받고 있다"고 느끼게 만듭니다. 즉, 작업의 **진행(progress)**을 관리하는 능력입니다.
- 핵심: 단일 코어에서도 구현 가능하며, 주로 I/O 대기 시간을 활용하여 다른 작업을 처리합니다. 컨텍스트 스위칭(Context Switching)이라는 오버헤드가 발생합니다.
-
병렬성 (Parallelism): 여러 명의 바리스타가 여러 손님을 응대하는 경우
- 손님 A: "아메리카노 주세요!"
- 손님 B: "라떼 주세요!"
- 바리스타 1은 손님 A의 아메리카노를 처음부터 끝까지 만듭니다.
- 바리스타 2는 손님 B의 라떼를 처음부터 끝까지 만듭니다.
- 두 바리스타는 진정한 동시에 각자의 커피를 만들고 있습니다. 두 손님은 동시에 커피를 받습니다.
- 핵심: 멀티코어 CPU와 같이 여러 개의 처리 장치가 있을 때만 가능하며, 여러 작업을 물리적으로 동시에 실행합니다.
다이어그램으로 보는 차이:
시간의 흐름 ---------------------------------->
[ 동시성 (Concurrency) - 단일 코어 ]
CPU: [ Task A ] -> [ Idle (I/O) ] -> [ Task B ] -> [ Idle (I/O) ] -> [ Task A ] -> [ Idle (I/O) ] -> [ Task B ] ...
(컨텍스트 스위칭)
[ 병렬성 (Parallelism) - 멀티 코어 ]
CPU 1: [ Task A ] ---------------------------------------------> [ Task A 완료 ]
CPU 2: [ Task B ] ---------------------------------------------> [ Task B 완료 ]
관련 개념:
- 프로세스(Process): 운영체제로부터 독립적인 메모리 공간과 자원을 할당받아 실행되는 프로그램의 인스턴스입니다. 각 프로세스는 독립적이며, 서로 직접적으로 메모리를 공유하지 않습니다. 프로세스 간 통신(IPC)은 비용이 많이 듭니다.
- 스레드(Thread): 프로세스 내에서 실행되는 실행 흐름의 단위입니다. 한 프로세스 내의 여러 스레드는 같은 메모리 공간과 자원을 공유합니다. 따라서 스레드 간 통신은 프로세스 간 통신보다 효율적이지만, 공유 자원에 대한 동기화 문제가 발생할 수 있습니다.
- Global Interpreter Lock (GIL, 파이썬): 파이썬의 CPython 인터프리터가 한 번에 하나의 스레드만 바이트코드를 실행하도록 강제하는 메커니즘입니다. 이는 파이썬 스레드가 CPU 바운드 작업에서 진정한 병렬성을 달성하기 어렵게 만듭니다. 즉, 멀티코어 CPU에서도 파이썬의
threading모듈은 I/O 바운드 작업에 대한 동시성에는 효과적이지만, CPU 바운드 작업에서는 병렬성을 제공하지 못하고 오히려 컨텍스트 스위칭 오버헤드만 발생시킬 수 있습니다. 진정한 병렬성을 위해서는multiprocessing모듈을 사용해야 합니다.
정리하자면, 동시성은 여러 작업을 잘 관리하는 능력이고, 병렬성은 여러 작업을 동시에 실행하는 능력입니다. 동시성은 병렬성 없이도 가능하지만, 병렬성은 동시성을 포함합니다.
3. 코드 예제 2개 (Python 또는 JavaScript, 주석 포함)
예제 1: Python (I/O 바운드 동시성 vs. CPU 바운드 병렬성)
파이썬의 threading과 multiprocessing 모듈을 사용하여 동시성과 병렬성의 차이를 보여줍니다. threading은 GIL 때문에 CPU 바운드 작업에서 진정한 병렬성을 얻기 어렵고, multiprocessing은 각 프로세스가 독립적인 인터프리터를 가지므로 GIL의 제약을 받지 않고 병렬성을 달성할 수 있습니다.
import time
import threading
import multiprocessing
import os
# --- 1. CPU 바운드 작업 ---
def cpu_bound_task(n):
"""주어진 숫자까지의 소수를 찾는 CPU 집약적 작업"""
primes = []
for num in range(2, n + 1):
is_prime = True
for i in range(2, int(num**0.5) + 1):
if num % i == 0:
is_prime = False
break
if is_prime:
primes.append(num)
# print(f"Process {os.getpid()} finished CPU-bound task up to {n}")
return primes
# --- 2. I/O 바운드 작업 ---
def io_bound_task(duration):
"""네트워크 요청이나 파일 읽기/쓰기 대기 시간을 시뮬레이션하는 I/O 집약적 작업"""
print(f"Thread {threading.current_thread().name} starting I/O-bound task for {duration} seconds...")
time.sleep(duration) # I/O 대기 시뮬레이션
print(f"Thread {threading.current_thread().name} finished I/O-bound task.")
if __name__ == "__main__":
print("--- CPU 바운드 작업 테스트 ---")
N = 5000000 # 계산할 상한선
# 순차 실행 (Single-threaded)
start_time = time.time()
cpu_bound_task(N)
print(f"순차 실행 (CPU): {time.time() - start_time:.2f}초")
# 스레딩 (Threading) - 파이썬의 GIL 때문에 병렬성 효과 미미 (CPU 바운드)
print("\n스레딩을 이용한 CPU 바운드 작업 (GIL의 영향)")
start_time = time.time()
threads = []
for _ in range(2): # 2개의 스레드로 작업 분할
thread = threading.Thread(target=cpu_bound_task, args=(N // 2,)) # N/2씩 두 번
threads.append(thread)
thread.start()
for thread in threads:
thread.join()
print(f"스레딩 실행 (CPU): {time.time() - start_time:.2f}초 (순차와 비슷하거나 더 느릴 수 있음)")
# 멀티프로세싱 (Multiprocessing) - 진정한 병렬성 (CPU 바운드)
print("\n멀티프로세싱을 이용한 CPU 바운드 작업 (진정한 병렬성)")
start_time = time.time()
processes = []
for _ in range(2): # 2개의 프로세스로 작업 분할
process = multiprocessing.Process(target=cpu_bound_task, args=(N // 2,)) # N/2씩 두 번
processes.append(process)
process.start()
for process in processes:
process.join()
print(f"멀티프로세싱 실행 (CPU): {time.time() - start_time:.2f}초 (순차보다 훨씬 빠름)")
print("\n--- I/O 바운드 작업 테스트 ---")
# 순차 실행 (Single-threaded)
start_time = time.time()
io_bound_task(2)
io_bound_task(2)
print(f"순차 실행 (I/O): {time.time() - start_time:.2f}초")
# 스레딩 (Threading) - I/O 바운드 동시성 (GIL의 영향 없음)
print("\n스레딩을 이용한 I/O 바운드 작업 (동시성)")
start_time = time.time()
threads = []
for i in range(2):
thread = threading.Thread(target=io_bound_task, args=(2,), name=f"IO_Thread-{i}")
threads.append(thread)
thread.start()
for thread in threads:
thread.join()
print(f"스레딩 실행 (I/O): {time.time() - start_time:.2f}초 (순차보다 훨씬 빠름)")
예제 설명:
cpu_bound_task: CPU를 많이 사용하는 소수 계산 함수입니다.io_bound_task:time.sleep()을 사용하여 네트워크 요청이나 파일 I/O와 같은 대기 시간을 시뮬레이션합니다.- CPU 바운드 작업:
- 순차 실행: 하나의 작업이 끝난 후 다음 작업이 시작됩니다.
- 스레딩:
threading모듈을 사용하지만, 파이썬의 GIL 때문에 멀티코어 CPU에서도 진정한 병렬 실행이 어렵습니다. 따라서 순차 실행과 시간 차이가 거의 없거나 오히려 컨텍스트 스위칭 오버헤드로 인해 더 느릴 수 있습니다. - 멀티프로세싱:
multiprocessing모듈을 사용하면 각 프로세스가 독립적인 파이썬 인터프리터를 가지므로 GIL의 제약을 받지 않고 여러 CPU 코어에서 병렬로 실행됩니다. 따라서 순차 실행보다 훨씬 빠르게 완료됩니다.
- I/O 바운드 작업:
- 순차 실행: 한 번의
time.sleep(2)이 끝난 후 다음time.sleep(2)이 시작되어 총 4초가 걸립니다. - 스레딩:
threading모듈을 사용합니다.time.sleep()동안 파이썬 인터프리터는 GIL을 해제하므로, 다른 스레드가 실행될 수 있습니다. 결과적으로 두io_bound_task가 거의 동시에 시작되어 전체 시간이 약 2초로 단축됩니다. 이는 I/O 바운드 작업에서 스레딩이 동시성을 제공하는 좋은 예입니다.
- 순차 실행: 한 번의
예제 2: JavaScript (Node.js) (비동기 동시성 vs. 워커 스레드 병렬성)
자바스크립트는 기본적으로 싱글 스레드 언어이지만, Node.js 환경에서는 비동기 I/O와 워커 스레드를 통해 동시성 및 병렬성을 구현할 수 있습니다.
// --- 1. CPU 바운드 작업 ---
function cpuBoundTask(n) {
let sum = 0;
for (let i = 0; i < n; i++) {
sum += Math.sqrt(i); // CPU를 많이 사용하는 연산
}
return sum;
}
// --- 2. I/O 바운드 작업 ---
function ioBoundTask(duration) {
return new Promise(resolve => {
console.log(`[${new Date().toLocaleTimeString()}] Starting I/O-bound task for ${duration}ms...`);
setTimeout(() => { // 비동기 I/O 대기 시뮬레이션
console.log(`[${new Date().toLocaleTimeString()}] Finished I/O-bound task for ${duration}ms.`);
resolve(`I/O task finished in ${duration}ms`);
}, duration);
});
}
// --- 메인 스레드 실행 ---
async function runMainThread() {
console.log("--- I/O 바운드 작업 테스트 (비동기 동시성) ---");
const ioStartTime = Date.now();
await Promise.all([
ioBoundTask(2000), // 2초 대기
ioBoundTask(2000) // 2초 대기
]);
console.log(`비동기 I/O 실행 (동시성): ${Date.now() - ioStartTime}ms (총 2초 내외)`);
console.log("\n--- CPU 바운드 작업 테스트 (싱글 스레드) ---");
const cpuStartTime = Date.now();
const N = 500000000; // 큰 숫자
console.log(`[${new Date().toLocaleTimeString()}] Starting CPU-bound task...`);
cpuBoundTask(N); // 이 작업이 완료될 때까지 다른 작업 블로킹
cpuBoundTask(N); // 이 작업이 완료될 때까지 다른 작업 블로킹
console.log(`[${new Date().toLocaleTimeString()}] Finished CPU-bound tasks.`);
console.log(`싱글 스레드 CPU 실행: ${Date.now() - cpuStartTime}ms (총 2*N의 시간)`);
}
// Node.js Worker Threads를 이용한 병렬성 (CPU 바운드)
// 이 부분은 별도의 파일 (worker.js)로 작성하고 메인 스크립트에서 호출해야 합니다.
// (간결함을 위해 여기에 포함하며, 실제 실행 시에는 worker.js 파일을 생성해야 함)
/*
// worker.js 파일 내용:
const { parentPort } = require('worker_threads');
function cpuBoundTask(n) {
let sum = 0;
for (let i = 0; i < n; i++) {
sum += Math.sqrt(i);
}
return sum;
}
parentPort.on('message', (message) => {
if (message.type === 'start') {
const result = cpuBoundTask(message.data.n);
parentPort.postMessage({ type: 'result', data: result });
}
});
*/
async function runWorkerThreads() {
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
const N = 500000000;
if (isMainThread) {
console.log("\n--- CPU 바운드 작업 테스트 (워커 스레드 병렬성) ---");
const workerStartTime = Date.now();
const numWorkers = 2;
const promises = [];
for (let i = 0; i < numWorkers; i++) {
promises.push(new Promise((resolve, reject) => {
// worker.js 파일이 실제 존재해야 합니다.
const worker = new Worker('./worker.js'); // 또는 인라인 워커 코드를 사용
worker.on('message', (msg) => {
if (msg.type === 'result') {
console.log(`[${new Date().toLocaleTimeString()}] Worker ${i} finished.`);
resolve(msg.data);
}
});
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) reject(new Error(`Worker stopped with exit code ${code}`));
});
console.log(`[${new Date().toLocaleTimeString()}] Worker ${i} starting CPU-bound task...`);
worker.postMessage({ type: 'start', data: { n: N / numWorkers } }); // 작업 분할
}));
}
await Promise.all(promises);
console.log(`워커 스레드 CPU 실행 (병렬성): ${Date.now() - workerStartTime}ms (순차보다 훨씬 빠름)`);
}
}
// 실행
(async () => {
await runMainThread();
// 워커 스레드 예제를 실행하려면 worker.js 파일을 생성해야 합니다.
// try {
// await runWorkerThreads();
// } catch (e) {
// console.error("워커 스레드 실행 중 오류 발생:", e.message);
// console.warn("워커 스레드 예제를 실행하려면 'worker.js' 파일을 생성해야 합니다. 주석을 참고하세요.");
// }
})();
예제 설명:
cpuBoundTask: CPU를 많이 사용하는 연산 함수입니다.ioBoundTask:setTimeout을 사용하여 비동기 I/O 대기 시간을 시뮬레이션합니다.Promise를 반환하여async/await과 함께 사용될 수 있도록 합니다.- I/O 바운드 작업 (비동기 동시성):
Promise.all과ioBoundTask를 사용하면, 두ioBoundTask가 거의 동시에 시작됩니다.setTimeout은 Node.js 이벤트 루프에 의해 비동기적으로 처리되므로, 메인 스레드는ioBoundTask의 대기 시간 동안 블로킹되지 않고 다른 작업을 수행할 수 있습니다. 결과적으로 총 2초 내외로 두 작업이 완료됩니다.
- CPU 바운드 작업 (싱글 스레드):
cpuBoundTask는 메인 스레드에서 동기적으로 실행되므로, 이 함수가 실행되는 동안 Node.js 이벤트 루프는 블로킹되고 다른 I/O 작업이나 사용자 입력에 응답할 수 없습니다. 두 번의cpuBoundTask가 순차적으로 실행되어 총 시간이 길어집니다.
- CPU 바운드 작업 (워커 스레드 병렬성):
- Node.js의
worker_threads모듈은 별도의 스레드에서 자바스크립트 코드를 실행할 수 있게 하여 CPU 바운드 작업의 병렬 처리를 가능하게 합니다.worker.js파일을 생성하고 메인 스크립트에서new Worker()로 호출하면, 각 워
- Node.js의
