재우니 개발자 블로그

ASP.NET Core 8에서 Amazon S3와 CloudFront를 활용한 파일 CRUD 구현

ASP.NET Core 8 환경에서 Amazon S3와 CloudFront를 활용하여 안전하고 성능이 좋은 파일 CRUD 기능을 구현하는 방법을 설명해 드리겠습니다.

1. 아키텍처 개요

사용자 요청 → ASP.NET Core API → AWS SDK → S3 버킷 
                    ↑                   ↓
                    └───── CloudFront ──┘
  • S3: 파일 실제 저장소
  • CloudFront: 콘텐츠 전송 네트워크(CDN)로 파일 접근 성능 향상
  • ASP.NET Core API: 비즈니스 로직과 인증/인가 처리

2. 준비 사항

2.1 NuGet 패키지 설치

dotnet add package AWSSDK.S3
dotnet add package AWSSDK.CloudFront
dotnet add package AWSSDK.Extensions.NETCore.Setup

2.2 AWS 구성 설정

  1. S3 버킷 생성 및 접근 정책 설정
  2. CloudFront 배포 구성 (Origin Access Identity 설정)
  3. IAM 사용자 생성 및 적절한 권한 설정

3. 구현

3.1 의존성 주입 설정 (Program.cs)

using Amazon.S3;
using Amazon.CloudFront;
using Amazon.Extensions.NETCore.Setup;

var builder = WebApplication.CreateBuilder(args);

// AWS 서비스 등록
builder.Services.AddAWSService<IAmazonS3>(new AWSOptions
{
    Region = Amazon.RegionEndpoint.APNortheast2 // 서울 리전
});

builder.Services.AddAWSService<IAmazonCloudFront>(new AWSOptions 
{
    Region = Amazon.RegionEndpoint.APNortheast2
});

// 파일 서비스 등록
builder.Services.AddScoped<IFileService, S3FileService>();

// API 컨트롤러 등록
builder.Services.AddControllers();

var app = builder.Build();

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

3.2 파일 서비스 인터페이스 정의

public interface IFileService
{
    Task<FileUploadResult> UploadFileAsync(IFormFile file, string directory, CancellationToken cancellationToken = default);
    Task<byte[]> DownloadFileAsync(string key, CancellationToken cancellationToken = default);
    Task<string> GetPresignedUrlAsync(string key, TimeSpan expiry, CancellationToken cancellationToken = default);
    Task<string> GetCloudFrontUrlAsync(string key, DateTime expiryTime, CancellationToken cancellationToken = default);
    Task<bool> DeleteFileAsync(string key, CancellationToken cancellationToken = default);
    Task<bool> UpdateFileAsync(string key, IFormFile newFile, CancellationToken cancellationToken = default);
}

public class FileUploadResult
{
    public bool Success { get; set; }
    public string Key { get; set; }
    public string Url { get; set; }
    public string ErrorMessage { get; set; }
}

3.3 S3 파일 서비스 구현

using Amazon.S3;
using Amazon.S3.Model;
using Amazon.CloudFront;
using Amazon.CloudFront.Model;
using Microsoft.Extensions.Configuration;
using System.Security.Cryptography;

public class S3FileService : IFileService
{
    private readonly IAmazonS3 _s3Client;
    private readonly IAmazonCloudFront _cloudFrontClient;
    private readonly string _bucketName;
    private readonly string _cloudFrontDomain;
    private readonly string _cloudFrontKeyPairId;
    private readonly string _cloudFrontPrivateKey;

    public S3FileService(
        IAmazonS3 s3Client,
        IAmazonCloudFront cloudFrontClient,
        IConfiguration configuration)
    {
        _s3Client = s3Client;
        _cloudFrontClient = cloudFrontClient;
        _bucketName = configuration["AWS:S3:BucketName"];
        _cloudFrontDomain = configuration["AWS:CloudFront:Domain"];
        _cloudFrontKeyPairId = configuration["AWS:CloudFront:KeyPairId"];
        _cloudFrontPrivateKey = configuration["AWS:CloudFront:PrivateKey"];
    }

    public async Task<FileUploadResult> UploadFileAsync(IFormFile file, string directory, CancellationToken cancellationToken = default)
    {
        try
        {
            // 파일명 충돌 방지를 위한 유니크 키 생성
            var fileName = Path.GetFileName(file.FileName);
            var fileExtension = Path.GetExtension(fileName);
            var uniqueFileName = $"{Guid.NewGuid()}{fileExtension}";
            var key = string.IsNullOrEmpty(directory) 
                ? uniqueFileName 
                : $"{directory.TrimEnd('/')}/{uniqueFileName}";

            // 메타데이터 설정
            var metadata = new Dictionary<string, string>
            {
                { "original-filename", fileName },
                { "content-type", file.ContentType },
                { "upload-date", DateTime.UtcNow.ToString("o") }
            };

            using var fileStream = file.OpenReadStream();
            var putObjectRequest = new PutObjectRequest
            {
                BucketName = _bucketName,
                Key = key,
                InputStream = fileStream,
                ContentType = file.ContentType,
                Metadata = metadata
            };

            await _s3Client.PutObjectAsync(putObjectRequest, cancellationToken);

            // CloudFront URL 생성
            var cloudFrontUrl = $"https://{_cloudFrontDomain}/{key}";

            return new FileUploadResult
            {
                Success = true,
                Key = key,
                Url = cloudFrontUrl
            };
        }
        catch (Exception ex)
        {
            return new FileUploadResult
            {
                Success = false,
                ErrorMessage = ex.Message
            };
        }
    }

