Post

파일업로드 개선

파일업로드 개선

문제

개인 프로젝트 중 Blazor를 통한 멀티 파일 업로드 기능을 개발했는데 아래는 처음 구현했었던 파일 업로드 로직이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public async Task<bool> UploadPhotoAsync(int albumId, Stream fileStream, string fileName)
    {
        try
        {
            // 1) 로컬에 사진 파일 저장
            var uploadsFolder = Path.Combine("경로가 들어감");
            if (!Directory.Exists(uploadsFolder))
            {
                Directory.CreateDirectory(uploadsFolder);
            }

            // 고유한 파일 이름 생성 (예: GUID)
            var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
            var filePath = Path.Combine(uploadsFolder, uniqueFileName);

            // 파일 스트림을 실제 파일로 저장
            using var writeStream = new FileStream(filePath, FileMode.Create, FileAccess.Write);
            await fileStream.CopyToAsync(writeStream);

            // 2) DB에 PhotoUrl(파일 경로), AlbumId 기록
            using var conn = new SqlConnection(_connectionString);
            using var cmd = new SqlCommand(@"
                INSERT INTO AlbumPhoto
                (
                    AlbumId,
                    PhotoUrl
                )
                VALUES
                (
                    @AlbumId,
                    @PhotoUrl
                );
            ", conn);

            cmd.Parameters.AddWithValue("@AlbumId", albumId);
            cmd.Parameters.AddWithValue("@PhotoUrl", $"생성된 포토 URL이 들어감");

            await conn.OpenAsync();
            var rowsAffected = await cmd.ExecuteNonQueryAsync();

            return rowsAffected > 0;
        }
        catch (Exception ex)
        {
	Console.WriteLine("ex.ErrorMessage")
            return false;
        }
    }

그러나 이렇게 했을 때 실제로 발생한 몇가지 문제점과 우려되는 부분이 존재했다.

  1. 게이트웨이 제한
    • 서버와 게이트웨이의 데이터 전송 제한으로 대용량 파일 업로드일 경우 문제가 발생함
  2. 네트워크 끊김
    • 업로드 중 연결이 끊어지면 사용자는 다시 처음부터 업로드 해야 하는 문제가 존재함
  3. 메모리 - OpenReadStream을 통해 파일 전체를 읽어오면 내부 버퍼를 사용하여 파일 데이터를 받게 되고, 과도한 메모리 점유 상황이 발생함
  4. 병렬처리
    • 다수의 파일 업로드시 굉장히 오랜 로딩을 사용자가 기다려야 하는 상황이 발생함

해결

가장 먼저 청크 단위 업데이트로 변경해서 클라이언트에서 메모리를 효율적으로 사용할 수 있도록 수정하고, 대용량 파일도 업로드를 가능하게 하고, 네트워크가 끊겨도 이어서 다시 업로드 할 수 있도록 수정하자고 생각했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
      // 청크 크기 (예: 2MB)
      const int chunkSize = 2 * 1024 * 1024;
      var buffer = new byte[chunkSize];
      int bytesRead;
      int chunkIndex = 0;
      
      // 임시 파일 구분용 ID (Guid + 파일명 등)
      string fileId = Guid.NewGuid().ToString();
      
      // 반복문으로 청크 단위 업로드
      while ((bytesRead = await fileStream.ReadAsync(buffer, 0, chunkSize)) > 0)
      {
          // 실제 전송할 크기만큼 배열 자르기
          var chunkData = new byte[bytesRead];
          Array.Copy(buffer, 0, chunkData, 0, bytesRead);
      
          // 서버에 청크 업로드
          // chunkIndex, fileId, albumId 등 메타정보 함께 전달
          await AlbumPhotoService.UploadChunkAsyncByAlbumId(
              AlbumId,
              chunkData,
              chunkIndex,
              fileId
          );
      
          chunkIndex++;
      }
      
      // 모든 청크가 전송된 뒤 최종 결합(서버에서 파일을 완성)
      bool isComplete = await AlbumPhotoService.FinishChunkUploadAsyncByAlbumId(AlbumId, fileId, file.Name);
      if (!isComplete)
      {
          // 실패 처리
          throw new Exception("Chunk 병합에 실패했습니다.");
      }

	   // AlbumPhotoService.cs
	  public async Task<bool> UploadChunkAsyncByAlbumId(int albumId, byte[] chunkData, int chunkIndex, string fileId)
    {
        try
        {
            var tempFolder = Path.Combine(경로);
            if (!Directory.Exists(tempFolder))
                Directory.CreateDirectory(tempFolder);

            // 앨범 ID + 파일ID로 고유한 임시 파일명
            // "1234-xxxx-xxxx-xxxx.tmp"
            var tempFileName = $"{albumId}-{fileId}.tmp";
            var tempFilePath = Path.Combine(tempFolder, tempFileName);

            // Append 모드로 파일 열기
            using var fs = new FileStream(tempFilePath, FileMode.Append, FileAccess.Write);
            await fs.WriteAsync(chunkData, 0, chunkData.Length);

            return true;
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
            return false;
        }
    }

    public async Task<bool> FinishChunkUploadAsyncByAlbumId(int albumId, string fileId, string originalFileName)
    {
        try
        {
            var tempFolder = Path.Combine(경로);
            var tempFileName = $"{albumId}-{fileId}.tmp";
            var tempFilePath = Path.Combine(tempFolder, tempFileName);

            if (!File.Exists(tempFilePath))
            {
                // 임시 파일이 없으면 실패 처리
                return false;
            }

            // 실제 업로드 폴더
            var uploadsFolder = Path.Combine(경로);
            if (!Directory.Exists(uploadsFolder))
                Directory.CreateDirectory(uploadsFolder);

            // 최종 파일이름 (GUID + 원본 파일 이름 등)
            string uniqueFileName = $"{Guid.NewGuid()}_{originalFileName}";
            var finalPath = Path.Combine(uploadsFolder, uniqueFileName);

            // 임시 파일 -> 최종 위치로 Move
            // (overwrite: false) 필요시 true로 사용
            File.Move(tempFilePath, finalPath, false);

            // DB Insert
            using var conn = new SqlConnection(_connectionString);
            using var cmd = new SqlCommand(@"
                INSERT INTO AlbumPhoto
                (
                    AlbumId,
                    PhotoUrl
                )
                VALUES
                (
                    @AlbumId,
                    @PhotoUrl
                );
            ", conn);

            cmd.Parameters.AddWithValue("@AlbumId", albumId);
            // PhotoUrl 경로에 최종 access url을 넣어줍니다.
            cmd.Parameters.AddWithValue("@PhotoUrl", $"경로");

            await conn.OpenAsync();
            var rowsAffected = await cmd.ExecuteNonQueryAsync();

            // 성공 시, 임시파일 처리 완료
            return rowsAffected > 0;
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
            return false;
        }
    }

이어서 최대 3개의 사진까지 병렬처리를 통해 동시 업로드를 할 수 있게 수정해서 사용자경험을 높이고자 세마포어를 활용했습니다. 또한 HandleFileChanged 함수의 복잡도가 증가함에 따라 단일책임을 갖는 함수로 로직을 분리해서 복잡도를 낮췄습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
private async Task HandleFileChanged(InputFileChangeEventArgs e)
    {
        if (e.FileCount == 0)
        {
            return;
        }

        IsLoading = true;
        _progressText = "파일 업로드를 준비 중입니다...";

        try
        {
            var existingPhotoIds = _photos.Select(p => p.AlbumPhotoId).ToHashSet();
            int totalFiles = e.FileCount;

            using SemaphoreSlim semaphore = new SemaphoreSlim(3); // 동시에 3개 업로드
            int completedCount = 0;
            var uploadTasks = new List<Task>();

            foreach (var file in e.GetMultipleFiles())
            {
                // 각 파일을 병렬로 업로드
                uploadTasks.Add(UploadSingleFileAsync(file, totalFiles, semaphore, completedCount));
            }

            await Task.WhenAll(uploadTasks);

            // 새로운 사진 목록 동기화
            _photos = await AlbumPhotoService.GetAlbumPhotoByIdAsync(AlbumId);

            // 기존에 없던 사진만 필터링
            var newPhotos = _photos
                .Where(p => !existingPhotoIds.Contains(p.AlbumPhotoId))
                .ToList();
            newPhotos.Reverse();

            // 피드 업로드 (주석 해제 여부는 상황에 따라 결정)
            for (int j = 0; j < newPhotos.Count; j++)
            {
                _progressText = $"피드 업로드 중 {j + 1}/{newPhotos.Count}";
                // await FeedService.CreateFeedAsync(AlbumId, _userId, newPhotos[j].AlbumPhotoId);

                await InvokeAsync(StateHasChanged);
            }

            // UI 표시용 이미지 경로 목록 갱신
            _imageUrls = _photos.Select(p => p.PhotoUrl ?? string.Empty).ToList();

            _alertContainer.ShowAlert(new HAlertModel
            {
                Title = "등록",
                Subtitle = "등록이 성공적으로 처리되었습니다.",
                Type = HAlert.AlertType.Success
            });
        }
        catch (Exception ex)
        {
            _alertContainer.ShowAlert(new HAlertModel
            {
                Title = "오류",
                Subtitle = "파일 업로드 중 오류가 발생했습니다.",
                Type = HAlert.AlertType.Error
            });
            Console.WriteLine(ex.Message);
        }
        finally
        {
            IsLoading = false;
            await InvokeAsync(StateHasChanged);
        }
    }

private async Task UploadSingleFileAsync(
        IBrowserFile file,
        int totalFiles,
        SemaphoreSlim semaphore,
        int completedCount
    )
    {
        await semaphore.WaitAsync();
        try
        {
            // 파일 업로드(Chunk 단위 등 필요 시 구현)
            await UploadChunkFile(file);

            int doneNow = Interlocked.Increment(ref completedCount);
            _progressText = $"파일 업로드 중 {doneNow}/{totalFiles}";

            // UI 스레드 갱신
            await InvokeAsync(StateHasChanged);
        }
        finally
        {
            semaphore.Release();
        }
    }
    
    private async Task UploadChunkFile(IBrowserFile file)
    {
        // 1GB 예시 (필요에 따라 조절)
        using var fileStream = file.OpenReadStream(maxAllowedSize: 1024 * 1024 * 1024);

        const int chunkSize = 2 * 1024 * 1024; // 2MB
        var buffer = new byte[chunkSize];
        int bytesRead;
        int chunkIndex = 0;

        // 서버에서 청크를 구별할 임시 ID
        string fileId = Guid.NewGuid().ToString();

        // chunkSize 단위로 반복해서 읽어 서버에 업로드
        while ((bytesRead = await fileStream.ReadAsync(buffer, 0, chunkSize)) > 0)
        {
            var chunkData = new byte[bytesRead];
            Array.Copy(buffer, 0, chunkData, 0, bytesRead);

            await AlbumPhotoService.UploadChunkAsyncByAlbumId(
                AlbumId,
                chunkData,
                chunkIndex,
                fileId
            );
            chunkIndex++;
        }

        // 모든 청크를 전송한 뒤 서버에서 최종 결합
        bool isComplete = await AlbumPhotoService.FinishChunkUploadAsyncByAlbumId(
            AlbumId,
            fileId,
            file.Name
        );
        
        if (!isComplete)
        {
            throw new Exception("Chunk 병합에 실패했습니다.");
        }
    }

사진의 용량 개수에 따라 다르겠지만, 동일한 사진으로 테스트 했을 때 2~3초 걸리던 업로드가 0.x 초만에 안정적으로 종료되는 것으로 확인됐습니다.

하지만 고정적으로 3개의 병렬 처리를 하는 로직은 사용자 개개인의 네트워크 상태를 고려하지 않아서 사용자에 따라 작업이 무거울 수도 있고 가벼울 수도 있다는 문제가 있습니다.

따라서 사용자의 네트워크 상태에 맞춰 개인화된 병렬 처리를 제공할 수 있도록 처리했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
    private async Task HandleFileChanged(InputFileChangeEventArgs e)
    {
        if (e.FileCount == 0)
        {
            return;
        }

        IsLoading = true;
        _progressText = "파일 업로드를 준비 중입니다...";

        try
        {
            // 네트워크 속도 측정
            int concurrencyLevel = await GetNetworkConcurrencyLevelAsync();

            var existingPhotoIds = _photos.Select(p => p.AlbumPhotoId).ToHashSet();
            int totalFiles = e.FileCount;

            using SemaphoreSlim semaphore = new SemaphoreSlim(concurrencyLevel);
        // 생략...

    private async Task<int> GetNetworkConcurrencyLevelAsync()
    {
        try
        {
            // 테스트할 대상 URL 또는 IP (빠르게 응답 가능한 서버)
            string testHost = "google.com";
            using var ping = new System.Net.NetworkInformation.Ping();

            // Ping을 통한 네트워크 상태 측정 (RTT: 밀리초)
            var reply = await ping.SendPingAsync(testHost, 1000); // 1초 타임아웃
            if (reply.Status == System.Net.NetworkInformation.IPStatus.Success)
            {
                long roundTripTime = reply.RoundtripTime;

                // RTT 기반으로 동시성 계수 조정
                if (roundTripTime < 50)
                {
                    return 6; // 매우 빠른 네트워크 (동시 업로드 6개)
                }
                if (roundTripTime < 150)
                {
                    return 4; // 적당한 네트워크 속도 (동시 업로드 4개)
                }
            }

            return 2; // 느린 네트워크 (동시 업로드 2개)
        }
        catch
        {
            // 측정 실패 시 기본 설정 (느린 네트워크로 간주)
            return 2;
        }
    }

하지만 이럴 경우 테스트할 대상이 응답하지 않거나, 네트워크가 느린 사용자는 구글을 한번 거치기 때문에 더 오래 시간이 걸린다는 단점이 있습니다.

따라서 사용자의 네트워크 속도를 웹사이트 접속할 때 미리 측정하고, 그것을 저장해놓고 사용하는 방법이 더 나을 것 같다고 생각합니다.

그리고 또한 현재의 Azure webapp 서버에 물리적으로 파일을 저장하는 방식은 보안이나 최적화에 적절하지 않다고 생각했습니다.

때문에 오로지 파일을 저장할 독립된 공간으로 Azure blob storage를 적용하기로 했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
public class AzureService(
    IConfiguration configuration
    ): IAzureService
{
    private readonly string _blobStorageToken = configuration.GetConnectionString("BlobStorageToken") ?? throw new InvalidOperationException("BlobStorageToken string not found.");
    private string _azureConnection = configuration.GetConnectionString("AzureConnection") ?? throw new InvalidOperationException("AzureConnection string not found.");
    
    public async Task<bool> UploadChunkAsync(byte[] chunkData, int chunkIndex, string fileId, string containerName = "images")
    {
        try
        {
            var blobServiceClient = new BlobServiceClient(_azureConnection);
            var containerClient = blobServiceClient.GetBlobContainerClient(containerName);

            // 컨테이너가 없으면 생성
            await containerClient.CreateIfNotExistsAsync();

            // 파일 ID로 임시 Blob 이름 생성
            var tempBlobName = $"{fileId}.tmp";
            var blockBlobClient = containerClient.GetBlockBlobClient(tempBlobName);

            // 각 청크를 고유한 Block ID로 업로드(Stage)
            var blockIdStr = $"{chunkIndex}-{Guid.NewGuid()}";
            var base64BlockId = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(blockIdStr));

            using var stream = new MemoryStream(chunkData);

            // 청크 스테이징
            await blockBlobClient.StageBlockAsync(
                base64BlockId: base64BlockId,
                content: stream);

            return true;
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
            return false;
        }
    }

    public async Task<string> MergeChunksAsync(string fileId, string? finalExtension = null, string containerName = "images")
    {
            try
            {
                var blobServiceClient = new BlobServiceClient(_azureConnection);
                var containerClient = blobServiceClient.GetBlobContainerClient(containerName);

                var tempBlobName = $"{fileId}.tmp";
                var blockBlobClient = containerClient.GetBlockBlobClient(tempBlobName);

                // 최종 Blob 이름(확장자가 있다면 적용)
                var finalBlobName = string.IsNullOrWhiteSpace(finalExtension)
                    ? tempBlobName
                    : $"{fileId}{finalExtension}";

                var finalBlobClient = containerClient.GetBlockBlobClient(finalBlobName);

                // 스테이징된 블록 목록(Committed + Uncommitted) 가져오기
                var blockList = await blockBlobClient.GetBlockListAsync(BlockListTypes.All);

                var allBlocks = new List<string>();

                // 이미 커밋된 블록
                foreach (var b in blockList.Value.CommittedBlocks)
                {
                    allBlocks.Add(b.Name);
                }
                // 아직 커밋되지 않은 블록
                foreach (var b in blockList.Value.UncommittedBlocks)
                {
                    allBlocks.Add(b.Name);
                }

                // 모든 블록을 커밋
                await blockBlobClient.CommitBlockListAsync(allBlocks);

                // 확장자 변경이 필요한 경우, 복사 후 기존 임시 이름 삭제
                if (!string.IsNullOrWhiteSpace(finalExtension))
                {
                    // 복사 시작
                    await finalBlobClient.StartCopyFromUriAsync(blockBlobClient.Uri);

                    // 복사가 완료될 때까지 대기
                    var props = await finalBlobClient.GetPropertiesAsync();
                    while (props.Value.CopyStatus == CopyStatus.Pending)
                    {
                        await Task.Delay(500);
                        props = await finalBlobClient.GetPropertiesAsync();
                    }

                    // 복사 성공 시 .tmp 블랍 제거
                    if (props.Value.CopyStatus == CopyStatus.Success)
                    {
                        await blockBlobClient.DeleteIfExistsAsync();
                    }

                    // 최종 파일의 URI 문자열 반환
                    return finalBlobClient.Uri.ToString();
                }
                else
                {
                    // .tmp 그대로 사용 시, 해당 URI 문자열 반환
                    return blockBlobClient.Uri.ToString();
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
                // 에러 시, 빈 문자열 등의 처리
                return string.Empty;
            }
    }
}

AzureService라는 객체를 만들어서 기존의 파일 업로드 로직을 Azure blob storage 업로드 로직으로 리팩토링했습니다.

다음번에는 파일 업로드 중 발생할 수 있는 보안적인 문제를 해결하는 포스팅을 해볼까 생각하고 있습니다.

그리고 Azure의 부하 테스트를 통해 서버가 어느정도 견딜 수 있는지도 궁금하네요.

이상 평범한 개발자의 파일 업로드 최적화 포스팅이였습니다!

This post is licensed under CC BY 4.0 by the author.