요즘IT
위시켓
새로 나온
인기요즘 작가들컬렉션
물어봐
새로 나온
인기
요즘 작가들
컬렉션
물어봐
개발
AI
IT서비스
기획
디자인
비즈니스
프로덕트
커리어
트렌드
스타트업
서비스 전체보기
위시켓요즘IT
고객 문의
02-6925-4867
10:00-18:00주말·공휴일 제외
[email protected]
요즘IT
요즘IT 소개작가 지원
기타 문의
콘텐츠 제안하기광고 상품 보기
요즘IT 슬랙봇크롬 확장 프로그램
이용약관
개인정보 처리방침
청소년보호정책
㈜위시켓
대표이사 : 박우범
서울특별시 강남구 테헤란로 211 3층 ㈜위시켓
사업자등록번호 : 209-81-57303
통신판매업신고 : 제2018-서울강남-02337 호
직업정보제공사업 신고번호 : J1200020180019
제호 : 요즘IT
발행인 : 박우범
편집인 : 노희선
청소년보호책임자 : 박우범
인터넷신문등록번호 : 서울,아54129
등록일 : 2022년 01월 23일
발행일 : 2021년 01월 10일
© 2013 Wishket Corp.
로그인
요즘IT 소개
콘텐츠 제안하기
광고 상품 보기
개발

간단한 스크립트라면 메모리 걱정은 안 해도 될까?

zwoo
10분
1일 전
1.2K

로컬에서 스크립트 실행할 때, 메모리까지 생각해 보셨나요?

 

아무리 단순한 반복 작업이라도, 운영체제 위에서 돌아가는 하나의 프로세스라는 사실을 잊으면 낭패를 볼 수 있다. 최근 AWS S3에 백업해 둔 DynamoDB 데이터를 대량으로 읽는 작업을 하다가, 도중에 ‘Out of Memory’ 에러로 작업이 중단되는 문제를 겪었다. 과연 내가 뭘 잘못한 걸까?

 

해당 스크립트는 Node.js v20 환경에서 자바스크립트로 작성되었고, 당시 나는 많은 양의 데이터를 한꺼번에 배열에 담은 후, 전체 데이터를 대상으로 Promise.allSettled()를 이용한 병렬 처리 방식을 택했다. 실행 속도를 높이는 데는 성공했지만, 이 방식은 처리량이 많아질수록 메모리를 지나치게 많이 차지하게 되었다. 결과적으로 Node.js의 힙 메모리 한계를 초과하면서 ‘Out of Memory’ 에러가 발생했다. 결국 메모리를 감당하지 못한 스크립트는 중단되었다.

 

이번 경험을 통해 많은 양의 데이터를 다룰 때는 병렬 처리도 좋지만, 메모리를 어떻게 쓸지 먼저 고민해야 한다는 교훈을 얻었다. 이번 글에서는 스크립트를 작성하는 과정에서 어떤 실수가 있었고, 이를 어떻게 개선했는지 단계별로 정리해 보려고 한다.

 

<출처: freepik>
 

무심코 지나친 힙 메모리

힙과 스택: 메모리는 어디에 저장될까?

먼저 메모리에 대해 간단히 정리해 보자. 컴퓨터 프로그램이 실행될 때 사용하는 메모리는 크게 두 영역으로 나뉜다. 스택(Stack)과 힙(Heap)이다.

 

  • 스택(Stack)은 함수 호출, 지역 변수 등에 사용되는 메모리 공간이다. LIFO(Last-In, First-Out) 구조로 작동하며 매우 빠르지만, 할당 용량이 제한되어 있다. 재귀 호출이 깊어지거나 무한 루프에 빠지면 StackOverflowException이 발생할 수 있다.
  • 힙(Heap): new 연산자로 생성한 객체나 배열 등 동적 데이터가 저장되는 공간으로, 상대적으로 유연하고 용량이 크다. 하지만 참조 관리가 복잡하고 누수가 발생하기 쉽다.

 

Node.js는 기본적으로

  • 64비트 환경: 약 1.5GB
  • 32비트 환경: 약 700MB
     

힙 메모리 제한을 가지고 있으며, --max-old-space-size 옵션으로 힙 크기를 조금 더 늘릴 수는 있지만 12GB 이상은 안정적으로 사용하기 어렵다.

 

GC(Garbage Collector)와 메모리 관리

GC(Garbage Collector)는 프로그램이 명시적으로 해제하지 않은 객체 중, 더 이상 참조되지 않는 객체를 탐색해 자동으로 해제하는 기능이다. GC는 힙 메모리에서만 동작하며, 스택 메모리는 함수 종료 시 시스템이 자동 해제한다. GC가 필요한 영역은 동적으로 할당된 객체들이 저장되는 힙이며, 여기서만 참조 여부를 판단해 메모리를 회수한다.

 