    public async Task<byte[]> DownloadFileAsync(string key, CancellationToken cancellationToken = default)
    {
        try
        {
            var request = new GetObjectRequest
            {
                BucketName = _bucketName,
                Key = key
            };

            using var response = await _s3Client.GetObjectAsync(request, cancellationToken);
            using var responseStream = response.ResponseStream;
            using var memoryStream = new MemoryStream();
            
            await responseStream.CopyToAsync(memoryStream, cancellationToken);
            return memoryStream.ToArray();
        }
        catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
        {
            throw new FileNotFoundException($"File with key '{key}' not found", ex);
        }
    }

    public async Task<string> GetPresignedUrlAsync(string key, TimeSpan expiry, CancellationToken cancellationToken = default)
    {
        var request = new GetPreSignedUrlRequest
        {
            BucketName = _bucketName,
            Key = key,
            Expires = DateTime.UtcNow.Add(expiry)
        };

        return await Task.FromResult(_s3Client.GetPreSignedURL(request));
    }

    public async Task<string> GetCloudFrontUrlAsync(string key, DateTime expiryTime, CancellationToken cancellationToken = default)
    {
        // CloudFront 서명된 URL을 생성
        var resourceUrl = $"https://{_cloudFrontDomain}/{key}";
        var policy = CreateCustomPolicy(resourceUrl, expiryTime);

        // 서명 생성
        string signature = CreatePolicySignature(policy);

        // 서명된 URL 생성
        var signedUrl = $"{resourceUrl}?Policy={UrlSafeBase64Encode(policy)}&Signature={signature}&Key-Pair-Id={_cloudFrontKeyPairId}";
        
        return await Task.FromResult(signedUrl);
    }

    public async Task<bool> DeleteFileAsync(string key, CancellationToken cancellationToken = default)
    {
        try
        {
            var deleteRequest = new DeleteObjectRequest
            {
                BucketName = _bucketName,
                Key = key
            };

            var response = await _s3Client.DeleteObjectAsync(deleteRequest, cancellationToken);
            return response.HttpStatusCode == System.Net.HttpStatusCode.NoContent;
        }
        catch
        {
            return false;
        }
    }

    public async Task<bool> UpdateFileAsync(string key, IFormFile newFile, CancellationToken cancellationToken = default)
    {
        try
        {
            // 기존 파일이 있는지 확인
            var existingObject = await _s3Client.GetObjectMetadataAsync(
                new GetObjectMetadataRequest
                {
                    BucketName = _bucketName,
                    Key = key
                }, 
                cancellationToken);

            // 파일이 존재하면 덮어쓰기
            using var fileStream = newFile.OpenReadStream();
            var putObjectRequest = new PutObjectRequest
            {
                BucketName = _bucketName,
                Key = key,
                InputStream = fileStream,
                ContentType = newFile.ContentType,
                Metadata = new Dictionary<string, string>
                {
                    { "original-filename", Path.GetFileName(newFile.FileName) },
                    { "content-type", newFile.ContentType },
                    { "update-date", DateTime.UtcNow.ToString("o") }
                }
            };

            var response = await _s3Client.PutObjectAsync(putObjectRequest, cancellationToken);
            return response.HttpStatusCode == System.Net.HttpStatusCode.OK;
        }
        catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
        {
            // 파일이 존재하지 않음
            return false;
        }
    }

    #region CloudFront 서명 유틸리티

    private string CreateCustomPolicy(string resourceUrl, DateTime expiryTime)
    {
        var epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
        var epochSeconds = (long)(expiryTime - epoch).TotalSeconds;

        return $@"{{
            ""Statement"": [
                {{
                    ""Resource"": ""{resourceUrl}"",
                    ""Condition"": {{
                        ""DateLessThan"": {{
                            ""AWS:EpochTime"": {epochSeconds}
                        }}
                    }}
                }}
            ]
        }}";
    }

    private string CreatePolicySignature(string policy)
    {
        // 실제 구현에서는 CloudFront 개인 키를 로드하고 서명을 생성해야 함
        // 여기서는 단순화된 예제 제공
        var privateKeyBytes = Convert.FromBase64String(_cloudFrontPrivateKey);
        
        using var rsa = RSA.Create();
        rsa.ImportRSAPrivateKey(privateKeyBytes, out _);
        
        var policyBytes = System.Text.Encoding.UTF8.GetBytes(policy);
        var signatureBytes = rsa.SignData(policyBytes, HashAlgorithmName.SHA1, RSASignaturePadding.Pkcs1);
        
        return UrlSafeBase64Encode(Convert.ToBase64String(signatureBytes));
    }

    private string UrlSafeBase64Encode(string input)
    {
        return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(input))
            .Replace('+', '-')
            .Replace('=', '_')
            .Replace('/', '~');
    }

    #endregion
}

3.4 파일 컨트롤러 구현

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;

[ApiController]
[Route("api/[controller]")]
public class FilesController : ControllerBase
{
    private readonly IFileService _fileService;
    private readonly ILogger<FilesController> _logger;

    public FilesController(IFileService fileService, ILogger<FilesController> logger)
    {
        _fileService = fileService;
        _logger = logger;
    }

