3장. Context · 스레드 · 동시성
하나의 서버 프로세스 안에서 쿼리·머지·백업이 뒤엉키지 않는 이유 — 설정 6단계 머지, 스레드 풀 계층, CPU slot이라는 교통정리 시스템을 해부합니다.
① 설정이 6단계 Context 계층에서 머지되는 순서 ② 백그라운드 작업이 SET을 무시하는 이유 ③ Global → 특수 풀로 이어지는 스레드 풀 계층 ④ 쿼리 하나가 여러 스레드로 병렬 처리되는 구조(ThreadGroup) ⑤ CPU slot 기반 ConcurrencyControl
3.1 Context 계층 — 이 설정은 대체 어디서 온 걸까
ClickHouse의 모든 동작은 설정(settings)으로 제어됩니다. 문제는 설정의 출처가 한 곳이 아니라는 것 — 코드 기본값부터 쿼리의 SETTINGS 절까지 6단계가 겹겹이 쌓이고, 뒤 단계가 앞 단계를 오버라이드합니다.
가장 아래 깔리는 것은 코드에 하드코딩된 기본값입니다. max_threads의 기본값은 auto — CPU 코어 수입니다.
그 위에 설정 파일(config.xml/yaml)이 덮입니다. 여러 파일이 있으면 ConfigProcessor가 하나로 병합하고, 런타임 리로드도 지원합니다.
다음은 Profile 설정과 User 설정. users.xml의 <profiles>, <users> 섹션에서 사용자·프로파일별 기본값을 정합니다.
세션에서 SET max_threads = 8을 실행하면 Session context가 그 위를 덮습니다. 최종값이 8로 바뀌었죠.
마지막으로 쿼리의 SETTINGS 절이 가장 강력합니다. 이 쿼리에 한해 최종값은 4. "SET 했는데 왜 안 먹지?"의 답은 대부분 이 우선순위에 있습니다.
단 하나의 큰 예외 — 백그라운드 작업(머지·뮤테이션)은 Session/Query 설정을 무시합니다. Global + 'background' 프로파일만 봅니다. 머지 튜닝은 반드시 profile로!
<!-- 잘못된 시도: 세션 설정은 백그라운드 머지에 영향 없음 -->
-- SET max_bytes_to_merge_at_max_space_in_pool = ... ❌
<!-- 올바른 방법: users.xml의 background 프로파일에 추가 ✅ -->
<profiles>
<background>
<max_bytes_to_merge_at_max_space_in_pool>161061273600</max_bytes_to_merge_at_max_space_in_pool>
</background>
</profiles>
<!-- 프로파일명은 background_profile 서버 설정으로 변경 가능 -->
3.2 스레드 풀 계층 — 스레드를 빌려 쓰는 조직도
스레드 생성·소멸은 비싼 작업이므로, ClickHouse는 스레드 풀 체계를 씁니다. 모든 특수 풀은 최상위 싱글톤인 Global Thread Pool 위에 세워진 계층 구조입니다. 특수 풀의 역할은 스레드를 직접 만드는 게 아니라 동시 작업 수 제한과 스케줄링입니다.
| 풀 | 담당 | 핵심 설정 / 특징 |
|---|---|---|
| Global Thread Pool | 모든 풀의 기반 (싱글톤) | max_thread_pool_size, thread_pool_queue_size. 과부하 시 우선순위 큐로 대기 |
| Server Pool | 클라이언트 커넥션 | 스레드 1개가 커넥션 1개 전담. 상한 max_connection |
| IO Thread Pool | I/O 블로킹 작업 | I/O 작업이 Global 풀을 고갈시켜 CPU 쿼리를 막는 것을 방지하는 격리벽 |
| Backups IO Pool | S3 백업 전용 | 백업이 인터랙티브 쿼리의 I/O를 침범하지 않도록 재격리 |
| Background Schedule Pool | 주기적 작업 | 동일 작업의 동시 실행 방지, 실행 시점 지연 가능 |
| MergeTreeBackgroundExecutor | 머지·뮤테이션·페치·무브 | 작업을 선점 가능한 스텝으로 분할 — 작은 머지가 큰 머지보다 먼저 끝나게 스케줄링 |
풀이 과부하일 때 대기열에서 우선순위가 높은 작업이 먼저 시작됩니다. 하지만 이미 실행 중인 작업 간에는 우선순위 차이가 없습니다. 우선순위로 실행 중인 무거운 쿼리를 밀어낼 수는 없다는 뜻입니다.
3.3 쿼리 하나 = 스레드 여러 개 — ThreadGroup의 구조
어느 풀에서 스레드를 받든, 작업 시작 시 ThreadStatus 인스턴스가 생성됩니다. Thread ID, Query ID, 성능 카운터, 리소스 소비량을 캡슐화한 객체로, thread-local 포인터라 CurrentThread::get()으로 어디서든 접근합니다. 그렇다면 쿼리 하나를 여러 스레드가 나눠 처리할 때, 이들은 어떻게 하나로 묶일까요?
클라이언트가 접속하면 Server pool의 스레드 하나가 커넥션을 전담합니다. 이 스레드가 쿼리의 Master thread가 됩니다.
Master thread는 QueryScope로 쿼리 컨텍스트를 연결하고, ThreadGroupStatus를 생성합니다 — 이 쿼리에 참여할 모든 스레드의 "소속 팀"입니다.
병렬 실행에 필요한 Worker 스레드들이 Global pool에서 할당되어 CurrentThread::attachTo(thread_group)으로 팀에 합류합니다.
Worker들이 서로 다른 파트를 병렬 처리하는 동안, ThreadGroup의 MemoryTracker와 ProfileEvents::Counters가 모든 스레드의 리소스 소비를 쿼리 단위로 집계합니다.
쿼리가 끝나면 스레드는 풀로 돌아가고 집계 결과는 system.query_log에 남습니다. "쿼리별 메모리 사용량"을 정확히 볼 수 있는 이유가 바로 이 구조입니다.
3.4 ConcurrencyControl — CPU slot으로 하는 좌석 배정
max_threads의 기본값은 쿼리 하나가 모든 코어를 쓰도록 되어 있습니다. 그런데 그런 쿼리가 10개 동시에 실행되면? 스레드가 코어 수의 10배가 되어 컨텍스트 스위칭·캐시 미스·메모리 오버헤드가 폭증합니다. ConcurrencyControl은 CPU slot이라는 좌석제를 도입해 이 문제를 풉니다.
- 스레드를 실행하려면 먼저 slot을 획득(acquire)해야 하고, 끝나면 반환(release)합니다.
- 서버 전체 slot 수는
concurrent_threads_soft_limit_num으로 전역 제한됩니다. - 각 slot은
free → granted → acquired → (release) → free의 상태 머신입니다.
// 1단계: 최소 1개 ~ 최대 max_threads개 slot 요청
// 첫 slot은 즉시 부여 — 이것이 "soft limit"의 핵심
auto slots = ConcurrencyControl::instance().allocate(1, max_threads);
// 2단계: slot이 부여될 때마다 스레드 생성
while (auto slot = slots->tryAcquire())
spawnThread([slot = std::move(slot)] { ... });
// 3단계: 전체 slot 수는 재시작 없이 런타임 조정 가능
ConcurrencyControl::setMaxConcurrency(concurrent_threads_soft_limit_num);
쿼리 A 혼자 실행 중이면 slot 8개를 모두 가져가 모든 코어를 활용합니다. 놀리는 자원이 없습니다.
쿼리 B가 도착하면 allocate(1, max_threads)의 규칙대로 첫 번째 slot은 즉시 부여됩니다. B는 최소 1개 스레드로 곧바로 실행을 시작합니다.
A의 스레드가 작업을 마치고 slot을 반환할 때마다 ConcurrencyControl이 B에게 재분배합니다. 시간이 지나면 4:4로 공정하게 수렴합니다.
쿼리 C, D가 더 와도 마찬가지 — 각자 즉시 1 slot을 받고, 다른 쿼리가 끝나면 점진적으로 스케일업합니다.
이것이 "soft" limit의 의미입니다. 총 slot 수를 초과하더라도 모든 쿼리는 최소 1개 스레드를 보장받으므로, 쿼리가 굶어 죽는 일은 없습니다. slot 수는 재시작 없이 런타임 조정도 가능합니다.
concurrent_threads_soft_limit_num 기본값은 0 (무제한)동시 쿼리가 많은 환경이라면 CPU 코어 수의 1~2배로 설정하는 것을 고려하세요. 너무 낮으면 개별 쿼리가 느려지고, 0(무제한)이면 스레드 폭증으로 컨텍스트 스위칭 비용이 커집니다.
3.5 종합 — SELECT 쿼리 하나의 라이프사이클
- 커넥션 수락 → Server pool에서 Master thread 할당
- Context 구성 → Global → Profile → User → Session → Query 순서로 설정 머지
- 파싱·해석 → Parser → AST → Interpreter → QueryPipeline (2장)
- slot 할당 →
allocate(1, max_threads), 최소 1개 즉시 확보 - 스레드 실행 → ThreadGroup 생성, Worker들이 attach하여 MemoryTracker·ProfileEvents 집계
- Pipeline 실행 → Processor DAG가 Block 단위로 필터·집계 처리
- 결과 출력 → Format → WriteBuffer → 클라이언트
- 정리 → slot 반환·재분배,
system.query_log기록
3.6 모니터링 — 스레드와 동시성 들여다보기
-- 현재 활성 스레드 현황
SELECT metric, value FROM system.metrics
WHERE metric IN ('GlobalThread', 'GlobalThreadActive',
'IOThreads', 'IOThreadsActive',
'BackgroundMergesAndMutationsPoolTask');
-- ConcurrencyControl 영향 확인: 요청한 스레드 vs 실제 사용한 스레드
SELECT query_id,
Settings['max_threads'] AS requested_threads,
length(thread_ids) AS actual_threads,
peak_memory_usage
FROM system.query_log
WHERE type = 'QueryFinish'
ORDER BY event_time DESC
LIMIT 20;
max_io_thread_pool_size와 max_backups_io_thread_pool_size를 독립적으로 튜닝하세요. 백업 작업이 인터랙티브 쿼리의 I/O 스레드를 잡아먹는 것을 막는 것이 두 풀을 분리한 이유입니다.
✍️ 이해도 체크
SET max_bytes_to_merge_at_max_space_in_pool = ...을 실행했는데 백그라운드 머지 동작이 바뀌지 않습니다. 이유는?<profiles>의 background 프로파일을 수정하거나 background_profile로 커스텀 프로파일을 지정해야 합니다.concurrent_threads_soft_limit_num = 64인 서버에 쿼리 100개가 동시에 실행되면?allocate(1, max_threads)의 첫 slot은 항상 즉시 부여되므로, 총 스레드 수가 제한(64)을 초과해 100이 되더라도 모든 쿼리가 실행됩니다. 이후 slot이 반환되면 ConcurrencyControl이 재분배합니다.이 장의 원문 문서: chapters/ch03_context_threads_concurrency.md — slot 상태 머신 상세, BackgroundSchedulePool의 보장 사항, 추가 모니터링 쿼리가 담겨 있습니다.