저렴한 필터 우선, LLM은 마지막에: 크론 잡(Cron Job) 내부에서 AI 매처(Matcher) 실행하기
요약
SaaS 운영 시 발생하는 AI 모델 호출 비용 문제를 해결하기 위해, LLM 실행 전 단계에서 엄격한 하드 필터를 적용하는 아키텍처를 제안합니다. 데이터 누락 시 무조건 거절하기보다 불확실한 데이터를 통과시켜 비용 효율성과 사용자 경험을 동시에 잡는 전략을 다룹니다.
핵심 포인트
- LLM 호출 전 순수 함수 기반의 하드 필터를 적용해 비용을 획기적으로 절감
- 모델 호출 비용은 '일자리 수 × 사용자 수'로 증가하므로 스케일링 전략이 필수적
- 데이터 누락 시 거절 대신 '알 수 없음'으로 처리하여 사용자 경험 유지
- 가장 효과적인 프롬프트 최적화 방법은 적절한 if 문 사용
저는 Upwork Scout라는 작은 SaaS를 운영하고 있습니다. 이 서비스는 Upwork를 24시간 내내 모니터링하며, 실제로 사용자에게 적합한 일자리만을 이메일로 보내줍니다. 서비스의 핵심 가치는 한 문장으로 설명될 만큼 간단합니다. 하지만 엔지니어링 측면에서는 결제 페이지와의 긴 싸움이 이어집니다.
저는 이전에 그 싸움의 절반인 스크래핑(Scraping) 부분에 대해 글을 쓴 적이 있습니다. 요약하자면 이렇습니다. 단순한 일자리 알림 도구들은 사용자당 한 번씩 스크래핑을 수행하므로, 비용이 가입자 수에 따라 선형적으로 증가하며 결국 성공이 곧 파산으로 이어지게 됩니다. 저는 이를 카테고리당 사이클당 한 번의 공유 스크래핑 방식으로 뒤집었고, 이제 스크래핑 비용은 사용자 수가 아닌 활성 카테고리 수를 따릅니다.
그다음 사람들이 실제로 비용을 지불하는 부분인 AI 매칭(AI matching)을 추가했는데, 즉시 첫 번째 방식보다 확장성(Scaling) 특성이 더 나쁜 두 번째 청구서를 만들어냈습니다. 스크래핑 비용은 카테고리를 따르지만, 모델 호출(Model calls)은 '일자리 수 × 사용자 수'를 따릅니다. 이 곱셈 연산이야말로 대부분의 "AI를 추가했다"는 기능들이 조용히 사장되는 지점입니다.
제가 도달한 구조와 그곳에 이르게 한 네 가지 규칙을 소개합니다.
규칙 1: 모델은 지루한 필터가 거절했을 일자리를 절대 보지 않는다
모든 사용자에게는 엄격한 필터(Hard filters)가 있습니다. 약 20개 정도인데, 다음과 같습니다: 직업 유형, 예산 하한선 및 상한선, 시간당 급여 범위, 경력 수준, 프로젝트 기간, 업무량, 최대 제안 수, 클라이언트의 결제 인증 여부, 클라이언트의 최소 생애 지출액, 클라이언트의 최소 평점, 채용 횟수, 국가 허용 및 차단 목록, 키워드 포함 및 제외.
이 중 그 어떤 것도 언어 모델(Language model)이 필요하지 않습니다. 이는 두 개의 구조체(Structs)에 대한 순수 함수(Pure function)이며, 공유된 일자리 스트림(Job stream)을 대상으로 메모리 내에서 실행되며 비용이 전혀 들지 않습니다:
export function jobMatchesFilters(job: JobDoc, f: UserFilters): boolean {
const catHit = f.categories.some((c) => job.categories.includes(c));
// ... 20개의 더 지루한 체크 항목들
...
AI 단계는 오직 이 과정을 통과한 일자리들에 대해서만 실행됩니다. 일반적인 사이클에서는 단 하나의 토큰(Token)을 소비하기 전에 후보군을 한 자릿수(An order of magnitude)만큼 줄여줍니다. 제가 작성한 가장 효과적인 프롬프트 최적화(Prompt optimization)는 바로 if 문이었습니다.
그 필터에는 제가 올바르게 구현하기까지 시간이 좀 걸렸던 미묘한 차이가 있습니다. Upwork는 필터가 필요로 하는 데이터를 항상 노출하지는 않습니다. 때로는 클라이언트의 총 지출액(total spend)이 누락되어 있고, 때로는 평점(rating)이 없으며, 때로는 이전에 저장된 오래된 작업(job)이 제가 나중에 추가한 필드보다 앞선 시점의 데이터일 수도 있습니다. 저의 첫 번째 버전은 데이터가 누락되면 거절(reject)하도록 설계되었는데, 이는 사용자들이 알림을 받지 못하게 되더라도 그 이유를 알 수 없게 만들어 제가 문제를 재현할 수 없음을 의미했습니다. 이제는 정책을 파일 상단에 작성하고 일관되게 적용합니다: 필터는 오직 확실한 증거가 있을 때만 거절합니다. 알 수 없는 데이터는 필터를 통과하며, 이메일과 대시보드는 해당 작업을 숨기는 대신 '알 수 없음'으로 표시합니다. 스크래퍼(scraper)가 알려줄 수 없는 부분은 인간이 결정하도록 두는 것입니다.
실제 데이터에서 발견한 작은 관련 세부 사항은 다음과 같습니다: Upwork는 동일한 필드에 전체 국가 이름과 ISO3 코드를 혼용합니다. 저는 하나의 덤프(dump)에서 "United States", "USA", "CAN", "NLD", "United Kingdom"을 모두 본 적이 있습니다. 만약 문자열 일치(string equality)로 국가를 필터링한다면, "United States" 필터는 "USA"로 태그된 모든 작업을 누락시키게 되며, 실패 모드가 '침묵'이기 때문에 이를 절대 알아차릴 수 없을 것입니다. 그래서 저는 별칭 테이블(alias table)을 유지합니다.
규칙 2: 프롬프트가 아닌 판정 결과(verdict)를 캐싱하라
프롬프트 캐싱(Prompt caching)은 미세한 최적화(fine optimization)입니다. 하지만 여기서 중요한 것은 아니었습니다.
스캔은 15분마다 실행됩니다. 캐싱이 없다면, 동일한 사용자가 하루에 96번이나 동일한 작업에 대해 재점수화(re-scored)될 것이며, 작업이나 프로필이 변경되지 않았기 때문에 각 호출은 모두 동일한 답변을 반환할 것입니다. 따라서 판정 결과(verdict)가 캐시 키(cache key)가 되며, 캐시 키는 다음 쌍(pair)입니다:
matches/${uid}_${jobId} -> { score, reason, scoredAt }
동일한 사용자에 대해 하나의 작업이 두 번 점수화되는 일은 절대 없습니다. 단 한 번도요. 이 단 하나의 규칙이 모델 비용(model spend)을 스캔 빈도로부터 완전히 분리해 줍니다. 저는 5분마다 스캔을 수행하더라도 모델 청구 비용은 변하지 않을 것입니다. 왜냐하면 점수화 볼륨(scoring volume)은 크론(cron)이 실행되는 빈도가 아니라, '새로운 작업 수 × 관심 있는 사용자 수'에 의해 제한되기 때문입니다.
또한 이는 제가 계획하지 않았던 결과물, 즉 각 사용자에게 왜 특정 작업(job)이 표시되었는지에 대한 영구적인 기록을 제공해 주었습니다. 누군가 왜 특정 알림을 받았는지 이메일로 문의해 오면, 추측하거나 성능이 변했을지도 모르는 모델에 프롬프트(prompt)를 다시 실행하는 대신, 당시 모델이 작성한 정확한 한 줄의 이유를 들어 답변할 수 있습니다.
규칙 3: 사용자당 한도를 설정하고, 실패하는 대신 성능을 낮추기 (degrade)
무료 플랜 사용자는 하루에 150개의 AI 점수(AI scores)를 받을 수 있습니다. 카운터는 날짜와 함께 사용자 문서(user document)에 저장되므로, 별도의 정리 작업(cleanup job) 없이도 자연스럽게 초기화됩니다.
let aiToday = u.aiScoresToday?.date === today ? u.aiScoresToday.count : 0;
중요한 부분은 누군가 한도에 도달했을 때 어떤 일이 일어나는가입니다. 유혹적인 방법은 남은 하루 동안 해당 사용자를 건너뛰는 것입니다. 하지만 그것은 잘못된 결정입니다. 제품의 약속은 '알림'이지 '점수가 매겨진 알림'이 아니기 때문입니다. 예산이 소진되면, 파이프라인(pipeline)은 남은 작업들에 대해 필터(filter)만 사용하는 방식으로 폴백(fallback)합니다. 사용자는 알림이 아예 오지 않는 대신, 몇 시간 동안 약간 덜 정밀한 알림을 받게 됩니다.
이것은 제가 AI 기능을 구현하며 계속해서 다시 배우고 있는 일반적인 원칙입니다. 모델은 이미 작동하고 있는 무언가 위에 얹어진 강화 계층(enhancement layer)이지, 하중을 지탱하는 벽(load bearing wall)이 아닙니다. 만약 당신의 기능에 "모델이 응답하지 않았을 때"에 대한 정의된 동작(defined behaviour)이 없다면, 당신은 아직 기능을 완성하지 않은 것입니다.
규칙 4: 점수 측정기(scorer)는 null을 반환하며, 절대 예외를 던지지 않는다
점수 측정 함수(scoring function)에는 하나의 엄격한 계약(contract)이 있습니다. API 키 문제, 네트워크 오류, 거부(refusal), 잘못된 형식의 JSON(malformed JSON) 등 무엇이든 간에, 로그를 남기고 null을 반환해야 합니다. 이 함수가 하는 그 어떤 일도 다른 모든 사람에게 이메일을 전달하고 있는 스캔 사이클(scan cycle)을 중단시켜서는 안 됩니다.
소비자 측(consumer side) 코드는 단 한 줄이며, 제가 가장 주의를 기울이는 부분입니다:
// 실제로 판결(verdict)이 있을 때만 임계값(threshold)을 적용합니다; AI 실패 => 필터만 사용.
if (verdict && verdict.score < matching.threshold) continue;
판결(verdict)이 누락되면 작업은 통과됩니다. 약간 어긋난 작업을 보내는 방향으로 틀리는 것이 아무것도 보내지 않는 방향으로 틀리는 것보다 낫습니다. 왜냐하면 평범한 알림을 받는 사용자는 그냥 넘어가지만, 알림을 전혀 받지 못하는 사용자는 이탈(churn)하기 때문입니다.
프롬프트는 대부분 채점 루브릭(scoring rubric)입니다
프롬프트 자체는 짧고 화려하지 않습니다. 프롬프트는 정확히 두 가지를 요구합니다: 0에서 100 사이의 정수 하나, 그리고 결정적인 요인을 명시하는 최대 18단어 이내의 문장 하나입니다.
제 역할을 다하는 부분은 루브릭(rubric)입니다. 명시적인 구간(bands)이 없으면 점수들이 70에서 85 사이의 모호한 범위에 몰리게 되어 임계값(threshold)이 무의미해집니다. 그래서 저는 구간을 정의하고 모델에게 단호하게 결정하라고 지시합니다:
90-100 완벽한 적합(perfect fit): 핵심 전문성 AND 정확히 원하는 종류의 업무
70-89 강력한 적합(strong fit): 명확하게 기술 범위 내에 있음, 사소한 불일치만 존재
50-69 괜찮은 적합(decent fit): 그럴듯하지만 눈에 띄는 기술 격차 존재
...
기본 알림 임계값(alert threshold)은 55이며, 사용자가 조정할 수 있습니다.
이와 같은 프로젝트를 할 때 제가 반복해서 강조할 두 가지가 더 있습니다:
구조화된 출력(structured outputs)을 사용하세요. 응답은 score를 정수로, reason을 문자열로 하는 JSON 스키마(schema)에 의해 제한됩니다. 저는 여전히 출력 과정에서 값을 제한(clamp)하고 반올림하지만, 이는 스키마를 신뢰하면서도 검증을 병행하는 데 비용이 들지 않기 때문입니다. 저는 산문 속에서 JSON을 추출하기 위해 정규 표현식(regex)을 쓰는 일을 그만두었습니다.
설명 부분을 제품의 일부로 만드세요. 한 문장으로 된 이유는 디버그 출력용이 아닙니다. 이는 작업 정보 아래의 알림 이메일에 바로 포함되며, 점수는 가장 적합한 항목이 상단에 오도록 이메일을 정렬합니다. 숫자만 달랑 있으면 사용자는 당신을 믿어야만 합니다. 하지만 숫자와 함께 "강력한 Retell 및 n8n 중첩이 있고 검증된 클라이언트이지만, 예산이 귀하의 시간당 $50 하한선보다 낮습니다"라는 문장이 더해지면, 사용자는 2초 만에 당신의 작업 내용을 확인할 수 있습니다. 이 문장은 모델을 업그레이드하여 얻을 수 있는 그 어떤 정확도 향상보다 리텐션(retention)에 더 큰 도움이 됩니다.
말이 나온 김에 덧붙이자면, 기본 모델은 Haiku 4.5이며 모델 이름은 환경 변수 (environment variable)로 관리됩니다. 제 개인적인 추산으로는 점수 하나당 비용이 약 0.1센트 정도 드는데, 이것이 바로 무료 티어 (free tier)가 존재할 수 있는 이유입니다. 만약 비용 대신 매칭 품질 (match quality)이 제약 사항 (binding constraint)이 되는 상황이 온다면, 환경 변수 하나만 바꾸는 것으로 향후 모든 점수 계산을 더 똑똑하게 만들 수 있습니다. 아직 그럴 필요는 없었습니다.
백그라운드 작업 (background job)에 LLM을 추가하려는 사람에게 해주고 싶은 말
일주일 동안 저를 고민하게 만든 질문은 어떤 모델을 쓸 것인가가 아니었습니다. 얼마나 많은 호출 (calls)을 피할 수 있는가였습니다.
결정론적 필터 (Deterministic filters)를 먼저 사용하세요. 비용이 들지 않고 정직하기 때문입니다. 요청 (request)이 아니라 작업의 자연스러운 정체성 (natural identity)에 기반하여 캐시 (cache)를 적용하세요. 모든 사용자에게 엄격한 상한선 (hard ceiling)을 부여하고, 그 상한선 아래에서 어떤 일이 일어날지 정의하세요. 모델이 에러 (error)로 빠지기보다는, 비(非) AI 경로 (non-AI path)로 실패하도록 만드세요.
그렇게 한다면 모델은 본연의 역할을 수행하게 됩니다. 즉, 모델 없이도 이미 작동하고 있는 파이프라인 (pipeline) 내에서 마지막에 수행되는, 가장 작고 가장 비용이 많이 드는 단계가 되는 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기