동시성 제어와 동기화: 뮤텍스부터 Actor 모델까지
뮤텍스, 세마포어, 데드락, 락프리 자료구조, CSP, Actor 모델 등 동시성 제어 총정리
상위 노트: 컴퓨터 공학 기초 관련 노트: 프로세스 관리와 CPU 스케줄링, 메모리 관리
개요
현대 백엔드 시스템은 수천~수만 개의 동시 요청을 처리한다. 다수의 실행 흐름이 공유 자원에 접근할 때 데이터 일관성을 보장하는 것이 동시성 제어의 핵심이다. 이 노트에서는 프로세스와 스레드의 차이부터 동기화 메커니즘, 교착 상태, 그리고 최신 비동기 I/O 기술까지 다룬다.
1. 프로세스 vs 스레드
기본 비교
| 특성 | 프로세스 (Process) | 스레드 (Thread) |
|---|---|---|
| 메모리 공간 | 독립적 (각자의 주소 공간) | 공유 (같은 프로세스 내 힙, 데이터 공유) |
| 스택 | 독립적 | 독립적 (각 스레드마다 고유 스택) |
| 생성 비용 | 높음 (fork, 메모리 복제) | 낮음 (스택만 새로 할당) |
| 컨텍스트 스위칭 | 비용 높음 (TLB 플러시 필요) | 비용 낮음 (같은 주소 공간) |
| 통신 | IPC 필요 (파이프, 소켓, 공유 메모리) | 직접 메모리 접근 (빠르지만 위험) |
| 안정성 | 하나가 죽어도 다른 프로세스 무관 | 하나가 죽으면 전체 프로세스 종료 가능 |
메모리 구조 비교
[프로세스 A] [프로세스 B]
┌──────────┐ ┌──────────┐
│ Stack │ │ Stack │
│ Heap │ ← 독립적 → │ Heap │
│ Data │ │ Data │
│ Text │ │ Text │
└──────────┘ └──────────┘
[스레드 1] [스레드 2] ← 같은 프로세스 내
┌────────┐ ┌────────┐
│ Stack │ │ Stack │ ← 각 스레드 독립 스택
└────────┘ └────────┘
↕ 공유 ↕
┌────────────────────┐
│ Heap (공유) │ ← 여기서 동시성 문제 발생
│ Data (공유) │
│ Text (공유) │
└────────────────────┘백엔드 아키텍처에서의 선택
| 모델 | 사용 기술 | 장점 | 단점 |
|---|---|---|---|
| 멀티 프로세스 | Nginx Worker, PM2 Cluster | 안정성, 격리 | 메모리 사용량, IPC 오버헤드 |
| 멀티 스레드 | Java Spring, Go net/http | 성능, 자원 공유 | 동기화 복잡성 |
| 이벤트 루프 | Node.js, Redis | 단순성, 적은 오버헤드 | CPU-bound 작업에 부적합 |
| 코루틴/경량 스레드 | Go Goroutine, Kotlin Coroutine | 수십만 개 동시 실행 | 디버깅 복잡성 |
2. 동시성 문제 (Concurrency Problems)
경쟁 조건 (Race Condition)
두 개 이상의 실행 흐름이 공유 데이터에 동시 접근하여 결과가 실행 순서에 의존하게 되는 상황.
# 경쟁 조건 예시: 은행 잔고 업데이트
balance = 1000
# 스레드 A: 출금 500
temp_a = balance # 1000 읽음
temp_a = temp_a - 500 # 500 계산
# 스레드 B: 출금 300 (A가 쓰기 전에 읽음)
temp_b = balance # 1000 읽음 (아직 A의 결과 반영 안됨)
temp_b = temp_b - 300 # 700 계산
balance = temp_a # 500 저장
balance = temp_b # 700 저장 (A의 출금이 사라짐!)
# 기대값: 200, 실제값: 700임계 영역 (Critical Section)
공유 자원에 접근하는 코드 영역. 동시에 하나의 실행 흐름만 진입해야 한다.
임계 영역의 3가지 요구 조건:
- 상호 배제 (Mutual Exclusion): 한 번에 하나의 프로세스만 임계 영역 진입
- 진행 (Progress): 임계 영역이 비어있으면 진입 대기 중인 프로세스가 진입 가능
- 한정 대기 (Bounded Waiting): 무한히 기다리는 프로세스가 없어야 함
3. 동기화 메커니즘
Mutex (Mutual Exclusion Lock)
이진 잠금 장치. 한 번에 하나의 스레드만 잠금을 획득하고 임계 영역에 진입할 수 있다.
// Go에서의 Mutex 사용
var mu sync.Mutex
var balance int = 1000
func withdraw(amount int) {
mu.Lock() // 잠금 획득
defer mu.Unlock() // 함수 종료 시 잠금 해제
if balance >= amount {
balance -= amount
}
}| 특성 | 설명 |
|---|---|
| 소유권 | 있음 -- 잠금을 획득한 스레드만 해제 가능 |
| 카운트 | 이진 (0 또는 1) |
| 용도 | 임계 영역 보호 |
| 문제 | 우선순위 역전 (Priority Inversion) |
Semaphore
카운팅 기반 동기화 도구. 동시 접근 가능한 자원의 수를 제어한다.
# Python에서의 Semaphore 사용
# DB 연결 풀: 최대 5개 동시 연결
db_pool = threading.Semaphore(5)
def query_database(sql):
db_pool.acquire() # 카운터 감소 (0이면 블로킹)
try:
connection = get_connection()
return connection.execute(sql)
finally:
db_pool.release() # 카운터 증가| 특성 | 설명 |
|---|---|
| 소유권 | 없음 -- 누구나 signal/release 가능 |
| 카운트 | 0 이상의 정수 |
| 용도 | 자원 개수 제한, 프로세스 간 신호 전달 |
| 종류 | Binary Semaphore (0/1), Counting Semaphore |
Mutex vs Semaphore 핵심 차이
Mutex: 화장실 열쇠 (1개)
→ 한 사람만 들어갈 수 있고, 그 사람만 나올 때 열쇠 반환
Semaphore: 주차장 카운터 (N개)
→ N대까지 입장 가능, 아무나 나가면 카운터 증가
→ 소유권 개념 없음Monitor
고수준 동기화 구조체. Mutex + Condition Variable을 캡슐화하여 안전한 동기화를 제공한다.
// Java의 Monitor (synchronized + wait/notify)
class BoundedBuffer {
private final Queue<Integer> queue = new LinkedList<>();
private final int capacity;
synchronized void produce(int item) throws InterruptedException {
while (queue.size() == capacity) {
wait(); // 버퍼가 가득 차면 대기
}
queue.add(item);
notifyAll(); // 소비자에게 신호
}
synchronized int consume() throws InterruptedException {
while (queue.isEmpty()) {
wait(); // 버퍼가 비었으면 대기
}
int item = queue.poll();
notifyAll(); // 생산자에게 신호
return item;
}
}Condition Variable
특정 조건이 만족될 때까지 스레드를 대기시키는 메커니즘:
- wait(): 조건이 만족될 때까지 대기 (Mutex를 자동 해제하고 대기)
- signal(): 대기 중인 스레드 하나를 깨움
- broadcast(): 대기 중인 모든 스레드를 깨움
4. 교착 상태 (Deadlock)
정의
두 개 이상의 프로세스가 서로가 보유한 자원을 기다리며 영원히 진행하지 못하는 상태.
발생 4가지 조건 (코프만 조건)
4가지 조건이 동시에 모두 만족되어야 교착 상태가 발생한다:
| 조건 | 설명 | 예시 |
|---|---|---|
| 상호 배제 (Mutual Exclusion) | 자원은 한 번에 하나의 프로세스만 사용 | DB row lock |
| 점유 대기 (Hold and Wait) | 자원을 보유한 채로 다른 자원 대기 | Lock A를 잡고 Lock B 대기 |
| 비선점 (No Preemption) | 이미 할당된 자원을 강제로 빼앗을 수 없음 | OS가 lock을 강제 회수 불가 |
| 순환 대기 (Circular Wait) | 프로세스 간 자원 대기가 원형 | A→B→C→A |
교착 상태 예시:
스레드 A: Lock(X) 획득 → Lock(Y) 대기
스레드 B: Lock(Y) 획득 → Lock(X) 대기
A ──가지고있음──→ X
↑ ↓ 대기
대기 B
↓ ↑
Y ←──가지고있음── B교착 상태 해결 전략
1. 예방 (Prevention) -- 4가지 조건 중 하나를 원천 차단
| 차단 조건 | 방법 | 단점 |
|---|---|---|
| 상호 배제 | 자원 공유 가능하게 | 현실적으로 불가능한 경우 많음 |
| 점유 대기 | 필요한 모든 자원을 한꺼번에 요청 | 자원 낭비, 기아 가능 |
| 비선점 | 자원을 강제로 회수 | 일관성 문제 |
| 순환 대기 | 자원에 순서 부여, 오름차순으로만 요청 | 가장 실용적 |
# 순환 대기 예방: Lock을 항상 같은 순서로 획득
LOCK_ORDER = {'account_lock': 1, 'transaction_lock': 2}
def transfer(from_acc, to_acc, amount):
# 항상 ID가 작은 계좌의 lock을 먼저 획득
first, second = sorted([from_acc, to_acc], key=lambda a: a.id)
with first.lock:
with second.lock:
from_acc.balance -= amount
to_acc.balance += amount2. 회피 (Avoidance) -- 은행원 알고리즘 (Banker's Algorithm)
시스템이 **안전 상태(Safe State)**인지를 확인한 후에만 자원을 할당한다.
- 안전 상태: 모든 프로세스가 순서대로 자원을 할당받아 완료될 수 있는 상태
- 불안전 상태: 교착 상태에 빠질 가능성이 있는 상태 (반드시 교착 상태는 아님)
3. 탐지 및 회복 (Detection & Recovery)
교착 상태를 허용하되, 주기적으로 탐지하여 해결한다:
- 탐지: Wait-for Graph에서 순환(cycle) 탐지
- 회복: 프로세스 종료, 자원 선점, 롤백
실무에서의 교착 상태
| 상황 | 해결책 |
|---|---|
| DB 트랜잭션 교착 | Lock timeout + 재시도 로직 |
| 분산 시스템 교착 | 전역 순서 보장, 분산 lock 관리자 |
| 마이크로서비스 순환 호출 | 서킷 브레이커, 비동기 메시지 큐 |
5. 고전 동기화 문제
생산자-소비자 문제 (Producer-Consumer)
유한 버퍼를 사이에 두고 생산자와 소비자가 동기화하는 문제.
해결: Mutex(버퍼 접근 보호) + Semaphore 2개(빈 슬롯 수, 채워진 슬롯 수)
읽기-쓰기 문제 (Readers-Writers)
- 읽기는 동시에 여러 스레드가 가능
- 쓰기는 배타적 접근 필요
해결: Read-Write Lock (RWLock)
// Go의 RWMutex
var rwmu sync.RWMutex
var data map[string]string
func read(key string) string {
rwmu.RLock() // 읽기 잠금 (여러 고루틴 동시 가능)
defer rwmu.RUnlock()
return data[key]
}
func write(key, value string) {
rwmu.Lock() // 쓰기 잠금 (배타적)
defer rwmu.Unlock()
data[key] = value
}식사하는 철학자 문제 (Dining Philosophers)
원형 테이블에서 5명의 철학자가 양쪽 포크를 동시에 잡아야 식사할 수 있는 문제. 교착 상태, 기아, 동시성의 종합 문제.
6. Lock-free 자료구조와 비차단 동기화
CAS (Compare-And-Swap) 연산
Lock-free 프로그래밍의 핵심 원자적(Atomic) 연산:
CAS(address, expected_value, new_value):
if *address == expected_value:
*address = new_value
return true
else:
return false// Java의 AtomicInteger -- CAS 기반
AtomicInteger counter = new AtomicInteger(0);
// Lock 없이 안전한 증가
counter.incrementAndGet();
// CAS 직접 사용
int expected = counter.get();
boolean success = counter.compareAndSet(expected, expected + 1);Lock-free vs Lock-based 비교
| 특성 | Lock-based | Lock-free |
|---|---|---|
| 교착 상태 | 가능 | 불가능 |
| 우선순위 역전 | 가능 | 불가능 |
| 성능 (경합 없을 때) | 비슷함 | 약간 빠름 |
| 성능 (경합 심할 때) | 급격히 저하 | 점진적 저하 |
| 구현 난이도 | 상대적으로 쉬움 | 매우 어려움 |
| 사용 예 | 일반적 동기화 | Redis, Java ConcurrentHashMap |
ABA 문제
CAS의 알려진 함정:
- 스레드 A가 값 "A"를 읽음
- 스레드 B가 A→B→A로 변경 (값이 다시 A)
- 스레드 A가 CAS 성공 (변경된 줄 모름)
해결: 버전 번호 추가 (AtomicStampedReference)
7. 현대적 동시성 모델
Go Goroutine -- CSP 모델
Go는 Communicating Sequential Processes (CSP) 모델을 채택한다. "공유 메모리로 통신하지 말고, 통신으로 메모리를 공유하라."
// Go Channel을 통한 안전한 통신
func main() {
ch := make(chan int, 10) // 버퍼 채널
// 생산자 고루틴
go func() {
for i := 0; i < 100; i++ {
ch <- i // 채널에 전송
}
close(ch)
}()
// 소비자: 채널에서 수신
for v := range ch {
fmt.Println(v)
}
}- 고루틴은 약 2KB 스택으로 시작 (OS 스레드: ~1MB)
- 수십만 개의 고루틴을 동시에 실행 가능
- Go 런타임 스케줄러가 M:N 스케줄링 (M개 고루틴: N개 OS 스레드)
Rust 소유권 모델 -- 컴파일 타임 동시성 안전성
Rust는 **소유권(Ownership)**과 빌림(Borrowing) 시스템으로 컴파일 타임에 데이터 레이스를 방지한다:
use std::thread;
use std::sync::{Arc, Mutex};
fn main() {
// Arc: Atomic Reference Counting (스레드 안전한 참조 카운팅)
// Mutex: 내부 데이터 보호
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
}Rust의 규칙:
- 가변 참조(
&mut T)는 동시에 하나만 존재 가능 → 데이터 레이스 원천 차단 Sendtrait: 스레드 간 소유권 이전 가능한 타입 표시Synctrait: 스레드 간 참조 공유 가능한 타입 표시- 규칙 위반 시 컴파일 에러 → 런타임 버그 방지
async/await 모델
// Node.js의 비동기 모델
async function handleRequest(req) {
// I/O 대기 중 이벤트 루프가 다른 요청 처리
const user = await db.findUser(req.userId);
const orders = await db.findOrders(user.id);
// 병렬 실행
const [profile, notifications] = await Promise.all([
fetchProfile(user.id),
fetchNotifications(user.id)
]);
return { user, orders, profile, notifications };
}8. io_uring -- 리눅스 비동기 I/O의 혁명 (2019-2026)
io_uring이란?
Linux 커널 5.1에서 도입된 고성능 비동기 I/O 인터페이스. 기존 read()/write() 시스템 콜의 오버헤드를 획기적으로 줄인다.
핵심 구조: 링 버퍼
사용자 공간 커널 공간
┌───────────────┐ ┌───────────────┐
│ Submission │ ← mmap() → │ │
│ Queue (SQ) │ 공유 메모리 │ Kernel │
│ SQE1, SQE2..│ │ I/O 처리 │
└───────────────┘ │ │
│ │
┌───────────────┐ │ │
│ Completion │ ← mmap() → │ │
│ Queue (CQ) │ 공유 메모리 │ │
│ CQE1, CQE2..│ └───────────────┘
└───────────────┘
SQE: Submission Queue Entry (요청)
CQE: Completion Queue Entry (결과)io_uring vs 기존 I/O
| 특성 | read()/write() | epoll | io_uring |
|---|---|---|---|
| 시스템 콜 수 | 매 I/O마다 | 이벤트 감지만 | 배치 제출 |
| 데이터 복사 | 사용자↔커널 | 사용자↔커널 | 제로 카피 가능 |
| 비동기 지원 | 없음 | 반비동기 | 완전 비동기 |
| 네트워크 + 파일 | 별도 API | 네트워크만 | 통합 API |
| 오버헤드 | 높음 | 중간 | 매우 낮음 |
2026년 최신 동향
- Linux 7.0(6.20): IOPOLL 요청 추적 방식 개선으로 비동기 I/O 폴링 성능 향상
- Monoio (ByteDance): Rust async 런타임으로 io_uring 기반 구현
- 실무 채택: ScyllaDB, TigerBeetle, Redpanda 등 고성능 데이터베이스가 io_uring 기반으로 구현
// io_uring 기본 사용 예시 (개념적)
struct io_uring ring;
io_uring_queue_init(32, &ring, 0);
// 읽기 요청 제출
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, size, offset);
io_uring_submit(&ring);
// 완료 결과 수신
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
int bytes_read = cqe->res;
io_uring_cqe_seen(&ring, cqe);9. 백엔드 실무에서의 동시성 패턴
패턴 1: 커넥션 풀 (Connection Pool)
요청 1 ──→ ┌──────────────┐ ──→ DB 연결 1
요청 2 ──→ │ Connection │ ──→ DB 연결 2
요청 3 ──→ │ Pool (N=5) │ ──→ DB 연결 3
요청 4 ──→ │ │ ──→ DB 연결 4
요청 5 ──→ │ Semaphore(5) │ ──→ DB 연결 5
요청 6 ──→ └──────────────┘ ──→ 대기... (블로킹)패턴 2: Work Stealing
여러 스레드가 각자의 작업 큐를 가지되, 자기 큐가 비면 다른 스레드의 큐에서 작업을 훔쳐온다:
- Java ForkJoinPool
- Go 런타임 스케줄러
- Tokio (Rust) 런타임
패턴 3: Actor 모델
각 Actor가 독립적 상태를 가지고, 메시지로만 통신:
- Erlang/Elixir (BEAM VM)
- Akka (Java/Scala)
- 공유 메모리 없이 동시성 구현 → 교착 상태 구조적 방지
다음 노트
→ 메모리 관리: 가상 메모리, 페이징, 페이지 교체, NUMA 아키텍처