Skip to main content
native protocol은 ClickHouse 클라이언트와 서버가 TCP 상에서 통신할 때 사용하는 바이너리 기반의 연결 지향 프로토콜입니다. 이 프로토콜은 SQL 쿼리, 결과 데이터, INSERT payload, 실행 텔레메트리, 오류 신호를 전달합니다. command-line client와 C++ 드라이버를 비롯해 대부분의 타사 네이티브 드라이버가 이 프로토콜을 기반으로 동작합니다. 이 페이지에서는 패킷 프레이밍, 연결 상태 머신(State Machine), 버전 협상, 그리고 Block을 제외한 모든 메시지의 body 등 프로토콜 자체를 다룹니다. Data 계열 패킷 내부의 바이트(Block, 그 컬럼, 유형별 인코딩)는 별도로 다루며, Native 형식 사양에 설명되어 있습니다.
관련 사양이 페이지는 짝을 이루는 두 문서 중 하나로, 관련 사양인 Native 형식과 함께 게시됩니다. 두 사양은 역할이 명확히 나뉩니다. 이 페이지는 패킷 및 전송 계층을 다루고, Native 형식 사양은 Data 계열 패킷 내부의 바이트를 다룹니다.
다음 특성은 프로토콜 전반에 걸쳐 적용됩니다. 이 프로토콜은 바이너리 기반이며 위치 기반(positional)입니다. BlockInfo 내부를 제외하면 필드 태그가 없으므로, 바이트 하나만 어긋나도 그 뒤의 모든 데이터가 동기화를 잃습니다. 또한 상태 유지형 프로토콜이며, 각 TCP 연결은 한 번에 하나의 쿼리만 처리합니다. 즉, 다중화는 지원하지 않습니다. 고정 폭 정수는 리틀 엔디언으로 인코딩됩니다.

개요

wire 상의 모든 메시지는 VarUInt 패킷 유형 코드로 시작하며, 그 뒤에는 해당 코드와 협상된 프로토콜 버전에 따라 구조가 달라지는 body가 이어집니다. 연결은 세 단계로 진행됩니다. 먼저 핸드셰이크를 한 번 수행하고, 이어서 Ping 또는 Query 교환을 원하는 횟수만큼 진행한 뒤, 마지막으로 연결을 종료합니다: 네이티브 TCP 프로토콜은 SQL에 어떤 FORMAT 절이 지정되어 있든 관계없이 항상 표 형식의 데이터를 Native 형식으로 전달합니다. RowBinary, CSV, JSON 등으로 다시 포맷팅하는 작업은 클라이언트의 몫이며, Native 블록을 디코딩한 후에 수행됩니다. (HTTP 인터페이스는 FORMAT 절을 실제로 반영하는 별도의 코드 경로입니다. HTTP는 이 문서에서 다루지 않습니다.)

보안

전송 보안(TLS)

TLS는 프로토콜 아래의 전송 계층(transport layer)에서 동작합니다. TLS를 활성화하면 TCP 스트림 전체가 암호화되며, 프로토콜 메시지는 TLS 사용 여부와 관계없이 바이트 단위까지 완전히 동일합니다.

인증

인증은 핸드셰이크 과정에서 ClientHello 메시지를 통해 이루어집니다. user 및 password 필드는 평문 문자열로 전송되므로, 전송 중인 자격 증명은 전송 계층 암호화(TLS)로 보호됩니다. 서버는 비어 있는 user 필드를 구성된 기본 세션 사용자로 해석합니다. 기본 세션 사용자는 default_session_user 서버 설정(기본값은 default)으로 지정되며, protocols 섹션에서 리스너별로 재정의할 수 있습니다. 서버에 기본 세션 사용자가 빈 값으로 구성되어 있거나 서버 버전이 26.8 미만이면, 비어 있는 user 필드는 예외와 함께 거부됩니다. 참고로 clickhouse-client는 빈 사용자 이름을 전송하지 않으며, 클라이언트 측에서 default로 대체합니다. SSH 챌린지-응답(challenge-response) 인증은 프로토콜 버전 54466부터 사용할 수 있습니다. 자세한 내용은 SSH 챌린지-응답 인증을 참조하십시오.

서버 간 시크릿

분산 쿼리 실행(distributed query execution) 시 서버들은 공유 시크릿을 wire 상으로 전송하지 않고, 해당 시크릿을 알고 있음을 증명하는 방식으로 서로를 인증합니다. 각 Query는 Query 필드 4에 32바이트 SHA-256 auth_hash를 담고 있습니다. 이 값은 salt, nonce, 구성된 시크릿, 쿼리를 바탕으로 계산되며, 수신 서버는 이 값을 직접 다시 계산하여 비교합니다. 이 동작은 INTERSERVER_SECRET 기능(v54441)이 지원되는 경우에만 사용됩니다. 외부 클라이언트는 이 필드에 항상 빈 문자열을 전송합니다. 자세한 내용은 서버 간 인증을 참고하십시오.

버전 관리 및 기능 게이트

버전 협상(Version negotiation)

클라이언트와 서버는 핸드셰이크 과정에서 각자 지원하는 최대 프로토콜 버전을 알립니다. **협상된 버전(negotiated version)**은 두 버전 중 더 낮은 버전으로 결정됩니다.
이후의 모든 메시지는 협상된 버전(negotiated version)에 따라 wire 상에 어떤 필드가 포함되는지 결정합니다.

기능 게이트(Feature gates)

각 기능은 해당 기능이 도입된 프로토콜 버전으로 식별되며, 협상된 버전(negotiated version)이 그 버전 번호 이상이면 활성화됩니다.
기능이 활성화된 경우 해당 기능의 필드는 wire 상에 반드시 포함되어야 합니다. 프로토콜은 엄격하게 위치 기반(positional)으로 해석되므로, 기능 게이트가 적용된 필드를 생략하면 그 뒤에 오는 모든 필드의 바이트 스트림이 손상됩니다.

기능 표

패킷 엔벌로프

