본문 바로가기
카테고리 없음

Next.js Convex AI 코드 감사기 만들기|fetch 제한·타임아웃

by mr.k0 2026. 8. 15.
반응형

 

항목 내용
환경 Next.js App Router + Convex, Node.js 20 런타임, 2026년 8월 공식 문서 기준
증상 query·mutation 안에서 외부 AI API를 부르면 감사 함수가 실행되지 않음
원인 query·mutation은 결정적 실행이라 외부 fetch 금지, 사용자 코드 1초 제한
해결 AI 호출은 action으로 빼고 결과 저장은 ctx.runMutation으로 넘김
바로가기 Convex Actions 공식 문서

 

증상: 감사 버튼을 눌렀는데 리포트가 오지 않는다

 

 

코드를 붙여넣고 감사 실행을 눌러도 결과가 끝까지 도착하지 않는 상태다. 화면은 로딩에 멈춰 있고 브라우저 콘솔에는 다음 문구만 남는다.

Could not find Convex client! `useQuery` must be used in the React component tree under `ConvexProvider`.

프로바이더를 붙여 이 문구를 지워도 다음 벽이 남는다. 감사 로직을 query나 mutation 안에 두고 외부 AI API를 fetch로 호출하면 그 함수는 아예 실행되지 못한다.

저장 단계에서는 또 다른 문구가 나온다. 여러 감사 결과를 같은 테이블에 몰아서 쓸 때 이런 형태로 실패한다.

Documents read from or written to the table "audits" changed while this mutation was being run and on every subsequent retry.

원인: query·mutation과 action은 애초에 다른 함수다

Convex의 query와 mutation은 결정적으로 실행돼야 한다. 그래야 같은 트랜잭션을 안전하게 재시도하고, 구독 중인 쿼리를 자동으로 다시 계산해 화면에 밀어줄 수 있다.

그래서 이 두 함수 안에서는 외부 네트워크 호출이 허용되지 않는다. 공식 문서는 서드파티 API 호출을 action의 역할로 못 박고 있고, action만 브라우저 표준 fetch를 쓸 수 있다.

시간 한도도 다르다. query와 mutation의 사용자 코드 실행 한도는 1초, action은 Convex 런타임에서 30분·Node.js 런타임에서 10분이다(2026년 8월 Limits 문서 기준). 모델 응답을 기다리는 작업이 mutation에 들어갈 수 없는 이유가 이 1초다.

세 번째 문구는 낙관적 동시성 제어(OCC) 때문이다. 같은 문서를 읽고 쓰는 mutation이 겹치면 Convex가 재시도하고, 재시도해도 계속 겹치면 위 메시지로 실패한다.

해결: 3단계로 감사 파이프라인을 쪼갠다

 

 

1단계는 접수다. 사용자의 요청은 mutation이 받아 job 문서 하나만 만들고, 같은 자리에서 ctx.scheduler.runAfter(0, ...)로 감사 action을 예약한다. UI는 job id를 즉시 받아 상태를 구독하므로 버튼이 멈춰 있지 않는다.

2단계는 호출이다. 실제 AI 요청은 internalAction 안에서 하고, 기본 런타임이 웹 표준 fetch를 제공하므로 HTTP만 쓰는 수준이면 별도 런타임 지정이 필요 없다.

// convex/audits.ts
import { v } from "convex/values";
import { mutation, internalAction } from "./_generated/server";
import { internal } from "./_generated/api";

export const start = mutation({
  args: { source: v.string() },
  handler: async (ctx, args) => {
    const jobId = await ctx.db.insert("audits", { status: "running", source: args.source });
    await ctx.scheduler.runAfter(0, internal.audits.run, { jobId });
    return jobId;
  },
});

export const run = internalAction({
  args: { jobId: v.id("audits") },
  handler: async (ctx, args) => {
    const job = await ctx.runQuery(internal.audits.get, { jobId: args.jobId });
    const res = await fetch("https://api.anthropic.com/v1/messages", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "x-api-key": process.env.ANTHROPIC_API_KEY,
        "anthropic-version": "2023-06-01",
      },
      body: JSON.stringify({
        model: "claude-sonnet-5",
        max_tokens: 4096,
        messages: [{ role: "user", content: "아래 코드의 보안 취약점을 찾아라: " + job.source }],
      }),
    });
    await ctx.runMutation(internal.audits.save, { jobId: args.jobId, report: await res.text() });
  },
});

3단계는 저장이다. action에는 ctx.db가 없으므로 결과는 반드시 ctx.runMutation으로 넘겨야 하고, 이 mutation 하나가 job 상태와 리포트를 함께 바꾸게 짜면 앞의 OCC 충돌도 줄어든다.

런타임 선택: 기본이냐 use node냐 HTTP action이냐

선택지는 셋이다. HTTP 요청만 하는 감사라면 기본 Convex 런타임 action이 맞다. 콜드 스타트가 없어 즉시 응답이 목표인 서비스에 유리하다.

npm SDK나 Node 전용 API(파일 파서, 네이티브 바인딩)를 써야 하면 파일 맨 위에 "use node"를 붙인다. 대신 그 파일에는 query·mutation을 둘 수 없고, "use node"가 없는 파일이 그 파일을 import 해서도 안 된다(Runtimes 문서). 인자 한도도 16 MiB가 아니라 5 MiB로 낮아진다.

깃 푸시마다 자동으로 감사를 걸 계획이면 convex/http.ts의 HTTP action으로 웹훅을 받는 쪽이 낫다. 외부에서 들어오는 트리거는 브라우저 클라이언트를 거치지 않기 때문이다.

재발 방지: 키·재시도·한도 세 가지

 

 

API 키는 프런트의 .env.local이 아니라 Convex 배포 환경변수에 넣는다. 대시보드의 Deployment Settings에서도 되고, CLI가 더 빠르다.

npx convex env set ANTHROPIC_API_KEY 'sk-ant-...'
npx convex env list

값 하나당 8 KiB까지 들어가고, 함수 안에서는 process.env.ANTHROPIC_API_KEY로 읽는다(환경변수 문서). 개발용과 프로덕션 배포는 값이 따로 관리되므로 배포 전 env list로 한 번 확인하고 넘어가는 편이 안전하다.

action은 부수 효과가 있어 Convex가 자동으로 재시도하지 않는다. 모델 호출이 중간에 끊기면 job이 running 상태로 남으므로, @convex-dev/action-retrier로 재시도를 붙이거나 실패 상태를 job 문서에 기록해 UI가 알 수 있게 한다.

공개 서비스라면 요청 한도를 건다. @convex-dev/rate-limiter는 mutation 트랜잭션 안에서 동작하는 한도라 요청이 몰려도 카운트가 새지 않는다.

대상 코드가 커질 때 걸리는 벽도 미리 계산해 둔다. 문서 하나는 1 MiB, 한 트랜잭션이 읽는 데이터는 16 MiB, 스캔 문서는 32,000개, 메모리는 기본 런타임 64MB·Node 런타임 512MB다. 리포트 전문은 파일 스토리지에 올리고 문서에는 storageId만 남기면 이 제한을 대부분 피한다.

맨 처음 콘솔 문구는 클라이언트 배치 문제다. layout.tsx는 서버 컴포넌트로 두고 "use client"를 붙인 별도 파일에서 ConvexProvider로 감싼 뒤 layout이 그 컴포넌트를 쓰는 구조가 Next.js 퀵스타트의 기본형이다.

반응형