BackEnd

파일 송수신[1] (feat. S3, ws)

sihanni 2024. 12. 19. 17:30

마루톡에는 파일을 전송하는 기능이 있다.

데이터저장을 서버 -> S3로 옮기면서 관련 부분도 공부를 해보자

 

 

데이터의 흐름

HTTP

파일 업로드 (client -> server)

  1. Http Request 생성
    클라이언트는 파일 데이터를 HTTP/HTTPS 요청의 Body에 포함하거나 multipart/form-data 형식으로 요청을 생성
    헤더에는 파일 크기, Content-Type, 인코딩 방식에 대한 정보가 포함된다.
  2. 네트워크 전송
    데이터는 TCP/IP 계층을 통해 패킷으로 쪼개져 전송됌
    클라이언트는 *TCP 연결을 통해 데이터를 서버로 스트리밍 하게 되는데, *네트워크 계층(3계층)은 IP 주소를 통해 목적지를 찾고 데이터를 전달함.
    데이터가 전송 도중 손실되면 tcp protocol이 이를 감지하고 재전송 요청을 보냄
  3. 서버 수신
    서버는 소켓을 통해 TCP 계층에서 데이터를 수신, HTTP 프로토콜에 따라 요청 헤더와 바디를 구분해서 데이터를 처리
    설정에 따라 서버에서 파일 데이터를 저장

파일 다운로드 (server -> client)

  1. Http Response 생성
    서버는 요청된 파일에 대한 정보를 확인, HTTP 응답의 헤더에 파일 이름, 크기, *MIME 타입 등을 설정한다.
  2. 네트워크 전송
    서버는 파일을 일정 크기의 청크로 나누어 TCP 계층을 통해 전송한다.
    클라이언트는 청크 데이터를 수신하면서 재조립(reassembly) 수행
  3. 클라이언트 수신 및 저장
    애플리케이션은 파일 데이터를 저장하거나 사용자에게 다운로드 창을 표시
    HTTP 응답 헤더의 Content-Disposition: attachment를 통해 브라우저가 파일을 다운로드 처리하게 만듬

 

WebSocket

full-duplex 통신 제공
메시지 frame 단위로 전송 (프레임 기반 전송은 TCP 스트림의 일부가 손실되어도 필요한 부분만 재전송하여 효율적이다)

전송되는 데이터가 Binary 또는 Text 형식으로 직접 전송되어 프로토콜 자체가 더 가볍다.

 

파일 업로드 (client -> server)

  1. ws, wss 를 통해 서버와 연결을 설정
  2. 클라이언트는 파일을 바이너리 데이터로 변환하여 웹소켓 메시지로 서버에 전송
    파일이 클 경우, 파일을 일정 청크로 나누어 순차적으로 전송 가능
  3. 서버는 각 메시지를 수신하여 데이터를 reassembly하여 파일을 완성

파일 다운로드 (server -> client)

  1. 클라이언트는 파일 다운로드 요청을 웹소켓 메시지로 보냄
  2. 서버는 요청된 파일을 일정 크기로 나눠 바이너리 프레임으로 클라이언트에게 전송
  3. 클라이언트는 수신한 데이터를 조립하여 파일로 저장

마루-톡에서는?

마루톡은 채팅서비스이기 때문에 기본적으로 웹소켓 프로토콜을 채팅기능에서 사용하고 있다.

우선 먼저 채팅창 내에서 파일을 전송하는 부분부터 살펴보자

 

	<InputContainer>
        <label htmlFor='fileInput'>
          <FileButton onClick={handleFileButtonClick}>+</FileButton>
        </label>
        <input
          type='file'
          id='fileInput'
          style={{ display: 'none' }} 
          onChange={handleFileChange}
        />
        //..중략..
        <SendButton onClick={sendMessage}>전송</SendButton>
      </InputContainer>

+ 버튼을 누르면, label에 연결된 input 요소('fileInput')를 click 하게되고, 파일 탐색기가 열리게 된다.

handleFileChange에서는 파일을 다중으로 선택해도 첫번째 파일만 추출되고, 

* FileReader 객체를 통해 파일을 비동기적으로 읽고 처리한다.

현재는 base64 데이터로 파일을 인코딩하여 서버로 전달한다.

const handleFileButtonClick = () => {
    const fileInput = document.getElementById('fileInput') as HTMLInputElement;
    fileInput.click();
  };

const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file && socket && room_id) {
      const reader = new FileReader();
      reader.onloadend = () => {
        const fileData = reader.result as string; 

        // 파일 전송
        socket.emit('sendFile', {
          room_id,
          file_name: file.name,
          file_data: fileData,
          sender_id: user?.id,
          sender_email: user?.email,
          sender_username: user?.username,
          sender_profile_img:
            user?.profile_img !== null
              ? user?.profile_img
              : '',
        });
      };
      reader.readAsDataURL(file); 
    }
  };

 