wire 상에서 전송되는 모든 메시지는 송수신 방향과 관계없이 동일한 외부 구조를 가집니다.
전체 패킷 유형 테이블은 패킷 유형 참고에서 확인할 수 있습니다. 패킷 유형은 고정 폭 바이트가 아니라 VarUInt입니다. 값이 128 미만이면 VarUInt로 인코딩해도 동일한 단일 바이트가 생성되지만, 향후 패킷 유형 값이 128 이상이 되더라도 호환성이 유지되도록 구현체는 반드시 VarUInt 인코딩을 사용해야 합니다. 메시지 참고에서는 각 패킷의 body, 즉 패킷 유형 코드 뒤에 오는 바이트만 설명합니다. 필드 번호는 body의 첫 번째 필드를 1로 하여 시작합니다.

청크 프레이밍 (v54470+)

CHUNKED_PROTOCOL 기능이 협상되면(핸드셰이크 참조), wire 상의 모든 패킷은 청크 프레이밍으로 감싸여 전송됩니다. 이 래핑은 방향별로 적용됩니다. 클라이언트→서버 방향과 서버→클라이언트 방향은 각각 따로 협상되므로, 두 방향이 서로 다른 모드(청크 방식 또는 프레이밍 없음)로 동작할 수도 있습니다. 패킷별 wire 레이아웃:
청크별 wire 레이아웃:
패킷 유형 VarUInt은 청크 스트림 내부에 위치합니다. 즉, 프레이밍보다 먼저 별도로 전송되는 바이트가 아니라 패킷 payload의 첫 번째 바이트(첫 번째 청크의 첫 번째 바이트)입니다. 각 패킷의 청크 payload는 패킷 엔벌로프의 [VarUInt packet_type_code][message body] 전체입니다. 클라이언트가 패킷 유형을 청크 스트림 바깥에 두면 피어는 해당 유형 바이트를 u32 청크 크기의 첫 번째 바이트로 읽게 되고, 그 결과 연결의 동기화가 깨집니다. writer의 버퍼가 패킷 중간에 가득 차면 하나의 패킷이 여러 청크로 분할될 수 있습니다. 분할 지점은 패킷 유형의 VarUInt 내부를 포함해 어디든 될 수 있습니다. 리더는 청크 payload를 이어 붙이고, 끝에 붙는 4바이트 0을 투명한 패킷 경계로 처리합니다. 즉, 이 값을 소비하되 패킷 body를 읽는 쪽에는 노출하지 않습니다. body가 없는 패킷도 마찬가지로 래핑됩니다. 청킹이 협상되면 Ping이나 Pong 같은 단일 바이트 패킷은 [u32 size = 1][0x04][u32 0]이 됩니다. 이 페이지의 다른 부분에서 “wire 상의 단일 바이트”로 설명하는 내용은 청킹 이전의 형태를 가리킵니다. 협상. ServerHello와 Addendum은 각각 방향별로 하나씩 총 두 개의 String 필드를 전달하며, 각 값은 {"chunked", "notchunked", "chunked_optional", "notchunked_optional"} 중 하나입니다.
  • chunked / notchunked는 엄격한 값으로, 해당 측이 반드시 그 모드를 사용해야 함을 의미합니다.
  • _optional 변형은 유연한 값으로, 상대 측이 선택한 모드를 그대로 따릅니다.
각 방향의 합의 값은 양측 값을 짝지어 다음과 같이 결정됩니다. 클라이언트 측에서는 클라이언트의 SEND 선호를 서버의 RECV 선호와 맞춰 협상하며, 반대 방향도 같은 방식으로 협상합니다. 타이밍. 협상 문자열은 프레이밍되지 않은 wire로 전송되며, 순서는 ClientHello → ServerHello(서버 선호) → Addendum(클라이언트가 협상한 값)입니다. 프레이밍 전환은 Addendum이 플러시된 이후에 전송되는 모든 바이트부터 적용됩니다. Addendum 자체와 ClientHello, ServerHello는 항상 프레이밍 없이 전송됩니다.

연결 수명 주기

연결은 어느 시점에서든 HANDSHAKE, READY, READING_RESPONSE, 종료의 네 가지 상태 중 정확히 하나에 속합니다. 이 프로토콜은 다중화(multiplexing)를 지원하지 않으므로, 클라이언트가 이전 응답을 모두 읽어 들이기 전에 새 요청을 보내면 wire 상에서 바이트가 뒤섞여 스트림이 손상됩니다.

상태

정상 경로는 HANDSHAKE → READY → READING_RESPONSE → READY 순으로 곧장 이어집니다. Ping/Pong은 자기 루프를 이루며, 모든 실패 경로는 단일 Terminated 싱크로 모입니다.

핸드셰이크 단계

인증을 수행하고 프로토콜 버전을 협상합니다. 연결당 정확히 한 번, 다른 모든 작업보다 먼저 이루어집니다. TCP 연결이 막 수립되었고 아직 메시지가 하나도 교환되지 않은 상태입니다. 흐름은 다음과 같습니다.
  1. 클라이언트는 자신이 지원하는 최대 프로토콜 버전을 담아 ClientHello를 전송합니다.
  2. 클라이언트는 응답을 읽은 후 패킷 유형에 따라 다음과 같이 처리합니다.
  3. negotiated_version ≥ 54458(ADDENDUM 기능)이면 클라이언트는 Addendum을 전송합니다. 이 판단은 클라이언트가 선언한 버전이 아니라 협상된 버전을 기준으로 합니다.
핸드셰이크에 성공하면 연결은 READY 상태로 전환되며, 오류가 발생하면 연결이 종료됩니다.

Ping 단계

TCP keepalive와는 별개로 동작하는 애플리케이션 수준의 활성 상태(liveness) 확인입니다. Ping/Pong 왕복이 성공하면 TCP 연결이 양방향으로 살아 있으며 서버가 정상적으로 응답하고 있음을 확인할 수 있습니다. Ping은 상태를 유지하지 않으며(stateless) 어떤 쿼리와도 연관되지 않으므로, 연속으로 여러 번 보낸 Ping은 서로 독립적입니다. READY 상태에서 시작하는 흐름은 다음과 같습니다.
  1. 클라이언트가 Ping을 전송합니다.
  2. 클라이언트가 응답을 읽습니다.

쿼리 단계

