본문 바로가기
TYLER SONGBlog
블로그 목록
CS학습

Concurrency: 여러 작업의 논리적 동시 진행과 자원 경쟁

동시성(Concurrency)은 여러 작업을 논리적으로 동시에 진행 가능한 구조로 다루는 것을 말하며, 실제 물리적으로 동시 실행되는 병렬성(Parallelism)과는 다른 개념이다. 단일 코어에서도 시분할(time-slicing)로 동시성을 구현할 수 있고, 멀티코어에서는 동시성과 병렬성이 함께 나타난다. 이 글에서는 스레드, 프로세스, 뮤텍스, 데드락

송민성6분 읽기

1. 개념

동시성(Concurrency)은 여러 작업이 겹치는 시간 구간 안에서 진행되도록 프로그램을 구성하는 능력을 말한다. 핵심은 "동시에 실행됨"이 아니라 "동시에 진행 중인 것처럼 다뤄짐"이라는 점이다.

이를 병렬성(Parallelism)과 혼동하기 쉬운데, 둘은 다음과 같이 구분된다.

  • 동시성(Concurrency): 여러 작업을 구조적으로 겹쳐서 다루는 것. 단일 코어 CPU에서도 컨텍스트 스위칭(context switching)으로 구현 가능하다.
  • 병렬성(Parallelism): 여러 작업이 물리적으로 동시에 실행되는 것. 멀티코어 이상의 하드웨어가 필요하다.

Rob Pike의 유명한 표현을 빌리면 "동시성은 여러 일을 동시에 다루는 것(dealing with)이고, 병렬성은 여러 일을 동시에 실행하는 것(doing)"이다.

2. 왜 사용하는가

단일 스레드로 순차 처리하면 I/O 대기(디스크, 네트워크) 동안 CPU가 유휴 상태로 남는다. 동시성을 도입하면 한 작업이 I/O를 기다리는 동안 다른 작업을 진행시켜 CPU 활용률과 처리량(throughput)을 높일 수 있다.

  • 응답성 향상: UI 스레드가 블로킹되지 않고 백그라운드 작업 진행
  • 처리량 증가: 여러 요청을 동시에 처리하는 서버
  • 자원 활용 극대화: I/O 대기 시간에 다른 계산 수행

3. 동작 원리

운영체제 관점에서 동시성은 크게 세 가지 실행 단위로 구현된다.

프로세스(Process): 독립된 메모리 공간을 가진 실행 단위. 프로세스 간 통신(IPC)이 필요하며 컨텍스트 스위칭 비용이 크다.

스레드(Thread): 프로세스 내에서 메모리(힙, 코드, 데이터 영역)를 공유하고 스택만 독립적으로 갖는 실행 단위. 프로세스보다 생성/전환 비용이 낮지만, 메모리를 공유하기 때문에 경쟁 상태(race condition)가 발생할 수 있다.

컨텍스트 스위칭(Context Switching): 단일 코어에서 여러 스레드를 번갈아 실행하기 위해 CPU가 현재 실행 상태(레지스터, 프로그램 카운터)를 저장하고 다른 스레드의 상태를 복원하는 과정. 이 전환에도 오버헤드가 존재한다.

경쟁 상태(race condition)는 두 개 이상의 스레드가 공유 자원에 동시에 접근하고, 그 결과가 실행 순서에 따라 달라질 때 발생한다. 이를 막기 위해 상호 배제(mutual exclusion)가 필요하며, 뮤텍스(mutex), 세마포어(semaphore), 락(lock) 같은 동기화 도구를 사용한다.

데드락(Deadlock)은 두 개 이상의 스레드가 서로가 점유한 자원을 기다리며 무한정 대기하는 상태다. 데드락이 발생하려면 다음 네 조건이 모두 성립해야 한다.

  1. 상호 배제(Mutual Exclusion): 자원을 한 번에 하나의 스레드만 사용
  2. 점유와 대기(Hold and Wait): 자원을 점유한 채 다른 자원을 기다림
  3. 비선점(No Preemption): 자원을 강제로 뺏을 수 없음
  4. 순환 대기(Circular Wait): 대기 관계가 원형을 이룸

4. 코드 예제

TypeScript(Node.js)의 이벤트 루프는 단일 스레드에서 동시성을 구현하는 대표적인 예다. 아래는 비동기 I/O를 이용한 동시성 예제다.

typescript
// 단일 스레드에서의 동시성: 이벤트 루프 기반 async function fetchUser(id: number): Promise<string> { return new Promise((resolve) => { // setTimeout으로 I/O 대기를 흉내냄 setTimeout(() => resolve(`user-${id}`), 100); }); } async function main() { console.time("concurrent"); // 세 요청을 동시에 시작 (실제로는 순차 실행이 아니라 겹쳐서 진행됨) const results = await Promise.all([ fetchUser(1), fetchUser(2), fetchUser(3), ]); console.timeEnd("concurrent"); // 약 100ms (순차 실행이었다면 약 300ms) console.log(results); } main();

