# UploadBytesAsync 동작 플로우

```mermaid
flowchart TD
    A[메서드 호출: UploadBytesAsync] --> B[입력값 검증: data가 null인지 확인]
    B -->|null이면 예외| X[ArgumentNullException 발생]
    B -->|정상| C[MemoryStream 생성 (data)]
    C --> D[UploadStreamInternalAsync 호출]
    D --> E[내부에서 FTP 업로드 진행]
    E --> F[업로드 완료]
```

## UploadBytesAsync 네트워크 시퀀스 다이어그램

```mermaid
sequenceDiagram
    participant App as 사용자 코드
    participant FtpClient as FtpClient
    participant NIC as 네트워크 인터페이스
    participant FTPServer as FTP 서버

    App->>FtpClient: UploadBytesAsync(remotePath, data)
    FtpClient->>NIC: FTP 연결 요청 (TCP SYN)
    NIC->>FTPServer: TCP SYN
    FTPServer->>NIC: TCP SYN+ACK
    NIC->>FtpClient: TCP SYN+ACK
    FtpClient->>NIC: TCP ACK
    NIC->>FTPServer: TCP ACK

    FtpClient->>FTPServer: USER 명령 (아이디 전송)
    FTPServer->>FtpClient: 331 User name okay, need password
    FtpClient->>FTPServer: PASS 명령 (비밀번호 전송)
    FTPServer->>FtpClient: 230 User logged in

    FtpClient->>FTPServer: TYPE I (Binary 모드)
    FTPServer->>FtpClient: 200 Command okay
    FtpClient->>FTPServer: PASV (Passive 모드 요청)
    FTPServer->>FtpClient: 227 Entering Passive Mode (IP,Port)

    FtpClient->>FTPServer: STOR remotePath (업로드 시작)
    FTPServer->>FtpClient: 150 File status okay; about to open data connection

    FtpClient->>NIC: 데이터 전송 (MemoryStream의 byte[])
    NIC->>FTPServer: 데이터 패킷 전송
    FTPServer->>FtpClient: 226 Closing data connection (업로드 완료)

    FtpClient->>FTPServer: QUIT
    FTPServer->>FtpClient: 221 Service closing control connection
```

FtpClient.cs

using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Threading;
using System.Threading.Tasks;

namespace FtpClient
{
    // FTP 클라이언트 기능을 제공하는 클래스
    public class FtpClient
    {
        // FTP 접속 및 설정 정보를 담는 구성 객체
        private readonly FtpClientConfig _config;

        // 생성자: FTP 설정 객체를 받아서 초기화
        public FtpClient(FtpClientConfig config)
        {
            // config가 null이면 예외 발생
            _config = config ?? throw new ArgumentNullException(nameof(config));
        }

        /// <summary>
        /// 로컬 파일에서 FTP로 업로드 (디스크에 있는 파일 사용)
        /// </summary>
        public Task UploadFileAsync(string remotePath, string localFilePath, CancellationToken cancellationToken = default(CancellationToken))
        {
            // 업로드할 로컬 파일 경로가 null 또는 빈 문자열이면 예외 발생
            if (string.IsNullOrEmpty(localFilePath))
                throw new ArgumentNullException(nameof(localFilePath));

            // 파일이 실제로 존재하지 않으면 예외 발생
            if (!File.Exists(localFilePath))
                throw new FileNotFoundException("Local file not found.", localFilePath);

            // 내부 공통 업로드 메서드 호출 (파일 스트림 생성)
            return UploadStreamInternalAsync(remotePath, () => File.OpenRead(localFilePath), cancellationToken);
        }

        /// <summary>
        /// 메모리에 있는 byte 배열을 바로 FTP로 업로드 (디스크 사용 X)
        /// </summary>
        public Task UploadBytesAsync(string remotePath, byte[] data, CancellationToken cancellationToken = default(CancellationToken))
        {
            // 업로드할 데이터가 null이면 예외 발생
            if (data == null) throw new ArgumentNullException(nameof(data));

            // MemoryStream을 바로 사용하여 업로드
            return UploadStreamInternalAsync(
                remotePath,
                () => new MemoryStream(data, writable: false),
                cancellationToken);
        }

        /// <summary>
        /// 이미 가지고 있는 Stream을 FTP로 업로드 (디스크 사용 X).
        /// Stream의 Position은 적절히 설정되어 있다고 가정.
        /// </summary>
        public Task UploadStreamAsync(string remotePath, Stream dataStream, CancellationToken cancellationToken = default(CancellationToken))
        {
            // 업로드할 스트림이 null이면 예외 발생
            if (dataStream == null) throw new ArgumentNullException(nameof(dataStream));
            // 스트림이 읽을 수 없으면 예외 발생
            if (!dataStream.CanRead) throw new ArgumentException("Stream must be readable.", nameof(dataStream));

            // 외부에서 전달한 Stream은 dispose하지 않음
            return UploadStreamInternalAsync(remotePath, () => dataStream, cancellationToken, disposeStream: false);
        }

