TechFeedTechFeed
Backend

headers was already sent, API 라우트, res.end | JSON을 두 번 쓰면 왜 500이 되나?

페이지 API에서 JSON을 두 번 쓰면 노드가 헤더를 이미 보냈다고 하고 화면은 500이 된다. res.json 다음 return, headersSent, 앱 라우트 Response 반환을 나눈다. 카카오 콜백과 토스 웹훅에서 200 뒤 오류 칸을 본다. Next.js, Node.js, API, 웹훅, 백엔드, 개발자 기준. 2026년 9월 Node.js, Next.js 공식 문서.

by

Cannot set headers after they are sent to the client는 제이슨을 두 번 써서 납니다. 페이지 라우트에서 성공 응답을 보낸 뒤 반환 없이 다른 줄이 또 쓰면, 노드가 예외를 던지고 화면은 500이 됩니다.


문의는 디비에 들어갔는데 응답만 터지는 일, 카카오 콜백이나 토스 웹훅을 페이지 폴더에 붙이다 보면 한 번은 만납니다. 로컬에선 성공 몸이 보이는데 배포 로그만 헤더를 이미 보냈다고 찍히죠.


성공 칸에서 바로 돌아오고, 에러 칸은 머리글이 나갔는지 확인한 뒤에만 씁니다. 앱 라우트의 반환 응답과는 자리가 다릅니다. 근거는 노드 오류 코드넥스트 페이지 라우트에 있습니다.


JSON을 두 번 쓰면 왜 500이 나나

헤더를 이미 보낸 뒤에 상태 코드나 제이슨을 다시 쓰려고 해서입니다. 응답 머리글은 한 번만 나갑니다. 노드도 머리글 쓰기는 한 번만, 그리고 스트림을 닫기 앞에서만 하라고 적혀 있습니다.


페이지 라우트의 응답 객체는 노드 서버 응답에 헬퍼를 붙인 값입니다. 제이슨 헬퍼는 내용 유형을 정하고 몸을 직렬화한 뒤 스트림을 닫죠. 그 순간 머리글을 보냈다는 값이 참이 됩니다. 그다음에 500 제이슨을 또 부르면 노드가 머리글을 이미 보냈다는 오류를 던집니다.


화면에는 그냥 500으로 보여요. 클라이언트는 이미 200 몸을 일부 받았을 수도 있고, 연결이 끊긴 채로 끝날 수도 있습니다. 로그에 헤더를 이미 보냈다는 영어 문장이 있으면 타임아웃이 아니라 이중 응답입니다. 앞단이 유효한 응답을 못 받은 502와는 칸이 다르고, 함수가 초를 넘겨 끊긴 504와도 다릅니다.


먼저 기억할 것 | 페이지 라우트는 요청당 응답을 한 번만 닫습니다. 제이슨·전송·종료 헬퍼 뒤에는 반드시 반환하세요. 앱 라우트는 응답 객체를 돌려주는 한 줄이 그 역할입니다.


서버가 응답 헤더를 한 번 보낸 뒤 두 번째 쓰기를 거절하는 개념 이미지
헤더가 소켓으로 나간 뒤에는 상태 코드와 제이슨을 다시 쓸 수 없다

로그에 이미 보냈다면 어디를 보나

가드 분기, 성공 뒤 오류 칸, 페이지 라우트와 앱 라우트 혼용 세 줄이면 원인이 갈립니다.


증상먼저 할 일
가드 누락 반환401을 보낸 뒤 200 제이슨이 또 나감제이슨 다음 줄에서 바로 반환
성공 뒤 오류 칸저장은 되고 응답만 500보낸 뒤 던진 오류는 로그만, 응답은 쓰지 않음
라우터 혼용앱 라우트에서 종료 헬퍼를 부름응답 객체를 돌려주기만
스트림 후 제이슨쓰기를 연 다음 제이슨을 또 부름쓰기 시작했으면 종료로만 닫기
미들웨어 중복헬퍼가 이미 401을 보냄헬퍼가 보냈으면 핸들러는 침묵

다섯 번째 칸은 인증 헬퍼가 이미 401 제이슨을 보냈는데, 핸들러가 그걸 모르고 본문을 또 쓸 때입니다. 로그인은 됐는데 저장만 거절되는 줄은 만료 토큰 글에서 봅니다. 여기서는 권한 실패 자체가 아니라, 실패 응답을 두 번 쓰는 줄만 고칩니다.


교차 출처 헤더를 나중에 붙이려다 같은 오류가 나기도 합니다. 허용 출처는 응답을 열기 전에 정해야 합니다. 프리플라이트 조건은 씨오알에스 에러 글과 자리를 나눕니다.


return을 빼먹은 분기가 가장 흔하다

가드에서 제이슨을 보낸 뒤 함수가 아래로 계속 흐르면 두 번째 몸이 나갑니다. 사용자 없음 분기에 401만 보내고 반환이 없으면, 다음 줄의 성공 응답이 바로 충돌합니다.


