Skip to main content
ClickHouse에 연결하기 위한 공식 C# 클라이언트입니다. 클라이언트 소스 코드는 GitHub 리포지토리에서 확인할 수 있습니다. 원저자는 Oleg V. Kozlyuk입니다. 이 라이브러리는 두 가지 주요 API를 제공합니다.
  • ClickHouseClient (권장): 싱글턴으로 사용하도록 설계된 고수준의 스레드 안전한 클라이언트입니다. 쿼리와 대량 삽입을 위한 간단한 비동기 API를 제공합니다. 대부분의 애플리케이션에 가장 적합합니다.
  • ADO.NET (ClickHouseDataSource, ClickHouseConnection, ClickHouseCommand): 표준 .NET 데이터베이스 추상화입니다. ORM 통합(Dapper, Linq2db)과 ADO.NET 호환성이 필요할 때 필수입니다. ClickHouseBulkCopy는 ADO.NET 연결을 사용해 데이터를 효율적으로 삽입할 수 있도록 돕는 헬퍼 클래스입니다. ClickHouseBulkCopy는 더 이상 권장되지 않으며 향후 릴리스에서 제거될 예정이므로, 대신 ClickHouseClient.InsertBinaryAsync를 사용하십시오.
두 API는 동일한 기본 HTTP 연결 풀을 공유하며, 같은 애플리케이션에서 함께 사용할 수 있습니다.

마이그레이션 가이드

  1. .csproj 파일에서 패키지 이름을 ClickHouse.Driver로 변경하고, NuGet의 최신 버전으로 업데이트합니다.
  2. 코드베이스의 모든 ClickHouse.Client 참조를 ClickHouse.Driver로 변경합니다.

지원되는 .NET 버전

ClickHouse.Driver는 다음과 같은 .NET 버전을 지원합니다.
  • .NET 6.0
  • .NET 8.0
  • .NET 9.0
  • .NET 10.0

지원되는 ClickHouse 버전

이 클라이언트는 공식적으로 최신 릴리스 3개와 최신 LTS 릴리스 2개를 지원합니다.

설치

NuGet에서 패키지를 설치합니다:
또는 NuGet 패키지 관리자를 사용하세요:

빠른 시작

구성

ClickHouse 연결을 구성하는 방법은 두 가지입니다.
  • 연결 문자열: 호스트, 인증 자격 증명, 기타 연결 옵션을 지정하는 세미콜론으로 구분된 키/값 쌍입니다.
  • ClickHouseClientSettings object: 설정 파일에서 로드하거나 코드에서 설정할 수 있는 강타입 구성 객체입니다.
아래에는 모든 설정의 전체 목록과 각 설정의 기본값 및 영향이 나와 있습니다.

연결 설정

데이터 포맷 및 직렬화

세션 관리

UseSession 플래그를 사용하면 서버 세션이 유지되어 SET SQL 문과 임시 테이블을 사용할 수 있습니다. 세션은 60초 동안 비활성 상태가 지속되면(기본 timeout) 재설정됩니다. 세션 수명은 ClickHouse SQL 문 또는 서버 구성을 통해 세션 설정을 지정하여 연장할 수 있습니다.ClickHouseConnection 클래스는 일반적으로 병렬 작업을 허용합니다(여러 스레드가 동시에 쿼리를 실행할 수 있음). 하지만 UseSession 플래그를 활성화하면 어떤 시점에도 connection당 활성 쿼리는 하나만 허용됩니다(이는 서버 측 제한입니다).

보안

HTTP 클라이언트 구성

로깅 및 디버깅

사용자 지정 설정 & 역할

연결 문자열을 사용해 사용자 지정 설정을 지정할 때는 set_ 접두사를 사용하십시오. 예: “set_max_threads=4”. ClickHouseClientSettings 객체를 사용할 때는 set_ 접두사를 사용하지 마십시오.사용 가능한 설정의 전체 목록은 여기를 참조하십시오.

연결 문자열 예시

기본 연결

사용자 지정 ClickHouse 설정 사용 시


QueryOptions

QueryOptions를 사용하면 쿼리마다 클라이언트 수준 설정을 재정의할 수 있습니다. 모든 속성은 선택 사항이며, 지정한 경우에만 클라이언트 기본값을 덮어씁니다. 예시:

InsertOptions

InsertOptions는 InsertBinaryAsync를 통한 대량 삽입 작업에 필요한 설정을 QueryOptions에 추가한 옵션입니다. 모든 QueryOptions 속성은 InsertOptions에서도 사용할 수 있습니다. 예시:

스키마 확인 쿼리 건너뛰기

기본적으로 InsertBinaryAsync는 각 삽입 전에 컬럼 타입을 확인하기 위해 SELECT ... WHERE 1=0 쿼리를 전송합니다. 높은 처리량이 필요한 환경에서는 두 가지 방법으로 이 오버헤드를 제거할 수 있습니다: 옵션 1: 컬럼 타입을 명시적으로 제공 컴파일 시점에 테이블 스키마를 알고 있다면 ColumnTypes를 통해 직접 전달하십시오. 그러면 스키마 확인 쿼리는 전혀 전송되지 않습니다:
옵션 2: 스키마 캐시 사용 같은 테이블에 반복해서 삽입하는 경우, UseSchemaCache = true로 설정하면 스키마를 한 번만 쿼리한 뒤 동일한 ClickHouseClient 인스턴스의 후속 삽입에서 이를 재사용합니다:
  • ColumnTypes는 UseSchemaCache보다 우선합니다. 둘 다 설정된 경우 명시적으로 지정한 타입이 사용됩니다.
  • 스키마 캐시는 ALTER TABLE 변경 사항을 감지하지 않습니다. 테이블 스키마를 수정한 경우 새 ClickHouseClient를 생성하거나 해당 테이블에서는 UseSchemaCache를 사용하지 마십시오.
  • 캐시 범위는 ClickHouseClient 인스턴스로 한정되며, 키는 (데이터베이스, 테이블)입니다. 동일한 테이블의 서로 다른 컬럼 부분 집합은 하나의 캐시된 스키마를 공유합니다.

ClickHouseClient

ClickHouseClient는 ClickHouse와 상호 작용할 때 권장되는 API입니다. 스레드 안전을 보장하며, singleton으로 사용하도록 설계되었고, 내부적으로 HTTP 연결 풀링을 관리합니다.

클라이언트 생성

연결 문자열 또는 ClickHouseClientSettings 객체를 사용해 ClickHouseClient를 생성합니다. 사용 가능한 옵션은 구성 섹션을 참조하십시오. ClickHouse Cloud 서비스의 세부 정보는 ClickHouse Cloud 콘솔에서 확인할 수 있습니다. 서비스를 선택하고 Connect를 클릭합니다: **C#**을 선택합니다. 아래에 연결 세부 정보가 표시됩니다. 자가 관리형 ClickHouse를 사용하는 경우 연결 세부 정보는 ClickHouse 관리자가 설정합니다. 연결 문자열 사용:
또는 ClickHouseClientSettings를 사용할 수 있습니다:
의존성 주입 시나리오에서는 IHttpClientFactory를 사용하세요:
ClickHouseClient는 애플리케이션 전반에서 장기간 유지하며 공유할 수 있도록 설계되었습니다. 한 번만 생성하고(일반적으로 싱글턴으로) 모든 데이터베이스 작업에 재사용하세요. 클라이언트는 내부적으로 HTTP 연결 풀링을 관리합니다.

쿼리 실행

결과를 반환하지 않는 SQL 문에는 ExecuteNonQueryAsync를 사용하십시오:
단일 값을 조회하려면 ExecuteScalarAsync를 사용합니다:

데이터 삽입

매개변수화된 삽입

ExecuteNonQueryAsync를 사용해 매개변수화된 쿼리로 데이터를 삽입합니다. 매개변수 타입은 SQL에서 {name:Type} 구문으로 지정해야 합니다:

대량 삽입

대량의 행을 효율적으로 삽입하려면 InsertBinaryAsync를 사용합니다. 이 메서드는 ClickHouse의 네이티브 행 바이너리 형식으로 데이터를 스트리밍하고, 병렬 Batch 업로드를 지원하며, 매개변수화 쿼리에서 발생할 수 있는 “URL too long” 오류를 방지합니다.
대규모 데이터셋에서는 InsertOptions를 사용해 배칭과 병렬성을 설정합니다:
  • 클라이언트는 삽입 전에 SELECT * FROM <table> WHERE 1=0을 통해 테이블 구조를 자동으로 가져옵니다. 제공된 값은 대상 컬럼 타입과 일치해야 합니다. 이 쿼리를 건너뛰려면 InsertOptions.ColumnTypes 또는 InsertOptions.UseSchemaCache를 사용하세요.
  • MaxDegreeOfParallelism > 1이면 배치가 병렬로 업로드됩니다. 세션은 병렬 삽입과 호환되지 않으므로 세션을 비활성화하거나 MaxDegreeOfParallelism = 1로 설정하세요.
  • 제공되지 않은 컬럼에 서버가 DEFAULT 값을 적용하도록 하려면 InsertOptions.Format에서 RowBinaryFormat.RowBinaryWithDefaults를 사용하세요.

POCO 삽입

object[] 배열을 구성하는 대신, 강력한 형식이 지정된 POCO 객체를 직접 삽입할 수 있습니다. 타입을 한 번 등록한 다음 IEnumerable<T>를 전달하면 됩니다:
기본적으로 공개적으로 읽을 수 있는 모든 속성은 이름을 엄격하게 대소문자 구분하여 일치시키는 방식으로 컬럼에 매핑됩니다. 속성을 사용해 이 매핑을 사용자 지정할 수 있습니다:
매핑된 모든 속성에 명시적인 Type이 지정되면 스키마 확인 쿼리는 완전히 생략됩니다. 일부 속성에만 명시적 타입이 있으면 드라이버는 전체 컬럼 집합에 대해 스키마 확인 쿼리를 사용합니다. InsertBinaryAsync<T>는 object[] 오버로드와 동일한 InsertOptions(배칭, 병렬 처리, 스키마 캐싱)를 지원합니다.
object[] 오버로드와 달리 InsertBinaryAsync<T>는 명시적인 컬럼 목록을 받지 않습니다. 컬럼은 등록된 타입에 매핑된 속성을 기준으로 결정됩니다. 삽입할 컬럼을 제어하려면 [ClickHouseNotMapped]를 사용해 속성을 제외하거나 [ClickHouseColumn(Name = "...")]를 사용해 이름을 변경하십시오.InsertOptions에서 ColumnTypes를 설정하면 POCO 특성보다 우선 적용됩니다.

스키마 진화

타입이 등록된 후 대상 테이블에 컬럼이 추가되더라도 POCO 삽입은 원활하게 동작합니다. 드라이버는 POCO에 매핑된 컬럼만 삽입하므로, DEFAULT(또는 다른 기본 표현식)가 있는 새 컬럼은 서버가 자동으로 채웁니다. 코드를 변경하거나 다시 등록할 필요가 없습니다.

삽입 쿼리 배치

바이너리 삽입은 INSERT INTO ... FORMAT ... 문을 행보다 앞선 요청 본문의 첫 줄에 기록합니다. 본문은 기본적으로 압축되므로 URL만 검사하는 라우팅과 로깅에서는 이 문을 확인할 수 없습니다. InsertOptions.QueryPlacement를 InsertQueryPlacement.Url로 설정하면 해당 문이 query URL 매개변수로 전송되어 본문에는 행만 남게 됩니다:
프록시, 로드 밸런서 또는 게이트웨이가 query 매개변수를 기준으로 라우팅하거나 이를 검사하는 경우, 또는 액세스 로그와 관측성 도구에서 쿼리문을 확인하고자 하는 경우에 사용하십시오. 이 방식은 쿼리문이 URL 길이에 포함되기 때문에 옵트인 방식으로 제공됩니다. 실제 적용되는 한도는 .NET 런타임, 중개 구성 요소, 서버가 부과하는 한도 중 가장 낮은 값입니다. .NET 6부터 .NET 9까지 System.Uri는 인코딩된 전체 요청 URI를 65,519자로 제한하며, 이 한도를 초과하면 드라이버가 InvalidOperationException을 발생시켜 InsertQueryPlacement.Body를 사용하도록 안내합니다. ClickHouse의 http_max_uri_size는 기본값이 1 MiB이지만, 중개 구성 요소가 더 낮은 한도를 부과할 수 있습니다. body 모드에서는 쿼리문과 행에 이러한 URL 길이 제한이 적용되지 않으며, 다른 요청 옵션은 여전히 URL에 나타날 수 있습니다. 이 설정은 Compressor와 무관합니다. body는 두 모드 모두에서 동일한 방식으로 인코딩됩니다.