클라이언트가 SQL 문을 전송하면 서버는 결과 블록과 실행 텔레메트리(execution telemetry)를 스트리밍으로 반환합니다. 응답은 일련의 패킷으로 구성되며, 반드시 하나의 EndOfStream 또는 Exception으로 종료됩니다. READY 상태부터의 흐름은 다음과 같습니다. 어느 시점에서든 오류가 발생하면 서버는 EndOfStream 대신 Exception을 전송하며, 이에 따라 쿼리가 종료됩니다.
  1. 클라이언트는 고유한 query_id(일반적으로 UUID)를 포함하여 Query를 전송합니다.
  2. 클라이언트는 외부 테이블(external table)을 모두 전송한 다음, 빈 Data 마커를 전송합니다. 빈 Data 패킷의 값은 table_name = "", num_columns = 0, num_rows = 0입니다. 서버는 이 마커를 수신해야 쿼리 실행을 시작합니다. 외부 테이블은 동일한 table_name을 가진 여러 Data 패킷으로 나뉘어 전송될 수 있습니다. 이때 첫 번째 패킷이 테이블의 스키마를 확정하며, 이후 같은 이름으로 전송되는 모든 패킷은 동일한 컬럼명과 유형을 동일한 순서로 포함해야 합니다. 그렇지 않으면 서버는 INCORRECT_DATA 오류와 함께 쿼리를 거부합니다(Data 참조).
  3. 클라이언트는 READING_RESPONSE 상태로 전환하고 쓰기 버퍼를 플러시합니다.
  4. 클라이언트는 루프를 돌며 응답 패킷을 읽고, 패킷 유형에 따라 처리합니다:
EndOfStream을 수신하거나 Exception을 처리하면 연결은 READY 상태로 돌아갑니다. 프로토콜 위반이나 I/O 오류가 발생하면 연결이 종료됩니다.
num_rows == 0은 처음 구현할 때 흔히 실수하는 부분입니다. 행이 0개인 블록은 경계 마커 또는 스키마 헤더일 뿐, 스트림 종료 신호가 아닙니다. 응답은 EndOfStream 또는 Exception을 받았을 때만 종료됩니다.

INSERT 단계

INSERT 단계는 쿼리 단계에 두 번의 교환이 추가된 형태입니다. 클라이언트가 INSERT SQL 문을 전송하면 서버는 대상 테이블의 구조를 설명하는 **스키마 블록(schema block)**으로 응답합니다. 이어서 클라이언트는 행이 담긴 Data 패킷을 스트리밍한 뒤 빈 Data 마커를 전송하고, 서버는 EndOfStream 또는 Exception으로 처리를 마칩니다. READY 상태에서 시작하며, SQL은 INSERT INTO <table> [(<cols>)] VALUES 형태의 INSERT입니다. 행 데이터는 Data 패킷을 통해 전달되므로 인라인 VALUES (...) 리터럴은 포함하지 않습니다. 전체 흐름은 다음과 같습니다.
  1. 클라이언트는 body를 INSERT SQL로 설정한 Query를 전송합니다.
  2. 클라이언트는 외부 테이블(external table)이 있으면 이를 전송합니다(INSERT에서는 드문 경우입니다). Query 단계와 달리 여기서는 빈 Data 마커를 전송하지 않습니다. INSERT Query 패킷은 보류 중인 데이터가 있는 상태로 전송되므로, 빈 데이터 종료 블록은 5단계로 미뤄집니다. 스키마 블록보다 먼저 이를 전송하면 서버는 이를 행 스트림의 끝으로 해석하여 행 없이 INSERT를 완료한 뒤, 첫 번째 실제 행 패킷을 예상치 못한 최상위 패킷으로 파싱하게 됩니다.
  3. 클라이언트는 스키마 Data 패킷을 읽을 때까지 메타데이터 패킷(TableColumns, Progress, ProfileInfo, Log, ProfileEvents)을 소비합니다. 스키마 Data 패킷은 행은 0개이지만 전체 컬럼 구조(이름과 타입)를 갖춘 Block입니다. 스키마 블록은 일종의 계약으로, 클라이언트가 이후 전송하는 행은 이 컬럼 형태와 일치해야 합니다.
  4. 클라이언트는 데이터 블록을 전송합니다. 각 블록마다 VarUInt(ClientPacket::Data = 2)를 쓰고, 이어서 빈 외부 테이블 이름에 해당하는 String("")을 쓴 다음 Block을 씁니다. 컬럼 타입은 위치 순서대로 스키마 블록의 컬럼과 일치해야 합니다.
  5. 클라이언트는 입력 종료 표시를 전송합니다. 이는 빈 Block(컬럼 0개, 행 0개)을 담은 Data 패킷입니다.
  6. 클라이언트는 EndOfStream(성공) 또는 Exception(실패)을 받을 때까지 응답 스트림을 소비합니다.
비동기 INSERT(v54484+). 쿼리에 async_insert = 1이 포함되면 서버는 행을 큐에 넣었다가 배치 단위로 플러시합니다. 협상된 버전이 54484(PROGRESS_IN_ASYNC_INSERT) 이상이면, 플러시가 완료된 후 서버는 추가 Progress 패킷을 보내고, 곧바로 해당 삽입의 ProfileEvents와 EndOfStream을 차례로 보냅니다. 54484 미만에서는 서버가 이 마지막 Progress를 보내지 않습니다. 이 패킷은 일반적인 Progress이지만, 서버가 쓰기 카운트를 반영하기 전에 쿼리 파이프라인을 재설정하므로 실제로는 증분에 경과 시간만 담깁니다. 기록된 행 수와 바이트 통계는 함께 전송되는 ProfileEvents를 통해 클라이언트에 전달됩니다. 6단계에서 이미 중간에 섞여 들어오는 Progress를 소비하는 클라이언트라면 패킷 하나를 더 받기만 하면 됩니다. 연결은 EndOfStream 또는 처리된 Exception을 받으면 READY 상태로 돌아갑니다. 프로토콜 위반이나 I/O 오류가 발생하면 연결이 종료됩니다.

메시지 참고

필드는 wire 상의 전송 순서대로 나열됩니다. Type 컬럼에는 다음 값이 사용됩니다.
  • VarUInt — 가변 길이 부호 없는 정수입니다(VarUInt 참조).
  • String — VarUInt 길이 접두사가 붙은 바이트열입니다(String 참조).
  • UInt8, Int32 등 — 고정 폭 리틀 엔디언 정수입니다.
  • Bool — 0x00 또는 0x01 값을 갖는 1바이트입니다.