    [HttpPost("upload")]
    [Authorize]
    [RequestSizeLimit(20 * 1024 * 1024)] // 20MB 제한
    public async Task<IActionResult> UploadFile(IFormFile file, [FromQuery] string directory = "")
    {
        if (file == null || file.Length == 0)
            return BadRequest("파일이 제공되지 않았습니다.");

        // 파일 유효성 검사
        if (!IsValidFile(file))
            return BadRequest("허용되지 않는 파일 형식입니다.");

        // 사용자 ID 가져오기 (인증된 사용자)
        var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
        
        // 사용자별 디렉토리 설정 (선택사항)
        var userDirectory = string.IsNullOrEmpty(directory) 
            ? $"users/{userId}" 
            : $"users/{userId}/{directory}";

        try
        {
            var result = await _fileService.UploadFileAsync(file, userDirectory);
            
            if (!result.Success)
                return StatusCode(500, result.ErrorMessage);

            // 업로드 결과를 데이터베이스에 기록하는 로직을 여기에 추가

            return Ok(new { 
                key = result.Key, 
                url = result.Url,
                fileName = file.FileName,
                contentType = file.ContentType,
                size = file.Length
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "파일 업로드 중 오류가 발생했습니다.");
            return StatusCode(500, "파일 업로드 중 오류가 발생했습니다.");
        }
    }

    [HttpGet("download/{key}")]
    [Authorize]
    public async Task<IActionResult> DownloadFile(string key)
    {
        try
        {
            // 접근 권한 검증 (사용자가 해당 파일에 접근할 권한이 있는지)
            if (!await UserHasAccessToFile(key))
                return Forbid();

            // 직접 파일 다운로드
            var fileBytes = await _fileService.DownloadFileAsync(key);
            
            // ContentType 추측 (실제 구현에서는 DB에서 조회하거나 메타데이터에서 가져올 수 있음)
            var contentType = GetContentTypeFromFileName(key);
            
            return File(fileBytes, contentType, Path.GetFileName(key));
        }
        catch (FileNotFoundException)
        {
            return NotFound("요청한 파일을 찾을 수 없습니다.");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "파일 다운로드 중 오류가 발생했습니다.");
            return StatusCode(500, "파일 다운로드 중 오류가 발생했습니다.");
        }
    }

    [HttpGet("url/{key}")]
    [Authorize]
    public async Task<IActionResult> GetFileUrl(string key, [FromQuery] bool useCdn = true)
    {
        try
        {
            // 접근 권한 검증
            if (!await UserHasAccessToFile(key))
                return Forbid();

            string url;
            if (useCdn)
            {
                // CloudFront를 통한 서명된 URL (24시간 유효)
                url = await _fileService.GetCloudFrontUrlAsync(key, DateTime.UtcNow.AddHours(24));
            }
            else
            {
                // S3 미리 서명된 URL (1시간 유효)
                url = await _fileService.GetPresignedUrlAsync(key, TimeSpan.FromHours(1));
            }
            
            return Ok(new { url });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "파일 URL 생성 중 오류가 발생했습니다.");
            return StatusCode(500, "파일 URL 생성 중 오류가 발생했습니다.");
        }
    }

    [HttpDelete("{key}")]
    [Authorize]
    public async Task<IActionResult> DeleteFile(string key)
    {
        try
        {
            // 접근 권한 검증
            if (!await UserHasAccessToFile(key))
                return Forbid();

            var result = await _fileService.DeleteFileAsync(key);
            
            if (!result)
                return StatusCode(500, "파일 삭제 중 오류가 발생했습니다.");

            // 데이터베이스에서 파일 기록 삭제하는 로직 추가

            return NoContent();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "파일 삭제 중 오류가 발생했습니다.");
            return StatusCode(500, "파일 삭제 중 오류가 발생했습니다.");
        }
    }

    [HttpPut("{key}")]
    [Authorize]
    [RequestSizeLimit(20 * 1024 * 1024)] // 20MB 제한
    public async Task<IActionResult> UpdateFile(string key, IFormFile file)
    {
        if (file == null || file.Length == 0)
            return BadRequest("파일이 제공되지 않았습니다.");

        // 파일 유효성 검사
        if (!IsValidFile(file))
            return BadRequest("허용되지 않는 파일 형식입니다.");

        try
        {
            // 접근 권한 검증
            if (!await UserHasAccessToFile(key))
                return Forbid();

            var result = await _fileService.UpdateFileAsync(key, file);
            
            if (!result)
                return NotFound("업데이트할 파일을 찾을 수 없습니다.");

            // 데이터베이스 파일 기록 업데이트 로직 추가

            return Ok(new { 
                message = "파일이 성공적으로 업데이트되었습니다.", 
                fileName = file.FileName,
                contentType = file.ContentType,
                size = file.Length
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "파일 업데이트 중 오류가 발생했습니다.");
            return StatusCode(500, "파일 업데이트 중 오류가 발생했습니다.");
        }
    }

    #region 헬퍼 메서드

    // 실제 구현에서는 사용자와 파일 간의 관계를 DB에서 확인
    private Task<bool> UserHasAccessToFile(string key)
    {
        var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
        
        // 파일 키가 사용자 디렉토리에 있는지 확인
        var userDirectory = $"users/{userId}";
        var hasAccess = key.StartsWith(userDirectory) || User.IsInRole("Admin");
        
        return Task.FromResult(hasAccess);
    }

    private bool IsValidFile(IFormFile file)
    {
        // 허용된 파일 형식 및 확장자 검사
        var allowedExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx" };
        var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
        
        if (string.IsNullOrEmpty(extension) || !allowedExtensions.Contains(extension))
            return false;
            
        // 추가적인 파일 유효성 검사 (파일 시그니처 확인 등)
        return true;
    }

    private string GetContentTypeFromFileName(string fileName)
    {
        var extension = Path.GetExtension(fileName).ToLowerInvariant();
        return extension switch
        {
            ".jpg" or ".jpeg" => "image/jpeg",
            ".png" => "image/png",
            ".gif" => "image/gif",
            ".pdf" => "application/pdf",
            ".doc" => "application/msword",
            ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
            ".xls" => "application/vnd.ms-excel",
            ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            ".ppt" => "application/vnd.ms-powerpoint",
            ".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
            _ => "application/octet-stream"
        };
    }

    #endregion
}

