Devin.KR

HTTP 성능 - keep-alive·타임아웃·압축

개발자KR 조회 15

이 장에서 배우는 것

앞서 다룬 프로세스 확장에 이어, 이번에는 단일 프로세스가 처리하는 네트워크 요청 자체의 효율을 끌어올리는 방법을 살펴본다. 네트워크 자원과 메모리를 다루는 방식에 따라 애플리케이션의 성능은 크게 달라진다.

  • 매번 통신 연결을 새로 맺는 낭비를 막고 하나의 연결을 재사용하는 원리를 배운다.
  • 클라이언트의 의도적인 지연이나 네트워크 불안정으로부터 서버를 보호하는 타임아웃 속성을 설정한다.
  • 대용량 응답을 보낼 때 메모리 사용량을 줄이면서 네트워크 전송 속도를 높이는 스트림 압축 기법을 익힌다.

문제 상황

여러 기기에서 발생하는 접속 로그를 중앙 서버로 모으는 애플리케이션을 운영하고 있다. 시간이 지나 트래픽이 늘어나면서 서버에 다음과 같은 증상이 나타나기 시작했다.

  • 로그를 보내는 데이터양이나 연산량은 서버 CPU가 충분히 감당할 수 있는 수준인데도 새로운 요청이 빈번하게 거부된다. 운영체제의 가용한 네트워크 포트가 고갈되어 새로운 통신 창구를 열지 못하는 상태다.
  • 일부 통신 환경이 나쁜 기기가 접속하여 요청 데이터를 아주 천천히 보내면, 서버가 해당 연결을 끊지 못하고 계속 유지하느라 메모리를 낭비한다.
  • 관리자가 수 기가바이트 크기의 전체 누적 로그 파일을 내려받으려 시도할 때마다 서버의 메모리가 한 번에 가득 차며 전체 서비스가 잠시 멈춘다.

이러한 증상은 모두 프로토콜의 특성과 Node.js가 입출력을 다루는 기본 방식을 상황에 맞게 조율하지 않아서 발생한다.

HTTP 연결 재사용과 keep-alive

클라이언트가 서버에 HTTP 요청을 보내려면 먼저 TCP 연결을 맺어야 한다. 이 과정은 클라이언트와 서버가 서로 패킷을 주고받으며 통신 준비를 마치는 3방향 핸드셰이크(3-way handshake) 단계를 포함한다. 보안 연결을 위해 HTTPS를 사용한다면 암호화 키를 교환하는 과정까지 더해진다.

한 번의 요청과 응답이 끝난 뒤 연결을 바로 닫아버리면, 다음 요청을 보낼 때 다시 핸드셰이크 과정을 거쳐야 한다. 단일 기기가 작은 크기의 로그를 짧은 주기로 반복해서 보내는 환경에서는 실제 데이터를 전송하는 시간보다 연결을 맺고 끊는 데 드는 시간이 더 길어진다. 게다가 연결을 끊은 뒤 운영체제의 네트워크 소켓은 일정 시간 동안 대기 상태(TIME_WAIT)에 머무른다. 너무 많은 연결이 단시간에 생성되고 닫히면 대기 상태의 소켓만 쌓여 결국 사용할 수 있는 포트가 부족해진다.

이 문제를 해결하기 위해 도입된 기능이 연결 재사용(keep-alive)이다. 하나의 TCP 연결을 열어두고 여러 번의 요청과 응답을 차례대로 주고받는 방식이다.

keep-alive 사용 여부에 따른 TCP 연결 비교

Node.js 서버는 기본적으로 클라이언트가 이 연결 유지를 요청하면 수락하도록 설정되어 있다. 반대로 Node.js가 클라이언트 역할을 할 때는 내장 모듈의 기본 상태가 매번 연결을 새로 맺도록 되어 있다. http 모듈로 다른 서버에 지속적인 요청을 보낼 때 연결을 재사용하려면 http.Agent 객체를 keepAlive: true 옵션과 함께 생성한 뒤 요청 옵션에 전달해야 한다.

이때 에이전트를 생성하며 maxSockets 속성도 함께 설정하는 것이 좋다. 이는 단일 호스트를 향해 동시에 열어둘 수 있는 최대 연결 수를 뜻한다. 연결을 무제한으로 열면 목적지 서버에 과부하를 줄 수 있으므로 적절한 제한을 걸어야 한다.

악의적인 지연과 서버 타임아웃