Role 컬럼은 각 필드를 사용하는 주체를 나타냅니다.
  • 클라이언트 — 외부 클라이언트가 설정합니다.
  • 서버 간 — 서버 간 통신에서만 의미가 있으며, 외부 클라이언트는 기본값을 기록합니다.
  • 공통 — 양쪽 모두 사용합니다.
이 표에서는 패킷 유형 코드 다음에 오는 각 패킷의 body만 다룹니다.

ClientHello (패킷 유형 0)

Client → Server. TCP 연결이 수립된 후 전송되는 첫 번째 메시지입니다.

ServerHello (패킷 유형 0)

Server → Client. 인증에 성공하면 ClientHello에 대한 응답으로 전송됩니다. Rule — password_complexity_rules의 요소: 이 목록은 서버 운영자가 구성한 비밀번호 정책을 반영하며 권고용일 뿐입니다. 서버는 핸드셰이크 중에 이 규칙을 강제하지 않습니다. 비밀번호 변경/설정 기능을 제공하는 클라이언트는 이 규칙을 활용해, 규칙에 맞지 않는 비밀번호를 서버로 보내기 전에 오류를 미리 표시할 수 있습니다.
악의적이거나 잘못 구성된 서버로 인한 과도한 리소스 사용을 막으려면 디코딩된 count는 최대 256개 항목으로, 각 pattern 및 message String은 최대 4096바이트로 제한하십시오. 비밀번호 정책이 구성되지 않은 서버에서는 일반적으로 count가 0입니다(뒤따르는 쌍 없음).

Addendum (패킷 유형 없음)

Client → Server 방향이며, ADDENDUM(v54458)으로 게이트됩니다. 핸드셰이크 교환이 완료된 직후에 전송됩니다. 별도의 패킷 유형이 아니므로, 필드는 패킷 유형 바이트 접두사 없이 원시 형태 그대로 wire로 전송됩니다. 청크 프레이밍으로의 전환은 이 Addendum이 플러시된 이후에 적용되며, Addendum 자체는 프레이밍되지 않습니다.

Ping (패킷 유형 4)

Client → Server. body는 없습니다. 청크 프레이밍을 적용하기 전에는 이 패킷이 단일 바이트 0x04로만 구성되며, 청킹(chunking)이 협상되면 이 바이트가 청크의 1바이트 payload가 됩니다(청크 프레이밍 참조).

Pong (패킷 유형 4)

Server → Client. body가 없습니다. 이 패킷은 청크 프레이밍 적용 전에는 단일 바이트 0x04이며, 청킹이 협상되면 이 바이트가 청크의 1바이트 payload가 됩니다(청크 프레이밍 참조).

Exception (패킷 유형 2)

Server → Client. 어느 단계에서든 서버에서 오류가 발생하면 전송됩니다.

Query (패킷 유형 1)

Client → Server.

ClientInfo (Query에 포함)

Client → Server 방향이며, Query body(필드 2)에 포함됩니다. CLIENT_INFO(v54032)부터 사용할 수 있습니다. (ClientInfo 내부의 일부 필드는 아래 필드별로 명시된 것처럼 이후 버전부터 사용할 수 있습니다.)
인터페이스에 따라 달라지는 레이아웃(필드 7–12)위의 필드 7–12는 TCP 분기입니다. query_interface(필드 6)가 TCP가 아닌 경우 이 필드들은 다른 wire 레이아웃으로 대체됩니다. 단순히 선택적 필드가 생략되는 것이 아니므로, 디코더는 필드 6의 값에 따라 분기해야 합니다.
  • query_interface = 2(HTTP): 대신 서버가 전달한 HTTP 요청 정보가 기록됩니다. 순서는 http_method(UInt8), http_user_agent(String), forwarded_for(String, X_FORWARDED_FOR_IN_CLIENT_INFO v54443 게이트 적용), http_referer(String, REFERER_IN_CLIENT_INFO v54447 게이트 적용)이며, 마지막으로 http_handler_name(String)과 http_request_url(String)이 이 순서대로 기록됩니다(둘 다 HTTP_HANDLER_IN_CLIENT_INFO v54490 게이트 적용). os_user/client_hostname/client_name/version_*/protocol_version 필드는 포함되지 않습니다. 마지막 두 필드는 매칭된 SQL 정의 HTTP 핸들러 이름과 요청 URL을 담고 있어 원격 세그먼트까지 그대로 전달됩니다(핸들러를 사용하지 않는 HTTP 쿼리에서는 비어 있음).
  • 그 밖의 인터페이스: TCP 필드(7–12)와 HTTP 필드 모두 기록되지 않으며, 스트림은 곧바로 quota_key로 이어집니다.
이 분기가 끝나면 레이아웃은 다시 하나로 합쳐집니다. 모든 인터페이스에서 quota_key(필드 13)와 distributed_depth(필드 14)가 이어지고, version_patch(필드 15)는 TCP에서만 기록됩니다.이 분기는 주로 서버 간 트래픽, 즉 시작 서버(initiating server)가 원래 HTTP로 수신한 쿼리를 전달하는 상황에서 중요합니다. 항상 TCP 필드를 읽는 디코더는 이러한 패킷을 잘못 해석하여 http_method나 http_user_agent를 quota_key로 처리하게 됩니다.
OpenTelemetry 인코딩(필드 16):

서버 간 인증

