기술

엑셀 업로드 메모리 100MB 줄겠지 했는데, 1,132MB가 줄었습니다

2026.09.127분 읽기

운영 힙을 2GB로 잡아둔 시스템이 있었습니다. 어떤 B2B 채권 분석 시스템의 대량 엑셀 업로드 기능이고, 평소 들어오는 파일은 수천 행이라 한 번도 문제가 된 적이 없었습니다. 운영 최대 사이즈를 가정하고 스트레스 테스트를 돌리면서 이야기가 달라졌습니다.

77,654행 64MB 파일을 넣자 peak 사용량이 1,647MB까지 올라갔습니다. 처리가 끝난 뒤에도 1,332MB가 그대로 남아 있었습니다. 힙 2GB의 8할을 요청 하나가 쓰고 있었다는 뜻입니다. 같은 업로드가 동시에 두 건만 들어와도 OOM입니다.


줄일 곳 후보가 두 개였습니다

개선안을 두 개 동시에 등록했습니다. 하나는 실패행을 메모리에 모아두지 않고 파일로 흘려 쓰는 스트리밍 전환입니다. 다른 하나는 행을 파싱하는 과정에서 생기는 임시 객체를 줄이는 작업입니다.

노트에는 앞의 것에 「약 100MB 추가 개선 예상」이라고 적었습니다. 뒤의 것에는 「1,000건당 270MB 감소, 이쪽이 dominant로 보임」이라고 적었습니다. 우선순위가 이 두 줄로 정해졌습니다.

그렇게 적은 데는 근거가 있었습니다. 메모리 로그에서 valley 값이 871MB에서 732MB로 내려가는 구간이 관찰됐습니다. 실패행이 끝까지 잡혀 있다면 바닥값이 내려갈 이유가 없다고 봤습니다. 그래서 누적의 본체는 실패행이 아니라 임시 객체 쪽이라고 판단했습니다.

149-1.jpg


기대가 낮았던 쪽을 먼저 고쳤습니다

구조 변경이 단순한 쪽이 스트리밍이었습니다. 기존 구조는 실패행을 인덱스 키로 하는 Map에 전부 담아뒀다가, 처리가 끝난 뒤 한 번에 엑셀로 내려썼습니다. 바꾼 구조는 배치 단위로 파일에 이어 쓰고 메모리에서는 즉시 버립니다.

예상 개선폭이 100MB였으니 확인 삼아 재보는 정도의 기대였습니다. 실측은 1,132MB 감소였습니다. 예상의 11배입니다.

지표

기존 구조

스트리밍 전환

차이

Peak used

1,647 MB

515 MB

-1,132 MB (-69%)

End used

1,332 MB

366 MB

-966 MB

Total heap allocated

2,144 MB

640 MB

-1,504 MB

메모리 패턴

단조 증가

232~498MB 사이클

GC 정상 회수

표에서 가장 오래 본 줄은 peak가 아니라 total heap allocated입니다. 2,144MB에서 640MB로 3.3배 줄었습니다. JVM이 OS에 힙을 더 달라고 요청한 횟수가 0이 됐다는 뜻이고, 이 값은 used 그래프보다 정직한 신호입니다.

149-2.jpg


valley가 내려간 것은 회수의 증거가 아니었습니다

기존 구조의 메모리 로그를 처음부터 다시 폈습니다. 8,000건에서 498MB, 40,000건에서 891MB, 60,000건에서 1,251MB, 77,000건에서 1,647MB였습니다. 1만 건마다 200~300MB씩 바닥이 올라가고 있었습니다.

valley가 871MB에서 732MB로 내려간 구간은 사실이었습니다. GC가 일부를 회수한 것도 사실입니다. 다만 그것은 국소 구간의 사실이었고, 전체 흐름은 단조 증가였습니다. 국소 구간의 회수를 전체 판단의 근거로 쓴 것이 첫 번째 오독입니다.

바꾼 구조에서는 6,000건마다 GC가 돌면서 232MB까지 내려갔다가 다시 500MB까지 올라가는 사이클이 반복됐습니다. 77,654건을 처리하는 동안 total heap은 640MB에서 한 번도 늘지 않았습니다. 같은 모양의 used 그래프라도 total heap이 고정이면 사이클이고, 늘어나면 누적입니다.


두 번째 개선안은 작업하지 않기로 했습니다

새 구조에서 임시 객체 쪽을 다시 쟀습니다. 1,000건당 30MB 정도 늘다가 6,000건마다 GC가 전부 회수하고 있었습니다. dominant로 보던 270MB는 이미 회수되던 양이었습니다. 줄여도 peak가 내려가지 않는 자리였습니다.

