C++ mutex로 race condition 해결하기 | 주문 카운터 버그부터 lock_guard까지

C++ mutex로 race condition 해결하기 | 주문 카운터 버그부터 lock_guard까지

이 글의 핵심

"주문 수가 맞지 않아요" 같은 실제 버그 사례로 시작하는 mutex 가이드. race condition·데드락 원인과 std::mutex·lock_guard·unique_lock 사용법, 실무에서 바로 쓸 수 있는 동기화 패턴을 정리합니다.

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

”주문 수가 맞지 않아요” - 공유 변수와 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
  2. 문제 시나리오: 구체적인 버그 사례
  3. std::mutex 완전 사용법
  4. lock_guard와 unique_lock: RAII로 락 잡기
  5. 데드락 피하기
  6. 자주 발생하는 실수와 해결법
  7. 성능 비교
  8. 베스트 프랙티스
  9. 프로덕션 패턴
  10. 실전 패턴과 주의점

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++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법
  • C++ atomic | Mutex 없이 스레드 안전 카운터 만드는 법 (memory_order 포함)
  • C++ RAII | “파일을 열 수 없습니다” 장애의 원인과 자동 리소스 관리

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

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만으로는 “조건이 만족될 때까지 기다렸다가 깨우기”를 표현하기 어렵습니다. 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 패턴을 다룹니다.

참고 자료


관련 글

  • C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법
  • C++ 고급 멀티스레딩 | 스레드 풀·Work Stealing
  • C++ condition_variable 실무 패턴 |
  • C++ atomic | Mutex 없이 스레드 안전 카운터 만드는 법 (memory_order 포함)
  • C++ 스택 vs 힙 | 재귀에서 프로그램이 죽는 이유와 스택 오버플로우 사례