
ko_chat코드베이스 기준 설계 및 구현 기록입니다.
(실제 운영 장애 회고가 아닌, 과거 콜센터 프로젝트 설계 했을때 느낀점을 리마인드를 하고 작성한 포스팅 입니다.)
클라이언트가 WebSocket을 통해 SEND_MESSAGE 이벤트를 발생시키면, 서버는 이를 수신하여 MySQL에 영속화한 뒤, Redis Pub/Sub을 통해 해당 채팅방을 구독 중인 다른 WAS 인스턴스들과 클라이언트들에게 메시지를 브로드캐스트하는 구조입니다.
// ChatWebSocketHandler.kt — WebSocket 수신 및 처리 분기
when (messageType) {
"SEND_MESSAGE" -> {
val jsonNode = objectMapper.readTree(payload)
val sendMessageRequest = SendMessageRequest(
chatRoomId = jsonNode.get("chatRoomId").asLong(),
type = MessageType.valueOf(jsonNode.get("messageType").asText()),
content = jsonNode.get("content")?.asText(),
metadata = jsonNode.get("metadata")?.let { node ->
if (node.isTextual) node.asText() else node.toString()
},
)
chatService.sendMessage(sendMessageRequest, userId)
}
}
기존 도메인 모델에서 메시지 타입은 TEXT와 SYSTEM 구조만 지원하고 있었습니다.
여기에 파일 업로드 기능을 추가할 때 가장 먼저 맞닥뜨린 선택지는 두 가지였습니다.
MySQL BLOB 타입을 활용한 바이너리 직접 저장: 이 방식은 데이터베이스의 크기를 급격히 비대하게 만들며, 백업 및 복구 비용을 폭증시킵니다.
대용량 조회 시 버퍼 풀 효율이 떨어지는 치명적인 문제가 있습니다.
WebSocket 커넥션을 통한 바이너리 전송: WebSocket 프레임에 대용량 파일을 실어 보내면 application-level의 프레임 제어가 복잡해집니다.
특히 모바일 환경이나 네트워크 전환 시 연결 불안정으로 인한 재전송 처리가 까다롭고, 전체 커넥션의 처리량을 저하시킵니다.
당시 로컬 환경에는 추후 RAG 기능을 고도화하기 위해 Docker Compose로 구성된 MinIO, Milvus, Kafka 등의 인프라가 이미 구성되어 있었습니다.
따라서 새로운 저장소를 추가하기보다는 기존에 확보된 인프라 자원을 효율적으로 연계하는 방향으로 선회했습니다.
실시간성이 중요한 채팅 파이프라인의 부하를 최소화하기 위해, 바이너리 업로드 경로와 메시지 전송 경로를 물리적으로 분리하는 아키텍처를 선택했습니다.