연결을 오래 유지하는 것은 성능을 높여주지만, 반대로 서버 자원을 불필요하게 묶어두는 원인이 되기도 한다. 서버가 감당할 수 있는 동시 연결 수는 무한하지 않다. 만약 누군가 악의적으로 수많은 연결을 맺어두고 헤더 데이터를 의도적으로 천천히 보낸다면, 서버는 이 요청이 언제 끝날지 모른 채 계속 메모리와 소켓을 할당한 상태로 기다려야 한다. 이를 슬로로리스(slowloris) 공격이라고 부른다.

이러한 현상을 막기 위해 http.Server 인스턴스에는 세 가지 주요 타임아웃 속성이 존재한다.

  • headersTimeout: 클라이언트가 연결을 맺은 시점부터 HTTP 헤더 전체를 서버로 보내는 데까지 허용되는 최대 시간이다.
  • requestTimeout: 연결 시점부터 헤더와 본문을 포함한 전체 요청 데이터를 모두 받는 데 허용되는 최대 시간이다. 무거운 파일 업로드를 오래 받는 서버가 아니라면 이 값을 설정해 두는 것이 안전하다.
  • keepAliveTimeout: 앞선 요청 처리가 끝난 후 다음 요청이 들어오기 전까지 서버가 빈 연결을 유지하며 대기하는 시간이다.
서버 타임아웃의 적용 구간

최신 Node.js 환경에서는 headersTimeout이 기본적으로 60초 등으로 잡혀 있어 어느 정도 서버를 보호해 준다. 하지만 로그 전송처럼 단순하고 빠른 통신 엔드포인트만 있다면 이 허용 시간을 훨씬 짧게 줄여 자원 회수 속도를 높이는 편이 유리하다.

응답 압축과 대용량 스트림

로그 데이터는 문자열이 반복되는 특성이 있어 압축 효율이 매우 높다. 관리자가 전체 로그를 내려받을 때 파일 내용을 그대로 응답하면 서버의 네트워크 대역폭을 불필요하게 낭비한다. 데이터를 압축해서 보내면 전송량은 줄어들지만, 대용량 파일을 한 번에 메모리로 읽어 들여 압축하면 앞서 언급한 메모리 부족 오류가 발생한다.

따라서 데이터는 항상 조각(chunk) 단위로 처리해야 한다. Node.js의 zlib 모듈은 스트림 형태의 Gzip 압축 등을 지원한다. 파일에서 데이터를 읽는 스트림, 읽은 데이터를 압축하는 변환 스트림, 그리고 HTTP 응답 스트림을 순서대로 이어주면 적은 메모리만으로도 기가바이트 단위의 파일을 안전하게 압축 전송할 수 있다.

스트림을 연결할 때는 node:stream/promises 모듈이 제공하는 pipeline 함수를 사용한다. 이 함수는 연결된 스트림 중 하나에서 오류가 발생하거나 클라이언트가 중간에 다운로드를 취소했을 때, 엮여 있는 다른 스트림들을 함께 정리해 주어 자원 누수를 방지한다.

완성 코드

개념을 종합하여 로그를 수집하고 대용량 로그를 압축하여 내려주는 서버, 그리고 연결 재사용 성능을 테스트하는 클라이언트를 작성한다. 두 파일을 한 디렉터리에 두고 터미널을 열어 실행해 본다.

server.mjs

import { createServer } from 'node:http';
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { pipeline } from 'node:stream/promises';
import { createGzip } from 'node:zlib';

const PORT = 3000;
// 응답 테스트를 위해 서버 스크립트 자신을 대용량 파일이라 가정한다.
const LOG_FILE = new URL(import.meta.url).pathname;

const server = createServer(async (req, res) => {
  if (req.method === 'POST' && req.url === '/logs') {
    // 로그 본문 수신 처리
    req.on('data', () => {});
    req.on('end', () => {
      res.writeHead(201, { 'Content-Type': 'text/plain' });
      res.end('created\n');
    });
    return;
  }

  if (req.method === 'GET' && req.url === '/download') {
    try {
      const fileStat = await stat(LOG_FILE);
      const acceptEncoding = req.headers['accept-encoding'] || '';

      // 클라이언트가 Gzip 압축을 지원하는지 확인한다.
      if (acceptEncoding.includes('gzip')) {
        res.writeHead(200, {
          'Content-Type': 'text/plain',
          'Content-Encoding': 'gzip'
        });
        // 읽기 스트림 -> 압축 변환 스트림 -> 응답 스트림 연결
        await pipeline(
          createReadStream(LOG_FILE),
          createGzip(),
          res
        );
      } else {
        res.writeHead(200, {
          'Content-Type': 'text/plain',
          'Content-Length': fileStat.size
        });
        await pipeline(
          createReadStream(LOG_FILE),
          res
        );
      }
    } catch (err) {
      if (!res.headersSent) {
        res.writeHead(500);
        res.end('server error\n');
      }
    }
    return;
  }

  res.writeHead(404);
  res.end('not found\n');
});

