메인 콘텐츠로 건너뛰기
clickhouse-c는 ClickHouse native protocol을 위한 헤더 전용 C 클라이언트입니다. 소스 코드와 각 헤더에 대한 참고 문서는 GitHub 리포지토리에 있습니다. 상위 수준의 클라이언트와 달리, 이 라이브러리는 의도적으로 많은 기능을 대신 처리하지 않습니다. 핵심 헤더는 사용자가 제공하는 I/O 콜백을 통해 Native 형식의 블록을 디코드하고 인코드합니다. 소켓, TLS Context, allocator, 재시도, 연결 풀링은 직접 관리해야 합니다. 따라서 내장하기에 충분히 작습니다. clickhouse.h만 포함하면 링크 시점 의존성은 libc 외에 없습니다.
이 라이브러리는 현재 활발히 개발되고 있습니다. v1은 핵심 ClickHouse 타입을 디코드합니다. 제한 사항이나 누락된 기능은 issue tracker를 통해 보고하십시오. 다만 이 라이브러리는 설계상 일부 기능이 의도적으로 제외되어 있다는 점을 이해하시기 바랍니다.

라이브러리가 하지 않는 일

다음은 의도적으로 지원 범위에 포함하지 않은 항목입니다. 이러한 항목은 애플리케이션이나 함께 사용하는 다른 라이브러리에서 처리하십시오:
  • HTTP 프로토콜. HTTP 인터페이스에는 libcurl을 직접 래핑하십시오.
  • DNS 확인, 엔드포인트 페일오버, 연결 풀링, retry, 백오프.
  • TLS 컨텍스트 수명 주기. OpenSSL 백엔드는 이미 연결된 SSL을 사용합니다.
  • 스레딩. 각 chc_client는 설계상 단일 스레드입니다.
  • 라이브러리 내부의 비동기 I/O. 블로킹 클라이언트 호출은 chc_io.read를 동기적으로 호출합니다. 자체적으로 I/O를 수행하지 않는 이벤트 루프 클라이언트가 필요하면 ioless client를 사용하십시오.

라이브러리 구성 방식

clickhouse-c는 평면적인 헤더 집합 형태로 제공됩니다. 각 헤더에는 선언과 구현이 모두 들어 있으며, 센티널 매크로로 보호됩니다. 빌드에 필요한 헤더를 선택하십시오.
헤더용도링크 플래그
clickhouse.h코어: 타입, 오류, allocator, I/O vtable, 타입 이름 parser, block reader, writer
clickhouse-client.hTCP 패킷 루프: Hello, Query, Data, EndOfStream, Exception, Progress, Pong
clickhouse-async.hI/O 없는 클라이언트: 호출자가 바이트를 넘겨 구동하는 동일한 패킷 루프, 소켓 없음
clickhouse-compression.hcompressed-frame layout, CityHash128, 코덱 디스패치, LZ4/ZSTD 어댑터-llz4 -lzstd
clickhouse-posix-io.h블로킹 read(2)/write(2) 기반 I/O 백엔드
clickhouse-openssl.hSSL_read/SSL_write 기반 I/O 백엔드-lssl -lcrypto

필수 서버 설정

디코더는 wire에서 출력 가능한 타입 이름을 읽으므로, 텍스트로 인코딩되어야 합니다. ClickHouse는 기본적으로 이를 텍스트로 기록하지만, 서버 또는 세션 프로필에서 이를 binary로 설정해도 디코딩이 깨지지 않도록 쿼리에 이 설정을 명시적으로 지정하십시오:
output_format_native_encode_types_in_binary_format = 0

프로젝트에 추가하기

