C++ Mutex & Lock | '뮤텍스와 락' 가이드
이 글의 핵심
C++ Mutex & Lock: "뮤텍스와 락" 가이드. mutex 기본·lock_guard (RAII).
들어가며
Mutex(뮤텍스)는 상호 배제(Mutual Exclusion)를 위한 동기화 도구입니다. 여러 스레드가 공유 데이터에 동시 접근하는 것을 방지하여 데이터 레이스를 막습니다.
1. std::mutex 기본
여러 스레드가 같은 변수를 동시에 읽고 쓰면, CPU 명령어 수준에서 연산이 겹치면서 값이 예측 불가능하게 깨지는 데이터 레이스가 발생합니다. std::mutex는 “한 번에 한 스레드만 이 코드를 실행할 수 있다”는 상호 배제를 강제해, 여러 스레드가 동시에 공유 데이터를 건드리지 못하게 막아주는 가장 기본적인 동기화 도구입니다.
기본 사용
아래 예제에서 sharedData는 두 스레드가 동시에 접근하는 공유 변수입니다. mtx.lock()을 호출한 스레드만 ++sharedData가 있는 임계 영역(critical section)에 진입할 수 있고, 다른 스레드는 mtx.unlock()이 호출될 때까지 대기합니다. lock()과 unlock()을 짝지어 직접 호출하는 이 방식은 뮤텍스의 동작 원리를 이해하기에는 좋지만, 다음 절에서 보듯 예외 안전성 문제를 안고 있어 실무에서는 그대로 쓰지 않습니다.
#include <mutex>
#include <thread>
#include <iostream>
// std::mutex: 상호 배제(Mutual Exclusion)를 위한 뮤텍스
// 여러 스레드가 동시에 접근하지 못하도록 보호
std::mutex mtx;
int sharedData = 0; // 공유 데이터 (여러 스레드가 접근)
void increment() {
// mtx.lock(): 뮤텍스 잠금 (다른 스레드는 대기)
// 이미 잠긴 경우 unlock될 때까지 블로킹
mtx.lock();
// 임계 영역 (Critical Section): 한 번에 한 스레드만 실행
++sharedData;
// mtx.unlock(): 뮤텍스 해제 (다른 스레드가 진입 가능)
mtx.unlock();
}
int main() {
// 두 스레드가 동시에 increment 실행
std::thread t1(increment);
std::thread t2(increment);
// join(): 스레드 종료 대기
t1.join();
t2.join();
// 뮤텍스 덕분에 데이터 레이스 없이 안전하게 증가
std::cout << "sharedData: " << sharedData << std::endl; // 2
return 0;
}
문제: 예외 안전성
lock()/unlock()을 직접 호출하는 방식의 가장 큰 약점은, 두 호출 사이에서 예외가 발생하거나 조기 return이 실행되면 unlock()에 도달하지 못한다는 것입니다. 아래 unsafeFunction은 mtx.lock() 이후 예외가 던져지면 뮤텍스가 잠긴 채로 함수를 빠져나가고, 이후 같은 뮤텍스를 기다리는 다른 스레드는 영원히 대기하게 됩니다.
#include <mutex>
#include <stdexcept>
#include <iostream>
std::mutex mtx;
void unsafeFunction() {
mtx.lock();
// 예외 발생 시 unlock 안 됨!
if (someCondition) {
throw std::runtime_error("에러");
}
mtx.unlock(); // 실행 안 됨
}
int main() {
try {
unsafeFunction();
} catch (...) {
std::cout << "예외 발생, 뮤텍스 잠김!" << std::endl;
}
return 0;
}
문제의 본질: unsafeFunction 중간에 예외가 나면 unlock 줄에 도달하지 못해 뮤텍스가 영구히 잠긴 상태가 될 수 있습니다. 이후 같은 뮤텍스를 기다리는 스레드는 전부 교착에 가까운 행렬로 멈춥니다.
2. lock_guard (RAII)
앞서 본 예외 안전성 문제를 해결하는 C++의 표준 해법이 RAII(Resource Acquisition Is Initialization)입니다. std::lock_guard는 생성될 때 뮤텍스를 잠그고, 스코프를 벗어나 소멸자가 호출될 때 자동으로 잠금을 해제하도록 설계된 얇은 래퍼 클래스입니다. 이렇게 자원 관리를 객체의 수명에 묶어두면, 함수가 정상적으로 끝나든 예외로 중간에 빠져나가든 소멸자는 반드시 호출되므로 뮤텍스 해제를 잊어버릴 걱정이 사라집니다.
자동 잠금/해제
safeIncrement 함수에서 std::lock_guard<std::mutex> lock(mtx);는 생성 즉시 mtx를 잠그고, lock 변수가 함수 끝에서 스코프를 벗어나며 소멸될 때 자동으로 unlock()을 호출합니다. lock_guard는 생성자에서 잠그고 소멸자에서 풀기 때문에, 함수 중간에 return이나 예외가 발생해도 해제가 보장됩니다. 가드 객체의 수명을 최소 임계 구역으로만 제한하면 스레드 간 경합(contention)도 함께 줄일 수 있습니다.
#include <mutex>
#include <thread>
#include <iostream>
std::mutex mtx;
int sharedData = 0;
void safeIncrement() {
std::lock_guard<std::mutex> lock(mtx);
++sharedData;
// 소멸자에서 자동 unlock
}
int main() {
std::thread t1(safeIncrement);
std::thread t2(safeIncrement);
t1.join();
t2.join();
std::cout << "sharedData: " << sharedData << std::endl; // 2
return 0;
}
패턴 설명: lock_guard는 생성자에서 잠그고 소멸자에서 풀기 때문에, 함수 중간에 return이나 예외가 나도 해제가 보장됩니다. 가드 객체의 수명을 최소 임계구역으로만 제한하면 경합(contention)도 줄일 수 있습니다.
예외 안전
이번에는 앞서 본 unsafeFunction을 lock_guard로 다시 작성해 비교해봅니다. safeFunction 내부에서 예외가 던져지면 스택 되감기(stack unwinding) 과정에서 lock 객체의 소멸자가 호출되어 mtx가 자동으로 해제되므로, unlock()을 명시적으로 호출하는 코드가 아예 필요 없어집니다. 이 차이 하나만으로도 실무에서는 mutex.lock()/unlock()을 직접 쓰는 대신 lock_guard를 기본값으로 삼아야 하는 이유가 충분합니다.
#include <mutex>
#include <stdexcept>
#include <iostream>
std::mutex mtx;
void safeFunction() {
std::lock_guard<std::mutex> lock(mtx);
// 예외 발생해도 자동 unlock
if (someCondition) {
throw std::runtime_error("에러");
}
// lock 소멸자에서 unlock
}
int main() {
try {
safeFunction();
} catch (...) {
std::cout << "예외 발생, 뮤텍스 자동 해제" << std::endl;
}
return 0;
}
3. unique_lock
lock_guard는 단순하고 빠르지만 잠근 뒤 스코프가 끝날 때까지 계속 잠긴 상태를 유지해야 한다는 제약이 있습니다. std::unique_lock은 lock_guard와 마찬가지로 RAII 기반이면서도 중간에 수동으로 lock()/unlock()을 호출하거나, 처음부터 잠그지 않은 상태로 생성하는 등 훨씬 유연한 제어를 제공합니다. 다만 이런 유연함은 내부적으로 잠금 상태를 추적하는 오버헤드로 이어지므로, 특별한 이유가 없다면 여전히 lock_guard를 우선 고려하는 것이 좋습니다.
유연한 잠금
아래 flexibleLock 함수는 unique_lock으로 잠근 뒤 “작업 1”을 수행하고, lock.unlock()으로 명시적으로 잠금을 풀어 락 없이 안전한 다른 작업을 수행한 다음, 다시 lock.lock()으로 재잠금하여 “작업 2”를 이어갑니다. 이렇게 임계 구역을 함수 전체가 아니라 필요한 구간에만 정밀하게 적용할 수 있다는 점이 unique_lock이 lock_guard보다 유연한 핵심 이유입니다.
#include <mutex>
#include <iostream>
std::mutex mtx;
void flexibleLock() {
std::unique_lock<std::mutex> lock(mtx);
// 작업 1
std::cout << "작업 1" << std::endl;
// 수동 해제
lock.unlock();
// 락 없이 작업
std::cout << "락 없는 작업" << std::endl;
// 다시 잠금
lock.lock();
// 작업 2
std::cout << "작업 2" << std::endl;
// 소멸자에서 자동 unlock
}
int main() {
flexibleLock();
return 0;
}
지연 잠금
std::defer_lock 태그를 생성자에 넘기면 unique_lock 객체는 만들어지지만 실제 잠금은 나중으로 미룰 수 있습니다. 이 패턴은 조건에 따라 잠금이 필요할 수도, 필요하지 않을 수도 있는 상황에서 유용한데, 무조건 잠갔다가 조건이 맞지 않으면 즉시 풀어주는 것보다 애초에 필요할 때만 잠그는 편이 불필요한 락 획득·해제 비용을 줄여줍니다. 다음 절에서 다루는 std::lock과 함께 사용하면 여러 뮤텍스를 데드락 없이 조합해서 잠그는 데도 활용됩니다.
#include <mutex>
#include <iostream>
std::mutex mtx;
void deferredLock() {
// 생성 시 잠금 안함
std::unique_lock<std::mutex> lock(mtx, std::defer_lock);
// 조건부 잠금
if (needLock) {
lock.lock();
}
// 작업 수행
}
int main() {
deferredLock();
return 0;
}
4. 여러 뮤텍스
계좌 이체처럼 두 개 이상의 공유 자원을 동시에 갱신해야 할 때는 여러 개의 뮤텍스를 함께 잠가야 하는데, 이때 잠그는 순서를 스레드마다 다르게 하면 데드락이라는 새로운 위험이 생깁니다.
데드락 문제
badTransfer는 mtx1을 먼저 잠근 뒤 mtx2를 잠그고, anotherBadTransfer는 반대로 mtx2를 먼저 잠근 뒤 mtx1을 잠급니다. 만약 한 스레드가 mtx1을 잠근 순간 다른 스레드가 mtx2를 잠갔다면, 첫 번째 스레드는 mtx2를, 두 번째 스레드는 mtx1을 서로 기다리며 영원히 풀리지 않는 순환 대기, 즉 데드락에 빠집니다. 이 문제는 잠금 순서가 스레드마다 다를 때만 발생하므로, 항상 같은 순서로 잠그도록 강제하거나 아래에서 볼 std::lock을 사용하면 해결됩니다.
#include <mutex>
#include <thread>
#include <iostream>
std::mutex mtx1, mtx2;
int balance1 = 100, balance2 = 200;
// ❌ 데드락 가능
void badTransfer() {
// Thread 1: mtx1 → mtx2
mtx1.lock();
mtx2.lock();
balance1 -= 50;
balance2 += 50;
mtx2.unlock();
mtx1.unlock();
}
void anotherBadTransfer() {
// Thread 2: mtx2 → mtx1 (데드락!)
mtx2.lock();
mtx1.lock();
balance2 -= 30;
balance1 += 30;
mtx1.unlock();
mtx2.unlock();
}
std::lock 사용
std::lock은 여러 뮤텍스를 한 번의 호출로 원자적으로 잠가, 어떤 스레드가 잠그는 순서와 무관하게 데드락이 발생하지 않도록 보장하는 알고리즘입니다. 내부적으로 교착 상태를 회피하는 순서로 잠금을 시도하기 때문에, 호출하는 쪽에서 잠금 순서를 신경 쓸 필요가 없습니다. 다만 std::lock으로 잠근 뮤텍스는 이미 잠겨 있으므로, 그 뒤에 lock_guard로 다시 감쌀 때는 추가로 잠그지 말라는 의미의 std::adopt_lock 태그를 함께 넘겨 소유권만 인수하도록 해야 합니다.
#include <mutex>
#include <thread>
#include <iostream>
std::mutex mtx1, mtx2;
int balance1 = 100, balance2 = 200;
// ✅ std::lock (데드락 방지)
void safeTransfer() {
std::lock(mtx1, mtx2); // 원자적으로 둘 다 잠금
std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
balance1 -= 50;
balance2 += 50;
std::cout << "이체 완료: " << balance1 << ", " << balance2 << std::endl;
}
int main() {
std::thread t1(safeTransfer);
std::thread t2(safeTransfer);
t1.join();
t2.join();
return 0;
}
scoped_lock (C++17)
std::lock + adopt_lock 조합은 안전하지만 매번 세 줄을 써야 해서 번거롭습니다. C++17에서 도입된 std::scoped_lock은 이 패턴을 하나의 클래스로 캡슐화해, 생성자에 여러 뮤텍스를 나열하기만 하면 내부적으로 데드락 없는 순서로 모두 잠그고 소멸자에서 한꺼번에 해제해줍니다. 실무에서 두 개 이상의 뮤텍스를 함께 다뤄야 한다면 std::lock을 직접 쓰기보다 scoped_lock을 사용하는 것이 더 간결하고 실수할 여지도 적습니다.
#include <mutex>
#include <thread>
#include <iostream>
std::mutex mtx1, mtx2;
// ✅ C++17: scoped_lock (더 간결)
void modernTransfer() {
std::scoped_lock lock(mtx1, mtx2);
// 작업 수행
std::cout << "이체 수행" << std::endl;
// 소멸자에서 자동 unlock
}
int main() {
std::thread t1(modernTransfer);
std::thread t2(modernTransfer);
t1.join();
t2.join();
return 0;
}
5. 뮤텍스 종류
표준 라이브러리는 std::mutex 외에도 특수한 상황을 위한 몇 가지 변형을 제공합니다. 재귀 잠금이 필요한 경우와 잠금 시도에 시간 제한을 두어야 하는 경우가 대표적입니다.
recursive_mutex
일반 std::mutex는 이미 잠근 스레드가 다시 잠그려고 하면 데드락에 빠집니다. 재귀 호출 구조에서 같은 스레드가 같은 뮤텍스를 여러 번 잠글 필요가 있다면 std::recursive_mutex를 사용해야 하는데, 아래 예제처럼 func2가 뮤텍스를 잠근 상태에서 내부적으로 func1을 호출해 같은 뮤텍스를 다시 잠가도 문제없이 동작합니다. 다만 재귀 뮤텍스가 필요하다는 것은 종종 설계가 복잡해졌다는 신호이므로, 가능하면 잠금 범위를 재구성해 일반 뮤텍스로 해결하는 것이 더 바람직합니다.
#include <mutex>
#include <iostream>
std::recursive_mutex rmtx;
void func1() {
std::lock_guard<std::recursive_mutex> lock(rmtx);
std::cout << "func1" << std::endl;
}
void func2() {
std::lock_guard<std::recursive_mutex> lock(rmtx);
std::cout << "func2" << std::endl;
func1(); // OK: 재귀 뮤텍스
}
int main() {
func2();
return 0;
}
timed_mutex
일반 뮤텍스의 lock()은 잠금을 획득할 때까지 무한정 대기하지만, std::timed_mutex는 try_lock_for나 try_lock_until로 일정 시간만 대기하다가 포기할 수 있습니다. 아래 예제는 100밀리초 동안만 잠금을 시도하고, 그 안에 획득하지 못하면 실패로 처리해 다른 작업을 계속 진행합니다. 이런 시간 제한 잠금은 응답 지연을 절대 허용할 수 없는 UI 스레드나, 잠금 실패 시 재시도·폴백 로직으로 넘어가야 하는 서버 코드에서 유용합니다.
#include <mutex>
#include <thread>
#include <iostream>
std::timed_mutex tmtx;
void tryLockFor() {
// 100ms 동안 시도
if (tmtx.try_lock_for(std::chrono::milliseconds(100))) {
std::cout << "락 획득" << std::endl;
// 작업 수행
std::this_thread::sleep_for(std::chrono::milliseconds(50));
tmtx.unlock();
} else {
std::cout << "락 획득 실패 (타임아웃)" << std::endl;
}
}
int main() {
std::thread t1(tryLockFor);
std::thread t2(tryLockFor);
t1.join();
t2.join();
return 0;
}
6. 실전 예제: 스레드
스레드 안전 카운터
지금까지 배운 lock_guard를 실제 클래스 설계에 적용한 예제입니다. ThreadSafeCounter는 내부 카운터 count에 접근하는 모든 메서드(increment, decrement, get, reset) 각각에서 std::lock_guard로 뮤텍스를 잠가, 클래스를 사용하는 쪽에서 동기화를 신경 쓰지 않아도 항상 안전하게 동작하도록 캡슐화했습니다. get()처럼 값을 읽기만 하는 const 메서드에서도 잠금이 필요하기 때문에 mtx를 mutable로 선언한 점에 주목할 만한데, 이는 읽기 연산도 다른 스레드의 쓰기와 겹치면 데이터 레이스가 될 수 있기 때문입니다. 아래 main에서는 10개 스레드가 각각 1000번씩 증가시켜 총 1만 번의 증가가 데이터 레이스 없이 정확히 반영되는지 확인합니다.
#include <mutex>
#include <thread>
#include <vector>
#include <iostream>
// 타입 정의
class ThreadSafeCounter {
mutable std::mutex mtx;
int count = 0;
public:
void increment() {
std::lock_guard<std::mutex> lock(mtx);
++count;
}
void decrement() {
std::lock_guard<std::mutex> lock(mtx);
--count;
}
int get() const {
std::lock_guard<std::mutex> lock(mtx);
return count;
}
void reset() {
std::lock_guard<std::mutex> lock(mtx);
count = 0;
}
};
int main() {
ThreadSafeCounter counter;
std::vector<std::thread> threads;
// 10개 스레드가 각각 1000번 증가
for (int i = 0; i < 10; ++i) {
threads.emplace_back([&counter]() {
for (int j = 0; j < 1000; ++j) {
counter.increment();
}
});
}
for (auto& t : threads) {
t.join();
}
std::cout << "최종 카운트: " << counter.get() << std::endl; // 10000
return 0;
}
정리
핵심 요약
- mutex: 상호 배제, 공유 데이터 보호
- lock_guard: 간단한 RAII 락
- unique_lock: 유연한 락 (수동 제어)
- scoped_lock: 여러 뮤텍스 (C++17)
- recursive_mutex: 재귀 잠금 가능
- timed_mutex: 시간 제한 잠금
Lock 비교
| Lock | 특징 | 오버헤드 | 사용 시기 |
|---|---|---|---|
| lock_guard | 간단, RAII | 낮음 | 기본 |
| unique_lock | 유연, 수동 제어 | 중간 | 조건부 잠금 |
| scoped_lock | 여러 뮤텍스 | 낮음 | 다중 뮤텍스 (C++17) |
실전 팁
사용 원칙:
- 기본은
lock_guard - 수동 제어 필요 시
unique_lock - 여러 뮤텍스는
scoped_lock(C++17) - 재귀 잠금은
recursive_mutex
데드락 방지:
- 뮤텍스 잠금 순서 통일
std::lock으로 원자적 잠금scoped_lock사용 (C++17)- 잠금 시간 최소화
성능:
- 임계 영역 최소화
lock_guard가 가장 빠름unique_lock은 필요 시만- 읽기 전용은
shared_mutex(C++17)
다음 단계
- C++ Atomic
- C++ Condition Variable
- C++ Thread
관련 글
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. Everything about C++ Mutex & Lock : from basic concepts to practical applications.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ jthread | ‘자동 조인 스레드’ 가이드
- C++ thread_local | ‘스레드 로컬 저장소’ 가이드
- C++ Algorithm Remove | ‘제거 알고리즘’ 가이드
이 글에서 다루는 키워드 (관련 검색어)
C++, mutex, lock, synchronization, C++11 등으로 검색하시면 이 글이 도움이 됩니다.