PostgreSQL array_length가 빈 배열에서 0이 아니라 NULL인 이유 — cardinality와의 차이

PostgreSQL데이터파이프라인DART회귀측정

PostgreSQL에서 array_length(arr, 1) >= 1 같은 조건을 WHERE에 걸면, 빈 배열 행은 걸러지는 게 아니라 판정 자체가 사라집니다. array_length가 빈 차원에서 0이 아니라 NULL을 주기 때문입니다.

SELECT array_length('{}'::text[], 1) AS empty_len, (array_length('{}'::text[], 1) >= 1) AS passes;
--  empty_len | passes
-- -----------+--------
--            |          ← 둘 다 NULL

NULL >= 1은 NULL입니다.

WHERE는 조건이 true인 행만 남기고 false든 NULL이든 나머지는 버립니다.

결과는 “걸러진 것”과 같지만 로그에도 카운트에도 남지 않습니다.

빈 배열에 0을 주는 cardinality(arr) >= 1을 쓰면 같은 일을 false로 합니다. 나중에 “몇 건이 걸러졌나”를 셀 때 이 차이가 갈립니다.

제 파이프라인에서는 이 조건 때문에 종목 코드가 빈 공시가 수익률 집계에서 통째로 빠졌습니다.

데이터만 백필하고 코드를 안 고친 탓에 나흘 만에 같은 상태로 돌아왔는데 석 달 동안 몰랐습니다.

아래는 빈 배열이 어디서 생겼는지, 데이터만 고치면 무슨 일이 벌어지는지, 그리고 왜 안 보였는지입니다.

빈 배열은 어디서 생겼나?

공시를 수집해서 분류하고, 종목 코드를 붙이고, 그 종목의 이후 수익률을 계산해 쌓는 파이프라인입니다. 분류 단계에는 제목만 봐도 결론이 정해지는 정형 공시를 LLM 없이 규칙으로 처리하는 빠른 경로가 있는데, 그 경로가 종목 코드를 빈 배열로 저장하고 있었습니다.

await this.eventFactRepo.save(
  this.eventFactRepo.create({
    sourceItemId,
    eventTags: ruleOutput.eventTags,
    relatedTickers: [],     // ← 하드코딩
    ...
    analyzedBy: 'rule',
  }),
);

LLM 경로에는 원본 payload의 stock_code를 꺼내는 함수가 붙어 있는데(부록), 빠른 경로는 그 함수를 부르지 않습니다.

종목 코드는 원본에 처음부터 들어 있었습니다.

그리고 다음 단계인 수익률 채우기 쿼리에 AND array_length(ef.related_tickers, 1) >= 1이 있어서, 티커가 빈 공시는 수익률 테이블에 행이 생기지 않습니다.

잘못된 값이 들어가지는 않습니다. 측정 대상에서 통째로 빠집니다.

데이터만 고치면 어떻게 되나?

5월에 한 조치는 기존 행의 티커를 원본 코드로 채우는 것이었습니다.

그 시점의 데이터는 맞게 됐고, 확인했고, 완료로 기록했습니다.

코드는 안 건드렸습니다.

그러면 그 뒤에 들어온 행은요? 다시 빈 채로 쌓였고, 일자별로 보면 경계가 하루 단위로 선명합니다.

날짜 규칙 경로 중 원본에 코드가 있는 건수 그중 티커가 빈 행
05-21 165 0
05-22 179 0
05-26 100 100
05-27 118 118
06-01 555 555

나흘입니다. 조치일 이후 첫 영업일부터 전건이 원래 상태입니다.

경로별로 비교하면 더 분명합니다. 원본에 종목 코드가 있는 공시만 놓고 보면 LLM 경로(Claude 6,705건, 로컬 LLM 5,666건)는 티커가 빈 행이 0.0%인데, 규칙 경로 22,018건은 63.6%입니다.

63.6%가 100%가 아닌 이유는 나머지가 5월 이전에 백필로 채워진 행이기 때문입니다.

이 코드는 규칙 경로에서 티커를 채운 적이 한 번도 없습니다. 채워져 있던 행들은 전부 손으로 넣은 것이었습니다.

저는 그걸 보고 “고쳐졌다”고 판단했습니다.

그 조치는 버전 관리에도 남아 있지 않습니다. 일회성 쿼리였으니까요.

나중에 “고쳐졌나”를 되짚을 때 볼 수 있는 건 결과 데이터뿐인데, 그 데이터는 조치 직후에 보면 당연히 맞습니다.

커밋을 뒤져서 반박할 방법이 없는 상태로 “완료”가 남습니다.

왜 석 달 동안 안 보였을까?

