Notice
Recent Posts
Recent Comments
Link
«   2026/01   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

DevYGwan

안전하고 효율적인 대용량 CSV 추출 아키텍처 본문

Study/DEVELOP

안전하고 효율적인 대용량 CSV 추출 아키텍처

YGwan 2025. 9. 10. 00:56

 저희는 실시간 공장 기기들의 데이터를 취급하고 있습니다. 데이터는 1초, 10초 등 특정 주기로 한번씩 올라옵니다. 기기 한개를 기준으로 1초에 한번씩 데이터들이 올라온다고 가정하면, 하루동안 24시간 × 60분 × 60초 = 86,400 개의 데이터가 올라옵니다. 물론 사용자는 기기 한개가 아니라 기기 여러개를 관리하고 있을 것이고, 1초, 5초, 10초 등등 특정 주기로 올라오는 데이터가 많아지면 이 데이터는 기하급수적으로 늘어날 것입니다.

 저희는 이러한 대용량 데이터를 관리하고 있는데, 이번에 구현해야 할 기능은 특정 범위에 해당하는 데이터를 추출하는 기능입니다. 추출은 csv로 추출합니다. 원래는 csv을 추출할 때, csv을 생성하는 라이브러리(OpenCSV 등)을 써서 단순히 데이터를 조회하고 csv로 변환해서 반환하는 것으로 로직을 구현합니다. 근데, 이번에는 대용량 데이터를 csv로 추출하는 경우도 존재하기 때문에 이를 어떻게 처리할지에 대해 고민했고, 여러 방법을 정리해 최종적으로 가장 안전하고 효율적인 방법을 찾아 처리했습니다. 그 방법은,

 

임시 파일에 데이터를 스트리밍 방식으로 csv 파일로 변환해 저장하고,
저장한 파일을 S3에 업로드해 반환 값을 저장하는 방식

 

입니다. 제가 고려한 여러 방법에 대한 장단점과 최종적으로 제가 왜 이러한 방법을 사용해 csv 변환 및 저장을 처리했는지 설명드리도록 하겠습니다.

 


 

1.  In-Memory 버퍼 방식

  • 데이터를 메모리 버퍼(ByteArrayOutputStream)에 전부 작성한 뒤 그 바이트 배열(byte[])을 반환하는 방식
  • 파일을 디스크에 저장하지 않고 메모리에 담았다가 한번에 응답하는 방식으로 구현

 

public ResponseEntity<byte[]> exportCsv() {
	// 1. ByteArrayOutputStream 생성 (메모리 버퍼 준비)
	// 2. OutputStreamWriter + CSVWriter를 연결
	// 3. 데이터 작성
	// 4. flush() 후 toByteArray()로 메모리에서 byte[] 배열 추출
}

 이렇게 in memory 버퍼 방식으로 구현하면 디스크에 데이터를 쓰지 않기 때문에 디스크 정리를 할 필요가 없고 메모리를 사용하기 때문에 구현이 단순하고 빠릅니다.

 하지만, 문서에 쓸 데이터의 크기 만큼 힙 메모리를 차지합니다. 즉, 데이터의 양과 비례하게 메모리 사용량이 증가합니다. 이 말은 결국 데이터가 많아지면, OOM이 발생할 가능성이 높다는 말이 됩니다.

 실제로 이러한 방식을 사용하여 동시에 여러 대용량 데이터 처리 관련된 요청을 보낸 후 모니터링 한 결과, 실제로 대용량 데이터 csv 추출 로직 전에는 heap memory가 전체 heap 메모리 중 약 1.7% 사용 중이였던 것에 반해, csv 추출이 끝난 시점에는 23.8%을 사용한 것을 확인할 수 있었습니다. 물론 csv 추출된 후 GC가 동작해 자동으로 heap 메모리를 정리해 다시 안정적인 상태로 돌아갔지만 이러한 csv 추출을 동시에 여러번, 더 큰 용량의 데이터를 추출한다면, OOM 에러가 발생할 것입니다.

 

 간단하게 장단점을 설명드리자면,

  • 장점
    • 구현이 쉽다.
    • 디스크를 사용하지 않기 때문에 임시 파일 & 디스크를 정리할 필요가 없다.
    • byte[] 크기를 알 수 있어서 Content-Length 헤더 설정을 통해 클라이언트 진행률 표시나 캐싱 처리에 용이하다.

 

  • 단점
    • 문서 크기만큼 힙 메로리를 사용하기 때문에 OOM 발생 위험이 있다.
    • 동시 요청이 많으면 요청이 끝나면 GC를 통해 힙 메모리를 정리하는 과정을 거치기 때문에 GC 압박이 크다.

 

 따라서 이렇게 이 방식은 저희 같이 대용량 데이터를 처리할 때는 전체 데이터를 한번에 조회해 메모리에 저장 후 csv로 변환 및 처리하기 때문에 메모리 위험이 커 사용하기 힘들다고 판단했습니다. 그래서 이렇게 메모리를 사용하는 방식이 아닌 다른 방식을 고려해야 했습니다.

 

