본문으로 건너뛰기 C++ mutex 완벽 마스터 | Race Condition·Deadlock·Best Practices

C++ mutex 완벽 마스터 | Race Condition·Deadlock·Best Practices

C++ mutex 완벽 마스터 | Race Condition·Deadlock·Best Practices

💡 핵심 개념: 여러 스레드가 같은 변수(구조체 필드 포함)를 읽고 쓰면, ++ 한 줄이라도 data race가 발생할 수 있다. std::mutex + lock_guard로 “같이 만지면 안 되는 구간”을 짧게 감싸라. 카운터 하나만이면 atomic(#7-4)도 후보다.

들어가며: 카운터가 왜 깨졌나

”주문 수가 맞지 않아요” - 공유 변수와 race condition

이벤트 당일 주문 처리 서버에서 실시간 주문 수를 여러 워커 스레드가 하나의 카운터에 더하는 구조였다. 배치가 끝나고 집계를 보니 실제 DB 건수와 수만 건 차이가 났다. 당시 코드:

std::atomic<int> counter{0};  // 나중에 atomic으로 바꿨지만, 당시엔 그냥 int
void processOrder(const Order& order) {
    // ... 주문 처리 ...
    counter++;  // 여러 스레드가 동시에 접근
}

위 코드 설명: 당시 counter가 일반 int였을 때, 여러 스레드가 동시에 counter++를 실행하면 읽기·증가·쓰기가 겹쳐서 일부 증가가 반영되지 않는다. 나중에 std::atomic<int>로 바꾸면 한 변수에 대한 증가는 락 없이 안전하게 할 수 있다. 여러 변수를 한꺼번에 일관되게 바꿀 때는 mutex가 필요하다. 원인

  • 당시에는 그냥 int counter였으며, counter++를 여러 스레드가 동시에 실행

  • 읽기-수정-쓰기가 원자적이지 않아 일부 증가가 덮어써짐 (data race)

  • 이벤트 트래픽이 커질수록 오차가 커짐 Java의 synchronized·java.util.concurrent 락과 맥락이 같습니다. Java 멀티스레드 글과 함께 보면 “임계 구역·상호 배제”가 어떻게 다른 문법으로 반복되는지 비교하기 좋습니다. 단일 변수의 순서까지 규정하려면 C++ memory order 쪽이 더 가깝습니다. 이런 공유 데이터 접근을 보호하려면 mutex로 “한 번에 한 스레드만” 접근하도록 하거나, 단순 카운터처럼 하나의 변수만 다룰 때는 atomic을 쓰면 됩니다. mutex는 여러 변수를 한꺼번에 일관되게 바꿀 때, atomic은 단일 변수의 읽기·쓰기·증가 등을 락 없이 안전하게 할 때 적합합니다. 정의를 풀어 쓰면 mutex는 “한 번에 한 스레드만 잠금을 잡으며, 나머지는 그 스레드가 풀 때까지 대기하게 하는 장치”라고 생각하면 됩니다. 해결

  • 카운터는 atomic으로 바꿨고

  • 그 외 공유 구조체(큐, 맵 등)는 mutex로 한 번에 한 스레드만 접근하도록 잠금 mutex로 한 번에 한 스레드만 접근하는 흐름을 요약하면 아래와 같습니다.

sequenceDiagram
  participant T1 as 스레드1
  participant M as mutex
  participant T2 as 스레드2
  T1->>M: lock()
  M-->>T1: 획득
  T2->>M: lock()
  Note over T2: 대기
  T1->>M: 크리티컬 섹션 실행
  T1->>M: unlock()
  M-->>T2: 획득
  T2->>M: 크리티컬 섹션 실행

이번 글에서는 mutex로 공유 데이터를 보호하는 방법을 다룹니다. 단순한 카운터 하나는 나중에 다룰 std::atomic으로도 보호할 수 있지만, 큐·맵·여러 변수를 한 번에 수정해야 할 때는 “한 번에 한 스레드만 이 구간에 들어가게” 하는 mutex가 맞습니다. 락을 잡은 채로 오래 두면 병목이 되므로, 보호가 필요한 구간만 최소한으로 감싸는 것이 좋습니다. 이 글을 읽으면

  • race condition과 data race가 무엇인지 구분할 수 있습니다.
  • std::mutexstd::lock_guard, std::unique_lock으로 크리티컬 섹션(critical section—한 번에 한 스레드만 실행해야 하는 코드 구간)을 보호할 수 있습니다.
  • 데드락(deadlock—두 스레드가 서로가 가진 락을 기다리며 영원히 멈추는 상태)을 피하는 순서 잠금·std::lock() 패턴을 쓸 수 있습니다.
  • RAII로 락을 잡아 예외 시에도 안전하게 풀 수 있습니다.
  • 자주 발생하는 실수와 성능 비교, 프로덕션 패턴을 활용할 수 있습니다.

1. Race condition과 data race

Race condition

Race condition(레이스 컨디션, 경쟁 조건)이란, 여러 스레드가 같은 데이터에 접근할 때 실행 순서에 따라 결과가 달라지는 상황입니다. 예를 들면 두 사람이 동시에 같은 통장 잔액을 읽고 각자 입금한 뒤 쓸 때, 한 번의 입금이 반영되지 않을 수 있는 것과 비슷합니다. 논리적 오류를 일으킬 수 있습니다. : 두 스레드가 counter를 동시에 증가시킬 때, 각각 “현재 값 읽기 → 1 더하기 → 쓰기”를 하는데, 두 스레드가 같은 값을 읽고 각자 1을 더해 쓰면 한 번의 증가가 사라집니다. 기대값은 2인데 1이 나올 수 있습니다.

flowchart LR
  subgraph T1[스레드 1]
    A1[읽기: 0] --> B1[+1] --> C1[쓰기: 1]
  end
  subgraph T2[스레드 2]
    A2[읽기: 0] --> B2[+1] --> C2[쓰기: 1]
  end
  C1 -.->|덮어씀| C2

Data race (C++ 표준 용어)

Data race(데이터 경합)는 한 스레드는 쓰기하고 다른 스레드는 읽기 또는 쓰기를 하며, 그 접근이 동기화되지 않은 경우를 말합니다. C++ 표준상 UB(undefined behavior, 미정의 동작—표준이 결과를 보장하지 않아 예측 불가능한 크래시나 잘못된 값이 나올 수 있음)입니다.

  • 동기화 수단: mutex, atomic, condition_variable 등
  • “멀티스레드에서 값이 가끔 틀린다”는 현상의 대부분은 data race 때문입니다.

mutex의 역할

Mutex(Mutual Exclusion)는 한 번에 한 스레드만 지정한 구간(크리티컬 섹션)을 실행하도록 만듭니다. 락을 잡은 스레드만 그 구간에 들어가고, 나머지는 락이 풀릴 때까지 대기합니다. 따라서 공유 변수에 대한 “읽기-수정-쓰기”가 겹치지 않게 됩니다. 실무: mutex를 잡은 채로 오래 두면 다른 스레드가 모두 대기하므로 처리량이 떨어집니다. 공유 데이터를 읽고 수정하는 부분만 최소한으로 감싸으며, I/O나 네트워크 대기는 락 밖에서 하세요. lock_guard나 unique_lock을 쓰면 예외 시에도 소멸자에서 락이 풀립니다.

2. 문제 시나리오: 구체적인 버그 사례

시나리오 1: 재고 감소 버그

온라인 쇼핑몰에서 여러 사용자가 동시에 같은 상품을 구매할 때, 재고가 음수가 되는 버그가 발생했습니다.

// ❌ 문제 코드: 재고가 음수로 떨어질 수 있음
int stock = 100;
void purchase(int quantity) {
    if (stock >= quantity) {   // 스레드 A: 100 >= 5 → true
        // 여기서 스레드 B가 끼어들어 stock을 읽고 10개 구매
        stock -= quantity;     // 스레드 A: stock = 95, 스레드 B: stock = 85
    }                          // 결과: 15개 팔렸는데 stock은 85? 또는 음수?
}

원인: if 조건 검사와 stock -= quantity 사이에 다른 스레드가 끼어들 수 있어, “검사 시점”과 “수정 시점”이 분리됩니다. check-then-act 패턴의 전형적인 race condition입니다. 해결: mutex로 조건 검사와 수정을 원자적으로 묶습니다.

// ✅ 해결: mutex로 검사와 수정을 한 번에
std::mutex stock_mtx;
int stock = 100;
bool purchase(int quantity) {
    std::lock_guard<std::mutex> lock(stock_mtx);
    if (stock >= quantity) {
        stock -= quantity;
        return true;
    }
    return false;
}

시나리오 2: 로그 버퍼 손상

여러 스레드가 공유 로그 버퍼에 동시에 쓰다가, 로그 메시지가 섞이거나 버퍼가 손상되는 문제가 있었습니다.

// ❌ 문제 코드: 로그가 섞이거나 크래시
std::string log_buffer;
void log(const std::string& msg) {
    log_buffer += msg;      // 여러 스레드가 동시에 +=
    log_buffer += "\n";     // 메모리 재할당 중 다른 스레드가 접근 → UB
}

원인: std::string::operator+=는 스레드 안전하지 않습니다. 내부적으로 메모리 재할당이 일어날 때 다른 스레드가 동시에 접근하면 data race로 UB가 발생합니다. 해결: mutex로 로그 쓰기를 보호합니다.

// ✅ 해결: mutex로 로그 쓰기 보호
std::mutex log_mtx;
std::string log_buffer;
void log(const std::string& msg) {
    std::lock_guard<std::mutex> lock(log_mtx);
    log_buffer += msg;
    log_buffer += "\n";
}

시나리오 3: 작업 큐에서 중복 처리

작업 큐에서 여러 워커가 pop할 때, 같은 작업을 두 워커가 가져가는 문제가 있었습니다.

// ❌ 문제 코드: 같은 작업을 두 스레드가 가져갈 수 있음
std::queue<Task> task_queue;
Task getTask() {
    if (!task_queue.empty()) {   // 스레드 A: empty() → false
        Task t = task_queue.front();  // 스레드 B: 여기서 pop하면?
        task_queue.pop();        // 스레드 A: front와 pop 사이에 B가 끼어듦
        return t;
    }
    return {};
}

원인: empty() 검사와 front()/pop() 사이에 다른 스레드가 끼어들 수 있습니다. front()pop()도 분리되어 있어, 두 스레드가 같은 front() 결과를 보고 각자 pop()할 수 있습니다. 해결: mutex로 전체 접근을 보호하며, condition_variable으로 “데이터가 있을 때만” 깨우는 패턴을 사용합니다.

// ✅ 해결: mutex + condition_variable (다음 글에서 상세)
std::mutex queue_mtx;
std::queue<Task> task_queue;
std::condition_variable cv;
Task getTask() {
    std::unique_lock<std::mutex> lock(queue_mtx);
    cv.wait(lock, [] { return !task_queue.empty(); });
    Task t = std::move(task_queue.front());
    task_queue.pop();
    return t;
}

시나리오 4: 캐시 통계 왜곡

캐시 서버에서 적중률(hit rate)을 실시간으로 집계할 때, hit_countmiss_count를 별도로 증가시키하다가 두 값의 합이 실제 요청 수와 맞지 않는 버그가 발생했습니다.

// ❌ 문제 코드: hit + miss 합이 요청 수와 불일치
int hit_count = 0;
int miss_count = 0;
void recordHit() {
    hit_count++;   // 스레드 A: hit 읽기
}                  // 스레드 B: miss 읽기 → 동시에 쓰기 시 data race
void recordMiss() {
    miss_count++;
}
// 나중에 hit_rate = hit_count / (hit_count + miss_count) 계산 시
// hit_count와 miss_count가 서로 다른 시점에 읽혀 일관성 없음

원인: hit_countmiss_count원자적으로 함께 갱신하지 않아, 집계 시점에 한쪽만 반영된 상태로 읽을 수 있습니다. 또한 단일 변수라도 hit_count++miss_count++가 각각 data race를 일으킬 수 있습니다. 해결: mutex로 두 카운터를 한 번에 보호하거나, 집계 시 락을 잡고 일관된 스냅샷을 읽습니다.

// ✅ 해결: mutex로 hit/miss를 함께 보호
std::mutex stats_mtx;
int hit_count = 0;
int miss_count = 0;
void recordHit() {
    std::lock_guard<std::mutex> lock(stats_mtx);
    hit_count++;
}
void recordMiss() {
    std::lock_guard<std::mutex> lock(stats_mtx);
    miss_count++;
}
double getHitRate() {
    std::lock_guard<std::mutex> lock(stats_mtx);
    int total = hit_count + miss_count;
    return total > 0 ? static_cast<double>(hit_count) / total : 0.0;
}

시나리오 5: 설정값 읽기/쓰기 충돌

런타임에 설정을 자주 읽고 가끔 쓰는 서비스에서, 설정 갱신 중에 읽기 스레드가 반쯤 갱신된 맵을 참조해 잘못된 값을 사용하는 문제가 있었습니다.

// ❌ 문제 코드: 쓰기 중 읽기 → 맵 내부 일관성 깨짐
// 실행 예제
std::map<std::string, std::string> config;
std::string get(const std::string& key) {
    auto it = config.find(key);  // 쓰기 스레드가 insert/erase 중이면?
    return it != config.end() ? it->second : "";
}
void set(const std::string& key, std::string value) {
    config[key] = std::move(value);  // 읽기와 동시에 수정 → UB
}

원인: std::map은 스레드 안전하지 않습니다. 한 스레드가 쓰는 동안 다른 스레드가 읽으면 data race로 UB가 발생합니다. 해결: std::shared_mutex로 읽기는 여러 스레드가 동시에, 쓰기는 단독으로 수행합니다. (아래 shared_mutex 섹션 참고)

3. std::mutex 완전 사용법

기본 사용

counter는 두 스레드 t1, t2가 공유하는 변수이므로, counter++만 하면 “읽기 → 증가 → 쓰기”가 겹쳐서 data race가 납니다. counter_mutex.lock()으로 진입한 스레드만 counter++를 하고 unlock()으로 락을 풀면, 다른 스레드는 lock()에서 대기했다가 차례대로 한 번씩만 증가시킬 수 있어 최종적으로 counter == 200000이 보장됩니다. 다만 lock()unlock() 사이에서 예외가 나거나 return하면 unlock()이 호출되지 않아 데드락이 되므로, 실무에서는 아래에서 보듯 lock_guard로 RAII 방식으로 잡는 것이 안전합니다.

// 복사해 연결하여넣은 뒤: g++ -std=c++17 -pthread -o mutex_safe mutex_safe.cpp && ./mutex_safe
#include <mutex>
#include <thread>
#include <iostream>
int counter = 0;
std::mutex counter_mutex;
void safeIncrement() {
    for (int i = 0; i < 100000; ++i) {
        counter_mutex.lock();
        counter++;
        counter_mutex.unlock();
    }
}
int main() {
    std::thread t1(safeIncrement);
    std::thread t2(safeIncrement);
    t1.join();
    t2.join();
    std::cout << "counter=" << counter << "\n";  // 200000
    return 0;
}

위 코드 설명: counter_mutex.lock()으로 진입한 스레드만 counter++를 하고 unlock()으로 락을 풉니다. 다른 스레드는 lock()에서 대기했다가 차례대로 한 번씩만 증가시키므로 최종값이 200000으로 맞습니다. 다만 lock과 unlock 사이에서 예외나 return이 나면 unlock이 호출되지 않아 데드락이 되므로, 실무에서는 lock_guard를 쓰는 편이 안전합니다. 실행 결과: counter=200000 이 출력됩니다.

  • lock(): 락을 잡을 때까지 대기. 잡으면 그 스레드만 크리티컬 섹션 진입.
  • unlock(): 락을 풀어 다른 스레드가 들어올 수 있게 함. 주의: 중간에 예외가 나거나 return을 해버리면 unlock()이 호출되지 않아 데드락이 됩니다. 따라서 보통은 RAII(Resource Acquisition Is Initialization, 리소스 획득은 초기화)로 락을 잡습니다.

std::mutex의 주요 메서드

메서드설명
lock()락 획득. 이미 다른 스레드가 잡고 있으면 대기. 같은 스레드가 두 번 잡으면 데드락(비재진입).
unlock()락 해제. lock()을 호출한 스레드만 호출 가능.
try_lock()락 시도. 잡으면 true, 못 잡으면 즉시 false 반환(대기 안 함).
// try_lock 예: 락을 못 잡으면 다른 작업 수행
std::mutex mtx;
void maybeUpdate() {
    if (mtx.try_lock()) {
        // 락 획득 성공 → 크리티컬 섹션 실행
        // ... 작업 ...
        mtx.unlock();
    } else {
        // 락 획득 실패 → 스킵하거나 다른 경로로 처리
    }
}

주의: try_lock()을 쓸 때는 반드시 성공 시 unlock()을 호출해야 합니다. 이 부분도 RAII로 처리하는 것이 안전합니다.

std::mutex의 복사/이동

  • std::mutex복사 불가, 이동 불가입니다.
  • 뮤텍스는 “잠금 상태”를 가진 리소스이므로, 복사나 이동 시 의미가 불명확해 표준에서 막아 두었습니다.
  • 여러 객체가 같은 뮤텍스를 공유하려면 포인터참조로 전달합니다.
// ✅ 포인터/참조로 공유
std::mutex g_mtx;
void worker(std::mutex& mtx) {
    std::lock_guard<std::mutex> lock(mtx);
    // ...
}
// std::thread t(worker, std::ref(g_mtx));

4. lock_guard와 unique_lock: RAII로 락 잡기

RAII란

RAII(Resource Acquisition Is Initialization)는 C++에서 리소스(메모리, 파일, 락 등)를 생성 시 획득하고 소멸 시 해제하는 패턴입니다. RAII 글에서 자세히 다룹니다. 예외가 나거나 early return을 해도 소멸자가 호출되므로, 락을 빼먹는 실수를 막을 수 있습니다.

std::lock_guard

생성 시 락을 잡으며, 소멸 시 자동으로 풀어줍니다. 예외가 나도 소멸자가 호출되므로 안전합니다.

#include <mutex>
std::mutex mtx;
int shared_value = 0;
void safeIncrement() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(mtx);
        shared_value++;
        // lock 소멸 시 자동으로 mtx.unlock()
    }
}