3.5 구성 파일 (appsettings.json)

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "AWS": {
    "Profile": "default",
    "Region": "ap-northeast-2",
    "S3": {
      "BucketName": "your-bucket-name"
    },
    "CloudFront": {
      "Domain": "your-distribution-id.cloudfront.net",
      "KeyPairId": "your-key-pair-id",
      "PrivateKey": "your-private-key-base64-encoded"
    }
  }
}

4. 보안 고려사항

  1. 접근 제어
    • S3 버킷에 직접 접근 제한 (CloudFront Origin Access Identity 사용)
    • 인증된 사용자만 파일 접근 가능하도록 인증 적용
    • 사용자별 디렉토리 구조 적용 (격리)
  2. URL 보안
    • 서명된 URL을 통한 제한된 시간 동안만 접근 허용
    • CloudFront 서명을 통한 무단 접근 방지
  3. 파일 검증
    • 업로드 전 파일 크기 및 유형 검증
    • 파일 확장자 제한 및 시그니처 확인
  4. 암호화
    • S3에 저장 시 서버 측 암호화 (SSE) 적용
    • HTTPS를 통한 전송 중 암호화

5. 성능 최적화

  1. CloudFront CDN 활용
    • 전 세계 엣지 로케이션을 통한 콘텐츠 캐싱
    • 지연 시간 감소 및 대역폭 비용 절감
  2. 서명된 URL 캐싱
    • 자주 액세스하는 파일의 URL 캐싱
    • 메모리 캐시 또는 Redis 활용
  3. 비동기 처리
    • 대용량 파일 처리를 위한 비동기 업로드/다운로드
    • 백그라운드 작업으로 파일 처리
  4. 스트리밍 지원
    • 대용량 파일의 경우 스트리밍 다운로드 지원
    • 부분 요청(Partial Request) 지원

6. 확장성 고려사항

  1. 데이터베이스 연동
    • 파일 메타데이터를 데이터베이스에 저장
    • 사용자-파일 관계 관리 및 검색 지원
  2. 분산 처리
    • 대용량 파일 작업을 위한 백그라운드 서비스 구현
    • SQS나 다른 메시지 큐를 활용한 비동기 처리

7. 운영 고려사항

  1. 로깅 및 모니터링
    • 파일 액세스 로그 추적
    • CloudWatch 알람 설정으로 이상 징후 감지
  2. 비용 최적화
    • 불필요한 파일 주기적 정리
    • 스토리지 클래스 최적화 (자주 접근하지 않는 파일은 저비용 스토리지로 이동)
  3. 백업 전략
    • 중요 파일 백업 정책 수립
    • 버전 관리 활성화

 

 


 

 

 

ASP.NET Core MVC에서 Razor 뷰와 JavaScript를 활용하여 파일 업로드, 다운로드, 목록 조회, 삭제 기능을 구현하겠습니다.

@{
    ViewData["Title"] = "파일 관리";
}

