ASP.NET Core에서 파일 업로드File uploads in ASP.NET Core

작성자: Steve SmithBy Steve Smith

ASP.NET MVC 작업은 소규모 파일에 대해서는 단순 모델 바인딩을, 대규모 파일에 대해서는 스트리밍을 사용하여 하나 이상의 파일 업로딩을 지원합니다.ASP.NET MVC actions support uploading of one or more files using simple model binding for smaller files or streaming for larger files.

GitHub에서 샘플 보기 또는 다운로드View or download sample from GitHub

모델 바인딩을 사용하여 작은 파일 업로딩Uploading small files with model binding

작은 파일을 업로드하기 위해 다중 파트 HTML 양식을 사용하거나 JavaScript를 사용하여 POST 요청을 생성할 수 있습니다.To upload small files, you can use a multi-part HTML form or construct a POST request using JavaScript. 다중 업로드된 파일을 지원하고 Razor를 사용하는 양식 예제가 아래에 나와 있습니다.An example form using Razor, which supports multiple uploaded files, is shown below:

<form method="post" enctype="multipart/form-data" asp-controller="UploadFiles" asp-action="Index">
    <div class="form-group">
        <div class="col-md-10">
            <p>Upload one or more files using this form:</p>
            <input type="file" name="files" multiple>
        </div>
    </div>
    <div class="form-group">
        <div class="col-md-10">
            <input type="submit" value="Upload">
        </div>
    </div>
</form>

파일 업로드를 지원하려면 HTML 양식에서 multipart/form-dataenctype을 지정해야 합니다.In order to support file uploads, HTML forms must specify an enctype of multipart/form-data. 위에 표시된 files 입력 요소에서는 다중 파일 업로드를 지원합니다.The files input element shown above supports uploading multiple files. 이 입력 요소에서 multiple 특성을 생략하면 하나의 파일만 업로드할 수 있습니다.Omit the multiple attribute on this input element to allow just a single file to be uploaded. 위의 태그는 브라우저에서 다음과 같이 렌더링합니다.The above markup renders in a browser as:

파일 업로드 양식

서버에 업로드된 개별 파일은 IFormFile 인터페이스를 사용하여 모델 바인딩을 통해 액세스할 수 있습니다.The individual files uploaded to the server can be accessed through Model Binding using the IFormFile interface. IFormFile에는 다음 구조체가 있습니다.IFormFile has the following structure:

public interface IFormFile
{
    string ContentType { get; }
    string ContentDisposition { get; }
    IHeaderDictionary Headers { get; }
    long Length { get; }
    string Name { get; }
    string FileName { get; }
    Stream OpenReadStream();
    void CopyTo(Stream target);
    Task CopyToAsync(Stream target, CancellationToken cancellationToken = null);
}

경고

유효성 검사없이 FileName 속성을 의존하거나 신뢰하지 마세요.Don't rely on or trust the FileName property without validation. FileName 속성은 표시 목적으로만 사용해야 합니다.The FileName property should only be used for display purposes.

모델 바인딩 및 IFormFile 인터페이스를 사용하여 파일을 업로드할 경우, 작업 메서드는 단일 IFormFile 또는 여러 파일을 나타내는 IEnumerable<IFormFile>(또는 List<IFormFile>)을 받아들일 수 있습니다.When uploading files using model binding and the IFormFile interface, the action method can accept either a single IFormFile or an IEnumerable<IFormFile> (or List<IFormFile>) representing several files. 다음 예제에서는 하나 이상의 업로드된 파일을 반복하여 로컬 파일 시스템에 저장하고 업로드된 파일의 총 수와 크기를 반환합니다.The following example loops through one or more uploaded files, saves them to the local file system, and returns the total number and size of files uploaded.