개선

  1. 저장 방식 (Buffer)
    * base64로 파일을 인코딩하여 전송하면 파일 크기가 약 33% 증가된다고함. (바이너리 데이터를 텍스트 형식으로 변환하는 과정에서 발생하는 오버헤드) -> 네트워크 대역폭을 더 많이 소모, 전송 시간 증가 가능성 
    base64로 인코딩된 데이터는 텍스트 형식이어서, 이 데이터를 서버에서 처리할 때 추가적인 메모리 공간이 필요하다.

 

서버: 웹소켓 연결 끊김 현상과 S3 in NestJS

multer을 써보고 싶었는데, http 요청 미들웨어라 아쉽게되었다.

그래서 http/https/ws 다 통용되는 걸로 만들어야했다.

그리고 채팅서비스에서 방마다 어떤 유저가 파일을 올렸는지 중복되게 하고싶지 않아서 파일 저장은

uploads/yyyymmdd/${room_id}/uuid 식으로 결정했다.

그리고 파일의 메타데이터는 db에 따로 저장하기로 했다.

 

 

그리고 파일 크기가 1MB 정도 이상부터는 웹소켓 연결이 끊겨버렸다. 

우선 socket.io의 기본 웹소켓 메시지 크기를 확인해보기로 했다.

https://socket.io/how-to/upload-a-file

 

How to upload a file | Socket.IO

Files can be sent as is:

socket.io

1MB인듯 하다. 해결의 실마리를 찾은 것 같다.

웹소켓 gateway 세팅에 

maxHttpBufferSize: 5 * 1024 * 1024,

를 추가하여 5mb까지 허용되도록 해서 해결했다.ㅠ

역시 문제를 해결하기 위해서는 문제의 근본이 뭔지 파악을 먼저 해야한다..

 

S3


그리고 파일저장을 기존 서버에 저장하는 형태에서 s3로 이관했다.
프리티어가 5GB까지 무료라 상당히 좋다.

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CustomLoggerService } from '../logger/logger.service';
import {
  ObjectCannedACL,
  PutObjectCommand,
  S3Client,
} from '@aws-sdk/client-s3';
import { v4 as uuidv4 } from 'uuid';
import { getCurrentDate } from '../time';
import { InjectRepository } from '@nestjs/typeorm';
import { S3Metadata } from './entities/s3.entity';
import { Repository } from 'typeorm';
import { User } from 'src/users/entities/user.entity';
@Injectable()
export class S3Service {
  private s3Client: S3Client;
  private readonly bucketName: string;

  constructor(
    private configService: ConfigService,
    private readonly logger: CustomLoggerService,
    @InjectRepository(S3Metadata)
    private readonly s3Repository: Repository<S3Metadata>,
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
  ) {
    this.s3Client = new S3Client({
      credentials: {
        accessKeyId: this.configService.get<string>('AWS_ACCESS_KEY'),
        secretAccessKey: this.configService.get<string>(
          'AWS_SECRET_ACCESS_KEY',
        ),
      },
      region: this.configService.get<string>('AWS_REGION'),
    });

    const environment = process.env.NODE_ENV || 'development';
    if (environment === 'production') {
      this.bucketName = this.configService.get<string>('S3_BUCKET');
      this.logger.log('production s3 bucket');
    } else {
      this.bucketName = this.configService.get<string>('S3_BUCKET');
      this.logger.log('development s3 bucket');
    }
  }
  async uploadFileToS3(
    sender_id: number,
    room_id: number,
    file_buffer: Buffer,
    file_name: string,
    content_type: string,
    client_id: string,
  ) {
    // uuid 생성
    const uuid = uuidv4();
    this.logger.log(`UUID:::${uuid}`);
    // 파일 경로 생성
    const todayDir = getCurrentDate();
    const s3Path = `uploads/${todayDir}/${room_id}/${uuid}`;
    // s3업로드
    const s3Params = {
      Key: s3Path,
      Body: file_buffer,
      Bucket: this.bucketName,
      ContentType: content_type,
      ACL: ObjectCannedACL.public_read,
    };
    const command = new PutObjectCommand(s3Params);
    try {
      const uploadS3 = await this.s3Client.send(command);
      if (uploadS3) {
        const sender = await this.userRepository.findOne({
          where: { id: sender_id },
        });
        if (sender) {
          const s3Metadata = await this.s3Repository.create({
            room_id: room_id,
            user: sender,
            original_filename: file_name,
            file_uuid: uuid,
            file_path: s3Path,
            client_id: client_id,
          });
          await this.s3Repository.save(s3Metadata);
        }
        const s3PullPath = `https://${this.bucketName}.s3.ap-northeast-2.amazonaws.com/${s3Path}`;
        return s3PullPath;
      }
    } catch (error) {
      this.logger.error(`S3 Upload Error::${error}`);
    }
  }
}