2.  Streaming Direct Download 방식

  • 대량 데이터를 CSV로 내보내되, 메모리에 다 올리지 않고 스트리밍으로 바로 응답하는 방식
  • 데이터를 flush()를 호출하기 전까진 버퍼(메모리)에서 관리하다가 flush가 호출될 때 HTTP 응답의 chunk가 생성돼 응답하는 방식

 

public ResponseEntity<StreamingResponseBody> exportCsvStream() {
        // 1. StreamingResponseBody 생성 (OutputStream 직접 전달)
        // 2. 헤더 작성
        // 3. 데이터 페이지 단위/루프 단위로 바로바로 작성
        // 4. 네트워크 효율을 위해 일정 간격마다 flush
        // 5. ResponseEntity<StreamingResponseBody> 로 반환
}

 이렇게 구현하면, 데이터 양의 따라 Memory 사용량이 증가하는 것이 아닌 flush 간격에 따른 데이터 chunk 단위만큼만 메모리를 사용하기 때문에(버퍼에 데이터를 저장하므로) OOM 발생 위험을 대폭 줄일 수 있습니다. 따라서 메모리 사용량이 거의 없습니다. 하지만 여러 단위(청크) 만큼 데이터를 보내기 때문에 동일한 데이터를 보내더라도 네트워크에서 더 많은 작은 패킷으로 쪼개져서 전송됩니다. 그래서 네트워크 오버헤드가 발생할 수 있습니다. 따라서 chunk를 관리하는 flush의 주기를 효율적으로 관리해야 됩니다.

 실제로 이러한 방식을 사용하여 동시에 여러 대용량 데이터 처리 관련된 요청을 보낸 후 모니터링 한 결과, 실제로 대용량 데이터 csv 추출 로직 전과 후에 메모리 사용량의 차이가 거의 나타나지 않았습니다. chunk 크기 만큼 데이터를 메모리(버퍼)에 저장하기 때문에 heap 메모리 사용은 증가했지만 이전과 다르게 확연히 작은 양의 메모리 사용을 볼 수 있었습니다.