경고: 다음 코드는 GetTempFileName을 사용합니다. 이전 임시 파일을 삭제하지 않고 65535개를 초과하는 파일을 만들면 IOException이 throw됩니다.Warning: The following code uses GetTempFileName, which throws an IOException if more than 65535 files are created without deleting previous temporary files. 실제 앱은 임시 파일을 삭제하거나 GetTempPathGetRandomFileName을 사용하여 임시 파일 이름을 만듭니다.A real app should either delete temporary files or use GetTempPath and GetRandomFileName to create temporary file names. 65535개의 파일 제한은 서버마다 있으므로 서버의 다른 앱은 65535개의 파일을 모두 사용할 수 있습니다.The 65535 files limit is per server, so another app on the server can use up all 65535 files.

[HttpPost("UploadFiles")]
public async Task<IActionResult> Post(List<IFormFile> files)
{
    long size = files.Sum(f => f.Length);

    // full path to file in temp location
    var filePath = Path.GetTempFileName();

    foreach (var formFile in files)
    {
        if (formFile.Length > 0)
        {
            using (var stream = new FileStream(filePath, FileMode.Create))
            {
                await formFile.CopyToAsync(stream);
            }
        }
    }

    // process uploaded files
    // Don't rely on or trust the FileName property without validation.

    return Ok(new { count = files.Count, size, filePath});
}

IFormFile 기술을 사용하여 업로드된 파일은 처리되기 전에 웹 서버의 메모리나 디스크에 버퍼링됩니다.Files uploaded using the IFormFile technique are buffered in memory or on disk on the web server before being processed. 작업 메서드 내부에서 IFormFile 내용을 스트림으로 액세스할 수 있습니다.Inside the action method, the IFormFile contents are accessible as a stream. 로컬 파일 시스템 외에도 파일을 Azure Blob 스토리지 또는 Entity Framework에 스트리밍할 수 있습니다.In addition to the local file system, files can be streamed to Azure Blob storage or Entity Framework.

Entity Framework를 사용하여 데이터베이스에 이진 파일 데이터를 저장하려면 엔터티에서 byte[] 형식의 속성을 정의합니다.To store binary file data in a database using Entity Framework, define a property of type byte[] on the entity:

public class ApplicationUser : IdentityUser
{
    public byte[] AvatarImage { get; set; }
}

IFormFile 형식의 viewmodel 속성을 지정합니다.Specify a viewmodel property of type IFormFile:

public class RegisterViewModel
{
    // other properties omitted

    public IFormFile AvatarImage { get; set; }
}

참고

위에 표시된 것처럼 IFormFile을 작업 메서드 매개 변수 또는 viewmodel 속성으로 직접 사용할 수 있습니다.IFormFile can be used directly as an action method parameter or as a viewmodel property, as shown above.

IFormFile을 스트림에 복사하고 바이트 배열로 저장합니다.Copy the IFormFile to a stream and save it to the byte array:

// POST: /Account/Register
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Register(RegisterViewModel model)
{
    ViewData["ReturnUrl"] = returnUrl;
    if  (ModelState.IsValid)
    {
        var user = new ApplicationUser 
        {
            UserName = model.Email,
            Email = model.Email
        };
        using (var memoryStream = new MemoryStream())
        {
            await model.AvatarImage.CopyToAsync(memoryStream);
            user.AvatarImage = memoryStream.ToArray();
        }
    // additional logic omitted

    // Don't rely on or trust the model.AvatarImage.FileName property 
    // without validation.
}

참고

관계형 데이터베이스에 이진 데이터를 저장하는 경우 성능에 나쁜 영향을 줄 수 있으므로 주의하세요.Use caution when storing binary data in relational databases, as it can adversely impact performance.

스트리밍을 사용하여 큰 파일 업로딩Uploading large files with streaming

파일 업로드의 크기 또는 빈도로 인해 앱에 대한 리소스 문제가 발생하는 경우 위에 표시된 모델 바인딩 접근 방식과 마찬가지로 전체를 버퍼링하는 대신 파일 업로드를 스트리밍하는 것이 좋습니다.If the size or frequency of file uploads is causing resource problems for the app, consider streaming the file upload rather than buffering it in its entirety, as the model binding approach shown above does. IFormFile을 사용하고 모델 바인딩이 훨씬 간단한 솔루션이지만 스트리밍을 제대로 구현하려면 여러 단계를 거쳐야 합니다.While using IFormFile and model binding is a much simpler solution, streaming requires a number of steps to implement properly.

참고

버퍼링된 단일 파일이 64KB를 초과하면 이 파일은 RAM에서 서버의 디스크에 있는 임시 파일로 이동됩니다.Any single buffered file exceeding 64KB will be moved from RAM to a temp file on disk on the server. 파일 업로드에서 사용되는 리소스(디스크, RAM)는 동시 파일 업로드 크기와 수에 따라 달라집니다.The resources (disk, RAM) used by file uploads depend on the number and size of concurrent file uploads. 스트리밍은 성능에 관한 것이 아니라 규모에 관한 것입니다.Streaming isn't so much about perf, it's about scale. 너무 많은 업로드를 버퍼링하려고 하면 메모리 또는 디스크 공간이 부족할 때 사이트가 중단됩니다.If you try to buffer too many uploads, your site will crash when it runs out of memory or disk space.

다음 예제에서는 JavaScript/Angular를 사용하여 컨트롤러 작업에 스트리밍하는 것을 보여 줍니다.The following example demonstrates using JavaScript/Angular to stream to a controller action. 사용자 지정 필터 특성을 사용하여 파일의 위조 방지 토큰이 생성되고 요청 본문 대신 HTTP 헤더에 전달됩니다.The file's antiforgery token is generated using a custom filter attribute and passed in HTTP headers instead of in the request body. 작업 메서드에서 업로드된 데이터를 직접 처리하므로 다른 필터에서 모델 바인딩을 사용할 수 없습니다.Because the action method processes the uploaded data directly, model binding is disabled by another filter. 작업 내에서 양식의 콘텐츠는 각 개별 MultipartSection을 읽고 적절하게 파일을 처리하거나 콘텐츠를 저장하는 MultipartReader를 사용하여 읽습니다.Within the action, the form's contents are read using a MultipartReader, which reads each individual MultipartSection, processing the file or storing the contents as appropriate. 모든 섹션을 읽은 후 작업에서 자체 모델 바인딩을 수행합니다.Once all sections have been read, the action performs its own model binding.

초기 작업에서는 양식을 로드하고 위조 방지 토큰을 쿠키에 저장합니다(GenerateAntiforgeryTokenCookieForAjax 특성을 통해).The initial action loads the form and saves an antiforgery token in a cookie (via the GenerateAntiforgeryTokenCookieForAjax attribute):

[HttpGet]
[GenerateAntiforgeryTokenCookieForAjax]
public IActionResult Index()
{
    return View();
}

이 특성은 ASP.NET Core의 기본 제공 위조 방지 지원을 사용하여 요청 토큰으로 쿠키를 설정합니다.The attribute uses ASP.NET Core's built-in Antiforgery support to set a cookie with a request token:

public class GenerateAntiforgeryTokenCookieForAjaxAttribute : ActionFilterAttribute
{
    public override void OnActionExecuted(ActionExecutedContext context)
    {
        var antiforgery = context.HttpContext.RequestServices.GetService<IAntiforgery>();

        // We can send the request token as a JavaScript-readable cookie, 
        // and Angular will use it by default.
        var tokens = antiforgery.GetAndStoreTokens(context.HttpContext);
        context.HttpContext.Response.Cookies.Append(
            "XSRF-TOKEN",
            tokens.RequestToken,
            new CookieOptions() { HttpOnly = false });
    }
}

Angular는 X-XSRF-TOKEN으로 명명된 요청 헤더에서 위조 방지 토큰을 자동으로 전달합니다.Angular automatically passes an antiforgery token in a request header named X-XSRF-TOKEN. Startup.cs의 해당 구성에서 이 헤더를 참조하도록 ASP.NET Core MVC 앱이 구성됩니다.The ASP.NET Core MVC app is configured to refer to this header in its configuration in Startup.cs:

public void ConfigureServices(IServiceCollection services)
{
    // Angular's default header name for sending the XSRF token.
    services.AddAntiforgery(options => options.HeaderName = "X-XSRF-TOKEN");

    services.AddMvc();
}

아래 표시된 DisableFormValueModelBinding 특성은 Upload 작업 메서드에 대한 모델 바인딩을 사용하지 않도록 설정하는 데 사용됩니다.The DisableFormValueModelBinding attribute, shown below, is used to disable model binding for the Upload action method.

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class DisableFormValueModelBindingAttribute : Attribute, IResourceFilter
{
    public void OnResourceExecuting(ResourceExecutingContext context)
    {
        var factories = context.ValueProviderFactories;
        factories.RemoveType<FormValueProviderFactory>();
        factories.RemoveType<JQueryFormValueProviderFactory>();
    }

    public void OnResourceExecuted(ResourceExecutedContext context)
    {
    }
}    

모델 바인딩이 사용되지 않으므로 Upload 작업 메서드는 매개 변수를 받아들이지 않습니다.Since model binding is disabled, the Upload action method doesn't accept parameters. ControllerBaseRequest 속성으로 직접 작동합니다.It works directly with the Request property of ControllerBase. MultipartReader는 각 섹션을 읽는 데 사용됩니다.A MultipartReader is used to read each section. 파일이 GUID 파일 이름으로 저장되고 키/값 데이터가 KeyValueAccumulator에 저장됩니다.The file is saved with a GUID filename and the key/value data is stored in a KeyValueAccumulator. 모든 섹션을 읽었으면 KeyValueAccumulator의 내용이 양식 데이터를 모델 형식으로 바인딩하는 데 사용됩니다.Once all sections have been read, the contents of the KeyValueAccumulator are used to bind the form data to a model type.

전체 Upload 메서드는 다음과 같습니다.The complete Upload method is shown below:

경고: 다음 코드는 GetTempFileName을 사용합니다. 이전 임시 파일을 삭제하지 않고 65535개를 초과하는 파일을 만들면 IOException이 throw됩니다.Warning: The following code uses GetTempFileName, which throws an IOException if more than 65535 files are created without deleting previous temporary files. 실제 앱은 임시 파일을 삭제하거나 GetTempPathGetRandomFileName을 사용하여 임시 파일 이름을 만듭니다.A real app should either delete temporary files or use GetTempPath and GetRandomFileName to create temporary file names. 65535개의 파일 제한은 서버마다 있으므로 서버의 다른 앱은 65535개의 파일을 모두 사용할 수 있습니다.The 65535 files limit is per server, so another app on the server can use up all 65535 files.

// 1. Disable the form value model binding here to take control of handling 
//    potentially large files.
// 2. Typically antiforgery tokens are sent in request body, but since we 
//    do not want to read the request body early, the tokens are made to be 
//    sent via headers. The antiforgery token filter first looks for tokens
//    in the request header and then falls back to reading the body.
[HttpPost]
[DisableFormValueModelBinding]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Upload()
{
    if (!MultipartRequestHelper.IsMultipartContentType(Request.ContentType))
    {
        return BadRequest($"Expected a multipart request, but got {Request.ContentType}");
    }

    // Used to accumulate all the form url encoded key value pairs in the 
    // request.
    var formAccumulator = new KeyValueAccumulator();
    string targetFilePath = null;

    var boundary = MultipartRequestHelper.GetBoundary(
        MediaTypeHeaderValue.Parse(Request.ContentType),
        _defaultFormOptions.MultipartBoundaryLengthLimit);
    var reader = new MultipartReader(boundary, HttpContext.Request.Body);

    var section = await reader.ReadNextSectionAsync();
    while (section != null)
    {
        ContentDispositionHeaderValue contentDisposition;
        var hasContentDispositionHeader = ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out contentDisposition);

        if (hasContentDispositionHeader)
        {
            if (MultipartRequestHelper.HasFileContentDisposition(contentDisposition))
            {
                targetFilePath = Path.GetTempFileName();
                using (var targetStream = System.IO.File.Create(targetFilePath))
                {
                    await section.Body.CopyToAsync(targetStream);

                    _logger.LogInformation($"Copied the uploaded file '{targetFilePath}'");
                }
            }
            else if (MultipartRequestHelper.HasFormDataContentDisposition(contentDisposition))
            {
                // Content-Disposition: form-data; name="key"
                //
                // value

                // Do not limit the key name length here because the 
                // multipart headers length limit is already in effect.
                var key = HeaderUtilities.RemoveQuotes(contentDisposition.Name);
                var encoding = GetEncoding(section);
                using (var streamReader = new StreamReader(
                    section.Body,
                    encoding,
                    detectEncodingFromByteOrderMarks: true,
                    bufferSize: 1024,
                    leaveOpen: true))
                {
                    // The value length limit is enforced by MultipartBodyLengthLimit
                    var value = await streamReader.ReadToEndAsync();
                    if (String.Equals(value, "undefined", StringComparison.OrdinalIgnoreCase))
                    {
                        value = String.Empty;
                    }
                    formAccumulator.Append(key, value);

                    if (formAccumulator.ValueCount > _defaultFormOptions.ValueCountLimit)
                    {
                        throw new InvalidDataException($"Form key count limit {_defaultFormOptions.ValueCountLimit} exceeded.");
                    }
                }
            }
        }

        // Drains any remaining section body that has not been consumed and
        // reads the headers for the next section.
        section = await reader.ReadNextSectionAsync();
    }

    // Bind form data to a model
    var user = new User();
    var formValueProvider = new FormValueProvider(
        BindingSource.Form,
        new FormCollection(formAccumulator.GetResults()),
        CultureInfo.CurrentCulture);

    var bindingSuccessful = await TryUpdateModelAsync(user, prefix: "",
        valueProvider: formValueProvider);
    if (!bindingSuccessful)
    {
        if (!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }
    }

    var uploadedData = new UploadedData()
    {
        Name = user.Name,
        Age = user.Age,
        Zipcode = user.Zipcode,
        FilePath = targetFilePath
    };
    return Json(uploadedData);
}

