성공 응답 하나를 보내고, 본문을 점진적으로 작성한 다음, 메시지를 정상적으로 마무리하세요. 이 Mac 터미널 안내에서는 각 청크를 별도의 응답으로 취급하거나 206 Partial Content를 사용하지 않고 완전한 합성 문서를 전달하는 방법을 보여 줍니다.

공개 호스팅용 HTML 페이지 하나를 제공한 다음, 클라이언트가 전체 콘텐츠를 받는지 확인하는 것이 이 작업의 목표입니다. 시연은 로컬에서 실행되며 어떤 것도 게시하지 않습니다. 터미널, curl, 그리고 공식 Node.js 다운로드 페이지에서 제공하는 지원 대상 Node.js LTS 릴리스가 필요합니다.

청크가 부분 응답을 의미하지 않는 이유

응답의 의미와 전송 프레이밍은 서로 다른 질문에 답합니다. 일반적인 성공 GET에서 200 OK는 성공적인 검색을 나타냅니다. 206 Partial Content는 서버가 표현의 선택된 부분을 전송해 범위 요청을 처리한다는 뜻입니다. 전송을 여러 번의 쓰기로 나누어도 범위 요청이 되는 것은 아닙니다. RFC 9110: HTTP Semantics, 206 Partial Content.

예를 들어, 서론과 설명, 맺음말로 구성된 합성 가이드는 하나의 문서인 채로 점진적으로 도착할 수 있습니다. 응답 헤더를 한 번 보내고, 이어서 본문 섹션을 순서대로 전송하세요. 각 섹션 앞에 새 HTTP 상태 표시줄을 붙이지 마세요.

HTTP/1.1 청크 전송 코딩은 본문 바이트를 16진수 길이로 감싸며, 크기가 0인 청크와 필수 종료 빈 줄로 끝납니다. 이러한 프레이밍 바이트는 전송 경계를 구분하며, 기사 콘텐츠가 아닙니다. 송신자는 Content-Length와 Transfer-Encoding을 함께 사용하면 안 됩니다. RFC 9112: Chunked Transfer Coding, Content-Length.

실용적인 원칙은 HTTP 라이브러리가 메시지를 프레이밍하도록 두는 것입니다. 애플리케이션은 문서 콘텐츠를 제공하고 그 콘텐츠가 끝났음을 알립니다.

어떤 스트리밍 방식을 사용해야 할까요?

지원되는 방식은 HTTP 버전에 따라 다릅니다. 아래 세 버전 모두 완전한 본문을 스트리밍할 수 있지만 프레이밍 방식은 서로 다릅니다.

HTTP 버전 본문 콘텐츠 전송 방식 완료 방식
청크 코딩을 사용하는 HTTP/1.1 길이 접두사가 붙은 청크 크기가 0인 종료 청크와 완료된 트레일러 섹션
HTTP/2 DATA 프레임 헤더 블록 완료를 포함해 마지막 프레임에 END_STREAM 설정
HTTP/3 DATA 프레임 응답 완료 및 스트림 전송 방향 종료

HTTP/2에서는 HTTP/1.1 청크 전송 코딩을 명시적으로 금지합니다. HTTP/3은 전송 코딩을 정의하지 않으며 Transfer-Encoding 헤더를 금지합니다. 모든 연결에 Transfer-Encoding: chunked를 강제하지 말고 서버가 해당 프로토콜의 프레이밍을 구현하도록 하세요. RFC 9113: HTTP Message Exchanges, RFC 9114: HTTP Message Framing.

이미 조립된 작은 문서는 보통 일반적인 방식으로 전송하는 것이 가장 간단합니다. 콘텐츠가 시간에 걸쳐 생성되는 경우에는 점진적으로 쓰는 방식이 유용합니다. 아래 예제는 동작을 살펴볼 수 있도록 세 번의 쓰기 사이에 의도적으로 간격을 둡니다. 이 지연은 설명용일 뿐, 게시 권장 사항이 아닙니다.

Mac에서 완전한 스트리밍 문서 시작하기

터미널을 열고 필수 구성 요소를 확인하세요.

node --version
curl --version

node를 사용할 수 없다면 Node.js 다운로드 페이지에서 지원되는 LTS 릴리스를 설치한 다음, 새 터미널 창을 여세요. 이 프로그램은 기본 제공 모듈만 사용하며 npm 패키지가 필요하지 않습니다.

아래 전체 블록을 터미널에 붙여 넣으세요. 시연 주소 http://127.0.0.1:8080/document에서 HTTP/1.1 서버가 시작됩니다. 세 문자열을 합치면 완전한 합성 페이지 하나가 됩니다.

node <<'JS'
const http = require('node:http');

