ASP.NET Core 파일 처리: FileResult 및 인터페이스

/posts/reflection-actionresult

🔥 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

https://learn.microsoft.com/ko-kr/dotnet/api/microsoft.aspnetcore.mvc.filecontentresult?view=aspnetcore-8.0

파일의 내용이 이미 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로 설정되어 브라우저가 파일을 인라인으로 표시하는 대신 다운로드하도록 유도합니다.

예시:

csharp
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

https://learn.microsoft.com/ko-kr/dotnet/api/microsoft.aspnetcore.mvc.virtualfileresult?view=aspnetcore-8.0

웹 애플리케이션의 가상 경로(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 (선택 사항): 다운로드 시 파일 이름.

예시:

csharp
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

https://learn.microsoft.com/ko-kr/dotnet/api/microsoft.aspnetcore.mvc.physicalfileresult?view=aspnetcore-8.0

서버의 실제 파일 시스템 경로(Physical Path)​를 사용하여 파일을 제공할 때 사용됩니다.

웹 애플리케이션의 루트 디렉토리를 벗어나 서버의 임의의 경로에 있는 파일을 제공해야 할 때 가장 유용합니다.

  • 정의: Microsoft.AspNetCore.Mvc.PhysicalFileResult
  • 데이터 원본: 서버의 실제 파일 시스템 경로 (절대 경로 또는 콘텐츠 루트 기준 상대 경로)
  • 메모리 사용: 낮음. VirtualFileResult와 마찬가지로 스트림 방식으로 전송되어 메모리 효율적입니다.
  • 성능: 효율적입니다.

주요 사용처:

  • 웹 루트 외부 파일: 사용자가 업로드한 파일, 백업 파일, 또는 보안상의 이유로 wwwroot에 직접 노출되지 않는 폴더에 저장된 파일.
  • 서버의 특정 위치에 있는 파일: 애플리케이션과 독립적으로 관리되는 파일 저장소의 파일.

생성자 매개변수:

  • string physicalPath: 파일의 절대 경로 (예: "C:\Uploads\document.pdf") 또는 애플리케이션의 콘텐츠 루트에 대한 상대 경로.
  • string contentType: 파일의 MIME 타입.
  • string? fileDownloadName (선택 사항): 다운로드 시 파일 이름.

주의사항: 사용자가 physicalPath를 직접 제어할 수 있는 경우 보안 취약점이 될 수 있습니다. 경로 유효성 검사 및 인가 처리가 필수적입니다.

예시:

csharp
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)

https://learn.microsoft.com/ko-kr/dotnet/api/microsoft.aspnetcore.mvc.filestreamresult?view=aspnetcore-8.0

FileStreamResult는 Stream 객체로부터 직접 파일을 제공할 때 사용됩니다.

이는 PhysicalFileResult나 VirtualFileResult와 마찬가지로 메모리 효율적이며,

파일이 이미 스트림 형태로 열려 있거나 외부 소스에서 스트림으로 직접 데이터를 읽어올 때 유용합니다.

  • 정의: Microsoft.AspNetCore.Mvc.FileStreamResult
  • 데이터 원본: System.IO.Stream
  • 메모리 사용: 낮음. 파일 내용을 한 번에 메모리에 로드하지 않고 스트림에서 직접 읽어 전송합니다.
  • 성능: 효율적입니다.

주요 사용처:

  • 네트워크 스트림에서 직접 읽어온 데이터.
  • 압축 파일 내부의 특정 파일을 스트림으로 추출하여 제공할 때.
  • 데이터베이스에서 BLOB 데이터를 Stream으로 직접 가져올 때.
  • 외부 API에서 스트림 형태로 응답을 받을 때.

생성자 매개변수:

  • Stream fileStream: 전송할 파일의 내용을 담고 있는 스트림.
  • string contentType: 파일의 MIME 타입.
  • string? fileDownloadName (선택 사항): 다운로드 시 파일 이름.

예시:

csharp
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

https://learn.microsoft.com/ko-kr/dotnet/api/microsoft.aspnetcore.http.iformfilecollection?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 (환경 정보 및 경로)

https://learn.microsoft.com/ko-kr/dotnet/api/microsoft.aspnetcore.hosting.iwebhostenvironment?view=aspnetcore-8.0

https://learn.microsoft.com/ko-kr/dotnet/api/microsoft.aspnetcore.components.routing.ihostenvironmentnavigationmanager?view=aspnetcore-8.0

애플리케이션의 호스팅 환경에 대한 정보를 제공하여, 파일 경로를 구성하고 접근할 때 매우 유용합니다. 이들은 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 (파일/디렉토리 메타데이터)

https://learn.microsoft.com/ko-kr/dotnet/api/microsoft.aspnetcore.mvc.razor.runtimecompilation.fileproviderrazorprojectitem.fileinfo?view=aspnetcore-8.0#microsoft-aspnetcore-mvc-razor-runtimecompilation-fileproviderrazorprojectitem-fileinfo