데이터 읽기

SELECT 쿌리를 실행하려면 ExecuteReaderAsync를 사용합니다. 반환된 ClickHouseDataReader는 GetInt64(), GetString(), GetFieldValue<T>() 같은 메서드를 통해 결과 컬럼에 형식에 맞게 접근할 수 있도록 해줍니다. 다음 행으로 이동하려면 Read()를 호출합니다. 더 이상 행이 없으면 false를 반환합니다. 컬럼은 인덱스(0부터 시작) 또는 컬럼 이름으로 접근할 수 있습니다.

POCO 읽기

컬럼을 인덱스나 이름으로 읽는 대신, 쿼리 결과를 사용자 정의 클래스에 직접 스트리밍할 수 있습니다. 클라이언트에 해당 타입을 한 번만 등록한 다음 QueryAsync<T>를 사용하십시오:
RegisterPocoType<T>()는 삽입과 읽기 매핑을 모두 설정하고, 두 매핑을 모두 사전에 검증합니다. RegisterBinaryInsertType<T>()는 변경 없이 유지되며, 하위 호환성(backwards compatibility)을 위해 계속 삽입 전용으로 남아 있습니다. 등록된 형식은 다음 조건을 충족해야 합니다.
  • 매개변수가 없는 public 생성자
  • public이며 init이 아닌 setter가 있는 public 속성이 1개 이상 있어야 합니다. required 속성도 지원됩니다.
컬럼 매칭은 대소문자를 구분합니다. 결과 컬럼이 누락되면 해당 속성은 기본값으로 유지되며, 추가 결과 컬럼은 무시됩니다. 드라이버는 값을 확장하거나 축소하지 않습니다. 아래에 나열된 대체 표현을 제외하면, 컬럼의 프레임워크 유형은 속성 유형에 할당 가능해야 하며, 일치하지 않으면 InvalidOperationException 예외가 발생합니다. 따라서 object 속성은 모든 컬럼을 허용합니다. QueryAsync<T>는 다음 각 컬럼을 대응하는 프로퍼티로 곧바로 읽어들입니다: 모든 행은 컬럼이 Nullable(...)인지 여부와 관계없이 해당 프로퍼티 타입의 널 허용 형식(long?, DateOnly? 등)도 허용합니다. Nullable(T) 컬럼에 널을 허용하지 않는 값 타입 프로퍼티를 사용하는 것은 등록 시점에는 허용되지만, NULL이 들어오면 예외가 발생합니다. LowCardinality(T), SimpleAggregateFunction(f, T), Object(T)와 같은 래퍼는 T와 완전히 동일하게 매핑됩니다. 복합 컬럼도 지원되며, 읽기 타입 참고에 제시된 프레임워크 타입을 사용합니다: Array(T)는 T[]로, Tuple(...)은 System.Tuple<...>로, Nested(...)는 Tuple<...>[]로, JSON은 JsonObject로(JsonReadMode=String에서는 string), Variant/Dynamic은 object로 매핑됩니다. Map(K, V) 컬럼은 특수한 경우입니다. List<KeyValuePair<K, V>> 또는 KeyValuePair<K, V>[] 프로퍼티는 박싱이 없는 경로로 읽으며, 어떤 MapReadMode에서든 wire 순서와 중복된 키를 그대로 유지합니다. Dictionary<K, V> 프로퍼티는 기본 모드에서만 동작합니다. 키와 값 타입은 정확히 일치해야 하므로, Map(String, Nullable(Int32))에는 KeyValuePair<string, int?>가 필요합니다. 하나의 컬럼이 둘 이상의 프로퍼티 타입을 제공하는 경우(DateTime 컬럼을 DateTime, DateTimeOffset 또는 DateOnly로, String 컬럼을 string 또는 byte[]로) 선언된 프로퍼티 타입이 표현 방식을 결정합니다. 이러한 대체 표현은 POCO 경로에 속하므로 QueryAsync<T>에서는 사용할 수 있지만 MapTo<T>에서는 사용할 수 없습니다. 리더를 수동으로 순회할 때는 ClickHouseDataReader.MapTo<T>()를 사용하여 리더를 다음으로 진행시키지 않고 현재 행을 등록된 POCO로 구체화합니다:
리더 루프를 직접 제어해야 할 때 MapTo<T>를 사용하십시오. 예를 들어 raw 컬럼 액세스와 POCO 머티리얼라이즈를 혼합해야 하는 경우입니다. 이 메서드는 리더의 박싱된 값을 통해 행을 읽으므로 위에서 설명한 대체 속성 타입을 제공하지 않으며, QueryAsync<T>보다 할당이 더 많이 발생합니다. 행만 필요하다면 QueryAsync<T>를 사용하는 것이 좋습니다. 구체적인 수치는 머티리얼라이즈 경로 선택하기를 참조하십시오. 클라이언트 수준 또는 쿼리별 읽기 값 컨버터는 두 경로 모두에 적용되며 박싱 없는 읽기를 비활성화하지 않습니다. 드라이버는 해당 컬럼을 읽은 방식과 일치하는 오버로드로 각 컬럼을 변환합니다. 박싱 없는 컬럼에는 유형이 지정된 ConvertValue<T>를, 복합(composite) 컬럼에는 박싱된 ConvertValue를 사용합니다. 두 오버로드를 일관되게 구현하십시오. 그렇지 않으면 동일한 컬럼이라도 경로에 따라 서로 다른 결과가 반환됩니다. LoggerFactory가 설정되면 RegisterPocoType<T>() 및 RegisterBinaryInsertType<T>()는 어떤 속성이 어떤 컬럼에 매핑되었는지와 어떤 항목이 왜 건너뛰어졌는지를 보여주는 Debug 수준의 로그(카테고리: ClickHouse.Driver.Client)를 출력합니다. 로깅 및 진단을 참조하십시오.

SQL 매개변수

ClickHouse에서 SQL 쿼리의 쿼리 매개변수는 일반적으로 {parameter_name:DataType} 포맷을 사용합니다. 예시:
SQL ‘bind’ 매개변수는 HTTP URI 쿼리 매개변수로 전달되므로, 너무 많이 사용하면 “URL이 너무 깁니다” 예외가 발생할 수 있습니다. 이 제한을 피하려면 대량 데이터 삽입에는 InsertBinaryAsync를 사용하세요.

ADO 스타일 @name 플레이스홀더

드라이버는 Dapper와 같은 ORM이 생성하는 @name 플레이스홀더도 지원합니다. 이는 클라이언트 측 편의 기능으로, 요청이 전송되기 전에 각 플레이스홀더가 {name:ResolvedType} 형태로 재작성되므로 서버에는 @가 전달되지 않습니다. 유형이 결정되는 방식은 유형 해석을 참조하십시오. 가능하다면 명시적인 {name:Type} 형식을 사용하십시오. 일치하는 매개변수가 없는 @name은 그대로 유지되어 서버가 이를 거부하게 됩니다. 매칭 시 대소문자를 구분하므로 @ID는 id라는 이름의 매개변수에 바인딩되지 않습니다.
재작성을 비활성화하려면 드라이버를 처음 사용하기 전에 ClickHouse.Driver.DisableReplacingParameters AppContext 스위치를 설정하십시오. 텍스트 재작성만 중단될 뿐 매개변수는 그대로 전송되므로, 네이티브 {name:Type} 구문으로 작성된 쿼리는 계속 정상적으로 동작합니다.

Identifier 매개변수

Identifier 매개변수 유형을 사용하면 따옴표로 묶은 문자열 리터럴 대신 데이터베이스, 테이블 또는 컬럼 이름을 안전하게 바인딩할 수 있습니다. SQL에서는 {name:Identifier} 구문을 사용하거나, ClickHouseDbParameter.ClickHouseType = "Identifier"로 설정하여 사용할 수 있습니다:
값은 있는 그대로 전송되며, server는 이를 일반 SQL 식별자로 치환하고 자체적으로 backtick 인용과 escaping을 적용합니다. 특수 문자(backtick 포함)가 있는 식별자도 안전하게 round-trip됩니다.

쿼리 ID

모든 쿼리에는 고유한 query_id가 할당되며, 이 값은 system.query_log 테이블에서 데이터를 조회하거나 장시간 실행 중인 쿼리를 취소하는 데 사용할 수 있습니다. QueryOptions를 통해 사용자 지정 쿼리 ID를 지정할 수 있습니다:
사용자 지정 QueryId를 지정하는 경우, 호출마다 고유한 값이 되도록 하십시오. 임의의 GUID를 사용하는 것이 좋습니다.

사용자 지정 매개변수 타입 매핑

@ 스타일 매개변수(예: WHERE id = @id)를 사용하면 드라이버가 .NET 값 형식을 기준으로 ClickHouse 타입을 자동으로 추론합니다. 예를 들어 int는 Int32로 매핑됩니다.
추론된 DateTime 매개변수의 동작SQL에 {name:Type} 힌트가 없고 ClickHouseType도 설정되지 않은 @ 스타일 매개변수의 경우, 시점을 포함하는 값은 단순한 DateTime이 아니라 DateTime('UTC')로 추론됩니다. Kind가 Utc 또는 Local인 DateTime과 모든 DateTimeOffset 값은 DateTime('UTC')로 전송되므로, 서버 시간대와 관계없이 시점이 유지됩니다.명시적 힌트({name:DateTime})는 추론보다 우선하며, 쿼리를 작성할 때 권장되는 방식입니다.
이 기본 동작을 재정의하려면 ClickHouseClientSettings에서 ParameterTypeResolver를 설정하십시오. 이렇게 하면 각 개별 매개변수에 ClickHouseType을 일일이 설정하지 않고도, 모든 DateTime 매개변수에 밀리초 정밀도를 위해 DateTime64(3)를 사용하거나 모든 decimal에 특정 scale을 적용할 수 있어 유용합니다. 간단한 타입 매핑에 DictionaryParameterTypeResolver 사용:
고급 시나리오를 위한 사용자 지정 IParameterTypeResolver: 값을 고려하거나 이름을 기준으로 확인해야 하는 경우 IParameterTypeResolver 인터페이스를 직접 구현하십시오. 기본 추론을 사용하도록 하려면 null을 반환하십시오:
단일 쿼리에도 QueryOptions.ParameterTypeResolver를 통해 리졸버를 설정할 수 있습니다. 설정된 경우 클라이언트 수준의 리졸버보다 우선합니다. 타입 결정 우선순위: 리졸버는 우선순위 체인의 한 단계입니다. 우선순위가 높은 순서부터 낮은 순서는 다음과 같습니다.
  1. 매개변수에 명시적으로 설정된 ClickHouseType
  2. 쿼리의 {name:Type} 구문에 지정된 SQL type hint
  3. IParameterTypeResolver (QueryOptions.ParameterTypeResolver에서 가져오고, 없으면 ClickHouseClientSettings.ParameterTypeResolver를 사용)
  4. 기본 제공 타입 추론(TypeConverter.ToClickHouseType)
리졸버는 ADO.NET ClickHouseConnection 경로에서도 작동합니다. 설정은 클라이언트에서 생성된 연결에 상속됩니다.

사용자 지정 매개변수 값 포맷팅

IParameterFormatter는 매개변수 값이 어떻게 직렬화되는지 결정하는 후크입니다. 기본 제공 포맷팅(예: DateTime 정밀도, Decimal 문화권 형식, 문자열 이스케이프 처리, 숫자 표현)이 스키마 또는 다운스트림 도구에서 기대하는 방식과 맞지 않을 때 사용합니다. 매개변수화된 모든 쿼리에 포맷터를 적용하려면 ClickHouseClientSettings에서 ParameterFormatter를 설정합니다. 이 포맷터는 값, 확인된 ClickHouse type name, 그리고 매개변수 이름을 받아 서버로 전송할 문자열 표현을 반환합니다. 기본 포맷터를 사용하려면 null을 반환합니다. 간단한 CLR 형식별 포맷팅에는 DictionaryParameterFormatter 사용:
고급 사용 사례를 위한 사용자 지정 IParameterFormatter:
QueryOptions.ParameterFormatter를 통해 쿼리별로 포맷터를 설정할 수도 있습니다. 설정된 경우 클라이언트 수준 포맷터보다 우선 적용됩니다. 복합 값: 포맷터는 최상위 컬렉션 매개변수와 복합 값(Array, Tuple, Map, Nullable, LowCardinality, Variant) 내부의 각 요소 모두에 대해 실행됩니다. 예를 들어 typeof(int) 매핑은 Array(Int32)의 각 Int32 요소를 개별적으로 포맷합니다. 복합 컨텍스트에서의 작은따옴표 래핑: 복합 리터럴 안에 포함된 문자열 계열 ClickHouse 타입(String, FixedString, Enum8, Enum16, IPv4, IPv6, UUID)의 경우, 드라이버는 포맷터의 출력값을 작은따옴표로 감싸지만 그 내용은 이스케이프하지 않습니다. 반환된 문자열에 이스케이프되지 않은 작은따옴표나 백슬래시가 포함되어 있으면 복합 리터럴의 형식이 잘못되어 서버가 쿼리를 거부합니다. 최상위 문자열 매개변수(복합 값에 포함되지 않은 경우)는 감싸지지 않은 채 그대로 사용되므로, 이 경우에는 이스케이프가 필요하지 않습니다. 포맷터 우선순위:
  1. IParameterFormatter (QueryOptions.ParameterFormatter에서 가져오고, 없으면 ClickHouseClientSettings.ParameterFormatter로 대체). null이 아닌 값을 반환하면 해당 값을 사용합니다.
  2. HttpParameterFormatter의 내장 타입별 포맷팅.
