다운로드는 끝났는데 ZIP은 열리지 않았습니다.

BackOffice CSV export는 5만 건을 넘기면 성공처럼 보였지만, 사용자가 받은 결과물은 손상된 ZIP이 되는 경우가 있었습니다. 서버는 응답을 끝냈고 브라우저도 다운로드를 마친 것처럼 보였지만, 파일은 마지막까지 완성되지 못했습니다. 사용자는 다시 시도했고, 운영자는 timeout인지 파일 손상인지부터 다시 확인해야 했습니다.

겉으로 보면 단순한 파일 손상처럼 보였지만, 실제로는 long-running 응답과 대용량 조회가 서로를 악화시키는 구조적 문제였습니다. 이번 작업의 목표는 분명했습니다. timeout만 늘려서 버티는 대신, 프론트엔드 계약을 바꾸지 않고 30만 건까지 무손상으로 다운로드되게 만드는 것이었습니다.

문제가 드러난 위치보다, 더 중요한 건 문제의 범위였습니다

문제가 보이기 시작한 위치는 BackOfficeExportResponseHelper.createExportResponse()였습니다. 이 helper는 StreamingResponseBody를 반환하고, Spring은 이 작업을 async task 스레드에서 처리하고 있었습니다.

중요한 건 이 경로가 한 endpoint만의 예외적인 실수는 아니었다는 점입니다. 같은 helper와 같은 응답 패턴이 여러 export endpoint에서 재사용되고 있었습니다. 그래서 한 번의 손상 ZIP은 우연한 회귀라기보다, 공통 다운로드 경로에 구조적 결함이 있다는 신호에 가까웠습니다.

작은 데이터에서는 큰 문제가 아니었습니다. 하지만 export처럼 오래 열려 있는 응답에서는 애플리케이션 로직보다 응답 수명주기가 먼저 한계에 닿습니다. 이때 확인해야 하는 것은 세 가지입니다.

  • 응답이 마지막까지 살아 있는가
  • 뒤 페이지로 갈수록 조회 비용이 얼마나 커지는가
  • 파일 포맷이 요구하는 종료 지점까지 실제로 도달하는가

이번 경로는 세 조건이 동시에 무너지고 있었습니다.

직접 원인 하나와, 그 원인을 앞당긴 요인 둘이 겹쳐 있었습니다

직접 원인은 명확했습니다. ZIP은 마지막에 Central Directory를 기록해야 완전한 파일이 됩니다. 그런데 async timeout이 먼저 발생하면 응답은 끝난 것처럼 보여도 이 마무리 단계까지 도달하지 못합니다. 사용자는 다운로드를 받았지만, 실제 파일은 복구 불가능한 ZIP으로 남습니다.

여기에 직접 원인을 더 빨리 터뜨린 가속 요인이 둘 있었습니다.

  • offset paging은 뒤 페이지로 갈수록 앞의 데이터를 반복해서 스캔하고 버리게 만듭니다.
  • 조직, 파트너, 트라이얼 lookup처럼 페이지마다 반복되는 보조 조회는 응답 시간을 더 불안정하게 만듭니다.

즉, 증상은 ZIP 손상이었지만, 그 뒤에서는 응답이 먼저 끝나는 문제조회가 점점 비싸지는 문제가 같은 경로 안에서 서로를 밀어 올리고 있었습니다. 파일 포맷 하나를 이해하는 것만으로는 부족했고, response lifecycle과 query pattern을 함께 봐야 했습니다.

timeout을 늘리는 건 해법이 아니었습니다

이 시점에서 가장 먼저 버린 선택은 timeout 증설이었습니다. timeout을 늘리면 실패 시점은 늦출 수 있어도, 응답이 helper 밖으로 빠져나가 있는 구조와 뒤 페이지로 갈수록 느려지는 조회 패턴은 그대로 남기 때문입니다. 이번 목표가 프론트엔드 계약을 유지한 채 같은 다운로드 경로를 안정화하는 것이었기 때문에, 단순히 기다리는 시간만 늘리는 선택은 문제를 미루는 데 가깝다고 봤습니다.

다른 대안도 검토 대상이었습니다.

대안장점한계이번 선택
timeout 증설변경 범위가 가장 작습니다.직접 원인과 가속 요인을 그대로 둔 채 실패 시점만 뒤로 미룹니다.제외
비동기 배치 export오래 걸리는 작업을 요청-응답 경로 밖으로 분리할 수 있습니다.즉시 다운로드 UX와 FE 계약을 바꿔야 하고 운영 구조가 커집니다.제외
현재 다운로드 경로 유지 + 응답/조회 구조 수정사용자 경험을 바꾸지 않고 구조적 원인을 제거할 수 있습니다.servlet thread를 더 오래 직접 관리해야 합니다.채택

결국 이번 작업은 “가장 근본적인 해법이 무엇인가”보다 “현재 경로를 유지한 채, 가장 비싼 실패를 없애려면 무엇을 바꿔야 하는가”에 더 가까웠습니다.

수정은 세 갈래로 묶었습니다

1. async 응답을 없애고, 응답 종료를 애플리케이션이 직접 책임지게 했습니다

StreamingResponseBody를 제거하고 HttpServletResponse에 직접 write하도록 바꿨습니다. 이렇게 하면 Tomcat async timeout이 개입하던 경로를 없애고, ZIP의 마지막 마무리 단계까지 같은 요청 안에서 제어할 수 있습니다.

