NULL 값 지원
NULL 값 지원 (Null Value Support)
Apache Pinot는 SQL에서 NULL 값을 다룰 수 있도록 여러 단계의 지원 모드를 제공해요. 기본적으로는 NULL 저장이 꺼져 있지만, 수집 시점에 NULL을 저장하도록 설정하고 쿼리 시점에 처리 모드를 골라서 표준 SQL 방식의 NULL 처리를 쓸 수 있어요. 이 문서가 없는 상태에서 헷갈리기 쉬운 부분을 정리해 드릴게요.
출처: 문서
본문
경고: 역사적인 이유로 Apache Pinot에서는 NULL 지원이 기본적으로 비활성화되어 있어요. 이는 향후 버전에서 변경될 예정이에요.
역사적인 이유로 Apache Pinot에서는 NULL 지원이 기본적으로 비활성화되어 있어요. NULL 지원이 비활성화되면 모든 컬럼이 not null로 취급돼요. IS NOT NULL 같은 조건(predicate)은 true로, IS NULL은 false로 평가돼요. COUNT, SUM, AVG, MODE 같은 집계 함수도 모든 컬럼을 not null로 취급해요.
예를 들어 아래 쿼리의 조건은 모든 레코드와 일치해요.
select count(*) from my_table where column IS NOT NULL
데이터의 NULL 값을 다루려면 다음을 수행해야 해요.
- 데이터 수집 전에 Pinot가 NULL 값을 저장하도록 지정해요. 수집 시점에 NULL 저장을 참고하세요.
- 쿼리 시점에 NULL 처리 모드 중 하나를 사용해요. 기본적으로 Pinot는
IS NULL과IS NOT NULL조건만 지원하는 기본 지원 모드를 사용하지만, 고급 NULL 처리 지원을 활성화할 수 있어요.
다음 표는 Pinot에서 NULL 처리 지원의 동작을 요약한 거예요.
| 비활성화 (기본) | 기본 (수집 시점 활성화) | 고급 (쿼리 시점 활성화) | |
|---|---|---|---|
| IS NULL | 항상 false | 데이터에 따라 다름 | 데이터에 따라 다름 |
| IS NOT NULL | 항상 true | 데이터에 따라 다름 | 데이터에 따라 다름 |
| 변환 함수 (Transformation functions) | 기본값 사용 | 기본값 사용 | null 인지 |
| NULL 인지 집계 (Null aware aggregations) | 기본값 사용 | 기본값 사용 | null 인지 |
Pinot가 NULL 값을 저장하는 방법
Pinot는 컬럼 값을 항상 forward index에 저장해요. Forward index는 NULL 값을 저장하지 않지만 각 행마다 값을 저장해야 해요. 따라서 NULL 처리 설정과 무관하게 Pinot는 항상 null 행에 대해 기본값을 forward index에 저장해요. 컬럼에 사용되는 기본값은 스키마 설정에서 defaultNullValue 필드 스펙으로 지정할 수 있어요. defaultNullValue는 데이터 타입에 따라 달라져요.
참고: 테이블 구성으로 쓰이는 JSON에서는
defaultNullValue가 항상 String이어야 해요. 컬럼 타입이 String이 아니면 Pinot가 그 값을 자동으로 컬럼 타입으로 변환해요.
비활성화된 NULL 처리
기본적으로 Pinot는 NULL 값을 전혀 저장하지 않아요. 즉 기본적으로 NULL 값이 수집되면 Pinot는 대신 기본 NULL 값(앞서 정의된 값)을 저장해요.
NULL 값을 저장하려면 아래 설명대로 테이블을 구성해야 해요.
수집 시점에 NULL 저장
NULL 저장이 활성화되면 Pinot는 null index 또는 null vector index라는 새 인덱스를 만들어요. 이 인덱스는 해당 컬럼에 NULL 값을 가진 행의 문서 ID를 저장해요.
위험: NULL 저장은 데이터가 수집된 후에도 활성화할 수 있지만, 이 모드가 활성화되기 전에 수집된 데이터는 null index를 저장하지 않으므로 not null로 취급돼요.
기존 세그먼트에 null index 백필(backfill)
NULL 저장이 활성화되기 전에 수집된 세그먼트는 세그먼트 리로드 중에 null vector를 재구성할 수 있어요. Pinot는 과거의 null과 컬럼의 defaultNullValue와 같은 실제 값을 구분할 수 없기 때문에 이 작업은 컬럼별 opt-in 작업이에요.
컬럼 기반 NULL 저장 또는 테이블 기반 NULL 저장을 사용해 컬럼의 NULL 저장을 활성화해요. 그런 다음 컬럼을 테이블 구성의 fieldConfigList에 추가해요.
{
"fieldConfigList": [
{
"name": "status",
"indexes": {
"null": {
"backfill": true
}
}
}
]
}
다음으로 영향받는 세그먼트를 리로드해요. 첫 리로드에서 Pinot는 컬럼의 forward index를 스캔하고, 저장된 값이 기본 null 값과 일치하면 해당 행을 null로 표시해요. 멀티-밸류 컬럼의 경우 저장된 값이 기본 null 값을 포함하는 단일 요소 배열이어야 해요. Pinot는 백필이 완료된 후에는 컬럼을 다시 스캔하지 않아요.
경고: 이 작업은 손실이 있을 수 있어요(lossy). 기본 null 값이 실제 값으로 발생할 수 없는 sentinel 값일 때만 활성화하세요. 메트릭 기본값
0이나 boolean 기본값false처럼 기본값이 일반적으로 유효한 데이터인 컬럼에서는 활성화하지 마세요.
백필은 지원되는 스칼라 저장 타입에서만 지원돼요. BOOLEAN과 MAP 같은 복합 타입을 포함한 지원되지 않는 타입은 테이블 구성 검증에서 거부돼요. 시간 컬럼의 경우 백필을 활성화하기 전에 범위 내의 명시적인 defaultNullValue를 설정하세요. 모든 서버가 이를 지원하는 Pinot 버전을 실행할 때까지 indexes.null.backfill을 활성화하지 마세요. 오래된 서버는 이 인덱스 구성을 읽을 수 없어요.
NULL 지원은 테이블별로 구성돼요. 한 테이블은 NULL을 저장하도록, 다른 테이블은 NULL을 저장하지 않도록 구성할 수 있어요. Pinot에서 NULL 저장 지원을 정의하는 방법은 두 가지예요.
- 컬럼 기반 NULL 저장 — 테이블의 각 컬럼을 nullable 또는 not nullable로 구성해요. 컬럼별로 NULL 저장 지원을 활성화하는 것을 권장해요. 이 방식만 multi-stage 쿼리 엔진에서 NULL 처리를 지원할 수 있어요.
- 테이블 기반 NULL 저장 — 테이블의 모든 컬럼을 nullable로 간주해요. Pinot 1.1.0 이전에 NULL 값을 처리하던 방식이며 지금은 deprecated예요.
참고: 컬럼 기반 NULL 저장이 테이블 기반 NULL 저장보다 우선한다는 점을 기억하세요. 두 모드가 모두 활성화된 경우 컬럼 기반 NULL 저장이 사용돼요.
컬럼 기반 NULL 저장
컬럼 기반 NULL 저장을 구성하는 것을 권장해요. 이 방식은 컬럼별로 NULL 처리를 지정할 수 있고 multi-stage 쿼리 엔진에서 NULL 처리를 지원해요.
컬럼 기반 NULL 처리를 활성화하려면:
- 데이터 수집 전에 스키마 구성에서 enableColumnBasedNullHandling을
true로 설정해요. - 그런 다음 기본값이 false인
notNull필드 스펙을 사용해 nullable이 아닌 컬럼을 지정해요.
{
"schemaName": "my_table",
"enableColumnBasedNullHandling": true,
"dimensionFieldSpecs": [
{
"name": "notNullColumn",
"dataType": "STRING",
"notNull": true
},
{
"name": "explicitNullableColumn",
"dataType": "STRING",
"notNull": false
},
{
"name": "implicitNullableColumn",
"dataType": "STRING"
}
]
}
테이블 기반 NULL 저장
이 방식은 Pinot 1.1.0 이전에 NULL 저장을 활성화하는 유일한 방법이었지만 그 이후로 deprecated예요. 테이블 기반 NULL 저장은 디스크 공간과 쿼리 성능 측면에서 컬럼 기반 NULL 저장보다 더 비싸요. 또한 테이블 기반 NULL 저장으로는 multi-stage 쿼리 엔진에서 NULL 처리를 지원할 수 없어요.
테이블 기반 NULL 저장이 활성화되면 모든 컬럼이 nullable로 간주돼요. 이 모드를 활성화하려면:
- tableIndexConfig.nullHandlingEnabled에서
nullHandlingEnabled구성을 활성화해요. - 스키마에서 enableColumnBasedNullHandling을 비활성화해요.
경고:
nullHandlingEnabled테이블 구성은 테이블 기반 NULL 처리를 활성화하는 반면,enableNullHandling은 쿼리 시점에 고급 NULL 처리를 활성화하는 쿼리 옵션이라는 점을 기억하세요. 자세한 내용은 고급 NULL 처리 지원을 참고하세요.
예를 들어:
{
"tableIndexConfig": {
"nullHandlingEnabled": true
}
}
쿼리 시점의 NULL 처리
쿼리 시점에 기본 NULL 처리를 활성화하려면 Pinot가 수집 시점에 NULL을 저장하도록 설정해요. 고급 NULL 처리 지원은 선택적으로 활성화할 수 있어요.
참고: multi-stage 쿼리 엔진은 컬럼 기반 NULL 저장을 요구해요. 테이블 기반 NULL 저장을 사용하는 테이블은 not nullable로 간주돼요.
single-stage 쿼리 엔진의 NULL 지원에서 전환한다면, 스키마를 수정해 enableColumnBasedNullHandling을 설정하면 돼요. nullHandlingEnabled를 제거하거나 false로 설정하기 위해 테이블 구성을 변경할 필요는 없어요. 사실 테이블에 null이 포함될 수 있음을 명확히 하기 위해 true로 유지하는 것을 권장해요. 전환 시:
- 재수집(reingestion)은 필요 없어요.
- 컬럼이 nullable에서 not nullable로 변경되고 이전에 null이었던 값이 있다면 기본값이 대신 사용돼요.
기본 NULL 지원
기본 NULL 지원은 세그먼트에 NULL 값이 저장되면 자동으로 활성화돼요 (수집 시점에 NULL 저장 참고).
이 모드에서 Pinot는 IS NULL이나 IS NOT NULL 같은 단순 조건을 처리할 수 있어요. 다른 변환 함수(CASE, COALESCE, + 등)와 집계 함수(COUNT, SUM, AVG 등)는 null 값에 대해 스키마에 지정된 기본값을 사용해요.
예를 들어 다음 테이블에서:
| rowId | col1 |
|---|---|
| 0 | null |
| 1 | 1 |
| 2 | 2 |
| 3 | 2 |
| 4 | null |
col1의 기본값이 1이라면 다음 쿼리:
select $docId as rowId, col1 from my_table where col1 IS NOT NULL
는 다음 결과를 반환해요.
| rowId | col1 |
|---|---|
| 1 | 1 |
| 2 | 2 |
| 3 | 2 |
반면:
select $docId as rowId, col1 + 1 as result from my_table
는 다음을 반환해요.
| rowId | col1 |
|---|---|
| 0 | 2 |
| 1 | 2 |
| 2 | 3 |
| 3 | 3 |
| 4 | 2 |
그리고 다음과 같은 쿼리는:
select $docId as rowId, col1 from my_table where col1 = 1
다음을 반환해요.
| rowId | col1 |
|---|---|
| 0 | null |
| 1 | 1 |
| 4 | null |
또한:
select count(col1) as count, mode(col1) as mode from my_table
| count | mode |
|---|---|
| 5 | 1 |
count와 mode 함수 모두 기대처럼 null 값을 무시하지 않고 forward index에 저장된 기본값(이 경우 1)을 읽기 때문이에요.
고급 NULL 처리 지원
고급 NULL 처리는 두 가지 요구사항이 있어요.
- 세그먼트가 NULL 값을 저장해야 해요 (수집 시점에 NULL 저장 참고).
- 쿼리가 query option인
enableNullHandling을true로 설정해 NULL 처리를 활성화해야 해요.
후자는 다음 중 한 가지 방법으로 할 수 있어요.
- 쿼리 시작 부분에
enableNullHandling=true를 설정해요. - JDBC를 사용한다면 접속 옵션
enableNullHandling=true를 설정해요 (URL 또는 property로).
대안으로 모든 쿼리에서 기본적으로 고급 NULL 처리를 활성화하려면 broker 구성 pinot.broker.query.enable.null.handling을 true로 설정할 수 있어요. 개별 쿼리는 필요 시 enableNullHandling 쿼리 옵션으로 false로 재정의할 수 있어요.
경고: 이름이 비슷하지만
nullHandlingEnabled테이블 구성과enableNullHandling쿼리 옵션은 서로 달라요.nullHandlingEnabled테이블 구성은 세그먼트가 어떻게 저장되는지를 바꾸고enableNullHandling쿼리 옵션은 쿼리가 어떻게 실행되는지를 바꾼다는 점을 기억하세요.
enableNullHandling 옵션이 true로 설정되면 Pinot 쿼리 엔진은 null을 표준 SQL 방식으로 해석하는 다른 실행 경로를 사용해요. 즉 IS NULL과 IS NOT NULL 조건이 null 감지 여부에 따라 true 또는 false로 평가될 뿐만 아니라(기본 NULL 지원 모드처럼) COUNT, SUM, AVG, MODE 같은 집계 함수도 null 값을 기대대로 처리해요(보통 null 값을 무시).
필터는 삼치 논리(three-valued SQL logic)도 사용해요: null 행은 해당 컬럼에 대한 비교의 일치도 아니고 부정의 일치도 아니에요. 이는 인덱스 필터 단축과 빠른 필터링된 count에도 적용되며, 한 번에 하나씩 평가되는 행에만 적용되는 게 아니에요. 예를 들어 enableNullHandling=true에서 WHERE c <> 1과 WHERE NOT (c = 1) 모두 저장된 기본 null 값이 비교를 만족하더라도 c가 null인 행을 제외해요. COUNT(*)는 여전히 필터에 선택된 모든 행을 세며, 제외된 null 행은 세지 않아요. 해당 행을 명시적으로 선택하려면 WHERE c IS NULL을 사용해요. 필터링된 컬럼이 그 세그먼트에 null 행을 포함하면 star-tree 인덱스는 필터링된 쿼리를 제공할 수 없어요.
스케치 기반 distinct-count 함수도 고급 NULL 처리가 활성화되면 null 행을 건너뛰어요. 이는 DISTINCTCOUNTBITMAP, DISTINCTCOUNTHLL, DISTINCTCOUNTHLLPLUS, DISTINCTCOUNTULL, DISTINCTCOUNTTHETASKETCH, DISTINCTCOUNTCPCSKETCH, FASTHLL, SEGMENTPARTITIONEDDISTINCTCOUNT 및 이들의 raw, smart, single-value, multi-value 변형에 적용돼요. 고급 NULL 처리가 비활성화되면 null 행은 계속해서 컬럼에 구성된 기본 null 값을 기여해요. count 반환 변형은 기여하는 non-null 행이 없으면 0을 반환하고, raw 스케치 변형은 함수별 빈 스케치 결과를 유지해요.
SKEWNESS, KURTOSIS, STUNION, HISTOGRAM, IDSET, SUMARRAYLONG, SUMARRAYDOUBLE도 고급 NULL 처리가 활성화되면 null 행을 건너뛰어요. 비활성화되면 이 함수들은 레거시 동작을 유지하며 컬럼에 구성된 기본 null 값을 집계해요. HISTOGRAM과 STUNION은 multi-value 입력을 받고, IDSET은 multi-value BYTES 입력을 받아요.
이 모드에서는 일부 인덱스를 사용할 수 없을 수 있고 쿼리가 상당히 비쌀 수 있어요. 성능 저하는 쿼리에 null 값을 포함하지 않는 컬럼을 포함해 테이블의 모든 컬럼에 영향을 줘요. 이 저하는 테이블이 컬럼 기반 NULL 저장을 사용할 때도 발생해요.
Star-tree는 all-or-nothing 규칙의 한 예외예요: 쿼리가 사용하는 집계 컬럼, 필터 컬럼, group-by 컬럼이 그 세그먼트에 null 값을 포함하지 않는다면 enableNullHandling=true일 때도 Pinot는 세그먼트에 star-tree 인덱스를 사용할 수 있어요. IS NULL 및 IS NOT NULL 같은 조건은 여전히 star-tree 인덱스를 사용할 수 없어요.
예시 쿼리
Select 쿼리
Filter 쿼리
Aggregate 쿼리
Aggregate Filter 쿼리
Group By 쿼리
Order By 쿼리
Transform 쿼리
부록: NULL을 저장하지 않고 NULL 값을 처리하는 우회 방법
사용 사례에 맞게 null index를 생성할 수 없다면, 스키마에 지정된 기본값이나 쿼리에 포함된 특정 값을 사용해 null 값을 필터링할 수 있어요.
참고: 아래 예시 쿼리는 데이터셋에 null 값이 사용되지 않을 때 동작해요. 지정된 null 값이 데이터셋에서 유효한 값이라면 예상치 못한 값이 반환될 수 있어요.
스키마에 지정된 기본 null 값 필터링
- 스키마의 dimension 필드(
dimensionFieldSpecs), metric 필드(metricFieldSpecs), date time 필드(dateTimeFieldSpecs)에 기본 null 값(defaultNullValue)을 지정해요. - 데이터를 수집해요.
- 지정된 기본 null 값을 걸러내려면 예를 들어 다음과 같은 쿼리를 작성할 수 있어요.
select count(*) from my_table where column <> 'default_null_value'
쿼리에서 특정 값 필터링
데이터셋에 포함되지 않을 쿼리의 특정 값을 필터링해요. 예를 들어 평균 나이를 계산할 때 -1을 사용해 Age 값이 null임을 나타내요.
- 다음 쿼리를 다시 작성해요:
select avg(Age) from my_table
- null 값을 다음과 같이 처리해요:
select avg(Age) from my_table WHERE Age <> -1