MOMSOFT / 기술 블로그
대용량 엑셀·PDF 내보내기를 메모리로 죽지 않게 만들기
엑셀 내보내기가 죽는 이유는 대개 POI가 아니라 설계입니다. 조회·문서·전송 세 계층을 각각 스트리밍으로 바꾸는 순서를 정리했습니다.
프로젝트 문의하기 →결론부터: 엑셀이 죽는 건 POI 탓이 아닙니다
대용량 내보내기가 OutOfMemoryError로 무너질 때, 팀은 보통 문서 라이브러리를 먼저 의심합니다. Apache POI를 SXSSF로 바꾸고 힙을 늘리는 것으로 대응합니다. 그런데 그렇게 고쳐도 얼마 지나지 않아 같은 자리에서 다시 터지는 경우가 많습니다. 원인이 한 군데가 아니기 때문입니다.
내보내기 요청 하나는 조회 · 문서 생성 · 응답 전송이라는 세 계층을 지납니다. 이 중 어느 하나라도 "전체를 다 모은 다음 다음 단계로 넘긴다"는 방식이면, 데이터가 늘어나는 만큼 메모리가 선형으로 늘어납니다. 세 계층을 전부 스트리밍으로 바꿔야 비로소 "행 수와 무관하게 대체로 일정한 메모리"라는 성질이 생깁니다.
그리고 세 계층을 다 고쳐도 남는 문제가 있습니다. 수십만 행짜리 파일은 만드는 데 시간이 걸리고, 그 시간 동안 HTTP 커넥션과 DB 커넥션이 붙잡혀 있습니다. 이건 메모리 문제가 아니라 응답 모델의 문제입니다. 결국 답은 하나로 수렴합니다. 큰 내보내기는 응답이 아니라 작업(job)으로 다뤄야 합니다.
이 글은 그 순서를 정리한 것입니다. 조회를 커서 기반으로 바꾸고, 문서를 스트리밍으로 쓰고, 동기 응답을 비동기 생성과 다운로드 링크로 바꾸고, 타임아웃·재시도·파일 정리를 붙이는 순서입니다. 마지막에는 이 구조를 만들지 말아야 하는 경우도 적었습니다. 대부분의 내보내기는 사실 여기까지 갈 필요가 없습니다.
왜 메모리가 터지는가 — 세 겹의 적재
전형적인 코드는 이렇게 생겼습니다.
List<OrderDto> rows = orderRepository.findAllByPeriod(from, to); // 1
Workbook wb = new XSSFWorkbook(); // 2
// ... rows 를 순회하며 셀 채우기 ...
ByteArrayOutputStream bos = new ByteArrayOutputStream(); // 3
wb.write(bos);
return ResponseEntity.ok(bos.toByteArray()); // 4
이 코드에는 같은 데이터가 형태만 바꿔 동시에 네 벌 존재합니다. 각각이 어떤 성격의 적재인지 구분하는 게 중요합니다. 성격이 다르면 해법도 다르기 때문입니다.
1. 결과셋 전체 적재
드라이버에 따라 다르지만, 널리 쓰이는 JDBC 드라이버 상당수는 기본 설정에서 결과셋을 클라이언트 쪽에 모두 받아 놓습니다. 여기에 ORM을 쓰면 한 겹이 더 붙습니다. 엔티티로 매핑되는 순간 각 행은 영속성 컨텍스트(1차 캐시)에 등록되고, 더티 체킹을 위해 로드 시점 상태 배열이 한 벌 더 잡힙니다. 즉 수정할 생각이 전혀 없는 읽기 전용 조회인데도 행당 메모리가 사실상 배로 듭니다. 조회 결과가 수십만 건이면 리스트 하나가 아니라 세션 안에 그만큼의 엔티티와 그만큼의 스냅샷이 남습니다.
2. 문서 객체 트리 적재
XSSF(일반 XSSFWorkbook)는 워크북 전체를 객체 트리로 메모리에 들고 있다가, write() 시점에 한 번에 XML로 직렬화합니다. 셀 하나가 자바 객체 하나입니다. 여기에 셀 스타일을 루프 안에서 매번 새로 만들면 스타일 객체까지 행 수만큼 늘어납니다. "행 수는 얼마 안 되는데 왜 이렇게 무겁냐"는 상황에서 가장 먼저 확인할 곳이 루프 안에서 생성되는 CellStyle입니다. 게다가 xlsx 형식 자체에 스타일 개수 상한이 있어서, 행마다 스타일을 만들면 메모리가 터지기 전에 그 한계에 먼저 걸려 예외가 나기도 합니다. 스타일은 루프 밖에서 만들어 재사용해야 합니다.
3. 직렬화 버퍼 적재
ByteArrayOutputStream은 완성된 파일 전체를 바이트 배열로 또 한 번 들고 있습니다. 게다가 이 클래스는 용량이 부족하면 내부 배열을 더 큰 것으로 교체하며 복사하므로, 최종 크기가 크면 확장 순간에 그보다 더 많은 공간을 동시에 점유합니다. toByteArray()는 거기서 한 벌을 더 복사합니다. 파일 크기가 곧 힙 요구량이 아니라, 그 몇 배가 순간 힙 요구량이 됩니다.
4. 동시성이 곱하기로 들어옵니다
가장 중요한 지점입니다. 위의 계산은 전부 요청 하나 기준입니다. 월말에 담당자 여러 명이 같은 버튼을 누르면 그 전부가 곱해집니다. 단일 요청 부하 테스트만 하고 넘어간 기능이 월말에만 죽는 이유가 이것입니다. 내보내기 기능은 반드시 동시 실행 수를 가정하고 설계해야 합니다. 뒤에서 다룰 작업 큐가 결국 이 곱셈을 상수로 바꾸는 장치입니다.
1층: 조회를 커서 기반으로 바꾸기
가장 아래층부터 고칩니다. 위에서 아무리 스트리밍으로 써도, 조회가 전체를 다 받아 놓으면 의미가 없습니다.
fetchSize는 그냥 켠다고 켜지지 않습니다
JDBC의 setFetchSize()는 드라이버마다 동작이 다르고, 조건을 맞추지 않으면 조용히 무시됩니다. 예외도 경고도 나지 않고 그냥 전량을 받아옵니다. "설정했으니 되겠지"가 가장 잘 배신하는 영역이라, 코드가 아니라 실제로 서버 커서가 열렸는지를 확인해야 합니다. 힙 사용량이 행 수에 비례해 올라가는지 한 번 재 보면 바로 드러납니다.
- PostgreSQL: 커넥션의
autoCommit이 false여야 서버 커서를 사용합니다. 자동 커밋 상태에서는 fetchSize를 아무리 줘도 전체를 받아옵니다. 결과셋 타입도 FORWARD_ONLY여야 합니다. - MySQL Connector/J: 전통적으로 FORWARD_ONLY · CONCUR_READ_ONLY 조건에서 fetchSize를
Integer.MIN_VALUE로 주는 방식으로 행 단위 스트리밍을 켰습니다. 별도로useCursorFetch옵션을 쓰는 경로도 있습니다. 이 둘은 서버 부하 특성과 커넥션 점유 방식이 다르니, 쓰는 드라이버 버전의 문서를 확인하고 고르십시오. - Oracle: 기본적으로 배치 단위로 가져오기 때문에 전량 적재 문제가 상대적으로 덜하고, fetchSize 조정이 비교적 직관적으로 반영됩니다. 대신 기본 배치 크기가 작아서 네트워크 왕복 횟수가 병목이 되는 반대 방향의 문제가 생기기도 합니다.
// PostgreSQL: autoCommit=false 여야 서버 커서가 열립니다
conn.setAutoCommit(false);
try (PreparedStatement ps = conn.prepareStatement(sql,
ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) {
ps.setFetchSize(1000);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
writer.writeRow(map(rs)); // 읽는 즉시 흘려보냅니다
}
}
}
ORM을 쓴다면 1차 캐시를 주기적으로 비웁니다
Hibernate로 스크롤할 때는 커서만 여는 걸로 부족합니다. 읽은 엔티티가 세션에 계속 쌓이므로, 커서를 썼는데도 결국 힙이 차오릅니다. 읽기 전용으로 열고, 일정 건수마다 세션을 비워야 합니다.
Session session = em.unwrap(Session.class);
session.setDefaultReadOnly(true); // 더티체킹 스냅샷 생략
try (var rs = session.createQuery(
"select o from Order o where o.createdAt < :asOf order by o.id", Order.class)
.setParameter("asOf", asOf)
.setFetchSize(1000)
.scroll(ScrollMode.FORWARD_ONLY)) {
int n = 0;
while (rs.next()) {
writer.writeRow(toRow(rs.get()));
if (++n % 1000 == 0) session.clear(); // 1차 캐시 비우기
}
}
ScrollableResults가 제네릭인지, get()이 무엇을 돌려주는지는 Hibernate 메이저 버전에 따라 다릅니다. 쓰는 버전의 시그니처를 확인하십시오. 영속성 컨텍스트 자체가 필요 없다면 StatelessSession을 쓰는 선택지도 있습니다. 1차 캐시도 더티 체킹도 없으니 clear()를 챙길 일이 아예 없어집니다. 대신 지연 로딩과 캐스케이드가 동작하지 않으므로, 연관 데이터가 필요하면 조인으로 직접 가져와야 합니다.
더 근본적으로는, 내보내기 경로에서 엔티티가 꼭 필요한 경우가 드뭅니다. 결국 하는 일이 "필드를 꺼내 셀에 넣는 것"이라면 DTO 프로젝션이나 순수 JDBC가 더 나은 선택입니다. 매핑 비용도, 캐시 문제도, 지연 로딩이 루프 안에서 터지는 문제도 함께 사라집니다.
OFFSET 페이징으로 쪼개면 뒤로 갈수록 느려집니다
커서가 부담스러워 "5천 건씩 나눠 조회"로 우회하는 경우가 많습니다. 그런데 LIMIT n OFFSET m은 건너뛸 m개 행을 실제로 읽고 버리는 방식이라, 뒤쪽 페이지로 갈수록 비용이 커집니다. 앞 페이지는 빠른데 마지막 페이지에서 타임아웃이 나는 전형적인 패턴이 여기서 나옵니다. 나눠 읽을 거면 키셋(keyset) 방식을 쓰십시오.
select id, ... from orders
where created_at < :asOf
and id > :lastId -- 직전 배치의 마지막 id
order by id
limit 5000;
키셋의 전제는 정렬 키가 유일하고 단조롭다는 것입니다. 사용자가 지정한 정렬(금액순, 이름순)로 나눠 읽어야 한다면 그 컬럼만으로는 부족하니, 반드시 (정렬컬럼, id) 복합 조건으로 이어 붙이고 같은 순서의 인덱스가 있는지 확인하십시오. 인덱스가 없으면 배치마다 정렬이 다시 일어나서 OFFSET보다 나을 게 없습니다.
일관성: 읽는 도중 데이터가 바뀝니다
긴 조회가 도는 동안 원본 테이블은 계속 변합니다. 나눠 읽는 방식은 그 사이 삽입·삭제 때문에 중복이나 누락이 생길 수 있습니다. 처리 방법은 두 가지입니다. 하나는 하나의 트랜잭션 스냅샷 안에서 커서로 끝까지 읽는 것, 다른 하나는 위 예시처럼 created_at < :asOf 같은 기준 시각을 조건에 못 박아 배치마다 같은 집합을 보게 만드는 것입니다. 후자는 트랜잭션을 길게 열지 않아도 되고, "이 파일은 몇 시 기준입니다"를 사용자에게 표시할 수 있다는 부수 효과도 있습니다. 재시도할 때 같은 결과가 나온다는 점도 큽니다.
장시간 커서의 DB 쪽 비용
커서 하나를 오래 열어 두는 건 공짜가 아닙니다. PostgreSQL 기준으로 오래 살아 있는 트랜잭션은 스냅샷을 유지시켜 죽은 튜플 정리를 지연시킵니다. 내보내기 하나 때문에 무관한 테이블 전반이 부풀 수 있습니다. 대응은 세 가지입니다.
- 가능하면 읽기 전용 복제본으로 보냅니다. 다만 복제본에서도 긴 조회는 공짜가 아닙니다. 복제 적용과 충돌해 쿼리가 취소될 수 있으니, 그쪽 설정도 함께 봐야 합니다.
- 커서를 무한정 열어 두지 말고, 기준 시각 + 키셋 방식으로 짧은 트랜잭션 여러 번으로 나눕니다.
statement_timeout,idle_in_transaction_session_timeout같은 안전장치를 내보내기 전용 커넥션 풀에 따로 걸어 둡니다.
내보내기용 커넥션 풀을 서비스 트래픽과 분리하는 건 특히 효과가 큽니다. 무거운 조회가 일반 API의 커넥션을 굶기는 사고를 구조적으로 막아 줍니다. 풀 크기가 곧 동시 실행 상한 역할을 하기도 합니다.
2층: 문서를 흘려 쓰기
엑셀: SXSSF의 계약 조건
SXSSF는 최근 N개 행만 메모리에 두고 나머지는 임시 파일로 내려 쓰는 구현입니다. 공짜로 얻는 게 아니라 제약을 받아들이는 대가로 얻는 것이고, 이 제약을 모르고 쓰면 런타임에 깨집니다.
- 내려간 행은 다시 못 만집니다. 윈도우 밖으로 밀려난 행에 접근하면 예외가 납니다. 합계 행을 맨 위에 만들어 두고 나중에 채우는 식의 코드는 성립하지 않습니다. 총계가 필요하면 마지막 행이나 별도 시트로 옮기거나, 값 대신 엑셀 수식으로 남기십시오. 시트 넓이 자동 맞춤처럼 전체 행을 훑어야 하는 기능도 같은 이유로 쓸 수 없습니다.
- 임시 파일 정리를 라이브러리에 맡기지 마십시오. SXSSF는 작업 중 임시 디렉터리에 조각 파일을 만듭니다. 이걸 지우는 건
dispose()이고, POI 버전에 따라close()가 어디까지 정리해 주는지는 다릅니다. 어느 쪽이든 예외 경로를 포함해finally에서dispose()를 명시적으로 호출하는 코드가 안전합니다. 빠뜨리면 디스크가 조용히 차오르는데, 이 장애는 발생 시점과 원인 코드가 멀어서 추적이 오래 걸립니다. - 공유 문자열 테이블 옵션은 켜지 마십시오. 공유 문자열 테이블은 메모리에 통째로 유지되므로, 문자열 종류가 많은 데이터에서는 스트리밍의 이점을 상당 부분 상쇄합니다.
- 시트당 행 한계는 1,048,576행입니다. 이건 xlsx 형식의 한계라 스트리밍으로 바꿔도 사라지지 않습니다. 넘어가면 시트를 분할하거나 형식을 바꿔야 합니다.
SXSSFWorkbook wb = new SXSSFWorkbook(100); // 메모리에 유지할 행 수
try (OutputStream out = Files.newOutputStream(tmpPath)) {
CellStyle dateStyle = wb.createCellStyle(); // 스타일은 루프 밖에서 1회
// ... 커서를 돌며 행을 씁니다 ...
wb.write(out);
} finally {
wb.dispose(); // 임시 파일 정리를 명시적으로
}
임시 파일 압축 옵션을 켜면 디스크는 덜 쓰지만 CPU를 더 씁니다. 디스크가 빠듯한 환경이 아니라면 기본값으로 두는 편이 낫습니다.
그리고 형식 자체를 다시 생각해 볼 여지가 있습니다. 사용자가 그 파일로 하는 일이 결국 다른 시스템에 넣거나 피벗을 돌리는 것이라면, 서식 없는 CSV가 훨씬 싸고 빠르고 안정적입니다. 행 수 한계도 없고, 스트리밍이 사실상 공짜입니다. 다만 CSV에는 별도의 함정이 있습니다. 엑셀에서 한글이 깨지는 문제로 BOM을 붙이는 관행, 선행 0이 있는 코드값이 숫자로 해석돼 사라지는 문제, =나 +로 시작하는 값이 수식으로 실행되는 인젝션 문제입니다. 마지막 건 보안 이슈이니 값 앞에 위험 문자가 오면 이스케이프하도록 공통 함수로 막으십시오.
PDF: 스트리밍이 되는지부터 확인합니다
PDF는 엑셀보다 사정이 나쁩니다. 대상이 텍스트만이 아니라 폰트, 이미지, 벡터 그래픽이기 때문입니다. 원칙은 두 가지입니다.
첫째, 메모리에 다 만들어 놓고 마지막에 넘기지 않습니다. 다만 "OutputStream에 쓰면 자동으로 스트리밍"은 아닙니다. 라이브러리마다 모델이 다릅니다. 문서 객체 그래프를 끝까지 들고 있다가 저장 시점에 직렬화하는 구현도 있고, 페이지를 닫는 시점에 그 페이지를 파일로 내려보낼 수 있는 구현도 있습니다. 쓰는 라이브러리가 (1) 스크래치 파일 같은 디스크 백업 모드를 제공하는지, (2) 페이지 단위로 flush 해서 메모리에서 내려보낼 수 있는지, 이 두 가지를 문서에서 확인하십시오. 둘 다 없으면 스트리밍이 아니라 문서를 나누는 쪽으로 설계를 바꾸는 게 빠릅니다.
둘째, 공유 자원은 한 번만 로드합니다. 폰트와 로고 이미지를 페이지마다 새로 임베드하면 파일 크기와 메모리가 함께 부풀어 오릅니다. 특히 한글 폰트는 글리프 수가 많아서, 서브셋 임베딩 여부가 결과물 크기를 크게 좌우합니다.
HTML을 렌더링해 PDF로 굽는 방식은 별도로 주의해야 합니다. 렌더러가 전체 DOM과 레이아웃을 메모리에 올리므로, 입력 HTML이 커지면 페이지 단위 스트리밍이라는 개념 자체가 성립하지 않습니다. 이 경우 해법은 스트리밍이 아니라 분할입니다.
실제로 수백 페이지짜리 단일 PDF가 필요한 경우는 생각보다 적습니다. "계약서 수천 건 일괄 출력" 같은 요구라면, 한 덩어리 PDF보다 건별 PDF를 만들어 ZIP으로 스트리밍하는 편이 메모리·실패 격리·재시도 측면에서 모두 낫습니다. 중간 한 건이 깨져도 나머지는 살릴 수 있고, 실패한 건만 다시 만들면 됩니다.
3층: 동기 응답을 버립니다
여기까지 하면 메모리는 잡힙니다. 그런데 여전히 남는 문제가 있습니다. HTTP 요청 하나가 수 분에서 수십 분 살아 있어야 한다는 점입니다.
응답 스트리밍(예: StreamingResponseBody)으로 커넥션을 유지하며 흘려보내는 방법이 있고, 중간 규모에서는 꽤 잘 동작합니다. 구현 비용도 낮습니다. 하지만 규모가 커지면 세 가지 한계에 부딪힙니다.
- 실패를 알릴 방법이 없습니다. 응답 본문 첫 바이트를 내보내는 순간 상태 코드와 헤더는 이미 나갔습니다. 그 뒤에 조회가 실패하면 사용자는 200 OK로 받은 깨진 파일을 손에 쥡니다. 그리고 그 파일이 정상인 줄 알고 보고에 쓰입니다. 이건 500 에러보다 훨씬 나쁩니다.
- 재시도가 불가능합니다. 사용자가 할 수 있는 건 처음부터 다시 누르는 것뿐이고, 그 사이 서버는 앞의 작업을 계속 돌리고 있습니다.
- 중간의 모든 장비가 끊을 권한을 갖습니다. 브라우저, 로드밸런서, 리버스 프록시, WAS가 전부 자기 타임아웃을 갖고 있습니다. 하나만 짧아도 전체가 실패합니다.
그래서 임계치를 넘는 내보내기는 작업으로 승격시킵니다. 요청은 즉시 작업 ID를 반환하고, 실제 생성은 워커가 합니다.
POST /exports -> 202 Accepted { "jobId": "..." }
GET /exports/{jobId} -> { "status": "RUNNING", "processedRows": 128000 }
-> { "status": "DONE", "downloadUrl": "...", "expiresAt": "..." }
작업 상태는 QUEUED → RUNNING → DONE | FAILED | EXPIRED 정도면 충분합니다. 테이블에는 요청자, 요청 파라미터, 기준 시각, 결과 파일 경로, 크기, 만료 시각, 실패 사유를 남깁니다. 이 테이블이 있다는 것만으로 운영에서 답할 수 있는 질문이 크게 늘어납니다. 누가 언제 어떤 조건으로 무엇을 받아 갔는지가 곧 감사 로그가 되기 때문입니다.
진행률은 욕심내지 마십시오. 퍼센트를 보여주려면 전체 건수를 알아야 하는데, 그 COUNT 쿼리가 본 조회만큼 무거운 경우가 흔합니다. 정확한 퍼센트가 요구사항이 아니라면 처리한 행 수만 갱신하는 편이 낫습니다. 사용자에게 필요한 정보는 대개 "멈춘 게 아니라 돌고 있다"는 사실 하나입니다.
권한은 요청 시점이 아니라 실행 시점 기준으로
작업으로 분리하면 실제 실행 주체가 요청자에서 워커로 바뀝니다. 워커가 넓은 권한으로 도는데 요청 파라미터에 담긴 조회 범위를 그대로 신뢰하면, 원래 화면에서는 볼 수 없던 데이터가 파일로 새어 나갑니다. 요청 시점에 권한을 검증했더라도, 실행 시점에 요청자 식별자를 기준으로 조회 범위를 다시 좁히는 코드가 있어야 합니다. 대량 데이터가 파일 하나로 응축되는 경로라 사고 규모가 커집니다.
구조가 바뀌면서 따라오는 이점
- 동시 실행 제어가 가능해집니다. 워커 수가 곧 동시 실행 상한입니다. 앞서 "동시성이 곱하기로 들어온다"고 했던 문제가 여기서 구조적으로 해결됩니다. 큐가 밀리면 사용자는 대기할 뿐, 서버는 죽지 않습니다.
- 같은 요청을 합칠 수 있습니다. 동일 파라미터 + 동일 기준 시각 요청이 이미 RUNNING이면 새 작업을 만들지 않고 기존 작업에 붙입니다. 사용자가 버튼을 연타해도 파일은 하나만 만들어집니다.
- 다운로드가 앱 서버를 거치지 않아도 됩니다. 오브젝트 스토리지에 올리고 만료 시간이 있는 서명된 URL을 내려 주면, 대용량 전송이 애플리케이션 힙과 스레드를 전혀 쓰지 않습니다. 전송 실패 시 재개도 스토리지가 처리합니다.
다만 이 URL은 유출되면 그 자체가 접근 권한입니다. 만료를 짧게 잡고, 파일명에 개인정보나 추측 가능한 규칙을 넣지 말고, 링크를 메일이나 메신저로 그대로 뿌리지 않는 편이 안전합니다.
타임아웃과 재시도
비동기로 바꿔도 타임아웃은 사라지지 않고 이동할 뿐입니다. 어디에 어떤 타임아웃이 있는지 먼저 목록으로 만들어야 합니다. 가장 자주 만나는 함정은 바깥 타임아웃이 안쪽보다 짧은 경우입니다. 이러면 서버는 계속 일하는데 클라이언트는 이미 끊겨 있고, 아무도 받지 않을 결과를 만드느라 리소스만 태웁니다.
| 지점 | 기본값의 성격 | 넘겼을 때 증상 |
|---|---|---|
| 브라우저 / HTTP 클라이언트 | 수십 초~수 분 | 다운로드 중단, 부분 파일 |
| 로드밸런서 유휴 타임아웃 | 수십 초 수준인 제품이 많음 | 502/504, 서버 로그엔 흔적 없음 |
| 리버스 프록시 read timeout | 분 단위 | 게이트웨이 오류 |
| WAS 요청 처리 | 설정에 따름 | 스레드 점유, 풀 고갈 |
| DB statement timeout | 미설정인 경우가 많음 | 무한정 도는 쿼리 |
| 트랜잭션 타임아웃 | 미설정인 경우가 많음 | 롤백, 커서 무효화 |
| 커넥션 풀 대기 | 초 단위 | 무관한 API가 함께 실패 |
원칙은 안쪽으로 갈수록 짧게입니다. DB 쿼리 타임아웃 < 배치 단위 타임아웃 < 작업 전체 타임아웃 순으로 여유를 두면, 어디서 끊겼는지가 로그에 정확히 남습니다. 반대로 안쪽이 길면 모든 실패가 "게이트웨이 오류"로만 보이고 원인을 못 찾습니다.
재시도는 "안전하게 버리기"부터
내보내기 재시도에서 가장 중요한 건 재시도 로직이 아니라 실패한 작업의 부분 결과물을 확실히 폐기하는 것입니다. 절반만 쓰인 엑셀 파일이 정상 파일처럼 노출되는 사고가 실제로 자주 납니다. 방법은 단순합니다.
- 로컬 디스크에 쓴다면 생성 중에는
.part같은 임시 이름으로 쓰고, 완료 시점에만 최종 이름으로 옮깁니다. 같은 파일시스템 안에서의 이름 변경은 원자적이므로, 최종 경로에 존재하는 파일은 항상 완성본입니다. - 오브젝트 스토리지에는 이름 변경이라는 연산이 없습니다. 대개 복사 후 삭제로 흉내 내므로 원자적이지도 않고 비용도 듭니다. 이 경우에는 파일 존재 여부가 아니라 작업 레코드의 상태를 유일한 공개 스위치로 삼는 편이 단순합니다. DONE이 되기 전에는 URL을 발급하지 않습니다.
- 재시도 시에는 이전 산출물을 지우고 처음부터 다시 만듭니다. 이어 쓰기는 정합성 검증 비용이 재생성 비용보다 비싼 경우가 대부분입니다.
재시도 횟수는 제한하고, 지수 백오프를 적용하고, 실패 사유를 일시적(커넥션 끊김, 락 대기, 일시적 스토리지 오류)과 영구적(파라미터 오류, 권한 없음, 데이터 형식 오류)으로 구분하십시오. 영구 실패를 세 번 재시도하는 건 부하만 세 배로 만들고 사용자는 세 배 오래 기다립니다. 그리고 워커가 작업 도중에 죽는 경우를 대비해, RUNNING 상태로 일정 시간 이상 갱신이 없는 작업을 회수하는 청소 잡이 필요합니다. 마지막 갱신 시각 컬럼 하나면 충분합니다.
파일 정리 — 가장 자주 빠뜨리는 부분
내보내기 기능의 장애 중 상당수는 OOM이 아니라 디스크 풀입니다. 임시 파일과 결과 파일이 쌓이는 경로가 최소 세 군데나 되기 때문입니다.
- SXSSF가 만드는 시스템 임시 디렉터리의 조각 파일
- 워커가 만드는
.part중간 산출물 - 완성된 결과 파일
여기에 규칙을 세웁니다.
- 결과 파일에 만료 시각을 부여합니다. "며칠 뒤 삭제"를 정책으로 정하고 작업 레코드에 기록합니다. 오브젝트 스토리지를 쓴다면 수명 주기 규칙에 맡기는 게 가장 확실합니다. 애플리케이션 코드로 지우는 건 코드가 안 도는 순간 멈추지만, 스토리지 정책은 계속 돕니다.
- 스토리지를 진실의 원천으로 삼습니다. DB의 작업 레코드와 실제 파일은 언젠가 반드시 어긋납니다. 삭제는 파일을 지운 뒤 레코드를 EXPIRED로 바꾸는 순서로, 그리고 파일이 이미 없어도 실패하지 않게 만듭니다.
- 고아 파일을 주기적으로 훑습니다. 작업 레코드가 없는 파일, 오래된
.part는 배치로 정리합니다. 워커가 비정상 종료하면 반드시 생깁니다. - 디스크 사용률에 경보를 겁니다. 임시 디렉터리를 애플리케이션 로그나 시스템 파티션과 같은 볼륨에 두지 마십시오. 내보내기 하나가 서버 전체를 멈추게 하는 최단 경로입니다.
- 총량 상한을 둡니다. 사용자별 보관 개수나 전체 용량 상한을 정하고, 초과하면 오래된 것부터 밀어냅니다. 만료만으로는 짧은 기간에 몰리는 요청을 못 막습니다.
도입 순서 체크리스트
한꺼번에 다 만들 필요는 없습니다. 아래 순서는 각 단계가 그 자체로 의미 있는 개선이 되도록, 그리고 중간에 멈춰도 손해가 없도록 배열한 것입니다.
- 측정합니다. 최대 행 수, 열 수, 실제 파일 크기, 월별 호출 횟수, 동시 호출 최대치, 그리고 생성에 걸리는 시간. 이게 없으면 다음 단계의 판단이 전부 추측입니다.
- 상한을 겁니다. 임시 조치지만 즉시 효과가 있습니다. 조회 건수 상한을 두고, 넘으면 기간을 좁히라고 안내합니다. 죽는 것보다 거절하는 게 낫습니다.
- 조회를 고칩니다. 커서 또는 키셋, 읽기 전용, DTO 프로젝션, 주기적 세션 클리어. 설정만 하지 말고 실제로 서버 커서가 열렸는지 확인합니다.
- 문서 생성을 고칩니다. SXSSF +
dispose(), 스타일 재사용, 파일 스트림에 직접 쓰기. - 임계치를 정합니다. "작은 건 동기 응답, 큰 건 작업 큐"로 두 경로를 공존시킵니다. 임계치는 행 수만으로 잡지 말고 행 × 열 또는 예상 바이트로 잡으십시오. 열이 100개인 5만 행이 열 5개인 50만 행보다 무겁습니다. 작은 조회까지 비동기로 만들면 사용자 경험만 나빠집니다.
- 작업 큐와 상태 조회를 붙입니다. 작업 테이블, 워커, 진행 표시, 다운로드 URL, 중복 요청 병합, 실행 시점 권한 재확인.
- 타임아웃을 계층별로 정렬합니다. 안쪽이 더 짧게.
- 정리 배치와 경보를 붙입니다. 만료 삭제, 고아 파일 청소, 좀비 작업 회수, 디스크 경보.
- 운영 지표를 남깁니다. 작업 소요 시간 분포, 실패율, 대기열 길이, 결과 파일 크기. 다음 병목은 여기서 보입니다.
| 전체 적재 동기 | 스트리밍 동기 | 비동기 작업 + 링크 | |
|---|---|---|---|
| 메모리 | 행 수에 비례 | 거의 일정 | 거의 일정 |
| 긴 작업 | 타임아웃 | 커넥션 계속 점유 | 영향 없음 |
| 실패 통보 | 가능 | 불가(깨진 파일) | 가능 |
| 재시도 | 사용자가 재요청 | 사용자가 재요청 | 서버가 자동 |
| 동시성 제어 | 없음 | 없음 | 워커 수로 제어 |
| 운영 대상 | 없음 | 없음 | 큐·스토리지·정리 배치 |
| 구현 비용 | 낮음 | 낮음 | 높음 |
언제는 하지 말아야 하는가
여기까지 읽고 전부 만들기로 결정했다면, 잠깐 멈추십시오. 비동기 내보내기 파이프라인은 구성 요소가 많습니다. 큐, 워커, 상태 테이블, 스토리지, 만료 정책, 정리 배치, 모니터링. 각각이 장애 지점이고 각각이 운영 대상입니다. 다음 경우에는 만들지 않는 편이 낫습니다.
데이터가 실제로 크지 않을 때
수천 행에 열 몇 개짜리 엑셀은 그냥 만들어서 내려 주면 됩니다. 이 정도는 커서도, 큐도 필요 없습니다. "언젠가 커질 것"을 근거로 지금 큐를 도입하는 건 대개 지나칩니다. 상한을 걸어 두고 실제로 부딪힐 때 고치는 편이 낫습니다. 상한에 걸린 요청이 로그에 몇 번 찍히는지가 곧 도입 시점을 알려 줍니다.
호출 빈도가 매우 낮을 때
분기에 한 번, 담당자 한 명이 쓰는 정산 자료라면 동시성 문제가 없습니다. 좀 무거워도 사람이 적은 시간에 돌리면 됩니다. 이런 기능에 파이프라인을 붙이면, 정작 쓸 때가 되면 아무도 동작을 기억하지 못하는 코드가 됩니다. 자주 실행되지 않는 인프라는 조용히 썩습니다.
사용자가 원하는 게 파일이 아닐 때
가장 흔한 오진입니다. 큰 엑셀을 요청하는 사용자에게 왜 필요한지 물어보면, 실제 목적은 특정 조건의 몇 백 건을 찾는 것이거나 합계를 보는 것인 경우가 많습니다. 엑셀은 그 사람이 아는 유일한 도구여서 나온 답입니다. 필터가 제대로 된 화면이나 집계 리포트를 주면 요구 자체가 사라집니다. 요구사항을 그대로 구현하기 전에 "그 파일을 열고 나서 무엇을 하십니까"를 한 번 물어보는 게 가장 싼 최적화입니다.
배치나 데이터 파이프라인으로 풀어야 할 때
매일 같은 시각에 같은 전량 데이터가 필요하다면, 그건 내보내기 기능이 아니라 정기 배치입니다. 미리 만들어 두면 사용자는 기다릴 필요조차 없고, 부하 시점도 우리가 고를 수 있습니다. 요청 시점 생성 구조를 고민하기 전에 미리 만들어 둘 수 있는지를 먼저 확인하십시오.
비슷하게, 목적이 다른 시스템으로의 이관이라면 파일이 아니라 API나 데이터 연계로 푸는 게 맞습니다. 사람이 엑셀을 받아서 다른 시스템에 올리는 흐름은 오류율이 높고 추적이 안 됩니다.
이미 있는 도구로 충분할 때
사내에 BI나 리포팅 도구가 이미 있고 사용자가 거기 접근할 수 있다면, 애플리케이션에 내보내기 파이프라인을 새로 만드는 건 같은 기능을 두 번 운영하는 일입니다. 데이터 범위와 권한 모델이 그쪽에서 이미 정리돼 있는지부터 확인하십시오.
보관 정책을 정할 수 없을 때
생성한 파일을 언제 지울지 결정할 수 없다면, 아직 이 구조를 만들 준비가 안 된 것입니다. 개인정보가 담긴 파일이 만료 없이 스토리지에 쌓이는 건 메모리 문제보다 훨씬 큰 위험이고, 사고가 나면 되돌릴 수도 없습니다. 보관 기간과 접근 통제를 먼저 합의하고 나서 구현에 들어가십시오. 이 합의를 못 받아 내는 상태라면, 그 자체가 아직 만들 때가 아니라는 신호입니다.
정리
대용량 내보내기는 라이브러리 교체로 끝나는 문제가 아닙니다. 조회에서 문서로, 문서에서 응답으로 데이터가 넘어가는 모든 경계에서 "전부 모으는 지점"을 없애는 일입니다. 한 군데라도 남아 있으면 나머지 최적화가 무의미해집니다. 커서를 열어 놓고 결과를 리스트에 담고 있으면 커서를 연 의미가 없습니다.
그리고 규모가 임계를 넘으면 문제의 성격이 바뀝니다. 메모리 문제가 아니라 시간 문제가 되고, 시간 문제는 응답 모델을 바꿔야 풀립니다. 작업으로 승격시키고, 상태를 남기고, 실패를 다루고, 만들어 낸 파일을 책임지고 지우는 것. 여기까지가 한 세트입니다. 앞부분만 만들고 뒷부분을 미루면 디스크가 대신 청구서를 보냅니다.
다만 대부분의 내보내기는 이 세트가 필요 없습니다. 필요한 건 상한 하나와 커서 조회 정도입니다. 어느 쪽인지 판단하려면 지금 그 기능이 실제로 몇 행을 다루고 있고, 한 달에 몇 번 눌리고, 동시에 몇 개나 도는지부터 재 보는 게 순서입니다.
같은 주제의 다른 글
맘소프트는 19년 넘게 금융 · 엔터프라이즈 · 모바일 시스템을 만들어 왔습니다. 여기 적는 글은 그 과정에서 실제로 부딪히고 해결한 것들입니다.