설치할 패키지는 없으므로, Git submodule 또는 복사를 사용해 헤더 파일을 프로젝트 트리에 직접 포함해야 합니다. 정확히 하나의 번역 단위만 CHC_IMPLEMENTATION을 정의해 구현을 포함하고, 나머지 모든 단위는 선언만 위해 동일한 헤더를 포함합니다.
/* clickhouse_impl.c */
#define CHC_IMPLEMENTATION
#include "clickhouse.h"
#include "clickhouse-posix-io.h"
#include "clickhouse-client.h"
#include "clickhouse-compression.h"
/* every other TU */
#include "clickhouse.h"
#include "clickhouse-client.h"
chc_alloc_stdlib를 사용하려면 clickhouse.h를 포함하기 전에 CHC_PROVIDE_STDLIB_ALLOC를 정의하십시오. clickhouse-compression.h에서 lz4/zstd 의존성을 제외하려면 CHC_NO_LZ4 또는 CHC_NO_ZSTD를 정의하십시오.

TCP를 통한 연결

ClickHouse 서버와 통신하려면 소켓을 직접 구성하고 이를 chc_io로 감싼 후 chc_client_init에 전달해야 합니다. 그러면 Hello 핸드셰이크를 동기적으로 수행합니다. 이 라이브러리는 DNS, 페일오버, 재연결, 풀링을 처리하지 않으므로 이러한 부분은 호출자 측에서 처리해야 합니다.
int fd = socket(AF_INET, SOCK_STREAM, 0);
int one = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one);

struct sockaddr_in sa = {};
sa.sin_family      = AF_INET;
sa.sin_port        = htons(9000);
sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
connect(fd, (struct sockaddr *) &sa, sizeof sa);

chc_alloc al = chc_alloc_stdlib();
chc_posix_io state;
chc_io io;
chc_posix_io_init(&state, &io, fd, NULL, NULL);

chc_client *client = NULL;
chc_client_opts opts = {
    .user     = "default",
    .password = "",
    .database = "default",
};
chc_err err = {};
if (chc_client_init(&client, &opts, &al, &io, &err) != CHC_OK) {
    fprintf(stderr, "connect: %s\n", err.msg);
    chc_client_close(client);   /* safe to call on the NULL-on-failure handle */
    return 1;
}

const chc_server_info *info = chc_client_server_info(client);
printf("connected to %s %llu.%llu.%llu\n", info->display_name,
       (unsigned long long) info->version_major,
       (unsigned long long) info->version_minor,
       (unsigned long long) info->version_patch);
chc_client는 단일 스레드 방식으로 동작하며 하나의 연결만 래핑합니다. 라이브러리는 chc_io 콜백을 동기적으로 호출하며, 해당 콜백이 내부에서 (epoll, io_uring, WaitLatchOrSocket) 어떻게 처리할지는 사용자 구현에 달려 있습니다.

쿼리 실행

쿼리를 전송한 다음 CHC_PKT_END_OF_STREAM이 나올 때까지 패킷을 계속 비우십시오. chc_client_send_query_ex를 사용해 필수 서버 설정을 함께 지정하십시오. 기본 chc_client_send_query는 빈 설정 목록을 전송하며 서버 기본값을 그대로 따릅니다.
chc_query_setting settings[] = {
    { .name = "output_format_native_encode_types_in_binary_format", .value = "0" },
};
chc_query_opts qopts = { .settings = settings, .n_settings = 1 };

const char *sql = "SELECT number, toString(number * number) FROM numbers(5)";
if (chc_client_send_query_ex(client, sql, strlen(sql), &qopts, &err) != CHC_OK) {
    fprintf(stderr, "query: %s\n", err.msg);
    return 1;
}

for (;;) {
    chc_packet pkt = {};
    if (chc_client_recv_packet(client, &pkt, &err) != CHC_OK) {
        fprintf(stderr, "recv: %s\n", err.msg);
        break;
    }

    if (pkt.kind == CHC_PKT_DATA) {
        for (size_t r = 0; r < chc_block_n_rows(pkt.block); r++)
            for (size_t c = 0; c < chc_block_n_columns(pkt.block); c++)
                print_value(chc_block_column_type(pkt.block, c),
                            chc_block_column(pkt.block, c), r);
    } else if (pkt.kind == CHC_PKT_EXCEPTION) {
        fprintf(stderr, "server: %s\n", pkt.exception->display_text);
    }

    bool done = pkt.kind == CHC_PKT_END_OF_STREAM;
    chc_packet_clear(client, &pkt);
    if (done) break;
}
서버 예외는 chc_client_recv_packet에서 non-OK를 반환하는 형태가 아니라 CHC_PKT_EXCEPTION 패킷으로 도착합니다. OK가 아닌 반환은 전송 계층 수준의 실패에서만 발생합니다. 결과의 첫 번째 CHC_PKT_DATA 패킷은 스키마를 설명하는 헤더 블록이며 행은 0개이고, 그 뒤에 데이터 블록이 이어집니다. 대신 소유권을 가져오려면 먼저 패킷의 해당 필드를 NULL로 설정해야 하며, chc_packet_clear는 패킷의 블록 또는 예외를 해제합니다.

