동시성 제어와 동기화: 뮤텍스부터 Actor 모델까지
뮤텍스, 세마포어, 데드락, 락프리 자료구조, CSP, Actor 모델 등 동시성 제어 총정리
Contents
공유 자원에 동시 접근할 때 데이터 일관성을 지키는 동기화 메커니즘과 동시성 모델을 정리한다.
개요
현대 백엔드 시스템은 수천~수만 개의 동시 요청을 처리한다. 다수의 실행 흐름이 공유 자원에 접근할 때 데이터 일관성을 보장하는 것이 동시성 제어의 핵심이다. 이 노트에서는 프로세스와 스레드의 차이부터 동기화 메커니즘, 교착 상태, 그리고 최신 비동기 I/O 기술까지 다룬다.
1. 프로세스 vs 스레드
기본 비교
| 특성 | 프로세스 (Process) | 스레드 (Thread) |
|---|---|---|
| 메모리 공간 | 독립적 (각자의 주소 공간) | 공유 (같은 프로세스 내 힙, 데이터 공유) |
| 스택 | 독립적 | 독립적 (각 스레드마다 고유 스택) |
| 생성 비용 | 높음 (fork, 메모리 복제) | 낮음 (스택만 새로 할당) |
| 컨텍스트 스위칭 | 비용 높음 (TLB 플러시 필요) | 비용 낮음 (같은 주소 공간) |
| 통신 | IPC 필요 (파이프, 소켓, 공유 메모리) | 직접 메모리 접근 (빠르지만 위험) |
| 안정성 | 하나가 죽어도 다른 프로세스 무관 | 하나가 죽으면 전체 프로세스 종료 가능 |
메모리 구조 비교
백엔드 아키텍처에서의 선택
| 모델 | 사용 기술 | 장점 | 단점 |
|---|---|---|---|
| 멀티 프로세스 | 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 |
교착 상태 해결 전략
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 and 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 공식 문서, Rob Pike의 동시성 격언).
// 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 모델
아래 코드에서 findUser와 findOrders는 의존 관계가 있어 순차 await로 처리하고, 서로 독립적인 fetchProfile과 fetchNotifications는 Promise.all로 병렬 실행한다.
// 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. Linux io_uring
io_uring은 Linux 커널 5.1에서 도입된 고성능 비동기 입출력(I/O) 인터페이스다.
io_uring이란?
기존 read()/write() 시스템 콜의 오버헤드를 크게 줄인다.
핵심 구조: 링 버퍼
io_uring vs 기존 I/O
| 특성 | read()/write() | epoll | io_uring |
|---|---|---|---|
| 시스템 콜 수 | 매 I/O마다 | 이벤트 감지만 | 배치 제출 |
| 데이터 복사 | 사용자↔커널 | 사용자↔커널 | 제로 카피 가능 |
| 비동기 지원 | 없음 | 반비동기 | 완전 비동기 |
| 네트워크 + 파일 | 별도 API | 네트워크만 | 통합 API |
| 오버헤드 | 높음 | 중간 | 매우 낮음 |
2026년 최신 동향
- Linux 7.0 (2026-04): io_uring에 비순환(non-circular) 큐와 BPF 필터링을 추가해 캐시 효율과 샌드박스 제어를 개선했다(kernelnewbies LinuxChanges 기준)
- Monoio (ByteDance): Rust async 런타임으로 io_uring 기반 구현
- 실무 채택: ScyllaDB, TigerBeetle, Redpanda 등 고성능 데이터베이스가 io_uring 기반으로 구현
아래 코드는 io_uring의 기본 사용 패턴을 보여준다. io_uring_queue_init으로 링 버퍼를 초기화하고, io_uring_get_sqe로 제출 큐 항목을 얻어 io_uring_prep_read로 읽기 작업을 준비한 뒤, io_uring_submit으로 커널에 제출하고 io_uring_wait_cqe로 완료를 대기한다.
// 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)
패턴 2: Work Stealing
여러 스레드가 각자의 작업 큐를 가지되, 자기 큐가 비면 다른 스레드의 큐에서 작업을 훔쳐온다:
- Java ForkJoinPool
- Go 런타임 스케줄러
- Tokio (Rust) 런타임
패턴 3: Actor 모델
각 Actor가 독립적 상태를 가지고, 메시지로만 통신:
- Erlang/Elixir (BEAM VM)
- Akka (Java/Scala)
- 공유 메모리 없이 동시성 구현 → 교착 상태 구조적 방지
정리
동시성 제어의 출발점은 임계 영역을 보호하는 Mutex, Semaphore, Monitor 같은 동기화 도구다. 여러 락을 잡을 때는 교착 상태 4조건(상호 배제, 점유 대기, 비선점, 순환 대기)을 떠올려, 가장 실용적인 락 순서 부여로 순환 대기를 끊는다. 경합이 심한 구간은 CAS 기반 lock-free 자료구조로 교착 상태와 우선순위 역전을 피할 수 있지만 구현 난이도가 높다. 더 큰 규모에서는 공유 메모리 대신 메시지로 통신하는 CSP와 Actor 모델, 그리고 io_uring 같은 비동기 I/O가 락 경합 자체를 줄인다. 워크로드의 특성(CPU-bound인지 I/O-bound인지, 경합 강도가 어느 정도인지)에 맞춰 동기화 도구와 동시성 모델을 고르는 것이 핵심이다.
다음 노트에서는 메모리 관리(가상 메모리, 페이징, 페이지 교체, NUMA 아키텍처)를 다룬다.