위 코드 설명: std::lock_guard<std::mutex> lock(mtx)는 생성 시 mtx.lock()을 호출하며, 스코프를 벗어날 때 소멸자에서 mtx.unlock()을 호출합니다. 예외가 나거나 return해도 소멸자가 불리므로 락이 풀리으며, 수동 unlock을 빼먹어 데드락이 나는 일을 막을 수 있습니다.

  • lock_guard수동 unlock이 없음. 스코프를 벗어날 때만 풀림.
  • 락을 잡은 채로 오래 두면 성능이 떨어지므로, 보호가 필요한 최소 구간만 감싸는 것이 좋습니다. 실행 예: g++ -std=c++17 -pthread -o demo demo.cpp && ./demo — 두 스레드가 각 10만 번 lock_guard로 증가하면 counter=200000이 보장됩니다.

std::lock_guard의 adopt_lock

이미 lock()으로 락을 잡은 상태에서, 소멸 시 unlock()만 해주고 싶을 때 std::adopt_lock을 사용합니다.

mtx.lock();
// ... 어떤 이유로 이미 락을 잡은 상태 ...
{
    std::lock_guard<std::mutex> lock(mtx, std::adopt_lock);
    // 락을 다시 잡지 않으며, 소멸 시 unlock만 수행
}