* AWS config 정보가 유출되지 않게 조심하자!

 

그리고 s3에서 가져온 데이터는 Buffer로 변환되는 IncomingMessage 객체이다.
이 객체는 스트림 형식으로 파일 데이터를 처리하고 있다고 하며, 파일데이터가 스트리밍 형식으로 반환되기 떄문에,
fileData.Body는 스트림 형태로 처리해야한다.

fileData.Body는 기본적으로 ReadableStream으로 반환되므로, 이를 사용하여 데이터를 처리한다.

 

주석

 

* MIME
(Multipurpose Internet Mail Extensions)은 인터넷에서 다양한 데이터 형식을 전송하기 위해 설계된 표준 프로토콜이다.
원래는 이메일을 통해 텍스트 외의 데이터(이미지, 비디오, 오디오 등)를 전송하기 위해 만들어졌지만, 현재는 HTTP와 같은 웹 통신에서도 널리 사용되어진다.

일반적으로 개발자 도구 네트워크 탭에서 Content-Type 헤더를 확인하면 MIME 타입을 확인할 수 있다.

데이터가 어떤 유형인지를 알려주는 MIME type을 정의한다. (HTML문서, JSON, PNG 이미지 등)

예시 형식: type/subtype

  • type: 데이터의 주요 범주 (text, image, application, audio, video, 등).
  • subtype: 세부 형식 (plain, html, json, png, mpeg, 등).

* TCP 

Transmission Control Protocol

전송 계층 (4계층) 에서 작동하며, 데이터의 신뢰성을 담당한다.
연결 지향적 프로토콜 : TCP는 통신 전에 *3-way-handshake를 통해 연결을 설정
데이터 전송 후에는 connection teardown으로 연결을 종료
TCP는 패킷 손실, 중복전송, 순서 뒤바뀜을 감지하고 복구
손실 데이터는 재전송 (TCP Retransmission) 메커니즘으로 복구
스트림 기반 데이터 전송: 데이터가 패킷 단위로 나뉘지만, 애플리케이션 계층에서는 연속된 스트림 처럼 보임

데이터는 segment 로 나뉘어 전송 (각 세그먼트에는 sequence number, acknowledgment number 가 포함되어 순서를 보장)

 

* 3-way-handshake (연결 설정)

  1. 클라이언트가 서버에 SYN 패킷을 보냄   (SYN: synchronize, tcp 연결을 시작하기 위해 클라이언트가 서버에게 보내는 요청)
  2. 서버는 클라이언트에 SYN-ACK 패킷으로 응답 (SYN-ACK: synchronize-acknowledgment, 요청에 응답하는 패킷)
  3. 클라이언트가 ACK 패킷으로 응답하면 연결 성립

* 4-way Handshake (연결 해제)

 

  1. 송신자가 FIN 패킷 전송 (FIN: finish, 양측 중 한쪽에서 FIN 패킷을 보내 연결을 닫는 의도를 전함)
  2. 수신자가 ACK 패킷으로 응답.
  3. 수신자가 FIN 패킷 전송. (거의 즉시 이뤄짐)
  4. 송신자가 ACK 패킷으로 응답하며 종료.

 

* 네트워크 계층

OSI (Open Systems Interconnection) 모델의 7계층 중 3계층에 해당하며 데이터 패킷의 라우팅과 전달을 담당

주요 역할

  • Routing
    송신자에서 수신자로 데이터를 전송하기 위해 최적의 경로를 선택
  • Packet Forwarding (패킷 전달)
    데이터그램을 목적지 까지 전달, 각 라우터는 패킷의 IP 헤더를 읽고 다음 홉으로 전달
  • Addressing (주소 지정)
    네트워크 내 장치를 식별하기 위한 IP주소를 사용 (IPv4(32비트), IPv6(128비트) 지원 )

동작 과정

  • 패킷 생성
    전송 계층 (TCP/UDP)에서 생성된 세그먼트를 캡슐화하여 IP패킷 생성
  • 라우팅 및 전달
    라우터는 패킷의 목적지 IP 주소를 기반으로 경로를 결정
  • 단편화
    패킷 크기가 MTU보다 크면 여러 조각으로 나뉨
  • TTL 감쇠 및 폐기
    각 홉에서 TTL 값을 감소, TTL이 0이 되면 폐기 및 ICMP 오류 메시지 전송

Duplex
데이터를 한 방향으로만 전송할 수 있는 통신 방식, 송신과 수신이 동시에 일어나지 않는다.