넥스트 페이지 라우트 문서는 핸들러가 상태 200 제이슨으로 몸을 보낸다고만 적혀 있어요. 여러 분기를 두면 호출만으로는 함수가 끝나지 않습니다. 자바스크립트는 그 줄 아래를 계속 실행하죠. 그래서 보낸 뒤에는 반드시 돌아옵니다.


메서드 가드도 같습니다. 등록 메서드가 아니면 405를 보내고 끝내야 합니다. 보냈는데 본문 파싱과 디비 쓰기가 이어지면, 카카오 콜백처럼 재시도가 오는 경로에서 같은 줄이 반복됩니다. 혼자 돌리는 문의 라우트도 메서드 가드 한 줄에 반환을 빼먹으면 저장은 되고 응답만 깨집니다.


가드마다 보낸 뒤 바로 돌아오기
export default async function handler(req, res) { if (req.method !== 'POST') { res.status(405).json({ error: 'method not allowed' }) return } const user = req.body && req.body.user if (!user) { res.status(401).json({ error: 'unauthorized' }) return } res.status(200).json({ ok: true, user }) }
  • [ ] 모든 제이슨·전송·종료 헬퍼 다음에 반환이 있다
  • [ ] 401·404·405 가드가 성공 경로와 같은 함수 아래로 안 흘러간다
  • [ ] 헬퍼가 응답을 닫으면 호출 쪽은 값을 보고 멈춘다
  • [ ] 로컬에서 가드 주소를 한 번씩 쳐 봤다

API 라우트 코드에서 가드 분기 뒤에 return을 두는 작업 장면
제이슨을 보낸 줄 바로 아래에 반환이 없으면 성공 몸이 한 번 더 나간다

저장은 됐는데 catch가 500을 또 쓸 때

성공 제이슨을 보낸 뒤에 난 오류는 응답을 고칠 수 없습니다. 성공을 닫은 다음에서 로그·알림·부가 조회가 던지면 오류 칸이 500을 또 쓰려 합니다. 디비 행은 있는데 클라이언트만 실패로 보는 전형적인 패턴입니다.


고치는 순서는 단순합니다. 클라이언트에 줄 값만 만든 뒤 응답을 한 번 닫습니다. 닫은 다음 일은 실패해도 응답을 건드리지 않습니다. 오류 칸에서는 머리글이 이미 나갔으면 로그만 남기고 멈춥니다. 노드 문서의 머리글 전송 여부는 그 값을 읽는 칸입니다.


페이지 라우트 예시처럼 시도 칸에서 결과를 제이슨으로 보내고 오류 칸에서 500을 보내는 형태는, 성공 경로가 응답을 닫기 전에만 유효합니다. 성공을 보낸 뒤 부가 작업은 시도 칸 밖으로 빼거나, 보낸 뒤에는 오류 칸이 로그만 남기게 하세요. 배포 함수가 길어서 끊기는 줄은 게이트웨이 타임아웃에서 봅니다.


보낸 뒤에는 에러 응답을 쓰지 않기
export default async function handler(req, res) { try { const id = req.body && req.body.id if (!id) { res.status(400).json({ error: 'id required' }) return } res.status(200).json({ ok: true, id }) } catch (err) { if (res.headersSent) { console.error('already sent', err) return } res.status(500).json({ error: 'failed' }) } }

머리글 전송 여부는 우회가 아니다 | 가드를 고친 뒤의 마지막 안전망입니다. 이 값이 참인데 오류 칸이 자주 탄다면, 성공 경로에서 아직 던지고 있는 줄이 있습니다. 로그 한 줄로 그 줄을 찾은 다음 응답 밖으로 옮기세요.


앱 라우트 Response와 페이지 API는 다른 칸

앱 라우트 핸들러는 웹 응답 객체를 돌려주고, 페이지 라우트는 노드 응답 객체에 씁니다. 넥스트 문서도 앱 폴더의 라우트 핸들러가 페이지 라우트의 자리에 해당한다고 적습니다. 둘을 한 파일에서 섞지 마세요.


앱 쪽은 제이슨 응답을 만들어 돌려주는 한 줄이 끝입니다. 반환하면 프레임워크가 스트림을 닫습니다. 여기서 종료 헬퍼를 부르거나, 반환 없이 응답 객체만 만들면 쓰이지 않는 값만 남습니다. 반대로 페이지 라우트에서 웹 응답만 만들고 노드 응답을 안 닫으면 함수가 몸 없이 끝나 502로 보일 수 있습니다. 프록시가 유효한 몸을 못 받은 줄은 배드 게이트웨이에서 봅니다.


옮기는 중에 페이지 폴더 웹훅과 앱 폴더 웹훅이 같이 있으면 어느 쪽이 붙었는지도 확인하세요. 로컬은 페이지가 받고 배포는 앱이 받는 식의 어긋남이 납니다. 파일 하나만 남기고, 그 칸의 응답 방식만 쓰세요.