Query 필드 4(auth_hash)는 wire 상에서 공유 클러스터 시크릿(cluster secret) 자체가 아닙니다. 원시 시크릿을 그대로 전송하면 인증에 실패할 뿐만 아니라 시크릿도 유출됩니다. 대신 서버 간 클라이언트로 동작하는 서버는 솔트를 적용한 SHA-256 해시로 자신이 시크릿을 알고 있음을 증명합니다.
  1. 서버 간 모드 진입. 연결하는 서버는 ClientHello 안에서 이를 알립니다. 이때 user 필드에는 서버 간 마커가 들어가고 password는 비어 있습니다. 이어서 같은 ClientHello 패킷 안에서 user/password 필드 바로 뒤에 두 개의 문자열, 즉 클러스터 이름과 새로 생성한 32바이트 salt(임의 값의 encodeSHA256)를 추가합니다. 서버는 ServerHello를 보내기 전에 이 두 문자열을 읽으므로 클라이언트는 이를 미리 기록해야 합니다. ServerHello를 먼저 기다리면 서버가 이 문자열을 읽느라 대기 상태에 있으므로 교착 상태(deadlock)가 발생합니다.
  2. 논스 획득. INTERSERVER_SECRET_V2(v54462)가 협상되면 ServerHello에 8바이트 UInt64 논스가 포함됩니다.
  3. 해시 계산. InitialQuery가 아닌 모든 Query 패킷에 대해 클라이언트는 필드 4에 encodeSHA256(salt + nonce + cluster_secret + query + query_id + initial_user + external_roles + current_roles), 즉 32바이트 다이제스트를 기록합니다. (nonce는 10진수 문자열 형식이며 v54462 이상으로 협상된 경우에만 포함됩니다. external_roles는 INTERSERVER_EXTERNALLY_GRANTED_ROLES(v54472)가 협상된 경우에만 추가됩니다. current_roles는 직렬화된 역할 이름 목록(존재 여부 바이트를 제외한 [VarUInt count][String]*count)이며, INTERSERVER_CURRENT_ROLES(v54488)가 협상되고 목록이 있는 경우에만 추가됩니다.) InitialQuery이거나 클러스터 시크릿이 구성되지 않은 경우에는 클라이언트가 빈 문자열을 대신 기록합니다.
  4. 검증. 서버는 최대 32바이트까지 필드 4를 읽고, 자신이 보유한 클러스터 시크릿으로 동일한 값을 이어 붙여 해시를 다시 계산합니다. 다이제스트가 일치하지 않으면 연결을 거부합니다.
외부(서버 간이 아닌) 클라이언트는 이 모드에 진입하지 않으며 항상 빈 auth_hash를 전송합니다.

Setting

Query body의 설정 목록(Query 패킷의 필드 3)에 인라인으로 인코딩됩니다. 이 목록은 협상된 버전과 관계없이 항상 존재하며, 키가 비어 있는 Setting으로 끝납니다. 이 종료 표시는 단일 VarUInt 0이며, 그 뒤에는 flags나 value가 오지 않습니다. 협상된 버전에 따라 달라지는 것은 설정별 인코딩 방식뿐이며, 이는 SETTINGS_SERIALIZED_AS_STRINGS(v54429)로 결정됩니다. v54429+ (STRINGS_WITH_FLAGS) — 각 설정은 다음 3개 항목으로 구성됩니다. key가 비어 있으면 필드 2와 3은 존재하지 않습니다. Pre-54429 (BINARY) — 각 설정은 [String key][type-specific binary value] 형식입니다. flags 필드는 기록되지 않으며, 값은 10진수/텍스트 문자열이 아니라 해당 설정의 네이티브 바이너리 형식(예: 고정 폭 정수 또는 길이 접두사가 붙은 문자열)으로 인코딩됩니다. 이 경우에도 목록은 빈 key로 끝납니다. 협상된 버전이 54429 미만인 환경을 대상으로 하는 클라이언트는 위의 3개 항목 형식이 아니라 이 바이너리 형식으로 읽고 써야 합니다. (단, 사용자 정의 설정은 예외로, 두 인코딩 모두에서 항상 flags와 문자열 값을 포함합니다.) flags 필드에는 다음 값이 포함됩니다.
  • 0x01 — Important: 쿼리 결과에 영향을 주는 설정이므로 이전 버전의 피어가 이를 임의로 무시해서는 안 됩니다.
  • 0x02 — Custom: 사용자 지정 설정입니다.
  • 0x0c — 독립적인 플래그가 아니라 2비트 등급(tier) 필드입니다: 0x00 = Production, 0x04 = Obsolete, 0x08 = Experimental, 0x0c = Beta. 반드시 2비트 전체(flags & 0x0c)를 읽어야 합니다. 단순히 flags & 0x04로 검사하면 Beta(0x0c)를 Obsolete로 잘못 분류하게 됩니다.
  • 0x80 — HotReload(재시작 없이 구성을 다시 로드함. flags enum에 정의되어 있으며, 주로 조정(coordination) 설정에서 사용됩니다).

Parameter

SELECT {x:UInt64}와 같은 매개변수화된 쿼리에 사용되는 쿼리 매개변수입니다. Custom 플래그(0x02)가 설정된 Setting과 동일한 방식으로 인코딩되며, 마찬가지로 빈 키로 종료됩니다.
매개변수 값은 원시 리터럴이 아니라 값의 SQL 표현입니다. String 타입 매개변수는 작은따옴표로 감싼 형태로 전달해야 합니다(예: {name:String}의 값은 Alice가 아니라 'Alice'). 그렇지 않으면 서버의 값 파서가 해당 값을 거부합니다.

데이터(Data) (패킷 유형 1 서버→클라이언트, 패킷 유형 2 클라이언트→서버)

양방향으로 사용됩니다. 결과 블록, INSERT 데이터, 외부 테이블(external table), 데이터 종료 마커를 전달합니다. wire 형식은 양방향이 대칭입니다. 두 방향 모두 Block 앞에 table_name 접두사가 붙으며, 패킷 유형 바이트만 다릅니다.
데이터 종료 마커는 table_name과 관계없이 블록이 비어 있는(컬럼 0개, 행 0개) 패킷입니다. 서버는 디코딩된 블록이 비어 있을 때(block.empty())에만 클라이언트 Data 패킷을 종료 신호로 처리합니다. table_name = ""이더라도 블록이 비어 있지 않은 패킷은 종료 신호가 아닌 일반 행 패킷입니다. 따라서 INSERT 행 스트림은 비어 있지 않은 Data 블록의 시퀀스와 그 끝을 알리는 하나의 빈 Data 블록으로 구성됩니다. 블록 변형과 각각의 의미는 블록 변형을 참조하십시오.

Progress (패킷 유형 3)