std::unique_lock

lock_guard보다 유연합니다. 수동으로 lock/unlock 할 수 있으며, std::condition_variable과 함께 쓸 때도 필요합니다.

std::mutex mtx;
std::unique_lock<std::mutex> lock(mtx);
// ... 크리티컬 섹션 ...
lock.unlock();  // 필요하면 일찍 풀기
// ... 락 없이 다른 작업 (I/O 대기 등) ...
lock.lock();    // 다시 잡기 가능

위 코드 설명: unique_lock은 생성 시 락을 잡으며, 중간에 lock.unlock()으로 풀었하다가 lock.lock()으로 다시 잡을 수 있습니다. 락이 필요 없는 구간(예: I/O 대기)에서는 unlock 해 두면 다른 스레드가 진입할 수 있어 병목을 줄일 수 있으며, condition_variable과 함께 쓸 때도 unique_lock이 필요합니다.

  • defer_lock으로 생성한 뒤 나중에 lock() 호출하는 패턴도 자주 씁니다.
  • 다음 글condition_variable에서는 반드시 unique_lock을 사용합니다.

unique_lock의 정책

정책설명
(기본)생성 시 즉시 lock()
std::defer_lock생성 시 lock 안 함. 나중에 lock() 호출
std::try_to_locktry_lock() 시도. 실패 시 락 없이 생성
std::adopt_lock이미 락을 잡은 상태. 소멸 시 unlock()
// defer_lock: std::lock()과 함께 사용
std::unique_lock<std::mutex> lock1(m1, std::defer_lock);
std::unique_lock<std::mutex> lock2(m2, std::defer_lock);
std::lock(lock1, lock2);  // 데드락 없이 둘 다 잡음

