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월 이전에 백필로 채워진 행이기 때문입니다.
이 코드는 규칙 경로에서 티커를 채운 적이 한 번도 없습니다. 채워져 있던 행들은 전부 손으로 넣은 것이었습니다.
저는 그걸 보고 “고쳐졌다”고 판단했습니다.
그 조치는 버전 관리에도 남아 있지 않습니다. 일회성 쿼리였으니까요.
나중에 “고쳐졌나”를 되짚을 때 볼 수 있는 건 결과 데이터뿐인데, 그 데이터는 조치 직후에 보면 당연히 맞습니다.
커밋을 뒤져서 반박할 방법이 없는 상태로 “완료”가 남습니다.
왜 석 달 동안 안 보였을까?
왜 아무도 몰랐을까요?
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로 받을 수 있습니다.