Claude APIAnthropicLLM 모더레이션Next.jsSupabase

"Opus 계속 써도 되나?" 싶을 때 — 직접 A/B 테스트로 모델을 낮춘 이야기

2026년 7월 17일5 분 읽기

"이거 Opus 계속 써도 되는 거 맞아?"

노가리는 댓글이 올라올 때마다 LLM으로 욕설·비방을 걸러낸다. 처음 만들 때는 별생각 없이 제일 똑똑한 모델(Opus)을 붙여뒀다. 그런데 트래픽이 늘기 전인 지금 문득 궁금해졌다.

"댓글 검열에 Opus 쓰냐? 비용 얼마나 나오는 거야? Haiku가 나을까?"

이 질문에 답하려면 두 가지가 필요했다. 하나는 실제로 얼마나 쓰고 있는지, 다른 하나는 모델을 낮췄을 때 품질이 얼마나 떨어지는지다. 감으로 판단하지 않고 둘 다 직접 확인해봤다.

Step 1: 실제 사용량부터 확인한다

비용 얘기를 하려면 트래픽 규모부터 알아야 한다. DB를 조회해보니 노가리 전체 댓글이 140개, 시드 크론이 하루에 만드는 방이 10개 정도였다. 아직은 초기 단계라 절대적인 비용 자체는 크지 않지만, "이 구조 그대로 트래픽이 10배, 100배 늘면 어떻게 되나"를 미리 점검해두는 게 맞겠다 싶었다.

Anthropic 가격표를 그대로 옮기면 이렇다.

모델입력 (1M 토큰)출력 (1M 토큰)
Claude Opus$5$25
Claude Haiku$1$5

정확히 5배 차이다. 댓글 검열처럼 호출 빈도가 높은 기능에서는 이 배수가 그대로 월 비용 차이로 이어진다.

Step 2: "정확도가 같은지"는 감으로 판단하지 않는다

비용만 보고 낮추면 위험하다. 검열 모델이 애매한 케이스에서 오탐(정상 댓글을 차단)이나 미탐(욕설을 통과)을 저지르기 시작하면 서비스 신뢰도가 바로 깨진다. 그래서 실제로 정규식 1차 필터를 통과하는 애매한 케이스를 모아서 Opus와 Haiku에게 똑같이 물어보는 비교 스크립트를 만들었다.

테스트 케이스는 이렇게 구성했다.

  • 욕설 5종 — 초성이나 변형으로 정규식을 피해가는 표현("새끼", "존나" 계열의 검열 회피형 표현들)
  • 욕설 없는 비판 2종 — 표현은 날카롭지만 욕설은 없는, "이건 통과시켜야 하는" 케이스

정규식만으로는 걸러지지 않고 LLM 판단까지 가야 하는, 딱 실제 서비스에서 애매하게 걸리는 지점만 골라낸 것이다. 둘 다 이 7개 케이스에 대해 7/7 정답을 냈다. 게다가 응답 속도는 Haiku가 더 빨랐다. 정확도 손실 없이 비용만 5분의 1로 줄일 수 있다는 근거가 확보된 셈이다.

// src/lib/anthropic.ts
-const SIMILARITY_MODEL = "claude-opus-4-8";
-const MODERATION_MODEL = "claude-opus-4-8";
+// 트래픽이 늘어나기 전(테스트 기간) 비용을 감당 가능한 수준으로 두기 위해
+// Haiku로 낮춰서 시작한다 — 댓글 검열은 정규식 1차 필터를 통과한 애매한
+// 케이스(욕설 5종 + 욕설없는 비판 2종)로 Opus와 직접 비교했을 때 정확도
+// 차이가 없었다(둘 다 7/7). 트래픽이 커지고 오탐/미탐이 늘면 그때 재검토.
+const SIMILARITY_MODEL = "claude-haiku-4-5-20251001";
+const MODERATION_MODEL = "claude-haiku-4-5-20251001";

바꾼 함수는 4개다. 댓글 검열(moderateComment), 신고된 콘텐츠 재심사(reviewReportedContent), 시드 댓글 생성(generateSeedComment), 방 제목 중복 검사(checkTopicSimilarity). 전부 코드에 "왜 Haiku로 바꿨는지"와 "언제 다시 검토해야 하는지(트래픽이 커지고 오탐/미탐이 늘면)"를 주석으로 남겼다. 나중에 이 결정을 다시 마주칠 미래의 나(혹은 다른 개발자)를 위한 최소한의 배려다.

이 과정에서 얻은 교훈은 하나다. 모델을 낮추는 결정을 "이 정도면 되겠지"로 하지 말고, 실제 실패 가능성이 높은 케이스를 모아서 A/B로 검증하고 그 근거를 코드에 남기자. 나중에 트래픽이 늘어서 오탐이 발생하면, 이 근거 자체가 "그때는 맞았는데 지금은 상황이 달라졌다"는 걸 판단하는 기준점이 된다.

같은 날 만든 기능: 관리자가 중복 방을 합칠 수 있게 하기

Haiku 전환 작업을 하다 보니 자연스럽게 떠오른 문제가 있었다. 노가리는 시드 크론이 매일 방을 자동으로 만드는데, 같은 대상(예: 같은 인물이나 사건)을 가리키는 방이 표현만 다르게 두 번 생기는 경우가 있다. 이걸 그냥 두면 대화가 두 방으로 쪼개져서 커뮤니티 효과가 떨어진다.

그래서 "중복 방이 생기면 관리자가 합칠 수 있게 해달라"는 요구사항으로 병합 기능을 만들었다.

스키마: 병합 이력을 남길 컬럼 하나

-- supabase/migrations/0024_topic_merge.sql
alter table topics add column merged_into_id uuid references topics(id);