<div class="container mt-4">
    <h2>파일 관리</h2>
    
    <!-- 파일 업로드 섹션 -->
    <div class="card mb-4">
        <div class="card-header">
            <h5 class="mb-0">파일 업로드</h5>
        </div>
        <div class="card-body">
            <form id="fileUploadForm" enctype="multipart/form-data">
                <div class="mb-3">
                    <label for="directory" class="form-label">디렉토리 (선택사항)</label>
                    <input type="text" class="form-control" id="directory" name="directory" placeholder="예: 문서/2025">
                </div>
                
                <div class="mb-3">
                    <label for="fileInput" class="form-label">파일 선택</label>
                    <input type="file" class="form-control" id="fileInput" name="file" required>
                    <div class="form-text">최대 20MB까지 업로드 가능. 지원 파일 형식: JPG, PNG, GIF, PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX</div>
                </div>
                
                <div class="progress mb-3 d-none" id="uploadProgressContainer">
                    <div class="progress-bar" role="progressbar" id="uploadProgressBar" style="width: 0%"></div>
                </div>
                
                <button type="submit" class="btn btn-primary" id="uploadButton">
                    <span class="spinner-border spinner-border-sm d-none" id="uploadSpinner"></span>
                    업로드
                </button>
            </form>
        </div>
    </div>
    
    <!-- 파일 검색 섹션 -->
    <div class="card mb-4">
        <div class="card-header d-flex justify-content-between align-items-center">
            <h5 class="mb-0">파일 목록</h5>
            <div class="d-flex">
                <input type="text" class="form-control form-control-sm me-2" id="searchInput" placeholder="파일명 검색...">
                <button class="btn btn-sm btn-outline-secondary" id="refreshButton">
                    <i class="bi bi-arrow-clockwise"></i>
                </button>
            </div>
        </div>
        <div class="card-body">
            <div class="table-responsive">
                <table class="table table-hover">
                    <thead>
                        <tr>
                            <th>파일명</th>
                            <th>유형</th>
                            <th>크기</th>
                            <th>업로드 일시</th>
                            <th>작업</th>
                        </tr>
                    </thead>
                    <tbody id="fileListBody">
                        <!-- 자바스크립트로 데이터 로드 -->
                    </tbody>
                </table>
            </div>
            
            <div id="fileListEmpty" class="text-center py-4 d-none">
                <p class="text-muted mb-0">파일이 없습니다.</p>
            </div>
            
            <div id="fileListLoading" class="text-center py-4">
                <div class="spinner-border text-primary" role="status">
                    <span class="visually-hidden">로딩 중...</span>
                </div>
            </div>
            
            <!-- 페이지네이션 -->
            <nav id="pagination" class="d-none">
                <ul class="pagination justify-content-center" id="paginationContainer">
                    <!-- 자바스크립트로 페이지네이션 생성 -->
                </ul>
            </nav>
        </div>
    </div>
    
    <!-- 파일 상세 모달 -->
    <div class="modal fade" id="fileDetailModal" tabindex="-1" aria-hidden="true">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title" id="fileDetailTitle">파일 상세</h5>
                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
                </div>
                <div class="modal-body">
                    <div class="mb-3 text-center" id="filePreviewContainer">
                        <!-- 이미지 미리보기가 표시될 곳 -->
                    </div>
                    
                    <table class="table table-sm">
                        <tbody>
                            <tr>
                                <th>파일명</th>
                                <td id="detailFileName"></td>
                            </tr>
                            <tr>
                                <th>키</th>
                                <td id="detailFileKey"></td>
                            </tr>
                            <tr>
                                <th>유형</th>
                                <td id="detailFileType"></td>
                            </tr>
                            <tr>
                                <th>크기</th>
                                <td id="detailFileSize"></td>
                            </tr>
                            <tr>
                                <th>업로드</th>
                                <td id="detailUploadDate"></td>
                            </tr>
                        </tbody>
                    </table>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-outline-danger" id="deleteFileBtn">삭제</button>
                    <button type="button" class="btn btn-primary" id="downloadFileBtn">다운로드</button>
                    <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">닫기</button>
                </div>
            </div>
        </div>
    </div>
    
    <!-- 파일 업데이트 모달 -->
    <div class="modal fade" id="fileUpdateModal" tabindex="-1" aria-hidden="true">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">파일 업데이트</h5>
                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
                </div>
                <div class="modal-body">
                    <form id="fileUpdateForm" enctype="multipart/form-data">
                        <input type="hidden" id="updateFileKey" name="key">
                        
                        <div class="mb-3">
                            <label for="updateFileInput" class="form-label">새 파일 선택</label>
                            <input type="file" class="form-control" id="updateFileInput" name="file" required>
                        </div>
                        
                        <div class="progress mb-3 d-none" id="updateProgressContainer">
                            <div class="progress-bar" role="progressbar" id="updateProgressBar" style="width: 0%"></div>
                        </div>
                    </form>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">취소</button>
                    <button type="button" class="btn btn-primary" id="updateFileBtn">
                        <span class="spinner-border spinner-border-sm d-none" id="updateSpinner"></span>
                        업데이트
                    </button>
                </div>
            </div>
        </div>
    </div>
</div>

@section Scripts {
    <script src="~/js/file-management.js" asp-append-version="true"></script>
}

 

 