포맷터는 null 또는 DBNull 값에는 적용되지 않으며, 이러한 값은 항상 ClickHouse null 센티널(\N)로 직렬화됩니다.

사용자 지정 읽기 값 변환

IReadValueConverter를 사용하면 역직렬화 후 데이터 리더가 반환하는 값을 CLR 타입은 변경하지 않은 채 변환할 수 있습니다. 일반적인 용도로는 시간대가 없는 DateTime 컬럼에서 DateTime.Kind = Utc를 설정하거나, 문자열을 트리밍하거나 정규화하거나, JSON column이 application code에 전달되기 전에 후처리하는 작업이 있습니다. 모든 읽기에 컨버터를 적용하려면 ClickHouseClientSettings에서 ReadValueConverter를 설정하십시오. 컨버터는 boxed(GetValue) 경로와 generic(GetFieldValue<T>) 경로 모두에서 각 행의 각 컬럼마다 한 번 호출됩니다. 컨버터를 설정하지 않으면 오버헤드가 전혀 없으며 리더는 값을 직접 반환합니다. 간단한 CLR 타입별 변환에 DictionaryReadValueConverter 사용:
런타임 CLR 유형이 For<T>에 등록되지 않은 값은 변경되지 않은 채 그대로 전달됩니다. 디스패치는 정확히 일치하는 CLR 유형을 기준으로 이루어지므로, 리더가 실제로 생성하는 유형을 등록하십시오(예: JsonReadMode.Binary의 JSON 컬럼에는 For<JsonObject>를 사용). 고급 시나리오를 위한 사용자 지정 IReadValueConverter: ClickHouse 측 타입 문자열을 기준으로 디스패치해야 한다면(예를 들어 DateTime과 DateTime('UTC')를 구분해야 하는 경우 — 둘 다 동일한 CLR 유형으로 나타남), IReadValueConverter를 직접 구현하십시오:
컨버터는 런타임 CLR 형식을 보존해야 합니다. 컬럼 메타데이터(GetFieldType, GetSchemaTable)는 이를 거쳐 다시 처리되지 않으므로, 반환되는 값과 일관성을 유지해야 합니다. QueryOptions.ReadValueConverter를 통해 쿼리별 컨버터를 설정할 수도 있습니다. 이 값을 설정하면 클라이언트 수준 컨버터보다 우선합니다. 디스패치 경계: 컨버터는 역직렬화된 전체 셀 값을 대상으로 컬럼마다 한 번씩 호출되며, 복합 컨테이너 내부로 재귀적으로 들어가지는 않습니다. Array(Int32) 컬럼에서는 전달되는 값이 int[]이고, Tuple(Int32, String)에서는 ITuple입니다. 실행되는 오버로드: 드라이버가 호출하는 오버로드는 호출자가 컬럼을 어떻게 읽었는지에 따라 달라지므로, 두 오버로드는 서로 일치해야 합니다:
  • ConvertValue<T> — 타입이 지정된 accessor인 GetByte, GetSByte, GetInt16/32/64, GetUInt16/32/64, GetFloat, GetDouble, GetGuid, GetDateTime, GetIPAddress, GetBigInteger 및 GetFieldValue<T>, 그리고 POCO 읽기 경로의 박싱이 없는 모든 컬럼.
  • ConvertValue(boxed) — GetValue, GetValues, 인덱서, GetChar, GetTuple, 그리고 GetBoolean, GetDecimal, GetString의 강제 변환 경로.
IsDBNull은 컨버터를 전혀 실행하지 않습니다. null 플래그를 직접 읽으므로 컨버터가 값이 null로 취급되는지 여부를 바꿀 수 없습니다. TryGetEnumOrdinal 역시 컨버터를 우회합니다 — enum의 서수 읽기를 참조하십시오. 컨버터는 ADO.NET ClickHouseConnection 경로에서 동작하며, 이 설정은 클라이언트에서 생성된 연결에 상속됩니다.

Raw 스트리밍

데이터 리더를 거치지 않고 특정 포맷으로 쿼리 결과를 직접 스트리밍하려면 ExecuteRawResultAsync를 사용합니다. 이는 데이터를 파일로 내보내거나 다른 시스템으로 그대로 전달할 때 유용합니다:
일반적으로 사용되는 포맷: JSONEachRow, CSV, TSV, Parquet, Native. 모든 옵션은 포맷 문서를 참조하십시오.

쿼리별 전송 압축

기본적으로 Compression=true(연결 문자열의 기본값)인 경우 클라이언트는 zstd, lz4, gzip, deflate를 협상하며, 스트림을 직접 자동으로 디코딩합니다. 원시 내보내기(예: Parquet, Arrow, Native)에서는 connection 전체 설정을 변경하지 않고도 CPU 사용량과 대역폭을 절충하기 위해 다른 코덱(예: zstd 또는 lz4)을 협상할 수 있습니다. QueryOptions.AcceptEncoding 및 ClickHouseCommand.AcceptEncoding은 단일 요청에 대해 HTTP Accept-Encoding 헤더를 설정하고, 기존에 적용된 기본값을 대체하며, URL에 enable_http_compression=1을 강제로 추가합니다(ClickHouse는 Accept-Encoding을 적용하기 전에 이를 요구합니다).

HttpClient 구성

별도로 구성할 것은 없습니다. 드라이버가 생성하는 HttpClient는 AutomaticDecompression을 DecompressionMethods.None으로 두고 드라이버가 직접 응답을 디코딩하므로, Content-Encoding이 모르는 사이에 제거되는 일이 없으며 원시 body가 서버가 보낸 그대로 전달됩니다.
직접 HttpClient를 제공하는 경우에도 AutomaticDecompression은 꺼 둔 상태로 유지하십시오. 이는 응답 측에만 적용되는 설정이 아닙니다. 전송 시점에 핸들러는 마스크에 포함된 알고리즘 중 나가는 Accept-Encoding에 빠져 있는 것을 모두 추가합니다. 따라서 GZip | Deflate 마스크를 가진 핸들러는 명시적으로 지정한 AcceptEncoding = "lz4"를 wire 상에서 lz4, gzip, deflate로, "identity"를 identity, gzip, deflate로 바꿔 버립니다. 게다가 ClickHouse는 순서와 q-value를 무시하고 자체 고정 코덱 우선순위에 따라 헤더를 해석하므로, 요청하지도 않은 코덱으로 응답할 수 있고, 핸들러가 이를 디코딩한 뒤 제거해 버리기 때문에 그런 일이 일어났다는 사실조차 알 수 없습니다. 마스크를 꺼 두면 선택한 내용만 그대로 전달됩니다.
AcceptEncoding이 드라이버가 디코딩할 수 없는 코덱(snappy)을 요청하는 경우에는 ExecuteRawResultAsync만 안전합니다. ExecuteReaderAsync, ExecuteScalarAsync, ExecuteNonQueryAsync는 해당 코덱 이름을 명시한 NotSupportedException과 함께 실패합니다(이전에는 compressed bytes를 결과 포맷으로 파싱하여 의미 없는 데이터를 생성했습니다).

오류 본문

서버가 4xx/5xx로 응답하고 enable_http_compression=1이 설정된 경우, 정상 응답에 사용했을 것과 동일한 코덱으로 오류 본문을 압축합니다. 드라이버는 지원하는 모든 코덱(lz4, zstd, gzip, deflate, br/brotli)에 대해 이를 디코딩하므로 ClickHouseServerException에 표시되는 메시지를 읽을 수 있습니다. 그 외의 코덱(snappy, …)에 대해서는 코덱 이름을 포함하고 원래 오류 텍스트는 system.query_log에서 확인하라고 안내하는 플레이스홀더를 반환합니다.

응답 압축 해제

Accept-Encoding은 서버에 응답을 압축해 달라고 요청할 뿐이며, 이를 디코딩하는 주체는 따로 있어야 합니다. 드라이버가 응답의 Content-Encoding을 보고 직접 디코딩하므로, 일반적인 읽기 API(ExecuteReaderAsync, ExecuteScalarAsync, ExecuteNonQueryAsync, QueryAsync<T>, Dapper, EF Core, linq2db)는 별도 설정 없이도 압축된 응답에서 그대로 동작합니다. lz4, zstd, gzip, deflate, br을 디코딩하며, snappy는 지원되지 않습니다. 기본적으로 드라이버는 **zstd, lz4, gzip, deflate**를 알리며, ClickHouse는 zstd로 응답합니다. 다른 방식을 사용하려면 Accept-Encoding을 직접 지정하십시오 — 클라이언트 전체에 적용하는 방법:
쿼리별로 지정할 수 있으며, 이 설정이 우선 적용됩니다:
또는 ClickHouseClientSettings를 직접 다루지 않는 ORM 사용자를 위해 연결 문자열에서 지정할 수도 있습니다:
이 값을 설정하면 URL에 enable_http_compression=1도 함께 강제 적용됩니다. ClickHouse는 이 설정이 있어야만 해당 헤더를 반영하기 때문이며, 이는 UseCompression이 false인 경우에도 마찬가지입니다. 코덱을 명시적으로 지정하는 것 자체가 압축을 요청하는 것으로 간주되기 때문입니다. 값을 설정하지 않은 상태에서 UseCompression=false이면 Accept-Encoding을 아예 전송하지 않습니다. Accept-Encoding은 네 곳에서 설정할 수 있으며, 이 중 코덱을 지정하는 첫 번째 항목이 적용됩니다:
  1. QueryOptions.AcceptEncoding (또는 ClickHouseCommand.AcceptEncoding)
  2. 쿼리의 CustomHeaders["Accept-Encoding"]
  3. 클라이언트의 CustomHeaders["Accept-Encoding"]
  4. ClickHouseClientSettings.AcceptEncoding 또는 연결 문자열 키워드 AcceptEncoding
어느 곳에서도 지정하지 않으면 드라이버는 기본 목록을 전송합니다. 코덱을 지정하지 않는 값(null, 빈 문자열, 공백, 쉼표만 있는 경우)은 설정되지 않은 것으로 간주되어 다음 위치로 넘어갑니다. 압축을 비활성화하려면 identity를 사용하십시오. 코덱을 선택하는 주체는 클라이언트가 아니라 서버입니다. ClickHouse는 Accept-Encoding의 토큰을 자체적으로 고정된 우선순위(zstd > br > lz4 > snappy > gzip > deflate)에 따라 검사하며, 나열한 순서와 q-value는 모두 무시합니다. 따라서 이 헤더는 요구 사항이 아니라 지원 가능한 기능을 알리는 수단이며, 선택 결과를 조정하는 유일한 방법은 어떤 토큰을 제외할지 결정하는 것입니다. 기본값에는 zstd가 포함되어 있으므로 기본 설정의 쿼리는 zstd로 응답되며, 나머지 토큰은 폴백 역할을 합니다. br는 디코딩은 가능하지만 기본적으로 알리지는 않습니다. payload 크기, 서버 CPU, 클라이언트 CPU 측면에서 각 코덱의 비교 결과는 데이터, 네트워크 링크, 서버의 http_zlib_compression_level(기본 제공 값: 3)에 따라 달라집니다. 압축 튜닝을 참고하십시오.
  • http_zlib_compression_level. 이 설정은 모든 HTTP 코덱에 적용되며 기본값은 3입니다. 이 값은 데이터, 링크 속도, CPU 사용량에 맞춰 조정해야 합니다.
  • 빠른 링크에서의 CPU 바운드 클라이언트. 드라이버는 호출한 thread에서 response body를 디코딩하므로, 네트워크가 bottleneck이 아닌 경우 client-side 디코딩 속도가 제한 요인이 될 수 있습니다.