Server → Client. 쿼리 실행 중에 주기적으로 전송됩니다. 모든 필드는 VarUInt이며, 각 패킷에는 누적 합계가 아니라 이전 Progress 패킷 이후의 증가분이 담깁니다. 서버는 전송하기 전에 카운터를 읽은 뒤 원자적으로 0으로 초기화하고, 마지막 전송 이후 경과한 시간을 elapsed_ns로 계산합니다. 따라서 클라이언트는 현재까지의 합계를 구하려면 연속된 패킷을 로컬에서 반드시 누적해야 합니다. 패킷 값을 절댓값으로 취급하면 패킷이 두 개 이상 도착하는 순간부터 진행 상황 표시가 뒤로 되돌아가거나 실제보다 적게 집계됩니다.

ProfileInfo (패킷 유형 6)

Server → Client. 쿼리당 한 번, 실행이 끝나갈 무렵 전송됩니다.

Totals (패킷 유형 7)

Server → Client. WITH TOTALS를 사용하는 쿼리에 대해 전송됩니다. wire 형식은 Data와 동일하며, table_name 문자열(항상 비어 있음) 뒤에 Block이 이어지는 구조입니다. 패킷 유형 바이트만 다릅니다.

Extremes (패킷 유형 8)

Server → Client. extremes 설정이 활성화되면 전송됩니다. wire 형식은 Data와 동일합니다. 블록은 정확히 2개의 행으로 구성되며, 0번 행에는 각 컬럼의 최솟값이, 1번 행에는 최댓값이 담깁니다.

Log (패킷 유형 10)

Server → Client. 쿼리에 활성 로그 큐가 있을 때 전송됩니다(send_logs_level 설정으로 지정되며, 자세한 내용은 로그 스트리밍을 참고하십시오). Data와 동일한 엔벌로프 및 body 포맷을 사용합니다. 블록은 num_columns = 8로 고정되어 있으며 미리 정의된 스키마를 따릅니다. 각 로그 라인은 8개 컬럼 전체에 걸친 하나의 행에 해당하며, 하나의 Log 패킷에 여러 행이 담길 수 있습니다.
8개의 컬럼은 정확히 다음 순서로 구성됩니다.

ProfileEvents (패킷 유형 14)

Server → Client. 쿼리별 성능 카운터를 전달합니다. 엔벌로프와 body 포맷은 Data와 동일합니다. 블록은 num_columns = 6으로 고정되어 있으며 미리 정의된 스키마를 사용합니다. 각 이벤트는 하나의 행에 해당합니다.
다음 6개의 컬럼으로 구성됩니다.
value 컬럼의 원소 타입은 패킷마다 동일하지 않습니다. 이전 버전 서버는 UInt64를, 최신 버전 서버는 Int64를 전송합니다. 특정 크기를 가정하지 말고 블록 헤더에서 해당 컬럼의 타입 문자열을 읽으십시오.

TableColumns (패킷 유형 11)

Server → Client 방향이며, COLUMN_DEFAULTS_METADATA(v54410)로 활성화됩니다. 서버는 컬럼 기본값 메타데이터를 전달하기 위해 INSERT 스키마 블록보다 먼저 이 패킷을 전송합니다. 단, 협상된 버전(negotiated version)이 54410 이상이고 동시에 input_format_defaults_for_omitted_fields 설정이 활성화된 경우에만 전송합니다. 54410 미만에서는 이 패킷이 전송되지 않으므로, 이전 버전의 클라이언트는 이 패킷을 기다려서는 안 됩니다. 이 경우 스키마 Data 블록이 바로 전송됩니다. v54410 이상의 클라이언트는 두 가지 순서에 모두 대비해야 합니다. 즉, 선택적으로 TableColumns가 먼저 오고 그다음에 스키마 블록이 올 수 있습니다.
v54481 이상에서 압축되는 body협상된 버전이 54481 이상(COMPRESSED_LOGS_PROFILE_EVENTS_COLUMNS)이면 서버는 두 필드를 모두 동일한 선택적 압축 출력 경로로 기록합니다. 따라서 쿼리에 compression = true가 설정된 경우 TableColumns body 전체(external_table + columns_description)가 압축 프레임 안에 포함되며, 클라이언트는 이에 대응하는 압축 해제 스트림을 통해 body를 읽습니다. 쿼리에 압축이 적용되지 않으면 body는 위 표에 나온 그대로 압축되지 않은 상태로 전송됩니다. 이는 INSERT 스키마 응답에서 특히 중요합니다. Log와 ProfileEvents에만 압축 처리를 전환하고 TableColumns에는 전환하지 않는 클라이언트는 쿼리 압축이 활성화된 경우 응답을 잘못 읽게 됩니다.

TimezoneUpdate (패킷 유형 17)

Server → Client 방향이며, TIMEZONE_UPDATES(v54464) 게이트가 적용됩니다. 이 패킷이 전송되는 곳은 단 한 곳, 바로 input 테이블 함수의 초기화 과정입니다(클라이언트로부터 행을 스트리밍하는 INSERT INTO <table> SELECT ... FROM input('<structure>') 형태의 쿼리). 서버는 입력 스키마 Data 블록을 전송한 직후(INSERT 단계 참조), 쿼리 Context의 현재 session_timezone을 담은 TimezoneUpdate를 전송합니다. 이를 통해 클라이언트는 곧 전송할 행을 동일한 시간대 기준으로 파싱할 수 있습니다. 쿼리 도중 임의로 실행된 SET session_timezone 변경에 대해서는 서버가 이 패킷을 전송하지 않으며, 이후 결과 블록의 포맷 방식을 클라이언트에 알리는 용도로도 사용되지 않습니다. 이 패킷은 입력 스키마 블록 직후, 클라이언트가 행 블록 전송을 시작하기 전에 한 번만 도착합니다. TimezoneUpdate를 무시하는 디코더라도 wire 정렬을 유지하려면 뒤따르는 String을 반드시 읽어서 소비해야 합니다.

SSH 챌린지-응답 인증 (패킷 유형 11, 12, 18)