이 구조의 핵심은 파일 바이너리 처리를 HTTP REST API 영역으로 격리시킨 점입니다.
클라이언트는 대용량 파일을 분리된 HTTP 엔드포인트로 먼저 업로드하고, 안전하게 업로드가 완료되면 반환받은 타입과 메타데이터만을 WebSocket Payload에 실어 보냅니다.
이로 인해 WebSocket 핸들러는 무거운 바이너리 파싱 부하에서 해방됩니다.
| 비교 지표 | 내용 |
|---|---|
| 선택 기술 | MinIO (S3 호환 온프레미스/로컬 오브젝트 스토리지) |
| 대안 기술 | MySQL BLOB 저장, 서버 로컬 디스크 파일 시스템 직접 쓰기 |
| 선택 근거 | 실시간 텍스트 메시지와 바이너리 데이터는 생명주기와 용량 특성이 전혀 다릅니다. |
| 구조적 이득 | RDB는 가벼운 인덱스 및 메타데이터만 관리하고, 무거운 파일은 chat/{roomId}/{uuid}-filename 경로로 오브젝트 스토리지에 격리하여 스토리지 확장성을 확보합니다. |
| 도입 비용 | 단일 트랜잭션 처리가 불가능하여 업로드와 메시지 전송이 2단계로 분리됩니다. 업로드 성공 후 메시지 전송이 실패했을 때의 예외 처리가 필요합니다. |
MinioStorageService.kt에서는 오브젝트 키의 고유성을 보장하기 위해 UUID를 조합하고, 프론트엔드가 격리된 파일에 안전하게 접근할 수 있도록 만료 시간이 지정된 Presigned URL을 발급합니다.
fun uploadChatFile(chatRoomId: Long, file: MultipartFile): StoredObject {
val safeName = sanitizeFileName(file.originalFilename ?: "file")
val objectKey = "chat/$chatRoomId/${UUID.randomUUID()}-$safeName"
val contentType = file.contentType?.takeIf { it.isNotBlank() } ?: "application/octet-stream"
file.inputStream.use { input ->
putObject(objectKey, input, file.size, contentType)
}
return StoredObject(
objectKey = objectKey,
fileName = safeName,
mimeType = contentType,
size = file.size,
url = createPresignedUrl(objectKey),
)
}
톰캣 환경에서 대용량 파일 업로드를 안정적으로 수용하기 위해 application.properties에 멀티파트 제한을 명시적으로 설정했습니다.
app.minio.enabled=true
app.minio.endpoint=http://localhost:9000
app.minio.bucket=ko-chat
spring.servlet.multipart.max-file-size=50MB
spring.servlet.multipart.max-request-size=50MB
인프라 설정 주의사항: 로컬 Docker Compose 환경 구성 시, MinIO는 대시보드 콘솔 포트와 실제 API 통신 포트를 분리하여 운영합니다.
스프링 부트의endpoint설정이 API 포트를 올바르게 바라보지 않으면 애플리케이션 단에서 커넥션 타임아웃 혹은 프로토콜 에러가 발생하므로 주의해야 합니다.
멀티파트 요청을 처리하는 ChatAttachmentController.kt 엔드포인트는 아래와 같이 구현했습니다.
@PostMapping("/{roomId}/attachments")
fun uploadAttachment(
authentication: Authentication,
@PathVariable roomId: Long,
@RequestPart("file") file: MultipartFile,
): ResponseEntity<AttachmentUploadResponse> {
val userId = chatUserResolver.resolveUserId(authentication.name)
return ResponseEntity.ok(chatAttachmentService.upload(roomId, userId, file))
}
ChatAttachmentService.kt 내부에서는 업로드된 파일의 MIME 타입을 기반으로 IMAGE와 일반 FILE 군을 식별하고, 비즈니스 요건에 맞는 보존 정책 만료일을 계산하여 메타데이터 DTO를 구성합니다.
val stored = minioStorageService.uploadChatFile(chatRoomId, file)
val messageType = if (stored.mimeType.startsWith("image/")) MessageType.IMAGE else MessageType.FILE
val metadata = MessageMetadataDto(
objectKey = stored.objectKey,
url = stored.url,
fileName = stored.fileName,
mimeType = stored.mimeType,
size = stored.size,
expiresAt = LocalDateTime.now().plusDays(FILE_RETENTION_DAYS),
)
return AttachmentUploadResponse(
messageType = messageType,
metadata = metadata,
content = if (messageType == MessageType.IMAGE) null else stored.fileName,
)
| 비교 지표 | 내용 |
|---|---|
| 선택 기술 | MessageType 확장 + 단일 TEXT 필드 내 JSON 포맷 비정규화 저장 |
| 대안 기술 | IMAGE_MESSAGE, FILE_MESSAGE 등 다형성 상속 구조의 테이블 분리 또는 조인 테이블 운영 |
| 선택 근거 | 실시간 채팅의 특성상 타임라인 순 정렬 및 조회가 빈번합니다. 테이블을 분리하면 조회 시 잦은 조인 혹은 Union 연산으로 인해 인덱스 효율이 떨어집니다. |
| 구조적 이득 | 기존의 대량 데이터 조회 및 Redis Pub/Sub 메시지 전송 파이프라인의 데이터 구조를 그대로 유지할 수 있어 공통 비즈니스 로직의 오버헤드가 없습니다. |
| 도입 비용 | 애플리케이션 레이어에서 JSON 문자열의 직렬화/역직렬화를 책임져야 하며, 메타데이터 구조 스키마가 변경될 경우 하위 호환성을 보장하기 위한 코드가 추가되어야 합니다. |
도메인 레벨에서 다루는 다양한 메시지 형태를 정의하기 위해 엔티티 구조를 확장했습니다.
// MessageType.kt
enum class MessageType {
TEXT,
IMAGE,
FILE,
LINK,
SYSTEM,
}
JPA 매핑 엔티티인 MessageJpaEntity.kt 구조입니다. 관계형 데이터베이스의 제약을 피하면서 구조의 가변성을 수용하기 위해 metadata 필드를 TEXT 타입으로 선언하여 JSON 포맷을 직접 수용하도록 설계했습니다.
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
var type: MessageType = MessageType.TEXT
@Column(columnDefinition = "TEXT")
var content: String? = null
@Column(columnDefinition = "TEXT")
var metadata: String? = null
프론트엔드와 통신하며 오픈그래프 데이터 및 파일 지표를 공통으로 캡슐화하는 MessageMetadataDto.kt 구조입니다.
data class MessageMetadataDto(
val objectKey: String? = null,
val url: String? = null,
val fileName: String? = null,
val mimeType: String? = null,
val size: Long? = null,
val expiresAt: LocalDateTime? = null,
val linkUrl: String? = null,
val title: String? = null,
val description: String? = null,
val imageUrl: String? = null,
val siteName: String? = null,
val domain: String? = null,
)
비즈니스 코어 로직을 담당하는 ChatServiceImpl.sendMessage에서는 텍스트 메시지가 인입되었을 때 단순 URL 형태인지 먼저 검사합니다.
조건 만족 시 이를 LINK 타입으로 동적 승격하고 Open Graph 메타데이터를 파싱하여 주입합니다.
반면 이미지나 파일 타입의 메시지는 업로드 단계를 거쳐 생성된 메타데이터가 누락되지 않았는지 Validation 단계를 수행합니다.
if (messageType == MessageType.TEXT && !content.isNullOrBlank() && linkPreviewService.isUrlOnly(content)) {
messageType = MessageType.LINK
val preview = linkPreviewService.preview(content.trim())
metadataJson = messageMetadataMapper.toJson(preview)
content = preview.linkUrl
}
if (messageType == MessageType.IMAGE || messageType == MessageType.FILE) {
require(!metadataJson.isNullOrBlank()) { "첨 be 메시지에는 metadata가 필요합니다." }
}
데이터가 정상적으로 영속화되면, 시스템은 기존 인프라 파이프라인인 로컬 세션 브로드캐스트와 Redis 메시지 브로커를 호출하여 클러스터 내 타 노드로 이벤트를 전파합니다.
val chatMessage = ChatMessage(
id = messageId,
content = savedMessage.content ?: "",
messageType = savedMessage.type,
metadata = savedMessage.metadata,
chatRoomId = request.chatRoomId,
senderId = senderId,
senderName = sender.displayName ?: sender.username ?: "",
sequenceNumber = savedMessage.sequenceNumber,
timestamp = savedMessage.createdAt,
)
webSocketSessionManager.sendMessageToLocalRoom(request.chatRoomId, chatMessage)
redisMessageBroker.broadcastToRoom(request.chatRoomId, chatMessage, excludeServerId = currentServerId)
| 비교 지표 | 내용 |
|---|---|
| 선택 기술 | Milvus 메타 스키마 동기 등록 |
| 대안 기술 | 파일 저장과 동시에 Apache Tika 등을 이용한 텍스트 추출 및 즉시 임베딩 |
| 선택 근거 | 파일로부터 텍스트를 추출하고 딥러닝 모델 기반 임베딩 벡터를 추출하는 연산은 무거운 I/O 및 CPU 부하를 동반합니다. 이를 채팅 메시지 트랜잭션 내에서 처리하면 심각한 성능 저하가 발생합니다. |
| 구조적 이득 | 메타데이터 기반의 레코드를 우선 생성해 두고 비동기 백그라운드 파이프라인이 바인딩될 자리를 선점함으로써 실시간 채팅 전송 지연을 제로에 가깝게 유지합니다. |
| 도입 비용 | 실질적인 의미 기반 벡터 검색은 백그라운드 워커가 벡터값을 업데이트하기 전까지 동작하지 않으며, 단기적으로는 플레이스홀더 형태의 더미 데이터가 적재됩니다. |
메시지 영속화 시점에 파일 타입인 경우 Milvus 컴포넌트를 호출하여 검색 인덱스를 위한 엔트리를 확보합니다.
if (messageType == MessageType.IMAGE || messageType == MessageType.FILE) {
val metadata = messageMetadataMapper.fromJson(metadataJson)
?: throw IllegalArgumentException("첨부 metadata 형식이 올바르지 않습니다.")
val attachment = MessageAttachmentJpaEntity().apply {
messageId = savedMessage.id
chatRoomId = request.chatRoomId
objectKey = metadata.objectKey ?: throw IllegalArgumentException("objectKey가 필요합니다.")
fileName = metadata.fileName ?: "file"
mimeType = metadata.mimeType ?: "application/octet-stream"
size = metadata.size ?: 0
}
val savedAttachment = messageAttachmentJpaRepository.save(attachment)
milvusAttachmentIndexService?.indexAttachment(savedAttachment)
}
MilvusAttachmentIndexService.kt 내부에서는 외부 벡터 데이터베이스 컴포넌트의 장애가 메인 실시간 채팅 기능에 영향을 주지 않도록 완벽히 예외 래핑을 처리하며, 지정된 디멘션 수만큼 0.0f로 채워진 가상 벡터를 우선 인서트합니다.
fun indexAttachment(attachment: MessageAttachmentJpaEntity) {
if (!collectionReady || client == null || attachment.milvusIndexed) {
return
}
try {
val vector = Collections.nCopies(properties.vectorDim, 0.0f) // placeholder 임시 벡터 생성
val fields = listOf(
InsertParam.Field("attachment_id", listOf(attachment.id ?: 0L)),
InsertParam.Field("message_id", listOf(attachment.messageId ?: 0L)),
InsertParam.Field("chat_room_id", listOf(attachment.chatRoomId)),
InsertParam.Field("object_key", listOf(attachment.objectKey)),
InsertParam.Field("file_name", listOf(attachment.fileName)),
InsertParam.Field("embedding", listOf(vector)),
)
client!!.insert(
InsertParam.newBuilder()
.withCollectionName(properties.collectionName)
.withFields(fields)
.build()
)
} catch (ex: Exception) {
logger.warn("Milvus 첨부파일 등록 실패(objectKey={}): {}", attachment.objectKey, ex.message)
}
}
클라이언트 레이어는 API 전송과 웹소켓 이벤트를 순차적으로 핸들링하는 구조를 지닙니다.
export const uploadChatAttachment = (
token: string,
roomId: number,
file: File,
): Promise<AttachmentUploadResponse> => {
const formData = new FormData()
formData.append('file', file)
return postFormData(`${chatPath}/${roomId}/attachments`, formData, token)
}
export const fetchLinkPreview = (token: string, url: string): Promise<MessageMetadata> =>
postJson(`${chatPath}/link-preview`, { url }, token)
프론트엔드는 파일 선택 인터페이스가 트리거되면 HTTP 전송을 먼저 수행한 뒤, 성공 플래그가 반환되었을 때 비로소 웹소켓 파이프라인으로 메타데이터 문자열을 발송합니다.
또한 사용자가 텍스트 입력창에 링크 정보만 입력했을 때 정규식을 검사하여 오프라인으로 썸네일을 요청하는 로직도 함께 제어합니다.
const sendRichMessage = (
messageType: MessageType,
content: string | null,
metadata?: MessageMetadata | null,
) => {
const wsMessage: OutgoingWebSocketMessage = {
type: 'SEND_MESSAGE',
chatRoomId: props.chatRoom.id,
messageType,
content,
metadata: metadata ? JSON.stringify(metadata) : null,
}
return sendMessage(wsMessage)
}
const handleFileSelected = async (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file || !isConnected.value) return
uploadLoading.value = true
try {
const uploaded = await uploadChatAttachment(props.token, props.chatRoom.id, file)
if (!sendRichMessage(uploaded.messageType, uploaded.content, uploaded.metadata)) {
emit('error', '메시지 전송에 실패했습니다')
}
} finally {
uploadLoading.value = false
}
}
// 텍스트 필드 정규식 감지를 통한 링크 프리뷰 처리 스니펫
if (URL_ONLY_REGEX.test(content)) {
const preview = await fetchLinkPreview(props.token, content)
if (sendRichMessage('LINK', preview.linkUrl ?? content, preview)) {
messageInput.value = ''
}
return
}
export type MessageType = 'TEXT' | 'IMAGE' | 'FILE' | 'LINK' | 'SYSTEM'
export interface MessageMetadata {
objectKey?: string | null
url?: string | null
fileName?: string | null
mimeType?: string | null
size?: number | null
expiresAt?: string | null
linkUrl?: string | null
title?: string | null
description?: string | null
imageUrl?: string | null
siteName?: string | null
domain?: string | null
}
서버로부터 브로드캐스트된 데이터 모델의 타입 플래그를 판단하여 조건부 분기 렌더링을 처리합니다.
<template v-if="message.type === 'IMAGE' && metadata?.url">
<a :href="metadata.url" target="_blank" rel="noopener noreferrer" class="chat-image-link">
<img :src="metadata.url" :alt="metadata.fileName ?? '이미지'" class="chat-image-preview" />
</a>
</template>
<template v-else-if="message.type === 'FILE' && metadata">
<div class="chat-file-card">
<strong>{{ metadata.fileName }}</strong>
<span>용량: {{ formatFileSize(metadata.size) }}</span>
<button type="button" @click="openFile">저장</button>
</div>
</template>
<template v-else-if="message.type === 'LINK' && metadata">
<a :href="metadata.linkUrl ?? message.content ?? '#'" class="chat-link-card" target="_blank">
<img v-if="metadata.imageUrl" :src="metadata.imageUrl" class="chat-link-thumb" />
<strong>{{ metadata.title ?? metadata.linkUrl }}</strong>
<span class="chat-link-domain">{{ metadata.domain }}</span>
</a>
</template>
LinkPreviewService.kt는 외부 차단 및 크롤러 차단 대응을 위해 표준 브라우저 User-Agent를 모방하도록 설계되었으며, 특정 도메인의 무응답으로 인한 WAS 스레드 고갈을 막기 위해 타임아웃을 8초로 타이트하게 제한했습니다.
추가로 YouTube 주소는 스크래핑을 통하지 않고 식별자를 분리하여 공식 레이아웃 데이터를 반환하도록 분기 처리했습니다.
fun preview(rawUrl: String): MessageMetadataDto {
val normalizedUrl = normalizeUrl(rawUrl)
val youtubeId = extractYoutubeId(normalizedUrl)
if (youtubeId != null) {
return previewYoutube(normalizedUrl, youtubeId)
}
return try {
val document = Jsoup.connect(normalizedUrl)
.userAgent("Mozilla/5.0 (compatible; ko-chat-bot/1.0)")
.timeout(8000)
.followRedirects(true)
.get()
MessageMetadataDto(
linkUrl = normalizedUrl,
title = firstNonBlank(meta(document, "og:title"), document.title(), normalizedUrl),
description = firstNonBlank(meta(document, "og:description"), meta(document, "description")),
imageUrl = firstNonBlank(meta(document, "og:image"), meta(document, "twitter:image"))
?.let { absolutize(it, normalizedUrl) },
domain = URI(normalizedUrl).host,
)
} catch (_: Exception) {
MessageMetadataDto(linkUrl = normalizedUrl, title = normalizedUrl)
}
}
보안상 보존 정책과 외부 주소 접근 제한을 위해 오브젝트 스토리지의 다운로드 링크는 영구 주소가 아닌 유효기간이 만료되는 구조를 취합니다.
사용자가 아주 과거의 타임라인을 조회할 때 발생할 수 있는 만료 링크 깨짐 현상을 해결하기 위해 메타데이터 오브젝트 키를 이용해 신규 토큰 주소를 즉시 재생성해 주는 별도의 리프레시 API 엔드포인트를 운영합니다.
@GetMapping("/files/url")
fun refreshFileUrl(
authentication: Authentication,
@RequestParam objectKey: String,
): ResponseEntity<Map<String, String>> {
val userId = chatUserResolver.resolveUserId(authentication.name)
val url = chatAttachmentService.refreshDownloadUrl(objectKey, userId)
return ResponseEntity.ok(mapOf("url" to url))
}
가장 큰 부작용은 바이너리는 스토리지에 업로드되었으나, 바로 직후 클라이언트 브라우저가 크래시되거나 소켓 끊김 현상으로 인해 웹소켓 메시지가 누락될 때 발생합니다.
결과적으로 messages 데이터 테이블과 영속 결합되지 못한 정크 바이너리 파일들이 스토리지 용량을 잠식하게 됩니다.
이 문제를 해결하기 위해 시스템 설계 수준에서 실시간 강제 정합성을 추구하기보다는, 백그라운드 스케줄러 배치 워커를 활용하여 주기적으로 message_attachments에 매핑되지 않은 스토리지 내 임시 파일 목록을 추적하여 대량 청소하는 구조를 채택했습니다.
스프링 애플리케이션 초기 기동 시 @PostConstruct 단계에서 MinIO 스토리지 버킷의 생존 여부를 조회합니다. 이때 네트워크나 스토리지 컨테이너 문제로 연결이 거부되더라도 warn 로그만 기록할 뿐, 핵심 메시지 서버 자체의 부팅 과정이 중단되지 않도록 완벽히 고립시켰습니다.
파일 전송 기능에 문제가 생기더라도 가장 중요한 기본 텍스트 대화의 실시간 가용성은 훼손되지 않아야 하기 때문입니다.
사용자가 임의로 입력한 주소를 서버 내부가 직접 컬링하는 과정은 항상 내부망 프라이빗 IP 변조 공격노출 위험을 안고 있습니다.
현재 아키텍처 버전에서는 8초 타임아웃 규칙 및 예외 발생 시 TEXT 형태로 무조건 Fallback 처리하는 방어 레이어만을 구축해 두었습니다.
향후 고도화 단계에서 내부 인트라넷 대역 웹 크롤링 요청 차단 필터 커스텀 컴포넌트가 연계되어야 합니다.
아키텍처의 복잡도를 제어하고 출시 속도를 확보하기 위해 아래 항목들은 초기 설계 범위에서 의도적으로 배제했습니다.
Kafka 기반 분산 비동기 파이프라인 완전 연동: Milvus 벡터 DB에 플레이스홀더를 박아 넣는 행위까지만 구현했으며, 실제 데이터 적재 이후 가공 파이프라인에서 바이너리를 긁어가 텍스트 임베딩을 완료하는 백그라운드 컨슈머 레이어는 차후 고도화 단계로 이관합니다.
업로드 파일 콘텐츠 모더레이션및 안티바이러스 스캔: 악성 코드 바이너리가 침투하는 것을 차단하기 위한 멀티 레이어 보안 검증은 현재 생략되어 있으며, 애플리케이션 레벨의 단순 MIME 필터링 및 50MB 용량 규격 제한만 작동합니다.
대용량 파일 청크 단위 분할 이어받기: 파일 크기 상한선이 최대 50MB 이내이므로 단일 HTTP 커넥션 처리 방식으로도 인프라 수용력이 충분하다고 판단했습니다.
이번 작업을 하면서 다시 한번 느낀 것은, 좋은 시스템은 모든 것을 완벽하게 해결하려는 것이 아니라 무엇을 얻고 무엇을 포기할지를 명확히 결정하는 것이라는 점입니다.
실무에서는 성능, 비용, 운영 편의성, 데이터 정합성 모두를 동시에 만족시키는 설계는 존재하지 않습니다.
중요한 것은 시스템의 목적에 맞는 우선순위를 세우고, 그에 따른 트레이드오프를 의도적으로 선택하는 것입니다.
이번 개편 역시 실시간 메시징이라는 핵심 가치에 집중하기 위해 일부 운영 비용과 비동기 처리에 따른 정합성 차이를 받아들였습니다.
처음에는 '왜 이렇게까지 분리해야 할까'라는 고민도 있었지만, 설계를 이어갈수록 시스템의 역할과 책임을 명확히 나누는 것이 장기적인 확장성과 유지보수성을 높이는 길이라는 점을 체감했습니다.
결국 좋은 아키텍처는 복잡한 기술을 적용하는 것이 아니라, 시스템이 가장 잘해야 하는 일에 집중할 수 있도록 적절한 타협을 설계하는 것이라는 사실을 다시 한번 배울 수 있었습니다.