C++ thread_local | '스레드 로컬 저장소' 가이드
이 글의 핵심
C++ thread_local의 C++, thread_local, "스레드, 1.
들어가며
C++11의 thread_local은 각 스레드마다 독립적인 저장소를 제공하여 스레드 안전한 코드를 작성할 수 있게 합니다. 멀티스레드 환경에서 동기화 없이 스레드별 데이터를 관리할 수 있습니다.
1. thread_local 기본
thread_local은 변수 선언 앞에 붙이는 저장 기간 지정자로, 이 키워드가 붙은 변수는 프로그램 전체에 하나만 존재하는 것이 아니라 각 스레드마다 독립적인 사본이 하나씩 만들어집니다. 일반 전역 변수를 여러 스레드가 공유하면 동시 접근으로 인한 데이터 레이스를 막기 위해 뮤텍스 같은 동기화 장치가 필요하지만, thread_local 변수는 애초에 스레드끼리 공유되지 않으므로 동기화 없이도 각 스레드가 안전하게 자신만의 값을 읽고 쓸 수 있습니다.
개념
아래 예시에서 thread_local int counter = 0;으로 선언된 counter는 이름은 하나지만 실제로는 각 스레드마다 별도의 메모리 공간에 존재하는 완전히 다른 변수입니다. t1과 t2 두 스레드가 동시에 func()를 호출해 counter++를 실행해도, 서로 다른 스레드의 counter는 물리적으로 분리되어 있으므로 한쪽의 증가가 다른 쪽에 영향을 주지 않고 각자 독립적으로 0에서 시작해 증가합니다.
#include <thread>
#include <iostream>
thread_local int counter = 0;
void func() {
counter++;
std::cout << "스레드 " << std::this_thread::get_id()
<< ": " << counter << std::endl;
}
int main() {
std::thread t1(func);
std::thread t2(func);
t1.join();
t2.join();
}
기본 사용
앞선 예시와 거의 동일한 구조지만, 변수명을 x로 바꿔 thread_local이 특정 이름이나 타입에 국한된 기능이 아니라 어떤 변수 선언에도 자유롭게 적용할 수 있는 범용적인 저장 기간 지정자라는 점을 보여줍니다. 실무에서는 이런 단순 카운터보다 스레드별로 독립적인 상태를 유지해야 하는 더 복잡한 자료구조(캐시, 버퍼, 난수 생성기 등)에 thread_local을 적용하는 경우가 많으며, 뒤에서 이런 실전 활용 예제를 차례로 다룹니다.
#include <thread>
#include <iostream>
thread_local int x = 0;
void worker() {
x++;
std::cout << "스레드 " << std::this_thread::get_id()
<< ": " << x << std::endl;
}
int main() {
std::thread t1(worker);
std::thread t2(worker);
t1.join();
t2.join();
}
2. 실전 예제
thread_local의 진가는 여러 스레드가 각자 독립적인 상태를 유지해야 하는 실전 상황에서 드러납니다. 아래 세 가지 예제는 스레드별 요청 카운터, 버퍼링된 로그 처리, 그리고 스레드 안전한 난수 생성기라는 대표적인 활용 사례를 다룹니다.
예제 1: 스레드별 카운터
서버가 여러 워커 스레드로 요청을 처리할 때, 각 스레드가 자신이 처리한 요청 수를 세고 싶다면 thread_local이 가장 간단한 해법입니다. 아래 requestCount는 스레드마다 독립적으로 0부터 시작해 증가하므로, 별도의 뮤텍스나 원자적 연산 없이도 “이 스레드가 지금까지 몇 건을 처리했는가”를 정확하게 추적할 수 있습니다. 만약 이 카운터가 일반 전역 변수였다면 여러 스레드가 동시에 증가시킬 때 락이 필요했겠지만, thread_local을 쓰면 애초에 경쟁이 발생할 여지 자체가 없어집니다.
#include <thread>
#include <vector>
#include <iostream>
thread_local size_t requestCount = 0;
void handleRequest() {
requestCount++;
std::cout << "스레드 " << std::this_thread::get_id()
<< " 요청: " << requestCount << std::endl;
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 5; i++) {
threads.emplace_back([] {
for (int j = 0; j < 3; j++) {
handleRequest();
}
});
}
for (auto& t : threads) {
t.join();
}
}
예제 2: 스레드별 버퍼
로그나 이벤트 데이터를 즉시 기록하지 않고 일정량 모았다가 한꺼번에 처리하는 배칭(batching) 패턴에서도 thread_local이 유용합니다. 아래 예시에서 각 스레드는 자신만의 buffer에 값을 계속 채워 넣다가 100개가 쌓이면 flush()로 한 번에 내보내는데, 이 버퍼가 스레드마다 독립적이기 때문에 여러 스레드가 동시에 process()를 호출해도 버퍼에 값을 추가하는 과정에서 락을 걸 필요가 전혀 없습니다. 이런 패턴은 로깅 시스템처럼 쓰기 빈도는 높지만 스레드 간 순서가 중요하지 않은 데이터를 다룰 때 성능을 크게 개선할 수 있습니다.
#include <thread>
#include <vector>
#include <iostream>
thread_local std::vector<int> buffer;
void flush(const std::vector<int>& buf) {
std::cout << "Flush: " << buf.size() << " items" << std::endl;
}
void process(int value) {
buffer.push_back(value);
if (buffer.size() >= 100) {
flush(buffer);
buffer.clear();
}
}
int main() {
std::thread t1([] {
for (int i = 0; i < 150; i++) {
process(i);
}
});
t1.join();
}
예제 3: 난수 생성기
std::mt19937 같은 난수 생성기 엔진은 내부 상태를 계속 갱신하며 다음 난수를 만들어내는 구조라서, 여러 스레드가 하나의 엔진 인스턴스를 동시에 사용하면 내부 상태가 손상되는 데이터 레이스가 발생합니다. 이 문제를 뮤텍스로 해결하면 매 호출마다 락 경쟁이 발생해 성능이 크게 떨어지지만, 아래처럼 thread_local로 엔진 자체를 선언하면 각 스레드가 자신만의 독립된 난수 생성기를 갖게 되어 동기화 비용 없이 병렬로 난수를 생성할 수 있습니다. 이런 이유로 멀티스레드 환경에서 난수를 다룰 때는 전역 난수 엔진 하나를 공유하기보다 thread_local로 스레드마다 별도의 엔진을 두는 것이 사실상 표준적인 관행입니다.
#include <random>
#include <thread>
#include <iostream>
thread_local std::mt19937 rng(std::random_device{}());
int getRandomNumber() {
std::uniform_int_distribution<int> dist(1, 100);
return dist(rng);
}
int main() {
std::thread t1([] {
for (int i = 0; i < 5; i++) {
std::cout << "스레드 1: " << getRandomNumber() << std::endl;
}
});
std::thread t2([] {
for (int i = 0; i < 5; i++) {
std::cout << "스레드 2: " << getRandomNumber() << std::endl;
}
});
t1.join();
t2.join();
}
3. 초기화
thread_local 변수가 정확히 언제 초기화되는지 아는 것은 예상치 못한 부작용을 피하는 데 중요합니다. 초기화 시점은 변수가 네임스페이스 스코프에 선언되었는지, 함수 내부의 지역 변수로 선언되었는지에 따라 달라지며, 아래 두 예제가 이 차이를 각각 보여줍니다.
스레드 시작 시
네임스페이스 스코프에서 thread_local int x = 10;처럼 선언된 변수는 해당 변수를 사용하는 스레드가 시작될 때 자동으로 초기화됩니다. 아래 예시에서 t1과 t2 두 스레드는 각각 시작되는 시점에 자신만의 x를 10으로 초기화하므로, worker() 함수 안에서 x를 출력하면 두 스레드 모두 항상 10을 보게 됩니다. 이런 즉시 초기화 방식은 변수가 간단한 상수나 기본값으로 채워지는 경우에는 문제없지만, 초기화 비용이 큰 객체라면 스레드를 생성할 때마다 불필요한 초기화 비용이 들 수 있다는 점을 뒤에서 다시 살펴봅니다.
#include <thread>
#include <iostream>
thread_local int x = 10;
void worker() {
std::cout << "x = " << x << std::endl;
}
int main() {
std::thread t1(worker);
std::thread t2(worker);
t1.join();
t2.join();
}
첫 사용 시
반면 함수 내부의 지역 변수로 선언된 thread_local 변수는 스레드가 시작될 때가 아니라, 그 함수가 처음 호출되어 해당 변수 선언문을 처음 실행하는 시점에 지연 초기화(lazy initialization)됩니다. 아래 예시에서 func()를 같은 스레드 안에서 두 번 호출하면 compute()가 호출되었다는 메시지는 첫 번째 호출에서만 출력되는데, 이는 thread_local int y = compute();가 각 스레드마다 딱 한 번만, 즉 그 스레드에서 처음 func()에 진입할 때만 실행되기 때문입니다. 이런 지연 초기화 방식은 초기화 비용이 크지만 모든 스레드가 그 함수를 반드시 호출하는 것은 아닌 경우에, 실제로 필요한 스레드에서만 초기화 비용을 지불하게 해주는 유용한 최적화입니다.
#include <thread>
#include <iostream>
int compute() {
std::cout << "compute() 호출" << std::endl;
return 42;
}
void func() {
thread_local int y = compute();
std::cout << "y = " << y << std::endl;
}
int main() {
std::thread t1([] {
func();
func();
});
t1.join();
}
4. 자주 발생하는 문제
thread_local은 사용법 자체는 단순하지만, 스레드의 생명주기와 얽히면서 몇 가지 미묘한 함정을 만들어 냅니다. 아래 네 가지는 실무에서 특히 자주 마주치는 문제들입니다.
문제 1: 소멸 순서
thread_local 객체는 해당 스레드가 종료될 때 자동으로 소멸자가 호출되며, 이는 스레드가 프로그램 전체보다 먼저 끝나든 나중에 끝나든 상관없이 성립합니다. 아래 예시에서 Resource 타입의 thread_local 변수 r은 t1 스레드가 실행을 마치고 종료되는 시점에 소멸자가 호출되어 “Resource 소멸”이 출력되는데, 이 소멸 시점이 main 함수가 끝나는 시점이나 다른 전역 변수의 소멸 시점과 정확히 어떤 순서로 일어나는지는 프로그램 구조에 따라 달라질 수 있습니다. 여러 개의 thread_local 객체가 서로 의존 관계를 가지고 있다면, 이 소멸 순서의 불확실성이 예상치 못한 버그로 이어질 수 있으므로 각 객체의 소멸자가 다른 thread_local 객체에 의존하지 않도록 설계하는 것이 안전합니다.
#include <thread>
#include <iostream>
struct Resource {
~Resource() {
std::cout << "Resource 소멸" << std::endl;
}
};
thread_local Resource r;
void func() {
std::cout << "func() 실행" << std::endl;
}
int main() {
std::thread t1(func);
t1.join();
}
문제 2: 클래스 멤버
클래스의 정적 멤버 변수도 thread_local로 선언할 수 있지만, 일반 정적 멤버와 마찬가지로 클래스 정의 안의 선언과 클래스 정의 밖의 실제 정의(및 초기화)를 분리해서 작성해야 합니다. 아래 예시에서 class MyClass 안에는 static thread_local int x;로 선언만 하고, 클래스 밖에서 thread_local int MyClass::x = 0;로 실제 저장 공간을 할당하고 초기값을 지정하고 있습니다. 이 문법을 놓치고 클래스 안에서 바로 초기화하려 하면 컴파일 에러가 발생하므로, 정적 멤버 변수의 일반적인 정의 규칙에 thread_local 키워드만 추가된 것이라고 이해하면 헷갈리지 않습니다.
#include <iostream>
class MyClass {
public:
static thread_local int x;
};
thread_local int MyClass::x = 0;
int main() {
MyClass::x = 42;
std::cout << MyClass::x << std::endl; // 42
}
문제 3: 초기화 비용
초기화 비용이 큰 객체를 thread_local로 즉시 초기화하도록 선언하면, 해당 변수를 실제로 사용하지 않는 스레드에서도 불필요하게 초기화 비용을 지불하게 됩니다. 아래 예시는 이런 낭비를 피하기 위해 thread_local std::unique_ptr<ExpensiveObject> obj;로 스마트 포인터만 즉시 초기화(비용이 거의 없는 nullptr 초기화)해 두고, func()가 처음 호출될 때만 if (!obj) 검사를 거쳐 실제 객체를 지연 생성하는 패턴을 보여줍니다. 이런 지연 초기화 패턴은 앞서 다룬 “첫 사용 시” 초기화와 함께 활용하면, 비용이 큰 자원을 실제로 필요한 스레드에서만, 필요한 시점에만 생성하도록 세밀하게 제어할 수 있습니다.
#include <memory>
#include <iostream>
struct ExpensiveObject {
ExpensiveObject() {
std::cout << "ExpensiveObject 생성" << std::endl;
}
};
thread_local std::unique_ptr<ExpensiveObject> obj;
void func() {
if (!obj) {
obj = std::make_unique<ExpensiveObject>();
}
}
int main() {
func();
func();
}
문제 4: 메모리 사용
thread_local 변수의 메모리는 스레드마다 각각 별도로 할당되므로, 전체 메모리 사용량은 변수 하나의 크기에 스레드 개수를 곱한 만큼 늘어납니다. 아래 예시처럼 100만 개의 int를 담는 큰 벡터를 thread_local로 선언하면, 스레드를 10개만 생성해도 이 벡터 하나로만 대략 40MB(int 4바이트 기준)에 가까운 메모리가 소비되며, 스레드 풀을 사용하는 서버 애플리케이션에서 스레드 수가 늘어날수록 이 부담은 선형으로 커집니다. 그래서 큰 자료구조를 thread_local로 선언하기 전에는 실제로 필요한 스레드 수를 고려해 전체 메모리 사용량을 미리 계산해 보고, 필요하다면 스레드 풀의 크기를 제한하거나 자료구조 크기를 줄이는 것을 함께 검토해야 합니다.
#include <vector>
#include <thread>
#include <iostream>
thread_local std::vector<int> largeBuffer(1000000);
void worker() {
std::cout << "Buffer size: " << largeBuffer.size() << std::endl;
}
int main() {
std::thread t1(worker);
std::thread t2(worker);
t1.join();
t2.join();
}
5. 사용 패턴
지금까지 다룬 개념과 주의사항을 정리하면, 실무에서 thread_local은 크게 두 가지 패턴으로 반복해서 등장합니다. 아래 두 패턴은 각각 스레드별 캐시와 스레드별 통계 수집으로, 두 경우 모두 “스레드마다 독립적이어야 하고, 다른 스레드와 공유할 필요가 없는 상태”라는 공통점을 가지고 있습니다.
패턴 1: 스레드별 캐시
계산 비용이 큰 값을 반복해서 요청받는 함수는 캐시를 두어 같은 입력에 대한 재계산을 피하는 것이 일반적인데, 이 캐시를 여러 스레드가 공유하면 동기화 오버헤드가 발생합니다. 아래 예시처럼 thread_local std::unordered_map<std::string, int> cache;로 캐시 자체를 스레드마다 독립적으로 두면, 각 스레드가 자신만의 캐시를 락 없이 자유롭게 읽고 쓸 수 있어 동기화 비용이 완전히 사라집니다. 다만 이 방식은 스레드마다 같은 값을 중복 계산하고 중복 저장하게 되므로, 스레드 수가 많고 캐시 항목이 크다면 전체 메모리 사용량과 캐시 적중률 사이의 트레이드오프를 함께 고려해야 합니다.
#include <unordered_map>
#include <string>
thread_local std::unordered_map<std::string, int> cache;
int getValue(const std::string& key) {
if (cache.find(key) != cache.end()) {
return cache[key];
}
int value = computeValue(key);
cache[key] = value;
return value;
}
패턴 2: 스레드별 통계
요청 처리 건수나 에러 발생 횟수 같은 통계를 여러 스레드가 함께 집계해야 할 때도, 매번 원자적 연산이나 뮤텍스로 하나의 공유 카운터를 갱신하는 대신 스레드별로 독립된 통계 구조체를 두는 방식이 흔히 쓰입니다. 아래 Statistics 구조체를 thread_local로 선언하면 각 스레드가 자신의 처리 건수와 에러 건수를 락 없이 빠르게 누적할 수 있고, 프로그램 종료 시점이나 주기적인 리포팅 시점에 모든 스레드의 통계를 한 곳에 모아 합산하는 방식으로 전체 통계를 얻을 수 있습니다. 이 접근은 통계 갱신 자체는 최대한 빠르게 하고, 상대적으로 드물게 일어나는 집계 시점에만 스레드 간 조율 비용을 지불한다는 점에서 효율적인 설계입니다.
#include <iostream>
struct Statistics {
size_t count = 0;
size_t errors = 0;
void print() {
std::cout << "Count: " << count << ", Errors: " << errors << std::endl;
}
};
thread_local Statistics stats;
void processRequest() {
stats.count++;
}
정리
핵심 요약
- thread_local: 스레드별 독립 변수
- 초기화: 스레드 시작 또는 첫 사용 시
- 용도: 캐시, 통계, 난수 생성기
- 성능: 접근 빠름, 초기화 비용 있음
- 메모리: 스레드 수 × 변수 크기
thread_local vs 전역 변수
| 특성 | thread_local | 전역 변수 |
|---|---|---|
| 스레드 안전 | O | X |
| 동기화 필요 | X | O |
| 메모리 | 스레드당 | 1개 |
| 성능 | 빠름 | 동기화 필요 시 느림 |
실전 팁
- 스레드별 캐시에 활용
- 난수 생성기는 thread_local 사용
- 초기화 비용 고려
- 메모리 사용량 주의
다음 단계
- C++ jthread
- C++ random_device
- C++ Mutex
관련 글
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. Everything about C++ thread_local : from basic concepts to practical applications.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ jthread | ‘자동 조인 스레드’ 가이드
- C++ constexpr Lambda | ‘컴파일 타임 람다’ 가이드
- C++ Template Lambda | ‘템플릿 람다’ 가이드
이 글에서 다루는 키워드 (관련 검색어)
C++, thread_local, TLS, thread, C++11 등으로 검색하시면 이 글이 도움이 됩니다.