필터를 추가하면 항목이 다른 분류로 이동하는 경로와, 티커가 없어 측정 대상에서 아예 이탈하는 경로가 따로 있다 공시 1건 ① 카테고리 이동 ② 티커 없음 분류가 바뀐다 행이 안 생긴다 — 보인다 — 안 보인다 같은 필터 하나가 두 갈래로 갈린다
같은 변경이 두 경로로 영향을 준다. 앞의 것만 확인하면 뒤의 것은 "변화 없음"으로 보인다.

왜 아무도 몰랐을까요?

8월에 규칙을 몇 개 더 추가하면서 그 변경이 기존 측정에 영향을 주는지 확인했습니다. 확인 방법은 분류가 어떻게 바뀌는지 보는 것이었고, 대상 건들이 전부 미분류에서 미분류로 남아서 “영향 없음”이라고 적었습니다.

틀렸습니다.

영향 경로가 둘이었습니다.

경로 ②(티커 없음)는 분류표에 흔적을 안 남깁니다. 미분류에서 미분류로 가는 것처럼 보이지만 실은 측정 대상에서 나가버리고, 라벨이 같으니 대조표에는 아무 변화가 없습니다.

5월 조치가 석 달 동안 안 보인 이유도 같습니다. 되돌아간 항목들은 어디에도 이상하게 나타나지 않고 그냥 없어졌습니다.

자주 묻는 질문

array_length 를 쓰면 안 되나요

써도 됩니다. 이 조건은 원하는 대로 동작해요. 문제는 빈 배열을 false 대신 NULL 로 걸러서 나중에 세어볼 수 없다는 것입니다. 세는 게 필요하면 cardinality 쪽이 맞습니다.

두 번째 차원은요

array_length(arr, 2) 는 2차원 배열의 둘째 차원 길이입니다. 1차원 배열에 쓰면 그것도 NULL 이라 같은 함정이 생깁니다.

백필만으로 끝내도 되는 경우가 있나요

원인이 이미 고쳐졌고 과거 데이터만 남은 경우입니다. 이 글의 사례는 반대였어요 — 코드가 그대로라 다음 배치부터 다시 어긋났습니다. “고쳤다”가 데이터에 대한 말인지 코드에 대한 말인지부터 적어야 합니다.

이런 걸 감시하려면 어떻게 하나요

제 경우에는 “라벨이 바뀌었나” 대신 “이 항목이 아직 세어지고 있나” 를 묻는 쪽으로 확인 질문을 바꿨습니다. 자동 감시 장치는 아직 없습니다.

참고 자료

  • PostgreSQL 배열 함수 는 array_length 가 빈 차원에서 NULL 을 주고 cardinality 가 0 을 준다는 근거입니다.
  • PostgreSQL WHERE 절 은 조건이 true 인 행만 남기고 false 와 NULL 을 똑같이 버린다는 근거입니다.

남는 것

“고쳤다”는 데이터에 대한 말인지 코드에 대한 말인지 구분해서 적어야 합니다. 백필은 그 시점의 데이터를 맞게 만들 뿐입니다. 원인을 안 고치면 다음 배치부터 다시 어긋나고, 그때는 처음보다 알아채기 어렵습니다. 이미 “완료”라고 적어뒀기 때문입니다.

빈 컬렉션에 대한 조건은 삼항 논리를 확인하고 씁니다. array_length(x, 1) >= 1은 원하는 대로 동작하지만 false를 거치지 않고 NULL을 거쳐서 그렇게 됩니다. cardinality(x)는 빈 배열에 0을 주므로 >= 1이 같은 일을 false로 합니다. 결과가 같아도, 나중에 “몇 건이 걸러졌나”를 세려 할 때 갈립니다.

필터를 추가할 때 “분류가 어떻게 바뀌나”만 보면 절반입니다. 항목이 측정 대상에서 아예 빠지는 경로가 따로 있고, 그건 분류표에 안 나타납니다. 확인해야 할 질문은 “라벨이 바뀌었나”보다 “이 항목이 아직 세어지고 있나”입니다.


부록 1: 경로별 집계와 원본 코드를 꺼내는 함수

분류 경로 건수 티커가 빈 행
Claude 6,705 0.0%
로컬 LLM 5,666 0.0%
규칙 22,018 63.6%
private resolveRelatedTickers(sourceItem: SourceItem, llmTickers: string[]): string[] {
  if (sourceItem.sourceType === 'DART') {
    const raw = sourceItem.rawPayload?.stock_code;   // 원본에 이미 들어 있다
    if (typeof raw === 'string') {
      const code = raw.trim();
      if (/^\d{6}$/.test(code)) return [code];
    }
    return [];
  }
  return llmTickers;
}

부록 2: 8월 감사 당시 기록

전향 부분이 틀렸다. 카테고리 매핑만 보고 판단했는데, 영향 경로가 두 개였다 — ② 모집단 이탈은 놓쳤다.

이 사이트의 수치는 주 1회 DB와 다시 대조하고, 판단이 바뀌면 지우지 않고 글 안에 덧붙입니다. 갱신은 RSS로 받을 수 있습니다.