NestJS Out of Memory 문제 해결하기

후추·2025년 11월 29일
post-thumbnail

요약

최근 회사에서 NestJS로 구현된 백엔드 서버가 클라우드 컨테이너 서비스 환경에서 약 3시간 주기로 예고 없이 재시작되는 장애가 발생했습니다. 분석 결과, 특정 Cron Job으로 인한 메모리 초과(Out of Memory)가 근본 원인이었습니다. 장애 현상부터 원인 분석, 해결 방안까지의 과정을 기술합니다.

장애 분석

1. 장애 현상 및 초기 분석

애플리케이션 로그에서는 별다른 에러가 남지 않은 채, 서버가 약 3시간 간격으로 재시작되는 현상이 관찰되었습니다.

  • 가설 1: CI/CD의 배포 파이프라인 문제: Github Actions 워크플로우를 검토했으나, push 기반으로만 동작하여 문제가 될 가능성이 적었습니다.
  • 가설 2: 클라우드 공급자의 문제: 클라우드 플랫폼 자체의 문제일 가능성을 염두에 두었습니다. 하지만 다른 서비스는 동일한 문제를 겪고 있지 않았고, 플랫폼 측에서 별도의 장애 공지를 내지 않았기 때문에 가능성이 적었습니다.
  • 가설 3: Health Check 실패: 컨테이너 환경에서 실행되고 있는 NestJS Application이 Health Check 요청에 정상 응답하지 못하여 Liveness Probe 실패로 컨테이너를 재시작시킬 가능성을 가장 유력하게 보았습니다.

실제로 컨테이너 관리 시스템의 이벤트 로그를 확인한 결과, Liveness probe failed 메시지가 주기적으로 기록되고 있었습니다. 즉, 애플리케이션 서버가 Health Check 응답을 하지 못해 장애 복구 조치로 서버가 재시작되고 있던 것입니다. 하지만 "왜 Health Check가 3시간마다 실패하는가?" 근본 원인에 대한 질문이 생겼습니다.

2. 원인 파악 과정

Health Check 실패의 원인을 찾기 위해 다음과 같은 단계적 접근을 시도했습니다.

1단계: 메모리 누수(Memory Leak) 의심 및 모니터링

가장 일반적인 원인인 메모리 누수를 의심했습니다. 하지만 클라우드 모니터링 지표상 평균 메모리 사용량은 항상 20% 미만으로 안정적이었습니다. 메모리 누수가 아닐 것이라고 잠정 결론 내렸습니다. (나중에 보니 이는 '평균의 함정'이었습니다.)

2단계: 이벤트 루프 블로킹(Event Loop Blocking) 가설

메모리 문제가 아니라면, 특정 작업이 Node.js의 이벤트 루프를 막아 서버 전체를 응답 불능 상태로 만든 것이 아닌지 의심했습니다. 마침 3시간 주기로 실행되는 Cron Job(ProductFeedService.generateFeed())이 있었습니다. 해당 Cron Job은 외부 채널 연동을 위해 products 정보를 DB에서 가져오고 상품 피드 파일을 만들어 외부 스토리지에 업로드하는 기능을 수행하고 있었습니다. 여기서 무거운 DB I/O, 외부 Network I/O 혹은 피드 파일을 만들기 위한 CPU Bound 작업 등이 Event Loop Blocking을 일으키는 유력한 원인이 아닐까 의심했습니다. 하지만 Local 환경에서 테스트할 때, Event Loop는 전혀 Blocking 되지 않는 것을 확인했습니다.

3단계: OOM(Out of Memory) 로그 발견 (결정적 단서)

단서를 확보하기 위해 Dev 환경에서 장애 복구를 위한 재배포가 이뤄지지 않도록 설정한 채 Cron Job을 테스트하던 중, 이전에는 발견되지 않았던 FATAL ERROR: JavaScript heap out of memory 로그를 포착했습니다.

이를 통해 Node.js 프로세스가 메모리 부족으로 강제 종료되었다는 사실을 알게 되었습니다. Liveness Probe가 실패했던 이유도 OOM(Out of memory)으로 인해 프로세스가 종료되었기 때문이었습니다.

4단계: 상세 메모리 로깅 및 원인 확정

OOM의 원인을 시각적으로 확인하기 위해, 힙 메모리 사용량을 로깅하는 코드를 추가하여 Cron Job을 실행했습니다.

const heapMemoryUsage = process.memoryUsage();
const rss = heapMemoryUsage.rss / 1024 / 1024;// Resident Set Size
const heapMemoryUsageInMB = heapMemoryUsage.heapUsed / 1024 / 1024;
this.logger.log(`heapMemoryUsage: ${JSON.stringify(heapMemoryUsage)}`);
this.logger.log(`RSS: ${rss}MB, Heap: ${heapMemoryUsageInMB}MB`);

그 결과, 평소 100MB 수준이던 힙 메모리가 Cron Job이 실행되자마자 수십 초 만에 2.2GB까지 수직 상승하며 메모리 한계치를 초과하는 것을 확인했습니다. 이를 통해 Cron Job(ProductFeedService.generateFeed())의 데이터 처리 방식이 OOM의 근본 원인임을 최종적으로 확정했습니다.

근본 원인

Cron Job(ProductFeedService.generateFeed())의 OOM은 두 가지 주요 원인이 복합적으로 작용한 결과입니다.

원인 1: 대용량 데이터의 인메모리(In-Memory) 누적

해당 Cron Job은 외부 서비스에 제공할 파일을 만들기 위해 조회한 모든 상품 객체를 allProductData라는 단일 배열에 누적했습니다.

