Express로 백엔드 API를 개발하다가 서버 콘솔에 Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client 로그가 뿜어져 나오며 응답이 꼬이거나 서버 프로세스가 방해받는 현상을 경험해봤을 것이다.
클라이언트 요청 하나에 처리 결과를 보내려고 했을 뿐인데 무심하게 터지는 이 에러는 개발자를 꽤나 당황스럽게 만든다.
다만 정확한 원인이 무엇이고 비동기 흐름에서 왜 이런 문제가 발생하는지 모른 채 그저 임시방편으로 코드 위치만 바꾸는 경우가 많다.
이번에는 이 에러가 정확히 뭔지, 왜 발생하는지, 그리고 어떻게 깔끔하게 해결하는지 완벽하게 정리해서 소개하겠다.

 

1. ERR_HTTP_HEADERS_SENT 에러의 기초 개념

HTTP 프로토콜 규격상 하나의 클라이언트 요청(Request)에는 단 하나의 서버 응답(Response)만 전송되어야 한다.
서버가 응답을 시작할 때 브라우저에 상태 코드(200, 400, 500 등)와 헤더 정보를 먼저 전송하고, 그 뒤에 실제 데이터 본문(Body)을 보낸다.
ERR_HTTP_HEADERS_SENT 에러는 이미 헤더 전송이 끝난 상태에서 res.send(), res.json(), res.redirect(), res.render() 같은 응답 메서드를 또다시 호출할 때 발생한다.
즉, Express 입장에서 이미 응답을 마쳐서 간판을 내렸는데 또 응답을 보내라고 하니 락을 걸고 에러를 던지는 것이다.

 

2. 에러가 발생하는 원인 비교 및 대표적 상황

실무 코드에서 이 에러가 발생하는 패턴은 크게 세 가지로 나뉜다.
조건문 분기 실수, 비동기 처리(Promise/async-await) 미숙, 그리고 Express 미들웨어에서의 next() 오용이다.

발생 원인상세 현상핵심 문제점
조건문 반환 누락if 문 내부에서 res.json()을 실행했지만 return 하지 않음조건문 하단 코드가 계속 실행되어 두 번째 res.json() 호출
비동기 예외 처리 미흡catch 블록이나 콜백 내부에서 응답 후 메인 흐름 진행비동기 작업 완료 전후로 응답이 중복 전송됨
미들웨어 next() 중복미들웨어에서 res.send()를 하고 동시에 next()를 호출함다음 라우터 핸들러에서도 응답을 시도하여 충돌 발생

 

3. 실전 예제 및 올바른 해결 코드

가장 자주 접하는 사용자 검증 및 DB 조회 비동기 API 시나리오를 통해 잘못된 코드와 올바른 코드를 비교해본다.

실수하기 쉬운 잘못된 코드

✗ 잘못된 코드: res.status().json()을 호출해도 함수 실행이 멈추지 않아서 아래쪽 응답 코드가 또 실행된다.

// ✗ 잘못된 코드 예시
app.post('/api/users', async (req, res) => {
    const { email } = req.body;

    if (!email) {
        // 응답은 전송되지만 함수가 종료되지 않는다!
        res.status(400).json({ error: '이메일이 누락되었습니다.' });
    }

    try {
        const user = await findUserByEmail(email);
        // 위에서 email이 없어 400 응답을 보냈어도 이 줄이 실행되며 ERR_HTTP_HEADERS_SENT 에러 발생
        res.status(200).json({ user });
    } catch (err) {
        res.status(500).json({ error: '서버 에러가 발생했습니다.' });
    }
});

출력 결과(서버 콘솔 에러 로그):

Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client
    at new NodeError (node:internal/errors:387:15)
    at ServerResponse.setHeader (node:_http_outgoing:603:11)
    at ServerResponse.status (/node_modules/express/lib/response.js:746:10)
완벽하게 수정된 올바른 코드

✓ 올바른 코드: 응답 전송 시 반드시 return 키워드를 붙여 라우터 핸들러 함수 실행을 즉시 중단시킨다.

// ✓ 올바른 코드 예시
app.post('/api/users', async (req, res) => {
    const { email } = req.body;

    if (!email) {
        // return을 추가하여 응답 전송과 동시에 함수 실행을 반환 및 종료한다
        return res.status(400).json({ error: '이메일이 누락되었습니다.' });
    }

    try {
        const user = await findUserByEmail(email);
        if (!user) {
            return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
        }
        return res.status(200).json({ success: true, user });
    } catch (err) {
        return res.status(500).json({ error: '서버 에러가 발생했습니다.' });
    }
});

수정 후 결과:
요청 파라미터가 유효하지 않을 경우 400 응답을 클라이언트에 안전하게 전달하고 핸들러가 정상 종료되며 콘솔 에러가 깔끔하게 사라진다.

 

4. 주의사항과 흔한 실수 방지 체크리스트

대부분의 개발자가 오해하는 부분은 res.json()이나 res.send() 자체가 return 처럼 동작해서 코드 실행을 멈춰줄 것이라고 생각하는 점이다.
Express의 응답 메서드는 단지 소켓을 통해 데이터를 내보내는 함수일 뿐 자바스크립트의 실행 제어 흐름에는 아무런 영향도 주지 않는다.

✗ 잘못된 것: res.send()나 res.redirect() 호출이 제어 흐름을 끊어줄 것이라 믿고 return을 생략하는 것.
✓ 올바른 것: 라우터 핸들러 내에서 응답을 보낼 때는 습관적으로 return res.json(...) 구문을 사용하는 것.

✗ 잘못된 것: 커스텀 인증 미들웨어에서 res.status(401).json(...)을 호출한 바로 다음 줄에 next()를 작성하는 것.
✓ 올바른 것: 인증 실패 시에는 return res.status(401).json(...)으로 끝내고, 통과했을 때만 return next()를 호출해 라우터로 넘기는 것.

 

5. 핵심 정리 및 응답 제어 패턴 습관화

ERR_HTTP_HEADERS_SENT 에러는 Express의 비동기 응답 제어 흐름이 명확하지 않을 때 발생하는 대표적인 신호다.
모든 라우터 핸들러와 미들웨어에서 응답 전송 시 return 키워드를 붙이는 작은 습관이 모여서 안정적인 백엔드 애플리케이션을 만든다는 점을 잊지 말자.
이 글의 예제 패턴을 참고해 기존 프로젝트의 조건부 응답 흐름과 미들웨어를 점검하면 더 이상 헤더 중복 에러로 인해 서버가 멈추는 일은 없을 것이다.