Next.js App Router: Route Handler 'body stream already read' 이중 파싱 에러 해결법

Next.js App Router Route Handler body double read 에러 해결 가이드
3초 요약: 이렇게 고치세요
  • 원인: Request의 body는 한 번 읽으면 소비되는 스트림이라, 미들웨어나 로거가 먼저 request.json()을 호출하면 이후 실제 로직에서 다시 읽으려 할 때 막힘
  • 해결: 로깅처럼 별도 용도로 넘길 땐 request.clone()으로 복제본 전달 / 한 로직 안에서는 파싱 결과를 변수에 담아 재사용
  • 주의: 스트림은 되돌릴 수 없어서, 어디선가 한 번이라도 소비하면 나머지 코드는 무조건 클론이나 캐싱된 변수를 거쳐야 함
const clonedRequest = req.clone();
await clonedRequest.json(); // 로깅용은 복제본으로
↓ 왜 이런 에러가 나는지 원리 보기

Next.js Route Handler에서 POST 요청 바디를 파싱하려 할 때 'TypeError: body stream already read' 에러를 만나는 경우가 있습니다. 미들웨어나 로깅 코드에서 요청 본문을 먼저 한 번 읽어버리면, 이후 실제 로직에서 다시 읽으려 할 때 스트림이 이미 닫혀 있어 발생하는 문제입니다. 원인과 request.clone()을 활용한 해결 방법을 정리합니다.


요청 본문을 두 번 읽으면 스트림이 잠기는 근본 원인

이 에러의 근본적인 원인은 Next.js가 활용하는 최신 웹 표준 스펙인 Request API의 "ReadableStream 단방향 데이터 흐름" 구조에 기인합니다. 전통적인 Node.js 환경의 Express 같은 프레임워크는 내부 미들웨어(body-parser)가 메모리에 본문 전체 문자열을 올려놓고 반복해서 접근할 수 있게 지원했습니다. 하지만 Next.js App Router는 서버 리소스를 절약하고 속도를 최적화하기 위해, 클라이언트로부터 유입되는 요청 본문을 실시간 스트림(Stream) 방식으로 조각조각 읽어 들입니다.

이 스트림 데이터는 한 번 읽어 메모리로 소비(Read/Consume)하는 순간 영구적으로 소멸되며 다시 되돌리거나 재생할 수 없습니다. 따라서 Route Handler 내부의 특정 함수나 검증 미들웨어에서 await request.json() 또는 await request.text()를 호출하여 바디를 단 1회라도 소모하면, 그 즉시 데이터 파싱 스트림 통로는 '소비 완료(disturbed)' 상태로 잠기게 됩니다. 이 잠긴 상태에서 뒤이어 비즈니스 코드가 await request.json()을 한 번 더 수행하려고 진입하면, 런타임 엔진이 "이미 읽어서 비워진 폐쇄 스트림을 다시 읽으려 한다"며 예외 오류를 일으키고 요청을 차단시키는 것입니다.

Request ReadableStream의 소비 메커니즘

중복 소비 시도 (Double Read) 디버깅용 로그 함수에서 request.json() 호출 -> 스트림 소비 및 폐쇄 -> 실제 DB 영속 로직에서 또 request.json() 파싱 호출 -> Stream Closed 에러 발생.
클론 혹은 변수 바인딩 우회 request.clone()으로 똑같은 복제 스트림 생성 후 로그 전달 또는 request.json() 값을 변수 하나에 최초 저장하고 돌려쓰기 -> 안전 통과.

실무 비즈니스 로깅 아키텍처에서 겪는 문제

Next.js Route Handler를 백엔드 프록시처럼 활용하는 구조에서는, 보안 감사나 로깅 목적으로 외부 API 요청이 들어올 때마다 전용 미들웨어가 body에 담긴 값을 먼저 파싱해 로그로 남기는 공통 모듈을 두는 경우가 흔합니다. 이런 구조에서 요청량이 늘어나면 스트림이 이미 소비된 상태로 뒤쪽 로직에 넘어가면서 요청이 누락되는 장애로 이어지기 쉽습니다.

원인은 로거가 먼저 바디를 소비하고 나면, 뒤쪽의 실제 비즈니스 로직 코드가 데이터를 파싱하려 할 때 스트림이 이미 닫혀 있어 예외가 발생하기 때문입니다. 이럴 때 안전한 방법은 로그 함수로 요청 데이터를 넘기기 전에 request.clone()을 명시적으로 호출해 복제본을 만들어 전달하는 것입니다. 이 패턴을 적용하면 보안 로깅과 본래의 비즈니스 로직이 서로 간섭하지 않고 깔끔하게 분리됩니다.