경쟁 상태(race condition)를 보여주는 예제. Node.js는 단일 스레드지만, 워커 스레드(worker_threads)를 사용하면 실제 메모리 공유 문제가 발생할 수 있다.

typescript
import { Worker, isMainThread, workerData, parentPort } from "worker_threads"; import { SharedArrayBuffer } from "buffer"; if (isMainThread) { const sharedBuffer = new SharedArrayBuffer(4); const sharedArray = new Int32Array(sharedBuffer); sharedArray[0] = 0; const workers = [1, 2].map( () => new Worker(__filename, { workerData: sharedBuffer }) ); let finished = 0; workers.forEach((w) => { w.on("message", () => { finished++; if (finished === workers.length) { console.log("최종 값:", sharedArray[0]); // 경쟁 상태로 인해 2000000보다 작을 수 있음 } }); }); } else { const sharedArray = new Int32Array(workerData); for (let i = 0; i < 1_000_000; i++) { // 원자적이지 않은 증가 연산: read-modify-write 사이에 다른 스레드가 끼어들 수 있음 sharedArray[0]++; } parentPort?.postMessage("done"); }

이 문제를 해결하려면 Atomics API로 원자적 연산을 사용해야 한다.

typescript
// Atomics.add를 사용한 원자적 증가 (경쟁 상태 방지) Atomics.add(sharedArray, 0, 1);

Python에서 락(Lock)을 이용한 상호 배제 예제.

python
import threading counter = 0 lock = threading.Lock() def increment(): global counter for _ in range(100_000): with lock: # 임계 구역(critical section) 진입 시 락 획득 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보다 작은 값이 나올 수 있음

5. 성능 특성

  • 컨텍스트 스위칭 비용: 스레드 전환 시 마이크로초(µs) 단위의 오버헤드가 발생한다. 정확한 수치는 CPU 아키텍처와 OS 스케줄러에 따라 다르므로 단정하지 않는다.
  • 프로세스 vs 스레드: 프로세스 생성은 스레드 생성보다 무겁다. 별도의 가상 메모리 공간과 페이지 테이블을 할당해야 하기 때문이다.
  • 락 경합(lock contention): 임계 구역에 진입하려는 스레드가 많을수록 대기 시간이 늘어나며, 극단적인 경우 처리량이 스레드 수 증가에도 개선되지 않거나 오히려 감소할 수 있다.
  • 락 없는(lock-free) 자료구조는 CAS(Compare-And-Swap) 같은 원자적 연산을 사용해 락 경합을 줄이지만 구현 복잡도가 높다.

6. 실무 사용 사례

  • 웹 서버: Node.js는 단일 스레드 이벤트 루프로 I/O 동시성을 구현하고, Java 기반 서버(Tomcat 등)는 스레드 풀로 요청마다 스레드를 할당한다.
  • 데이터베이스 커넥션 풀: 여러 요청이 제한된 수의 DB 커넥션을 공유할 때 동시성 제어가 필요하다.
  • 캐시 갱신: 여러 요청이 동시에 캐시를 갱신하려 할 때 레이스 컨디션을 막기 위해 락이나 원자적 연산(예: Redis의 INCR)을 사용한다.
  • 배치 작업 병렬화: 대량 데이터 처리 시 워커 풀을 구성해 작업을 분산한다.
sql
-- PostgreSQL의 행 단위 락을 이용한 동시성 제어 예시 BEGIN; SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- 다른 트랜잭션의 접근을 막음 UPDATE accounts SET balance = balance - 100 WHERE id = 1; COMMIT;

7. 주의할 점

  • 경쟁 상태(race condition): 공유 자원에 대한 동시 접근을 반드시 동기화 도구로 보호해야 한다.
  • 데드락(deadlock): 여러 락을 획득할 때는 항상 같은 순서로 획득해서 순환 대기를 방지한다.
  • 기아 상태(starvation): 특정 스레드가 자원을 계속 할당받지 못하는 상황. 공정한 스케줄링 정책(fair lock)으로 완화할 수 있다.
  • 락 남용: 임계 구역을 필요 이상으로 넓게 잡으면 병렬성이 떨어지고 성능이 저하된다. 임계 구역은 최소한으로 유지한다.
  • 가짜 공유(false sharing): 서로 다른 데이터가 같은 캐시 라인에 위치해 불필요한 캐시 무효화가 발생하는 현상. 저수준 성능 튜닝에서 고려 대상이다.

8. 핵심 정리

동시성은 여러 작업을 논리적으로 겹쳐서 진행하는 구조이며, 병렬성은 물리적으로 동시에 실행하는 것이다. 스레드와 프로세스는 동시성을 구현하는 실행 단위이고, 공유 자원 접근은 경쟁 상태를 유발할 수 있어 뮤텍스, 세마포어 같은 동기화 도구로 보호해야 한다. 데드락은 상호 배제, 점유와 대기, 비선점, 순환 대기 네 조건이 모두 성립할 때 발생하며, 락 순서를 통일하거나 락 범위를 최소화해 예방할 수 있다.

© 2026 Tyler Song