컬럼 데이터 읽기

블록은 컬럼 지향입니다. 각 컬럼에는 chc_column_layout에서 반환하는 물리적 레이아웃이 있으며, 해당 레이아웃에 따라 분기 처리합니다. 선언된 유형은 chc_block_column_type에서 가져옵니다. 복합 레이아웃은 중첩될 수 있으므로, Nullable(Array(String))를 읽으려면 먼저 Nullable을 벗기고, 배열 오프셋을 따라간 다음, 문자열 데이터를 슬라이싱해야 합니다.
레이아웃Accessor
CHC_COL_FIXEDchc_column_fixed_data(c, &elem_size)n_rows * elem_size 크기의 리틀 엔디언 바이트
CHC_COL_STRINGchc_column_string_data(c), chc_column_string_offsets(c)offsets[i]는 호스트 바이트 순서에서 행 i의 배타적 끝 위치이며, 행 0은 0에서 시작합니다
CHC_COL_NULLABLEchc_column_null_map(c) (행당 1바이트, 1 = NULL), chc_column_nullable_inner(c)
CHC_COL_ARRAYchc_column_array_offsets(c) (누적 끝 위치), chc_column_array_values(c); MapArray(Tuple(K, V))로 디코딩됩니다
CHC_COL_TUPLEchc_column_tuple_arity(c), chc_column_tuple_child(c, i) — 각 하위 요소의 행 수는 동일합니다
CHC_COL_LOW_CARDINALITYchc_column_lc_key_size(c) (1/2/4/8), chc_column_lc_keys(c), chc_column_lc_dict(c); 딕셔너리 슬롯 0은 기본값입니다
일반 숫자, 문자열, 널 허용 컬럼을 위한 리더:
void print_value(const chc_type *t, const chc_column *c, size_t row)
{
    if (chc_column_layout(c) == CHC_COL_NULLABLE) {
        if (chc_column_null_map(c)[row]) { fputs("\\N", stdout); return; }
        print_value(chc_type_child(t, 0), chc_column_nullable_inner(c), row);
        return;
    }

    switch (chc_column_layout(c)) {
    case CHC_COL_FIXED: {
        /* fixed_data is a raw little-endian byte slab. memcpy into a typed
           local to avoid unaligned loads and strict-aliasing UB, then
           byte-swap on big-endian hosts. */
        size_t es;
        const uint8_t *p = chc_column_fixed_data(c, &es) + row * es;
        switch (chc_type_kind(t)) {
        case CHC_UINT64: { uint64_t v; memcpy(&v, p, sizeof v); printf("%" PRIu64, v); break; }
        case CHC_INT32:  { int32_t  v; memcpy(&v, p, sizeof v); printf("%" PRId32, v); break; }
        case CHC_FLOAT64: { double  v; memcpy(&v, p, sizeof v); printf("%g", v); break; }
        /* ... remaining numeric kinds ... */
        default: break;
        }
        break;
    }
    case CHC_COL_STRING: {
        const uint8_t  *bytes   = chc_column_string_data(c);
        const uint64_t *offsets = chc_column_string_offsets(c);
        uint64_t start = row == 0 ? 0 : offsets[row - 1];
        fwrite(bytes + start, 1, (size_t) (offsets[row] - start), stdout);
        break;
    }
    default: break;
    }
}
CHC_COL_FIXED 데이터는 wire상에서 리틀 엔디언입니다. 빅 엔디언 호스트에서는 다중 바이트 정수를 직접 바이트 스왑해야 합니다. 오프셋과 LowCardinality 키는 디코딩 시점에 이미 호스트 순서로 스왑됩니다. UUIDs는 리틀 엔디언 UInt64 절반 2개로 구성되며, IPv4는 4바이트 리틀 엔디언 정수이고, IPv6는 network byte order입니다. DateTime64 틱은 UTC이며, 타입의 시간대는 메타데이터일 뿐입니다. 신뢰할 수 없는 피어로부터 데이터를 수집할 때는 순회하기 전에 각 컬럼에 대해 chc_column_validate를 호출하십시오. chc_block_read는 배열 오프셋 및 LowCardinality 키와 같은 필드 간 불변 조건을 검증하지 않으므로, 그렇지 않으면 위조된 block이 내부 컬럼 경계를 넘어 읽을 수 있습니다.

