SKIP 응답에도 돈은 나간다: AI 호출을 막는 클라이언트 게이트키퍼 구현기

SKIP 응답에도 돈은 나간다: AI 호출을 막는 클라이언트 게이트키퍼 구현기

2026년 2월 26일

지난 글에서 프롬프트로 AI 면접관을 침묵시키는 방법을 정리했습니다. 부정 지시 반복, 쿨다운 타이머, SKIP 응답 패턴까지 적용했지만 마지막에 이런 말을 남겼습니다.

“코드 분석 프롬프트는 여전히 완벽하지 않다.”

이번 글은 그 이후 이야기입니다. 프롬프트가 아니라 API 호출 자체를 클라이언트에서 막는 방식으로 바꿨습니다.


1. 문제 상황

라이브 코딩 세션 중 코드 변화를 감지하면 AI 면접관이 자동으로 반응하는 기능을 만들었다. 의도는 지원자가 오랫동안 막혀 있거나 방향을 완전히 틀 때만 개입하는 것이었다.

그런데 실제로 써보니 달랐다.

  • 변수명 iindex로 바꿈 → AI: “지금 코드에서 어떤 부분을 고치고 계신가요?”
  • 주석 한 줄 추가 → AI: “현재 접근법에 대해 설명해주시겠어요?”

타이핑을 멈출 때마다 면접관이 말을 걸었다. 코딩에 집중하기 어려웠고, AI 호출 비용도 예상보다 빠르게 쌓였다.

2. 내가 처음 한 생각

이전 글에서 정리한 프롬프트 전략을 그대로 적용해봤다. “기본적으로 SKIP이라고만 응답하라”는 명령을 코드 분석 프롬프트에 넣었다.

// getCodeAnalysisPrompt
**기본적으로 "SKIP"이라고만 응답하세요.**
아래 조건에 확실히 해당하는 경우에만 질문하세요.

타이밍 조건도 추가했다. 코드가 바뀐 뒤 8초 debounce를 두고, AI가 한 번 말한 뒤 45초 cooldown을 걸었다.

const CODE_CHANGE_DEBOUNCE_MS = 8000;
const AI_QUESTION_COOLDOWN_MS = 45000;

개입 빈도는 줄었다. 그런데 근본적인 문제는 그대로였다. 변수명 수정이나 주석 추가처럼 사소한 변화에도 여전히 API를 호출했다. AI가 "SKIP"을 반환할 때도 호출 비용은 발생했고, 그 비용으로 판단을 사는 셈이었다.

프롬프트로 AI의 응답을 제어하는 것과, API 호출 자체를 막는 것은 전혀 다른 문제였다.

3. 실제로 해 본 선택

문제의 본질을 다시 정의했다. “AI에게 언제 개입할지 가르치는 것”이 아니라 “호출할 가치가 있는 순간인지 클라이언트가 먼저 판단하는 것”이었다.

두 가지 조건을 클라이언트에서 직접 계산하기로 했다.

Diff threshold — 변경량이 충분히 큰가?

이전 코드와 현재 코드의 순 문자 증감을 측정한다. 50자 미만이면 API 호출을 건너뛴다.

export const DIFF_THRESHOLD = 50;

export function calculateDiffSize(prev: string, curr: string): number {
  return Math.abs(curr.length - prev.length);
}

변수명 수정(iindex), 오타 교정, 짧은 주석은 대부분 여기서 걸러진다.

Structural signal — 의미 있는 구조가 바뀌었나?

function, for, while, if, const, let 같은 구조 키워드의 등장 횟수를 이전과 현재에서 세어 비교한다. 수가 달라지면 구조적 변화로 본다.

/**
 * 알고리즘 문제 풀이에서 의미 있는 로직 단위를 나타내는 키워드.
 * 변수명 수정·공백·주석 변경은 이 목록에 해당하지 않아 자연히 걸러진다.
 * 알려진 한계: 문자열 리터럴 안에 키워드가 포함된 경우 오탐 가능.
 * 코딩 테스트 맥락에서 발생 빈도가 낮아 허용 가능한 트레이드오프로 수용한다.
 */
