마루톡에는 파일을 전송하는 기능이 있다.
데이터저장을 서버 -> S3로 옮기면서 관련 부분도 공부를 해보자
데이터의 흐름
HTTP
파일 업로드 (client -> server)
- Http Request 생성
클라이언트는 파일 데이터를 HTTP/HTTPS 요청의 Body에 포함하거나 multipart/form-data 형식으로 요청을 생성
헤더에는 파일 크기, Content-Type, 인코딩 방식에 대한 정보가 포함된다. - 네트워크 전송
데이터는 TCP/IP 계층을 통해 패킷으로 쪼개져 전송됌
클라이언트는 *TCP 연결을 통해 데이터를 서버로 스트리밍 하게 되는데, *네트워크 계층(3계층)은 IP 주소를 통해 목적지를 찾고 데이터를 전달함.
데이터가 전송 도중 손실되면 tcp protocol이 이를 감지하고 재전송 요청을 보냄 - 서버 수신
서버는 소켓을 통해 TCP 계층에서 데이터를 수신, HTTP 프로토콜에 따라 요청 헤더와 바디를 구분해서 데이터를 처리
설정에 따라 서버에서 파일 데이터를 저장
파일 다운로드 (server -> client)
- Http Response 생성
서버는 요청된 파일에 대한 정보를 확인, HTTP 응답의 헤더에 파일 이름, 크기, *MIME 타입 등을 설정한다. - 네트워크 전송
서버는 파일을 일정 크기의 청크로 나누어 TCP 계층을 통해 전송한다.
클라이언트는 청크 데이터를 수신하면서 재조립(reassembly) 수행 - 클라이언트 수신 및 저장
애플리케이션은 파일 데이터를 저장하거나 사용자에게 다운로드 창을 표시
HTTP 응답 헤더의 Content-Disposition: attachment를 통해 브라우저가 파일을 다운로드 처리하게 만듬
WebSocket
full-duplex 통신 제공
메시지 frame 단위로 전송 (프레임 기반 전송은 TCP 스트림의 일부가 손실되어도 필요한 부분만 재전송하여 효율적이다)
전송되는 데이터가 Binary 또는 Text 형식으로 직접 전송되어 프로토콜 자체가 더 가볍다.
파일 업로드 (client -> server)
- ws, wss 를 통해 서버와 연결을 설정
- 클라이언트는 파일을 바이너리 데이터로 변환하여 웹소켓 메시지로 서버에 전송
파일이 클 경우, 파일을 일정 청크로 나누어 순차적으로 전송 가능 - 서버는 각 메시지를 수신하여 데이터를 reassembly하여 파일을 완성
파일 다운로드 (server -> client)
- 클라이언트는 파일 다운로드 요청을 웹소켓 메시지로 보냄
- 서버는 요청된 파일을 일정 크기로 나눠 바이너리 프레임으로 클라이언트에게 전송
- 클라이언트는 수신한 데이터를 조립하여 파일로 저장
마루-톡에서는?
마루톡은 채팅서비스이기 때문에 기본적으로 웹소켓 프로토콜을 채팅기능에서 사용하고 있다.
우선 먼저 채팅창 내에서 파일을 전송하는 부분부터 살펴보자

<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);
}
};
개선
- 저장 방식 (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 세팅에
를 추가하여 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 (연결 설정)
- 클라이언트가 서버에 SYN 패킷을 보냄 (SYN: synchronize, tcp 연결을 시작하기 위해 클라이언트가 서버에게 보내는 요청)
- 서버는 클라이언트에 SYN-ACK 패킷으로 응답 (SYN-ACK: synchronize-acknowledgment, 요청에 응답하는 패킷)
- 클라이언트가 ACK 패킷으로 응답하면 연결 성립
* 4-way Handshake (연결 해제)
- 송신자가 FIN 패킷 전송 (FIN: finish, 양측 중 한쪽에서 FIN 패킷을 보내 연결을 닫는 의도를 전함)
- 수신자가 ACK 패킷으로 응답.
- 수신자가 FIN 패킷 전송. (거의 즉시 이뤄짐)
- 송신자가 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에서 객체가 특정 클래스의 인스턴스인지 확인하는 연산자
'BackEnd' 카테고리의 다른 글
| 로드 밸런서 (0) | 2024.12.31 |
|---|---|
| [bcrypt] 비밀번호가 관리되는 방식 (0) | 2024.12.27 |
| Amazon Elastic Container Service 공략 (0) | 2024.12.11 |
| nestJS 실시간 채팅 앱 - [9] logging (winston) (0) | 2024.12.09 |
| 서버 모니터링 (feat. prometheus, grafana) (0) | 2024.12.08 |