기회는 준비된 자에게
대량 데이터 내보내기 중 발생한 서버 메모리 문제 개선기 본문
고객/계약 데이터를 관리하는 고객사 내부 ERP를 약 1년간 개발 및 유지보수하고 있었습니다. 이 시스템은 여러 지점에서 입력한 고객 계약 데이터를 운영자가 승인하고, 이후 계약/결제/입금 관련 업무를 관리하는 백오피스 형태의 웹 애플리케이션이었습니다.
문제가 발생한 기능은 승인 완료된 고객 데이터를 조건에 맞게 엑셀로 내보내는 기능이었습니다. 초기에는 데이터 규모가 작아 문제가 없었지만, 운영 기간이 길어지면서 고객 데이터가 수천 건까지 쌓였고, 전체 기간·전체 고객을 대상으로 엑셀 내보내기를 실행했을 때 502 응답이 발생했습니다.
이번 글에서는 대량 엑셀 내보내기에서 서버 메모리 피크가 커진 원인과, 기존 다운로드 경험을 유지하면서 이를 분할 조회 방식으로 개선한 과정을 정리합니다.
(NodeJS 와 Prisma 기반의 문법이 일부 포함되어 있습니다.)
문제 상황과 원인 확인
문제가 발생한 기능은 승인 완료된 고객 데이터를 엑셀로 내려받는 기능이었습니다. 어드민이 조건을 지정해 고객 데이터를 내려받고, 내부 확인이나 외부 공유가 필요할 때 사용하기 위해 만든 기능이었습니다.
처음에는 데이터가 많지 않았기 때문에 전체 데이터를 한 번에 조회해도 문제가 없었습니다. 로컬 환경에서도 정상적으로 동작했고, 운영 서버에서도 속도만 조금 느릴 뿐 결과는 정상적으로 내려왔습니다.
문제는 데이터가 누적된 뒤 전체 다운로드를 시도할 때 발생했습니다. 브라우저에서는 502 응답이 반환되었고, Nginx 입장에서는 upstream 서버가 응답을 주기 전에 연결이 끊긴 상태였습니다.
처음에는 단순히 요청 시간이 오래 걸려 timeout이 발생한 것으로 의심했습니다. 하지만 PM2 로그를 확인해보니, 엑셀 다운로드 요청을 처리하던 Node.js 프로세스가 메모리 제한을 초과해 재시작되고 있었습니다.
정확히 말하면 서버 전체가 장시간 중단된 것은 아니었습니다. PM2가 프로세스를 곧바로 다시 띄워주고 있었기 때문에 장애가 길게 이어지지는 않았습니다. 다만 해당 순간에는 다른 요청도 일시적으로 영향을 받을 수 있는 위험한 상태였습니다.
또한 서버 전체 메모리가 완전히 고갈된 문제도 아니었습니다. 특정 Node.js 프로세스가 PM2의 프로세스 단위 메모리 제한을 넘은 것이 직접적인 원인이었습니다.
이를 재현하기 위해 로컬 환경에서 Node.js 메모리 제한을 낮춰 동일한 기능을 실행했습니다.
NODE_OPTIONS="--max-old-space-size=512" npm run start
운영 환경과 비슷한 메모리 제한을 걸고 실행하자, 로컬에서도 유사하게 프로세스가 종료되는 현상을 확인할 수 있었습니다. 반대로 운영 환경에서 PM2의 메모리 제한을 일시적으로 높였을 때는 시간이 오래 걸리더라도 결과가 반환되었습니다.
이를 통해 문제를 단순 timeout이나 DB 오류가 아니라, 대량 데이터를 한 번에 조회하고 가공하는 과정에서 서버 프로세스의 메모리 피크가 커지는 구조적 문제로 판단했습니다.
기존 엑셀 다운로드 구조의 한계
기존 구조는 단순했습니다. 클라이언트는 필터 조건을 만들어 서버에 한 번 요청하고, 서버는 조건에 맞는 데이터를 모두 조회한 뒤 엑셀 다운로드에 필요한 형태로 가공해 응답했습니다.
const params = createDownloadParams(filters);
const response = await fetchDownloadData(params);
const data = await response.json();
downloadExcel(data);
서버 쪽 흐름도 크게 다르지 않았습니다. 조건에 맞는 데이터를 한 번에 조회하고, 관련 데이터까지 함께 불러온 뒤, JavaScript 객체로 변환해 JSON 응답을 만들었습니다.
const rows = await database.findMany({
where: filters,
include: relatedData,
});
const result = rows.map(formatForExcel);
return json(result);
데이터가 적을 때는 이 방식이 문제가 되지 않았습니다. 하지만 데이터가 수천 건까지 늘어나면 한 요청 안에서 처리해야 할 데이터가 급격히 커집니다.
예를 들어 고객이 4,000명이고, 각 고객과 함께 내려받아야 하는 부가 데이터가 여러 개씩 붙어 있다면 서버는 단순히 4,000행만 처리하는 것이 아닙니다. 각 고객에 연결된 데이터까지 함께 조회하고, 이를 다시 엑셀 다운로드에 맞는 형태로 가공해야 합니다.
이 과정에서 서버 메모리에는 다음과 같은 데이터가 동시에 올라갈 수 있습니다.
DB에서 조회한 원본 데이터
+ 각 데이터에 연결된 부가 데이터
+ 엑셀 응답용으로 다시 만든 객체
+ JSON 직렬화 과정에서 생기는 문자열/버퍼
+ 아직 GC되지 않은 중간 객체
최종 응답 크기만 보면 몇 MB 수준일 수 있습니다. 하지만 실제 서버 메모리에서는 원본 데이터와 가공 데이터, 직렬화 데이터가 겹치면서 훨씬 큰 메모리 피크가 발생할 수 있었습니다.
기존 흐름을 단순화하면 다음과 같습니다.
Client
→ Server
→ 전체 데이터 조회
→ 전체 데이터 가공
→ 전체 JSON 응답
→ Client에서 엑셀 생성
결국 데이터가 일정 규모를 넘어서자 PM2의 메모리 제한을 초과했고, 프로세스가 재시작되며 502가 발생했습니다.
해결 방향
해결 방법은 몇 가지가 있었습니다.
1. PM2 메모리 제한 상향
2. 서버에서 CSV 스트리밍
3. 비동기 작업으로 파일 생성 후 다운로드 링크 제공
4. 기존 엑셀 다운로드 흐름은 유지하되, 서버 응답을 작은 단위로 분할
PM2 메모리 제한을 높이는 방법은 가장 빠른 임시 조치였습니다. 실제로 제한을 높이면 당장의 502는 사라질 수 있었습니다. 하지만 데이터가 더 늘어나면 같은 문제가 다시 발생할 수 있고, 제한된 서버 메모리 안에서 다운로드 기능 하나가 과도한 메모리를 사용하는 구조는 그대로 남습니다.
서버 스트리밍이나 비동기 파일 생성 방식은 대량 다운로드에 더 적합한 구조입니다. 다만 기존 다운로드 흐름을 꽤 많이 바꿔야 하고, XLSX 형식을 유지할지 CSV로 전환할지, 생성한 파일을 어디에 저장하고 언제 삭제할지 같은 추가 결정도 필요했습니다.
이번 개선의 목표는 조금 더 현실적이었습니다.
1. 서버 메모리 피크를 줄인다.
2. 기존 엑셀 다운로드 경험은 유지한다.
3. 기존 다운로드 흐름은 크게 바꾸지 않는다.
그래서 서버가 전체 데이터를 한 번에 처리하지 않도록 만들고, 클라이언트가 데이터를 나누어 받아 최종 엑셀 파일을 생성하는 방식으로 개선했습니다.
Cursor 기반 분할 다운로드
개선 후 구조는 다음과 같습니다.