// 파일 관리 JavaScript
document.addEventListener('DOMContentLoaded', function() {
    // 상수 및 설정
    const API_URL = '/api/files';
    const PAGE_SIZE = 10;
    
    // DOM 요소
    const fileUploadForm = document.getElementById('fileUploadForm');
    const fileInput = document.getElementById('fileInput');
    const directoryInput = document.getElementById('directory');
    const uploadButton = document.getElementById('uploadButton');
    const uploadSpinner = document.getElementById('uploadSpinner');
    const uploadProgressContainer = document.getElementById('uploadProgressContainer');
    const uploadProgressBar = document.getElementById('uploadProgressBar');
    const fileListBody = document.getElementById('fileListBody');
    const fileListEmpty = document.getElementById('fileListEmpty');
    const fileListLoading = document.getElementById('fileListLoading');
    const searchInput = document.getElementById('searchInput');
    const refreshButton = document.getElementById('refreshButton');
    const pagination = document.getElementById('pagination');
    const paginationContainer = document.getElementById('paginationContainer');
    
    // 파일 상세 모달 요소
    const fileDetailModal = new bootstrap.Modal(document.getElementById('fileDetailModal'));
    const fileDetailTitle = document.getElementById('fileDetailTitle');
    const filePreviewContainer = document.getElementById('filePreviewContainer');
    const detailFileName = document.getElementById('detailFileName');
    const detailFileKey = document.getElementById('detailFileKey');
    const detailFileType = document.getElementById('detailFileType');
    const detailFileSize = document.getElementById('detailFileSize');
    const detailUploadDate = document.getElementById('detailUploadDate');
    const downloadFileBtn = document.getElementById('downloadFileBtn');
    const deleteFileBtn = document.getElementById('deleteFileBtn');
    
    // 파일 업데이트 모달 요소
    const fileUpdateModal = new bootstrap.Modal(document.getElementById('fileUpdateModal'));
    const fileUpdateForm = document.getElementById('fileUpdateForm');
    const updateFileKey = document.getElementById('updateFileKey');
    const updateFileInput = document.getElementById('updateFileInput');
    const updateFileBtn = document.getElementById('updateFileBtn');
    const updateSpinner = document.getElementById('updateSpinner');
    const updateProgressContainer = document.getElementById('updateProgressContainer');
    const updateProgressBar = document.getElementById('updateProgressBar');
    
    // 상태 변수
    let currentPage = 1;
    let totalPages = 1;
    let fileList = [];
    let currentFileKey = null;
    let searchTerm = '';
    
    // 초기화
    loadFileList();
    
    // 이벤트 리스너
    fileUploadForm.addEventListener('submit', handleFileUpload);
    searchInput.addEventListener('input', handleSearch);
    refreshButton.addEventListener('click', () => loadFileList());
    downloadFileBtn.addEventListener('click', downloadCurrentFile);
    deleteFileBtn.addEventListener('click', deleteCurrentFile);
    updateFileBtn.addEventListener('click', updateCurrentFile);
    
    // 파일 업로드 처리
    function handleFileUpload(e) {
        e.preventDefault();
        
        const file = fileInput.files[0];
        if (!file) {
            showToast('warning', '파일을 선택해주세요.');
            return;
        }
        
        // 파일 크기 검증 (20MB)
        if (file.size > 20 * 1024 * 1024) {
            showToast('error', '파일 크기는 20MB를 초과할 수 없습니다.');
            return;
        }
        
        // 파일 형식 검증
        const validExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx'];
        const fileExtension = '.' + file.name.split('.').pop().toLowerCase();
        if (!validExtensions.includes(fileExtension)) {
            showToast('error', '지원되지 않는 파일 형식입니다.');
            return;
        }
        
        // UI 업데이트
        setUploading(true);
        
        // FormData 생성
        const formData = new FormData();
        formData.append('file', file);
        
        const directory = directoryInput.value.trim();
        if (directory) {
            formData.append('directory', directory);
        }
        
        // AJAX 요청
        const xhr = new XMLHttpRequest();
        xhr.open('POST', `${API_URL}/upload`, true);
        
        // 토큰 추가 (인증된 사용자)
        const token = getAuthToken();
        if (token) {
            xhr.setRequestHeader('Authorization', `Bearer ${token}`);
        }
        
        // 진행률 처리
        xhr.upload.addEventListener('progress', (e) => {
            if (e.lengthComputable) {
                const percentComplete = Math.round((e.loaded / e.total) * 100);
                uploadProgressBar.style.width = percentComplete + '%';
                uploadProgressBar.textContent = percentComplete + '%';
            }
        });
        
        // 완료 처리
        xhr.onload = function() {
            if (xhr.status >= 200 && xhr.status < 300) {
                const response = JSON.parse(xhr.responseText);
                showToast('success', '파일이 성공적으로 업로드되었습니다.');
                resetUploadForm();
                loadFileList(); // 목록 새로고침
            } else {
                let errorMsg = '파일 업로드 중 오류가 발생했습니다.';
                try {
                    const error = JSON.parse(xhr.responseText);
                    errorMsg = error.message || errorMsg;
                } catch (e) {}
                showToast('error', errorMsg);
            }
            setUploading(false);
        };
        
        // 오류 처리
        xhr.onerror = function() {
            showToast('error', '네트워크 오류가 발생했습니다.');
            setUploading(false);
        };
        
        xhr.send(formData);
    }
    
    // 파일 목록 로드
    function loadFileList() {
        setLoading(true);
        
        fetch('/api/files/list?page=' + currentPage + '&pageSize=' + PAGE_SIZE + '&search=' + encodeURIComponent(searchTerm), {
            method: 'GET',
            headers: {
                'Authorization': 'Bearer ' + getAuthToken(),
                'Content-Type': 'application/json'
            }
        })
        .then(response => {
            if (!response.ok) {
                throw new Error('목록을 불러오는데 실패했습니다.');
            }
            return response.json();
        })
        .then(data => {
            fileList = data.items || [];
            totalPages = data.totalPages || 1;
            renderFileList();
            renderPagination();
        })
        .catch(error => {
            console.error('Error:', error);
            showToast('error', error.message);
            fileList = [];
            renderFileList();
        })
        .finally(() => {
            setLoading(false);
        });
    }
    
    // 파일 목록 렌더링
    function renderFileList() {
        fileListBody.innerHTML = '';
        
        if (fileList.length === 0) {
            fileListEmpty.classList.remove('d-none');
            return;
        }
        
        fileListEmpty.classList.add('d-none');
        
        fileList.forEach(file => {
            const row = document.createElement('tr');
            row.innerHTML = `
                <td>
                    <a href="#" class="file-name-link" data-key="${file.key}">${file.fileName}</a>
                </td>
                <td>${getFileTypeLabel(file.contentType)}</td>
                <td>${formatFileSize(file.size)}</td>
                <td>${formatDate(file.uploadDate)}</td>
                <td>
                    <div class="btn-group btn-group-sm">
                        <button type="button" class="btn btn-outline-primary preview-btn" 
                            data-key="${file.key}" title="미리보기">
                            <i class="bi bi-eye"></i>
                        </button>
                        <button type="button" class="btn btn-outline-success download-btn" 
                            data-key="${file.key}" title="다운로드">
                            <i class="bi bi-download"></i>
                        </button>
                        <button type="button" class="btn btn-outline-info update-btn" 
                            data-key="${file.key}" title="업데이트">
                            <i class="bi bi-pencil"></i>
                        </button>
                        <button type="button" class="btn btn-outline-danger delete-btn" 
                            data-key="${file.key}" title="삭제">
                            <i class="bi bi-trash"></i>
                        </button>
                    </div>
                </td>
            `;
            
            fileListBody.appendChild(row);
        });
        
        // 이벤트 바인딩
        document.querySelectorAll('.file-name-link, .preview-btn').forEach(btn => {
            btn.addEventListener('click', (e) => {
                e.preventDefault();
                const key = e.currentTarget.getAttribute('data-key');
                showFileDetail(key);
            });
        });
        
        document.querySelectorAll('.download-btn').forEach(btn => {
            btn.addEventListener('click', (e) => {
                e.preventDefault();
                const key = e.currentTarget.getAttribute('data-key');
                downloadFile(key);
            });
        });
        
        document.querySelectorAll('.update-btn').forEach(btn => {
            btn.addEventListener('click', (e) => {
                e.preventDefault();
                const key = e.currentTarget.getAttribute('data-key');
                showUpdateModal(key);
            });
        });
        
        document.querySelectorAll('.delete-btn').forEach(btn => {
            btn.addEventListener('click', (e) => {
                e.preventDefault();
                const key = e.currentTarget.getAttribute('data-key');
                confirmDeleteFile(key);
            });
        });
    }
    
    // 페이지네이션 렌더링
    function renderPagination() {
        paginationContainer.innerHTML = '';
        
        if (totalPages <= 1) {
            pagination.classList.add('d-none');
            return;
        }
        
        pagination.classList.remove('d-none');
        
        // 이전 버튼
        const prevLi = document.createElement('li');
        prevLi.className = 'page-item' + (currentPage === 1 ? ' disabled' : '');
        prevLi.innerHTML = `<a class="page-link" href="#" data-page="${currentPage - 1}">&laquo;</a>`;
        paginationContainer.appendChild(prevLi);
        
        // 페이지 번호
        const maxButtons = 5;
        const startPage = Math.max(1, currentPage - Math.floor(maxButtons / 2));
        const endPage = Math.min(totalPages, startPage + maxButtons - 1);
        
        for (let i = startPage; i <= endPage; i++) {
            const pageLi = document.createElement('li');
            pageLi.className = 'page-item' + (i === currentPage ? ' active' : '');
            pageLi.innerHTML = `<a class="page-link" href="#" data-page="${i}">${i}</a>`;
            paginationContainer.appendChild(pageLi);
        }
        
        // 다음 버튼
        const nextLi = document.createElement('li');
        nextLi.className = 'page-item' + (currentPage === totalPages ? ' disabled' : '');
        nextLi.innerHTML = `<a class="page-link" href="#" data-page="${currentPage + 1}">&raquo;</a>`;
        paginationContainer.appendChild(nextLi);
        
        // 이벤트 바인딩
        document.querySelectorAll('.page-link').forEach(link => {
            link.addEventListener('click', (e) => {
                e.preventDefault();
                const targetPage = parseInt(e.currentTarget.getAttribute('data-page'));
                if (targetPage >= 1 && targetPage <= totalPages && targetPage !== currentPage) {
                    currentPage = targetPage;
                    loadFileList();
                }
            });
        });
    }
    
    // 파일 상세 정보 표시
    function showFileDetail(key) {
        const file = fileList.find(f => f.key === key);
        if (!file) return;
        
        currentFileKey = key;
        
        fileDetailTitle.textContent = file.fileName;
        detailFileName.textContent = file.fileName;
        detailFileKey.textContent = file.key;
        detailFileType.textContent = getFileTypeLabel(file.contentType);
        detailFileSize.textContent = formatFileSize(file.size);
        detailUploadDate.textContent = formatDate(file.uploadDate);
        
        // 미리보기 표시
        filePreviewContainer.innerHTML = '';
        if (isImageFile(file.contentType)) {
            getFileUrl(key, true).then(url => {
                const img = document.createElement('img');
                img.src = url;
                img.className = 'img-fluid';
                img.style.maxHeight = '200px';
                filePreviewContainer.appendChild(img);
            });
        } else if (isPdfFile(file.contentType)) {
            filePreviewContainer.innerHTML = `
                <div class="text-center">
                    <i class="bi bi-file-earmark-pdf text-danger" style="font-size: 3rem;"></i>
                    <p class="mt-2">PDF 문서</p>
                </div>
            `;
        } else {
            filePreviewContainer.innerHTML = `
                <div class="text-center">
                    <i class="bi bi-file-earmark text-primary" style="font-size: 3rem;"></i>
                    <p class="mt-2">${getFileTypeLabel(file.contentType)}</p>
                </div>
            `;
        }
        
        fileDetailModal.show();
    }
    
    // 파일 업데이트 모달 표시
    function showUpdateModal(key) {
        const file = fileList.find(f => f.key === key);
        if (!file) return;
        
        currentFileKey = key;
        updateFileKey.value = key;
        
        // UI 초기화
        updateProgressBar.style.width = '0%';
        updateProgressBar.textContent = '';
        updateProgressContainer.classList.add('d-none');
        updateFileInput.value = '';
        
        fileUpdateModal.show();
    }
    
    // 파일 업데이트 처리
    function updateCurrentFile() {
        const file = updateFileInput.files[0];
        if (!file) {
            showToast('warning', '파일을 선택해주세요.');
            return;
        }
        
        // 파일 크기 검증
        if (file.size > 20 * 1024 * 1024) {
            showToast('error', '파일 크기는 20MB를 초과할 수 없습니다.');
            return;
        }
        
        // 업데이트 진행
        setUpdating(true);
        
        const formData = new FormData();
        formData.append('file', file);
        
        const xhr = new XMLHttpRequest();
        xhr.open('PUT', `${API_URL}/${currentFileKey}`, true);
        
        // 토큰 추가
        const token = getAuthToken();
        if (token) {
            xhr.setRequestHeader('Authorization', `Bearer ${token}`);
        }
        
        // 진행률 처리
        xhr.upload.addEventListener('progress', (e) => {
            if (e.lengthComputable) {
                const percentComplete = Math.round((e.loaded / e.total) * 100);
                updateProgressBar.style.width = percentComplete + '%';
                updateProgressBar.textContent = percentComplete + '%';
            }
        });
        
        // 완료 처리
        xhr.onload = function() {
            if (xhr.status >= 200 && xhr.status < 300) {
                showToast('success', '파일이 성공적으로 업데이트되었습니다.');
                fileUpdateModal.hide();
                loadFileList(); // 목록 새로고침
            } else {
                let errorMsg = '파일 업데이트 중 오류가 발생했습니다.';
                try {
                    const error = JSON.parse(xhr.responseText);
                    errorMsg = error.message || errorMsg;
                } catch (e) {}
                showToast('error', errorMsg);
            }
            setUpdating(false);
        };
        
        // 오류 처리
        xhr.onerror = function() {
            showToast('error', '네트워크 오류가 발생했습니다.');
            setUpdating(false);
        };
        
        xhr.send(formData);
    }
    
    // 현재 파일 다운로드
    function downloadCurrentFile() {
        if (currentFileKey) {
            downloadFile(currentFileKey);
        }
    }
    
    // 파일 다운로드
    function downloadFile(key) {
        // 직접 다운로드 링크로 이동
        window.location.href = `${API_URL}/download/${key}`;
    }
    
    // 파일 삭제 확인
    function confirmDeleteFile(key) {
        currentFileKey = key;
        
        if (confirm('정말 이 파일을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) {
            deleteFile(key);
        }
    }
    
    // 현재 파일 삭제
    function deleteCurrentFile() {
        if (currentFileKey) {
            if (confirm('정말 이 파일을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) {
                deleteFile(currentFileKey);
                fileDetailModal.hide();
            }
        }
    }
    
    // 파일 삭제
    function deleteFile(key) {
        fetch(`${API_URL}/${key}`, {
            method: 'DELETE',
            headers: {
                'Authorization': 'Bearer ' + getAuthToken()
            }
        })
        .then(response => {
            if (!response.ok) {
                throw new Error('파일 삭제 중 오류가 발생했습니다.');
            }
            showToast('success', '파일이 성공적으로 삭제되었습니다.');
            loadFileList(); // 목록 새로고침
        })
        .catch(error => {
            console.error('Error:', error);
            showToast('error', error.message);
        });
    }
    
    // 파일 URL 가져오기
    async function getFileUrl(key, useCdn = true) {
        try {
            const response = await fetch(`${API_URL}/url/${key}?useCdn=${useCdn}`, {
                method: 'GET',
                headers: {
                    'Authorization': 'Bearer ' + getAuthToken(),
                    'Content-Type': 'application/json'
                }
            });
            
            if (!response.ok) {
                throw new Error('URL을 가져오는데 실패했습니다.');
            }
            
            const data = await response.json();
            return data.url;
        } catch (error) {
            console.error('Error:', error);
            showToast('error', error.message);
            return null;
        }
    }
    
    // 검색 처리
    function handleSearch(e) {
        searchTerm = e.target.value.trim();
        currentPage = 1; // 첫 페이지로 이동
        loadFileList();
    }
    
    // UI 상태 함수들
    function setLoading(isLoading) {
        if (isLoading) {
            fileListLoading.classList.remove('d-none');
            fileListBody.classList.add('d-none');
            pagination.classList.add('d-none');
        } else {
            fileListLoading.classList.add('d-none');
            fileListBody.classList.remove('d-none');
        }
    }
    
    function setUploading(isUploading) {
        if (isUploading) {
            uploadButton.disabled = true;
            uploadSpinner.classList.remove('d-none');
            uploadProgressContainer.classList.remove('d-none');
        } else {
            uploadButton.disabled = false;
            uploadSpinner.classList.add('d-none');
            uploadProgressContainer.classList.add('d-none');
        }
    }
    
    function setUpdating(isUpdating) {
        if (isUpdating) {
            updateFileBtn.disabled = true;
            updateSpinner.classList.remove('d-none');
            updateProgressContainer.classList.remove('d-none');
        } else {
            updateFileBtn.disabled = false;
            updateSpinner.classList.add('d-none');
            updateProgressContainer.classList.add('d-none');
        }
    }
    
    function resetUploadForm() {
        fileUploadForm.reset();
        uploadProgressBar.style.width = '0%';
        uploadProgressBar.textContent = '';
    }
    
    // 유틸리티 함수들
    function formatFileSize(bytes) {
        if (bytes === 0) return '0 Bytes';
        
        const k = 1024;
        const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
        const i = Math.floor(Math.log(bytes) / Math.log(k));
        
        return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
    }
    
    function formatDate(dateString) {
        const date = new Date(dateString);
        return new Intl.DateTimeFormat('ko-KR', {
            year: 'numeric',
            month: '2-digit',
            day: '2-digit',
            hour: '2-digit',
            minute