그래서 두 번째 개선안은 착수하지 않고 취소로 닫았습니다. 취소 사유에 「측정 결과 dominant가 아님」과 근거 수치를 같이 적었습니다. 하지 않기로 한 결정도 근거와 함께 남겨야 반년 뒤에 같은 후보가 다시 올라오지 않습니다.


되돌릴 수 있게 만들어 두었습니다

적용한 구조는 네 조각입니다.

스트리밍 방식의 실패행 기록기 인터페이스와 공통 추상 클래스 — 파일은 첫 실패행이 나올 때 열고, 이어 쓰기는 스레드 안전하게 처리합니다

동적 컬럼 너비 계산 핸들러 — 자동 폭 조정 API 대신 한글 문자 폭을 직접 보정합니다

업로드 종류별 구현체와 팩토리 — 대상이 늘어도 기록 방식은 한 곳에 둡니다

기존 버전과 새 버전 병존 — 토글 한 줄로 이전 구현으로 되돌아갑니다

큰 최적화일수록 되돌리는 경로를 같이 만들어 둡니다. 이번 변경은 메모리를 69% 줄이는 대신 파일 쓰기 시점을 통째로 바꿉니다. 운영에서 예상 못 한 증상이 나오면 원인 분석보다 롤백이 먼저입니다.

핫패스를 정리하면서 딸려 온 효과도 있었습니다. 헤더와 enum을 맞춰보는 계산을 매 행마다 하던 것을, 생성자에서 한 번 계산해 캐시로 들고 있게 바꿨습니다. 7만 건 기준으로 약 4억 번의 비교와 500만 개의 스트림·Optional 객체 할당이 0이 됐습니다.


운영에서 달라진 것

힙 2GB 기준으로 peak 1.6GB는 여유가 20%도 안 되는 상태였습니다. 0.5GB가 되면서 여유가 75%로 바뀌었습니다. 10배 사이즈인 77만 행을 가정하면 기존 구조는 OOM이 거의 확실했고, 새 구조는 여전히 500MB 안에서 처리됩니다.

힙 확장이 사라진 것도 운영 지표로는 큽니다. JVM이 힙을 늘리는 구간마다 GC pause가 길어지고 업로드 응답 시간이 튀었습니다. 지금은 처리 시간이 파일 크기에 비례해 예측 가능한 범위 안에 들어옵니다.

149-3.jpg


다음에 같은 판단을 할 때 보는 것

peak와 valley를 같이 봅니다. valley가 회복돼도 peak는 계속 올라갈 수 있습니다

total heap이 늘었는지 봅니다. used 사이클보다 누적을 먼저 알려줍니다

구간별 스냅샷을 남깁니다. 1만 건 단위 수치가 없으면 단조 증가인지 사이클인지 구분되지 않습니다

가설이 둘이면 구현이 싼 쪽을 먼저 재봅니다. 측정 한 번이 가설 하나를 통째로 지웁니다

다만 이 결론을 모든 업로드에 그대로 옮기지는 않습니다. 여기서 지배 항목을 가른 것은 7.7만 행이라는 운영 사이즈였고, 사이즈가 달라지면 지배 항목도 달라집니다. 구간별 메모리 로그가 남아 있지 않았다면 이 비교 자체가 불가능했습니다.


도입 전에 확인할 것

운영 최대 사이즈로 부하를 걸어본 기록이 있는지 확인합니다. 평소 사이즈로는 위험 구간이 드러나지 않습니다

메모리 개선 작업에 착수 전 측정치와 착수 후 측정치가 둘 다 남아 있는지 확인합니다

성능 개선 변경에 롤백 경로가 있는지 확인합니다. 되돌릴 수 없는 최적화는 운영에서 부담이 됩니다

하지 않기로 한 작업의 사유와 근거 수치가 남아 있는지 확인합니다

이번 작업에서 코드를 고친 시간보다 로그를 다시 읽은 시간이 길었습니다. 처음 가설은 그럴듯했고, 근거로 삼은 숫자도 실제 관측값이었습니다. 틀린 것은 그 숫자 하나로 전체를 설명하려 한 부분입니다. 메모리 최적화는 측정에서 시작해 가설을 세우고 다시 측정으로 닫는 순환이고, 마지막 측정을 건너뛰면 그럴듯한 가설이 그대로 결론 자리에 앉습니다.

#메모리최적화#스트리밍#GC#JVM#OOM방지#성능측정