다음 중 하나라도 해당된다면 쿼리별로 또는 클라이언트 전체에 다른 코덱을 요청하십시오:
이 판단은 응답을 기준으로 이루어지므로, 무엇을 요청했든 관계없이 Content-Encoding이 지시하는 대로 body가 디코딩됩니다. 헤더가 없거나 identity이면 그대로 통과하고, 지원되는 코덱은 디코딩되며, 그 외의 값은 해당 값을 명시한 오류를 발생시킵니다. 이중 디코딩의 위험은 없습니다. caller가 제공한 handler의 AutomaticDecompression이 이미 body를 디코딩했다면 Content-Encoding도 함께 제거되므로, driver는 plaintext를 보게 되어 아무런 처리도 하지 않습니다. 원시(raw) 결과는 어떤 코덱도 광고하지 않습니다. ExecuteRawResultAsync(그리고 공개 API인 PostStreamAsync / InsertRawStreamAsync)는 응답 본문을 있는 그대로 전달하므로, 직접 코덱을 지정하지 않는 한 아무 코덱도 요청하지 않습니다. 드라이버 내부에서 이러한 본문을 디코딩하는 부분이 없기 때문에, 여기서 코덱을 제안하면 내보내기 작업이 소리 없이 압축 파일 생성으로 바뀌어 버립니다. 따라서 규칙은 단순하며 HttpClient가 어떻게 구성되었는지와도 무관합니다. 있는 그대로 전달되는 본문은 서버가 보낸 그대로 도착하며, 코덱을 요청하지 않는 한 서버는 평문(plaintext)을 전송합니다. 코덱을 명시적으로 요청하는 것(클라이언트 전체 또는 쿼리 단위)이 의도적으로 압축된 바이트를 내보내는 방법입니다. 명시적으로 지정한 AcceptEncoding은 어느 수준에서 지정하든 원시 요청에도 그대로 적용되며, 결과를 디코딩하고자 할 때는 ClickHouseRawResult.ReadDecompressedStreamAsync()를 사용하면 됩니다. ReadAsStreamAsync, ReadAsByteArrayAsync, ReadAsStringAsync, CopyToAsync는 항상 도착한 바이트를 그대로 반환합니다.
반환된 스트림은 위와 같이 범위를 벗어나기 전에 끝까지 읽으십시오. 응답이 압축된 경우에는 leaveOpen으로 생성된 디코더를 받게 되므로, 이를 해제해도 응답은 그대로 유지됩니다. 반면 압축되지 않은 경우에는 HTTP 콘텐츠 스트림 자체를 받게 되므로, 이를 해제하면 본문이 종료됩니다. 어느 경우든 ClickHouseRawResult가 응답을 소유하므로, 스트림을 해제한 뒤에는 다른 읽기 멤버를 호출하지 마십시오. ClickHouseRawResult의 해제는 항상 필요하며, 그것만으로 충분합니다. 이 해제로 응답과 여기에 삽입된 디코더(디코더는 풀링된 버퍼를 보유합니다)가 모두 해제됩니다. 따라서 위의 await using은 선택 사항이며, 그대로 두어도 안전합니다. 순차적으로 반복 호출하면 동일한 스트림이 반환되며, 이 유형은 동시에 사용하기에 안전하지 않습니다. 실행 가능한 예시는 Select_007_ResponseCompression.cs를 참조하십시오.

삽입(request) 압축

Zstd는 삽입 시 사용되는 기본 코덱입니다. InsertOptions.Compressor의 초기값은 ZstdCompressor.Default이며, 이는 수준 3의 zstd를 의미합니다. 코덱을 변경하려면 다른 압축기를 지정하고, 본문을 압축하지 않고 전송하려면 null로 설정하십시오.
드라이버에는 네 가지 코덱이 기본 포함되어 있습니다. 각 코덱은 Default 인스턴스와 수준 및 쓰기 버퍼 크기를 인수로 받는 생성자를 제공합니다:
압축기 인스턴스를 공유하십시오. 각 Default는 하나의 공유 인스턴스이며, 네 가지 압축기 모두 여러 스레드에서 동시에 사용해도 안전합니다. InsertOptions.MaxDegreeOfParallelism이 1보다 클 때가 바로 이런 경우로, 하나의 삽입 작업이 모든 배치에 대해 하나의 압축기를 사용하기 때문입니다. 이들 중 IDisposable을 구현하는 것은 없습니다. 인스턴스를 직접 한 번만 생성한 뒤, Default를 사용하는 방식과 동일하게 재사용하십시오.
IClickHouseCompressor는 public이며, 구현체는 다음 두 개의 멤버만 제공하면 됩니다:
서버는 지정한 Content-Encoding을 수용할 수 있어야 합니다. 나머지 멤버인 Decompress, MethodByte, MaxEncodedLength, Encode, Decode는 NotSupportedException을 throw하는 기본 구현을 가지고 있으므로, 코덱에 필요한 것만 재정의하면 됩니다. 요청을 압축할 뿐 아니라 응답 본문도 디코딩하려면 Decompress를 구현하고, 본문이 손상되었거나 포맷이 잘못된 경우 반환되는 스트림에서 InvalidDataException을 raise하십시오. InsertOptions.Compressor는 바이너리 삽입에만 적용됩니다. driver의 다른 요청 본문은 서로 다른 규칙에 따라 압축되며, 그 어느 것도 이 설정을 거치지 않습니다.
  • 모든 SQL 텍스트 요청(ExecuteReaderAsync, ExecuteScalarAsync, ExecuteNonQueryAsync, QueryAsync<T>, ExecuteRawResultAsync, ADO.NET 계층)은 UseCompression이 true인 경우, 즉 기본적으로 statement를 Content-Encoding: gzip으로 전송합니다. 코덱은 구성할 수 없습니다. AcceptEncoding은 응답에만 영향을 주므로 선택지는 gzip 아니면 압축 없음뿐입니다. Compression=false이면 statement를 압축하지 않고 그대로 전송합니다. statement는 크기가 작으므로 이를 신경 쓸 일은 거의 없지만, 프록시나 packet capture로 요청을 들여다볼 때는 알아 두면 유용합니다.
  • 멀티파트 본문 — 매개변수를 폼 데이터로 전송하는 쿼리(UseFormDataParameters=true) — 는 UseCompression 설정과 관계없이 항상 압축되지 않은 상태로 전송됩니다.
  • 원시 upload(InsertRawStreamAsync, PostStreamAsync)는 호출마다 지정하는 자체 flag를 사용하며, UseCompression과 InsertOptions.Compressor 중 어느 것도 참조하지 않습니다. flag가 설정되어 있으면 gzip으로, 그렇지 않으면 압축하지 않은 상태로 전송합니다. InsertRawStreamAsync의 useCompression 매개변수는 기본값이 true이므로, false를 전달하지 않는 한 원시 upload는 클라이언트에 Compression=false가 설정되어 있더라도 gzip으로 압축됩니다.

압축 튜닝

압축은 CPU를 소모하는 대신 전송 바이트를 줄입니다. 이것이 이득인지 여부는 코덱의 실행 속도 대비 네트워크 연결이 얼마나 빠른지에 거의 전적으로 달려 있습니다. 모든 상황에 들어맞는 설정은 존재하지 않습니다.

결정을 좌우하는 단 하나의 수치

압축은 코덱이 네트워크보다 빠르기만 하면 그만한 가치가 있습니다. 읽기 경로에서 이 임계값은 대부분이 예상하는 것보다 낮습니다. ClickHouse가 출력 버퍼에서 HTTP 응답을 단일 스레드로 압축하기 때문입니다. 16 vCPU ClickHouse Cloud 서비스에서 측정한 결과(hits, RowBinary, 수준 3), 서버는 대략 100~200MB/s의 속도로 압축된 출력을 생성합니다. 따라서 결과가 크고 한 번에 하나의 쿼리만 처리된다고 가정하면, 대략 100MB/s 부근에서 압축의 이득이 사라집니다. 하나의 클라우드 리전 내에서 단일 HTTPS 스트림은 흔히 이 수치를 넘어서지만, 공용 인터넷이나 VPN, 리전 경계를 넘는 경우에는 대체로 이보다 낮습니다. 삽입 경로는 더 빠른 링크 속도에서도 압축이 유리합니다. 클라이언트가 자체 코어에서 압축을 수행하므로 일반적으로 서버의 응답 압축보다 빠르기 때문입니다.

배포 환경별 대략적인 가이드

이 표에 반영되지 않은 사항이 세 가지 있습니다:
  • 이그레스 비용: 데이터 전송 요금이 부과되는 환경이라면 바이트 자체에 지연 시간 외의 비용이 붙으므로, 링크 속도와 관계없이 압축률을 높이는 쪽이 유리합니다.
  • 작은 결과: 위 내용은 모두 큰 페이로드를 전제로 합니다. 응답이 작을 때는 코덱의 영향이 거의 없고 요청당 오버헤드가 대부분을 차지합니다.
  • 병렬 삽입은 삽입 기준선을 끌어올립니다. 위의 처리량 수치는 모두 단일 스레드 기준입니다. InsertOptions.MaxDegreeOfParallelism의 기본값은 1이지만, 이 값을 높이면 배치가 동시에 압축되므로 클라이언트의 전체 인코딩 속도는 할당한 코어 수에 대략 비례해 확장됩니다. 따라서 빠른 링크에서도 병렬 삽입은 단일 스레드 삽입이 압축할 가치를 잃는 속도를 훨씬 넘어서까지 압축할 가치를 유지합니다. 표의 삽입 항목은 하한선으로 보고, 이미 병렬로 배치를 처리하고 있다면 링크가 너무 빨라 압축이 불필요하다고 결론짓기 전에 다시 테스트하십시오.
읽기 경로는 여러 쿼리에 걸쳐서만 병렬화됩니다.

코덱 선택

수준

응답 압축은 http_zlib_compression_level이라는 단일 서버 설정으로 제어되며, 이 설정은 zlib뿐만 아니라 모든 HTTP 코덱에 적용됩니다. 기본값은 3입니다. 측정을 통해 확인한 근거가 없다면 그대로 두십시오. 기본값보다 높이면 CPU를 많이 소모하는 데 비해 크기 감소 효과는 매우 미미하며(zstd의 경우 3 → 6으로 올리면 서버 CPU 사용량이 대략 두 배가 되지만 바이트 수는 약 14%만 줄어듭니다), br은 비정상적으로 나빠집니다. 반대로 수준 1로 낮추면 양상이 확연히 달라집니다. lz4는 비용이 훨씬 낮아지고, zstd는 lz4 대비 CPU 이점을 잃습니다. 필요하다면 쿼리별로 설정하십시오:

직접 교차점 측정하기

코덱과 압축 수준 선택을 최적화하는 가장 빠른 방법은 여러 코덱으로 동일한 쿼리를 실행해 소요 시간을 측정하고 비교해 보는 것입니다.
같은 내용을 서버 측에서 확인하려면 system.query_log에서 ProfileEvents를 다시 읽어오십시오. 해당 행을 찾을 수 있도록 QueryOptions.QueryId를 설정하십시오:
직접 벤치마크할 때 빠지기 쉬운 함정이 하나 있습니다. ORDER BY 없이 LIMIT n만 사용하면 실행할 때마다 서로 다른 행이 반환되므로, 반복할 때마다 압축되는 데이터가 달라져 비율이 무의미한 노이즈가 됩니다. 고정된 결과 집합을 기준으로 비교하십시오.

Raw 스트림 삽입

InsertRawStreamAsync를 사용하면 CSV, JSON, Parquet 또는 지원되는 모든 ClickHouse 포맷으로 된 파일이나 메모리 스트림에서 데이터를 직접 삽입할 수 있습니다. CSV 파일에서 삽입하기:
드라이버가 스트림의 소유권을 가져갑니다. InsertRawStreamAsync와 PostStreamAsync는 요청이 성공하든 실패하든 완료되는 시점에 전달받은 스트림을 dispose합니다. 직접 dispose하지 말고, 이후에 재사용하지도 마십시오. 위 예시에서 FileStream을 using으로 감싸지 않은 이유가 바로 여기에 있습니다.사용자가 직접 작성한 using은 드라이버가 이미 스트림을 dispose한 뒤에 실행됩니다. FileStream이나 MemoryStream이라면 이 두 번째 호출이 무해하지만, Dispose가 풀에 버퍼를 반환하거나 참조 카운트를 감소시키는 스트림이라면 리소스가 두 번 해제됩니다.소유권은 인수가 수락된 이후에만 넘어갑니다. table, 스트림 또는 포맷이 누락되어 호출이 ArgumentException이나 ArgumentNullException을 throw한 경우, 스트림은 여전히 사용자의 것입니다.
데이터 수집 동작을 제어하는 옵션은 포맷 설정 문서를 참고하십시오.

