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::mutex와std::lock_guard,std::unique_lock으로 크리티컬 섹션(critical section—한 번에 한 스레드만 실행해야 하는 코드 구간)을 보호할 수 있습니다.- 데드락(deadlock—두 스레드가 서로가 가진 락을 기다리며 영원히 멈추는 상태)을 피하는 순서 잠금·
std::lock()패턴을 쓸 수 있습니다. - RAII로 락을 잡아 예외 시에도 안전하게 풀 수 있습니다.
- 자주 발생하는 실수와 성능 비교, 프로덕션 패턴을 활용할 수 있습니다.
목차
- Race condition과 data race
- 문제 시나리오: 구체적인 버그 사례
- std::mutex 완전 사용법
- lock_guard와 unique_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_count와 miss_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_count와 miss_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_lock | try_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_lock은 unlock() 후 lock()으로 다시 잡을 수 있어, I/O 구간에서 락을 풀어 병목을 줄일 수 있습니다.
lock_guard vs unique_lock 선택 가이드
| 상황 | 권장 |
|---|---|
| 구간 보호만 필요 | lock_guard (가볍고 단순) |
| condition_variable 사용 | unique_lock (필수) |
| 중간에 unlock 후 다시 lock | unique_lock |
| 여러 뮤텍스 std::lock()으로 한 번에 | unique_lock + defer_lock |
5. 데드락 피하기
데드락: 두 스레드가 서로가 가진 락을 기다리며 영원히 멈추는 상황.
예: 스레드 A는 mutex1 → mutex2 순서로 잡고, 스레드 B는 mutex2 → mutex1 순서로 잡으면, 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: 락 순서를 통일
항상 같은 순서로 락을 잡습니다. 예: 항상 mutex1 → mutex2.
// 좋은 예: 모든 스레드가 같은 순서 (먼저 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_guard나 unique_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_guard | 1.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;
}
};
위 코드 설명: value와 mtx를 한 구조체에 두면, add와 get에서만 락을 잡고 접근하도록 강제할 수 있어, 실수로 락 없이 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 패턴을 다룹니다.
참고 자료
- cppreference - std::mutex
- cppreference - std::lock_guard
- cppreference - std::unique_lock
- cppreference - std::shared_mutex
- cppreference - std::scoped_lock
- C++ Core Guidelines - CP.20
- C++ Core Guidelines - CP.21
관련 글
- C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법
- C++ 고급 멀티스레딩 | 스레드 풀·Work Stealing
- C++ condition_variable 실무 패턴 |
- C++ atomic | Mutex 없이 스레드 안전 카운터 만드는 법 (memory_order 포함)
- C++ 스택 vs 힙 | 재귀에서 프로그램이 죽는 이유와 스택 오버플로우 사례