        /// <summary>
        /// 내부 공통 구현부 (Stream을 FTP로 업로드)
        /// </summary>
        private async Task UploadStreamInternalAsync(
            string remotePath,
            Func<Stream> streamFactory,
            CancellationToken cancellationToken,
            bool disposeStream = true)
        {
            // 업로드할 FTP 경로가 null 또는 빈 문자열이면 예외 발생
            if (string.IsNullOrEmpty(remotePath))
                throw new ArgumentNullException(nameof(remotePath));

            // FTP 업로드용 URI 생성
            Uri uri = _config.BuildUri(remotePath);

            // FTP 업로드 요청 객체 생성 및 각종 옵션 설정
            var request = (FtpWebRequest)WebRequest.Create(uri);
            request.Method = WebRequestMethods.Ftp.UploadFile; // 업로드 명령 지정
            request.Credentials = _config.ToCredential();      // 인증 정보 설정
            request.EnableSsl = _config.UseSsl;                // SSL 사용 여부 지정
            request.UsePassive = _config.UsePassive;           // Passive 모드 사용 여부 지정
            request.UseBinary = _config.UseBinary;             // 바이너리 모드 사용 여부 지정
            request.KeepAlive = false;                         // 업로드 후 연결 유지 안함

            Stream requestStream = null; // FTP 서버로 데이터 전송할 스트림
            Stream sourceStream = null;  // 실제 업로드할 데이터 스트림

            try
            {
                // 취소 요청이 들어오면 예외 발생
                cancellationToken.ThrowIfCancellationRequested();

                // FTP 서버로 데이터 전송 스트림 획득
                requestStream = await request.GetRequestStreamAsync().ConfigureAwait(false);

                // 실제 데이터를 제공할 스트림 생성 (파일/메모리)
                sourceStream = streamFactory();
                if (sourceStream == null)
                    throw new InvalidOperationException("Stream factory returned null.");

                // 데이터를 복사할 때 사용할 버퍼(80KB) 생성
                byte[] buffer = new byte[81920];
                int bytesRead;
                // sourceStream에서 데이터를 읽어서 FTP 서버로 전송
                while ((bytesRead = await sourceStream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) > 0)
                {
                    await requestStream.WriteAsync(buffer, 0, bytesRead, cancellationToken).ConfigureAwait(false);
                }

                // 서버의 응답을 받아 업로드 성공 여부 확인
                using (var response = (FtpWebResponse)await request.GetResponseAsync().ConfigureAwait(false))
                {
                    // 필요하다면 response.StatusDescription 로깅 가능
                    // Console.WriteLine($"Upload File Complete, status {response.StatusDescription}");
                }
            }
            finally
            {
                // disposeStream 옵션에 따라 sourceStream 자원 해제
                if (disposeStream && sourceStream != null)
                    sourceStream.Dispose();

                // FTP 서버로 데이터 전송 스트림 자원 해제
                if (requestStream != null)
                    requestStream.Dispose();
            }
        }