const parts = [
  '<!doctype html>\n<html lang="en"><head><meta charset="utf-8">' +
    '<title>Paper Shapes</title></head><body><main>\n' +
    '<h1>Paper Shapes</h1>\n',
  '<p>A circle, a square, and a triangle make this sample collection.</p>\n',
  '<p>End of the paper shapes guide.</p>\n</main></body></html>\n'
];

const server = http.createServer((req, res) => {
  res.on('error', error => console.error(error.message));
  if (req.method !== 'GET' || req.url !== '/document') {
    res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
    res.end('No sample document at this path.\n');
    return;
  }

  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
  let index = 0;
  const timer = setInterval(() => {
    if (res.destroyed) {
      clearInterval(timer);
      return;
    }
    res.write(parts[index++]);
    if (index === parts.length) {
      clearInterval(timer);
      res.end();
    }
  }, 300);
  res.on('close', () => clearInterval(timer));
});

server.on('error', error => {
  console.error(error.message);
  process.exitCode = 1;
});
server.listen(8080, '127.0.0.1', () => {
  console.log('Sample: http://127.0.0.1:8080/document');
});
JS

애플리케이션은 writeHead()을 한 번, write()을 세 번, end()를 한 번 호출합니다. Node 문서에 따르면 연속된 쓰기는 동일한 응답 본문의 일부이며, 각 응답을 완료하려면 end()을 호출해야 합니다. HTTP 프레이밍은 Node가 처리하므로 문자열에 청크 길이나 종료 0을 추가하지 마세요. Node.js HTTP documentation.

이 예제는 작은 문자열 세 개를 사용하는 제한된 시연입니다. 대용량 또는 연속적인 데이터 생성에서는 백프레셔를 고려하세요. write()이 false을 반환하면 drain까지 데이터 생성을 멈추고, 동시에 연결 종료와 오류도 처리하세요. Node.js response writing documentation.

이 터미널 창은 계속 실행해 두세요. EADDRINUSE가 표시되면 포트 8080을 이미 사용 중이라는 뜻입니다. 서버에서 사용 가능한 다른 포트를 선택하고 아래 모든 클라이언트 명령의 포트도 변경하세요. 완료되면 Control-C로 시연을 중지하세요.

디코딩된 콘텐츠와 HTTP 청크 프레이밍 모두 확인하기

두 번째 터미널 창에서 문서를 요청하세요.

curl --http1.1 --include --no-buffer --max-time 5 \
  http://127.0.0.1:8080/document

200 OK 응답 하나, text/html; charset=utf-8의 Content-Type, 그리고 조립된 페이지 콘텐츠가 표시되어야 합니다. 이 HTTP/1.1 예제에서는 응답이 Transfer-Encoding: chunked를 사용해야 합니다. curl은 일반적으로 전송 프레이밍을 디코딩하므로, 기본 출력에는 16진수 청크 크기가 아닌 문서가 표시됩니다.

프레이밍을 확인하려면 --raw를 사용해 요청을 반복하세요.

curl --http1.1 --include --no-buffer --raw --max-time 5 \
  http://127.0.0.1:8080/document

curl 문서에 따르면 --raw는 콘텐츠 및 전송 인코딩의 내부 디코딩을 비활성화합니다. --no-buffer는 curl의 출력 버퍼링을 비활성화하고, --max-time 5는 작업 시간을 제한합니다. 이 옵션을 사용하면 무기한 기다리는 명령이 아니라 짧은 진단 명령으로 실행됩니다. curl manual.

원시 출력에서는 콘텐츠를 둘러싼 크기 줄이 표시되고, 그 뒤에 크기가 0인 종료 청크가 나옵니다. 이 종료 청크가 없으면 청크 방식 HTTP/1.1 본문은 불완전합니다. 고정 길이 프레이밍의 경우 선언된 길이보다 적은 바이트를 받으면 불완전합니다. RFC 9112: Handling Incomplete Messages.

원시 출력은 전송 과정을 살펴볼 때만 사용하세요. 크기 줄을 페이지 파일에 복사하거나 문서 섹션으로 취급하지 마세요.

처리하기 전에 문서 전체를 받기

다음 단계에서 완전한 페이지가 필요하다면 파싱, 저장 또는 승인하기 전에 본문 전체를 읽으세요. WHATWG는 응답 본문을 스트림으로 정의합니다. text() 메서드는 본문 전체를 읽어 문자열을 반환하며, 본문 소비에 실패하면 거부됩니다. 응답 헤더만 받았다고 해서 본문 전송이 끝난 것은 아닙니다. WHATWG Fetch Standard: Body mixin, Processing responses.

서버를 계속 실행해 둔 상태에서, 아래 클라이언트를 두 번째 터미널 창에 통째로 붙여 넣으세요.