추가 예시

실제 사용에 도움이 되는 추가 예시는 GitHub 리포지토리의 examples 디렉터리에서 확인하십시오.

ADO.NET

이 라이브러리는 ClickHouseConnection, ClickHouseCommand, ClickHouseDataReader를 통해 ADO.NET을 완전하게 지원합니다. 이 API는 ORM 통합(Dapper, Linq2db)과 표준 .NET 데이터베이스 추상화가 필요할 때 사용해야 합니다.

ClickHouseDataSource를 사용한 수명 주기 관리

적절한 수명 주기 관리와 연결 풀링을 위해 항상 ClickHouseDataSource에서 연결을 생성하세요. DataSource는 내부적으로 단일 ClickHouseClient를 관리하며, 모든 연결은 해당 HTTP 연결 풀을 공유합니다.
종속성 주입 시:
운영 코드에서는 ClickHouseConnection을 직접 생성하지 마십시오. 직접 인스턴스화할 때마다 새 HTTP 클라이언트와 연결 풀(connection pool)이 생성되므로, 부하가 걸리면 소켓 고갈이 발생할 수 있습니다:
대신 항상 ClickHouseDataSource를 사용하거나 ClickHouseClient 인스턴스 하나를 공유하십시오.

ClickHouseCommand 사용하기

연결을 통해 SQL을 실행할 명령을 생성합니다:
명령 메서드:
  • ExecuteNonQueryAsync() - INSERT, UPDATE, DELETE, DDL 문에 사용됩니다
  • ExecuteScalarAsync() - 첫 번째 행의 첫 번째 컬럼을 반환합니다
  • ExecuteReaderAsync() - 결과를 순회할 수 있는 ClickHouseDataReader를 반환합니다

ClickHouseDataReader 사용

ClickHouseDataReader는 쿼리 결과에 타입이 지정된 형태로 접근할 수 있도록 제공합니다:

enum의 서수 값 읽기

Enum8 또는 Enum16 컬럼은 해당 레이블로 구체화됩니다. GetFieldType은 string을 반환하고, GetString, GetValue, GetFieldValue<string>은 모두 레이블을 반환합니다. 저장된 값이 문자열이므로, 숫자형 accessor는 enum 컬럼에 대해 InvalidCastException을 발생시킵니다. 레이블에 대응하는 숫자를 얻으려면 TryGetEnumOrdinal을 사용하세요:
Enum8/Enum16 컬럼과, cell이 NULL이 아닌 Nullable(Enum...) 컬럼에 대해서는 true를 반환하고 value를 설정합니다. NULL cell이거나 enum이 아닌 컬럼에 대해서는 value를 0으로 설정한 뒤 false를 반환합니다. 서수 값은 wire에서 전달된 signed 값이므로 음수일 수 있으며, Enum16의 서수 값은 1바이트를 초과할 수 있습니다.

모범 사례

연결 수명 및 풀링

ClickHouse.Driver는 내부적으로 System.Net.Http.HttpClient를 사용합니다. HttpClient에는 엔드포인트별 연결 풀이 있습니다. 그에 따라 다음과 같은 특성이 있습니다.
  • 데이터베이스 세션은 연결 풀에서 관리하는 HTTP 연결을 통해 다중화됩니다.
  • HTTP 연결은 풀에서 자동으로 재사용됩니다.
  • ClickHouseClient 또는 ClickHouseConnection 객체를 dispose한 뒤에도 연결이 유지될 수 있습니다.
권장 패턴:
사용자 지정 HttpClient 또는 HttpClientFactory를 사용하는 경우, half-closed connection으로 인한 오류를 방지할 수 있도록 PooledConnectionIdleTimeout을 서버의 keep_alive_timeout보다 작은 값으로 설정하십시오. Cloud 배포의 기본 keep_alive_timeout은 10초입니다.
공유 HttpClient 없이 여러 개의 ClickHouseClient 또는 별도의 ClickHouseConnection 인스턴스를 생성하지 마십시오. 각 인스턴스는 자체 연결 풀을 생성합니다.

DateTime 처리

  1. 가능하면 항상 UTC를 사용하십시오. 타임스탬프는 DateTime('UTC') 컬럼에 저장하고, 코드에서는 DateTimeKind.Utc를 사용하십시오. 이렇게 하면 시간대와 관련된 모호성을 없앨 수 있습니다.
  2. 시간대를 명시적으로 처리해야 할 때는 DateTimeOffset을 사용하십시오. DateTimeOffset은 항상 특정 시점을 나타내며, 오프셋 정보도 함께 포함합니다.
  3. SQL type hint에 시간대를 지정하십시오. UTC가 아닌 컬럼을 대상으로 하는 Unspecified DateTime 값을 매개변수로 사용할 때는 SQL에 시간대를 포함하십시오.

비동기 삽입

비동기 삽입은 배칭의 책임을 클라이언트에서 서버로 옮깁니다. 클라이언트 측에서 배칭해야 하는 대신, 서버가 들어오는 데이터를 버퍼에 저장했다가 구성 가능한 임계값에 따라 스토리지로 플러시합니다. 이는 많은 에이전트가 작은 페이로드를 전송하는 관측성 워크로드와 같은 고동시성 시나리오에서 유용합니다. CustomSettings 또는 연결 문자열(connection string)을 통해 비동기 삽입을 활성화하세요:
두 가지 모드 (wait_for_async_insert로 제어):
wait_for_async_insert=0에서는 오류가 플러시 중에만 드러나므로 원래 삽입과 연결해 추적할 수 없습니다. 또한 클라이언트가 백프레셔를 제공하지 않으므로 server 과부하가 발생할 위험이 있습니다.
주요 설정:

세션

상태를 유지하는 서버 측 기능이 필요할 때만 세션을 활성화하세요. 예:
  • 임시 테이블 (CREATE TEMPORARY TABLE)
  • 여러 SQL 문에 걸쳐 쿼리 컨텍스트 유지
  • 세션 수준 설정 (SET max_threads = 4)
세션을 활성화하면 동일한 세션이 동시에 사용되지 않도록 요청이 직렬화됩니다. 따라서 세션 상태가 필요하지 않은 워크로드에는 오버헤드가 발생합니다.
ADO.NET 사용(ORM 호환성을 위해):

지원 데이터 타입

ClickHouse.Driver는 모든 ClickHouse 데이터 타입을 지원합니다. 아래 표는 데이터베이스에서 데이터를 읽을 때 ClickHouse 타입과 네이티브 .NET 타입 간의 매핑을 보여줍니다.

타입 매핑: ClickHouse에서 읽어올 때

정수 타입


부동 소수점 타입


Decimal 타입

Decimal 타입 변환은 UseCustomDecimals 설정으로 제어됩니다.

불리언 타입


String 타입

기본적으로 String 및 FixedString(N) 컬럼은 모두 string으로 반환됩니다. 이를 byte[]로 읽으려면 연결 문자열에서 ReadStringsAsByteArrays=true를 설정하세요. 이 옵션은 유효한 UTF-8이 아닐 수 있는 바이너리 데이터를 저장할 때 유용합니다.이 설정은 다른 타입 안에 중첩된 문자열에도 적용되므로, Array(String)은 byte[][]로 읽히고 Map(String, String)은 키를 포함해 Dictionary<byte[], byte[]>로 읽힙니다. 유일한 예외는 JSON 컬럼으로, 그 안의 문자열 리프는 항상 텍스트입니다. JSON 타입을 참조하세요.

날짜 및 시간 타입

ClickHouse는 DateTime 및 DateTime64 값을 내부적으로 Unix timestamp(epoch 이후의 초 또는 그보다 작은 단위)로 저장합니다. 저장은 항상 UTC로 이루어지지만, 컬럼에는 연결된 시간대가 있을 수 있으며 이 시간대는 값이 표시되고 해석되는 방식에 영향을 줍니다. DateTime 값을 읽을 때 DateTime.Kind 속성은 컬럼의 시간대에 따라 설정됩니다: UTC가 아닌 컬럼의 경우, 반환되는 DateTime은 해당 시간대의 현지 시각을 나타냅니다. 해당 시간대에 맞는 올바른 오프셋이 포함된 DateTimeOffset을 가져오려면 ClickHouseDataReader.GetDateTimeOffset()을 사용하십시오:
명시적인 시간대가 없는 컬럼(즉, DateTime('Europe/Amsterdam')이 아니라 DateTime)의 경우, 드라이버는 Kind=Unspecified인 DateTime을 반환합니다. 이렇게 하면 시간대에 대해 별도로 가정하지 않고, 저장된 wall-clock time을 정확히 그대로 유지할 수 있습니다. 명시적인 시간대가 없는 컬럼에서 시간대 인식 동작이 필요하다면, 다음 중 하나를 사용하십시오.
  1. 컬럼 정의에 명시적인 시간대를 사용합니다: DateTime('UTC') 또는 DateTime('Europe/Amsterdam')
  2. 읽은 후 시간대를 직접 적용합니다.

JSON 타입

JSON 컬럼의 반환 타입은 JsonReadMode 설정으로 제어됩니다:
  • Binary (기본값): System.Text.Json.Nodes.JsonObject를 반환합니다. JSON 데이터에 구조적으로 접근할 수 있지만, IP 주소, UUID, 큰 Decimal 값과 같은 ClickHouse의 특수 타입은 JSON 구조 안에서 문자열 표현으로 변환됩니다.
  • String: 원본 JSON을 string으로 반환합니다. ClickHouse의 JSON 표현을 그대로 유지하므로, parsing 없이 JSON을 그대로 전달해야 하거나 역직렬화를 직접 처리하려는 경우에 유용합니다.
None은 세 번째 모드입니다. 읽기 동작은 Binary와 완전히 동일하지만, 쿼리와 함께 server setting을 전송하지 않습니다. server setting을 설정할 수 없는 connection에서 사용하십시오. 컬럼 타입에 선언된 경로는 타입이 지정된 경로(typed path) 이고, 그 외 문서에 포함된 경로는 동적 경로(dynamic path) 입니다. 두 경로는 값이 null일 때 동작이 달라집니다. 타입이 지정된 경로는 항상 JsonObject에 나타납니다. Nullable(T) 또는 Dynamic으로 선언된 경우, 저장된 값이 null일 때와 문서에 해당 경로가 아예 없을 때 모두 JSON null로 반환되므로 두 경우를 구분할 수 없습니다:
널을 허용하지 않는 타입으로 선언된 경우, 존재하지 않는 경로는 해당 타입의 기본값을 갖습니다. 즉, JSON(x String)은 {"x":""}를, JSON(x Int64)는 {"x":0}을 반환합니다. 값이 null인 동적 경로는 객체에서 완전히 제거되므로 ContainsKey가 false를 반환합니다. 일반 JSON 컬럼에서 {"x":null}을 읽으면 {}가 됩니다. 중첩된 타입이 지정된 경로는 상위 경로를 함께 생성하므로, 빈 문서에 대해서도 JSON(a.b Nullable(Int64))은 {"a":{"b":null}}을 반환합니다.
이는 server가 직접 렌더링한 결과이므로, 이제 Binary 모드와 String 모드가 일치합니다. 1.4.0 이전에는 null을 담고 있는 타입이 지정된 경로가 JsonObject에서 제거되어 {"x":null}이 {}로 읽혔으며, JSON(a.b Nullable(Int64))와 같은 중첩 경로에서는 a 하위 트리 전체가 사라졌습니다.
JSON 컬럼 내부의 문자열 리프는 ReadStringsAsByteArrays 설정값과 관계없이 항상 텍스트로 반환됩니다. JsonValue에는 바이트 배열 형태가 없어 byte[]로 반환하면 base64로 표시되기 때문입니다. 이는 String, FixedString은 물론 이들이 LowCardinality, Nullable, SimpleAggregateFunction으로 감싸진 경우, 그리고 Array와 Map 내부의 문자열(맵 키 포함)에도 동일하게 적용됩니다.
JSON 리더가 타입을 알 수 없는 바이트 배열은 여전히 base64로 표시됩니다. Variant 또는 Dynamic 타입이 지정된 경로는 행마다 타입이 결정되는 값을 담기 때문에, Variant(Array(UInt8), String) 아래의 문자열은 base64로 인코딩되어 반환됩니다. 이는 두 설정 모두에서 동일합니다.정확히 String이 아닌 JSON 맵 키 타입, 예를 들어 Map(LowCardinality(String), String)은 NotSupportedException을 발생시킵니다.
ClickHouse는 하나의 경로를 값으로 선언하면서 동시에 다른 경로의 부모로도 선언하는 컬럼을 허용합니다. 예를 들면 JSON(a Int64, a.b Int64)입니다. 두 경로 모두 모든 행에 존재하므로, 서버는 해당 행을 중복 키가 포함된 형태로 렌더링합니다: {"a":0,"a":{"b":7}}. JsonObject는 하나의 키에 두 개의 값을 담을 수 없으므로, JsonReadMode.Binary는 두 경로를 명시한 SerializationException을 throw합니다. JSON(a Map(String, Int64))처럼 값이 Map이고 동일한 행에 동적 a.b가 함께 존재할 때도 마찬가지입니다. 이는 해당 행에서 양쪽 모두가 값을 가진 경우에만 해당됩니다. 아무것도 가지지 않은 쪽 — NULL, 빈 객체, 또는 값이 모두 NULL인 하위 트리 — 은 서버가 두 경로 중 어느 쪽을 먼저 전송하든 데이터를 가진 쪽에 자리를 내줍니다. 따라서 Nullable 타입으로 선언된 겹침은 행마다 한쪽만 채워지므로 오류 없이 읽힙니다. JSON(a Nullable(Int64), a.b Nullable(Int64))는 예상대로 {"a":5}와 {"a":{"b":7}}를 반환합니다. 이러한 컬럼을 JsonReadMode.String으로 읽으면 중복 키를 포함한 서버의 JSON 텍스트를 그대로 얻을 수 있습니다. AllowDuplicateJsonKeys를 설정하면 예외를 throw하는 대신 해당 컬럼을 계속 JsonObject로 읽습니다. 이때 드라이버는 행에 담긴 두 값 중 마지막 값만 유지하고 나머지는 버리므로 결과에 손실이 발생합니다. {"a.b":7}를 담고 있는 JSON(a Int64, a.b Int64)는 {"a":0}으로 읽힙니다. 값을 가진 경로의 부모가 스칼라나 배열을 가지는 경우에는 하위 트리를 그 아래에 배치할 수 없으므로 여전히 예외가 throw됩니다.

