[Core] 파일 업로드 외 FileHelper

2025. 8. 7. 23:49·C#.NET/ASP.NET



이 코드는 파일 관련 도우미 클래스이다.
업로드만 샘플코드로 작성하고, 직접 사용 하려면 메서드 내의 주석을 보고
고쳐서 사용해야 한다.

- NET9 버전이라 이 근처의 버전에서 동작할 것으로 보인다.
- 업로드 외 유틸리티 메서드가 몇 개 더 있다.

1. FileHelper (cs) - 핵심

using Microsoft.AspNetCore.Http;
using System.IO;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Linq;
using System;
using System.Threading;
using System.Text;

namespace DogAcademy.Lib
{
    /// <summary>
    /// 파일 및 디렉터리 관련 작업을 처리하는 유틸리티 클래스.
    /// 모든 I/O 작업은 비동기로 처리하여 성능을 최적화.
    /// </summary>
    public static class FileHelper
    {
        /// <summary>
        /// IFormFile 컬렉션을 사용하여 지정된 경로에 파일들을 비동기적으로 업로드.
        /// 동일한 이름의 파일이 존재할 경우, "파일명_(n).확장자" 형태로 고유한 이름을 부여하여 저장.
        /// </summary>
        /// <param name="uploadPath">파일을 업로드할 기본 디렉터리 경로.</param>
        /// <param name="files">업로드할 파일들의 IFormFile 컬렉션.</param>
        /// <param name="cancellationToken">취소 토큰.</param>
        /// <returns>업로드 성공한 파일들의 전체 경로 리스트.</returns>
        public static async Task<List<string>> UploadFilesAsync(string uploadPath, IReadOnlyList<IFormFile> files, CancellationToken cancellationToken = default)
        {
            if (files == null || files.Count == 0) // .Any() 대신 .Count 사용
            {
                return new List<string>();
            }

            await CreateDirectoryIfNotExistsAsync(uploadPath);

            var uploadedFilePaths = new List<string>();

            foreach (var file in files)
            {
                if (file.Length > 0)
                {
                    cancellationToken.ThrowIfCancellationRequested();

                    var originalFileName = Path.GetFileName(file.FileName);
                    var fullPath = Path.Combine(uploadPath, originalFileName);
                    var uniqueFullPath = await GetUniqueFileNameAsync(fullPath);

                    try
                    {
                        using var stream = new FileStream(uniqueFullPath, FileMode.Create);
                        await file.CopyToAsync(stream, cancellationToken);

                        uploadedFilePaths.Add(uniqueFullPath);
                    }
                    catch (IOException ex)
                    {
                        throw new IOException($"'{uniqueFullPath}' 파일 저장 중 오류 발생. 파일이 다른 프로세스에 의해 사용 중이거나 디스크 공간이 부족할 수 있음.", ex);
                    }
                }
            }

            return uploadedFilePaths;
        }

        /// <summary>
        /// 지정된 경로에 파일이 존재하는지 확인.
        /// </summary>
        /// <param name="filePath">확인할 파일의 전체 경로.</param>
        /// <returns>파일 존재 여부.</returns>
        public static Task<bool> FileExistsAsync(string filePath)
        {
            return Task.FromResult(File.Exists(filePath));
        }

        /// <summary>
        /// 지정된 경로에 디렉터리가 존재하는지 확인.
        /// </summary>
        /// <param name="path">확인할 디렉터리 경로.</param>
        /// <returns>디렉터리 존재 여부.</returns>
        public static Task<bool> DirectoryExistsAsync(string path)
        {
            return Task.FromResult(Directory.Exists(path));
        }

        /// <summary>
        /// 파일 경로를 받아 중복되지 않는 고유한 파일명을 생성하여 반환.
        /// 예: 'image.jpg'가 존재하면 'image_(1).jpg' 반환.
        /// </summary>
        /// <param name="filePath">원본 파일 경로.</param>
        /// <returns>고유성이 보장된 전체 파일 경로.</returns>
        public static async Task<string> GetUniqueFileNameAsync(string filePath)
        {
            if (!await FileExistsAsync(filePath))
            {
                return filePath;
            }

            string? directory = Path.GetDirectoryName(filePath);
            string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(filePath);
            string extension = Path.GetExtension(filePath);
            int count = 1;

            string newFullPath;
            do
            {
                // Path.Combine을 사용하여 안전하게 경로 결합.
                string newFileName = $"{fileNameWithoutExtension}_({count++}){extension}";
                newFullPath = Path.Combine(directory ?? string.Empty, newFileName);
            }
            while (await FileExistsAsync(newFullPath));

            return newFullPath;
        }