Full-Duplex
데이터를 양방향으로 동시에 송수신할 수 있는 방식, 송신과 수신이 동시에 가능하므로 더 효율적인 통신이 가능하다.

 

* FileReader

브라우저에서 파일을 읽을 수 있도록 해주는 JS API.

사용자에게 파일을 선택하게하고, 그 파일을 비동기적으로 읽어들여 처리를 할 수 있다.

다양한 파일 형식을 지원하고, 파일 데이터를 특정 형식(base64, binary, text)로 변환해서 사용할 수 있다.


파일을 읽을 때 사용하는 메서드

  • readAsText(file): 파일을 텍스트로 읽습니다.
  • readAsDataURL(file): 파일을 base64 인코딩된 데이터 URL 형식으로 읽습니다.
  • readAsArrayBuffer(file): 파일을 이진 데이터 형식(버퍼)으로 읽습니다.
  • readAsBinaryString(file) (구식): 파일을 바이너리 문자열 형식으로 읽습니다.

파일을 읽는 과정에서 결과를 처리할 수 있는 이벤트 리스너를 등록할 수 있고, 읽기 작업이 완료되면 onloadend 이벤트 핸들러가 실행된다.

 

  • onloadstart: 파일 읽기가 시작될 때 호출됩니다.
  • onprogress: 파일이 읽히는 동안 호출됩니다(진행 상태를 추적하는 데 유용).
  • onloadend: 파일 읽기가 끝날 때 호출됩니다. 파일이 읽히면 이 핸들러에서 결과를 처리합니다.
  • onload: 파일을 성공적으로 읽었을 때 호출됩니다.
  • onerror: 파일을 읽는 중 오류가 발생하면 호출됩니다.

 

* Base64 인코딩
바이너리 데이터를 ASCII 문자열로 변환하는 인코딩 방식 ( 바이너리 데이터를 6비트씩 묶어서 64개의 문자를 사용해 표현 )

데이터를 텍스트 형식으로 인코딩하여 이메일이나 HTTP 프로토콜과 같은 텍스트 기반 시스템에서 전송할 수 있게 한다.
바이너리 데이터를 Base64 형식으로 변환하는 과정에서 발생하는 오버헤드로 인해 원본 바이너리 데이터보다 약 33% 더 많은 공간 차지
예시)
1. 바이너리 데이터를 8비트씩 처리 (1바이트 -> 8개의 이진수)
파일의 첫 번째 3바이트 : 01000001 01000010 01000011 (A, B, C에 해당하는 8비트 이진수)
2. 6비트로 나누기 ( 3바이트(24비트)를 6비트로 나눈다)
010000 010100 001001 000011

3. 각 6비트로 나뉜 조각을 Base64 인코딩 표를 사용해 텍스트로 매핑

4. 원본 데이터 길이가 3바이트 배수가 아니라면, 부족한 부분은 =로 패딩하여 데이터 길이를 맞춤.

 

6비트씩 처리하므로, 1바이트는 1.33개의 Base64 문자로 변환되어 (8비트 ÷ 6비트 = 1.33), 원본보다 33% 많아지는 것임.
그럼에도 불구하고 텍스트 기반 시스템에서는 바이너리 파일을 안전하게 바로 전송할 수 있는 수단임

 

* Buffer, Blob
Buffer은 주로 Node.js에서 사용되는 객체
바이너리 데이터 또는 바이트 데이터를 처리하고 다룰 수 있게 해준다.

Blob은 웹 브라우저 환경에서 사용되는 객체로, 대용량 바이너리 데이터를 다룰 수 있게 해준다.

이미지, 비디오, 오디오와 같은 파일을 다룰 때 주로 사용

 

  • Buffer Node.js에서 사용되며, 주로 서버 사이드에서 바이너리 데이터를 다루는 데 사용됩니다. 파일 시스템, 네트워크 요청 등의 처리에서 중요합니다.
  • Blob 브라우저 환경에서 사용되며, 클라이언트 측에서 파일을 처리하거나 업로드/다운로드 할 때 사용됩니다. 대개 사용자 파일을 처리할 때 유용합니다.
  • ArrayBuffer는 JavaScript에서 이진 데이터를 처리하기 위한 기본 객체입니다. 이는 원시 이진 데이터를 나타내며, ArrayBuffer 자체는 데이터를 저장하지 않지만, 데이터를 저장하는 메모리 공간을 제공합니다. ArrayBuffer는 주로 네트워크 요청, 파일 읽기 및 쓰기, WebGL 또는 오디오/비디오 데이터와 같은 바이너리 데이터를 다룰 때 유용합니다.

* instanceof

instanceof는 JavaScript에서 객체가 특정 클래스의 인스턴스인지 확인하는 연산자