여기서 핵심은 sync가 더 빠르다는 뜻이 아닙니다. 누가 응답의 생명주기를 책임지는지를 다시 명확하게 만든다는 뜻에 가깝습니다. 오래 걸리는 다운로드에서는 throughput 최적화보다도, 파일 포맷이 요구하는 종료 지점까지 안정적으로 도달하는 편이 더 중요할 때가 있습니다.

2. offset 대신 cursor paging으로 바꿨습니다

credit history export는 (created_at, ledger_id) 복합 cursor 기준으로 다시 조회하게 만들었습니다. export처럼 끝까지 순회하는 작업에서는 다음 페이지를 찾는 편의보다 마지막 페이지까지 비슷한 비용으로 갈 수 있는지가 더 중요했습니다.

offset paging은 관리 화면처럼 얕은 탐색에서는 충분히 단순하고 유용합니다. 하지만 export처럼 끝까지 순회해야 하는 경로에서는 앞의 데이터를 계속 스캔하고 버리는 비용이 결국 뒤쪽 페이지에서 크게 드러납니다. cursor paging이 항상 정답은 아니지만, 적어도 이번 경로에서는 조회 비용을 선형에 가깝게 유지하는 편이 더 중요했습니다.

3. lookup을 export 단위로 재사용하게 만들었습니다

조직, 파트너, 트라이얼 관련 lookup은 export 전체에서 재사용할 수 있도록 Caffeine LRU 캐시를 적용했습니다. organizationName 필터도 export 시작 시점에 한 번만 계산해서 넘기도록 바꿨습니다. 대용량 export에서는 메인 쿼리보다 이런 보조 조회가 전체 시간을 더 불안정하게 만드는 경우가 많았습니다.

이 지점은 의외로 자주 놓칩니다. 메인 조회를 한 번 줄이는 것보다, 페이지마다 따라붙는 작은 lookup 여러 개를 없애는 편이 전체 export 시간을 더 크게 바꾸는 경우가 많기 때문입니다.

성능 말고도 같이 바로잡은 점이 있었습니다

이번 작업 중에는 organizationName 매칭 결과가 0건일 때 전체 ledger가 노출될 수 있는 조건 분기 버그도 발견했습니다. 대용량 export는 시간이 오래 걸리는 기능이기도 하지만, 필터 하나가 잘못되면 잘못된 데이터가 크게 내려갈 수 있는 기능이기도 합니다.

client disconnect 시 IOException을 조용히 처리하도록 바꾼 것도 같은 맥락입니다. 실제 실패와 사용자가 창을 닫은 경우를 구분하지 못하면, 운영자는 더 많은 로그를 보게 되지만 더 많은 정보를 얻지는 못합니다. 다운로드 기능에서 observability는 에러를 많이 남기는 것이 아니라, 의미 있는 실패만 남기는 쪽에 더 가깝습니다.

다만 이 부분은 본줄기보다 우선순위가 높았던 건 아닙니다. 핵심은 여전히 손상 ZIP을 없애고, 공통 export 경로를 구조적으로 안정화하는 일이었습니다.

운영 관점의 trade-off도 분명했습니다

이 구조가 모든 서비스에 그대로 맞는 건 아닙니다. 요청-응답 안에서 직접 write를 수행하는 만큼, servlet thread를 더 오래 붙잡는 비용은 분명히 존재합니다.

그래서 이 선택이 맞는 조건도 함께 봐야 했습니다.

  • 현재 서비스는 즉시 다운로드 UX를 유지해야 했습니다.
  • 프론트엔드 계약을 바꾸지 않는 편이 더 중요했습니다.
  • 지금 문제는 분 단위의 초장기 작업보다, 수십 초 안에서 손상 ZIP이 생기는 구조적 결함이었습니다.

만약 export 동시성이 훨씬 높거나, 파일 생성 시간이 수 분 이상으로 길어지는 환경이라면 비동기 job과 다운로드 링크 발급 구조가 더 맞을 수도 있습니다. 이번 선택은 “항상 sync가 맞다”가 아니라, 당시 요구사항과 실패 비용 기준에서 더 작은 변경으로 더 큰 문제를 제거한 선택이었습니다.

결과

  • 50,000건 export는 30초 timeout으로 끊기던 상태에서 8.8초 내 정상 완료로 바뀌었습니다.
  • 300,000건 export도 손상 없이 내려가도록 바뀌었습니다.
  • 영향 범위는 BackOfficeExportResponseHelper를 사용하는 7개 export 엔드포인트 전체였습니다.
  • HTTP 응답 형식은 유지되어 프론트엔드 변경은 필요하지 않았습니다.

이번 변경으로 좋아진 것은 처리 시간만이 아닙니다. 이제는 큰 파일도 끝까지 내려가고, export가 커질수록 어디서 비용이 늘어나는지 설명할 수 있게 됐습니다. 그리고 더 중요한 변화도 있었습니다. 다운로드는 성공처럼 보이는데 실제 결과물은 쓸 수 없던 상태를 없앴다는 점입니다.

정리

대용량 다운로드에서 먼저 봐야 할 것은 압축 포맷이 아니라, 응답이 끝까지 살아 있는지와 조회 비용이 뒤로 갈수록 얼마나 커지는지입니다. 이번 문제도 같은 기준으로 다시 보면서 풀렸습니다.

다운로드는 시작됐는가보다, 끝까지 정상 종료됐는가가 더 중요합니다. 이 기준을 놓치면 파일은 만들어져도 결과물은 쓸 수 없게 됩니다. 이번 작업은 export를 더 빠르게 만드는 일이라기보다, 결과물을 끝까지 믿을 수 있게 만드는 일이었습니다.