        /// <summary>
        /// 지정된 경로에 디렉터리가 없으면 비동기적으로 생성.
        /// </summary>
        /// <param name="path">생성할 디렉터리 경로.</param>
        public static Task CreateDirectoryIfNotExistsAsync(string path)
        {
            if (!Directory.Exists(path))
            {
                Directory.CreateDirectory(path);
            }
            return Task.CompletedTask;
        }

        /// <summary>
        /// 지정된 파일을 비동기적으로 삭제.
        /// </summary>
        /// <param name="filePath">삭제할 파일의 전체 경로.</param>
        public static async Task DeleteFileAsync(string filePath)
        {
            try
            {
                if (await FileExistsAsync(filePath))
                {
                    File.Delete(filePath);
                }
            }
            catch (IOException ex)
            {
                throw new IOException($"'{filePath}' 파일 삭제 중 오류 발생. 다른 프로세스에서 사용 중일 수 있음.", ex);
            }
        }

        /// <summary>
        /// 지정된 디렉터리와 그 안의 모든 하위 파일/디렉터리를 재귀적으로 삭제.
        /// </summary>
        /// <param name="path">삭제할 디렉터리 경로.</param>
        public static Task DeleteDirectoryAsync(string path)
        {
            if (!Directory.Exists(path))
            {
                return Task.CompletedTask;
            }

            try
            {
                Directory.Delete(path, recursive: true);
            }
            catch (IOException ex)
            {
                throw new IOException($"'{path}' 디렉터리 삭제 중 오류 발생. 하위 파일이나 디렉터리가 사용 중일 수 있음.", ex);
            }

            return Task.CompletedTask;
        }

        /// <summary>
        /// 파일 크기를 읽기 좋은 형식(KB, MB, GB 등)의 문자열로 변환.
        /// </summary>
        /// <param name="fileBytes">파일 크기 (bytes).</param>
        /// <returns>변환된 파일 크기 문자열.</returns>
        public static string GetFileSizeString(long fileBytes)
        {
            if (fileBytes == 0) return "0 Bytes";

            string[] suf = { "Bytes", "KB", "MB", "GB", "TB", "PB", "EB" };
            int place = Convert.ToInt32(Math.Floor(Math.Log(fileBytes, 1024)));
            double num = Math.Round(fileBytes / Math.Pow(1024, place), 1);
            return $"{num} {suf[place]}";
        }

        /// <summary>
        /// 파일의 확장자를 기반으로 MIME 타입을 반환.
        /// </summary>
        /// <param name="fileName">MIME 타입을 확인할 파일 이름 또는 전체 경로.</param>
        /// <returns>MIME 타입 문자열. 알 수 없는 경우 "application/octet-stream".</returns>
        public static string GetMimeType(string fileName)
        {
            string extension = Path.GetExtension(fileName).ToLowerInvariant();
            return extension switch
            {
                ".txt" => "text/plain",
                ".pdf" => "application/pdf",
                ".doc" => "application/vnd.ms-word",
                ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
                ".xls" => "application/vnd.ms-excel",
                ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
                ".png" => "image/png",
                ".jpg" or ".jpeg" => "image/jpeg",
                ".gif" => "image/gif",
                ".csv" => "text/csv",
                ".zip" => "application/zip",
                _ => "application/octet-stream", // 알 수 없는 파일 형식에 대한 기본값
            };
        }

        /// <summary>
        /// 업로드된 파일의 확장자가 허용 목록에 포함되는지 검사. (보안 강화)
        /// </summary>
        /// <param name="file">검사할 IFormFile 객체.</param>
        /// <param name="allowedExtensions">허용할 확장자 배열 (예: new[] { ".jpg", ".png" }).</param>
        /// <returns>허용된 확장자인 경우 true.</returns>
        public static bool ValidateFileExtension(IFormFile file, string[] allowedExtensions)
        {
            if (file == null) return false;
            var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
            return allowedExtensions.Contains(ext);
        }

        /// <summary>
        /// 업로드된 파일의 크기가 최대 허용 크기를 초과하는지 검사.
        /// </summary>
        /// <param name="file">검사할 IFormFile 객체.</param>
        /// <param name="maxSizeInBytes">최대 허용 파일 크기 (bytes).</param>
        /// <returns>파일 크기가 유효한 경우 true.</returns>
        public static bool ValidateFileSize(IFormFile file, long maxSizeInBytes)
        {
            return file != null && file.Length <= maxSizeInBytes;
        }

