🔥 ASP.NET Core 파일 처리: FileResult 및 인터페이스
ASP.NET Core 애플리케이션에서 파일은 핵심적인 요소입니다.
클라이언트로부터 파일을 업로드 받거나(Upload), 서버에 있는 파일을 클라이언트에게 제공(Download/Serve)하는 등 다양한 시나리오에서 파일을 효율적이고 안전하게 다루는 것이 중요합니다.
파일을 클라이언트에게 전송하는 주요 FileResult 유형(FileContentResult, VirtualFileResult, PhysicalFileResult)의 차이점과
파일 업로드, 파일 시스템 접근, 환경 정보 제공 등 파일 관련하여 알아야 할 핵심 인터페이스와 개념들을 정리합니다.
1️⃣ File Download 및 Serving을 위한 FileResult 유형
ASP.NET Core MVC/API에서 컨트롤러 액션 메서드가 파일을 HTTP 응답으로 스트리밍할 때 사용하는 IActionResult의 파생 클래스들입니다.
파일 데이터를 가져오는 방식과 경로를 해석하는 방식에 따라 구분됩니다.
✅ FileContentResult
파일의 내용이 이미 byte[] 배열 형태로 메모리에 로드되어 있을 때 사용됩니다.
웹 서버는 이 바이트 배열을 그대로 클라이언트에게 스트리밍합니다.
- 정의:
Microsoft.AspNetCore.Mvc.FileContentResult - 데이터 원본:
byte[] (메모리) - 메모리 사용:
높음. 파일의 모든 내용이 메모리에 로드되므로, 큰 파일을 처리할 경우 메모리 부족 문제가 발생할 수 있습니다. - 성능:
작은 파일이나 동적으로 생성된 파일에 적합합니다. 대용량 파일에는 비효율적입니다.
주요 사용처:
- 동적으로 생성된 파일:
이미지 생성 라이브러리(예: System.Drawing.Common, ImageSharp)로 런타임에 생성된 이미지. - 데이터베이스에 저장된 파일:
파일 데이터가 BLOB(Binary Large Object) 형태로 데이터베이스에 저장되어 있으며, 이를 byte[]로 읽어와야 하는 경우. - 작은 파일:
메모리 오버헤드가 무시할 만한 수준의 작은 파일.
생성자 매개변수:
byte[] fileContents
: 전송할 파일의 바이트 배열 데이터.string contentType
: 파일의 MIME 타입 (예: “image/png”, “application/pdf”, “text/plain”).string? fileDownloadName
(선택 사항): 브라우저가 파일을 다운로드할 때 제안할 파일 이름.
이 매개변수를 지정하면 Content-Disposition 헤더가 attachment로 설정되어 브라우저가 파일을 인라인으로 표시하는 대신 다운로드하도록 유도합니다.
예시:
using Microsoft.AspNetCore.Mvc; using System.Text; public class DynamicFileController : Controller { // GET: /DynamicFile/GenerateText public IActionResult GenerateText() { var content = "이것은 동적으로 생성된 텍스트 파일입니다. " + "현재 시간: " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); byte[] fileBytes = Encoding.UTF8.GetBytes(content); return File(fileBytes, "text/plain", "generated_text.txt"); } // GET: /DynamicFile/GetImageFromDatabase public IActionResult GetImageFromDatabase() { // 실제 시나리오에서는 DB에서 byte[]를 읽어옵니다. // 여기서는 예시를 위해 더미 바이트 배열을 사용합니다. byte[] imageBytes = new byte[] { /* ... PNG 이미지 바이트 데이터 ... */ }; return File(imageBytes, "image/png", "db_image.png"); } }
✅ VirtualFileResult
웹 애플리케이션의 가상 경로(Virtual Path)를 사용하여 파일을 제공할 때 사용됩니다.
ASP.NET Core는 이 가상 경로를 실제 파일 시스템 경로로 변환하여 파일을 찾아 클라이언트에 스트리밍합니다.
- 정의:
Microsoft.AspNetCore.Mvc.VirtualFileResult - 데이터 원본:
웹 루트(wwwroot)를 기준으로 한 가상 경로 - 메모리 사용:
낮음. 파일의 내용이 메모리에 한 번에 로드되지 않고, 스트림 방식으로 전송되므로 대용량 파일에도 적합합니다. - 성능:
효율적입니다.
주요 사용처:
- wwwroot 내의 정적 파일: 애플리케이션의 wwwroot 폴더 내에 있는 CSS, JavaScript, 이미지 등 정적 파일을 컨트롤러 액션 메서드에서 직접 제공해야 할 때 (예: 특정 인증/권한이 필요한 정적 파일).
- StaticFileOptions로 구성된 가상 경로: UseStaticFiles 미들웨어 구성 시 RequestPath를 지정하여 만든 가상 경로에 있는 파일.
생성자 매개변수:
string virtualPath
: 웹 루트를 기준으로 한 가상 경로 (예: ~/images/logo.png, /css/site.css).string contentType
: 파일의 MIME 타입.string? fileDownloadName
(선택 사항): 다운로드 시 파일 이름.
예시:
using Microsoft.AspNetCore.Mvc; public class StaticAssetController : Controller { // GET: /StaticAsset/GetAppStyle public IActionResult GetAppStyle() { // wwwroot/css/app.css 파일을 제공합니다. return File("~/css/app.css", "text/css"); } // GET: /StaticAsset/DownloadSampleDocument public IActionResult DownloadSampleDocument() { // wwwroot/downloads/sample.pdf 파일을 다운로드합니다. return File("/downloads/sample.pdf", "application/pdf", "SampleDocument.pdf"); } }
✅ PhysicalFileResult
서버의 실제 파일 시스템 경로(Physical Path)를 사용하여 파일을 제공할 때 사용됩니다.
웹 애플리케이션의 루트 디렉토리를 벗어나 서버의 임의의 경로에 있는 파일을 제공해야 할 때 가장 유용합니다.
- 정의:
Microsoft.AspNetCore.Mvc.PhysicalFileResult - 데이터 원본:
서버의 실제 파일 시스템 경로 (절대 경로 또는 콘텐츠 루트 기준 상대 경로) - 메모리 사용:
낮음. VirtualFileResult와 마찬가지로 스트림 방식으로 전송되어 메모리 효율적입니다. - 성능:
효율적입니다.
주요 사용처:
- 웹 루트 외부 파일: 사용자가 업로드한 파일, 백업 파일, 또는 보안상의 이유로 wwwroot에 직접 노출되지 않는 폴더에 저장된 파일.
- 서버의 특정 위치에 있는 파일: 애플리케이션과 독립적으로 관리되는 파일 저장소의 파일.
생성자 매개변수:
string physicalPath
: 파일의 절대 경로 (예: “C:\Uploads\document.pdf”) 또는 애플리케이션의 콘텐츠 루트에 대한 상대 경로.string contentType
: 파일의 MIME 타입.string? fileDownloadName
(선택 사항): 다운로드 시 파일 이름.
주의사항: 사용자가 physicalPath를 직접 제어할 수 있는 경우 보안 취약점이 될 수 있습니다. 경로 유효성 검사 및 인가 처리가 필수적입니다.
예시:
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Hosting; // IWebHostEnvironment를 사용하기 위해 필요 public class UploadedFileController : Controller { private readonly IWebHostEnvironment _env; public UploadedFileController(IWebHostEnvironment env) { _env = env; } // GET: /UploadedFile/Download/{fileName} public IActionResult Download(string fileName) { // 업로드된 파일이 ContentRootPath/UserUploads 폴더에 저장되어 있다고 가정합니다. var filePath = Path.Combine(_env.ContentRootPath, "UserUploads", fileName); if (!System.IO.File.Exists(filePath)) { return NotFound(); } // 파일 확장자를 기반으로 MIME 타입 결정 (실제로는 더 견고한 로직 필요) string contentType; var ext = Path.GetExtension(fileName).ToLowerInvariant(); if (ext == ".pdf") contentType = "application/pdf"; else if (ext == ".jpg" || ext == ".jpeg") contentType = "image/jpeg"; else contentType = "application/octet-stream"; // 기본값: 알 수 없는 파일 return PhysicalFile(filePath, contentType, fileName); } }
✅ FileStreamResult (스트림 기반 파일 Serving)
FileStreamResult는 Stream 객체로부터 직접 파일을 제공할 때 사용됩니다.
이는 PhysicalFileResult나 VirtualFileResult와 마찬가지로 메모리 효율적이며,
파일이 이미 스트림 형태로 열려 있거나 외부 소스에서 스트림으로 직접 데이터를 읽어올 때 유용합니다.
- 정의:
Microsoft.AspNetCore.Mvc.FileStreamResult - 데이터 원본:
System.IO.Stream - 메모리 사용:
낮음. 파일 내용을 한 번에 메모리에 로드하지 않고 스트림에서 직접 읽어 전송합니다. - 성능:
효율적입니다.
주요 사용처:
- 네트워크 스트림에서 직접 읽어온 데이터.
- 압축 파일 내부의 특정 파일을 스트림으로 추출하여 제공할 때.
- 데이터베이스에서 BLOB 데이터를 Stream으로 직접 가져올 때.
- 외부 API에서 스트림 형태로 응답을 받을 때.
생성자 매개변수:
Stream fileStream
: 전송할 파일의 내용을 담고 있는 스트림.string contentType
: 파일의 MIME 타입.string? fileDownloadName
(선택 사항): 다운로드 시 파일 이름.
예시:
using Microsoft.AspNetCore.Mvc; using System.IO; public class StreamFileController : Controller { // GET: /StreamFile/GetLogFile public IActionResult GetLogFile() { var logFilePath = Path.Combine(AppContext.BaseDirectory, "Logs", "application.log"); if (!System.IO.File.Exists(logFilePath)) { return NotFound(); } // FileStream을 직접 열어 FileStreamResult로 반환 var stream = new FileStream(logFilePath, FileMode.Open, FileAccess.Read); return File(stream, "text/plain", "application.log"); } }
2️⃣ 파일 처리 관련 기타 핵심 인터페이스 및 개념
FileResult 유형 외에도 ASP.NET Core에서 파일을 효율적이고 안전하게 다루기 위해 알아야 할 중요한 인터페이스와 개념들이 있습니다.
✅ IFormFile 및 IFormFileCollection (파일 업로드)
https://learn.microsoft.com/ko-kr/dotnet/api/microsoft.aspnetcore.http.iformfile?view=aspnetcore-8.0
클라이언트(브라우저)에서 서버로 파일을 전송할 때 사용하는 인터페이스입니다.
- IFormFile: 단일 업로드 파일을 나타냅니다.
- 정의: Microsoft.AspNetCore.Http.IFormFile
- 주요 속성: FileName, ContentType, Length.
- 주요 메서드: CopyToAsync(Stream), OpenReadStream().
- IFormFileCollection: 여러 개의 업로드 파일을 나타내는 IFormFile 객체의 컬렉션입니다.
- 정의: Microsoft.AspNetCore.Http.IFormFileCollection
- 사용 예시:
<input type="file" multiple>
폼 요소로 여러 파일을 받을 때 컨트롤러 액션의 매개변수로 사용됩니다.
✅ IWebHostEnvironment 및 IHostEnvironment (환경 정보 및 경로)
애플리케이션의 호스팅 환경에 대한 정보를 제공하여, 파일 경로를 구성하고 접근할 때 매우 유용합니다. 이들은 DI 컨테이너에 등록되어 있어 생성자를 통해 쉽게 주입받을 수 있습니다.
- IWebHostEnvironment: 웹 호스팅 환경에 특화된 정보를 제공합니다.
- 정의: Microsoft.AspNetCore.Hosting.IWebHostEnvironment
- 주요 속성:
WebRootPath
: 웹 루트(wwwroot) 폴더의 물리적 경로. 정적 파일이 위치하는 곳입니다.WebRootFileProvider
: WebRootPath에 대한 IFileProvider 인스턴스.
- 주요 사용처: 정적 파일 저장 경로, 웹에 노출될 리소스 경로 구성.
- IHostEnvironment: 일반적인 호스팅 환경 정보를 제공합니다. IWebHostEnvironment는 IHostEnvironment를 상속합니다.
- 정의: Microsoft.Extensions.Hosting.IHostEnvironment
- 주요 속성:
ContentRootPath
: 애플리케이션의 콘텐츠 루트 폴더(프로젝트 파일이 있는 기본 디렉토리)의 물리적 경로.ContentRootFileProvider
: ContentRootPath에 대한 IFileProvider 인스턴스.EnvironmentName
: 현재 실행 중인 환경의 이름 (예: “Development”, “Production”, “Staging”).
- 주요 사용처: 로그 파일, 설정 파일 등 애플리케이션이 사용하는 비-웹 관련 파일 경로 구성.
✅ IFileProvider (추상화된 파일 시스템 접근)
파일 시스템 접근을 추상화하는 핵심 인터페이스입니다.
물리적인 디스크, 메모리, 압축 파일 등 다양한 소스에서 파일을 일관된 방식으로 다룰 수 있게 해줍니다.
- 정의: Microsoft.Extensions.FileProviders.IFileProvider
- 주요 역할: 파일 존재 여부 확인, 디렉토리 내용 열거, 파일 내용 스트림 생성 등 파일 시스템 작업을 수행합니다. UseStaticFiles 미들웨어, Razor Pages 등에서 내부적으로 사용됩니다.
- 주요 메서드:
GetFileInfo(string subpath)
: 지정된 하위 경로의 파일 또는 디렉토리에 대한 IFileInfo 객체를 반환합니다.GetDirectoryContents(string subpath)
: 지정된 하위 경로의 디렉토리 콘텐츠(IDirectoryContents)를 반환합니다.Watch(string filter)
: 지정된 필터와 일치하는 파일 또는 디렉토리의 변경 사항을 감시합니다 (예: 파일 변경 시 캐시 무효화).
- 주요 구현체:
PhysicalFileProvider
: 실제 파일 시스템 경로에서 파일을 제공합니다.CompositeFileProvider
: 여러 IFileProvider를 결합하여 여러 위치에서 파일을 찾을 수 있게 합니다.
✅ IFileInfo 및 IDirectoryContents (파일/디렉토리 메타데이터)
IFileProvider와 함께 사용되어 파일 시스템 항목의 상세 정보를 제공합니다.
- IFileInfo: 단일 파일 또는 디렉토리에 대한 메타데이터 (이름, 크기, 수정 시간, 존재 여부 등)를 제공합니다. IFileProvider.GetFileInfo()가 반환합니다.
- IDirectoryContents: 디렉토리 내의 파일 및 서브디렉토리 목록을 나타냅니다. IFileProvider.GetDirectoryContents()가 반환하며, IEnumerable<IFileInfo>를 구현합니다.
3️⃣ 고급 파일 처리 기능
✅ 파일 업로드 보안 강화
✨ 파일 시그니처 검증
파일 확장자만으로는 안전하지 않으며, 파일 시그니처(매직 바이트) 검증이 필요합니다.
public static class FileSignatureValidator { private static readonly Dictionary<string, byte[][]> FileSignatures = new() { { ".jpg", new[] { new byte[] { 0xFF, 0xD8, 0xFF } } }, { ".jpeg", new[] { new byte[] { 0xFF, 0xD8, 0xFF } } }, { ".png", new[] { new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A } } }, { ".pdf", new[] { new byte[] { 0x25, 0x50, 0x44, 0x46 } } }, { ".gif", new[] { new byte[] { 0x47, 0x49, 0x46, 0x38 } } } }; public static bool IsValidFileSignature(IFormFile file, string[] allowedExtensions) { if (file.Length == 0) return false; var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); if (!allowedExtensions.Contains(ext) || !FileSignatures.ContainsKey(ext)) return false; using var reader = new BinaryReader(file.OpenReadStream()); var signatures = FileSignatures[ext]; var headerBytes = reader.ReadBytes(signatures.Max(s => s.Length)); return signatures.Any(signature => headerBytes.Take(signature.Length).SequenceEqual(signature)); } }
✨ 커스텀 파일 검증 어트리뷰트
public class AllowedExtensionsAttribute : ValidationAttribute { private readonly string[] _extensions; public AllowedExtensionsAttribute(params string[] extensions) { _extensions = extensions; } protected override ValidationResult IsValid(object value, ValidationContext validationContext) { if (value is IFormFile file) { if (!FileSignatureValidator.IsValidFileSignature(file, _extensions)) { return new ValidationResult("허용되지 않는 파일 형식입니다."); } } return ValidationResult.Success; } } public class MaxFileSizeAttribute : ValidationAttribute { private readonly int _maxFileSize; public MaxFileSizeAttribute(int maxFileSize) { _maxFileSize = maxFileSize; } protected override ValidationResult IsValid(object value, ValidationContext validationContext) { if (value is IFormFile file && file.Length > _maxFileSize) { return new ValidationResult($"파일 크기는 {_maxFileSize / (1024 * 1024)}MB를 초과할 수 없습니다."); } return ValidationResult.Success; } } // 사용 예시 public class FileUploadModel { [AllowedExtensions(".jpg", ".jpeg", ".png", ".pdf")] [MaxFileSize(5 * 1024 * 1024)] // 5MB public IFormFile UploadedFile { get; set; } }
✅ 대용량 파일 처리 최적화
✨ 스트리밍 업로드
[HttpPost] [DisableFormValueModelBinding] public async Task<IActionResult> UploadLargeFile() { if (!IsMultipartContentType(Request.ContentType)) return BadRequest("지원하지 않는 미디어 타입입니다."); var boundary = GetBoundary(Request.ContentType); var reader = new MultipartReader(boundary, HttpContext.Request.Body); var section = await reader.ReadNextSectionAsync(); while (section != null) { if (ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out var contentDisposition)) { if (HasFileContentDisposition(contentDisposition)) { var fileName = contentDisposition.FileName.Value; var filePath = Path.Combine("uploads", fileName); using var targetStream = new FileStream(filePath, FileMode.Create); await section.Body.CopyToAsync(targetStream); } } section = await reader.ReadNextSectionAsync(); } return Ok("파일 업로드 완료"); } private static bool IsMultipartContentType(string contentType) { return !string.IsNullOrEmpty(contentType) && contentType.IndexOf("multipart/", StringComparison.OrdinalIgnoreCase) >= 0; } private static string GetBoundary(string contentType) { var elements = contentType.Split(' '); var element = elements.Where(entry => entry.StartsWith("boundary=")).First(); var boundary = element.Substring("boundary=".Length); return HeaderUtilities.RemoveQuotes(boundary).Value; } private static bool HasFileContentDisposition(ContentDispositionHeaderValue contentDisposition) { return contentDisposition != null && contentDisposition.DispositionType.Equals("form-data") && (!StringSegment.IsNullOrEmpty(contentDisposition.FileName) || !StringSegment.IsNullOrEmpty(contentDisposition.FileNameStar)); }
✨ Range Request 지원 (부분 콘텐츠 전송)
public IActionResult DownloadWithRangeSupport(string fileName) { var filePath = Path.Combine("uploads", fileName); if (!System.IO.File.Exists(filePath)) return NotFound(); var fileInfo = new FileInfo(filePath); var rangeHeader = Request.Headers["Range"].ToString(); if (!string.IsNullOrEmpty(rangeHeader)) { // Range 요청 처리 var range = ParseRangeHeader(rangeHeader, fileInfo.Length); if (range.HasValue) { var (start, end) = range.Value; var contentLength = end - start + 1; Response.StatusCode = 206; // Partial Content Response.Headers.Add("Content-Range", $"bytes {start}-{end}/{fileInfo.Length}"); Response.Headers.Add("Content-Length", contentLength.ToString()); var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read); stream.Seek(start, SeekOrigin.Begin); return File(stream, "application/octet-stream", fileName, enableRangeProcessing: true); } } return PhysicalFile(filePath, "application/octet-stream", fileName); } private (long start, long end)? ParseRangeHeader(string rangeHeader, long fileLength) { if (!rangeHeader.StartsWith("bytes=")) return null; var range = rangeHeader.Substring(6); var parts = range.Split('-'); if (parts.Length != 2) return null; long start = 0, end = fileLength - 1; if (!string.IsNullOrEmpty(parts[0])) start = long.Parse(parts[0]); if (!string.IsNullOrEmpty(parts[1])) end = long.Parse(parts[1]); if (start > end || start >= fileLength) return null; return (start, Math.Min(end, fileLength - 1)); }
✅ 임시 파일 관리 및 정리
✨ 임시 파일 자동 정리 서비스
public class TempFileCleanupService : BackgroundService { private readonly ILogger<TempFileCleanupService> _logger; private readonly string _tempDirectory; public TempFileCleanupService(ILogger<TempFileCleanupService> logger, IWebHostEnvironment env) { _logger = logger; _tempDirectory = Path.Combine(env.ContentRootPath, "temp"); // 임시 디렉토리가 존재하지 않으면 생성 if (!Directory.Exists(_tempDirectory)) { Directory.CreateDirectory(_tempDirectory); } } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { CleanupOldTempFiles(); await Task.Delay(TimeSpan.FromHours(1), stoppingToken); } catch (Exception ex) { _logger.LogWarning(ex, "임시 파일 삭제 실패: {FilePath}", file); } } } } // Program.cs에서 서비스 등록 builder.Services.AddHostedService<TempFileCleanupService>();
✅ 파일 압축 및 최적화
✨ 이미지 자동 리사이징
using SixLabors.ImageSharp; using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Formats.Jpeg; public class ImageProcessingResult : FileStreamResult { public ImageProcessingResult(Stream imageStream, string contentType, int maxWidth = 800, int maxHeight = 600) : base(ProcessImage(imageStream, maxWidth, maxHeight), contentType) { } private static Stream ProcessImage(Stream originalStream, int maxWidth, int maxHeight) { using var image = Image.Load(originalStream); if (image.Width > maxWidth || image.Height > maxHeight) { image.Mutate(x => x.Resize(new ResizeOptions { Size = new Size(maxWidth, maxHeight), Mode = ResizeMode.Max })); } var outputStream = new MemoryStream(); image.SaveAsJpeg(outputStream, new JpegEncoder { Quality = 85 }); outputStream.Position = 0; return outputStream; } } // 사용 예시 public IActionResult GetOptimizedImage(string fileName) { var filePath = Path.Combine("images", fileName); if (!System.IO.File.Exists(filePath)) return NotFound(); var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read); return new ImageProcessingResult(stream, "image/jpeg"); }
✅ 파일 메타데이터 및 추적
✨ 파일 메타데이터 모델
public class FileMetadata { public string Id { get; set; } = Guid.NewGuid().ToString(); public string OriginalName { get; set; } public string StoredName { get; set; } public string ContentType { get; set; } public long Size { get; set; } public string Hash { get; set; } // SHA256 해시 public DateTime UploadedAt { get; set; } = DateTime.UtcNow; public string UploadedBy { get; set; } public int DownloadCount { get; set; } public DateTime? LastAccessedAt { get; set; } } public class SecureFileService { private readonly ILogger<SecureFileService> _logger; private readonly string _uploadDirectory; private readonly Dictionary<string, FileMetadata> _fileMetadata = new(); public SecureFileService(ILogger<SecureFileService> logger, IWebHostEnvironment env) { _logger = logger; _uploadDirectory = Path.Combine(env.ContentRootPath, "SecureUploads"); if (!Directory.Exists(_uploadDirectory)) { Directory.CreateDirectory(_uploadDirectory); } } public async Task<FileMetadata> SaveFileAsync(IFormFile file, string userId) { // 파일 시그니처 검증 var allowedExtensions = new[] { ".jpg", ".jpeg", ".png", ".pdf", ".docx", ".xlsx" }; if (!FileSignatureValidator.IsValidFileSignature(file, allowedExtensions)) { throw new InvalidOperationException("허용되지 않는 파일 형식입니다."); } var metadata = new FileMetadata { OriginalName = file.FileName, StoredName = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}", ContentType = file.ContentType, Size = file.Length, UploadedBy = userId }; var filePath = Path.Combine(_uploadDirectory, metadata.StoredName); // 파일 저장 using (var stream = new FileStream(filePath, FileMode.Create)) { await file.CopyToAsync(stream); } // 파일 해시 계산 metadata.Hash = await ComputeFileHashAsync(filePath); // 메타데이터 저장 _fileMetadata[metadata.Id] = metadata; _logger.LogInformation("파일 저장 완료: {FileName} ({FileId})", metadata.OriginalName, metadata.Id); return metadata; } public async Task<(Stream stream, FileMetadata metadata)> GetFileAsync(string fileId) { if (!_fileMetadata.TryGetValue(fileId, out var metadata)) { throw new FileNotFoundException("파일을 찾을 수 없습니다."); } var filePath = Path.Combine(_uploadDirectory, metadata.StoredName); if (!System.IO.File.Exists(filePath)) { throw new FileNotFoundException("물리적 파일이 존재하지 않습니다."); } // 다운로드 통계 업데이트 metadata.DownloadCount++; metadata.LastAccessedAt = DateTime.UtcNow; var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read); return (stream, metadata); } private async Task<string> ComputeFileHashAsync(string filePath) { using var sha256 = SHA256.Create(); using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read); var hash = await sha256.ComputeHashAsync(stream); return Convert.ToBase64String(hash); } public bool DeleteFile(string fileId, string userId) { if (!_fileMetadata.TryGetValue(fileId, out var metadata)) { return false; } // 권한 확인 if (metadata.UploadedBy != userId) { throw new UnauthorizedAccessException("파일 삭제 권한이 없습니다."); } var filePath = Path.Combine(_uploadDirectory, metadata.StoredName); try { if (System.IO.File.Exists(filePath)) { System.IO.File.Delete(filePath); } _fileMetadata.Remove(fileId); _logger.LogInformation("파일 삭제 완료: {FileName} ({FileId})", metadata.OriginalName, fileId); return true; } catch (Exception ex) { _logger.LogError(ex, "파일 삭제 실패: {FileName} ({FileId})", metadata.OriginalName, fileId); return false; } } }
✅ 에러 처리 및 로깅
✨ 포괄적인 파일 에러 처리
public class FileController : Controller { private readonly ILogger<FileController> _logger; private readonly SecureFileService _fileService; public FileController(ILogger<FileController> logger, SecureFileService fileService) { _logger = logger; _fileService = fileService; } [HttpPost] public async Task<IActionResult> Upload(IFormFile file) { try { if (file == null || file.Length == 0) return BadRequest(new { error = "파일이 선택되지 않았습니다." }); if (file.Length > 10 * 1024 * 1024) // 10MB return BadRequest(new { error = "파일 크기가 10MB를 초과합니다." }); // 파일명 검증 (경로 조작 공격 방지) var fileName = Path.GetFileName(file.FileName); if (string.IsNullOrEmpty(fileName) || fileName.Contains("..")) return BadRequest(new { error = "유효하지 않은 파일명입니다." }); var result = await _fileService.SaveFileAsync(file, User.Identity.Name); _logger.LogInformation("파일 업로드 성공: {FileName}, 사용자: {User}, 파일ID: {FileId}", file.FileName, User.Identity.Name, result.Id); return Ok(new { fileId = result.Id, fileName = result.OriginalName, size = result.Size, message = "파일 업로드 완료" }); } catch (InvalidOperationException ex) { _logger.LogWarning("파일 업로드 유효성 검사 실패: {FileName}, 오류: {Error}", file?.FileName, ex.Message); return BadRequest(new { error = ex.Message }); } catch (IOException ex) { _logger.LogError(ex, "파일 I/O 오류: {FileName}", file?.FileName); return StatusCode(500, new { error = "파일 저장 중 오류가 발생했습니다." }); } catch (UnauthorizedAccessException ex) { _logger.LogError(ex, "파일 접근 권한 오류: {FileName}", file?.FileName); return StatusCode(500, new { error = "파일 접근 권한이 없습니다." }); } catch (Exception ex) { _logger.LogError(ex, "예상치 못한 오류: {FileName}", file?.FileName); return StatusCode(500, new { error = "파일 처리 중 오류가 발생했습니다." }); } } [HttpGet("{fileId}")] public async Task<IActionResult> Download(string fileId) { try { var (stream, metadata) = await _fileService.GetFileAsync(fileId); _logger.LogInformation("파일 다운로드: {FileName} ({FileId}), 사용자: {User}", metadata.OriginalName, fileId, User.Identity.Name); return File(stream, metadata.ContentType, metadata.OriginalName); } catch (FileNotFoundException ex) { _logger.LogWarning("파일 다운로드 실패 - 파일 없음: {FileId}, 사용자: {User}", fileId, User.Identity.Name); return NotFound(new { error = "파일을 찾을 수 없습니다." }); } catch (Exception ex) { _logger.LogError(ex, "파일 다운로드 오류: {FileId}", fileId); return StatusCode(500, new { error = "파일 다운로드 중 오류가 발생했습니다." }); } } [HttpDelete("{fileId}")] public IActionResult Delete(string fileId) { try { var success = _fileService.DeleteFile(fileId, User.Identity.Name); if (!success) return NotFound(new { error = "파일을 찾을 수 없습니다." }); return Ok(new { message = "파일이 삭제되었습니다." }); } catch (UnauthorizedAccessException ex) { _logger.LogWarning("파일 삭제 권한 없음: {FileId}, 사용자: {User}", fileId, User.Identity.Name); return Forbid("파일 삭제 권한이 없습니다."); } catch (Exception ex) { _logger.LogError(ex, "파일 삭제 오류: {FileId}", fileId); return StatusCode(500, new { error = "파일 삭제 중 오류가 발생했습니다." }); } } }
4️⃣ 파일 처리 시 보안 및 성능 고려사항
✅ 보안 고려사항
- 경로 유효성 검사:
PhysicalFileResult나 파일 업로드 시 사용자가 제공한 파일 이름/경로에 대해 경로 조작(Path Traversal) 공격을 방지하기 위해 철저한 유효성 검사를 수행해야 합니다.Path.GetFullPath()
,Path.GetFileName()
등을 사용하여 안전한 경로를 구성하고, 허용된 디렉토리 내에만 파일을 저장하도록 제한해야 합니다. - MIME 타입:
올바른 Content-Type을 지정하여 브라우저가 파일을 올바르게 해석하고, 잠재적인 스크립트 실행 공격을 방지합니다.FileExtensionContentTypeProvider
와 같은 클래스를 사용하여 MIME 타입을 자동으로 추론하는 것이 좋습니다. - 파일 시그니처 검증:
파일 확장자만으로는 안전하지 않으므로, 파일의 매직 바이트(시그니처)를 검증하여 실제 파일 형식을 확인해야 합니다. - 파일 크기 제한:
서비스 거부 공격을 방지하기 위해 업로드 파일 크기를 제한해야 합니다. - 바이러스 스캔:
중요한 시스템에서는 업로드된 파일에 대한 바이러스 검사를 수행해야 합니다.
✅ 성능 고려사항
- 대용량 파일:
큰 파일은byte[]
로 메모리에 로드하는FileContentResult
대신, 스트림 기반의VirtualFileResult
,PhysicalFileResult
,FileStreamResult
를 사용하여 메모리 사용량을 최소화해야 합니다. - 캐싱:
UseStaticFiles
미들웨어의StaticFileOptions.OnPrepareResponse
를 사용하여 Cache-Control 헤더를 설정하거나,ResponseCaching
미들웨어를 사용하여 캐싱 전략을 최적화할 수 있습니다. - 응답 압축:
UseResponseCompression
미들웨어를 사용하여 정적 파일 및 동적 응답을 gzip, brotli 등으로 압축하여 네트워크 대역폭 사용을 줄이고 로딩 속도를 향상시킬 수 있습니다. - 스트리밍 처리:
대용량 파일 업로드/다운로드 시 전체 파일을 메모리에 로드하지 않고 스트림으로 처리하여 메모리 효율성을 높입니다. - 비동기 처리:
파일 I/O 작업은 항상 비동기 메서드(CopyToAsync
,ReadAsync
등)를 사용하여 스레드 풀을 효율적으로 활용합니다.
5️⃣ 프로덕션 환경 설정
✅ Program.cs 설정 예시
var builder = WebApplication.CreateBuilder(args); // 서비스 등록 builder.Services.AddControllers(); builder.Services.AddScoped<SecureFileService>(); builder.Services.AddHostedService<TempFileCleanupService>(); // 파일 업로드 설정 builder.Services.Configure<FormOptions>(options => { options.MultipartBodyLengthLimit = 100 * 1024 * 1024; // 100MB options.ValueLengthLimit = int.MaxValue; options.ValueCountLimit = int.MaxValue; options.KeyLengthLimit = int.MaxValue; }); // 응답 압축 설정 builder.Services.AddResponseCompression(options => { options.EnableForHttps = true; options.Providers.Add<BrotliCompressionProvider>(); options.Providers.Add<GzipCompressionProvider>(); }); var app = builder.Build(); // 미들웨어 파이프라인 app.UseResponseCompression(); app.UseStaticFiles(new StaticFileOptions { OnPrepareResponse = ctx => { // 정적 파일 캐싱 설정 ctx.Context.Response.Headers.Append("Cache-Control", "public,max-age=31536000"); } }); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.Run();
✅ 로깅 설정 (appsettings.json)
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning", "FileController": "Information", "SecureFileService": "Information", "TempFileCleanupService": "Information" } } }
6️⃣ 결론
ASP.NET Core의 이러한 다양한 파일 처리 도구와 개념을 이해하고 적절히 활용하는 것은 견고하고 효율적인 웹 애플리케이션을 구축하는 데 필수적입니다.
특히 보안, 성능, 사용자 경험을 모두 고려한 파일 처리 시스템을 구축하기 위해서는:
- 적절한 FileResult 선택: 파일의 특성과 사용 목적에 따라 최적의 FileResult 유형을 선택
- 강력한 보안 검증: 파일 시그니처 검증, 경로 조작 방지, 크기 제한 등 다층적 보안 적용
- 성능 최적화: 스트리밍 처리, 캐싱, 압축 등을 통한 성능 향상
- 포괄적인 에러 처리: 예상 가능한 모든 오류 상황에 대한 적절한 처리와 로깅
- 사용자 경험 고려: Range Request 지원, 진행률 표시, 적절한 피드백 제공
이러한 원칙들을 따라 구현하면 안전하고 효율적이며 사용자 친화적인 파일 처리 시스템을 구축할 수 있습니다.