맵(Map) 타입

ClickHouse의 Map(K, V)는 물리적으로 Array(Tuple(K, V))이며, 동일한 키를 가진 항목을 여러 개 담을 수 있습니다. 반면 Dictionary는 그렇지 않기 때문에, 기본 모드에서는 중복된 키의 마지막 값만 유지되고 앞선 쌍은 삭제됩니다. MapReadMode 설정으로 표현 방식을 선택할 수 있습니다.
  • Dictionary(기본값): Dictionary<K, V>를 반환합니다.
  • KeyValuePairs: 서버가 전송한 순서 그대로 List<KeyValuePair<K, V>>를 반환하므로, 키가 중복되는 항목까지 모든 쌍이 보존됩니다.
mode는 Map 컬럼의 프레임워크 타입을 결정하므로 GetFieldValue<T>, driver가 보고하는 schema 타입, POCO 프로퍼티 매핑에도 동일하게 적용됩니다. 또한 Array(Map(...)), Map(K, Map(...)), Tuple(..., Map(...)), Dynamic을 포함하여 컬럼의 타입 트리에 맵이 나타나는 모든 위치에 적용됩니다. 두 표현 방식 모두 어느 mode에서든 쓰기 경로에서 허용됩니다 — 맵 쓰기를 참조하십시오.

기타 타입

Dynamic 및 Variant 타입은 각 행의 실제 기반 타입에 해당하는 타입으로 변환됩니다.

Geometry 타입

Geometry 타입은 모든 Geometry 타입을 저장할 수 있는 Variant 타입입니다. 대응되는 타입으로 변환됩니다.

타입 매핑: ClickHouse에 쓰기

데이터를 삽입할 때 드라이버는 .NET 타입을 해당 ClickHouse 타입으로 변환합니다. 아래 표에는 각 ClickHouse 컬럼 타입에 허용되는 .NET 타입이 나와 있습니다.

정수 타입


부동 소수점 타입


불리언 타입


String 타입


날짜 및 시간 타입

범위를 벗어난 값바이너리 쓰기 경로에서는 지원 범위를 벗어나는 Date, Date32, DateTime, DateTime32 값에 대해 Write 시점에 ArgumentOutOfRangeException이 발생하며, 예외 메시지에는 컬럼 타입과 지원 범위가 포함됩니다. 이전에는 범위를 벗어난 값이 32비트 정수를 거치며 조용히 잘린 뒤 server에서 reinterpret되어, 실제처럼 보이지만 잘못된 timestamp가 생성될 수 있었습니다.
드라이버는 값을 쓸 때 DateTime.Kind를 따릅니다: DateTimeOffset 값은 항상 정확한 시점을 유지합니다. 예시: UTC DateTime (시점 유지)
예시: 지정되지 않은 DateTime(현지 시계 시간)
권장 사항: 가장 단순하고 예측 가능한 동작을 위해 모든 DateTime 작업에는 DateTimeKind.Utc 또는 DateTimeOffset을 사용하십시오. 이렇게 하면 서버 시간대, 클라이언트 시간대, 컬럼 시간대와 관계없이 코드가 항상 일관되게 동작합니다.

HTTP 매개변수와 대량 복사

Unspecified DateTime 값을 쓸 때는 HTTP 매개변수 바인딩과 대량 복사 방식 사이에 중요한 차이가 있습니다: 대량 복사는 대상 컬럼의 시간대를 알고 있으므로 Unspecified 값을 해당 시간대로 올바르게 해석합니다. HTTP 매개변수는 컬럼의 시간대를 자동으로 알지 못합니다. 따라서 SQL 타입 힌트에 시간대를 지정해야 합니다:

Decimal 타입


JSON 타입

JSON을 쓸 때의 동작은 JsonWriteMode 설정으로 제어됩니다:
  • String (기본값): string, JsonObject, JsonNode 또는 임의의 객체를 허용합니다. 모든 입력은 System.Text.Json.JsonSerializer를 통해 직렬화되며, 서버 측에서 파싱할 수 있도록 JSON 문자열로 전송됩니다. 가장 유연한 모드이며 타입 등록 없이도 사용할 수 있습니다.
  • Binary: 등록된 POCO 타입만 허용합니다. 데이터는 클라이언트 측에서 전체 타입 힌트 지원과 함께 ClickHouse의 바이너리 JSON 포맷으로 변환됩니다. 사용 전에 connection.RegisterJsonSerializationType<T>()를 호출해야 합니다. 이 모드에서 string 또는 JsonNode 값을 쓰면 ArgumentException이 발생합니다.
JSON 컬럼에 타입 힌트가 있는 경우(예: JSON(id UInt64, price Decimal128(2))), 드라이버는 이 힌트를 사용해 값을 원래 타입 정보를 온전히 유지하면서 직렬화합니다. 이를 통해 일반 JSON으로 직렬화할 때 정밀도가 손실될 수 있는 UInt64, Decimal, UUID, DateTime64 같은 타입의 정밀도를 보존할 수 있습니다. POCO는 JsonWriteMode에 따라 두 가지 방식으로 JSON 컬럼에 쓸 수 있습니다: String 모드(기본값): POCO는 System.Text.Json.JsonSerializer를 통해 직렬화됩니다. 타입 등록은 필요하지 않습니다. 가장 간단한 방식이며 익명 객체에도 사용할 수 있습니다. Binary 모드: POCO는 드라이버의 바이너리 JSON 포맷을 사용해 직렬화되며, 타입 힌트를 완전히 지원합니다. 사용 전에 connection.RegisterJsonSerializationType<T>()로 타입을 등록해야 합니다. 이 모드에서는 특성을 사용해 사용자 지정 경로 매핑을 적용할 수 있습니다:
  • [ClickHouseJsonPath("path")]: 속성을 사용자 지정 JSON 경로에 매핑합니다. 중첩 구조를 다루거나 속성 이름이 원하는 JSON 키와 다를 때 유용합니다. Binary 모드에서만 작동합니다.
  • [ClickHouseJsonIgnore]: 직렬화 대상에서 속성을 제외합니다. Binary 모드에서만 작동합니다.
컬럼 타입 힌트와 속성 이름의 매칭은 대소문자를 구분합니다. 속성 UserId는 userid가 아니라 UserId로 정의된 힌트에만 일치합니다. 이는 userName과 UserName 같은 경로가 각각 별도의 필드로 공존할 수 있도록 허용하는 ClickHouse 동작과 일치합니다. 제한 사항(Binary 모드 전용):
  • POCO 타입은 직렬화 전에 connection.RegisterJsonSerializationType<T>()를 사용해 connection에 등록해야 합니다. 등록되지 않은 타입을 직렬화하려고 하면 ClickHouseJsonSerializationException이 발생합니다.
  • 딕셔너리 및 배열/리스트 속성이 올바르게 직렬화되려면 컬럼 정의에 타입 힌트가 필요합니다. 힌트가 없으면 대신 String 모드를 사용하십시오.
  • POCO 속성의 NULL 값은 컬럼 정의의 해당 경로에 Nullable(T) 타입 힌트가 있을 때만 기록됩니다. ClickHouse는 동적 JSON 경로 내부에 Nullable 타입을 허용하지 않으므로, 힌트가 없는 null 속성은 건너뜁니다.
  • ClickHouseJsonPath 및 ClickHouseJsonIgnore 특성은 String 모드에서는 무시됩니다(Binary 모드에서만 작동합니다).

기타 타입


Geometry 타입


쓰기에서 지원되지 않음


중첩 타입 처리

ClickHouse 중첩 타입(Nested(...))은 배열 문법으로 읽고 쓸 수 있습니다.

로깅 및 진단

ClickHouse .NET 클라이언트는 Microsoft.Extensions.Logging 추상화와 통합되어 가볍고 필요할 때 선택적으로 사용할 수 있는 로깅을 제공합니다. 활성화하면 드라이버는 connection 수명 주기 이벤트, 명령 실행, 전송 작업, 대량 삽입 작업에 대해 구조화된 메시지를 출력합니다. 로깅은 완전히 선택 사항이므로 로거를 구성하지 않은 애플리케이션도 추가 오버헤드 없이 계속 실행됩니다.

빠른 시작

appsettings.json 사용

표준 .NET 구성을 사용해 로깅 수준을 설정할 수 있습니다:

인메모리 구성 사용하기

코드에서 범주별로 로깅 상세도를 설정할 수도 있습니다:

범주 및 이미터

드라이버는 전용 범주를 사용하므로 구성 요소별로 로그 레벨을 세밀하게 조정할 수 있습니다:

예시: 연결 문제 진단하기

다음 내용이 로그에 기록됩니다:
  • HTTP 클라이언트 팩터리 선택(기본 풀 또는 단일 연결)
  • HTTP handler 구성(SocketsHttpHandler 또는 HttpClientHandler)
  • 연결 풀 설정(MaxConnectionsPerServer, PooledConnectionLifetime 등)
  • timeout 설정(ConnectTimeout, Expect100ContinueTimeout 등)
  • SSL/TLS 구성
  • 연결 열림/닫힘 이벤트
  • 세션 ID 추적

디버그 모드: 네트워크 추적 및 진단

네트워킹 문제를 진단하는 데 도움이 되도록, 드라이버 라이브러리에는 .NET 네트워킹 내부 구성의 저수준 트레이싱을 활성화하는 도우미가 포함되어 있습니다. 이를 활성화하려면 수준이 Trace로 설정된 LoggerFactory를 전달하고 EnableDebugMode를 true로 설정해야 합니다(또는 ClickHouse.Driver.Diagnostic.TraceHelper 클래스를 통해 수동으로 활성화할 수 있습니다). 이벤트는 ClickHouse.Driver.NetTrace 범주에 기록됩니다. 경고: 이렇게 하면 매우 상세한 로그가 대량으로 생성되며 성능에도 영향을 줍니다. 프로덕션 환경에서는 디버그 모드를 활성화하지 않는 것이 좋습니다.

OpenTelemetry

이 드라이버는 .NET System.Diagnostics.Activity API를 통해 OpenTelemetry 분산 트레이싱을 기본으로 지원합니다. 이 기능을 활성화하면 드라이버가 데이터베이스 작업에 대한 스팬을 생성하며, 생성된 스팬은 Jaeger나 ClickHouse 자체(OpenTelemetry Collector 사용)와 같은 관측성 백엔드로 내보낼 수 있습니다.

트레이싱 활성화