        /// <summary>
        /// 여러 chunk 파일을 순서대로 이어붙여 FTP 서버에 하나의 파일로 업로드한다.
        /// chunkPaths 순서대로 데이터를 Write 하며, 서버에는 단일 파일로 저장된다.
        /// </summary>
        public async Task UploadLargeFileAsync(
            string remotePath,
            IList<string> chunkPaths,
            CancellationToken cancellationToken = default(CancellationToken))
        {
            // 업로드할 FTP 경로가 null 또는 빈 문자열이면 예외 발생
            if (string.IsNullOrEmpty(remotePath))
                throw new ArgumentNullException(nameof(remotePath));

            // chunkPaths가 null이거나 비어 있으면 예외 발생
            if (chunkPaths == null || chunkPaths.Count == 0)
                throw new ArgumentException("chunkPaths must contain at least one file path.");

            // FTP 업로드용 URI 생성
            Uri uri = _config.BuildUri(remotePath);

            // FTP 업로드 요청 객체 생성 및 각종 옵션 설정
            var request = (FtpWebRequest)WebRequest.Create(uri);
            request.Method = WebRequestMethods.Ftp.UploadFile; // 업로드 명령 지정
            request.Credentials = _config.ToCredential();      // 인증 정보 설정
            request.EnableSsl = _config.UseSsl;                // SSL 사용 여부 지정
            request.UsePassive = _config.UsePassive;           // Passive 모드 사용 여부 지정
            request.UseBinary = _config.UseBinary;             // 바이너리 모드 사용 여부 지정
            request.KeepAlive = false;                         // 업로드 후 연결 유지 안함

            Stream requestStream = null; // FTP 서버로 데이터 전송할 스트림

            try
            {
                // 취소 요청이 들어오면 예외 발생
                cancellationToken.ThrowIfCancellationRequested();

                // FTP 서버로 데이터 전송 스트림 획득
                requestStream = await request.GetRequestStreamAsync().ConfigureAwait(false);

                // chunk 쓰기 버퍼
                byte[] buffer = new byte[1024 * 1024]; // 1MB
                int bytesRead;

                // chunkPaths에 있는 각 파일을 순서대로 처리
                foreach (var path in chunkPaths)
                {
                    // 취소 요청이 들어오면 예외 발생
                    cancellationToken.ThrowIfCancellationRequested();

                    // 파일이 실제로 존재하는지 확인, 없으면 예외 발생
                    if (!File.Exists(path))
                        throw new FileNotFoundException("Chunk file not found: " + path);

                    // 파일을 읽기 전용으로 스트림을 열어서 사용
                    using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
                    {
                        // 파일 스트림에서 데이터를 버퍼 단위로 읽어서 FTP 서버로 전송
                        while ((bytesRead = await fs.ReadAsync(buffer, 0, buffer.Length, cancellationToken)
                                        .ConfigureAwait(false)) > 0)
                        {
                            await requestStream.WriteAsync(buffer, 0, bytesRead, cancellationToken)
                                               .ConfigureAwait(false);
                        }
                    }
                }

                // 서버의 응답을 받아 업로드 성공 여부 확인
                using (var response = (FtpWebResponse)await request.GetResponseAsync().ConfigureAwait(false))
                {
                    // 필요시 response.StatusDescription 사용
                }
            }
            finally
            {
                // FTP 서버로 데이터 전송 스트림 자원 해제
                if (requestStream != null)
                    requestStream.Dispose();
            }
        }

        /// <summary>
        /// 첫 번째 chunk는 메모리(byte[])에서 읽고,
        /// 이후 chunk들은 파일에서 읽어 순서대로 이어붙여
        /// FTP 서버에 하나의 파일로 업로드한다.
        /// </summary>
        public async Task UploadLargeFileWithMemoryAsync(
            string remotePath,
            byte[] memoryChunk,
            IList<string> chunkPaths,
            CancellationToken cancellationToken = default(CancellationToken))
        {
            // 업로드할 FTP 경로가 null 또는 빈 문자열이면 예외 발생 (필수 입력값 검증)
            if (string.IsNullOrEmpty(remotePath))
                throw new ArgumentNullException(nameof(remotePath));

            // 첫 번째 chunk로 사용할 메모리 데이터가 null이면 예외 발생 (필수 입력값 검증)
            if (memoryChunk == null)
                throw new ArgumentNullException(nameof(memoryChunk));

            // 이어붙일 파일 chunk 목록이 null이면 예외 발생 (필수 입력값 검증)
            if (chunkPaths == null)
                throw new ArgumentNullException(nameof(chunkPaths));

            // FTP 서버에 업로드할 파일의 URI를 생성 (경로 조합)
            Uri uri = _config.BuildUri(remotePath);

            // FTP 업로드 요청 객체 생성 및 각종 옵션 설정
            var request = (FtpWebRequest)WebRequest.Create(uri);
            request.Method = WebRequestMethods.Ftp.UploadFile; // FTP 업로드 명령 지정
            request.Credentials = _config.ToCredential();      // FTP 인증 정보 설정
            request.EnableSsl = _config.UseSsl;                // SSL 사용 여부 지정
            request.UsePassive = _config.UsePassive;           // Passive 모드 사용 여부 지정
            request.UseBinary = _config.UseBinary;             // 바이너리 모드 사용 여부 지정
            request.KeepAlive = false;                         // 업로드 후 연결을 유지하지 않음

            Stream requestStream = null; // FTP 서버로 데이터를 전송할 스트림 변수 선언

            try
            {
                // 취소 요청이 들어오면 예외 발생 (비동기 작업 취소 지원)
                cancellationToken.ThrowIfCancellationRequested();

                // FTP 서버로 데이터를 전송할 스트림을 비동기로 획득
                requestStream = await request.GetRequestStreamAsync().ConfigureAwait(false);

                // 데이터를 복사할 때 사용할 버퍼(1MB 크기) 생성
                byte[] buffer = new byte[1024 * 1024];
                int bytesRead; // 실제로 읽은 바이트 수를 저장할 변수

                ///
                /// 1) 먼저 메모리(byte[]) chunk를 FTP로 전송
                ///
                // memoryChunk를 읽기 전용 메모리 스트림으로 감싸서 사용
                using (var ms = new MemoryStream(memoryChunk, writable: false))
                {
                    // 메모리 스트림에서 데이터를 버퍼 단위로 읽어서 FTP 서버로 전송
                    while ((bytesRead = await ms.ReadAsync(buffer, 0, buffer.Length, cancellationToken)
                                           .ConfigureAwait(false)) > 0)
                    {
                        // 읽은 만큼의 데이터를 FTP 서버로 전송
                        await requestStream.WriteAsync(buffer, 0, bytesRead, cancellationToken)
                                           .ConfigureAwait(false);
                    }
                }

                ///
                /// 2) 그 뒤 파일 chunk들을 순차적으로 이어붙임
                ///
                // chunkPaths에 있는 각 파일을 순서대로 처리
                foreach (var filePath in chunkPaths)
                {
                    // 취소 요청이 들어오면 예외 발생
                    cancellationToken.ThrowIfCancellationRequested();

                    // 파일이 실제로 존재하는지 확인, 없으면 예외 발생
                    if (!File.Exists(filePath))
                        throw new FileNotFoundException("Chunk file not found: " + filePath);

                    // 파일을 읽기 전용으로 스트림을 열어서 사용
                    using (var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read))
                    {
                        // 파일 스트림에서 데이터를 버퍼 단위로 읽어서 FTP 서버로 전송
                        while ((bytesRead = await fs.ReadAsync(buffer, 0, buffer.Length, cancellationToken)
                                                   .ConfigureAwait(false)) > 0)
                        {
                            // 읽은 만큼의 데이터를 FTP 서버로 전송
                            await requestStream.WriteAsync(buffer, 0, bytesRead, cancellationToken)
                                               .ConfigureAwait(false);
                        }
                    }
                }

                // 서버의 응답을 받아 업로드 성공 여부 확인
                using (var response = (FtpWebResponse)await request.GetResponseAsync().ConfigureAwait(false))
                {
                    // 필요시 response.StatusDescription을 통해 서버 응답 메시지 확인 가능
                }
            }
            finally
            {
                // FTP 서버로 데이터 전송 스트림 자원 해제 (메모리 누수 방지)
                if (requestStream != null)
                    requestStream.Dispose();
            }
        }
    }
}