// 타임아웃 설정 (밀리초 단위)
server.headersTimeout = 5000;
server.requestTimeout = 10000;
server.keepAliveTimeout = 5000;

server.listen(PORT, () => {
  console.log(`Server listening on port ${PORT}`);
});

client.mjs

import { request, Agent } from 'node:http';

// 연결 재사용이 활성화된 통신 에이전트 생성
const keepAliveAgent = new Agent({
  keepAlive: true,
  maxSockets: 10
});

function sendLog(useKeepAlive) {
  return new Promise((resolve, reject) => {
    const options = {
      hostname: 'localhost',
      port: 3000,
      path: '/logs',
      method: 'POST',
      agent: useKeepAlive ? keepAliveAgent : undefined
    };

    const req = request(options, (res) => {
      // 응답 본문을 비워주어야 end 이벤트가 정상적으로 발생한다.
      res.resume();
      res.on('end', () => resolve(res.statusCode));
    });

    req.on('error', reject);
    req.write('{"log": "sample data"}');
    req.end();
  });
}

async function runTest() {
  const requestCount = 500;

  console.log('--- keep-alive 미사용 ---');
  let start = Date.now();
  for (let i = 0; i < requestCount; i++) {
    await sendLog(false);
  }
  console.log(`소요 시간: ${Date.now() - start}ms\n`);

  console.log('--- keep-alive 사용 ---');
  start = Date.now();
  for (let i = 0; i < requestCount; i++) {
    await sendLog(true);
  }
  console.log(`소요 시간: ${Date.now() - start}ms`);
  
  // 모든 작업이 끝난 뒤 에이전트 내부 소켓을 강제로 닫아 프로세스를 종료시킨다.
  keepAliveAgent.destroy();
}

runTest();

줄별 해설

server.mjs 파일의 주요 로직은 다음과 같이 작동한다.

  • if (acceptEncoding.includes('gzip')): 클라이언트가 전송한 요청 헤더를 검사하여 Gzip 포맷을 해석할 수 있는지 확인한다. 이 확인 없이 무작정 압축 데이터를 보내면 클라이언트 쪽에서는 텍스트가 심하게 깨져 보인다.
  • await pipeline(createReadStream(LOG_FILE), createGzip(), res): 디스크에서 파일을 읽어오는 스트림을 압축 스트림으로 통과시킨 뒤, 최종적으로 응답 객체(res)로 밀어 넣는다. 파이프라인(pipeline)은 프로미스를 반환하므로 await 키워드로 제어 흐름을 다루기 좋다. 클라이언트가 데이터를 받다가 연결을 강제로 끊어 res 스트림이 닫혀도 앞선 파일 스트림까지 깔끔하게 닫아준다.
  • server.headersTimeout = 5000: 헤더 데이터를 5초 안에 전부 보내지 못하는 클라이언트의 연결을 끊어버려 자원을 회수한다.

client.mjs 파일에서 눈여겨볼 부분은 다음과 같다.

  • new Agent({ keepAlive: true, maxSockets: 10 }): 목적지로 향하는 연결을 보관하고 재사용하는 에이전트를 만든다.
  • agent: useKeepAlive ? keepAliveAgent : undefined: 옵션에 undefined를 전달하면 내장 모듈이 제공하는 전역 기본 에이전트를 사용하며, 여기에는 재사용 기능이 꺼져 있다.
  • res.resume(): 스트림으로 들어오는 응답 본문 데이터를 메모리에 담지 않고 곧바로 흘려보낸다. 본문이 필요 없는 요청이라도 스트림의 데이터를 비워주지 않으면 내부 버퍼가 가득 차서 처리가 영구히 정지될 수 있다.
  • keepAliveAgent.destroy(): 통신 테스트가 완료되어도 에이전트 안에는 서버와 연결이 닫히지 않은 소켓이 남아 있다. 활성화된 소켓이 존재하면 Node.js 이벤트 루프가 계속 대기하므로 프로세스가 끝나지 않는다. 따라서 강제로 모든 소켓을 닫아 스크립트가 온전히 종료되게 만든다.

실행 결과

터미널에서 서버를 백그라운드나 별도 탭으로 실행한 뒤 클라이언트 스크립트를 구동한다. 실행 환경의 디스크나 통신 속도에 따라 시간 수치는 다르게 나올 수 있지만, 차이의 비율은 뚜렷하게 관찰된다.

$ node server.mjs &
[1] 12345
Server listening on port 3000

