1. 배경: 갱신이 데이터를 지우는 구조
이 파이프라인은 영업 대상 기업의 공시 정보를 매일 갱신해 Account Plan 초안의 근거로 씁니다. 초안 품질이 공시 데이터의 최신성에 직접 의존하므로, 갱신이 실패하면 영업담당자가 낡은 근거로 제안을 작성하게 됩니다.
개발 기간의 적재 대상은 검증용 4개사였고, 상용 시점의 대상은 12,000곳입니다. 규모가 3,000배로 늘어납니다.
이 차이가 위험한 이유는 공시 API의 일일 호출 한도에 있습니다. 4개사 기준 하루 24회는 한도 2만 회의 0.1%에 불과하나, 12,000곳이면 72,000회로 한도를 3.6배 초과합니다. 더 큰 문제는 초과 시점의 동작입니다. 이 API는 한도를 넘겨도 HTTP 200을 반환하고 본문 상태 코드로만 알리므로, 호출부가 이를 예외로 취급하지 않으면 빈 응답을 정상 데이터로 받아 기존 캐시를 덮어씁니다. 매일 도는 갱신 작업이 데이터를 채우는 것이 아니라 지우는 방향으로 동작하게 됩니다.
증상이 보고되기 전에 구조를 점검했고, 결함 세 가지를 확인했습니다. 셋 모두 적재 대상이 적어 드러나지 않고 잠재해 있던 항목입니다.
2. 문제 정의
2.1 하루 호출 한도를 세는 코드가 없었습니다
공시 데이터를 제공하는 외부 API 는 하루 20,000회 한도가 있습니다. 그런데 호출 횟수를 누적하는 코드가 어디에도 없었습니다. 사전 차단도, 누적 카운터도 없었고 20000 이라는 리터럴 자체가 소스에 존재하지 않았습니다.
한도와 관련해 존재한 것은 두 곳뿐이었고 둘 다 사후 반응이었습니다.
| 위치 | 내용 |
|---|---|
| 임원 현황 조회 | 한도 초과 응답 코드를 받으면 503 으로 변환 |
| API 클라이언트 | 한도 초과 시 응답이 ZIP 이 아닌 평문이라는 파싱 분기 |
나머지 세 개 호출부에는 한도 초과 코드에 대한 처리가 아예 없었습니다.
4개사 기준으로는 회사당 6회, 하루 24회입니다. 한도의 0.1% 라 문제가 드러날 수 없었습니다. 12,000곳이면 72,000회로 한도를 3.6배 초과합니다.
문제는 초과하는 순간의 동작입니다. 이 API 는 한도를 넘겨도 HTTP 200 을 반환하고 본문에 상태 코드로 알립니다. 대부분의 호출부가 그것을 예외로 취급하지 않으므로, 빈 결과를 정상 응답으로 받아 기존 캐시에 빈 값을 덮어씁니다. 갱신 작업이 데이터를 채우는 것이 아니라 지우는 방향으로 동작합니다.
2.2 기업 식별자가 28%만 붙어 있었습니다
공시 조회는 기관이 부여한 고유 식별자를 키로 씁니다. 이 값이 없으면 해당 기업은 수집 대상에서 통째로 제외됩니다.
적재된 데이터를 세어 보니 사기업 347곳 중 96곳에만 값이 있었습니다.
private 347곳, 식별자 보유 96
public 909곳, 식별자 보유 128%입니다. 12,000곳으로 환산하면 8,600곳이 공시·재무·임원 정보가 빈 채로 남고, 그 위에서 생성되는 초안도 근거가 빕니다.
원인은 시딩 로직이 기업명만으로 대조하고 있었기 때문입니다. 매칭에 실패한 251곳을 뽑아 보니 표기가 흔들리는 사례들이었습니다. 사명이 변경되었거나(구 사명 → 신 사명), 영문·한글 표기가 갈리는 경우입니다. (주) 제거와 공백 정규화 정도로는 넘을 수 없는 차이였습니다.
2.3 사용하지 않는 값에 회사당 1회를 쓰고 있었습니다
업종 분류 코드를 받기 위해 회사마다 기업 개황 API 를 1회씩 호출하고 있었습니다. 12,000곳이면 12,000회로 하루 한도의 60% 입니다.
이 값을 소비하는 지점을 추적한 결과는 다음과 같았습니다.
pythonreturn _load().get(code[:2], ("", []))앞 2자리만 사용합니다. 3~5자리 정밀도로 받아서 2자리로 잘라 쓰고 있었습니다.
더 확인해 보니 프로젝트 설계 문서에 같은 판단이 이미 기록되어 있었습니다. 앞 2자리로 묶으면 결과가 업종명 기반 매핑과 동일하므로 호출할 이유가 없다는 내용이었습니다. 판단은 내려져 있었고 소비하는 쪽은 그대로 따르고 있었는데, 수집 경로만 이전 방식으로 남아 있었습니다.
3. 기술적 난점
세 문제는 성격이 달라 보이지만 공통 조건이 하나 있습니다. 외부 API 호출은 재시도 자체가 비용이라는 점입니다.
내부 연산이라면 실패해도 다시 돌리면 그만입니다. 그러나 하루 한도가 있는 자원에서는 잘못된 호출 한 번이 그날의 다른 작업에서 차감됩니다. 따라서 "실패하면 재시도한다"가 아니라 "부르기 전에 부를 필요가 있는지 판단한다" 가 기준이 되어야 했습니다.
식별자 매칭 문제에는 조건이 하나 더 있었습니다. 매칭률을 올리려고 추가 조회를 하면 절감하려던 호출을 다시 쓰게 됩니다. 호출을 늘리지 않으면서 매칭률을 올려야 했습니다.
4. 대안 검토
4.1 식별자 매칭
1안. 기업명 정규화 규칙 확대
- 장점: 기존 구조를 유지하며 규칙만 추가하면 됩니다.
- 단점: 사명 변경은 문자열 정규화로 해결되지 않습니다. 규칙을 늘려도 상한이 낮고, 규칙 간 충돌 가능성이 커집니다.
2안. 미매칭 기업만 개별 조회로 보완
- 장점: 매칭률을 확실히 올릴 수 있습니다.
- 단점: 미매칭 251곳에 각각 호출이 필요합니다. 12,000곳 규모에서는 절감 목표와 정면으로 충돌합니다.
3안. 종목코드를 대체 키로 사용
- 장점: 종목코드는 6자리 유일값이라 표기 흔들림이 없습니다. 미매칭 251곳을 조회해 보니 전부 종목코드는 보유하고 있었습니다. 그리고 기관이 배포하는 전체 기업 목록 파일에 종목코드 필드가 함께 들어 있는데, 이 파일은 이름 색인을 만들 때 이미 받고 있었습니다. 추가 호출이 발생하지 않습니다.
- 단점: 비상장 기업에는 적용되지 않습니다. 다만 이번 미매칭 집합에는 해당 사례가 없었습니다.
3안을 채택했습니다.
4.2 업종 코드
1안. 호출을 유지하되 캐시 주기를 늘림
- 장점: 정확도를 그대로 둡니다.
- 단점: 신규 기업 유입 시 여전히 호출이 발생하고, 총량이 회사 수에 비례하는 성질은 변하지 않습니다.
2안. 업종명에서 분류 코드를 유도
- 장점: 호출이 0 이 됩니다. 업종명은 고객 목록에 이미 포함되어 있어 별도 확보가 필요 없습니다.
- 단점: 규칙 기반이라 검증이 필요합니다.
2안을 채택했습니다. 소비하는 쪽이 2자리만 쓰므로 요구 정밀도가 낮다는 것이 근거였습니다.
5. 설계 결정 및 근거
5.1 계수 지점을 두 곳으로 수렴
호출 카운터를 각 호출부에 넣으면 새 호출부가 추가될 때 누락됩니다. 그래서 모든 fetch 함수가 반드시 거치는 저수준 요청 함수 두 개에만 계수를 넣었습니다. 텍스트 응답용과 바이너리 응답용입니다.
한도 초과 감지는 두 갈래로 두었습니다.
- 자체 계수가 예산에 도달하면 호출 전에 차단합니다.
- 응답 본문에 한도 초과 코드가 있으면 그날 예산을 즉시 닫습니다.
두 번째가 필요한 이유는 자체 계수와 API 측 실제 계수가 어긋날 수 있기 때문입니다. 재시도나 다른 프로세스의 호출이 반영되지 않습니다. 자체 계수를 신뢰하되 실제 응답을 우선하는 구조입니다.
바이너리 응답은 ZIP 이라 선두 바이트가 PK 입니다. 여기에 문자열 검사를 그대로 적용하면 오탐하므로, 평문일 때만 검사하도록 분기했습니다.
5.2 예산 소진 시 동작을 호출자별로 분리
같은 "예산 없음" 상태라도 부르는 쪽에 따라 필요한 동작이 다릅니다.
| 부르는 쪽 | 예산 없을 때 |
|---|---|
| 대시보드 엔드포인트 | 빈 결과 반환 |
| 적재 워커 | 예외 발생 |
대시보드는 묵은 캐시로라도 화면을 그려야 하므로 예외가 올라오면 안 됩니다. 반면 적재 워커는 "예산이 없어 못 받았다"와 "받았는데 비었다"를 구분해야 합니다. 앞의 경우 진행 커서를 이동시키면 그 구간이 영구 누락되고, 뒤의 경우 이동시키지 않으면 무한 반복됩니다. 예외 여부로 이 둘을 나눴습니다.
5.3 규칙 순서가 결과를 바꾸는 지점
업종명에서 분류 코드를 유도하는 규칙은 위에서부터 먼저 맞는 것을 채택하는 구조입니다. 첫 실행에서 IT 서비스 기업 한 곳이 소프트웨어(62)가 아니라 전자부품(26)으로 분류되었습니다.
26 규칙에 포함된 "컴퓨터" 키워드가 62 보다 앞에 있어 먼저 걸린 것입니다. 62 규칙을 26 앞으로 이동시켜 해결했고, 잘못 저장된 1곳도 함께 정정했습니다.
키워드 규칙에서는 규칙의 내용뿐 아니라 위치가 결과를 결정합니다. 동일한 성질의 매핑이 프로젝트 내에 하나 더 있어 설계 문서에 경고를 남겼습니다.
6. 실험 결과
6.1 식별자 매칭
시딩 말미에 종목코드 기반 보정을 자동 실행하도록 추가했습니다.
종목코드로 식별자 보정: {'대상': 251, '채움': 251, '못찾음': 0, '호출': 1}251곳 전부가 채워졌고 호출은 1회입니다. 이 1회는 색인 생성을 위한 것이므로 회사 수와 무관하게 고정입니다.
6.2 업종 코드 유도
정확도는 원래 방식으로 값을 확보한 5곳을 정답지로 삼아 검증했습니다.
| 업종명 | 유도값 | 실제값 |
|---|---|---|
| 종합 소매업 | 47 | 47 |
| 반도체 제조업 | 26 | 26 |
| 금융 지원 서비스업 | 66 | 66 |
| 통신 및 방송 장비 제조업 | 26 | 26 |
| 자동차용 엔진 및 자동차 제조업 | 30 | 30 |
5건 전부 일치했습니다. 다만 5건은 정확도를 확정하기에 부족한 표본입니다. 원래 방식으로 확보된 값이 그것뿐이라 현재로서는 이 이상 검증할 수 없고, 상용 적재 후 표본을 늘려 재검증할 항목으로 남겨 두었습니다.
커버리지는 업종명 78종을 규칙에 반영해 두 차례에 걸쳐 채웠습니다.
| 단계 | 매칭 | 비율 |
|---|---|---|
| 1차 | 320 / 347 | 92% |
| 2차 | 347 / 347 | 100% |
6.3 한도 추적
예산 소진 상태를 강제로 만들어 동작을 확인했습니다.
remaining after exhaust: 0
budget_exhausted(): True
기업정보 조회 시도 -> None (네트워크 요청 없음)
워커 모드: 예외 발생 확인며칠에 걸친 백필도 시뮬레이션했습니다.
day1 (예산 5곳): 처리 5, 중단 사유 예산 소진, 커서 413
day2 (예산 6곳): 처리 6, 중단 사유 예산 소진, 커서 424
커서 이동: 413 -> 424
중복 없음: True (11건 처리)
day3 (예산 0): 처리 0, 커서 유지: True
완주 (무제한): 처리 85, 커서 초기화 0, 사이클 1
전건 고유: True6.4 종합
| 항목 | 이전 | 이후 |
|---|---|---|
| 업종코드 확보 | 회사당 1회 (12,000회) | 0회 |
| 식별자 매칭률 | 28% (96/347) | 100% (호출 1회) |
| 한도 추적 | 없음 | 계수 2지점 · 사전 차단 |
회사당 6회 중 1회가 제거되었고, 백필이 실제로 수행되는 대상이 3.6배로 늘었습니다.
이 수치는 사기업 347곳 기준의 실측이며, 12,000곳 규모는 아직 검증하지 않았습니다. 매칭률은 종목코드 보유 여부에 의존하므로 비상장 기업 비중이 높아지면 달라질 수 있습니다.
7. 결론 및 시사점
첫째, 값을 확보하기 전에 소비하는 쪽을 먼저 확인해야 합니다. 업종 코드는 5자리로 받아 2자리로 잘라 쓰고 있었습니다. 사용처를 한 번만 따라갔으면 애초에 호출할 이유가 없었습니다. 수집 코드는 "이 값이 필요하다"까지만 알고 "얼마나 정밀하게 필요한가"는 모르는 상태로 작성되기 쉽습니다.
둘째, 선택적으로 동작하는 코드는 매칭률을 세어야 합니다. "값이 있으면 사용한다" 형태의 분기는 값이 없을 때 조용히 건너뜁니다. 예외도 로그도 남지 않습니다. 대상이 적을 때는 드러나지 않다가 규모가 커지면 대부분이 누락됩니다. 이번 경우 72%가 그렇게 빠지고 있었습니다.
셋째, 한도가 있는 자원은 사용량을 세는 지점을 먼저 만들어야 합니다. 한도 초과가 예외로 보고되지 않고 정상 응답 형태로 반환되는 API 에서는, 계측이 없으면 실패했다는 사실 자체를 알 수 없습니다. 이 경우 갱신 작업이 유효한 데이터를 빈 값으로 덮어쓰는 방향으로 동작합니다.
세 문제 모두 검증 대상이 4개사인 동안에는 정상으로 보였습니다. 규모를 가정한 점검은 부하 테스트와는 별개로, 코드가 암묵적으로 전제하는 조건을 확인하는 작업입니다.