데이터 삽입

chc_block_builder로 블록을 만든 다음 chc_client_send_data에 전달합니다. 빌더는 복사하지 않고 포인터를 기록하므로 컬럼 슬랩은 전송이 끝날 때까지 유효해야 합니다. INSERT는 쿼리를 전송하고, 서버의 헤더 블록을 기다린 다음 하나 이상의 데이터 블록을 전송한 후, 빈 블록을 보내 스트림을 종료합니다.
const char *sql = "INSERT INTO greetings (id, message) VALUES";
chc_client_send_query(client, sql, strlen(sql), "", 0, &err);

/* Wait for the server's header block (schema, 0 rows). */
bool got_header = false;
while (!got_header) {
    chc_packet pkt = {};
    if (chc_client_recv_packet(client, &pkt, &err) != CHC_OK) {
        fprintf(stderr, "recv: %s\n", err.msg);
        return 1;
    }
    chc_packet_kind kind = pkt.kind;
    if (kind == CHC_PKT_DATA) got_header = true;
    else if (kind == CHC_PKT_EXCEPTION && pkt.exception)
        fprintf(stderr, "server: %s\n", pkt.exception->display_text);
    chc_packet_clear(client, &pkt);
    if (kind == CHC_PKT_EXCEPTION || kind == CHC_PKT_END_OF_STREAM) return 1;  /* no header coming */
}

chc_block_builder *bb = NULL;
chc_block_builder_init(&bb, &al, &err);

uint64_t ids[3] = { 1, 2, 3 };
chc_type *u64 = NULL;
chc_type_parse("UInt64", 6, &al, &u64, &err);
chc_block_builder_append_fixed(bb, "id", 2, u64, ids, 3, &err);

/* String columns: cumulative exclusive end offsets + a packed byte slab. */
uint64_t offsets[3] = { 5, 11, 20 };   /* "hello", "buenas", "goedendag" */
const uint8_t bytes[] = "hellobuenasgoedendag";
chc_block_builder_append_string(bb, "message", 7, offsets, bytes, 3, &err);

chc_client_send_data(client, bb, &err);   /* the populated block */
chc_client_send_data(client, NULL, &err); /* empty block ends the INSERT */

/* Drain to EndOfStream. */
for (;;) {
    chc_packet pkt = {};
    chc_client_recv_packet(client, &pkt, &err);
    bool done = pkt.kind == CHC_PKT_END_OF_STREAM;
    chc_packet_clear(client, &pkt);
    if (done) break;
}

chc_block_builder_destroy(bb);
chc_type_destroy(u64, &al);
chc_block_builder_append_fixedn_rows * elem_size개의 리틀 엔디언 바이트를 받습니다; chc_block_builder_append_string는 패킹된 slab에 대해 호스트 바이트 순서의 누적된 배타적 끝 오프셋을 받습니다. 더 낮은 수준의 chc_block_write 대신 chc_client_send_data를 통해 빌더를 전달하면 클라이언트가 협상된 revision을 기반으로 블록 옵션을 설정하고 압축을 적용할 수 있습니다.

압축