IFileProvider와 함께 사용되어 파일 시스템 항목의 상세 정보를 제공합니다.

  • IFileInfo: 단일 파일 또는 디렉토리에 대한 메타데이터 (이름, 크기, 수정 시간, 존재 여부 등)를 제공합니다. IFileProvider.GetFileInfo()가 반환합니다.
  • IDirectoryContents: 디렉토리 내의 파일 및 서브디렉토리 목록을 나타냅니다. IFileProvider.GetDirectoryContents()가 반환하며, IEnumerable<IFileInfo>를 구현합니다.

3️⃣ 고급 파일 처리 기능

✅ 파일 업로드 보안 강화

✨ 파일 시그니처 검증

파일 확장자만으로는 안전하지 않으며, 파일 시그니처(매직 바이트) 검증이 필요합니다.

csharp
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));
    }
}

✨ 커스텀 파일 검증 어트리뷰트

csharp
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; }
}

✅ 대용량 파일 처리 최적화

✨ 스트리밍 업로드

csharp
[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 지원 (부분 콘텐츠 전송)

csharp
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));
}

✅ 임시 파일 관리 및 정리

✨ 임시 파일 자동 정리 서비스

csharp
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>();

✅ 파일 압축 및 최적화

✨ 이미지 자동 리사이징

csharp
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");
}

✅ 파일 메타데이터 및 추적

✨ 파일 메타데이터 모델

csharp
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;
        }
    }
}

✅ 에러 처리 및 로깅

✨ 포괄적인 파일 에러 처리

csharp
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️⃣ 파일 처리 시 보안 및 성능 고려사항

✅ 보안 고려사항

  1. 경로 유효성 검사: PhysicalFileResult나 파일 업로드 시 사용자가 제공한 파일 이름/경로에 대해 경로 조작(Path Traversal) 공격을 방지하기 위해 철저한 유효성 검사를 수행해야 합니다. Path.GetFullPath(), Path.GetFileName() 등을 사용하여 안전한 경로를 구성하고, 허용된 디렉토리 내에만 파일을 저장하도록 제한해야 합니다.
  2. MIME 타입: 올바른 Content-Type을 지정하여 브라우저가 파일을 올바르게 해석하고, 잠재적인 스크립트 실행 공격을 방지합니다. FileExtensionContentTypeProvider와 같은 클래스를 사용하여 MIME 타입을 자동으로 추론하는 것이 좋습니다.
  3. 파일 시그니처 검증: 파일 확장자만으로는 안전하지 않으므로, 파일의 매직 바이트(시그니처)를 검증하여 실제 파일 형식을 확인해야 합니다.
  4. 파일 크기 제한: 서비스 거부 공격을 방지하기 위해 업로드 파일 크기를 제한해야 합니다.
  5. 바이러스 스캔: 중요한 시스템에서는 업로드된 파일에 대한 바이러스 검사를 수행해야 합니다.

✅ 성능 고려사항

  1. 대용량 파일: 큰 파일은 byte[]로 메모리에 로드하는 FileContentResult 대신, 스트림 기반의 VirtualFileResult, PhysicalFileResult, FileStreamResult를 사용하여 메모리 사용량을 최소화해야 합니다.
  2. 캐싱: UseStaticFiles 미들웨어의 StaticFileOptions.OnPrepareResponse를 사용하여 Cache-Control 헤더를 설정하거나, ResponseCaching 미들웨어를 사용하여 캐싱 전략을 최적화할 수 있습니다.
  3. 응답 압축: UseResponseCompression 미들웨어를 사용하여 정적 파일 및 동적 응답을 gzip, brotli 등으로 압축하여 네트워크 대역폭 사용을 줄이고 로딩 속도를 향상시킬 수 있습니다.
  4. 스트리밍 처리: 대용량 파일 업로드/다운로드 시 전체 파일을 메모리에 로드하지 않고 스트림으로 처리하여 메모리 효율성을 높입니다.
  5. 비동기 처리: 파일 I/O 작업은 항상 비동기 메서드(CopyToAsync, ReadAsync 등)를 사용하여 스레드 풀을 효율적으로 활용합니다.

5️⃣ 프로덕션 환경 설정

✅ Program.cs 설정 예시

csharp
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)

json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "FileController": "Information",
      "SecureFileService": "Information",
      "TempFileCleanupService": "Information"
    }
  }
}

6️⃣ 결론

ASP.NET Core의 이러한 다양한 파일 처리 도구와 개념을 이해하고 적절히 활용하는 것은 견고하고 효율적인 웹 애플리케이션을 구축하는 데 필수적입니다.

특히 보안, 성능, 사용자 경험을 모두 고려한 파일 처리 시스템을 구축하기 위해서는:

  1. 적절한 FileResult 선택: 파일의 특성과 사용 목적에 따라 최적의 FileResult 유형을 선택
  2. 강력한 보안 검증: 파일 시그니처 검증, 경로 조작 방지, 크기 제한 등 다층적 보안 적용
  3. 성능 최적화: 스트리밍 처리, 캐싱, 압축 등을 통한 성능 향상
  4. 포괄적인 에러 처리: 예상 가능한 모든 오류 상황에 대한 적절한 처리와 로깅
  5. 사용자 경험 고려: Range Request 지원, 진행률 표시, 적절한 피드백 제공

이러한 원칙들을 따라 구현하면 안전하고 효율적이며 사용자 친화적인 파일 처리 시스템을 구축할 수 있습니다.