개발자공부일기

Next.js에서 Edge Runtime과 Prisma를 같이 쓰다가 겪은 문제 본문

트러블슈팅

Next.js에서 Edge Runtime과 Prisma를 같이 쓰다가 겪은 문제

JavaCPP 2025. 7. 24. 14:44

Next.js의 App Router를 기반으로 음성 기반 AI 면접 기능을 만들던 중, 예상치 못한 충돌을 겪었다.
Whisper(Gemini API)를 통해 음성파일을 텍스트로 전사하고, 그 결과를 Prisma로 DB에 저장하는 흐름이었다.
한 줄로 정리하면 “음성 → 텍스트 → DB 저장”이라는 단순한 로직이었는데, 실행 환경(runtime) 문제로 인해 전체 구조를 바꾸게 되었다.


만들고자 했던 구조

  • 사용자가 마이크로 음성 입력
  • 업로드된 음성 파일을 Whisper API로 전송해 텍스트로 변환
  • 전사된 텍스트를 Prisma로 DB에 저장

처음엔 이걸 하나의 API에서 처리하려고 했다.


문제 1: Edge Runtime에서 Prisma 사용 불가

Whisper 처리를 위해 req.formData()를 사용해야 했고, 이건 Edge Runtime에서만 동작한다.
그래서 해당 API에 다음처럼 선언했다.

export const runtime = "edge";

이 선언으로 인해 req.formData()는 잘 동작했지만, Prisma를 사용하는 코드에서 바로 에러가 발생했다.

에러 로그

PrismaClient is unable to run in this browser environment...

원인

Prisma는 내부적으로 .prisma 파일, 쿼리 엔진 바이너리, fs, Buffer 등을 사용하는데,
Edge Runtime은 이러한 Node.js 기능을 지원하지 않는다.
결국 Prisma는 Edge에서 동작 불가하다. 공식적으로도 문서에 명시되어 있다.


구조 분리로 해결

처음엔 multipart 파서를 직접 구현하거나 Node 환경에서 formData를 흉내내는 방식도 고려했지만, 결국 구조를 분리하는 게 가장 명확했다.

API 역할  런타임
/api/AIInterview/[sessionId]/record 음성 파일 → 텍스트 전사 Edge
/api/AIInterview/[sessionId]/answer 텍스트 저장 (Prisma 사용) Node

문제 2: fetch()에서 상대 경로 사용 불가

record API에서 전사된 텍스트를 answer API에 전달하려고 다음과 같이 작성했다.

await fetch(`/api/AIInterview/${sessionId}/answer`, ...)

하지만 Edge 환경에서 실행하자 다음과 같은 에러가 발생했다.

Error: URL is malformed "undefined/api/AIInterview/7/answer"
Please use only absolute URLs

원인

Edge Runtime에서 fetch()는 반드시 절대 URL만 사용할 수 있다. 상대 경로는 지원되지 않는다.


해결 방법

1. .env.local에 API 베이스 경로 설정

NEXT_PUBLIC_API_BASE_URL=http://localhost:3000

2. fetch에 절대 URL 적용

await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/AIInterview/${sessionId}/answer`, ...)

이후 반드시 서버를 재시작해야 환경변수가 반영된다.


최종 구조

/record (Edge Runtime)

Whisper 전용. 텍스트 전사 후, answer API로 전송.

export async function POST(
  req: NextRequest,
  context: { params: Promise<{ sessionId: string }> }
) {
  try {
    const { sessionId } = await context.params;
    const formData = await req.formData();
    const audio = formData.get("file") as Blob | null;

    if (!audio) {
      return NextResponse.json(
        { error: "audio 파일이 필요합니다." },
        { status: 400 }
      );
    }

    const text = await transcribeAudio(audio);

    // 이제 텍스트로 answer API를 호출함
    const res = await fetch(
      `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/AIInterview/${sessionId}/answer`,
      {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ answerText: text }),
      }
    );

    const data = await res.json();

    return NextResponse.json(
      { ...data, transcribedText: text },
      { status: res.status }
    );
  } catch (error: any) {
    console.error("record 에러:", error);
    return NextResponse.json(
      { error: error.message ?? "알 수 없는 오류" },
      { status: 500 }
    );
  }
}

/answer (Node.js Runtime)

Prisma 사용 가능. 전사된 텍스트를 DB에 저장.

export async function POST(
  req: NextRequest,
  context: { params: Promise<{ sessionId: string }> }
) {
  try {
    const { sessionId } = await context.params;
    const numSessionId = Number(sessionId);
    const { answerText } = await req.json();

    if (!answerText || isNaN(numSessionId)) {
      return NextResponse.json({ error: "입력값 오류" }, { status: 400 });
    }

    // 가장 최근 unanswered 질문 찾기
    const record = await prisma.mockInterviewRecord.findFirst({
      where: { sessionId: numSessionId, answerText: null },
      orderBy: { createdAt: "desc" },
    });

    console.log("기록:", record);

    if (!record) {
      return NextResponse.json(
        { error: "답변할 질문이 없습니다." },
        { status: 404 }
      );
    }

    const { summary, feedback } = await generateFeedback(
      record.question,
      answerText
    );

    const updated = await prisma.mockInterviewRecord.update({
      where: { interviewId: record.interviewId },
      data: {
        answerText,
        summary,
        feedback,
      },
    });

    return NextResponse.json({ summary, feedback, record: updated });
  } catch (error: unknown) {
    if (error instanceof Error) {
      console.error("세션 종료 오류:", error);
      return NextResponse.json({ error: error.message }, { status: 500 });
    }

    return NextResponse.json(
      { error: "Unknown error occurred" },
      { status: 500 }
    );
  }
}

느낀 점

처음엔 단일 API에서 모든 걸 처리하는 게 깔끔하다고 생각했다. 하지만 런타임 제약 때문에 구조를 나누는 게 훨씬 낫다는 걸 알게 됐다.

  • Whisper와 같이 브라우저 기반 API를 사용하는 경우는 Edge에서 처리
  • DB, 파일 시스템 등 Node 기반 기능은 Node.js 런타임에서 처리
  • API 간 역할이 분리되면 디버깅도 쉬워지고, 테스트도 명확해진다

 

'트러블슈팅' 카테고리의 다른 글

Next.js 프로덕션모드 ESLint에러  (0) 2025.07.17
Next.js 15에서 params 경고 해결하기  (0) 2025.06.20
DuckTopia  (0) 2025.02.11
중복 랜더링  (0) 2025.01.17
패킷 길이문제  (0) 2025.01.16