Notes
Strategy2026

복지정책 자격 판정 엔진 — LLM을 쓰지 않기로 한 이유

청년 지원사업을 개인 조건에 맞춰 판정하는 엔진에서, 가장 중요한 설계 결정은 '모르는 것을 모른다고 말하게 하는 것'이었다.

내 조건에 맞는 청년 지원사업만 골라 주 1회 메일로 받는 시스템을 만들었다. 어려운 쪽은 수집이 아니라 판정이었다. 정부 API는 자격 조건을 코드값으로만 주고, 행정구역 개편으로 지역코드가 뒤섞여 있고, 조건의 상당수는 애초에 자유 텍스트다.

이 도메인에서 오답의 비용은 비대칭이다. 자격 없는 사람에게 '자격 충족'을 띄우면 신청했다 반려되고 끝이지만, 자격 있는 사람을 목록에서 지우면 그 사람은 자기가 뭘 놓쳤는지조차 모른다. 그래서 확신이 없을 때 가야 할 방향은 언제나 '확인 필요'다.

판정 5단계 — 기본값은 '확인 필요'

판정의미
✅ eligible구조화된 조건을 전부 만족
⚠ conflict조건은 맞지만 이미 받는 제도와 중복수급 제한 가능성
❓ check일부 조건이 자유 텍스트라 기계 판정 불가 (기본값)
⬜ ineligible명시적 조건에 위배
⏹ closed신청 기간 종료

LLM을 판정 경로에서 뺀 이유

자유 텍스트를 LLM에게 읽히면 판정률은 확실히 올라간다. 하지만 LLM이 '이 사람은 대상이 아니다'라고 틀리면, 그 결과는 목록에서 항목이 사라지는 형태로 나타난다. 사용자는 틀린 판정을 볼 수조차 없으니 반박할 기회가 없다. 그래서 LLM은 요약과 자유텍스트 구조화에만 쓰고, verdict를 결정하는 코드 경로에는 넣지 않았다.

빈 배열은 '제한 없음'이 아니라 '모름'

가장 크게 데인 버그다. 온통청년 API는 취업상태·학력·혼인을 한글명 없이 코드값으로만 준다(전수 2,698건 중 한글명 0건). 코드표 매핑이 실패하면 어댑터가 빈 배열을 넘기는데, 초기 구현은 빈 배열을 '제한 없음 → 통과'로 읽었다. 조건이 있는 공고가 조건 없는 공고로 둔갑해, 자격 미달자에게 '자격 충족'이 떴다.

function judgeList(policyValues: string[], userValue: string) {
  // 값이 없다는 것은 "제한이 없다"가 아니라 "모른다"이다.
  if (policyValues.length === 0) return "unknown";

  if (policyValues.some((v) => v !== NEEDS_CHECK && NO_LIMIT.test(v)))
    return "pass-nolimit";

  if (policyValues.includes(userValue)) return "pass";

  // 어댑터가 조건의 존재는 확인했지만 내용을 확정하지 못한 경우.
  // 여기서 fail을 내면 자격 있는 사람이 목록에서 사라진다.
  if (policyValues.includes(NEEDS_CHECK)) return "unknown";

  return "fail";
}

행정구역 개편 — prefix 치환이 통하지 않는 경우

법정동코드 개편에는 두 종류가 있고 난이도가 완전히 다르다. 시도코드만 바뀌고 뒷자리가 유지되면 기계 변환이 되지만, 시도가 통합되면서 시군구가 재배정되면 변환식 자체가 없다.

유형예시처리
(A) prefix만 변경춘천 42110 → 51110, 군산 45130 → 52130양방향 기계 변환
(B) 시군구 재배정목포 46110 → 12110 (유지) / 광양 46230 → 12190 (밀림)확인된 것만 표로, 나머지 unknown

(B)를 prefix 치환으로 처리하면 광양(46230)이 12230이 되어 엉뚱한 지역과 매칭된다. 그래서 응답 원문의 등록기관명과 대조해 확인한 매핑만 표에 넣고, 표에 없는 구 코드는 no-match가 아니라 unknown으로 돌려준다. 틀린 매핑은 없는 매핑보다 나쁘다.

자동 판정을 일부러 포기한 지점

  • 사업자등록 — 미취업자 대상 사업에서 매출 0원이어도 등록만으로 제외하는 곳이 있고 휴업을 인정하는 곳이 있다. 자동 fail이면 받을 걸 놓치고 자동 pass면 반려된다 → check
  • 재학생 — 미취업자 대상 사업에서 재학생 인정 여부가 제도마다 갈린다 → check
  • 창업 대상 사업 + 사업자등록 보유 — 프로필 신분이 '학생'이어도 창업자로 인정될 수 있다. fail을 내면 정작 받을 수 있는 창업지원이 사라진다 → check

'자격 판정'과 '메일에 실을 것' 분리

첫 실발송에서 자격 충족 63건이 그대로 실렸다. 그중 20건 넘게가 '○○센터 운영', '△△대회 개최' 같은 기관 예산 항목이었다 — 개인이 신청하는 사업이 아니다. 지역으로 거르는 방법은 쓸 수 없었다. 실제 데이터를 보니 이런 사업들의 지역코드에는 전국이 다 들어 있었고, 인구유입형 사업은 오히려 해당 지역을 대상에서 빼고 있었다.

그래서 '어디 사업인가'가 아니라 '무엇을 주는 사업인가'로 갈랐다. 중요한 건 이 층을 판정 엔진과 완전히 분리했다는 점이다. 관심 밖이라고 verdict를 손대면 대시보드에서도 사라지고, 나중에 조건이 바뀌어도 영영 안 보인다. 여기서 밀려난 항목은 판정 결과가 그대로 남고 발송 표식도 찍히지 않아 다음 주에 다시 후보가 된다.

역할산출
matching.ts자격이 되는가verdict + reasons
relevance.ts그중 메일에 실을 것인가순서와 분량 (상한 12건)

회귀 테스트가 붙잡아 둔 것

네트워크·DB 없이 도는 회귀 테스트 50건을 붙였다. 전부 실제로 터졌던 것들이다.

  • 코드표 유실 → 빈 배열이 '통과'로 둔갑해 자격 미달자에게 '자격 충족'
  • 신청기간을 사업기간 필드로 읽는 실수 — 둘 다 코드값이라 정규식으로는 걸리지 않는다
  • 시도 통합 지역의 구 코드를 prefix 치환해 엉뚱한 지역과 매칭

이 엔진의 설계 목표는 정확도가 아니라 '받을 수 있는 걸 놓치지 않는 것'이다. 그래서 확신이 없으면 사람에게 넘긴다 — 최종 확인은 언제나 공고 원문이다.

Next온프레미스 풀스택 서버 구축