unique_lock 완전 예제: 중간 unlock으로 I/O 분리

// unique_lock으로 락 범위를 세밀하게 제어
#include <iostream>
#include <mutex>
#include <thread>
std::mutex mtx;
std::string shared_data;
void processWithIO() {
    std::unique_lock<std::mutex> lock(mtx);
    // 1. 공유 데이터 읽기
    std::string local_copy = shared_data;
    lock.unlock();  // 2. I/O 전에 락 해제 → 다른 스레드가 접근 가능
    // 3. 락 없이 무거운 작업 (파일 I/O, 네트워크 등)
    // simulateIO(local_copy);
    lock.lock();   // 4. 결과 반영을 위해 다시 락
    shared_data = local_copy;
}
int main() {
    std::thread t1(processWithIO);
    std::thread t2(processWithIO);
    t1.join();
    t2.join();
    return 0;
}

핵심: unique_lockunlock()lock()으로 다시 잡을 수 있어, I/O 구간에서 락을 풀어 병목을 줄일 수 있습니다.

lock_guard vs unique_lock 선택 가이드

상황권장
구간 보호만 필요lock_guard (가볍고 단순)
condition_variable 사용unique_lock (필수)
중간에 unlock 후 다시 lockunique_lock
여러 뮤텍스 std::lock()으로 한 번에unique_lock + defer_lock