SSH_AUTHENTICATION(v54466)으로 제어되며, 명시적으로 활성화한 경우에만 사용됩니다. ClientHello가 user = " SSH KEY AUTHENTICATION " + <real_user>(앞뒤 공백 포함)와 password = ""를 전송하면 연결이 SSH 흐름으로 진입합니다. 서버는 접두사를 읽은 뒤 이를 제거하여 실제 사용자를 복원하고, 챌린지-응답 방식으로 전환합니다. 이 흐름은 비밀번호 인증 대신 실행되며, 챌린지-응답 교환은 ServerHello 이전에 이루어집니다. 서버는 인증에 성공할 때까지 Hello 응답을 보내지 않습니다.
  1. 클라이언트는 SSH 마커 접두사와 빈 비밀번호를 포함한 ClientHello를 전송합니다.
  2. 클라이언트는 SSHChallengeRequest(패킷 11)를 전송합니다. 이 시점에 서버는 아직 ServerHello를 전송하지 않은 상태이며, 인증을 먼저 처리하기 위해 이 패킷이 도착할 때까지 대기합니다.
  3. 서버는 무작위 바이트를 담은 SSHChallenge(패킷 18)로 응답합니다.
  4. 클라이언트는 서명할 문자열을 구성한 뒤 원시 챌린지가 아닌 이 문자열에 서명하고, 서명을 담은 SSHChallengeResponse(패킷 12)를 전송합니다. 서명 대상 메시지는 다음 4개 파트를 정확히 이 순서대로, 구분자 없이 바이트 단위로 연결한 것입니다.
  5. 서버는 동일한 decimal(protocol_version) + default_database + user + challenge 문자열을 재구성한 뒤, 사용자에게 등록된 공개 키로 서명을 검증합니다. 검증에 성공하면 비밀번호 흐름과 동일한 응답인 ServerHello를 전송하고, 이후 핸드셰이크가 정상적으로 진행됩니다(Addendum 등). 실패하면 Exception을 반환하고 연결을 종료합니다. 원시 챌린지 바이트에만 서명하는 클라이언트는 인증에 실패합니다.
이는 ServerHello가 ClientHello 직후에 바로 전송되는 비밀번호 핸드셰이크와는 반대 순서입니다. SSH auth에서는 서명 검증이 완료될 때까지 ServerHello 전송이 보류되므로, ServerHello가 전송되기 전에 SSH challenge-response 과정이 핸드셰이크 중간에 삽입됩니다.
SSH auth를 사용하지 않는 외부 클라이언트는 패킷 11, 12, 18을 전혀 받지 않습니다. 사용자가 username 접두사를 통해 명시적으로 사용하도록 선택하지 않는 한, 이 패킷들은 wire상에 전송되지 않습니다.

MergeTreeAllRangesAnnouncementResponse (패킷 유형 14)

Client → Server, 서버 간 통신 전용입니다. parallel_replicas_protocol_version ≥ 8 조건으로 게이트됩니다(VERSIONED_PARALLEL_REPLICAS_PROTOCOL 참고). 외부 클라이언트는 이 패킷을 전송하지 않습니다. 협상된 병렬 레플리카 버전이 ≥ 8이면, 팔로워가 보낸 MergeTreeAllRangesAnnouncement(패킷 유형 15, server→client 방향)에 대한 initiator의 요청/응답 사이클이 다음과 같이 달라집니다.
  1. 팔로워가 읽기 파이프라인을 열고 initiator에 MergeTreeAllRangesAnnouncement를 전송합니다.
  2. announcement의 mode가 Default가 아닐 때만(WithOrder = 1 또는 ReverseOrder = 2이며, 둘 다 순서 보장 병렬 읽기에 사용됨) initiator가 MergeTreeAllRangesAnnouncementResponse로 응답합니다. mode = Default = 0이면 initiator는 응답하지 않으며 팔로워도 대기하지 않습니다. Default 모드에서는 MergeTreeReadTaskRequest마다 범위를 할당하므로 파트 목록을 미리 받을 필요가 없습니다.
  3. 팔로워는 첫 번째 MergeTreeReadTaskRequest(서버 패킷 16 — 팔로워→initiator로 전송되며, initiator는 클라이언트 패킷 10인 MergeTreeReadTaskResponse로 응답함)를 보내기 전에 응답을 기다리며(응답이 예정된 경우), 반환된 파트 목록을 바탕으로 자신의 #split_i 스트림이 소유한 파트만 대상으로 소스를 생성합니다.
버전 8 미만에서는 모드와 관계없이 announcement를 fire-and-forget 방식으로 전송하며, 팔로워는 로컬에서 인식하는 모든 파트를 대상으로 소스를 생성합니다(기존 방식).

Body

RangesInDataPartsDescription body

RangesInDataPartDescription body

MergeTreePartInfo body

MarkRanges body

패킷 유형 참고

Client → Server

Server → Client

QueryPlan

QueryPlan은 서버 간 통신에서만 사용되는 클라이언트 패킷입니다. stage가 QueryPlan(7)인 Query 다음에 전송되며, 직렬화된 계획 스트림 1개를 포함합니다. 버전 10 이전에는 필드 2와 3이 없습니다. 실행 제한 중 하나라도 기본값이 아닌 initiator는 이전 레이아웃을 전송해서는 안 됩니다. 대신 원본 SQL 쿼리를 전송하여, 이전 버전의 수신 측이 쿼리 설정에서 제한을 재구성할 수 있도록 해야 합니다.

구성

이 섹션에서는 native protocol 연결의 동작을 결정하는 조정 가능한 매개변수를 다룹니다. 아래의 기본값은 최근 서버 릴리스를 기준으로 하며, 버전 및 배포 환경에 따라 다를 수 있습니다.

전송 계층 설정

소켓 옵션

타임아웃

타임아웃은 다음과 같이 중첩됩니다.
OS keepalive가 가장 먼저 동작하며, 커널 수준에서 응답하지 않는 피어를 별도의 알림 없이 감지할 수 있습니다. 그다음 방어선은 애플리케이션의 수신 타임아웃입니다. 유휴 타임아웃은 오랫동안 사용되지 않은 연결을 회수하는 최후의 수단입니다.

연결 제한

쿼리를 주기적으로 실행하는 연결은 무기한 유지될 수 있습니다. 1시간 동안 유휴 상태인 연결만 정리되며, 기본적으로 최대 연결 수명 제한은 없습니다.

애플리케이션 계층 설정

이 설정들은 Query 패킷의 설정 목록에 포함되어 쿼리별로 전달됩니다. 이를 통해 서버가 wire로 전송하는 내용이나 메시지의 프레이밍 방식이 달라집니다.

