Go 백엔드에서 스트리밍 및 임시 파일을 사용한 대용량 파일 업로드 처리
Grace Collins
Solutions Engineer · Leapcell

소개
웹 애플리케이션의 진화하는 환경에서 파일 업로드를 처리하는 것은 일반적인 요구 사항입니다. 작은 이미지나 문서를 업로드하는 것은 보통 간단하지만, 비디오 아카이브, 대규모 데이터셋 또는 소프트웨어 패키지와 같은 기가바이트 단위의 파일을 다룰 때는 그 어려움이 급격히 커집니다. 비효율적인 접근 방식은 애플리케이션 병목 현상, 메모리 고갈 또는 서비스 충돌을 초래하여 사용자 경험과 시스템 안정성을 크게 저하시킬 수 있습니다. 이 글은 Go 백엔드에서 대용량 파일 업로드를 관리하기 위한 강력한 전략을 탐구하며, 주로 두 가지 강력한 기술인 스트리밍과 임시 파일 저장에 중점을 둡니다. 이러한 방법을 활용함으로써 개발자는 성능이나 안정성에 타협하지 않고 가장 큰 파일조차 처리할 수 있는 확장 가능하고 탄력적인 서비스를 구축할 수 있습니다.
효율적인 파일 처리를 위한 핵심 개념
구현 세부 사항으로 들어가기 전에, Go에서 효율적인 대용량 파일 업로드 처리를 뒷받침하는 핵심 개념에 대한 기본적인 이해를 확립해 보겠습니다.
- Multipart/form-data: 이는 파일 및 기타 폼 데이터를 서버로 보내는 표준 인코딩 유형입니다. 경계 문자열로 구분되는 여러 유형의 데이터(텍스트 필드, 파일)를 단일 요청으로 보낼 수 있습니다.
- 스트리밍: 전체 파일을 처리하기 전에 메모리에 로드하는 대신, 스트리밍은 데이터가 도착하는 대로 작은 청크로 읽고 처리합니다. 이는 대용량 파일에서 메모리 고갈을 방지하고 지연 시간을 줄이는 데 중요합니다.
- 임시 파일: 들어오는 파일 데이터를 스트리밍되는 대로 서버 디스크의 임시 파일에 직접 저장하는 것은 효과적인 전략입니다. 이는 메모리 부담을 디스크로 오프로드하고 애플리케이션이 (적절한 복구 메커니즘을 통해) 다시 시작하거나 충돌하더라도 탄력적인 처리를 가능하게 합니다. 임시 파일은 일반적으로 처리 후 자동으로 또는 수동으로 정리됩니다.
io.Reader및io.Writer인터페이스: Go의 표준 라이브러리는 I/O 작업을 위한 강력하고 유연한 인터페이스를 제공합니다.io.Reader는 읽을 수 있는 모든 것을 나타내고,io.Writer는 쓸 수 있는 모든 것을 나타냅니다. 이들은 스트리밍 작업의 기본입니다.http.Request.ParseMultipartFormvs.http.Request.MultipartReader:ParseMultipartForm(maxMemory int64): 이 함수는 전체 멀티파트 요청 본문을 파싱하여maxMemory바이트는 메모리에, 나머지는 디스크에 버퍼링합니다. 작은 파일에는 편리하지만 여전히 상당한 메모리를 소비할 수 있으며, 무언가를 메모리에 로드하려고 시도하기 때문에 매우 큰 파일에는 이상적이지 않습니다.MultipartReader(): 이 메소드는 멀티파트 요청을 수동으로 스트리밍 파싱할 수 있는multipart.Reader를 반환합니다. 이는 미세한 제어를 제공하고 불필요한 메모리 로드를 방지하므로 효율적인 대용량 파일 처리에 선호되는 방법입니다.
스트리밍 및 임시 파일을 사용한 대용량 파일 업로드 구현
대용량 파일을 처리하는 핵심 원칙은 전체 파일을 메모리에 유지하지 않는 것입니다. 대신, 들어오는 데이터를 서버 디스크의 임시 파일로 직접 스트리밍합니다.
단계별 구현
실용적인 Go 예제를 통해 이를 설명해 보겠습니다.
1. 서버 설정
먼저 기본적인 Go HTTP 서버가 필요합니다.
package main import ( "fmt" "io" "log" "mime/multipart" "net/http" "os" "path/filepath" "time" ) const maxUploadSize = 10 * 1024 * 1024 * 1024 // 10 GB func uploadHandler(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // 악의적인 공격 또는 우발적인 대용량 파일 업로드를 방지하기 위해 요청 본문 크기 제한 r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) // 요청이 multipart/form-data인지 확인 if err := r.ParseMultipartForm(0); err != nil { // MultipartReader가 작동하도록 0으로 파싱 if err == http.ErrNotMultipart { http.Error(w, "Expected multipart/form-data", http.StatusBadRequest) return } if err.Error() == "http: request body too large" { http.Error(w, "File is too large. Max size is 10GB.", http.StatusRequestEntityTooLarge) return } http.Error(w, fmt.Sprintf("Error parsing multipart form: %v", err), http.StatusInternalServerError) return } // 멀티파트 리더 가져오기 mr, err := r.MultipartReader() if err != nil { http.Error(w, fmt.Sprintf("Error getting multipart reader: %v", err), http.StatusInternalServerError) return } for { part, err := mr.NextPart() if err == io.EOF { break // 모든 파트 읽음 } if err != nil { http.Error(w, fmt.Sprintf("Error reading next part: %v", err), http.StatusInternalServerError) return } // Content-Disposition에 따라 파일 파트인지 확인 if part.FileName() != "" { err = saveUploadedFile(part) if err != nil { http.Error(w, fmt.Sprintf("Error saving file: %v", err), http.StatusInternalServerError) return } } else { // 다른 폼 필드 처리 (예: 텍스트 입력) fieldName := part.FormName() fieldValue, _ := io.ReadAll(part) log.Printf("Received form field: %s = %s\n", fieldName, string(fieldValue)) } } fmt.Fprintf(w, "File upload successful!") } func saveUploadedFile(filePart *multipart.Part) error { // 고유한 임시 파일 생성 tempFile, err := os.CreateTemp("", "uploaded-*.tmp") if err != nil { return fmt.Errorf("failed to create temporary file: %w", err) } defer func() { // 처리 중 또는 오류 발생 시 임시 파일 정리. if r := recover(); r != nil { // 처리 중 패닉 처리 log.Printf("Recovered from panic, removing temporary file: %s", tempFile.Name()) _ = os.Remove(tempFile.Name()) panic(r) // 정리 후 재 패닉 } if err != nil { // 오류가 발생한 경우 정리 확인 log.Printf("Error occurred, removing temporary file: %s", tempFile.Name()) _ = os.Remove(tempFile.Name()) } // 모든 것이 성공적으로 완료되고 처리가 완료되면 여기에 파일이 삭제되지 않습니다. // 일반적으로 처리 후 최종 목적지로 이동하거나 처리됩니다. // 데모 목적으로 잠시 보관한 후 삭제하겠습니다. // 실제 앱에서는 이 defer os.Remove 전에 tempFile을 final destination으로 이동할 것입니다. // 현재는 경로만 기록하겠습니다. // 이 예제에서는 처리를 시뮬레이션한 후 제거합니다. // 실제 앱에서는 이 tempFile을 최종 대상으로 이동합니다. // 지금은 즉시 삭제합니다. // 실제 앱에서는 이 tempFile을 최종 대상으로 이동합니다. // 데모 목적으로는 즉시 삭제하겠습니다. _ = os.Remove(tempFile.Name()) }() // 파일 내용을 임시 파일로 스트리밍 bytesWritten, err := io.Copy(tempFile, filePart) if err != nil { _ = tempFile.Close() // 오류 반환 전에 리소스 누수 방지를 위해 닫기 _ = os.Remove(tempFile.Name()) // 복사 오류 시 명시적으로 제거 return fmt.Errorf("failed to write file content to temporary file: %w", err) } log.Printf("Successfully saved file '%s' (%d bytes) to temporary file: %s\n", filePart.FileName(), bytesWritten, tempFile.Name()) _ = tempFile.Close() // 쓰기 후 임시 파일 닫기 // 이 시점에서 파일은 디스크에 있습니다. 메모리 제약 없이 처리, 이동 또는 다른 작업을 수행할 수 있습니다. // 예: // finalPath := filepath.Join("uploads", filePart.FileName()) // err = os.Rename(tempFile.Name(), finalPath) // if err != nil { // return fmt.Errorf("failed to move temporary file to final destination: %w", err) // } // log.Printf("File moved to: %s", finalPath) return nil } func main() { http.HandleFunc("/upload", uploadHandler) fmt.Println("Server listening on :8080") log.Fatal(http.ListenAndServe(":8080", nil)) }
2. 클라이언트 측 ( curl 사용 예제)
먼저 큰 더미 파일을 만든 다음 curl로 테스트할 수 있습니다.
# 1GB 더미 파일 생성 (macOS/Linux) dd if=/dev/zero of=large_file.bin bs=1G count=1
그런 다음 업로드합니다.
curl -X POST -H "Content-Type: multipart/form-data" -F "document=@large_file.bin" -F "description=A very large file" http://localhost:8080/upload
uploadHandler 설명
- 메서드 확인:
POST요청만 처리하도록 합니다. http.MaxBytesReader: 이것은 중요한 보안 및 리소스 관리 조치입니다.r.Body를 래핑하여 요청 본문의 총 크기를 제한합니다. 클라이언트가maxUploadSize보다 더 많은 데이터를 보내면 연결이 즉시 닫히고http.StatusRequestEntityTooLarge가 전송됩니다.r.ParseMultipartForm(0): 이 함수를 주로 호출하여Content-Type헤더 파싱을 트리거하고multipart/form-data인지 확인합니다.0을 전달함으로써 폼 값에 대해 메모리에 어떤 부분도 버퍼링되지 않도록 명시적으로 지정합니다. 파일 부분은 별도로 처리됩니다.r.MultipartReader(): 스트리밍 마법이 시작되는 곳입니다.*multipart.Reader를 반환하며, 이를 사용하여 멀티파트 요청의 각 부분을 하나씩 반복할 수 있습니다.- 파트를 통한 반복:
for루프는io.EOF가 반환될 때까지(io.EOF는 요청의 끝을 나타냄)mr.NextPart()를 계속 호출합니다. part.FileName(): 파일 부분(파일명이 있는 경우)과 일반 폼 필드를 구별하는 데 도움이 됩니다.saveUploadedFile(part): 이 함수는 단일 파일 부분을 저장하는 논리를 캡슐화합니다.
saveUploadedFile 함수 설명
os.CreateTemp("", "uploaded-*.tmp"): 이것이 임시 파일 전략의 핵심입니다.os.CreateTemp는 시스템의 기본 임시 디렉토리(또는 지정된 디렉토리)에 새롭고 고유한 임시 파일을 생성하고*os.File핸들을 반환합니다.uploaded-*.tmp패턴은 예측 가능한 명명 규칙을 보장합니다.- 정리용
defer문:defer호출os.Remove(tempFile.Name())은 필수적입니다.saveUploadedFile이 종료될 때 오류가 발생했는지 여부에 관계없이 임시 파일이 삭제되도록 보장합니다. 실제 시나리오에서는 업로드가 성공하면 함수가 종료되기 전에 이 임시 파일을 최종 목적지로 이동하여 성공적으로 업로드된 파일의 삭제를 방지하게 됩니다. 예제에서는 데모의 단순성을 위해 현재 삭제합니다.defer에는recover()블록도 포함됩니다. 이는 파일 처리 중에 패닉이 발생하더라도 임시 파일이 여전히 정리되도록 보장하는 강력한 사례입니다.
io.Copy(tempFile, filePart): 스트리밍이 발생하는 곳입니다. 전체 파일을 메모리에 로드하지 않고 들어오는filePart(io.Reader)에서tempFile(io.Writer)로 데이터를 청크로 효율적으로 복사합니다. 복사된 바이트 수와 발생한 오류를 반환합니다.- 로깅 및 오류 처리: 포괄적인 로깅은 디버깅에 도움이 되며, 강력한 오류 처리는 애플리케이션이 예측 가능하게 작동하도록 합니다.
애플리케이션 시나리오
이 스트리밍 및 임시 파일 접근 방식은 다음과 같은 경우에 이상적입니다.
- 비디오 호스팅 플랫폼: 처리할 대용량 비디오 파일 업로드.
- 클라우드 스토리지 서비스: 기가바이트 단위의 아카이브 또는 백업 저장.
- 데이터 수집 시스템: 분석을 위해 대규모 데이터셋 수신.
- 소프트웨어 배포: 사용자가 대규모 애플리케이션 패키지를 업로드하도록 허용.
- 메모리 고갈 없이 고처리량 파일 전송이 필요한 모든 서비스.
결론
Go에서 대용량 파일 업로드를 처리하려면 리소스 효율성을 우선시하는 신중한 접근 방식이 필요합니다. io.Copy를 사용한 스트리밍과 디스크의 임시 파일을 활용함으로써 메모리 고갈과 같은 일반적인 함정을 피하고 애플리케이션이 부하가 높을 때도 응답성과 안정성을 유지하도록 보장할 수 있습니다. 이 방법은 기가바이트 단위의 파일 전송조차 관리하기 위한 확장 가능하고 탄력적이며 성능이 뛰어난 솔루션을 제공합니다. 효과적인 대용량 파일 업로드 전략은 강력한 클라우드 네이티브 애플리케이션을 구축하는 데 필수적입니다.