ASP.NET Core 애플리케이션에서는 ClickHouse 드라이버의 ActivitySource를 OpenTelemetry 구성에 추가하세요:
콘솔 애플리케이션, 테스트 또는 수동 설정 시:

스팬 속성

각 스팬에는 표준 OpenTelemetry 데이터베이스 속성과, 디버깅에 활용할 수 있는 ClickHouse 전용 쿼리 통계가 포함됩니다.

구성 옵션

ClickHouseDiagnosticsOptions를 사용해 추적 동작을 제어합니다:
IncludeSqlInActivityTags를 활성화하면 트레이스에 민감한 데이터가 노출될 수 있습니다. 프로덕션 환경에서 사용할 때는 주의하십시오.

TLS 구성

HTTPS를 통해 ClickHouse에 연결할 때 TLS/SSL 동작은 여러 방식으로 구성할 수 있습니다.

사용자 지정 인증서 유효성 검사

사용자 지정 인증서 유효성 검사 로직이 필요한 프로덕션 환경에서는 ServerCertificateCustomValidationCallback 핸들러가 구성된 자체 HttpClient를 제공하십시오:
사용자 지정 HttpClient를 제공할 때의 중요 사항
  • 자동 압축 해제: AutomaticDecompression은 꺼 두십시오. 드라이버가 압축된 응답을 직접 디코딩하므로 필요하지 않으며, 오히려 요청 측면에서 역효과를 냅니다. 전송 시점에 핸들러가 자신의 마스크에 포함된 모든 알고리즘을 나가는 Accept-Encoding에 추가로 덧붙여 드라이버가 알린 범위를 넓히기 때문에, ClickHouse가 요청하지 않은 코덱으로 응답할 수 있습니다. 응답 압축 해제를 참조하십시오.
  • 유휴 시간 제한: 반쯤 열린 연결로 인한 연결 오류를 방지하려면 PooledConnectionIdleTimeout을 서버의 keep_alive_timeout보다 작게 설정하십시오(ClickHouse Cloud에서는 10초).

성능 튜닝

이 섹션에서는 클라이언트로 최적의 성능을 얻는 방법과, 특정 사용 사례에 맞게 클라이언트 성능을 조정할 수 있는 다양한 옵션을 설명합니다.

한눈에 보기

| 상황 | 권장 조치 | |---|---|---| | 행을 POCO로 읽는 경우 | MapTo<T> 대신 QueryAsync<T> 사용 | | 대량 삽입을 수행하는 경우 | InsertOptions.BatchSize 값 늘리기 | | 삽입이 많은 콘솔 앱이나 워커 앱을 실행하는 경우 | 서버 GC 활성화 | | 네트워크로 대용량 결과를 읽는 경우 | 응답 압축을 켠 상태로 유지(기본값) | | 빠른 연결로 삽입하는 경우 | InsertOptions.Compressor = null 시도 | | 동일한 테이블에 여러 번 삽입하는 경우 | UseSchemaCache 또는 ColumnTypes 사용 | | 매우 큰 결과를 읽는 경우 | ReadBufferSize 값 늘리기 |

읽기: 머티리얼라이즈 경로 선택

결과에서 행을 가져오는 방법은 세 가지가 있으며, 비용은 서로 다릅니다. 일부 경로는 값을 박싱하기 때문에 할당(allocation)이 늘어나고 성능이 떨어집니다. hits 데이터셋의 105개 컬럼을 1,000,000행 읽는 경우:
ORM은 타입이 지정된 accessor를 사용할 때 빠른 경로를 이용합니다. linq2db는 각 컬럼에 대해 GetInt64, GetDouble, GetDateTime을 등록하므로 박싱 없이 읽습니다. GetValue를 통해 읽는 코드 (Dapper의 dynamic 결과 포함)는 각 값을 박싱합니다. ORM 쿼리가 자주 실행되면서 GetValue를 통해 읽는다면, 해당 쿼리에 한해 QueryAsync<T>를 사용하십시오.

삽입: 배치 크기와 병렬성

배치 크기는 삽입 처리량을 좌우하는 가장 중요한 단일 설정입니다. InsertOptions.BatchSize의 기본값은 100,000행입니다. 큰 배치를 사용하십시오. 1,000,000행을 삽입할 때 배치당 행 수를 10,000에서 100,000으로 늘린 결과는 다음과 같습니다: 배치 크기를 제어할 수 없다면(예: 다수의 소규모 producer가 각각 독립적으로 행을 전송하는 경우) async 삽입를 사용해 서버가 배칭을 처리하도록 하십시오. 병렬 업로드. InsertOptions.MaxDegreeOfParallelism의 기본값은 1입니다. 이 값을 늘리면 여러 배치를 동시에 전송할 수 있습니다. 압축이 활성화된 경우 효과가 가장 큰데, 각 배치가 각자의 thread에서 압축되기 때문입니다. session은 병렬 삽입에서 동작하지 않으므로, session을 비활성화하거나 MaxDegreeOfParallelism = 1을 유지하십시오. 스키마 확인 쿼리를 제거하십시오. InsertBinaryAsync를 호출할 때마다 컬럼 타입을 확인하기 위해 먼저 SELECT ... WHERE 1=0 쿼리를 전송합니다. ColumnTypes 또는 UseSchemaCache로 이 왕복 통신을 없애는 방법은 스키마 확인 쿼리 건너뛰기를 참조하십시오.
박싱 없는 삽입 경로는 기본 RowBinary 포맷에 적용됩니다. RowBinaryWithDefaults는 DBDefault marker를 찾기 위해 각 값을 검사해야 하므로 더 느린 경로를 사용합니다.

압축: 두 방향의 결론이 다릅니다

압축은 CPU를 바이트와 맞바꾸는 작업입니다. 이 맞바꿈이 이득인지는 전송 방향, ClickHouse 서버까지의 연결 대역폭, 선택한 압축 알고리즘과 데이터의 궁합, 그리고 전송되는 바이트마다 비용을 지불해야 하는지 여부에 따라 달라집니다. 읽기: 서버가 동일한 머신에서 실행 중인 경우가 아니라면 압축을 켜 두십시오. 이것이 기본값입니다. 압축을 사용하지 않은 경우와 비교했을 때, 레벨 1의 zstd는 다음과 같은 결과를 보였습니다: 삽입: 압축을 적용하기 전에 먼저 측정하십시오. 절감 효과가 압축을 켤 만큼 크지 않을 수 있습니다. 또한 압축 해제가 서버에 추가 부하를 준다는 점도 염두에 두십시오. 이 부하는 Zstd와 LZ4에서는 크지 않지만 다른 알고리즘(예: Brotli)에서는 클 수 있습니다. 삽입 압축을 끄려면 다음과 같이 하십시오:
코덱 선택, 압축 수준, 그리고 사용 환경에 맞는 교차점을 찾는 방법은 압축 튜닝을 참조하십시오.

버퍼

ReadBufferSize는 HTTP 응답을 읽는 버퍼의 크기를 설정합니다. 기본값은 64 KiB입니다. driver는 이 버퍼를 공유 풀에서 빌려 쓰고 reader를 해제할 때 반환하므로, 쿼리마다 메모리를 새로 할당하지는 않습니다. 결과 크기가 큰 경우 이 값을 늘리면 버퍼를 다시 채우는 횟수를 줄일 수 있습니다. driver는 동시에 열려 있는 reader마다 버퍼를 하나씩 유지하므로, 버퍼 size가 크거나 동시 reader 수가 많을수록 메모리 사용량이 늘어납니다.
reader는 항상 dispose하십시오. reader를 dispose하면 풀에서 가져온 버퍼가 반환되고 HTTP connection이 해제됩니다. reader를 그대로 방치하면 버퍼가 풀로 반환되지 않으며 HTTP connection도 사용할 수 없는 상태로 남을 수 있습니다. 일반적인 garbage collection은 dispose를 대체하지 못합니다.

런타임과 GC

삽입이 많은 애플리케이션에서는 Server GC를 활성화하십시오. 동일한 코드와 동일한 할당 바이트 수 기준으로, Workstation GC는 삽입 작업에서 Server GC보다 최대 97% 더 느렸습니다.
ASP.NET Core 프로젝트는 이미 이 설정이 적용되어 있습니다. 반면 콘솔 애플리케이션, 워커 서비스, 대부분의 컨테이너 이미지에서는 그렇지 않습니다. 원인은 generation 0 예산의 크기입니다. Workstation GC는 예산이 작기 때문에, 삽입 작업에서 만들어지는 수명이 짧은 버퍼가 generation 0에서 회수되지 못합니다. 대신 generation 1로 승격(promotion)되며, 그 결과 generation 2 작업이 훨씬 많아집니다. 한 삽입 사례에서는 작업 1,000회당 generation 2 수집 횟수가 Server GC에서는 4,000회, Workstation GC에서는 73,000회로 측정되었습니다.
Server GC는 처리량을 위한 설정이지 지연 시간을 위한 설정이 아닙니다. 동일한 측정에서 Server GC는 총 일시 중지 시간이 절반 이하였지만, 개별 일시 중지 시간은 더 길었습니다 (95백분위수 기준 114.6ms 대 61.9ms). 서비스가 테일 지연 시간에 민감하다면, 선택하기 전에 두 mode를 모두 측정해 보십시오.

지연 시간: 연결 재사용

새로운 TCP 연결을 수립하고 TLS handshake를 수행하는 데는 상당한 시간이 소요됩니다. 연결을 재사용하면 쿼리의 지연 시간을 크게 줄일 수 있습니다.
  • 요청마다 클라이언트를 생성하지 마십시오. 각각 고유한 HttpClient를 가진 새 클라이언트는 새로운 연결 풀을 생성하며, handshake 비용을 다시 치르게 됩니다. 애플리케이션 수명 전체에 걸쳐 하나의 ClickHouseClient를 사용하십시오. 이 클래스는 thread-safe하며 singleton 용도로 설계되었습니다.
  • ADO.NET 및 ORM에서는 ClickHouseDataSource를 사용하여 모든 연결이 하나의 풀을 공유하도록 하십시오.
전체 패턴은 Connection lifetime 및 pooling을 참조하십시오.

직접 측정하기

많은 경우 성능은 데이터의 형태, 서버와의 연결 속도, 클라이언트 CPU와 서버 CPU 중 어느 쪽을 더 사용할지에 대한 트레이드오프, 하드웨어 제약 등에 따라 달라집니다. 따라서 사용자의 데이터와 환경을 기준으로 직접 성능을 측정해 보는 것을 권장합니다. 서버가 담당한 작업량을 확인하려면 QueryOptions.QueryId를 설정하고 카운터를 읽어 오십시오:

ORM 지원

ORM은 ADO.NET API(ClickHouseConnection)를 사용해야 합니다. 연결 수명 주기를 올바르게 관리하려면 ClickHouseDataSource에서 연결을 생성하십시오:

Dapper

ClickHouse.Driver는 Dapper와 함께 사용할 수 있습니다. 드라이버는 Dapper의 @parameter 구문을 ClickHouse의 네이티브 {parameter:Type} 구문으로 자동 변환하며, 타입은 .NET 값에서 자동으로 추론됩니다. 적절한 연결 수명 주기 관리를 위해 ClickHouseDataSource를 사용하세요:

매개변수 전달 방식

Dapper의 모든 표준 매개변수 전달 방식을 지원합니다. 익명 객체:
POCO 클래스:
딕셔너리:
DynamicParameters (딕셔너리나 익명 객체에서):

POCO로 쿼리 결과 매핑하기

Dapper는 이름을 기준으로 컬럼을 속성에 매핑합니다(대소문자를 구분하지 않음).

ClickHouse 네이티브 매개변수 구문

타입을 명시적으로 제어해야 할 때는 SQL에서 ClickHouse의 {param:Type} 구문을 직접 사용하고, 매개변수 값에는 Dictionary<string, object>를 사용하십시오. 동일한 매개변수에 @param 구문과 {param:Type} 구문을 함께 사용하지 마십시오.

WHERE IN

Dapper의 네이티브 IN 확장은 정상적으로 동작합니다:
Dapper는 이를 WHERE id IN (@Ids1, @Ids2, @Ids3)로 재작성하고, 드라이버는 확장된 각 매개변수를 개별적으로 변환합니다. 배열 매개변수를 사용하는 ClickHouse의 has() 함수도 동작합니다:

사용자 지정 타입 핸들러

일부 ClickHouse 타입(예: ITuple, BigInteger, ClickHouseDecimal)은 애플리케이션 시작 시 핸들러를 등록해야 합니다:
type handler 구현 예시는 Dapper 예시를 참조하십시오.

Dapper.Contrib