앱 라우트는 반환 한 줄로 닫기
export async function POST(request) { const body = await request.json() if (!body || !body.id) { return Response.json({ error: 'id required' }, { status: 400 }) } return Response.json({ ok: true, id: body.id }) }
응답 한 번두 번 쓰는 실수
페이지 라우트제이슨을 보낸 뒤 반환가드 제이슨 다음 성공 제이슨
앱 라우트응답 객체를 돌려줌만든 객체를 안 돌려주고 종료 헬퍼
스트림쓰기 후 종료쓰기 후 제이슨

카카오·토스 웹훅에서 200을 먼저 보낸 뒤

웹훅은 빨리 200을 주고 본 일을 뒤에서 하는 경우가 많습니다. 그 패턴 자체는 맞습니다. 문제는 200을 보낸 다음 오류 칸이 500 제이슨을 또 쓸 때입니다. 카카오는 실패로 보고 같은 콜백을 다시 넣고, 토스도 재시도 줄에 남깁니다. 문의 한 건이 두 번 저장됩니다.


제가 쓰는 콜백도 검증만 통과하면 바로 200을 닫고, 알림 메일과 디비 보강은 그 뒤에 둡니다. 뒤 일이 실패하면 로그만 남깁니다. 이미 닫힌 응답에 에러 몸을 얹지 않습니다. 로컬에서 터널로 콜백을 받을 때는 재시도가 안 보여서 놓치기 쉽습니다. 배포 로그에 헤더를 이미 보냈다는 줄이 있으면, 200 다음 오류 칸부터 보세요.


아웃바운드가 막혀 함수가 늦게 죽는 줄은 페치 타임아웃입니다. 헤더 이중 전송과 동시에 보이면, 대개 성공 응답을 보낸 뒤 외부 호출이 던진 겁니다. 외부 호출을 응답 앞으로 당길지, 뒤로 미루고 에러 응답을 막을지 하나만 고르세요. 둘 다 하면 같은 사고가 납니다.


웹훅 서버가 먼저 200을 보내고 뒤 작업을 분리하는 데이터센터 장면
콜백은 200을 빨리 닫고, 닫은 뒤에는 에러 제이슨을 다시 쓰지 않는다

참고 자료


내부 연계: 502 배드 게이트웨이, 504 게이트웨이 타임아웃, 씨오알에스 프리플라이트, 401 만료 토큰, 페치 타임아웃


인용한 동작은 2026년 9월 공개 문서 기준입니다.


자주 묻는 질문

디비에는 들어갔는데 화면만 500입니다. 롤백해야 하나요?

저장 다음에 응답을 두 번 쓴 경우가 많습니다. 행을 지우기 전에 로그에서 헤더를 이미 보냈다는 줄을 보세요. 가드 반환과 오류 칸의 두 번째 제이슨부터 고치면, 다음 요청은 200으로 끝납니다.


로컬은 되고 버셀만 헤더를 이미 보냈다고 합니다.

로컬은 성공 몸만 보고 두 번째 쓰기를 못 볼 수 있습니다. 배포는 함수가 예외를 로그에 남깁니다. 같은 핸들러에서 200 다음 오류 칸이 있는지, 페이지 파일과 앱 파일이 둘 다 있는지를 확인하세요.


머리글 전송 여부만 보면 끝나나요?

그건 마지막 안전망입니다. 가드마다 반환을 넣고, 성공 응답 뒤에 던질 일을 빼는 쪽이 먼저입니다. 참인데 오류 칸이 자주 오면 아직 두 번 쓰고 있는 줄이 있습니다.


앱 라우트에서도 같은 오류가 나나요?

순수하게 Response만 반환하면 이 노드 오류는 잘 안 납니다. 앱 핸들러 안에서 노드 res에 쓰거나, 페이지 API 헬퍼를 그대로 가져오면 납니다. 파일 위치의 응답 방식 하나만 쓰세요.


카카오 콜백이 같은 이벤트를 두 번 넣습니다.

200을 준 뒤 에러 제이슨을 또 쓰면 상대는 실패로 보고 재시도합니다. 검증 통과 후 200만 닫고, 뒤 실패는 로그만 남기세요. 이벤트 아이디로 한 번만 저장하는 칸은 별도입니다.


502나 504와 무엇이 다른가요?

502는 프록시가 유효한 응답을 못 받은 줄이고, 504는 실행 한도가 먼저 끊긴 줄입니다. 지금 보는 줄은 함수 안에서 응답을 두 번 닫으려다 노드가 예외를 던진 경우입니다. 로그 문구를 먼저 읽으세요.


헤더를 이미 보냈다는 줄은 같은 요청에서 제이슨을 두 번 닫으려 했다는 뜻입니다. 가드마다 반환하고, 성공을 보낸 뒤에는 에러 몸을 쓰지 마세요. 앱 라우트면 반환만 합니다. 관련 글: 502, 504, 씨오알에스.


headers already sentERR_HTTP_HEADERS_SENTAPI 라우트res.jsonres.endNext.jsNode.js웹훅카카오토스백엔드개발자

함께 보면 좋은 문제 해결

EXPLORE / Backend

이어서 읽어보기

전체 토픽 둘러보기