        /// <summary>
        /// 텍스트 파일의 모든 내용을 문자열로 비동기적으로 읽음.
        /// </summary>
        /// <param name="filePath">읽어올 파일의 전체 경로.</param>
        /// <param name="cancellationToken">취소 토큰.</param>
        /// <returns>파일의 텍스트 내용.</returns>
        public static async Task<string> ReadTextAsync(string filePath, CancellationToken cancellationToken = default)
        {
            if (!File.Exists(filePath))
            {
                throw new FileNotFoundException("파일을 찾을 수 없습니다.", filePath);
            }
            return await File.ReadAllTextAsync(filePath, Encoding.UTF8, cancellationToken);
        }

        /// <summary>
        /// 주어진 문자열을 텍스트 파일에 비동기적으로 씀. 파일이 존재하면 덮어씀.
        /// </summary>
        /// <param name="filePath">쓸 파일의 전체 경로.</param>
        /// <param name="content">파일에 쓸 텍스트 내용.</param>
        /// <param name="cancellationToken">취소 토큰.</param>
        public static async Task WriteTextAsync(string filePath, string content, CancellationToken cancellationToken = default)
        {
            string? directory = Path.GetDirectoryName(filePath);
            if (directory != null)
            {
                await CreateDirectoryIfNotExistsAsync(directory);
            }
            await File.WriteAllTextAsync(filePath, content, Encoding.UTF8, cancellationToken);
        }
    }
}



2. (샘플) 컨트롤러
 - Index() 가 뷰고 나머진 샘플에선 $.ajax를 사용하기에 Upload() 는 JSONResult 이다.

using DogAcademy.Lib;
using Microsoft.AspNetCore.Mvc;

namespace DogAcademy.Controllers
{
    public class FileTestController : Controller
    {
        private readonly IWebHostEnvironment _webHostEnvironment;

        // 10MB로 최대 파일 크기 제한
        private const long MaxFileSize = 10L * 1024 * 1024;

        // 허용할 파일 확장자 목록
        private readonly string[] _allowedExtensions = { ".jpg", ".jpeg", ".png", ".gif", ".zip" };

        public FileTestController(IWebHostEnvironment webHostEnvironment)
        {
            _webHostEnvironment = webHostEnvironment;
        }

        public IActionResult Index()
        {
            return View();
        }

        [HttpPost]
        public async Task<IActionResult> Upload(List<IFormFile> files)
        {
            if (files == null || !files.Any())
            {
                return Json(new { success = false, message = "업로드할 파일을 선택해주세요." });
            }
            if (files.Count > 10)
            {
                return Json(new { success = false, message = "파일은 최대 10개까지 업로드할 수 있습니다." });
            }

            var successfulUploads = new List<object>();
            var failedUploads = new List<object>();

            foreach (var file in files)
            {
                if (!FileHelper.ValidateFileExtension(file, _allowedExtensions))
                {
                    failedUploads.Add(new { fileName = file.FileName, reason = "허용되지 않는 파일 형식입니다." });
                    continue;
                }

                if (!FileHelper.ValidateFileSize(file, MaxFileSize))
                {
                    failedUploads.Add(new { fileName = file.FileName, reason = $"파일 크기가 너무 큽니다 (최대 {FileHelper.GetFileSizeString(MaxFileSize)})." });
                    continue;
                }
            }

            var validFiles = files
                .Where(f => !failedUploads.Any(failed => (string)failed.GetType().GetProperty("fileName").GetValue(failed, null) == f.FileName))
                .ToList();

            if (!validFiles.Any() && failedUploads.Any())
            {
                return Json(new { success = false, message = "모든 파일이 유효성 검사에 실패했습니다.", errors = failedUploads });
            }

            try
            {                
                var basePath = Path.Combine(_webHostEnvironment.WebRootPath, "Files");
                var yearMonthFolder = DateTime.Now.ToString("yyyyMM");
                var uploadPath = Path.Combine(basePath, yearMonthFolder);

                var uploadedPaths = await FileHelper.UploadFilesAsync(uploadPath, validFiles);

                foreach (var path in uploadedPaths)
                {
                    var fileInfo = new FileInfo(path);
                    successfulUploads.Add(new
                    {
                        fileName = fileInfo.Name,
                        size = FileHelper.GetFileSizeString(fileInfo.Length),                        
                        path = $"/uploads/{yearMonthFolder}/{fileInfo.Name}"
                    });
                }

                string status;
                if (successfulUploads.Any() && !failedUploads.Any())
                    status = "allSuccess";
                else if (!successfulUploads.Any() && failedUploads.Any())
                    status = "allFailure";
                else
                    status = "partialSuccess";

                return Json(new { status, successes = successfulUploads, errors = failedUploads });

            }
            catch (Exception ex)
            {
                return Json(new { success = false, message = $"서버 오류가 발생했습니다: {ex.Message}" });
            }
        }

    }
}



