1편은 이렇게 끝났습니다. "이 작업은 100만 건 문제를 해결한 게 아닙니다. 서버 측 파일 생성은 설계까지만 왔습니다."
이번에 그걸 구현했습니다. 13개 태스크, 커밋 19개.
그런데 이 환경에서는 빌드가 돌지 않습니다. 망 분리라 내부 저장소에 접근할 수 없고, gradle 은 의존성 해석 단계에서 멈춥니다. 프론트도 npm run lint 조차 없습니다.
테스트를 23개 썼습니다. 0개 실행했습니다.
그 상태로 코드를 넘겨야 했습니다. 이 글은 그때 무엇이 검증선 역할을 했고, 거기서 무엇이 걸렸는지에 대한 기록입니다.
결론부터 요약합니다.
- 리뷰가 컴파일러 역할을 했습니다. 잡아낸 건 스타일이 아니라 진짜 컴파일 오류였습니다
- 규모가 10배 되자 없던 제약이 생겼습니다. 10만 기준으로 쓴 설계에 엑셀 행 한계 이야기가 한 줄도 없었습니다
- 1편에서 메모리를 아끼려 넣은 구조가 새 버그를 만들었습니다. 정산 증빙에 같은 거래가 두 번 찍히는 경로였습니다
- 제가 여러 번 틀리게 말한 것들이 있었고, 전부 리뷰가 잡았습니다
1️⃣ 규모가 한 자릿수 바뀌자, 없던 제약이 생겼습니다
1편을 쓸 때 저는 10만 건을 가정하고 설계 문서를 썼습니다. 그 문서에는 엑셀 시트의 행 한계 이야기가 한 줄도 없습니다.
10만에서는 존재하지 않는 문제였으니까요.
실제 규모가 한 달치 약 100만 건으로 확인되면서 이게 튀어나왔습니다.
xlsx 시트 한 장의 최대 행 수는 1,048,576행입니다. 2의 20제곱입니다. 임의로 정한 숫자가 아니라 포맷이 행 인덱스에 할당한 비트 수입니다. (열은 16,384 = 2의 14제곱입니다.)
100만 건이면 헤더 한 줄을 더해 1,000,001행. 한계까지 4만 8천 행 남았습니다. 거래가 조금만 늘거나 기간이 하루만 길어져도 시트 분할 없이는 파일 자체가 만들어지지 않습니다.
시트 분할이 실제로 해결하는 것, 못 하는 것
여기서 착각하기 쉬운 게 있습니다. 시트를 나눈다고 파일이 가벼워지지 않습니다.
해결하는 것: "1,048,576행을 넘으면 저장 자체가 실패한다"
해결하지 못하는 것: 파일 크기, 여는 시간, 브라우저·엑셀의 메모리
100만 행 xlsx 는 시트가 1장이든 10장이든 Excel 이 통째로 파싱합니다. 실무에서 대용량을 CSV 로 받으라고 안내하는 이유가 이겁니다 — CSV 는 행 제한이 없고 스트리밍이 자연스럽습니다. 시트 분할은 포맷을 유지한 채 한계를 우회하는 것이고, 업계 관행은 포맷을 바꾸는 쪽입니다.
이번에는 분할까지만 했습니다. 그 한계를 설계 문서에 적어뒀습니다.
임계값을 상수로 박지 않은 진짜 이유
시트당 100만 행에서 나누기로 했습니다. 처음엔 이렇게 썼습니다.
private static final int DEFAULT_ROWS_PER_SHEET = 1_000_000;
리뷰에서 지적이 왔습니다. 이러면 제가 제안한 경계 테스트를 돌릴 수 없습니다. "1,000,001행이면 시트 2장" 을 검증하려면 실제로 100만 행을 써야 하니까요.
그런데 생성자 주입으로 바꾼 진짜 이유는 테스트 속도가 아니었습니다.
운영 건수(약 100만)와 임계값(100만)이 거의 같습니다. 달에 따라 1장이 되기도 하고 2장이 되기도 합니다. 즉 분할 코드가 프로덕션에서 간헐적으로만 실행됩니다. 평소엔 안 타다가 어느 달 갑자기 타는 코드입니다.
그런 코드가 가장 위험합니다. 그래서 임계값을 주입 가능하게 만들어 단위 테스트가 항상 분할 경로를 타게 했습니다. 테스트에서는 3을 넣습니다.
// 테스트: 임계값 3, 데이터 4행 → 시트 2장
try (Workbook wb = writeAndRead(3, 4)) {
assertThat(wb.getNumberOfSheets()).isEqualTo(2);
assertThat(wb.getSheetName(0)).isEqualTo("거래내역 1 (총 2장)");
}
시트 이름에서 만난 함정
거래내역(1/2) 로 쓰고 싶었습니다. 못 씁니다.
엑셀 시트 이름에는 / 를 쓸 수 없습니다. 금지 문자가 : \ / ? * [ ] 이고 31자 제한이 있습니다.
그래서 거래내역 1 (총 2장) 형태로 갔는데, 여기서 더 고약한 게 나왔습니다.
31자를 넘을 때 무엇을 자르느냐가 문제입니다. 전체를 뭉쳐서 자르면 기본명이 긴 경우 접미사가 잘려나갑니다.
"가나다라마바사아자차카타파하가나다라마바사아자차카타파하가나다" (31자) + " 1 (총 2장)"
→ 40자 → 앞 31자로 자름 → "가나다라마바사아자차카타파하가나다라마바사아자차카타파하가나다"
↑ 시트 1도, 시트 2도 똑같은 이름이 됩니다
POI 는 중복 시트명에 예외를 던집니다. 다운로드 전체가 실패합니다.
그래서 순서를 뒤집었습니다 — 접미사를 먼저 확보하고, 남는 자리에 기본명을 맞춰 자릅니다.
String suffix = " " + index + " (총 " + sheetCount + "장)";
int room = Math.max(0, 31 - suffix.length());
String head = baseName.length() > room ? baseName.substring(0, room) : baseName;
return WorkbookUtil.createSafeSheetName(head + suffix);
그리고 총 장수는 다 쓰기 전에 알 수 없습니다. 스트리밍이니까요. 쓰는 동안은 순번만 붙여두고, workbook.write() 이전에 이름을 고칩니다. 시트 이름은 flush 된 행 데이터가 아니라 워크북 메타데이터라 그 시점까지 바꿀 수 있습니다.
2️⃣ 컴파일러가 없으면, 리뷰가 컴파일러가 됩니다
여기서부터가 이 글의 본론입니다.
13개 태스크를 각각 다른 에이전트에게 맡기고, 태스크마다 독립 리뷰어를 붙였습니다. 빌드가 안 도니 리뷰가 유일한 검증선이었습니다.
그래서 리뷰 요청 방식을 바꿨습니다. "코드 품질을 봐달라"가 아니라 이렇게 썼습니다.
이 리뷰가 이 코드에 대한 유일한 검증입니다. 여기서는 컴파일도 실행도 할 수 없으니, 당신이 컴파일러이고 테스트 러너라고 생각하고 읽으세요. 산술은 손으로 계산하세요.
그리고 검증할 항목을 하나씩 명시했습니다. "행 인덱스 계산을 추적하라", "테스트 7개가 각각 통과할지 손으로 트레이스하고, 실패할 것이 있으면 이름을 대라".
그랬더니 컴파일 오류가 나왔습니다
핵심 클래스를 리뷰하던 에이전트가 로컬 gradle 캐시에서 POI 소스 jar 을 꺼내서 확인했습니다.
// POI 5.2.3 — SXSSFSheet.java
public void flushRows() throws IOException { ... } // ← checked exception
제가 계획서에 넣은 코드는 이걸 처리하지 않았습니다.
private void openNextSheet() { // throws 선언 없음
if (current != null) {
current.flushRows(); // ← unreported exception IOException
}
...
}
그리고 이 메서드를 부르는 header() / row() 는 checked 예외를 선언하지 않는 인터페이스의 구현이라 전파도 불가능합니다. 컴파일이 안 되는 코드였습니다.
컴파일러가 1초에 알려줄 것을 사람(모델)이 소스 jar 을 뜯어서 몇 분에 찾았습니다. 그게 유일한 방법이었습니다.
더 아팠던 건, 그게 제 계획서의 결함이었다는 것
구현자는 지시대로 정확히 옮겨 적었습니다. 결함은 제가 쓴 계획서에 있었습니다.
계획서에 완성된 코드를 넣는 방식에는 장점이 있습니다. 구현자가 헤매지 않고, 검증할 지점이 명확해집니다. 그런데 그 코드를 아무도 컴파일해보지 않았다면, 계획서는 결함을 13개 태스크에 그대로 복제하는 장치가 됩니다.
같은 종류의 사고가 한 번 더 있었습니다. 계획서에서 "화면의 maskCardNumber 함수를 삭제하라"고 지시했는데, 구현자가 확인해보니 그 함수는 화면 테이블의 카드번호 컬럼에서도 쓰이고 있었습니다. 지웠으면 렌더가 깨지고 lint 가 터졌을 자리입니다.
구현자가 지시를 따르지 않고 근거와 함께 이탈했습니다. 그게 맞는 판단이었습니다.
3️⃣ 1편의 결론이 2편의 버그를 만들었습니다
이게 이번에 가장 배운 지점입니다.
1편에서 메모리를 상수로 만들려고 청크 조회를 도입했습니다. 대상 id 를 먼저 확보하고, 5,000개씩 잘라 IN 으로 조회하는 구조입니다. 1편에는 이렇게 썼습니다.
살아있는 건
List<Long>id 목록과 청크 하나뿐입니다. 힙 점유가 건수와 무관해집니다.
리뷰어가 여기서 버그를 찾았습니다. persistence 라이브러리 jar 을 꺼내 JPA 애노테이션을 읽고 나서였습니다.
조인이 fan-out 되면 id 목록에 중복이 생깁니다
조회에 조인이 여섯 개 걸려 있는데, 그중 하나가 이런 관계였습니다.
@ManyToOne // 유니크 제약 없음
private Merchant merchant;
한 가맹점에 해당 행이 둘 이상이면, 그 가맹점의 모든 거래가 f배로 불어납니다. select(id) 는 같은 id 를 f번 반환합니다. 정렬 키가 같으니 인접해서 나옵니다.
여기까지는 예전에도 있던 일입니다. 문제는 자르는 순간 생깁니다.
중복이 한 청크 안에 다 들어감 → IN 이 dedupe → 조인이 다시 fan-out → f행. 정상
중복이 청크 경계에 걸침 → 앞 청크가 f행, 뒤 청크가 또 f행 → 2f행. 이중계상
IN 절은 청크 안에서만 중복을 없앱니다. 경계를 넘으면 두 청크가 각각 온전히 fan-out 합니다.
Before
After (문제가 생긴 구조)
고치는 건 한 줄이었습니다
.select(p.id).distinct()
허무하지만, 이게 정산 증빙 파일입니다. 같은 거래가 두 번 찍힌 엑셀로 대사를 하면 금액이 어긋납니다. 조용히 어긋납니다 — 파일은 정상으로 보이고 행이 조금 많을 뿐입니다.
그리고 이건 원래 있던 버그가 아닙니다. 1편에서 메모리를 아끼려고 도입한 청크 구조가 만든 새 버그입니다.
최적화를 넣을 때 "이 구조가 깨뜨리는 기존 불변식은 무엇인가"를 묻지 않으면, 아낀 메모리만큼 다른 데서 값을 치릅니다. 여기서 깨진 불변식은 "한 거래는 결과에 정확히 f번 나온다" 였습니다.
4️⃣ 제가 반복해서 틀리게 말한 것들
리뷰가 없었으면 그대로 나갔을 것들입니다. 셋 다 제가 여러 번 단정해서 말한 내용입니다.
"임시 파일에 카드번호가 들어 있다"
작업 내내 이렇게 말했습니다. 리소스 정리를 강조하려는 의도였고, 실제로 정리는 중요합니다.
그런데 틀렸습니다. 마스킹이 파일에 쓰기 전에 일어납니다. 임시 파일에 들어가는 건 123456******3456 같은 마스킹된 값이고, 평문은 JVM 힙에만 잠깐 존재합니다.
노출 범위를 실제보다 크게 말하고 있었습니다. 정리가 필요하다는 결론은 같지만, 근거가 과장돼 있었습니다.
"이 훅을 쓰는 화면은 5개"
공용 훅을 고치면서 영향받는 화면을 5개로 셌습니다. 리뷰가 6개라고 정정했습니다. 다른 작업자가 같은 기간에 추가한 화면 하나를 제가 못 본 겁니다.
전부 기존 경로를 타서 영향은 없었지만, 회귀 확인 목록에서 하나가 빠질 뻔했습니다.
"이 Provider 는 죽은 코드다"
새 경로로 갈아타면서 옛 Provider 가 도달 불가능해졌습니다. 그냥 두려 했습니다.
최종 리뷰가 이걸 Important 로 올렸습니다. 이유가 제 생각보다 날카로웠습니다.
프론트는 이제 엑셀 생성 콜백을 넘기지 않습니다. 그런데 훅은 그 콜백을 optional 로 호출하고 성공 토스트는 무조건 띄우고 있었습니다. 만약 어떤 이유로 옛 경로가 되살아나면 — 백엔드만 롤백된다든지 — 사용자는 "다운로드가 완료되었습니다"를 보고 파일은 받지 못합니다.
죽은 코드가 아니라 조용한 실패 경로였습니다. 지웠고, 훅에는 가드를 넣었습니다.
마치며 — 무엇이 검증됐고 무엇이 안 됐나
정직하게 적습니다.
검증된 것: 없습니다. 컴파일도 안 해봤습니다.
리뷰가 잡은 것: 컴파일 오류 1건, 정산 이중계상 1건, 백그라운드 스레드 무한 루프 1건(청크 크기가 0이면 subList 가 빈 결과를 주고 인덱스가 안 늘어납니다), 리소스 계약 누락 1건, 조용한 실패 경로 1건.
여전히 남은 것: 응답에 Content-Length 가 없어 스트리밍 중 끊기면 잘린 파일이 성공으로 보입니다. 워커 스레드 4개를 7종류의 다운로드가 나눠 쓰는데, 대용량 export 가 그중 하나를 몇 분씩 붙듭니다. 조회 전용 저장소 분리는 여전히 없습니다.
뒤의 둘은 일부러 고치지 않았습니다. 측정 없이 스레드 풀을 건드리는 게 문제보다 위험하다고 판단했고, Content-Length 는 포트 시그니처를 바꿔야 해서 검증 못 하는 환경에서 손대는 비용이 이득보다 컸습니다. 둘 다 근거와 함께 문서에 남겼습니다.
돌아보면 이번 작업에서 가장 값진 판단은 코드가 아니라 리뷰 요청 문장이었습니다.
"코드 품질을 봐주세요"라고 했으면 스타일 지적이 왔을 겁니다. "당신이 컴파일러입니다, 산술을 손으로 계산하세요, 테스트가 통과할지 하나씩 트레이스하고 실패할 것의 이름을 대세요"라고 했더니 소스 jar 을 뜯어서 checked exception 을 찾아왔습니다.
검증 수단이 없을 때 할 수 있는 건, 검증을 대신할 사람에게 무엇을 어떻게 봐야 하는지 정확히 알려주는 것뿐이었습니다.
그리고 그렇게 해도 여전히 컴파일러가 아닙니다. 개발망에서 ./gradlew test 를 돌리기 전까지, 이 코드에 대해 제가 할 수 있는 가장 정확한 말은 "아직 모른다" 입니다.