결제 웹훅을 처리하는 라우트에 감사 로그를 나중에 추가하다가, 로깅 함수를 앞단에 끼워 넣은 뒤부터 주문이 간헐적으로 누락되는 장애를 겪은 적이 있습니다. 로그는 정상적으로 남고 있어서 처음엔 로깅 쪽 문제가 아니라고 생각하고 며칠을 다른 곳만 뒤졌는데, 실제로는 로깅 함수가 request.json()을 먼저 호출해버려 뒤쪽 결제 처리 로직이 빈 스트림을 받고 있었습니다.


스트림 에러 우회 해결 코드 예시

request.clone() 기법과 단일 const body 캐싱 방식을 혼용하여 스트림 락 문제를 회피하는 작성 패턴입니다.

import { NextResponse } from 'next/server';

// 보안 감사 및 디버깅용 전용 로깅 함수
async function logRequestBody(req: Request) {
  try {
    // 인자로 넘어온 Request 객체의 복제품을 생성해 스트림 소비 방지
    const clonedRequest = req.clone();
    const parsedData = await clonedRequest.json();
    console.log('[보안 로깅 시스템] 유입 본문 데이터:', parsedData);
  } catch (err) {
    console.error('로깅 데이터 파싱 실패', err);
  }
}

export async function POST(request: Request) {
  // 1. 요청 복제본을 감시 로거에 전달 (본래의 request 스트림은 여전히 보존됨)
  await logRequestBody(request);

  // 2. 단일 캐싱 변수 기법을 혼용하여 혹시 모를 내부 중복 호출 원천 차단
  let requestBody;
  try {
    requestBody = await request.json(); // 본래의 request 스트림 1회 소비
  } catch (error) {
    return NextResponse.json({ error: "올바르지 않은 JSON 데이터 규격" }, { status: 400 });
  }

  // 3. 실제 비즈니스 가공 로직 진행 (이미 파싱해 둔 변수 활용)
  const { userId, productId } = requestBody;
  console.log(`주문 요청 승인 - 유저: ${userId}, 상품번호: ${productId}`);

  return NextResponse.json({ success: true, message: "주문 처리가 완료되었습니다." });
}

해결 코드의 라인별 동작 해설

위 코드는 요청 본문 유실을 방지하기 위한 이중 안전장치를 갖추고 있습니다. POST 라우트가 최초 실행되면 호출되는 logRequestBody(request) 내부에서, 인자로 주입된 request 객체의 복제품인 req.clone()을 만들어 별개의 스트림 인스턴스로 분리합니다. 이 복제된 clonedRequest는 원본 request와 독립적인 스트림이므로, 내부에서 await clonedRequest.json()을 호출해 소비하더라도 원본 request 스트림은 영향을 받지 않고 그대로 대기 상태를 유지합니다. 이후 메인 핸들러 로직으로 제어가 반환되면, 원본 request.json()을 한 번만 읽어 들인 후 requestBody라는 로컬 변수에 담아둡니다. 이후 코드에서 해당 데이터가 여러 번 필요할 때는 스트림 메소드를 다시 호출하는 게 아니라 이미 파싱된 requestBody 변수를 재참조하기 때문에, 스트림 닫힘과 중복 읽기 예외를 원천적으로 피할 수 있습니다.


요약: 본문 데이터 추출 방식 비교 장단점

요청 본문을 다루는 전략은 크게 세 가지로 나뉩니다. 요청마다 json()을 여러 번 직접 호출하는 순수 다중 읽기 방식은 스트림이 이미 소비된 뒤 다시 읽으려 하면 런타임이 다운되므로 피해야 합니다. request.clone()으로 원본과 별개인 복제 스트림을 만들어 로깅 등 우회 용도로 넘기는 방식은 미들웨어 감시 로직에 특히 적합합니다. 함수 진입 시점에 한 번만 파싱해 변수에 담아두고 이후 재참조하는 단일 로컬 캐싱 방식은 하나의 API 컨트롤러 내부에서 표준적으로 쓰기 좋습니다.

추가 추천 자료 및 공식 레퍼런스

MDN Web Docs와 Next.js의 Request 객체 클론 명세는 아래 문서에서 확인할 수 있습니다.

호마다의 웹개발 팁

요청 본문을 여러 곳에서 다뤄야 한다면, 매번 스트림을 다시 읽으려 하지 말고 한 번 파싱한 값을 변수에 담아 재사용하는 습관을 들이는 게 가장 안전합니다. 관련 기초가 헷갈린다면 [호마다의 IT 개발 입문 블로그] 도 참고해보세요.

댓글

이 블로그의 인기 게시물

TypeScript: Unknown vs Any 타입 차이점과 안전한 타입 가드(Type Guard)

Next.js 하이드레이션 오류 원인과 해결법 완벽 정리

Next.js App Router에서 getServerSideProps 대체 및 데이터 페칭법