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