GC는 언어나 런타임이 자체적으로 제공하는 기능이며, 대표적으로 JavaScript (V8), Java, C#, Python 등에서 구현되어 있다. 언어나 프레임워크에 따라 GC 방식과 제어 가능 범위는 달라진다. 예를 들어 Node.js의 경우 명시적으로 global.gc()를 호출할 수 있지만, 이를 사용하려면 실행 시 --expose-gc 옵션이 필요하다.

 

대표적인 방식으로는 마크-앤-스윕(Mark-and-Sweep) 알고리즘이 있다. GC는 유휴 시간에 작동하며, 루트 객체에서 도달할 수 없는 객체를 메모리에서 제거한다. 하지만, 여전히 참조가 남아 있는 경우 메모리에서 해제되지 않아 누수(leak)가 발생할 수 있다.

 

하지만 GC가 모든 것을 완벽하게 정리해 줄 것이라 믿고 방심하면, 예상치 못한 메모리 폭증(Memory Spike)이 발생할 수 있다. 이는 단기간에 급격한 객체 생성 또는 참조 누적으로 인해 힙이 급격히 확장되는 현상으로, 시스템이 갑작스럽게 불안정해지는 원인이 된다.

 

문제의 발단: 한꺼번에 처리하려다 OOM 발생

힙 공간이 제한되어 있다는 것은 너무 당연한 사실이고, 이론상으로는 잘 알고 있었다. 그런데 평소에 코드를 짜면서 배열을 다룰 때, 그 배열에 담기는 용량에 대해 고민할 일이 많지 않았다. 변명해 보자면, 이렇게 많은 데이터를 다루는 건 난생처음이었다.

 

AWS S3 버킷에서 읽어온 객체는 압축되어 있었고, 압축을 풀면 그 안에는 수백만 개의 JSON 데이터가 들어있었다. 내가 해야 할 일은 각각의 JSON 데이터를 AWS DynamoDB에 업로드하는 것이었다. 나는 습관적으로 tasks 배열을 만들어서 병렬처리를 하려고 시도했다.

 

처음 작성한 스크립트는 다음과 같다.

 

async function readS3AndProcess() {
  
  // S3 읽어오는 로직 (생략)

  const tasks = [];

  for await (const line of rl) {
      if (line.trim()) {
          try {
              const data = JSON.parse(line);
              tasks.push(data.Item);
          } catch (err) {
              logProcessing(Status.Error, `JSON parse error in line: ${line}`);
          }
      }
  }
  await processBatch(tasks);
}

async function processBatch(tasks) {
  const promises = tasks.map(async (item) => {
        try {
            await processDocument(item);
        } catch (err) {
            logProcessing(Status.Error,`Process error: ${err.message}`);
        }
    });

    await Promise.all(promises);
}

 

이 코드는 모든 JSON 데이터를 한꺼번에 메모리에 담고, 병렬로 처리하기 때문에 데이터 개수가 많을수록 힙을 빠르게 소모한다. 당시 처리 대상이었던 JSON 문서의 크기는 약 0.5KB이었고, 이론적으로는 수백만 개를 담을 수 있을 것처럼 보였다. 하지만 실제 메모리 사용량은 훨씬 커져 예상보다 훨씬 적은 개수만 담아도 한계에 도달했다. OOM(Out of Memory)이 나는 건 시간문제였고, 결국 발생하고 말았다.

 

해결책: 청크 단위 병렬 처리

이럴 때 일반적으로 생각할 수 있는 방법은 청크 단위 병렬 처리다. 먼저 모든 데이터를 로딩한 후, 일정 크기 단위로 나눠 병렬 처리하는 방식이다. 이 방식은 코드가 간결하고 처리 속도도 빠르다.

 

const chunkSize = 100;

for (let i = 0; i < data.length; i += chunkSize) {
  const chunk = data.slice(i, i + chunkSize);
  await Promise.allSettled(chunk.map(async (item) => {
    const parsed = await parseAndProcess(item);
    await saveToDatabase(parsed);
  }));
}

 

하지만 이번 작업에서는 S3에 저장된 gzip 압축 파일을 stream으로 라인 단위로 읽어 들이는 방식이 필수적이었다. 압축 해제 시 데이터 크기가 수 GB 이상으로 증가할 수 있어, 전체를 메모리에 로딩하면 곧바로 OOM 에러로 이어질 수 있기 때문이다.

 

해결책 보완: stream + 청크 단위 처리

위 문제를 해결하기 위해 stream을 읽어 들이면서 일정 개수씩 배열에 모으고, 주기적으로 처리하고 배열을 비우는 방식으로 수정했다.

 