5. 데드락 피하기

데드락: 두 스레드가 서로가 가진 락을 기다리며 영원히 멈추는 상황. 예: 스레드 A는 mutex1mutex2 순서로 잡으며, 스레드 B는 mutex2mutex1 순서로 잡으면, A가 1 잡고 2를 기다리는 동안 B가 2 잡고 1을 기다릴 수 있습니다.

sequenceDiagram
  participant A as 스레드 A
  participant B as 스레드 B
  participant M1 as mutex1
  participant M2 as mutex2
  A->>M1: lock()
  B->>M2: lock()
  A->>M2: lock() - 대기
  B->>M1: lock() - 대기
  Note over A,B: 데드락! 서로가 서로를 기다림

해결 1: 락 순서를 통일

항상 같은 순서로 락을 잡습니다. 예: 항상 mutex1mutex2.

// 좋은 예: 모든 스레드가 같은 순서 (먼저 m1, 그다음 m2)
std::lock_guard<std::mutex> l1(mutex1);
std::lock_guard<std::mutex> l2(mutex2);

위 코드 설명: 모든 스레드가 항상 mutex1을 먼저 잡으며, 그다음 mutex2를 잡으면, 한 스레드가 1을 잡은 상태에서 2를 기다리는 동안 다른 스레드는 1을 기다리므로 서로가 서로를 기다리는 데드락이 생기지 않습니다. 락 순서를 코드/문서로 통일해 두는 것이 중요합니다. 실무 팁: 뮤텍스에 주소를 기준으로 순서를 정하면, 동적으로 여러 객체의 뮤텍스를 잡을 때도 일관된 순서를 유지할 수 있습니다.

// 주소 기준 순서: 항상 낮은 주소 먼저
void transfer(Account& a, Account& b, int amount) {
    auto* m1 = &a.mtx;
    auto* m2 = &b.mtx;
    if (m1 > m2) std::swap(m1, m2);
    std::lock_guard<std::mutex> l1(*m1);
    std::lock_guard<std::mutex> l2(*m2);
    // ...
}

해결 2: std::lock()으로 한 번에 잡기

여러 뮤텍스를 데드락 없이 한꺼번에 잡고 싶을 때 std::lock()을 사용합니다.

std::mutex m1, m2;
void both() {
    std::unique_lock<std::mutex> lock1(m1, std::defer_lock);
    std::unique_lock<std::mutex> lock2(m2, std::defer_lock);
    std::lock(lock1, lock2);  // 데드락 없이 둘 다 잡음
    // ... 두 락 모두 잡힌 상태 ...
}

위 코드 설명: std::lock(lock1, lock2)는 두 뮤텍스를 데드락 없이 한꺼번에 잡습니다. 내부적으로 데드락 회피 알고리즘을 사용해 순서와 관계없이 안전하게 잡으며, defer_lock으로 생성한 unique_lock을 넘기면 잡은 뒤 adopt 상태가 되어 소멸 시 자동으로 풀립니다. std::lock(m1, m2, ...)은 내부적으로 데드락 회피 알고리즘을 쓰므로, 순서와 상관없이 안전하게 잡을 수 있습니다. 잡은 뒤에는 lock_guardunique_lock으로 adopt해서 소멸 시 자동으로 풀리게 하는 식으로 사용합니다.

해결 2-1: std::scoped_lock (C++17) — 권장

C++17부터 std::scoped_lock은 여러 뮤텍스를 한 번에 데드락 없이 잡으며, RAII로 소멸 시 자동 해제합니다. std::lock + lock_guard를 한 번에 처리하는 편의 클래스입니다.

// C++17: scoped_lock으로 여러 뮤텍스 한 번에
#include <mutex>
std::mutex m1, m2;
void safeOperation() {
    std::scoped_lock lock(m1, m2);  // 데드락 없이 둘 다 잡음, 소멸 시 자동 해제
    // ... 크리티컬 섹션 ...
}

계좌 이체 예: transfer(a,b)transfer(b,a)가 동시에 호출되어도 std::scoped_lock으로 데드락 없이 안전합니다. 주소 순서(m1 > m2)로 일관된 락 순서를 유지할 수 있습니다.

해결 3: 락 범위 최소화

락을 잡은 채로 오래 두지 않으면, 데드락에 걸릴 “시간 창”이 줄어듭니다. I/O나 무거운 연산은 락 밖에서 수행합니다.

// ❌ 나쁜 예: 락 안에서 I/O
{
    std::lock_guard<std::mutex> lock(mtx);
    data = compute();
    saveToFile(data);  // 디스크 I/O - 락을 오래 잡음
}
// ✅ 좋은 예: 락은 최소 구간만
Data data;
{
    std::lock_guard<std::mutex> lock(mtx);
    data = shared_data;  // 복사만 하고
}
saveToFile(data);  // 락 밖에서 I/O