chc_client_opts에 압축 모드와 초기화된 코덱을 전달합니다. 클라이언트는 들어오는 데이터 패킷의 압축을 해제하고, 나가는 패킷은 압축합니다. 압축 헤더는 LZ4ZSTD 어댑터를 제공하며, 각 초기화는 자체 슬롯만 채우므로 둘 중 하나라도 지원하려면 둘 다 호출하십시오.
#include "clickhouse-compression.h"

chc_codec codec = {};
chc_lz4_codec_init(&codec);
chc_zstd_codec_init(&codec);

chc_client_opts opts = {
    .user        = "default",
    .compression = CHC_COMP_LZ4,   /* or CHC_COMP_ZSTD */
    .codec       = &codec,
};
프로젝트에서 바인딩을 제공하지 않는 압축 라이브러리를 사용하려면 chc_codec를 직접 채워 넣으십시오; vtable은 clickhouse-compression.h에 선언되어 있습니다.

TLS

clickhouse-openssl.hSSL_read/SSL_writechc_io 백엔드를 제공합니다. OpenSSL은 직접 처리해야 합니다. 라이브러리는 SSL_CTX를 생성하거나 인증서를 검증하거나 SNI를 설정하거나 SSL_connect / SSL_shutdown을 호출하지 않습니다. chc_io.read가 호출될 때는 핸드셰이크가 이미 완료되어 있어야 합니다.
#include "clickhouse-openssl.h"

SSL *ssl = /* connected, handshake complete */;
chc_openssl_io state;
chc_io io;
chc_openssl_io_init(&state, &io, ssl, NULL, NULL);
/* hand &io to chc_client_init, same as the POSIX backend */
ClickHouse Cloud 및 기타 TLS가 활성화된 배포에서는 9440 포트에서 네이티브 프로토콜을 사용합니다. 두 백엔드 모두 선택적으로 check_cancel 콜백을 허용하며, 이 콜백은 읽기 사이에서 폴링되고, chc_openssl_io_set_deadline / chc_posix_io_set_deadline을 통해 읽기 마감 시간도 설정할 수 있습니다.

I/O를 사용하지 않는(async) 클라이언트

clickhouse-async.h는 이벤트 루프용 TCP 클라이언트의 I/O 없는 변형입니다. 이 클라이언트는 소켓을 전혀 건드리지 않습니다. 수신한 바이트를 전달하고 전송할 바이트를 꺼내며, epoll, io_uring, 또는 WaitLatchOrSocket은 직접 구동해야 합니다. 옵션, 패킷 유형, 그리고 블록 빌더는 블로킹 클라이언트와 동일합니다. chc_async_client_init는 I/O를 수행하지 않으며 블로킹되지 않습니다. 이후 핸드셰이크는 재개 가능한 상태 머신으로 실행되며, 모든 송신과 수신도 마찬가지입니다. 파싱이 전달한 바이트 범위를 넘어가면, 호출은 블로킹하는 대신 CHC_WOULD_BLOCK을 반환합니다 — 수신 바이트를 더 전달하고 다시 호출하면, 파서는 블록의 중간 지점부터 재개됩니다.
#include "clickhouse-async.h"

chc_async_client *c = NULL;
chc_client_opts opts = { .user = "default" };
chc_async_client_init(&c, &opts, &al, &err);

for (;;) {
    int rc = chc_async_handshake(c, &err);
    if (rc == CHC_OK) break;
    if (rc != CHC_WOULD_BLOCK) break;   /* hard error */
    pump(c);   /* drain pending_out to the socket; feed received bytes to chc_async_submit */
}

chc_async_send_query(c, sql, strlen(sql), "", 0, &err);