$ node client.mjs
--- keep-alive 미사용 ---
소요 시간: 823ms

--- keep-alive 사용 ---
소요 시간: 146ms

실무에서 자주 틀리는 것

스트림을 다룰 때 pipe() 함수만 사용하기

압축과 전송을 위해 stream1.pipe(stream2) 형태의 코드를 작성하는 경우가 잦다. 하지만 이 방식은 데이터를 흘려보내는 데는 충분해도, 중간에 예기치 못한 에러가 났을 때 연관된 다른 자원들을 자동으로 해제해 주지 않아 메모리가 새는 원인이 된다.

// 틀린 코드
createReadStream('large.log')
  .pipe(createGzip())
  .pipe(res);
// 고친 코드
import { pipeline } from 'node:stream/promises';

await pipeline(
  createReadStream('large.log'),
  createGzip(),
  res
);

프록시 앞단의 타임아웃 불일치

서버 앞단에 Nginx나 클라우드의 로드 밸런서가 있을 경우, Node.js 서버의 keepAliveTimeout이 로드 밸런서의 타임아웃보다 짧으면 문제가 생긴다. 앞단의 로드 밸런서는 연결이 유효하다고 생각하여 새 요청을 뒷단으로 넘겼는데, 그 찰나에 Node.js가 유휴 시간이 지났다며 연결을 끊어버리면 간헐적인 HTTP 502 오류가 발생한다.

// 틀린 코드 (기본값을 방치하거나 앞단보다 너무 짧게 둔다)
const server = createServer(app);
server.keepAliveTimeout = 5000;
// 고친 코드 (로드 밸런서 타임아웃이 60초일 때)
const server = createServer(app);
server.keepAliveTimeout = 65000; // 로드 밸런서보다 여유 있게 설정

이미 압축된 데이터에 다시 압축 시도

텍스트 형태인 로그나 JSON은 gzip 압축 효율이 뛰어나지만, 이미지(JPEG, PNG)나 이미 압축을 거친 백업 파일(ZIP, GZ)을 다시 압축기에 통과시키면 전체 용량이 줄어들지 않고 오히려 커질 수 있다. 불필요한 CPU 자원만 소모하므로 파일 확장자나 콘텐츠 타입을 확인하여 선별적으로 압축해야 한다.

한눈에 보기

주요 HTTP 모듈 타임아웃 설정 비교
속성 이름 적용 구간 주요 목적
headersTimeout 연결 시작부터 헤더 전송 완료까지 느린 연결 공격(slowloris) 방어
requestTimeout 연결 시작부터 요청 본문 전체 수신까지 거대하거나 무한한 본문 전송 차단
keepAliveTimeout 요청 처리 후 다음 요청까지의 유휴 시간 더 이상 사용되지 않는 소켓 풀 정리
대용량 응답 처리 방식 비교
처리 방식 메모리 사용량 네트워크 전송량
전체 메모리 적재 및 전송 파일 크기만큼 크게 늘어남 파일 크기 그대로 전송
스트림 전송 (압축 안 함) 일정 버퍼 크기만 차지함 (안정적) 파일 크기 그대로 전송
스트림 전송 (Gzip 압축 적용) 일정 버퍼 크기만 차지함 (안정적) 텍스트의 경우 크게 감소함

연습 문제

  1. 클라이언트가 다수의 요청을 보낼 때 새로운 TCP 연결을 맺지 않고 성능을 높이기 위해 http.Agent 생성자에 넣어야 하는 옵션의 이름과 값은 무엇인가?
  2. 클라이언트가 헤더를 의도적으로 아주 느리게 보내 서버 자원을 고갈시키는 공격을 방어하기 위해 http.Server 인스턴스에 설정할 수 있는 속성은 무엇인가?
  3. 스트림으로 응답할 때 중간에 에러가 발생하더라도 자원 누수를 안전하게 방지하기 위해 pipe() 대신 사용할 수 있는 모듈과 함수는 무엇인가?

정답과 해설

  1. keepAlive: true
    에이전트를 생성할 때 이 옵션을 부여하면 요청 처리가 끝난 소켓을 바로 닫지 않고 보관해두었다가 다음 요청 시 재사용한다.
  2. headersTimeout
    이 속성에 밀리초 단위의 값을 설정하면, 클라이언트가 해당 시간 안에 모든 요청 헤더를 보내지 못했을 때 서버가 연결을 강제로 종료한다.
  3. node:stream/promises 모듈의 pipeline
    파이프라인 안의 여러 스트림 중 하나라도 문제가 생기거나 닫히면 연결된 다른 스트림을 모두 안전하게 파괴하여 메모리를 반환한다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.