병합되어 사라지는 방은 삭제하지 않는다. 대신 상태를 EXPIRED로 바꾸고 merged_into_id에 살아남은 방의 id를 남긴다. 이렇게 하면 그 방 URL로 들어온 사람도 "이 방은 통합되었습니다"라는 안내와 함께 새 방으로 연결할 수 있다.

API: PATCH가 아니라 POST를 쓴 이유

/api/admin/topic-merge는 단순한 상태 변경이 아니라 댓글을 실제로 이동시키는, 부수효과가 큰 작업이라 PATCH보다 POST가 의미상 더 맞다고 판단했다.

// src/app/api/admin/topic-merge/route.ts
export async function POST(request: NextRequest) {
  if (!(await isAdminAuthenticated())) {
    return NextResponse.json({ error: "권한이 없습니다." }, { status: 403 });
  }

  const { keepId, mergeId } = await request.json();
  // ...유효성 검사...

  const admin = createAdminClient();

  // 댓글을 복사가 아니라 실제로 이동시킨다 — 작성 시각, 공감 수 등이
  // 그대로 유지된다
  await admin
    .from("comments")
    .update({ topic_id: keepId })
    .eq("topic_id", mergeId);

  // last_comment_at은 댓글 insert 트리거로만 갱신되는데, 여기서는 새 댓글을
  // 만드는 게 아니라 topic_id만 옮기는 거라 트리거가 안 붙는다 — 병합된
  // 댓글까지 반영해서 직접 재계산한다.
  const { data: latestComment } = await admin
    .from("comments")
    .select("created_at")
    .eq("topic_id", keepId)
    .order("created_at", { ascending: false })
    .limit(1)
    .maybeSingle();

  if (latestComment) {
    await admin
      .from("topics")
      .update({ last_comment_at: latestComment.created_at })
      .eq("id", keepId);
  }

  await admin
    .from("topics")
    .update({ status: "EXPIRED", merged_into_id: keepId })
    .eq("id", mergeId);

  return NextResponse.json({ ok: true });
}

여기서 눈여겨볼 부분은 두 가지다.

  1. 댓글은 복사가 아니라 이동이다. topic_id만 바꿔치기하기 때문에 작성 시각, 공감 수가 그대로 유지된다. 데이터를 새로 만들지 않으니 정합성이 깨질 여지가 적다.
  2. 트리거가 못 잡는 부수효과는 직접 처리한다. last_comment_at 컬럼은 원래 "댓글이 새로 insert될 때"만 트리거로 갱신되도록 설계되어 있었다. 그런데 병합은 update라서 이 트리거가 발동하지 않는다. 이런 "트리거가 커버 못 하는 사각지대"는 놓치기 쉬운데, 실제로 병합 후 화면을 확인하면서 최신 댓글 시각이 갱신 안 되는 걸 보고서야 알아챘다.

병합 대상이 되는 방에 열려있던 신고도 함께 정리했다. "중복 방으로 판단해 병합 처리"라는 처리 결과를 남기고 RESOLVED로 닫는다.

UI: 검색해서 두 방을 고르는 폼

관리자 페이지(/admin/topics)에 TopicMergeForm을 추가해서 두 방을 검색·선택하면 바로 병합 요청을 보낼 수 있게 했다. Base UI의 Dialog 컴포넌트로 확인창을 띄워서 실수로 잘못 병합하는 걸 막았다.

기능을 다 만든 뒤에는 실제로 테스트 방 2개를 만들어서 병합 흐름 전체(검색 → 선택 → 확인 → 댓글 이동 → 원래 방 EXPIRED 처리 → 상세 페이지 안내 문구)를 Playwright로 끝까지 확인했다. 이 정도 부수효과가 큰 관리자 기능은 코드만 보고 넘어가면 꼭 어딘가에서 어긋난다.

자주 쓰는 패턴 요약

상황판단 방법
모델을 낮춰도 될지 고민될 때실제로 실패 가능성이 높은 경계 케이스를 모아 A/B 테스트, 결과를 코드 주석에 근거로 남기기
데이터를 옮기는 관리자 기능을 만들 때복사보다 이동, 트리거가 커버 못 하는 파생 컬럼(카운트, 최신 시각 등)은 직접 재계산
상태 변경 API의 HTTP 메서드부수효과가 크면 PATCH보다 의미가 명확한 POST 고려
삭제 대신 병합할 때실제 삭제 대신 상태 플래그 + 참조 컬럼으로 이력 남기기

정리

Opus에서 Haiku로 바꾸는 결정 자체는 사실 되게 사소해 보인다. 상수 두 줄 바꾸는 일이니까. 근데 "바꿔도 되나?"라는 질문에 실제로 답을 내는 과정 — 실사용량 확인, 가격 비교, 애매한 케이스로 A/B 테스트 — 은 생각보다 손이 많이 갔다. 대신 그만큼 나중에 "이거 왜 Haiku 쓰는 거지?"라는 질문에 바로 답할 수 있는 근거가 남았다.

그리고 같은 날 만든 방 병합 기능은 "댓글은 복사가 아니라 이동", "트리거가 놓치는 파생 컬럼은 직접 재계산"이라는, 관리자 도구를 만들 때 계속 마주치게 될 패턴을 다시 확인한 작업이었다. 둘 다 화려한 기능은 아니지만, 서비스가 오래 굴러가려면 이런 식의 "근거를 남기는 습관"과 "부수효과를 끝까지 추적하는 습관"이 결국 쌓여서 차이를 만든다고 생각한다.

PM

backtodev

40대 PM, 다시 개발자로 돌아갑니다. 실패하고 배우며 성장하는 기록.