3. (샘플) 뷰

@{
    ViewData["Title"] = "파일 업로드";
}

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>@ViewData["Title"]</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
    <style>
        /* CSS는 이전과 동일 */
        body {
            background-color: #f8f9fa;
        }

        .upload-container {
            max-width: 800px;
            margin: 50px auto;
            padding: 30px;
            background-color: #fff;
            border-radius: 10px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.1);
        }

        .file-drop-area {
            border: 2px dashed #0d6efd;
            border-radius: 5px;
            padding: 40px;
            text-align: center;
            color: #0d6efd;
            cursor: pointer;
            transition: background-color 0.2s;
        }

            .file-drop-area.is-active {
                background-color: #e9ecef;
            }

            .file-drop-area .fake-btn {
                margin-top: 10px;
            }

        #file-list .list-group-item {
            display: flex;
            justify-content: space-between;
            align-items: center;
        }

        .spinner-border {
            display: none;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="upload-container">
            <h2 class="text-center mb-4"><i class="fa fa-upload"></i> 파일 업로드</h2>

            <form id="uploadForm" enctype="multipart/form-data">
                <label for="fileInput" class="file-drop-area mb-3">
                    <i class="fa fa-cloud-upload-alt fa-3x"></i>
                    <p class="mt-2">여기에 파일을 드래그 앤 드롭하거나 클릭하여 선택하세요.</p>
                    <p class="small text-muted">최대 10개까지 업로드 가능</p>
                    <span class="btn btn-primary fake-btn">파일 선택</span>
                </label>
                <input type="file" id="fileInput" name="files" multiple class="d-none">
            </form>

            <div id="file-list" class="mb-3"></div>

            <div class="d-grid gap-2">
                <button type="button" id="uploadButton" class="btn btn-success btn-lg">
                    <span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
                    업로드 시작
                </button>
            </div>

            <div id="result" class="mt-4"></div>
        </div>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>

    <script>
        $(document).ready(function () {
            // ... 파일 선택 및 목록 업데이트 로직은 이전과 동일 ...
            // region 파일 선택 및 목록 로직 (변경 없음)
            const fileInput = $('#fileInput');
            const fileDropArea = $('.file-drop-area');
            const fileListContainer = $('#file-list');
            const uploadButton = $('#uploadButton');

            fileDropArea.on('dragover', function (e) {
                e.preventDefault();
                $(this).addClass('is-active');
            });
            fileDropArea.on('dragleave', function (e) {
                e.preventDefault();
                $(this).removeClass('is-active');
            });
            fileDropArea.on('drop', function (e) {
                e.preventDefault();
                $(this).removeClass('is-active');
                const files = e.originalEvent.dataTransfer.files;
                fileInput.prop('files', files);
                updateFileList();
            });

            fileInput.on('change', updateFileList);

            function updateFileList() {
                fileListContainer.html('');
                const files = fileInput[0].files;

                if (files.length > 10) {
                    alert('파일은 최대 10개까지 선택할 수 있습니다.');
                    fileInput.val('');
                    return;
                }

                if (files.length > 0) {
                    const list = $('<ul class="list-group"></ul>');
                    for (let i = 0; i < files.length; i++) {
                        const file = files[i];
                        const fileSize = (file.size / 1024 / 1024).toFixed(2) + ' MB';
                        list.append(`<li class="list-group-item"><span><i class="fa fa-file me-2"></i>${file.name}</span><span class="badge bg-secondary">${fileSize}</span></li>`);
                    }
                    fileListContainer.append(list);
                }
            }
            // endregion

            uploadButton.on('click', function () {
                const files = fileInput[0].files;

                if (files.length === 0) {
                    alert('업로드할 파일을 선택해주세요.');
                    return;
                }

                if (files.length > 10) {
                    alert('파일은 최대 10개까지 업로드할 수 있습니다.');
                    return;
                }

                const formData = new FormData();
                for (let i = 0; i < files.length; i++) {
                    formData.append('files', files[i]);
                }

                const spinner = $(this).find('.spinner-border');
                spinner.show();
                $(this).prop('disabled', true);
                $('#result').html('');

                $.ajax({                    
                    url: '@Url.Action("Upload", "FileTest")',
                    type: 'POST',
                    data: formData,
                    processData: false,
                    contentType: false,                    
                    success: function (response) {
                        let resultHtml = '';

                        // 성공 목록을 만드는 헬퍼 함수
                        const buildSuccessList = (items) => {
                            let list = '<h5><i class="fa fa-check-circle text-success"></i> 성공</h5><ul class="list-group mb-3">';
                            items.forEach(f => {
                                list += `<li class="list-group-item list-group-item-success d-flex justify-content-between align-items-center">${f.fileName} <span class="badge bg-success">${f.size}</span></li>`;
                            });
                            return list + '</ul>';
                        };

                        // 실패 목록을 만드는 헬퍼 함수
                        const buildErrorList = (items) => {
                            let list = '<h5><i class="fa fa-times-circle text-danger"></i> 실패</h5><ul class="list-group">';
                            items.forEach(e => {
                                list += `<li class="list-group-item list-group-item-danger">${e.fileName} - <span class="text-danger small">${e.reason}</span></li>`;
                            });
                            return list + '</ul>';
                        };

                        // 컨트롤러에서 받은 status 값에 따라 분기 처리
                        switch (response.status) {
                            case 'allSuccess':
                                resultHtml += '<div class="alert alert-success"><strong>업로드 완료!</strong> 모든 파일이 성공적으로 업로드되었습니다.</div>';
                                resultHtml += buildSuccessList(response.successes);
                                break;
                            case 'allFailure':
                                resultHtml += `<div class="alert alert-danger"><strong>업로드 실패!</strong> ${response.message || '모든 파일이 유효성 검사에 실패했습니다.'}</div>`;
                                if (response.errors) {
                                    resultHtml += buildErrorList(response.errors);
                                }
                                break;
                            case 'partialSuccess':
                                resultHtml += '<div class="alert alert-warning"><strong>부분 성공!</strong> 일부 파일은 성공하고 일부는 실패했습니다.</div>';
                                if (response.successes && response.successes.length > 0) {
                                    resultHtml += buildSuccessList(response.successes);
                                }
                                if (response.errors && response.errors.length > 0) {
                                    resultHtml += buildErrorList(response.errors);
                                }
                                break;
                            default: // 서버 에러 등 예외 케이스
                                resultHtml = `<div class="alert alert-danger"><strong>오류 발생:</strong> ${response.message || '알 수 없는 오류가 발생했습니다.'}</div>`;
                                break;
                        }

                        $('#result').html(resultHtml);
                        fileInput.val('');
                        fileListContainer.html('');
                    },
                    error: function (xhr, status, error) {
                        $('#result').html(`<div class="alert alert-danger"><strong>오류 발생:</strong> 서버와 통신할 수 없습니다. (${error})</div>`);
                    },
                    complete: function () {
                        spinner.hide();
                        uploadButton.prop('disabled', false);
                    }
                });
            });
        });
    </script>
</body>
</html>



4. 살펴보기

 

'C#.NET > ASP.NET' 카테고리의 다른 글

더미 이미지 생성 (엑박 방지)  (0) 2025.09.03
ModelState 를 사용한 서버측 유효성 검사  (0) 2025.08.18
[Core] 핫리로드 NET6+ (새로고침시 적용)  (4) 2025.08.07
[Core] EPPlus를 사용한 엑셀다운로드 및 파일읽기  (3) 2025.08.04
IP 주소 받아오기 (외부API 사용 포함)  (1) 2025.08.03
'C#.NET/ASP.NET' 카테고리의 다른 글
  • 더미 이미지 생성 (엑박 방지)
  • ModelState 를 사용한 서버측 유효성 검사
  • [Core] 핫리로드 NET6+ (새로고침시 적용)
  • [Core] EPPlus를 사용한 엑셀다운로드 및 파일읽기
iyak
iyak
C#, ASP.NET, MSSQL을 주로 다룹니다
  • iyak
    iyak
    나의 정리 공간

    post | manage
  • 전체
    오늘
    어제
    • 분류 전체보기 (72)
      • C#.NET (35)
        • C# (13)
        • ASP.NET (20)
        • WinForm (0)
        • 설정 (2)
      • JAVA,Spring (0)
        • Spring (0)
      • DB (15)
        • SQLServer (15)
        • MySQL & MaridDB (0)
      • Web (18)
        • JS & jQuery (16)
        • Web개발 관련사이트 (2)
      • 기타 (4)
        • 프로그램 (4)
  • 블로그 메뉴

    • 홈
    • 태그
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    ModelState
    Skeleton
    await
    Dapper
    페이징
    EFCore
    async
    CTE
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.0
iyak
[Core] 파일 업로드 외 FileHelper
상단으로

티스토리툴바