1. 개요
기존에 구현하던 TCP 기반 채팅 애플리케이션에 파일 전송 기능을 추가하면서, 단순히 "파일을 전송하는 것" 이상의 문제가 발생했다.
- 채팅(JSON)과 파일(binary)이 동일한 TCP 연결을 공유한다는 점
- 대용량 파일(10GB 이상) 전송 중에도 채팅은 끊기지 않아야 한다는 요구
- 그리고 무엇보다 메모리 폭주 없이 안정적으로 동작해야 한다는 제약
이 글에서는 TCP 스트림의 특성으로 인해 발생한 문제를 어떻게 인식했고, 어떤 프로토콜과 구조로 해결했는지를 단계적으로 정리한다.
GitHub 주소 : https://github.com/proLmpa/ChatApp
2. 요구사항 정의
정리된 요구사항은 다음과 같다.
- N:N 채팅방 환경
- '/f <target> <path>' 명령어를 통해 특정 사용자에게 파일 전송
- 서버는 저장소가 아닌 전달자(relay) 역할만 수행
- 10GB 이상 대용량 파일 전송 중에도 채팅 가능
- 파일 전송과 채팅이 동시에 발생해도 메모리 폭주가 없어야 함
- 채팅(JSON)과 파일(binary)을 명확히 구분할 수 있어야 함
이 시점에서 핵심 문제는 명확해졌다.
"TCP 스트림 위에서 서로 다른 성격의 데이터(JSON + binary)를 어떻게 안전하게 공존시킬 것인가?"
3. TCP 스트림의 함정 : "메시지 경계가 없다."
현재 프로젝트가 사용 중인 서버-클라이언트 간 연결 방식은 다음과 같은 방식이다.
class ConnectionService(
private val socket: Socket
) {
private val input = DataInputStream(socket.getInputStream())
private val output = DataOutputStream(socket.getOutputStream())
...
이는 전형적인 TCP 스트림 기반 통신이다.
TCP는 다음을 보장한다.
- 순서 보장
- 신뢰성
- 바이트 스트림
하지만 TCP는 메시지를 모른다.
"여기까지가 하나의 메시지", "이건 채팅이고, 이건 파일이다" 같은 개념은 전혀 제공하지 않는다.
기존 JSON 프로토콜의 한계
초기 구현에서는 모든 이벤트가 JSON 패킷이었다.
- 채팅
- 이름 등록 / 변경
- 접속 / 종료
이 상태에서는 JSON 하나만 파싱하면 되었기 때문에 큰 문제가 없었다.
하지만 파일 전송을 JSON 기반으로 구현하려면 :
- binary → Base64 encoding로 비용 발생
- JSON body에 포함됨
- 대용량 파일일 수록 메모리/CPU 낭비 급증
특히 10GB 이상 파일 전송을 고려하면 현실적인 선택이 아니었다.
두 가지 프로토콜의 공존
즉, 다음의 두 가지 프로토콜이 필요해졌다.
- JSON_PACKET : 채팅, 제어, 상태 이벤트
- FILE_CHUNK : 파일 데이터를 그대로 분할 전송
여기서 문제가 발생한다.
TCP 스트림에는 메시지 경계가 없기 때문에 파일 전송 중 채팅이 끼어들면 수신 측에서는 "파일의 일부"로 오인될 수 있다.
4. 해결 전략 : Frame 기반 multiplexing
4-1. Frame 프로토콜 설계
이제부터 모든 데이터는 Frame 단위로 전송된다.
[1 byte ] FrameType
[4 bytes] Payload Length
[n bytes] Payload
enum class FrameType(val code: Byte) {
JSON_PACKET(0x01),
FILE_CHUNK(0x02);
companion object {
fun fromCode(code: Byte): FrameType? =
entries.find { it.code == code}
}
}
FrameType 유형
| FrameType | 설명 |
| JSON_PACKET | 채팅/제어용 JSON 패킷 |
| FILE_CHUNK | 파일 데이터 |
FrameType을 통해 "이 payload를 어떻게 해석해야 하는지"를 명확히 분리한다.
Frame 읽기 / 쓰기 동작
// ConnectionService.kt
private val sendQueue = LinkedBlockingQueue<Pair<FrameType, ByteArray>>(256)
fun readFrame(): Pair<FrameType, ByteArray> {
val typeCode = input.readByte()
val frameType = FrameType.fromCode(typeCode)
?: throw IOException("Unknown frameType: $typeCode")
val length = input.readInt()
if (length < 0) throw IOException("Negative frame length: $length")
val payload = ByteArray(length)
input.readFully(payload)
return frameType to payload
}
private fun writeFrame(frameType: FrameType, payload: ByteArray) {
if (!sendQueue.offer(frameType to payload, 3, TimeUnit.SECONDS)) {
throw IOException("Send queue fully (backpressure)")
}
}
- JSON과 FILE_CHUNK가 완전히 분리된다.
- 동일 TCP 연결에서 멀티플렉싱 가능
- 메시지 경계 명확화
4-2. JSON packet 프로토콜 설계
[4 bytes] Packet Length
[4 bytes] PacketType
[n bytes] JSON Body
data class Packet (
val length : Int,
val type : PacketType,
val body : ByteArray
)
- length : Packet 전체 길이
- type : CHAT_MESSAGE, NAME_REGISTER 등 패킷 유형 명시
- body : 실제로 전달되는 DTO의 직렬화 값(bytes)
JSON_PACKET 읽기 / 쓰기 동작
// ConnectionService.kt
fun readPacket(): Packet {
val (frameType, payload) = readFrame()
if (frameType != FrameType.JSON_PACKET) {
throw IOException("Expected JSON_PACKET, but got $frameType")
}
return Protocol.decodePacket(payload)
}
fun writePacket(packet: Packet) {
val payload = Protocol.encodePacket(packet)
writeFrame(FrameType.JSON_PACKET, payload)
}
// Protocol.kt
...
// BytesArray(payload) to Packet
fun decodePacket(payload: ByteArray): Packet {
val dis = DataInputStream(ByteArrayInputStream(payload))
val length = dis.readInt()
val typeCode = dis.readInt()
val type = PacketType.fromCode(typeCode)
?: throw IOException("Unknown PacketType: $typeCode")
val bodySize = length-8
val body = ByteArray(bodySize)
dis.readFully(body)
return Packet(length, type, body)
}
// Packet to payload(BytesArray)
fun encodePacket(packet: Packet): ByteArray {
val buffer = ByteBuffer.allocate(packet.length)
buffer.putInt(packet.length)
buffer.putInt(packet.type.code)
buffer.put(packet.body)
return buffer.array()
}
...
JSON_PACKET은 Frame 내부 payload로만 존재한다.
4-3. File Chunk 프로토콜 설계
[n bytes] Chunk Transfer ID (UTF)
[4 bytes] Sequential Number
[4 bytes] Chunk Length
[n bytes] Chunk Data
data class FileChunk(
val transferId: String,
val seq: Int,
val length: Int,
val data: ByteArray
)
- transferId : 동시 다중 파일 전송 구분을 위한 고유 식별코드(UUID)
- seq : 순서 보장 / 확장성
- length : chunk 크기
- data : 실제 파일 데이터
FILE_CHUNK 읽기 / 쓰기 동작
fun readFileChunk(payload: ByteArray): FileChunk {
val dis = DataInputStream(payload.inputStream())
val transferId = dis.readUTF()
val seq = dis.readInt()
val length = dis.readInt()
val buffer = ByteArray(length)
dis.readFully(buffer)
return FileChunk(transferId, seq, length, buffer)
}
fun writeFileChunk(chunk: FileChunk) {
val baos = ByteArrayOutputStream()
val dos = DataOutputStream(baos)
dos.writeUTF(chunk.transferId)
dos.writeInt(chunk.seq)
dos.writeInt(chunk.length)
dos.write(chunk.data, 0, chunk.length)
dos.flush()
writeFrame(FrameType.FILE_CHUNK, baos.toByteArray())
}
fun writeRawFileChunk(bytes: ByteArray) {
writeFrame(FrameType.FILE_CHUNK, bytes)
}
5. 클라이언트 - FILE_CHUNK 전송 단계
이제 FILE_CHUNK 프로토콜을 기반으로 실제 로직을 구현한다.
// ClientSession.kt
internal fun handleFileTransfer(input: String): Boolean {
if(!input.startsWith("/f ")) return false
val parts = input.split(" ", limit = 3)
if (parts.size < 3) {
println("Usage: /f <user_name> <file_name_with_path>")
return true
}
val target = parts[1]
val filePath = parts[2]
sendFile(target, filePath)
return true
}
private fun sendFile(target: String, filePath: String) {
val file = File(filePath)
if (!file.exists() || !file.isFile) {
println("File not found: $filePath")
return
}
val transferId = UUID.randomUUID().toString()
val fileName = file.name
val fileSize = file.length()
logger.info { "Sending FILE to=$target, filename=$fileName, size=$fileSize" }
// 1. Send FILE_SEND_REQUEST (metadata)
sendPacket(PacketType.FILE_SEND_REQUEST, FileSendRequestDTO(target, transferId, fileName, fileSize))
// 2. Stream file chunks
val buffer = ByteArray(64 * 1024)
var seq = 0
try {
BufferedInputStream(file.inputStream()).use { input ->
while (true) {
val read = input.read(buffer)
if (read == -1) break
val data = buffer.copyOf(read)
conn.writeFileChunk(FileChunk(transferId, seq++, read, data))
}
}
// 3. Notify Completion
sendPacket(PacketType.FILE_SEND_COMPLETE, FileSendCompleteDTO(transferId))
println("File send finished: $fileName")
logger.info { "File send finished: $fileName" }
} catch (e: IOException) {
logger.error(e) { "Failed to send file: $fileName" }
}
}
- '/f ' 파싱 및 File metadata 구성
- FILE_SEND_REQUEST 전송
- 파일을 chunk로 만들어 FILE_CHUNK 전송
- FILE_SEND_COMPLETE 전송
6. 서버 - FILE_CHUNK 전송 단계
서버는 전송된 CHUNK를 저장하지 않는다.
// ConnectionService.kt
companion object {
fun peekTransferId(payload: ByteArray): String {
val dis = DataInputStream(ByteArrayInputStream(payload))
return dis.readUTF()
}
}
// ClientHandler.kt
private val transferTargets = ConcurrentHashMap<String, String>()
// 1. FILE_SEND_REQUEST JSON 패킷 처리
private fun handleFileSendRequest(dto: FileSendRequestDTO) {
val targetHandler = clientMapLock.withLock {
clients.values.firstOrNull { it.clientData.name == dto.target }
} ?: run {
sendPacket(
createPacket(
PacketType.USER_NOT_EXISTS,
ServerInfoDTO("The user does not exist.")
)
)
return
}
transferTargets[dto.transferId] = targetHandler.clientData.id
targetHandler.sendPacket(
createPacket(
PacketType.FILE_SEND_REQUEST,
dto
)
)
logger.info { "Forward FILE_SEND_REQUEST transferId=${dto.transferId} ${clientData.name} -> ${dto.target}" }
}
// 2. FILE_CHUNK 전달
private fun handleFileChunk(payload: ByteArray) {
val transferId = peekTransferId(payload)
val targetId = transferTargets[transferId] ?: run {
logger.warn {
"FILE_CHUNK received but no target stored for sender=${clientData.name}"
}
}
val targetHandler = clientMapLock.withLock { clients[targetId] } ?: return
targetHandler.conn.writeRawFileChunk(payload)
logger.debug { "Forwarded FILE_CHUNK from ${clientData.name} to $targetId" }
}
// 3. FILE_SEND_COMPLETE 전달
private fun handleFileSendComplete(dto: FileSendCompleteDTO) {
val targetId = transferTargets.remove(dto.transferId) ?: return
val targetHandler = clientMapLock.withLock { clients[targetId] } ?: return
targetHandler.sendPacket(
createPacket(
PacketType.FILE_SEND_COMPLETE,
dto
)
)
logger.info { "Forward FILE_SEND_COMPLETE transferId=${dto.transferId}" }
}
- FILE_SEND_REQUEST 경로 결정 (sender → target)
- FILE_CHUNK payload 그대로 전달 (해석 X)
- FILE_SEND_COMPLETE 전달 및 상태(state) cleanup
7. 클라이언트 - FILE_CHUNK 수신 단계
// ClientSession.kt
// 0. 수신되는 파일에 대한 상태(context) 저장용 동시성 맵 구성
data class IncomingFileContext(
val originalFileName: String,
val totalSize: Long,
var receivedSize: Long,
val out: BufferedOutputStream
)
private val incomingFile = ConcurrentHashMap<String, IncomingFileContext>()
// 1. FILE_SEND_REQUEST JSON_PACKET 수신
private fun handleIncomingFileRequest(dto: FileSendRequestDTO) {
val fileName = dto.fileName
val fileSize = dto.fileSize
val transferId = dto.transferId
println("Incoming file: $fileName ($fileSize bytes)")
val downloadDir = File("./downloads").apply { if (!exists()) mkdirs() }
val originalName = File(fileName).nameWithoutExtension
val extension = File(fileName).extension
val savedFileName = if (extension.isNotEmpty()) {
"${originalName}__${transferId}.${extension}"
} else {
"${originalName}__${transferId}"
}
val savedAs = File(downloadDir, savedFileName)
val out = BufferedOutputStream(FileOutputStream(savedAs))
incomingFile[transferId] = IncomingFileContext(fileName, fileSize, 0L, out)
logger.info {
"Prepare receiving file '$fileName' transferId=$transferId > ${savedAs.absolutePath}"
}
}
// 2. 실제 FILE_CHUNK 연속 수신
private fun handleFileChunkFrame(payload: ByteArray) {
val chunk = conn.readFileChunk(payload)
val ctx = incomingFile[chunk.transferId] ?: run {
logger.warn { "FILE_CHUNK for unknown transferId=${chunk.transferId} ignored."}
return
}
ctx.out.write(chunk.data, 0, chunk.length)
ctx.receivedSize += chunk.length
if (ctx.receivedSize >= ctx.totalSize) {
ctx.out.flush()
ctx.out.close()
incomingFile.remove(chunk.transferId)
println("File received successfully: ${ctx.originalFileName}")
logger.info { "File receive completed: ${ctx.originalFileName}" }
}
}
// 3. FILE_SEND_COMPLETE JSON_PACKET 수신
...
PacketType.FILE_SEND_COMPLETE -> {
val dto = packet.toDTO<FileSendCompleteDTO>()
val ctx = incomingFile[dto.transferId] ?: return
ctx.out.flush()
ctx.out.close()
logger.info { "File receive completed by COMPLETE: transferId=${dto.transferId}" }
}
...
- IncomingFileContext로 상태 관리
- transferID 기반 파일 식별
- 파일명 충돌 방지 + 확장자 유지 : <name>__<transferId>.<ext>
8. 동시성 이슈 해결
Frame 기반 프로토콜을 완성한 이후, 다음과 같은 동시성 문제가 동시에 발생했다.
- 파일 전송(FILE_CHUNK) + 채팅(JSON_PACKET)이 동시에 발생
- 여러 쓰레드에서 OutputStream.write() 경쟁
- flush() 호출 순서가 뒤엉키며 frame 경계가 깨질 위험 존재
이 문제든은 단순히 "락을 건다" 수준으로는 해결되지 않는다. 성능과 안정성이 동시에 무너질 수 있기 때문이다.
이를 해결하기 위해 선택한 전략이 Single Writer Thread와 Bounded Queue (LinkedBlockingQueue) 구조다.
8-1. Single Writer Thread가 보장하는 성능 특성
private val writerThread = Thread {
try {
while (!socket.isClosed) {
val (type, payload) = sendQueue.take()
output.writeByte(type.code.toInt())
output.writeInt(payload.size)
output.write(payload)
output.flush()
}
} catch (_: Exception) {
close()
}
}.apply {
isDaemon = true
name = "connection-writer-${socket.port}"
start()
}
이 구조가 제공하는 성능적 이점은 다음과 같다.
- 프레임 단위 원자성 보장 (Frame Atomicity)
- 하나의 frame은 하나의 쓰레드에서만 write
- writeByte → writeInt → write(payload)가 절대 분리되지 않음
- 결과적으로, JSON_PACKET과 FILE_CHUNK가 섞여 깨질 가능성 제거
- 성능 관점에서 이는 재시도·복구 비용을 원천 차단하는 효과를 가진다.
- write 경쟁 제거 → CPU context switching 감소
- 멀티 쓰레드에서 직접 OutputStream.write()를 호출하면, 내부적으로:
- synchronized 경쟁
- kernel 진입 / 이탈 반복
- 쓰레드 간 컨텍스트 스위칭 증가 발생
- Single Writer 구조를 통해 :
- write 시스템 콜을 호출하는 쓰레드는 단 하나
- 생산자 쓰레드는 byte array를 만들고 큐에 넣는 작업만 수행함.
- 멀티 쓰레드에서 직접 OutputStream.write()를 호출하면, 내부적으로:
이 구조를 통해 파일 전송 + 채팅이 동시에 발생하는 상황에서 불필요한 CPU 소모를 크게 줄인다.
8-2. Bounded Queue가 제공하는 성능 안정성 (Backpressure)
private val sendQueue = LinkedBlockingQueue<Pair<FrameType, ByteArray>>(256)
이 큐는 단순한 버퍼가 아니다. 이 큐는 애플리케이션 레벨 backpressure 장치다.
- 메모리 사용량 상한선 보장
- Queue 크기 = 256
- Frame 하나당 최대 크기 제한 존재
- 결과적으로, 송신 대기 데이터의 최대 메모리 사용량이 예측 가능해짐.
- 아래의 문제 방지
- 대용량 파일 전송 중, FILE_CHUNK 생산 속도가 네트워크 전송 속도 초과
- JVM Heap에 ByteArray 무한 적재
- Out of Memory(OOM) 또는 Grabage Collection(GC) 폭주
- 결론적으로 Bounded Queue는
- 생산자 쓰레드를 block 또는 실패시키며
- "지금은 더 못 보낸다"는 신호를 명확히 전달한다.
Backpressure
Backpressure는 속도를 느리게 하는 장치가 아니라, 시스템을 무너지지 않게 만드는 장치다.
이 구조를 통해 :
- 네트워크가 느리다면 → Queue가 가득 차고
- Queue가 가득 차면 → 생산자가 자연스럽게 속도를 줄이게 된다.
- 결과적으로, 시스템 처리량이 네트워크 대역폭에 맞춰 자동으로 조절된다.
최대 처리량은 줄어들 수 있지만, 처리 실패율은 극적으로 감소한다.
8-3. flush 전략의 성능적 의미
output.flush()
flush는 비용이 비싼 연산이다.
하지만 Single Writer 구조에서는 flush 호출 주체가 하나이기 때문에 호출 타이밍이 프레임 경계로 고정된다.
이로 인해 flush 빈도가 예측 가능해지고, 커널 버퍼와의 상호작용이 안정화되며, 지연 편차(latency jitter)가 감소한다.
8-4. 이 구조가 제공하는 "성능"의 의미
이 구조에서 말하는 성능은 "초당 몇 MB를 보내느냐"가 아니다. 이 구조가 보장하는 성능은 다음과 같다 :
| 항목 | 의미 |
| 프레임 무결성 | 데이터 재전송 / 복구 비용 제거 |
| 메모리 안정성 | OOM, GC 폭주 방지 |
| 처리 예측성 | 최대 메모리·크기 계산 가능 |
| 처리량 안정성 | 네트워크 속도에 맞춘 자연스러운 조절 |
| 확장성 기반 | priority queue, rate limit 확장 가능 |
Single Writer Thread와 Bounded Queue를 도입함으로써,
파일 전송과 채팅이 동시에 발생하는 상황에서도
프레임 무결성, 메모리 상한, 처리량 안정성을 모두 확보할 수 있었다.
9. 트러블슈팅 내역
- FILE_SEND_COMPLETE를 DTO 없이 보낸 파싱 실패
- transferId 매핑 누락으로 FILE_CHUNK drop
- Queue에 offer + put 중복 enqueue 버그
- 파일 수신 완료 조건 중복 처리
모두 TCP 스트림 + 상태 머신 특성을 이해하면서 해결했다.
10. 성능/안정성 관점 정리
10-1. chunk size : 64KB (GC/IO 균형)
// ClientSession.kt
...
// 2. Stream file chunks
val buffer = ByteArray(64 * 1024)
var seq = 0
try {
BufferedInputStream(file.inputStream()).use { input ->
while (true) {
val read = input.read(buffer)
if (read == -1) break
val data = buffer.copyOf(read)
conn.writeFileChunk(FileChunk(transferId, seq++, read, data))
}
}
// 3. Notify Completion
sendPacket(PacketType.FILE_SEND_COMPLETE, FileSendCompleteDTO(transferId))
println("File send finished: $fileName")
logger.info { "File send finished: $fileName" }
} catch (e: IOException) {
logger.error(e) { "Failed to send file: $fileName" }
}
}
...
파일을 64KB 단위로 분할함으로써, 너무 작은 chunk로 인한 I/O 호출 과다와 너무 큰 chunk로 인한 Heap 메모리 및 GC 부담을 동시에 피할 수 있었다. 디스크 읽기, 네트워크 전송, GC 비용 간의 균형을 고려한 선택이었다.
10-2. 서버 무상태(relay) → 수평 확장 가능
// ClientHandler.kt
private fun handleFileChunk(payload: ByteArray) {
val transferId = peekTransferId(payload)
val targetId = transferTargets[transferId] ?: run {
logger.warn {
"FILE_CHUNK received but no target stored for sender=${clientData.name}"
}
}
val targetHandler = clientMapLock.withLock { clients[targetId] } ?: return
targetHandler.conn.writeRawFileChunk(payload)
logger.debug { "Forwarded FILE_CHUNK from ${clientData.name} to $targetId" }
}
서버는 파일 내용을 저장하거나 해석하지 않고, FILE_CHUNK payload를 그대로 전달하는 relay 역할만 수행한다.
이로 인해 서버 인스턴스 간 상태 공유가 필요 없으며, 로드 밸런서를 통한 수평 확장이 가능한 구조를 유지할 수 있다.
10-3. backpressure로 메모리 상한 보장
// ConnectionService.kt
private val sendQueue = LinkedBlockingQueue<Pair<FrameType, ByteArray>>(256)
Bounded Queue를 통해 송신 대기 프레임 수에 명확한 상한을 두어, 파일 전송 속도가 네트워크 처리 속도를 초과하더라도 JVM Heap 메모리가 무한히 증가하는 상황을 방지했다.
이는 시스템을 빠르게 만드는 장치가 아니라, 무너지지 않게 만드는 장치다.
10-4. 파일/채팅 완전 분리로 안정성 확보
FrameType을 통해 JSON_PACKET과 FILE_CHUNK를 명확히 분리함으로써, 파일 전송 중 발생하는 채팅 메시지가 파일 데이터로 오인되는 문제를 제거했다.
동일 TCP 스트림 위에서도 서로 다른 성격의 데이터가 독립적으로 안전하게 처리될 수 있는 구조를 확보했다.
이 구조는 최대 성능을 끌어내기보다는,
대용량 파일 전송과 실시간 채팅이 동시에 발생해도
시스템이 예측 가능하고 안정적으로 동작하도록 설계되었다.
11. 결론 : "TCP 위에 프로토콜을 설계한다는 것"
TCP는 신뢰성과 순서를 보장해주지만, 그 이상도 그 이하도 아니었다.
메시지의 경계, 데이터의 의미, 동시성 제어는 모두 애플리케이션의 책임이었다.
파일 전송 기능을 채팅 애플리케이션에 추가하는 과정에서, 이 사실을 이론이 아닌 실제 문제로 마주하게 되었다.
단순히 파일을 전송하는 것이 목표였다면, HTTP나 기존 라이브러리를 사용하는 것이 훨씬 쉬운 선택이었다.
그러나 동일한 TCP 스트림 위에서 채팅과 파일 전송이 공존하는 환경에서 직접 설계하면서, "TCP 위에 무엇을 얹을 것인가"를 처음부터 끝까지 고민해야 했다.
이번 프로젝트에서는 :
- Frame 기반 프로토콜을 통해 메시지 경계를 정의하고
- Single Writer와 backpressure를 통해 동시성과 메모리 안정성을 확보했으며,
- 서버를 무상태 relay로 유지함으로써 확장 가능한 구조를 만들 수 있었다.
이 과정에서 성능이란 단순히 더 빠른 전송이 아니라, 예측 가능하고 무너지지 않는 동작이라는 점을 다시 한번 확인했다.
이 구현은 완성형이 아닌, TCP 스트림 위에서 애플리케이션 레벨 프로토콜을 설계할 때 어떤 고민과 선택이 필요한지를 기록한 결과물에 가깝다.
그리고 이 경험은 이후 어떤 네트워크 기반 시스템을 설계하더라도 반드시 되돌아보게 될 중요한 기준점이 될 것 같다.
12. 후속 과제
- 다중 파일 동시 전송
- 파일 전송 재개(resume) / 취소(cancel)
- rate limit / priority (chat > file)
- 무결성 검증 (hash)
- TLS 적용
Powered By. ChatGPT
'Backend' 카테고리의 다른 글
| [Backend] 고성능 시스템에서 전송 속도를 통제하는 방법 (0) | 2025.12.31 |
|---|---|
| [Backend] BitTorrent Protocol : 거대 파일을 조각 내어 공유·분산하기 (1) | 2025.12.29 |
| [Backend] HTTP 발전의 역사 (1) | 2025.12.08 |
| [Backend] JVM - Primitive type과 Reference type (1) | 2025.12.02 |
| [Backend] ShadowJar 플러그인 적용 후 “기본 Manifest 속성이 없습니다” 오류 해결기 (0) | 2025.11.24 |