node --input-type=module <<'JS'
try {
  const response = await fetch('http://127.0.0.1:8080/document', {
    signal: AbortSignal.timeout(5000)
  });
  if (response.status !== 200) {
    throw new Error(`Expected 200; received ${response.status}`);
  }

  const html = await response.text();
  const marker = 'End of the paper shapes guide.';
  if (!html.includes(marker) || !html.endsWith('</html>\n')) {
    throw new Error('Expected sample ending is missing');
  }

  console.log(html);
  console.log('Complete sample document received.');
} catch (error) {
  console.error('Document not accepted:', error.message);
  process.exitCode = 1;
}
JS

Node는 브라우저와 호환되는 fetch() 구현을 제공합니다. 이 구현의 AbortSignal.timeout()는 지정된 밀리초 후에 요청을 중단하는 신호를 만듭니다. 여기서는 작은 로컬 예제를 위한 설명용 제한 시간으로 5초를 사용합니다. Node.js global objects documentation, AbortSignal timeout documentation.

이 클라이언트는 서로 다른 두 가지를 확인합니다. 본문을 성공적으로 모두 읽었는지와 예상한 샘플 끝부분이 있는지입니다. 두 번째 확인은 애플리케이션별 진단입니다. 애플리케이션이 end()을 호출하기 전에 실수로 내용을 줄였더라도 HTTP 전송 자체는 성공할 수 있습니다. 완료 표시자는 그러한 특정 실수를 찾아내는 데 도움이 되지만, 모든 문단을 검증하거나 일반적인 HTML 유효성 검사를 수행하지는 않습니다.

이 클라이언트는 페이지 전체를 메모리에 버퍼링합니다. 매우 큰 문서에는 명시적인 문서 형식과 완료 규칙을 갖춘 점진적 처리를 선택하세요. 각 읽기 작업이 완전한 문단이나 독립적으로 의미 있는 응답을 전달한다고 가정하지 마세요.

상태 코드를 바꾸기 전에 완료 여부 진단하기

문제의 위치를 찾으려면 다음 순서를 따르세요. 이 순서는 전송 완료와 애플리케이션이 의도한 콘텐츠를 분리하므로, 겉보기에 다운로드가 성공했다고 해서 끝부분이 빠진 사실을 놓치지 않습니다.

관찰 결과 다음 조치
로컬 명령에서 연결할 수 없음 서버가 실행 중이고 포트가 일치하는지 확인하세요.
curl 시간 초과 데이터 생성이 완료되고 end()을 호출하는지 확인하고 오류 출력을 보존하세요.
전송은 끝났지만 끝부분 확인에 실패함 생성된 콘텐츠와 애플리케이션이 응답을 끝내는 지점을 살펴보세요.
완전한 문서가 한꺼번에 도착함 원시 출력과 디코딩된 출력을 비교하세요. 표시 시점만 보고 여러 응답이 있었다고 추론하지 마세요.
일반적인 전체 페이지 요청에서 206을 받음 범위 처리가 의도치 않게 적용되었는지 확인하세요. 점진적 전송만으로는 이 상태 코드를 사용할 이유가 없습니다.

완료 확인은 별도의 HTTP 기능이 아니라 프로토콜과 Fetch 동작에 따른 권장 사항입니다. 본문 읽기 작업에 실패하면 위 클라이언트는 실패를 보고하고 비정상적으로 종료하여, 지금까지 받은 콘텐츠를 성공한 것으로 출력하지 않습니다.

공개 페이지와 Google 검색에는 어떤 의미가 있나요?

유용한 문서 콘텐츠를 HTML 응답 자체에 포함하세요. Google은 응답 HTML에 모든 콘텐츠가 들어 있는 페이지를 크롤링하고 파싱하기 쉽다고 설명하며, 모든 봇이 JavaScript를 실행하는 것은 아니므로 서버 측 렌더링이나 사전 렌더링을 권장합니다. 이 예제는 텍스트가 문서 본문에 직접 도착하므로 이 접근 방식을 따릅니다. Google Search Central: Understand the JavaScript SEO basics.

Google 크롤러 문서에 따르면 200 응답은 받은 콘텐츠를 다음 처리 단계로 전달하지만, 색인 생성을 보장하지는 않습니다. 인용한 문서에는 페이지 색인을 보장하는 특별한 청크 크기나 지연 시간이 제시되어 있지 않습니다. 스트리밍은 전송 구현으로 다루고, 완성된 콘텐츠는 별도로 확인하세요. Google: How HTTP status codes affect Google's crawlers.

이 시연을 공개 호스팅에 적용할 때는 실제 문서 엔드포인트에서 확인 절차를 반복하세요. 성공 상태를 확인하고, 본문 전체를 읽은 다음, 최종 콘텐츠가 의도한 페이지와 일치하는지 비교하세요. 문서를 전달한 쓰기 횟수, 프레임 수 또는 화면에 보인 업데이트 수와 완료 판정을 별개로 유지하세요.

출처