// 간략하게 작성한 코드입니다.
async function generateFeed() {
  let page = 1;
  while (true) {
    // DB에서 상품 데이터를 페이지 단위로 조회
    const products = await productRepository.find({ page, pageSize: 100, ... });
    if (products.length === 0) {
      break;
    }
    // 조회한 데이터를 배열에 계속 누적시킴
    allProductData.push(...products);
    page++;
  }
  // 메모리에 모든 데이터를 올린 상태에서 파일 생성 및 업로드
  uploadFeedFile(allProductData);
}

실험 결과 약 1300개 product를 처리할 때, 이 배열이 2.2GB의 메모리를 차지하며 프로세스를 다운시켰습니다.

원인 2: 과도한 JOIN으로 인한 '뚱뚱한' 객체

어찌보면 고작 1300개 product를 조회하는 것이 2.2GB의 메모리를 사용했다는 사실이 의아할 수 있습니다. 단순 계산했을 때 product 객체 하나에 약 1.73MB 메모리 용량을 차지한 셈이니까요. 일반적인 경우 객체가 그렇게 많은 데이터를 포함하리라 예상하기 힘듭니다. 하지만 상품 객체 하나를 조회하기 위해 15개가 넘는 테이블을 Join 하고 있었기에 얘기가 달라집니다. 해당 테이블들은 주로 1:N 관계였기에 TypeORM이 생성하는 엔티티 객체 하나하나가 수많은 자식 객체를 포함하는 거대한 인메모리 객체 그래프가 되었습니다.

await productRepository.find({
  where: {
   category: {
     parentCategory: {
       grandParentCategory: true
     }
   },
   colors: true,
   keywords: true,
   fabric: {
     colorways: {
       colors: true
     }
   }
//...
  }
})

이러한 '뚱뚱한' 객체들이 메모리에 누적되면서 OOM을 가속화했습니다.

참고로, 저희 서비스는 Product를 대상으로 Vector 검색 기능을 제공하고 있습니다. Vector 검색을 위해 개별 Product는 각각 Embedding Vector 값을 DB에 저장하고 있는데요. 이는 고차원의 벡터 데이터로 매우 큰 용량을 차지합니다. Product 객체를 조회하면서 Embedding Vector를 함께 조회한 것이 개별 객체의 크기를 키운 원인이기도 했습니다.

해결 방안

OOM의 근본 원인을 해결하기 위해 다음과 같은 해결책을 적용했습니다.

1. 배치(Batch) 처리 도입

데이터를 메모리에 누적하지 않기 위해, 전체 데이터를 한 번에 처리하는 대신 작은 단위(예: 150개)로 나누어 처리하는 배치 방식을 도입했습니다. 이를 위해 메모리 상에서 전체 데이터로 한 번에 파일을 생성하지 않고, 작은 단위의 데이터로 디스크에 임시 파일을 작성합니다. 파일이 완성되면 외부 스토리지에 업로드합니다.

// 간략하게 작성한 코드입니다.
async function generateFeed() {
  let page = 1;
  await writeFile(path); // 임시 파일 생성
  while (true) {
    // DB에서 상품 데이터를 페이지 단위로 조회
    const products = await productRepository.find({ page, pageSize: 150, ... });
    if (products.length === 0) {
      break;
    }
    await appendFile(path, products); // 임시 파일에 이어쓰기
    page++;
  }
  const tempFile = await readFile(path); // 임시 파일 완성 후 업로드
  uploadFeedFile(tempFile);
  await unlink(path); // 임시 파일 삭제
}

2. SQL 최적화

파일 생성을 위해 필요한 데이터는 무거운 Product 객체가 아닙니다. 따라서 ORM Entity를 조회하는 대신, getRawMany와 명시적 .select()를 사용하여 필요한 데이터만 담은 가벼운 객체를 조회합니다. 또한 애플리케이션에서 수행하던 데이터 가공 로직(예: 키워드 합치기)을 SQL의 집계 함수(STRING_AGG 등)으로 변경합니다. 이를 통해 비효율적인 JOIN 연산을 줄이고 DB에서 불러오는 데이터도 축소시킬 수 있습니다.

await this.productRepository
      .createQueryBuilder('p')
      .select('p.slug', 'id')
      .addSelect('p.name', 'product_name')
      .addSelect('pd.name', 'product_design_name')
      .addSelect('p.thumbnail', 'thumbnail')
      .addSelect('p.reviews_count', 'review_count')
      .addSelect('p.rating', 'satisfylevel')
      // 최소 가격 계산 (서브쿼리)
      .addSelect(
        (subQuery) =>
          subQuery
            .select('MIN(pvp.price)')
            .from('product_variant_price', 'pvp')
            .where('pvp.product_id = p.id'),
        'min_price',
      )
      // 카테고리 정보
      .addSelect('c.title', 'category_title')
      .addSelect('c.category_type', 'category_type')
      .addSelect('c.external_category_id', 'external_category_id')
      .addSelect('pc.title', 'parent_category_title')
      .addSelect('gpc.title', 'grand_parent_category_title')
      // variant names (^ 구분자로 집계, 중복 제거)
      .addSelect(
        (subQuery) =>
          subQuery
            .select("string_agg(DISTINCT var.name, '^' ORDER BY var.name DESC)")
            .from('product_variant_mapping', 'pvm')
            .leftJoin('product_variant', 'var', 'var.id = pvm.variant_id')
            .where('pvm.product_id = p.id')
            .andWhere('var.name IS NOT NULL'),
        'variant_names',
      )
      // ...
      .getRawMany<ProductRawData>();

결론

배치 처리와 쿼리 취적화 전략을 적용함으로써, OOM 문제를 근본적으로 해결하고 장애가 일어나지 않도록 만들었습니다. 앞으로 Cron Job과 쿼리 작성 시 위 내용을 참고해서 비슷한 장애를 겪지 않으면 좋겠습니다.

0개의 댓글