압축

Query 패킷의 compression 플래그(필드 6)는 압축 사용 여부를 결정하며, 위 설정은 압축이 켜져 있을 때 사용할 코덱을 지정합니다.

로그 스트리밍

send_logs_level을 "none" 이외의 값으로 설정하면 서버는 쿼리 실행 중에 Log 패킷을 전송합니다.

진행 상황 보고

이 값은 엄격한 최댓값이 아니라 목표 최솟값입니다. 쿼리의 작업 처리 속도가 충분히 빠르지 않으면 서버는 Progress 패킷을 이보다 드물게 전송할 수 있습니다.

결과 엔벌로프

비동기 INSERT

분산 추적(Distributed tracing)

범위 외 설정

다음 설정은 프로토콜 수준 설정으로 오해받기도 하지만, 실제로는 wire 동작이 아니라 SQL 실행, 저장소 또는 CPU 사용을 제어합니다. 프로토콜 구현에서 이 설정들을 별도로 처리할 필요는 없습니다.
  • max_threads — 쿼리 실행 시의 병렬 처리 수준입니다.
  • max_memory_usage — 쿼리별 메모리 사용 상한입니다.
  • max_block_size, preferred_block_size_bytes — 쿼리 처리 중 내부 블록 크기를 조정하며, wire 블록은 이 설정과 무관합니다.
  • compile_expressions — JIT 컴파일 설정으로, CPU에만 영향을 줍니다.
  • async_insert_max_data_size — 서버 측 큐 버퍼 설정입니다.
  • input_format_native_* / output_format_native_* 계열을 제외한 모든 input_format_* 및 output_format_* 설정 — native가 아닌 설정은 다른 포맷(예: HTTP에서 사용하는 포맷)을 선택하거나 조정할 뿐, native protocol의 Data 블록에는 영향을 주지 않습니다.
단, *_native_* 설정은 예외입니다. 이 설정들은 native TCP Data 블록 내부의 바이트를 변경하므로 프로토콜 구현에서 반드시 고려해야 합니다. output_format_native_encode_types_in_binary_format은 컬럼의 type 필드를 텍스트 문자열 대신 바이너리 타입 인코딩으로 바꾸고(단, 보조 쿼리에는 적용되지 않습니다. 컬럼 wire 레이아웃 참조), output_format_native_write_json_as_string은 JSON 컬럼을 String으로 출력하며, output_format_native_use_flattened_dynamic_and_json_serialization은 FLATTENED Dynamic/JSON 레이아웃을 선택합니다. 이 설정들은 패킷 엔벌로프가 아닌 블록 body에 영향을 주므로 Native Format 명세에서 다룹니다. 자세한 내용은 컬럼 wire 레이아웃 및 버전이 지정된 타입을 참조하십시오.

용어집

Cancel — 클라이언트가 보내는 패킷(유형 3)으로, 실행 중인 쿼리를 중단합니다. 이 페이지에서는 자세히 다루지 않습니다. End-of-client-data marker — 클라이언트가 입력 스트림을 닫기 위해 보내는 빈 Data 패킷(컬럼 0개, 행 0개)입니다. 전송 위치는 쿼리 종류에 따라 다릅니다.
  • 일반 쿼리(SELECT 등): Query 패킷과 외부 테이블 Data 패킷 뒤에 전송되어 “더 이상 외부 데이터가 없음”을 알립니다. 이후 서버가 쿼리 실행을 시작합니다.
  • INSERT: 클라이언트는 스키마 블록 이전에 마커를 보내지 않습니다. 서버가 먼저 스키마 블록을 보내면 클라이언트가 행 Data 블록을 스트리밍하고, 그런 다음에야 빈 Data 패킷을 보내 행 스트림을 종료합니다. 스키마 블록보다 먼저 빈 마커를 보내면 곧바로 행이 끝난 것으로 해석되어 데이터가 손실됩니다.
Feature — 특정 프로토콜 버전에서 도입된 wire 포맷 변경 사항입니다. 협상된 버전이 해당 기능의 버전 이상이면 활성화됩니다. 버전 관리 및 기능 게이트를 참조하십시오. Inter-server — 서버 간 분산 쿼리에서만 의미가 있는 필드에 붙는 역할 레이블입니다. 외부 클라이언트는 기본값(보통 빈 문자열, 0 또는 false)을 기록합니다. Negotiated version — 핸드셰이크 과정에서 계산되는 min(client_version, server_version) 값입니다. 연결이 유지되는 동안 활성화되는 기능을 결정합니다. Packet — wire 메시지로, VarUInt 패킷 유형 코드 뒤에 유형별로 포맷이 다른 body가 이어집니다. 패킷 엔벌로프를 참조하십시오. Packet type code — 패킷 맨 앞에 위치하여 패킷의 포맷을 식별하는 VarUInt입니다. 현재 0–18 값이 할당되어 있습니다. 패킷 유형 참고를 참조하십시오. Response stream — 쿼리 실행 중 서버가 내보내는 패킷 시퀀스입니다. 길이는 정해져 있지 않으며, 정확히 하나의 EndOfStream(성공) 또는 Exception(실패)으로 종료됩니다. 쿼리 단계를 참조하십시오. Schema block — INSERT 단계에서 클라이언트가 데이터를 보내기 전에 서버가 예상 컬럼 구조를 알리기 위해 보내는 헤더 블록(컬럼은 있지만 행이 0개인 Block)입니다. Settings list — Query body에 포함된 (key, flags, value) 튜플의 시퀀스로, 빈 키로 종료됩니다. 쿼리별 애플리케이션 계층 구성을 전달합니다. Setting을 참조하십시오. Stage — Query 패킷의 VarUInt 필드(필드 5)로, 서버가 쿼리를 어느 단계까지 실행할지 제어합니다. 외부 클라이언트는 일반적으로 2(Complete)를 보내며, 분산 쿼리와 직렬화된 쿼리 계획에서는 더 높은 값을 사용합니다. 전체 wire 값 목록은 Query 필드 5를 참조하십시오. Terminator — 스트림을 종료하는 패킷입니다. Query 응답은 EndOfStream(성공) 또는 Exception(실패)으로 끝나고, 클라이언트의 입력 스트림은 빈 Data 마커로 끝납니다.