const STRUCTURAL_KEYWORDS = [
  "function",
  "for",
  "while",
  "if",
  "else",
  "class",
  "return",
  "switch",
  "try",
  "catch",
  "const",
  "let",
  "var",
];

export function hasStructuralChange(prev: string, curr: string): boolean {
  const prevCounts = countKeywords(prev);
  const currCounts = countKeywords(curr);
  return STRUCTURAL_KEYWORDS.some(
    (keyword) => prevCounts.get(keyword) !== currCounts.get(keyword),
  );
}

두 조건을 AND로 묶어 하나의 게이트로 만들었다.

export function shouldTriggerAI(
  prev: string,
  curr: string,
  threshold = DIFF_THRESHOLD,
): boolean {
  return (
    calculateDiffSize(prev, curr) >= threshold &&
    hasStructuralChange(prev, curr)
  );
}

그리고 debounce 콜백 맨 앞에서 이 게이트를 통과하지 못하면 즉시 반환한다.

codeChangeTimeoutRef.current = window.setTimeout(async () => {
  // 1. 클라이언트 게이트
  if (!shouldTriggerAI(currentState.previousCode, codeMonitor.currentCode)) {
    return; // API 호출 없음
  }

  // 2. cooldown 체크
  const timeSinceLastQuestion = Date.now() - lastAIQuestionTimeRef.current;
  if (timeSinceLastQuestion < AI_QUESTION_COOLDOWN_MS) return;

  // 3. 여기까지 왔을 때만 AI 호출
  await interviewer.sendToAI({ type: "code_changed", ... });
}, CODE_CHANGE_DEBOUNCE_MS);

최종 호출 흐름은 이렇게 된다.

코드 변화
→ 8초 대기 (Pause detection)
→ diffSize >= 50? (Diff threshold)
→ 키워드 카운트 변화? (Structural signal)
→ 두 조건 모두 통과 시 → AI 호출

4. 결과

변경 내용이전이후
변수명 수정API 호출 → AI가 SKIPAPI 호출 없음
주석 추가API 호출 → AI가 SKIPAPI 호출 없음
if문 새로 추가API 호출 → AI가 개입API 호출 → AI가 개입
함수 전체 재작성API 호출 → AI가 개입API 호출 → AI가 개입

의미 없는 변화에서는 호출이 없어졌고, 의미 있는 변화에서는 정상적으로 AI가 개입한다.

기술적으로 확인한 것 하나: calculateDiffSize는 순 문자 증감만 측정하기 때문에 완전히 동일한 길이로 코드를 통째로 바꾸면 0을 반환한다. 이런 경우는 Structural signal 쪽에서 잡히기도 하지만, 완벽하지 않다는 건 안다. 코딩 테스트 맥락에서 현실적으로 발생 가능성이 낮아 허용 가능한 트레이드오프로 받아들였다.

5. 지금 다시 해본다면?

처음부터 “AI에게 판단을 위임하는 것 자체가 비용”이라는 관점에서 설계했을 것이다.

프롬프트 엔지니어링은 AI의 응답을 제어하는 데 효과적이다. 하지만 AI에게 “이 호출이 가치 있는지” 판단하게 만드는 건 근본적으로 비용이 드는 행위다. 조건이 코드로 표현 가능하다면, 호출 전에 클라이언트에서 먼저 걸러내는 것이 맞다.

프롬프트로 해결하려는 시도가 틀린 건 아니었다. 덕분에 어떤 변화가 “의미 있는지”를 언어로 정의할 수 있었고, 그게 Diff threshold와 Structural signal의 기준이 됐다. 다만 그 판단을 AI에게 맡길 필요는 없었다.