이종관
글 목록으로

동시성 제어와 동기화: 뮤텍스부터 Actor 모델까지

뮤텍스, 세마포어, 데드락, 락프리 자료구조, CSP, Actor 모델 등 동시성 제어 총정리

2025년 1월 13일·19 min read·
backend
concurrency
mutex
deadlock
actor-model

상위 노트: 컴퓨터 공학 기초 관련 노트: 프로세스 관리와 CPU 스케줄링, 메모리 관리

개요

현대 백엔드 시스템은 수천~수만 개의 동시 요청을 처리한다. 다수의 실행 흐름이 공유 자원에 접근할 때 데이터 일관성을 보장하는 것이 동시성 제어의 핵심이다. 이 노트에서는 프로세스와 스레드의 차이부터 동기화 메커니즘, 교착 상태, 그리고 최신 비동기 I/O 기술까지 다룬다.

1. 프로세스 vs 스레드

기본 비교

특성프로세스 (Process)스레드 (Thread)
메모리 공간독립적 (각자의 주소 공간)공유 (같은 프로세스 내 힙, 데이터 공유)
스택독립적독립적 (각 스레드마다 고유 스택)
생성 비용높음 (fork, 메모리 복제)낮음 (스택만 새로 할당)
컨텍스트 스위칭비용 높음 (TLB 플러시 필요)비용 낮음 (같은 주소 공간)
통신IPC 필요 (파이프, 소켓, 공유 메모리)직접 메모리 접근 (빠르지만 위험)
안정성하나가 죽어도 다른 프로세스 무관하나가 죽으면 전체 프로세스 종료 가능

메모리 구조 비교

plaintext
[프로세스 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)

두 개 이상의 실행 흐름이 공유 데이터에 동시 접근하여 결과가 실행 순서에 의존하게 되는 상황.

python
# 경쟁 조건 예시: 은행 잔고 업데이트
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가지 요구 조건:

  1. 상호 배제 (Mutual Exclusion): 한 번에 하나의 프로세스만 임계 영역 진입
  2. 진행 (Progress): 임계 영역이 비어있으면 진입 대기 중인 프로세스가 진입 가능
  3. 한정 대기 (Bounded Waiting): 무한히 기다리는 프로세스가 없어야 함

3. 동기화 메커니즘

Mutex (Mutual Exclusion Lock)

이진 잠금 장치. 한 번에 하나의 스레드만 잠금을 획득하고 임계 영역에 진입할 수 있다.

go
// 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
# 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 핵심 차이

plaintext
Mutex: 화장실 열쇠 (1개)
  → 한 사람만 들어갈 수 있고, 그 사람만 나올 때 열쇠 반환
 
Semaphore: 주차장 카운터 (N개)
  → N대까지 입장 가능, 아무나 나가면 카운터 증가
  → 소유권 개념 없음

Monitor

고수준 동기화 구조체. Mutex + Condition Variable을 캡슐화하여 안전한 동기화를 제공한다.

java
// 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
plaintext
교착 상태 예시:
  스레드 A: Lock(X) 획득 → Lock(Y) 대기
  스레드 B: Lock(Y) 획득 → Lock(X) 대기
 
     A ──가지고있음──→ X
     ↑                ↓ 대기
     대기              B
     ↓                ↑
     Y ←──가지고있음── B

교착 상태 해결 전략

1. 예방 (Prevention) -- 4가지 조건 중 하나를 원천 차단

차단 조건방법단점
상호 배제자원 공유 가능하게현실적으로 불가능한 경우 많음
점유 대기필요한 모든 자원을 한꺼번에 요청자원 낭비, 기아 가능
비선점자원을 강제로 회수일관성 문제
순환 대기자원에 순서 부여, 오름차순으로만 요청가장 실용적
python
# 순환 대기 예방: 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 += amount

2. 회피 (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
// 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) 연산:

plaintext
CAS(address, expected_value, new_value):
    if *address == expected_value:
        *address = new_value
        return true
    else:
        return false
java
// 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-basedLock-free
교착 상태가능불가능
우선순위 역전가능불가능
성능 (경합 없을 때)비슷함약간 빠름
성능 (경합 심할 때)급격히 저하점진적 저하
구현 난이도상대적으로 쉬움매우 어려움
사용 예일반적 동기화Redis, Java ConcurrentHashMap

ABA 문제

CAS의 알려진 함정:

  1. 스레드 A가 값 "A"를 읽음
  2. 스레드 B가 A→B→A로 변경 (값이 다시 A)
  3. 스레드 A가 CAS 성공 (변경된 줄 모름)

해결: 버전 번호 추가 (AtomicStampedReference)

7. 현대적 동시성 모델

Go Goroutine -- CSP 모델

Go는 Communicating Sequential Processes (CSP) 모델을 채택한다. "공유 메모리로 통신하지 말고, 통신으로 메모리를 공유하라."

go
// 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) 시스템으로 컴파일 타임에 데이터 레이스를 방지한다:

rust
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)는 동시에 하나만 존재 가능 → 데이터 레이스 원천 차단
  • Send trait: 스레드 간 소유권 이전 가능한 타입 표시
  • Sync trait: 스레드 간 참조 공유 가능한 타입 표시
  • 규칙 위반 시 컴파일 에러 → 런타임 버그 방지

async/await 모델

javascript
// 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() 시스템 콜의 오버헤드를 획기적으로 줄인다.

핵심 구조: 링 버퍼

plaintext
사용자 공간                         커널 공간
┌───────────────┐               ┌───────────────┐
│ 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()epollio_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 기반으로 구현
c
// 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)

plaintext
요청 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 아키텍처