6. 자주 발생하는 실수와 해결법

실수 1: lock/unlock 쌍을 맞추지 않음

증상: 데드락, 프로그램이 멈춤.

// ❌ 잘못된 예: early return 시 unlock 누락
void process() {
    mtx.lock();
    if (error) return;  // unlock() 호출 안 됨 → 데드락
    doWork();
    mtx.unlock();
}

해결: RAII 사용.

// ✅ 올바른 예: lock_guard 사용
void process() {
    std::lock_guard<std::mutex> lock(mtx);
    if (error) return;  // 소멸자에서 자동 unlock
    doWork();
}

실수 2: 보호할 데이터와 mutex를 분리

증상: 실수로 락 없이 공유 데이터에 접근.

// ❌ 나쁜 예: mtx와 data가 분리
std::mutex mtx;
int global_counter;  // 누군가 mtx 없이 접근할 수 있음
void add() {
    global_counter++;  // 실수로 락 없이!
}

해결: 데이터와 mutex를 한 구조체/클래스에 묶으며, 메서드를 통해서만 접근.

// ✅ 좋은 예: 데이터와 mutex를 함께 캡슐화
struct ThreadSafeCounter {
    std::mutex mtx;
    int value = 0;
    void add(int n) {
        std::lock_guard<std::mutex> lock(mtx);
        value += n;
    }
};

실수 3: 같은 스레드에서 같은 mutex를 두 번 잡음

증상: 데드락. std::mutex는 비재진입(non-reentrant)이므로, 같은 스레드가 이미 잡은 락을 다시 잡으면 자기 자신을 기다리며 멈춥니다.

// ❌ 잘못된 예
std::mutex mtx;
void foo() {
    std::lock_guard<std::mutex> lock(mtx);
    bar();  // bar() 안에서 다시 mtx를 잡으면?
}
void bar() {
    std::lock_guard<std::mutex> lock(mtx);  // 데드락!
}

해결 1: 설계를 바꿔 재진입이 필요 없게 함. (권장)

// ✅ bar는 락을 잡지 않으며, foo에서만 잡음
void bar() {
    // 락 없이 내부 로직만
}
void foo() {
    std::lock_guard<std::mutex> lock(mtx);
    bar();
}

해결 2: 꼭 같은 스레드에서 다시 잡아야 한다면 std::recursive_mutex 사용. (설계 재검토 권장)

std::recursive_mutex mtx;
void bar() {
    std::lock_guard<std::recursive_mutex> lock(mtx);  // 같은 스레드면 OK
}

실수 4: 락 안에서 외부 코드 호출

증상: 데드락 또는 성능 저하. 외부 코드가 다른 락을 잡거나, 콜백에서 다시 같은 락을 잡을 수 있습니다.

// ❌ 위험: 락 안에서 콜백 호출
std::mutex mtx;
void process(Data& data, std::function<void()> callback) {
    std::lock_guard<std::mutex> lock(mtx);
    modify(data);
    callback();  // callback이 다시 process를 호출하면? 또는 다른 락?
}

해결: 락 밖에서 콜백 호출. 필요한 데이터는 락 안에서 복사해 두며, 락을 풀은 뒤 콜백 호출.

// ✅ 안전: 락 밖에서 콜백
void process(Data& data, std::function<void()> callback) {
    {
        std::lock_guard<std::mutex> lock(mtx);
        modify(data);
    }
    callback();  // 락 풀린 상태에서 호출
}

실수 5: 조건 검사와 수정 분리 (check-then-act)

증상: race condition. “검사”와 “수정” 사이에 다른 스레드가 끼어듦.

// ❌ 잘못된 예
if (!queue.empty()) {
    // 여기서 다른 스레드가 pop할 수 있음
    auto item = queue.front();
    queue.pop();
}

해결: mutex로 검사와 수정을 한 구간에 묶기.

// ✅ 올바른 예
std::lock_guard<std::mutex> lock(mtx);
if (!queue.empty()) {
    auto item = std::move(queue.front());
    queue.pop();
}

실수 6: try_lock 성공 시 unlock 누락

try_lock()으로 락을 잡은 뒤 unlock()을 호출하지 않으면 데드락. 해결: std::unique_lock<std::mutex> lock(mtx, std::try_to_lock)으로 RAII 사용.

실수 7: 반환값으로 공유 데이터 참조 전달

락을 풀은 뒤 반환된 참조로 접근하면 data race. 해결: 복사로 반환하거나 forEach처럼 접근 메서드를 제공.

실수 8: 뮤텍스와 데이터 수명 불일치

뮤텍스가 먼저 소멸되면 락 없이 접근 가능. 해결: 뮤텍스를 먼저 선언해 데이터보다 나중에 소멸되게 함 (std::mutex mtx; std::string data;).

7. 성능 비교

mutex vs atomic vs lock-free

방식적합한 상황장점단점
std::mutex여러 변수, 복잡한 조건, 큐/맵 등구현 단순, 범용적락 오버헤드, 대기 시 컨텍스트 스위치
std::atomic단일 변수(카운터, 플래그)락 없음, 빠름복합 연산은 compare_exchange 등으로 복잡
lock-free극한 성능 필요 시대기 최소화구현 난이도 높음, ABA 문제 등

벤치마크 예시 (참고용)

단순 카운터 1,000,000회 증가를 4스레드로 수행했을 때 대략적인 상대 시간:

방식상대 시간
mutex + lock_guard1.0 (기준)
std::atomic약 0.1~0.2
lock-free (CAS 루프)약 0.15~0.25
해석: 단일 변수만 다룰 때는 atomic이 mutex보다 훨씬 빠릅니다. 여러 변수를 일관되게 수정해야 하면 mutex가 필요하며, 그때는 락 범위를 최소화하는 것이 성능에 중요합니다.
// 단순 카운터: atomic이 적합
std::atomic<int> counter{0};
counter.fetch_add(1, std::memory_order_relaxed);
// 여러 변수: mutex 필요
std::mutex mtx;
int a, b;
{
    std::lock_guard<std::mutex> lock(mtx);
    a++;
    b = a * 2;
}

락 범위와 성능

락을 잡은 구간이 길수록 다른 스레드의 대기 시간이 늘어나 처리량이 떨어집니다.

// ❌ 나쁜 예: 락 범위가 넓음
for (int i = 0; i < N; ++i) {
    std::lock_guard<std::mutex> lock(mtx);
    auto x = fetchFromNetwork();  // 네트워크 I/O를 락 안에서!
    shared_data += x;
}
// ✅ 좋은 예: 락은 최소 구간만
for (int i = 0; i < N; ++i) {
    auto x = fetchFromNetwork();  // 락 밖에서 I/O
    {
        std::lock_guard<std::mutex> lock(mtx);
        shared_data += x;
    }
}

8. 베스트 프랙티스

원칙권장
RAII 우선lock()/unlock() 대신 lock_guard, unique_lock, scoped_lock 사용
락 범위 최소화I/O·네트워크·무거운 연산은 락 밖에서 수행
데이터·뮤텍스 캡슐화공유 데이터와 mutex를 한 클래스에 묶고 메서드로만 접근
여러 뮤텍스std::scoped_lock(C++17) 또는 일관된 락 순서
읽기 많음std::shared_mutex + shared_lock 고려
단일 변수std::atomic 우선 검토
락 안에서 외부 호출금지—콜백·가상 함수는 락 밖에서
검사 도구ThreadSanitizer(-fsanitize=thread), Helgrind 활용

9. 프로덕션 패턴

패턴 1: 스레드 안전 래퍼 클래스

공유 데이터를 클래스로 감싸으며, 모든 접근을 메서드를 통해서만 하도록 합니다.

template <typename T>
class ThreadSafeQueue {
public:
    void push(T value) {
        std::lock_guard<std::mutex> lock(mtx_);
        queue_.push(std::move(value));
    }
    bool try_pop(T& value) {
        std::lock_guard<std::mutex> lock(mtx_);
        if (queue_.empty()) return false;
        value = std::move(queue_.front());
        queue_.pop();
        return true;
    }
    bool empty() const {
        std::lock_guard<std::mutex> lock(mtx_);
        return queue_.empty();
    }
private:
    mutable std::mutex mtx_;
    std::queue<T> queue_;
};

주의: empty()try_pop()을 따로 호출하는 것은 여전히 race가 있을 수 있습니다. “비어있지 않으면 pop”을 원자적으로 하려면 try_pop 하나로 처리하는 것이 맞습니다.

패턴 2: 읽기/쓰기 락 (shared_mutex)

읽기는 여러 스레드가 동시에, 쓰기는 단독으로 하고 싶을 때 std::shared_mutex(C++17)를 사용합니다.

#include <shared_mutex>
// 타입 정의
class Config {
public:
    std::string get(const std::string& key) const {
        std::shared_lock<std::shared_mutex> lock(mtx_);
        auto it = data_.find(key);
        return it != data_.end() ? it->second : "";
    }
    void set(const std::string& key, std::string value) {
        std::unique_lock<std::shared_mutex> lock(mtx_);
        data_[key] = std::move(value);
    }
private:
    mutable std::shared_mutex mtx_;
    std::map<std::string, std::string> data_;
};
  • shared_lock: 읽기용. 여러 스레드가 동시에 shared_lock 가능.
  • unique_lock: 쓰기용. exclusive. 다른 shared/unique 모두 대기. 읽기/쓰기 락 동작: shared_lock은 여러 스레드가 동시에 읽기 가능, unique_lock은 쓰기 시 단독 접근. 읽기 다수·쓰기 소수일 때 성능에 유리합니다.

패턴 3: 락 없는 읽기 + 쓰기 시 복사

쓰기가 드물고 읽기가 많을 때, 쓰기 시 전체를 복사한 뒤 포인터를 원자적으로 스왑하는 패턴입니다.

class SnapshotCache {
public:
    std::shared_ptr<const Data> get() const {
        return std::atomic_load(&data_);  // 락 없이 읽기
    }
    void update(Data new_data) {
        auto copy = std::make_shared<Data>(std::move(new_data));
        std::atomic_store(&data_, copy);  // 원자적 스왑
    }
private:
    std::shared_ptr<const Data> data_;
};

패턴 4: 모니터링과 데드락 탐지

프로덕션에서는 락 대기 시간을 로깅하거나, 데드락 탐지 도구(ThreadSanitizer, Helgrind)를 활용합니다.

# ThreadSanitizer로 data race 검사 (빌드 시)
g++ -fsanitize=thread -g -O1 -o app app.cpp
# Helgrind로 데드락 검사 (실행 시)
valgrind --tool=helgrind ./app

패턴 5: 스레드 안전 초기화

  • Meyers Singleton: static 지역 변수는 C++11부터 스레드 안전 초기화
  • std::call_once: 한 번만 초기화가 필요할 때 std::call_once + std::once_flag 사용 권장