문제 해결Troubleshooting

다음은 파일 업로드 및 가능한 솔루션을 사용하여 작업할 때 자주 발생하는 몇 가지 일반적인 문제입니다.Below are some common problems encountered when working with uploading files and their possible solutions.

IIS에서 예기치 않은 찾을 수 없음 오류Unexpected Not Found error with IIS

다음 오류는 파일 업로드가 서버에 구성된 maxAllowedContentLength를 초과했음을 나타냅니다.The following error indicates your file upload exceeds the server's configured maxAllowedContentLength:

HTTP 404.13 - Not Found
The request filtering module is configured to deny a request that exceeds the request content length.

기본 설정은 30000000이며, 약 28.6MB입니다.The default setting is 30000000, which is approximately 28.6MB. web.config를 편집하여 값을 사용자 지정할 수 있습니다.The value can be customized by editing web.config:

<system.webServer>
  <security>
    <requestFiltering>
      <!-- This will handle requests up to 50MB -->
      <requestLimits maxAllowedContentLength="52428800" />
    </requestFiltering>
  </security>
</system.webServer>

이 설정은 IIS에만 적용됩니다.This setting only applies to IIS. Kestrel에서 호스팅하는 경우 기본적으로 동작이 발생하지 않습니다.The behavior doesn't occur by default when hosting on Kestrel. 자세한 내용은 요청 제한 <requestLimits>을 참조하세요.For more information, see Request Limits <requestLimits>.

IFormFile을 사용한 Null 참조 예외Null Reference Exception with IFormFile

컨트롤러가 IFormFile을 사용하여 업로드된 파일을 수락하고 있지만 값이 항상 null인 경우 HTML 양식에서 enctype 값을 multipart/form-data로 지정하는지 확인합니다.If your controller is accepting uploaded files using IFormFile but you find that the value is always null, confirm that your HTML form is specifying an enctype value of multipart/form-data. <form> 요소에 이 특성이 설정되어 있지 않은 경우 파일 업로드가 발생하지 않고 모든 바인딩된 IFormFile 인수는 null이 됩니다.If this attribute isn't set on the <form> element, the file upload won't occur and any bound IFormFile arguments will be null.