for (;;) {
    chc_packet pkt = {};
    int rc = chc_async_recv_packet(c, &pkt, &err);
    if (rc == CHC_WOULD_BLOCK) { pump(c); continue; }
    if (rc != CHC_OK) break;

    bool done = pkt.kind == CHC_PKT_END_OF_STREAM;
    if (pkt.kind == CHC_PKT_DATA && pkt.block) { /* read columns as above */ }
    chc_async_packet_clear(c, &pkt);
    if (done) break;
}
pump는 바이트를 양방향으로 이동합니다. Outbound에서는 chc_async_pending_out가 큐에 쌓인 바이트에 대한 포인터와 길이를 돌려줍니다. 소켓이 그중 일부를 받아들인 뒤에는 그 개수만큼 chc_async_consume_out를 호출하십시오. 부분 쓰기도 가능합니다. Inbound에서는 소켓 읽기 데이터를 chc_async_submit에 전달하십시오. 전송은 블로킹되지 않으며 backpressure도 적용하지 않으므로, pending-out 길이를 주시하고 너무 커지면 추가 전송을 중지하십시오. 작동하는 liburing 드라이버는 test/test_async_uring.c에 있습니다.

메모리와 allocator

모든 진입점은 chc_alloc vtable을 받으므로, 메모리 할당은 호스트에서 사용하는 방식에 따릅니다.
typedef struct chc_alloc {
    void *ud;
    void *(*alloc)  (void *ud, size_t bytes);
    void *(*realloc)(void *ud, void *p, size_t old_bytes, size_t new_bytes);
    void  (*free)   (void *ud, void *p, size_t bytes);
} chc_alloc;
clickhouse.h를 포함하기 전에 CHC_PROVIDE_STDLIB_ALLOC를 정의하고, 표준 malloc 기반 할당자를 사용하려면 chc_alloc_stdlib()를 호출하십시오.

오류 및 서버 예외

함수는 CHC_OK (0) 또는 0이 아닌 CHC_ERR_* 코드를 반환합니다. 이 코드는 반환값이며, 호출자 스택에 할당된 chc_err에는 사람이 읽을 수 있는 메시지가 저장됩니다. 이 라이브러리는 힙에 오류를 할당하지 않습니다.
typedef struct chc_err {
    int  server_code;           /* set when the return code is CHC_ERR_SERVER */
    char msg[CHC_ERR_MSG_LEN];  /* NUL-terminated, default 256 bytes */
    char server_name[64];       /* ClickHouse exception class, if SERVER */
} chc_err;
서버 측 쿼리 오류는 chc_err 실패가 아닙니다. 이러한 오류는 패킷 스트림에서 CHC_PKT_EXCEPTION으로 전달되며, 서버의 code, display_text, stack_trace를 포함합니다. chc_err 검사는 전송, 프로토콜, 디코드 실패에만 사용하십시오.

지원되는 데이터 타입

블록 리더는 다음 타입을 디코딩합니다:
  • Int8Int256, UInt8UInt256
  • Float32, Float64, BFloat16
  • Bool
  • Decimal32, Decimal64, Decimal128, Decimal256
  • Date, Date32, DateTime, DateTime64, Time, Time64
  • String, FixedString(N)
  • UUID, IPv4, IPv6
  • Enum8, Enum16
  • Nullable(T), Array(T), Tuple(...), Map(K, V), Nested(...)
  • LowCardinality(T)
  • Interval
  • QBit(...)
  • Point, Ring, Polygon, MultiPolygon
  • SimpleAggregateFunction(f, T), 내부 T로 디코딩됩니다
  • JSONObject('json'), 문자열 직렬화에서는 String 컬럼으로 디코딩됩니다(아래 참조)
JSONObject('json')는 쿼리에서 output_format_native_write_json_as_string=1을 설정한 경우에만 디코딩됩니다. 각 행은 CHC_COL_STRING 컬럼의 JSON 문서 1개로 전달되므로 문자열 Accessor가 이를 읽습니다; builder는 chc_block_builder_append_json_string으로 동일한 shape를 기록합니다. 다른 JSON serialization version은 모두 해당 setting 이름이 포함된 CHC_ERR_TYPE을 반환합니다. Variant, Dynamic, AggregateFunction은 아직 디코딩할 수 없으며 CHC_ERR_TYPE을 반환합니다; 폴백으로 서버 측에서 String으로 캐스팅하십시오.
마지막 수정일 2026년 6월 11일