FtpClientConfig.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading.Tasks;

namespace FtpClient
{
    // FTP 접속 및 업로드 관련 설정 정보를 담는 클래스
    [Serializable]
    public class FtpClientConfig
    {
        // FTP 서버 주소 (예: "ftp.example.com")
        public string Host { get; set; }          // e.g. "ftp.example.com"
        // FTP 서버 포트 (기본값: 21)
        public int Port { get; set; } = 21;
        // FTP 로그인 사용자명
        public string Username { get; set; }
        // FTP 로그인 비밀번호
        public string Password { get; set; }

        /// <summary>
        /// FTP 서버의 루트 경로(옵션). 예: "/base/dir"
        /// </summary>
        public string BasePath { get; set; } = "/";

        /// <summary>
        /// FTPS(SSL) 사용 여부 (true면 SSL/TLS로 접속)
        /// </summary>
        public bool UseSsl { get; set; } = false;

        /// <summary>
        /// Passive 모드 사용 여부 (보통 true 권장, 방화벽 환경에서 유리)
        /// </summary>
        public bool UsePassive { get; set; } = true;

        /// <summary>
        /// Binary 모드 여부 (파일 업로드 시 보통 true)
        /// </summary>
        public bool UseBinary { get; set; } = true;

        // FTP 인증 정보를 NetworkCredential 객체로 반환
        public NetworkCredential ToCredential()
        {
            return new NetworkCredential(Username, Password);
        }

        // remotePath를 포함한 FTP 업로드용 URI를 생성
        public Uri BuildUri(string remotePath)
        {
            // remotePath가 null 또는 빈 문자열이면 예외 발생
            if (string.IsNullOrEmpty(remotePath))
                throw new ArgumentNullException(nameof(remotePath));

            // BasePath가 null/빈 문자열이면 기본값 "/" 사용
            string basePath = string.IsNullOrEmpty(BasePath) ? "/" : BasePath;
            // BasePath가 슬래시로 끝나지 않으면 슬래시 추가
            if (!basePath.EndsWith("/")) basePath += "/";

            // remotePath가 슬래시로 시작하면 제거 (중복 방지)
            if (remotePath.StartsWith("/"))
                remotePath = remotePath.Substring(1);

            // 최종 FTP URI 문자열 조합
            var uriString = $"ftp://{Host}:{Port}{basePath}{remotePath}";
            // Uri 객체로 반환
            return new Uri(uriString);
        }
    }
}

 

블로그 이미지

RIsN

,