간단하게 장단점을 설명드리자면,

  • 장점
    • 메모리 사용 최소화 → 초대형 CSV/PDF에도 안전하다.
    • 서버가 쓰는 즉시 클라이언트가 다운로드 → 총 소요 시간 단축이 단축된다.
    • 백프레셔(backpressure): 네트워크가 느리면 생성도 자연스럽게 늦어져 안정적으로 메모리 관리가 가능하고 꾸준히 데이터를 받기 때문에 진행률 확인에 용이하다.

 

  • 단점
    • chunk 단위로 데이터를 전송하기 때문에 파일 전체 크기(Content-length)을 알 수 없다.
    • 데이터 전송 중간에 에러가 나면 온전하지 않은 데이터가 남는다.
    • 네트워크 이동이 잦다. (chunk 단위로 데이터를 스트리밍하여 전송하기 때문에)
    • streaming 처리를 위한 코드가 약간 복잡하다.

 

 따라서 이 방식은 메모리 사용을 안정적으로 처리할 수 있어 효율적으로 데이터 csv 추출 기능을 활용할 수 있다고 생각했습니다. 그래서 이 방법을 써도 됐습니다. 하지만 chunk 단위로 네트워크를 통해 데이터를 전송하기 때문에 flush 주기를 효율적으로 관리해야 된다. 라는 한계를 좀 더 잘 풀면 더 간편하게 안정적으로 서비스를 운영할 수 있겠다 라는 생각이 들었습니다. 또한 저희는 onprem 환경의 제한적인 네트워크 상황에서 되도록이면 네트워크의 Bandwidth을 생각하지 않았으면 좋겠다는 생각이 들었습니다.

 

3. Streaming + S3 Offload 방식

 이 방법은 2번과 동일한 방식을 통해 스트리밍으로 csv 파일을 생성합니다. 하지만 2번과 다른점은 2번은 이렇게 스트리밍 방식으로 데이터를 처리하돼, 네트워크를 타는게 아닌 서버 내의 tmp파일에 csv 파일을 생성하고 최종적으로 파일이 다 만들어지면, 이 파일을 사용자에게 전송하는 형식으로 로직을 처리하는 방식입니다. 따라서 메모리 사용량은 위의 Data Streaming 방식과 크게 차이가 나진 않았습니다.

 

public ResponseEntity<String> exportCsvS3() {
	// 1. 서버 로컬 tmp 파일 생성 (임시 CSV 저장소 준비)
	// 2. DB에서 데이터 페이지 단위로 조회
	// 3. tmp 파일에 CSVWriter로 데이터 작성 (페이지/배치 단위)
	// 4. 파일 작성이 끝나면 flush & close
	// 5. 완료된 tmp 파일을 S3에 업로드
	// 6. tmp 파일 정리
	// 7. S3 객체에 대한 Presigned URL 생성
	// 8. ResponseEntity<String> 으로 Presigned URL 반환
}

 이렇게되면, 사용자에게 실시간으로 데이터를 주거나 진행률 확인을 하게 할 순 없지만, 네트워크 이동은 한번만 이루어지고 이를 만약 S3에 업로드 해(Onprem 환경에서는 Minio 사용) presigned url로 사용자에게 전달한다면, 더 가벼운 데이터로 사용자에게 전달할 수 있습니다. 

 물론, 임시 파일을 생성하기 때문에 이후에 이 파일을 삭제하거나, S3나 Minio에 업로드 시에 이만큼의 오버헤드가 발생한다는 단점은 존재합니다. 하지만 사용자가 재다운로드 하거나 할 때 효율적으로 관리할 수 있을 것이라고 생각해 이 방법을 선택했습니다.

 

간단하게 장단점을 설명드리자면,

  • 장점
    • 클라이언트가 바로 S3에서 다운로드 → 서버 부하가 감소한다. (네트워크 offload)
    • S3로 생성된 파일이 관리되기 때문에 파일 재사용 가능하다. (동일 요청 시 바로 기존 URL 제공)
    • 최종적으로 만들어진 하나의 파일 url을 주기 때문에 네트워크 패킷이 가볍고, 네트워크 오버헤드가 발생하지 않는다.

 

  • 단점
    • csv 생성 시 사용한 tmp 파일 처리가 필요하다.
    • s3 & minio에 업로드하는 오버헤드가 발생한다.
    • 구현이 3가지 방법 중 가장 복잡하다.

 

 따라서 저희는 csv 생성 시 그렇게 엄청 오래 걸리지도 않기 때문에 진행률이나 Conent-Length가 필수적이지 않으며, 한번의 네트워크 간의 통신을 통해 완성된 데이터 csv파일을 받을 수 있는 3번째 방법(데이터 스트리밍 & tmp 파일 생성 후 S3 업로드) 을 사용하기로 결정했습니다. 또한, 이미 S3 & Minio 환경이 갖춰져 있는 상태에서 사용자 재 다운로드 같은 use case도 커버할 수 있어 더 효율적이라고 생각했습니다.

 