// Meyers Singleton
static Singleton& instance() { static Singleton inst; return inst; }
// std::call_once
std::once_flag flag;
std::call_once(flag, [] { resource = std::make_unique<HeavyResource>(); });

10. 실전 패턴과 주의점

보호할 데이터와 mutex를 묶어 두기

공유 데이터와 그 데이터를 보호하는 mutex를 한 구조체/클래스에 두면, 락 없이 그 데이터에 접근하는 실수를 줄일 수 있습니다.

struct ThreadSafeCounter {
    int value = 0;
    std::mutex mtx;
    void add(int n) {
        std::lock_guard<std::mutex> lock(mtx);
        value += n;
    }
    int get() {
        std::lock_guard<std::mutex> lock(mtx);
        return value;
    }
};

위 코드 설명: valuemtx를 한 구조체에 두면, addget에서만 락을 잡고 접근하도록 강제할 수 있어, 실수로 락 없이 value에 접근하는 일을 줄일 수 있습니다. public으로 노출하지 않고 메서드를 통해서만 접근하게 하면 mutex 사용이 일관됩니다.

락 범위를 짧게

락을 잡은 채로 I/O나 무거운 연산을 하면 다른 스레드가 오래 대기합니다. 최소한의 구간만 락을 잡으며, 그 바깥에서는 로컬 복사본으로 작업하는 편이 좋습니다.

재진입(recursive) mutex

같은 스레드가 같은 mutex를 두 번 잡으면 일반 std::mutex에서는 데드락입니다. 같은 스레드에서 한 락을 다시 잡아야 하는 설계라면 std::recursive_mutex를 쓸 수 있지만, 보통은 설계를 바꿔서 재진입이 필요 없게 만드는 것이 더 낫습니다.

구현 체크리스트

mutex 기반 동기화를 적용할 때 확인할 항목:

  • 공유 데이터와 mutex를 한 구조체/클래스에 묶었는가?
  • lock_guard, unique_lock, scoped_lock으로 RAII를 사용하는가?
  • 락 범위를 최소화했는가? (I/O는 락 밖에서)
  • 여러 뮤텍스를 잡을 때 std::scoped_lock 또는 std::lock()을 사용하는가?
  • 읽기 많음 → shared_mutex + shared_lock 사용을 고려했는가?
  • 조건 검사와 수정을 락 안에서 원자적으로 수행하는가?
  • 단일 변수만 다룰 때는 atomic 사용을 고려했는가?
  • 락 안에서 콜백·외부 코드를 호출하지 않는가?

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

C++ mutex, lock_guard, unique_lock, shared_mutex, scoped_lock, race condition, 데드락, 스레드 동기화, 크리티컬 섹션, 데드락 방지 등으로 검색하시면 이 글이 도움이 됩니다.

정리

  • Race condition은 실행 순서에 따라 결과가 달라지는 상황, data race는 동기화 없이 한이 쓰기하는 접근으로 undefined behavior입니다.
  • std::mutex로 크리티컬 섹션을 보호하며, std::lock_guard로 RAII 락을 사용하면 예외 안전하게 락을 풀 수 있습니다.
  • std::unique_lock은 수동 lock/unlock과 condition_variable에 필요합니다.
  • std::shared_mutex는 읽기 다수·쓰기 소수일 때 읽기 병렬화에 유리합니다.
  • std::scoped_lock(C++17)로 여러 뮤텍스를 데드락 없이 한 번에 잡을 수 있습니다.
  • 데드락을 피하려면 락 순서 통일 또는 std::lock()/scoped_lock을 사용합니다.
  • 자주 하는 실수: lock/unlock 누락, 데이터와 mutex 분리, 재진입 데드락, 락 안에서 외부 코드 호출, 참조 반환으로 락 밖 노출.
  • 단일 변수는 atomic, 여러 변수/복잡한 조건은 mutex. 락 범위를 최소화해 성능을 유지합니다.

초보자를 위한 체크리스트

  • 공유 데이터를 건드리는 모든 경로에 같은 mutex로 보호했는가?
  • lock_guard/unique_lock으로 예외 나도 락이 풀리게 했는가? (수동 unlock 지양)
  • 락을 잡은 채 네트워크·디스크 I/O를 하지 않았는가?
  • mutex를 두 개 이상 잡을 때 순서가 갈라져 데드락 나지 않는가? (C++17이면 scoped_lock)

💡 초보자 팁: 상세 항목은 본문 구현 체크리스트와 같이 보면 됩니다. “조건이 될 때까지 대기”가 필요하면 다음 글 condition_variable(#7-3)로 이어가면 됩니다.

다음 글

mutex만으로는 “조건이 만족될 때까지 기다렸하다가 깨우기”를 표현하기 어렵습니다. condition_variable을 쓰면 “데이터가 들어올 때까지 대기” 같은 패턴을 깔끔하게 짤 수 있습니다.

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. C++ 스레드 동기화 완벽 가이드. race condition·data race 원인과 해결법, std::mutex·lock_guard·unique_lock 사용법, 데드락 방지 전략, 크리티컬 섹션 보호, 실제.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다. 한 줄 요약: 공유 데이터는 mutex·lock_guard로 감싸서 data race를 막을 수 있습니다. 다음으로 condition_variable(#7-3)를 읽어보면 좋습니다. 다음 글: C++ 실전 가이드 #7-3: condition_variable과 실전 패턴 - 대기/알림, producer-consumer 패턴을 다룹니다.

참고 자료


관련 글