async function readS3AndProcess() {
  
  // S3 읽어오는 로직 (생략)

  let tasks = [];

  for await (const line of rl) {
      if (line.trim()) {
          try {
              const data = JSON.parse(line);
              tasks.push(data.Item);

              if (tasks.length >= BATCH_SIZE) {
                  await processBatch(tableName, tasks);
                  tasks = [];    // 배열 초기화 
              }
          } catch (err) {
              logProcessing(Status.Error, `JSON parse error in line: ${line}`);
          }
      }
  }

  // 남은 tasks 처리
  if (tasks.length > 0) {
      await processBatch(tasks);
  }

}


async function processBatch(tasks) {
  const promises = tasks.map(async (item) => {
        try {
            await processDocument(item);
        } catch (err) {
            logProcessing(Status.Error,`Process error: ${err.message}`);
        }
    });

    await Promise.allSettled(promises);     // allSettled 방식으로 변경
}

 

이 코드는 stream에서 일정 수량의 데이터를 읽어 청크 단위로 병렬 처리하고, 그 후 즉시 메모리를 비우는 방식으로 설계되어 힙 메모리 점유를 최소화할 수 있었다. 실시간성과 메모리 관리 사이의 균형을 고려한 설계였다.

 

참고로 스크립트를 최초 작성하던 시점에는 Promise.all()을 사용했지만, 이후에는 Promise.allSettled() 로 변경했다. 이는 일부 작업이 실패하더라도 전체 Promise 처리를 멈추지 않도록 하기 위함이다.

 

정리하면 다음과 같다.

  • `readline`을 통해 gzip stream을 라인 단위로 읽고,
  • 각 줄을 JSON으로 파싱한 뒤, 일정 개수씩 임시 배열 `tasks`에 저장한다.
  • `tasks.length`가 `BATCH_SIZE`에 도달하면 `processBatch()`를 호출해 병렬 처리하고 배열을 초기화한다.
  • stream을 끝까지 읽으며 이 과정을 반복한다.
  • 모든 stream을 읽은 후, 마지막에 남은 데이터가 있으면 한 번 더 처리한다.

 

기타 메모리 누수를 방지하는 방법들

대부분의 고수준 언어들은 메모리를 자동으로 관리해 준다. 하지만 프로그래밍 언어마다 메모리를 직접 관리하기 위한 방법도 있다. 만약 관심이 있다면 아래 키워드들을 찾아보는 것도 좋다.

 

  • 불필요한 참조 제거: JS나 C#에서 더 이상 사용하지 않는 객체는 = null 처리하여 GC 대상이 되도록 만든다.
  • (JS) WeakRef, FinalizationRegistry 사용 : 약한 참조 및 객체 소멸 이후 콜백 처리 가능.
  • C#의 using 문법: IDisposable을 명시적으로 해제해 주는 방식으로, GC에 의존하지 않도록 한다.
using (var stream = new FileStream(path, FileMode.Open))
{
    // 파일 처리
}

 

  • GC 강제 호출(권장되진 않음)
    • Node.js: global.gc() (단, --expose-gc 옵션 필요)
    • .NET: GC.Collect()

 

단, GC를 강제로 호출하는 행위는 성능에 악영향을 줄 수 있으므로 가급적 피하는 것이 좋다.

 

 

작은 스크립트에도 메모리 철학이 필요하다

로컬에서 실행하는 스크립트는 작고 단순해 보이지만, 결국 메모리라는 유한한 자원 위에서 돌아가는 하나의 프로세스다. 개발자들은 알고리즘, 패턴, 구조에만 집중하지만, 실제로 실무에서 벌어지는 대부분의 문제는 환경적 제약, 특히 메모리 설계 실패에서 비롯되곤 한다.

 

작은 부주의 하나가 예기치 않은 OutOfMemoryException으로 이어질 수 있고, 잘못된 메모리 사용은 전체 작업의 실패로 직결된다. 이번 경험을 통해 단순한 반복 작업이라도 메모리 흐름을 설계하고, 데이터양과 구조를 예측하며 실행 환경에 맞게 리스크를 최소화하는 습관이 필수임을 깨달았다.

 

개발자는 빠른 코드뿐 아니라, 확장성이 있고 오래 사용할 수 있는 코드를 써야 한다. 그 첫걸음은 메모리를 의식하는 것에서 시작한다. 단순해 보이는 스크립트라도, 프로덕션 수준으로 고민하는 과정에서 성장의 기회를 얻을 수 있다.

 

©️요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.

에디터가 직접 고른 실무 인사이트 매주 목요일에 만나요.
newsletter_profile0명 뉴스레터 구독 중