서버는 한 번에 최대 500건만 처리하고, 응답에는 다음 요청에 사용할 cursor를 포함합니다. 클라이언트는 cursor가 없어질 때까지 반복 요청합니다.
이 방식의 핵심은 최종적으로 내려받는 데이터의 양을 줄이는 것이 아닙니다. 사용자는 여전히 전체 데이터를 다운로드할 수 있습니다. 대신 서버가 한 번의 요청에서 감당해야 하는 데이터량을 제한해 메모리 피크를 낮추는 것이 목적입니다.
서버 구현
서버 다운로드 로직에는 limit과 cursor 개념을 추가했습니다.
const limit = 500;
const cursor = request.cursor;
const rows = await database.findMany({
where: {
...filters,
...(cursor && { cursorKey: { lt: cursor } }),
},
orderBy: {
cursorKey: "desc",
},
take: limit + 1,
});
여기서 take: limit + 1을 사용한 이유는 다음 페이지가 있는지 확인하기 위해서입니다. 예를 들어 500건을 응답하려면 실제로는 501건을 조회합니다. 501번째 데이터가 존재한다면 아직 다음 페이지가 있다는 뜻입니다.
const hasMore = rows.length > limit;
const pageItems = hasMore ? rows.slice(0, limit) : rows;
const nextCursor = hasMore
? pageItems[pageItems.length - 1].cursorKey
: null;
응답에는 현재 페이지 데이터와 다음 요청에 사용할 cursor를 함께 내려줍니다.
return {
data: pageItems.map(formatForExcel),
nextCursor,
};
정렬 기준은 수정일처럼 변경 가능한 값이 아니라, 생성 이후 변하지 않는 고유 값을 기준으로 잡았습니다. 다운로드 도중 데이터가 수정되면 수정일 기준 정렬은 페이지 경계가 흔들릴 수 있습니다. 반면 변하지 않는 고유 값은 전체 데이터를 순차적으로 훑는 작업에서 cursor 기준으로 사용하기에 더 안정적이라고 판단했습니다.
또한 다음 페이지를 조회할 때 cursor 행을 다시 찾고 skip하는 방식 대신, cursor 값을 경계값으로 사용했습니다.
where cursorKey < cursor
이 방식은 cursor가 가리키는 행 자체를 다시 찾는 방식이 아니라, cursor 값을 기준으로 다음 범위를 조회하는 방식입니다. 따라서 요청 사이에 특정 행의 상태가 바뀌거나 필터 조건에서 제외되더라도 다음 범위 조회를 이어가기 쉽습니다.
클라이언트 구현
클라이언트는 더 이상 다운로드 API를 한 번만 호출하지 않습니다. 서버가 내려준 nextCursor가 존재하는 동안 반복 요청합니다.
const result = [];
let cursor = null;
do {
const response = await fetchDownloadPage({
filters,
cursor,
limit: 500,
});
if (!response.ok) {
throw new Error("엑셀 데이터를 불러오지 못했습니다.");
}
result.push(...response.data);
cursor = response.nextCursor;
} while (cursor);
downloadExcel(result);
전체 데이터가 4,000건이라면 클라이언트는 대략 8번 요청을 보내게 됩니다.
4,000건 / 500건 = 약 8번 요청
서버는 매 요청마다 최대 500건만 조회하고 가공합니다. 따라서 기존처럼 한 요청에서 전체 데이터를 모두 들고 있을 필요가 없어졌습니다.
엑셀 파일 생성 자체는 기존과 동일하게 브라우저에서 수행하도록 유지했습니다. 서버는 데이터를 작은 단위로 나누어 내려주고, 클라이언트가 이를 합쳐 최종 XLSX 파일을 생성합니다.
실패 처리
분할 다운로드 방식은 요청이 여러 번으로 나뉘기 때문에, 중간 요청 실패 가능성을 고려해야 합니다.
이번 개선에서는 중간 요청 중 하나라도 실패하면 전체 다운로드를 실패 처리했습니다. 일부 데이터만 포함된 엑셀 파일을 정상 파일처럼 내려주는 것이 더 위험하다고 판단했기 때문입니다.
일부 요청 실패
→ 부분 파일 생성하지 않음
→ 사용자에게 실패 알림
→ 다시 시도
향후 데이터가 더 늘어나 요청 횟수가 많아진다면, 각 요청에 재시도 정책을 추가할 수 있습니다. 다만 이번 개선에서는 우선 데이터 누락 가능성을 차단하는 쪽을 선택했습니다.
개선 결과와 한계
개선 전에는 서버가 전체 데이터를 한 번에 처리했습니다.
전체 데이터 조회
→ 전체 데이터 가공
→ 전체 JSON 직렬화
→ 서버 메모리 피크 증가
→ PM2 메모리 제한 초과
→ 프로세스 재시작
→ 502 발생
개선 후에는 서버가 한 번에 처리하는 데이터량을 제한했습니다.
500건 조회
→ 500건 가공
→ 500건 응답
→ 다음 cursor 반환
이를 통해 기존 XLSX 다운로드 UX는 유지하면서, 서버가 한 요청에서 감당해야 하는 메모리 사용량을 줄일 수 있었습니다.
물론 이 방식이 대량 다운로드의 최종 해답은 아닙니다. 브라우저는 여전히 전체 데이터를 모은 뒤 XLSX 파일을 생성합니다. 데이터가 수만 건 이상으로 늘어나면 브라우저 메모리나 처리 시간이 새로운 병목이 될 수 있습니다.
그 시점에는 서버 스트리밍, 비동기 다운로드 작업, 파일 생성 후 다운로드 링크 제공 같은 방식이 더 적절할 수 있습니다.
정리
이번 문제는 단순히 엑셀 다운로드가 느린 문제가 아니었습니다. 데이터가 적을 때는 드러나지 않았지만, 데이터가 늘어나면서 전체 조회, 데이터 가공, JSON 직렬화가 한 번의 요청 안에서 겹치며 서버 메모리 피크가 커졌습니다.
이를 해결하기 위해 다운로드 API를 cursor 기반 분할 조회 구조로 변경했습니다. 서버는 한 번에 500건씩만 처리하고, 클라이언트는 cursor를 따라 여러 번 요청해 전체 데이터를 모은 뒤 기존 방식으로 엑셀 파일을 생성합니다.
이번 개선은 대량 다운로드의 최종 구조라기보다는, 제한된 운영 환경에서 서버 메모리 피크를 빠르게 낮추기 위한 1차 구조 개선이었습니다.
일반 목록 조회와 다운로드 기능은 다르게 설계해야 한다고 느꼈습니다. 목록 조회는 일부 데이터만 보여주면 되지만, 다운로드는 결국 전체 데이터를 다루는 기능입니다. 따라서 처음부터 데이터가 늘어났을 때의 조회 범위, 메모리 사용량, 직렬화 비용, 다운로드 방식을 함께 고려해야 합니다.