GetAll<T>() 및 Get<T>(id)는 작동합니다. Insert<T>()는 작동하지 않습니다. SQL Server 구문(SCOPE_IDENTITY, [])을 생성하기 때문입니다. 대신 ClickHouseClient 네이티브 InsertBinaryAsync 메서드를 사용하는 것이 좋습니다.
속성 이름은 ClickHouse 컬럼 이름과 정확히 일치해야 합니다(대소문자 구분).

제한 사항

Linq2db

이 드라이버는 .NET용 경량 ORM 및 LINQ 프로바이더인 linq2db와 호환됩니다. 자세한 내용은 프로젝트 웹사이트 문서를 참조하십시오. 예시 사용법: ClickHouse 프로바이더를 사용하여 DataConnection을 생성합니다:
테이블 매핑은 특성 또는 Fluent API 구성으로 정의할 수 있습니다. 클래스 이름과 속성 이름이 테이블 및 컬럼 이름과 정확히 일치하면 별도의 구성이 필요하지 않습니다:
쿼리 실행:
대량 복사: 효율적인 대량 삽입을 위해 BulkCopyAsync를 사용하세요.

Entity Framework Core

ClickHouse용 공식 Entity Framework Core 프로바이더입니다. C# 클래스를 ClickHouse 테이블에 매핑하고, LINQ로 쿼리하고, SaveChanges를 통해 데이터를 삽입하는 작업을 모두 익숙한 EF Core 패턴으로 수행할 수 있습니다.
이 프로바이더는 현재 활발히 개발되고 있습니다. 현재 릴리스에서는 LINQ 쿼리(JOIN, 서브쿼리, 집합 연산 포함), SaveChanges / BulkInsertAsync를 통한 INSERT, 전체 DDL(CREATE / ALTER / DROP)을 포함한 마이그레이션, 그리고 ClickHouse 전용 테이블 엔진 구성을 지원합니다. UPDATE / DELETE는 지원되지 않습니다.

설치

.NET 10.0 및 EF Core 10이 필요합니다.

빠른 시작

엔터티와 DbContext를 정의한 다음 LINQ로 쿼리합니다:

지원되는 타입

Decimal128/Decimal256 컬럼의 전체 정밀도가 필요한 경우 decimal 대신 ClickHouse.Driver.Numerics의 ClickHouseDecimal을 사용하세요 — .NET decimal은 유효 자릿수가 28~29자리로 제한됩니다.

지원되는 LINQ 작업

쿼리: Where, OrderBy, Take, Skip, Select, First, Single, Any, All, Count, Distinct, AsNoTracking GROUP BY 및 집계: Count, LongCount, Sum, Average, Min, Max와 함께 사용하는 GroupBy — HAVING(.GroupBy() 뒤의 .Where()), 단일 프로젝션의 여러 집계, 집계 결과에 대한 OrderBy를 포함합니다. 조인: Join(INNER), GroupJoin/SelectMany 패턴(LEFT 및 CROSS). LEFT JOIN은 일치하는 항목이 없는 행에 실제 null을 반환합니다(아래의 LEFT JOIN NULL 의미 체계 참조). 서브쿼리: 상관 Contains / IN, Any / EXISTS, All, 그리고 프로젝션 내 스칼라 서브쿼리. 집합 연산: Concat(→ UNION ALL), Union(→ UNION DISTINCT), Intersect, Except. 인라인 로컬 컬렉션: 메모리 내 컬렉션(int[], List<T> 등)에 대한 조인과 Contains는 일련의 UNION으로 변환됩니다. 문자열 메서드: Contains, StartsWith, EndsWith, IndexOf, Replace, Substring, Trim/TrimStart/TrimEnd, ToLower, ToUpper, Length, IsNullOrEmpty, Concat(및 + 연산자). 수학 함수: 표준 Math 및 MathF 메서드는 산술, 로그, 삼각, 유틸리티 함수를 비롯해 해당 ClickHouse 함수로 변환됩니다. 프로바이더는 JOIN 동작에 대한 Entity Framework의 기대에 맞추기 위해 모든 연결 경로에 set_join_use_nulls=1을 자동으로 주입합니다. ClickHouse 서버 또는 프로필에서 이 설정 변경을 금지하는 경우(예: readonly=1 프로필) 다음과 같이 비활성화하십시오:
옵트아웃을 활성화하면 LEFT JOIN은 ClickHouse 컬럼의 기본값을 반환하며, EF의 null 기반 탐색 감지 기능이 더 이상 예상대로 작동하지 않습니다. == null 대신 0 / ""에 대한 명시적 비교를 사용하세요.

데이터 삽입

SaveChanges는 드라이버의 네이티브 InsertBinaryAsync API를 사용합니다 — 압축된 요청 본문과 함께 RowBinary 인코딩을 사용하므로, 매개변수화된 SQL보다 훨씬 효율적입니다:
엔터티는 저장 후 다른 EF Core 프로바이더와 마찬가지로 Added에서 Unchanged로 전환됩니다. 배치 크기는 설정할 수 있으며(기본값은 1000):

대량 삽입

높은 처리량의 로드에는 SaveChanges 대신 BulkInsertAsync를 사용하세요. 이는 DbContext의 확장 메서드로, EF Core의 변경 추적기, identity resolution, 상태 관리를 완전히 우회하고 RowBinary 인코딩과 압축된 요청 본문을 사용해 드라이버의 InsertBinaryAsync를 직접 호출합니다. 따라서 삽입 후 엔터티 추적이 필요 없는 대규모 데이터셋을 로드하는 데 적합합니다:
입력은 어떤 IEnumerable<T>이든 사용할 수 있으며, 모든 엔터티를 메모리에 로드하지 않고 스트리밍 방식으로 처리합니다. 반환값은 삽입된 행 수입니다. 삽입 후 엔터티는 DbContext에 attach되지 않으므로 Added → Unchanged 상태 전환이 발생하지 않습니다.

열거형

ClickHouse Enum8/Enum16 컬럼은 string 속성 또는 C# enum 타입에 매핑할 수 있습니다. C# 열거형을 사용하면 프로바이더가 열거형과 해당 문자열 표현 간에 자동으로 변환합니다:

사용자 지정 타입 변환

EF Core의 ValueConverter 시스템을 사용하면 사용자 지정 타입을 프로바이더가 이미 지원하는 타입에 매핑할 수 있습니다. 프로바이더는 사용자 지정 타입을 직접 처리하지 않으며, EF Core가 그 경계에서 타입을 변환합니다. 속성별 변환:
재사용 가능한 컨버터 클래스:

컬럼 타입 어노테이션

string, int, DateTime 등과 같은 스칼라 타입의 경우 프로바이더가 ClickHouse 타입을 자동으로 추론합니다. 매개변수화된 타입과 래퍼의 경우에는 ClickHouse 타입을 명시적으로 지정해야 합니다. 데이터 어노테이션(속성) 사용:
OnModelCreating에서 Fluent API 사용:
Array(Nullable(Int32)) 및 LowCardinality(Nullable(String))와 같은 중첩된 래퍼 형식이 지원되며, 프로바이더는 모든 중첩 수준에서 Nullable과 LowCardinality를 자동으로 해제합니다.

Variant 및 Dynamic 컬럼

ClickHouse Variant(T1, T2, ...) 및 Dynamic 컬럼은 .NET의 object에 매핑됩니다. object는 자동 형식 유추를 하기에는 너무 범용적이므로, .HasColumnType()을 사용해 저장 형식을 명시적으로 선언해야 합니다:
읽는 시점에 값은 저장된 discriminator에 대응하는 .NET 형식으로 자동으로 역직렬화됩니다(예: string, ulong, ulong[]).

JSON 컬럼

이 프로바이더는 ClickHouse의 Json 컬럼 타입을 지원하며, System.Text.Json.Nodes.JsonNode(프라이머리) 또는 string(자동 ValueConverter를 통해)으로 매핑됩니다:
JSON 읽기와 쓰기는 SaveChanges와 BulkInsertAsync를 통해 모두 수행할 수 있습니다:
raw JSON 문자열을 선호하는 경우, 속성을 Json 컬럼 유형의 string으로 매핑하세요. 프로바이더에서 ValueConverter를 자동으로 적용합니다:
  • JSON 경로 변환 없음 — LINQ의 entity.Data["name"]는 ClickHouse의 data.name SQL 구문으로 변환되지 않습니다. JSON이 아닌 컬럼에 필터를 적용하고, 메모리에서 JSON을 확인하십시오.
  • NULL 의미 체계 — ClickHouse의 JSON 타입은 NULL 값에 대해 SQL NULL 대신 {}(빈 객체)를 반환합니다.
  • 정수 정밀도 — ClickHouse JSON은 모든 정수를 Int64로 저장합니다. JsonNode로 읽을 때는 GetValue<int>() 대신 GetValue<long>()를 사용하십시오.

테이블 엔진

ToTable(name, t => ...) Fluent API를 사용해 ClickHouse 테이블 엔진과 엔진별 절을 구성합니다. 엔진을 지정하지 않으면 프로바이더는 엔터티의 기본 키(primary key)에서 도출한 ORDER BY를 사용하며, 기본 테이블 엔진으로 MergeTree를 적용합니다.
지원되는 엔진 계열: 엔진 절: WithOrderBy, WithPartitionBy, WithPrimaryKey, WithSampleBy, WithTtl, WithSettings. 모두 HasXxxEngine()이 반환하는 엔진 빌더에 연결됩니다. 컬럼 수준 기능: HasCodec, HasTtl, HasComment, HasDefault — 모두 마이그레이션에 포함됩니다. 데이터 스키핑 인덱스 — HasIndex(...).HasSkippingIndexType(...)를 통해 사용합니다:
표준(스키핑이 아닌) 인덱스는 ClickHouse에 해당 기능이 없으므로 아무 경고 없이 무시됩니다. 고유 인덱스는 ClickHouse가 고유성을 강제하지 않으므로 예외를 발생시킵니다.

마이그레이션

EF Core의 표준 마이그레이션 워크플로:
지원되는 작업:

마이그레이션 제한 사항

마이그레이션 외에도 이 프로바이더는 아직 다음을 지원하지 않습니다:
  • UPDATE / DELETE
  • 트랜잭션: BeginTransaction은 no-op입니다. ClickHouse는 ACID 트랜잭션을 지원하지 않습니다.
  • JSON 경로 쿼리 변환: LINQ의 entity.Data["key"]는 ClickHouse의 data.key SQL 구문으로 변환되지 않습니다. JSON이 아닌 컬럼을 기준으로 필터링하고, 메모리에서 JSON을 확인하십시오.

제한 사항

요소가 8개 이상이고 마지막 위치에 중첩 튜플이 있는 튜플

요소가 7개를 초과하는 C# ValueTuple 타입은 컴파일러가 생성한 중첩 방식을 사용합니다. 즉, 8번째 제네릭 인수(TRest)는 나머지 요소를 담는 또 다른 ValueTuple입니다. 예를 들어 (int, int, int, int, int, int, int, string, string)는 ValueTuple<int, int, int, int, int, int, int, ValueTuple<string, string>>로 컴파일됩니다. 이로 인해 ClickHouse 컬럼이 8개 요소로 이루어진 튜플이고 마지막 요소 자체도 튜플인 경우 모호성이 생깁니다. 예를 들어 Tuple(Int32, Int32, Int32, Int32, Int32, Int32, Int32, Tuple(String, String))가 이에 해당합니다. 드라이버는 다음 두 경우를 구분할 수 없습니다.
  • 컴파일러가 생성한 TRest 중첩을 사용하는 9개 요소의 평면 튜플
  • 마지막 요소가 중첩된 Tuple(String, String)인 8개 요소 튜플
두 경우 모두 동일한 .NET Type인 ValueTuple<int, int, int, int, int, int, int, ValueTuple<string, string>>가 생성됩니다. 드라이버는 8번째 인수를 TRest로 처리합니다(즉, 평탄화합니다). 따라서 마지막에 중첩 튜플이 있는 8개 요소 케이스는 잘못 serialize됩니다. 이는 System.Tuple와 ValueTuple 모두에 영향을 줍니다. 둘 다 요소가 7개를 초과하면 TRest 중첩을 사용하기 때문입니다. 요소가 7개 이하인 튜플이나 마지막 요소 자체가 튜플이 아닌 튜플은 영향을 받지 않습니다. 우회 방법: 드라이버가 이를 TRest 중첩과 구분할 수 있도록 내부 튜플을 한 겹 더 감싸십시오:

AggregateFunction 컬럼

AggregateFunction(...) 유형의 컬럼은 직접 쿼리하거나 삽입할 수 없습니다. 삽입하려면:
조회하려면:

마지막 수정일 2026년 9월 26일