그럼, 지금까지 소개한 방식들의 플로우 및 장단점을 다시한번 정리하고 어떤 상황에서 이러한 방식이 적합한지 제가 생각한 기준을 표를 통해 공유드리도록 하겠습니다.

 

※ 장단점 비교

구분 플로우 장점 단점
 In-Memory 방식 1. DB 조회
2. 전체 CSV 메모리에 생성
3. 완료 후 한번에 응답
- 구현 단순
- Content-Length 제공
- 다운로드 진행률 등 제공


- 대용량 시 OOM 위험
Data Streaming 방식 1. DB 조회
2. CSV 행/페이지 단위로 작성
3. flush 시마다 네트워크로 전송 (chunked)
- 메모리 사용 최소화
- 대규모 데이터에 안전
- 클라이언트가 즉시 다운로드 시작
- Content-Length 모름
- 네트워크 중단 시 불완성한 파일 전송
- flush 전략에 따라 패킷/효율 차이 발생
- 구현 복잡성 증가
Streaming + S3 방식 1. DB 조회
2. 서버 tmp 파일에 스트리밍 방식으로 CSV 생성
3. 완료 후 S3 업로드
4. Presigned URL 반환
- 서버 부하 최소화
- 재시작 및 파일 재사용 가능
- 클라이언트가 안정적으로 데이터 다운

- tmp 파일 관리 필요
- s3 & minio 등 업로드 비용 발생
- 구현 복잡성 증가

 

 따라서 이러한 장단점을 통해 간단하게 어떤 상황에서 사용할지 좋은지 정리하자면,

  • In-Memory 방식
    • 소규모 데이터(수천 행, 수 MB 이하) 추출 시 용이
    • 빠른 프로토타입/테스트 시 용이
    • 작고 단순할 때는 베스트이다. (메모리 = 파일 크기, 구현 단순)

 

  • Data Streaming 방식
    • 대량 데이터(수십만~수백만 행) 추출 시 용이
    • 실시간 다운로드 UX 필요할 때 용이 (진행률 제공, 바로 다운로드 가능)

 

  • Streaming + tmp & S3 방식
    • 초대용량 데이터(GB급)
    • 네트워크 불안정 환경에 용이 (한번만 네트워크 이동으로 전송 가능)
    • 여러 사용자 반복 다운로드 필요할 때 용이 (S3와 같은 별도의 저장소에서 관리)
    • 안정성과 재사용성 최강이지만 대신 즉시성↓, 구현 복잡도↑.

 


정리

 확실히 구현 방법은 여러가지고 내 상황을 맞는 최적의 구현 방법을 선택하는 것이 중요한 것 같습니다. 모든 방법마다 장단점이 존재하는데 내 상황에서 가장 고려해야 할 점이 무엇인지 찾고, 그 점을 잘 커버할 수 있는 방법을 찾는게 능력인 것 같습니다... 그러기 위해선 더 많은 케이스를 경험하고 문제가 될 수 있는 상황을 가정하여 성능 테스트를 해보는 것이 중요한 것 같습니다.

 따라서 저의 케이스에서 가장 중요한 것은 대용량 데이터를 커버할 수 있는 방법 찾기였고 그걸 커버할 수 있는 방법은 위에서 소개한 방법 중 Streaming Direct Download 방식과 Streaming + S3 Offload 방식이였습니다. 그 중 제한된 네트워크 대역폭에서도 효율적으로 처리가 가능하고 재사용 및 csv 관리가 가능한 방식인 후자의 방식을 선택했습니다.