-
Notifications
You must be signed in to change notification settings - Fork 0
Chat
실시간 채팅과 AI 기반 욕설 모더레이션을 담당하는 도메인입니다.
- 실시간 채팅 — STOMP over WebSocket, Redis Pub/Sub fan-out
- AI 모더레이션 — Redis Streams 기반 비동기 워커 + Hugging Face 혐오표현 분류
| 목표 | 접근 |
|---|---|
| 채팅 지연 최소화 | 모더레이션을 채팅 전송 경로에서 분리 (사후 처리) |
| 수평 확장 | WebSocket 세션은 인스턴스에 종속 → Redis Pub/Sub 로 인스턴스 간 fan-out |
| AI 지연 흡수 | Redis Streams 큐 + 별도 컨슈머 → 외부 AI 호출이 채팅을 막지 않음 |
| 대용량 로그 | chat_logs 를 created_at 기준 RANGE 파티셔닝 |
-
채팅 엔드포인트 — WebSocket
/ws/chat(native, SockJS 미사용) -
발행 —
/app/streams/{streamId}/chat -
구독 —
/topic/streams/{streamId} -
Redis 채널 —
stream:events:{streamId}(도메인 공용) -
모더레이션 큐 — Redis Streams
moderation:chat, 컨슈머 그룹moderation-workers
실시간 채팅과 AI 기반 욕설 모더레이션을 담당하는 도메인입니다.
- 실시간 채팅 — STOMP over WebSocket, Redis Pub/Sub fan-out
- AI 모더레이션 — Redis Streams 기반 비동기 워커 + Hugging Face 혐오표현 분류
- [설계 목표](#설계-목표)
- [모듈 구조](#모듈-구조)
- [실시간 채팅 흐름](#실시간-채팅-흐름)
- [AI 모더레이션 파이프라인](#ai-모더레이션-파이프라인)
- [인증](#인증)
- [데이터 모델](#데이터-모델)
- [주요 설계 결정](#주요-설계-결정)
- [트러블슈팅](#트러블슈팅)
- [향후 과제](#향후-과제)
| 목표 | 접근 |
|---|---|
| 채팅 지연 최소화 | 모더레이션을 채팅 전송 경로에서 분리 (사후 처리) |
| 수평 확장 | WebSocket 세션은 인스턴스에 종속 → Redis Pub/Sub 로 인스턴스 간 fan-out |
| AI 지연 흡수 | Redis Streams 큐 + 별도 컨슈머 → 외부 AI 호출이 채팅을 막지 않음 |
| 대용량 로그 |
chat_logs 를 created_at 기준 RANGE 파티셔닝 |
헥사고날 아키텍처. application 은 포트만 알고, 기술은 어댑터가 담당합니다.
services/chat/
├── api/
│ ├── domain/ ChatLogs, Blacklist
│ ├── readmodel/ ChatReadModels (ModerationResult)
│ └── exception/ ChatErrorCode, ChatException
│
├── application/
│ ├── port/ ChatCommandPort (영속화)
│ │ ChatBroadcastPort (실시간 전파)
│ │ ChatModerationPort (모더레이션 큐 적재)
│ │ ModerationPort (혐오 판정)
│ ├── usecase/ ChatSendUseCase, ChatBlindUseCase
│ └── service/ ChatCommandService
│
├── driving/ (인바운드 어댑터)
│ └── websocket/ ChatStompApi STOMP 메시지 진입점
│ StompEventListener 구독/해제 이벤트 → 입장 알림
│
└── driven/ (아웃바운드 어댑터)
├── rdb/ ChatCommandAdapter chat_logs 영속화
├── socket/ ChatBroadcastAdapter Redis Pub/Sub 발행
│ relay/ ChatMessage, BlindMessage, PresenceMessage
├── moderation/ ChatModerationQueueAdapter Redis Streams XADD
│ ChatModerationStreamSubscriber 컨슈머 그룹 등록
│ ChatModerationStreamConsumer 큐 소비 → 판정 → 블라인드
└── huggingface/ HuggingFaceClientAdapter HF /classify 호출
포트 분리 원칙
-
ModerationPort(도메인 언어) ↔HuggingFaceClientAdapter(구현 기술) → AI 제공자를 교체해도 application 은 바뀌지 않습니다. -
moderation모듈은 Redis 큐/컨슈머만,huggingface모듈은 AI 호출만 담당합니다.
WebSocket 세션은 접속한 인스턴스에만 존재합니다. 여러 인스턴스로 확장하면 A 인스턴스의 메시지가 B 인스턴스의 구독자에게 닿지 않으므로, Redis Pub/Sub 을 fan-out 채널로 사용합니다.
[클라이언트]
│ SEND /app/streams/{id}/chat { "message": "..." }
▼
ChatStompApi ──▶ ChatSendUseCase (ChatCommandService)
│
├─ 1. chat_logs 저장 (Snowflake ID 채번)
│
├─ 2. ChatBroadcastPort ──▶ Redis PUBLISH stream:events:{id}
│ │
│ [모든 인스턴스가 구독]
│ ▼
│ RelaySubscriber (core)
│ │
│ STOMP /topic/streams/{id}
│ ▼
│ [모든 구독자]
│
└─ 3. ChatModerationPort ──▶ Redis Streams XADD (비동기 모더레이션)
핵심: 저장·브로드캐스트는 즉시 수행하고, AI 판정은 큐에 넣고 바로 반환합니다. 채팅은 AI 응답을 기다리지 않습니다.
/topic/streams/{id} 하나의 토픽으로 여러 도메인 이벤트가 전달됩니다. 클라이언트는 type 으로 분기합니다.
| type | 발행 주체 | 내용 |
|---|---|---|
CHAT |
chat | 채팅 메시지 |
JOIN / LEAVE
|
chat | 입·퇴장 알림 |
BLIND |
chat | 모더레이션 차단 (해당 chatId 메시지를 가림) |
DONATION |
donation | 후원 알림 |
VIEWER_COUNT |
stream | 실시간 시청자 수 |
STREAM_ENDED |
stream | 방송 종료 |
relay 채널(
stream:events:{id})과 토픽 규약은 core 공용입니다. chat 은 여러 소비자 중 하나일 뿐이며, stream·donation 도 같은 채널로 발행합니다.
| 방식 | 지연 | 노출 |
|---|---|---|
| 사전 검열 | 채팅 전송이 AI 응답(수백 ms)만큼 지연 | 위반 메시지가 전혀 노출되지 않음 |
| 사후 블라인드 ✅ | 채팅은 즉시 전송 | 위반 메시지가 잠시 노출된 뒤 가려짐 |
라이브 채팅은 즉시성이 핵심이므로 사후 방식을 택했습니다. AI 응답이 느려지거나 외부 API 가 장애를 겪어도 채팅 자체는 정상 동작합니다(fail-open).
[채팅 전송]
│
└─ XADD moderation:chat { chatId, streamId, message }
│
│ (컨슈머 그룹: moderation-workers)
▼
ChatModerationStreamConsumer
│
├─ ModerationPort.classify(message)
│ └─ HuggingFaceClientAdapter ──▶ HF Space POST /classify
│ ([smilegate-ai/kor_unsmile](https://huggingface.co/smilegate-ai/kor_unsmile))
│
└─ isHate == true 이면
├─ chat_logs.is_blocked = true, block_reason = 라벨
└─ BLIND 브로드캐스트 (chatId)
│
▼
[클라이언트가 해당 chatId 메시지를 가림]
| 후보 | 평가 |
|---|---|
| Pub/Sub | 구독자가 없을 때 메시지 유실. 워커 재시작 시 그 사이 채팅이 검사되지 않음 |
| List (LPUSH/BRPOP) | 소비 즉시 삭제되어 재처리 불가. 컨슈머 그룹 없음 |
| Streams ✅ | 메시지 보존, 컨슈머 그룹으로 수평 확장, ACK/PENDING 으로 재처리 가능 |
smilegate-ai/kor_unsmile — 한국어 혐오표현 분류 모델.
Hugging Face Space(Docker, FastAPI)에 배포해 HTTP API 로 노출하고, 백엔드는 X-API-Key 인증으로 호출합니다.
POST /classify
{ "chatId": "...", "message": "..." }
→
{ "isHate": true, "hateLabels": ["악플/욕설"] }
fail-open 정책 — HF 호출이 실패하면 isHate=false 로 처리합니다. 외부 AI 장애가 채팅 기능을 중단시키지 않게 하기 위함입니다.
STOMP 연결 시 JWT 를 검증하고, 신원을 Principal 에 담습니다.
CONNECT Authorization: Bearer <JWT>
│
▼
StompAuthChannelInterceptor (core)
│
├─ 토큰 없음/검증 실패 → 익명 통과 (읽기만 허용)
└─ 검증 성공 → accessor.setUser(new StompPrincipal(userId, nickname))
│
▼
이후 모든 프레임(SEND/SUBSCRIBE)에서 신원 참조
| 대상 | 시청·구독 | 채팅 전송 |
|---|---|---|
| 로그인 사용자 | ✅ | ✅ |
| 비로그인(익명) | ✅ | ❌ |
ChatStompApi 는 Principal 이 StompPrincipal 인지 확인하고, 아니면 전송을 거부합니다. 클라이언트가 보낸 값을 신원으로 신뢰하지 않습니다.
CREATE TABLE chat.chat_logs (
id BIGINT NOT NULL, -- Snowflake
stream_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
sender_nickname VARCHAR(50), -- 채팅 당시 닉네임 (스냅샷)
message TEXT NOT NULL,
is_blocked BOOLEAN NOT NULL DEFAULT FALSE,
block_reason VARCHAR(100),
created_at TIMESTAMP NOT NULL,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);파티셔닝 이유
- 채팅 로그는 쓰기 편중 + 시간축 조회가 지배적입니다.
- 오래된 로그 삭제 시
DELETE(수억 건, VACUUM 부하) 대신 파티션 DROP(즉시)이 가능합니다. - PK 에
created_at이 포함된 것은 파티션 키 제약 때문입니다.
닉네임을 스냅샷으로 저장하는 이유 — 사용자가 닉네임을 바꿔도 과거 채팅은 당시 닉네임으로 남아야 합니다.
CREATE TABLE chat.blacklist (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
reason VARCHAR(100), -- AI_PROFANITY_STRIKE_OUT 등
unblock_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);반복 위반자 제재용 테이블입니다. (누적 차단 횟수 기반 자동 제재는 [향후 과제](#향후-과제) 참고)
AI 호출은 수백 ms ~ 수 초가 걸립니다. 이를 전송 경로에 두면 채팅이 그만큼 느려지고, 외부 API 장애가 곧 채팅 장애가 됩니다. 큐를 사이에 두어 채팅 지연과 AI 지연을 격리했습니다.
stream:events:{id} 는 chat 전용이 아닙니다. stream(시청자 수·방송 종료), donation(후원) 도 같은 채널로 발행하고, core 의 RelaySubscriber 가 STOMP 토픽으로 전달합니다.
덕분에 클라이언트는 토픽 하나만 구독하면 되고, 새 도메인 이벤트를 추가할 때 WebSocket 인프라를 건드릴 필요가 없습니다.
ModerationPort.classify(message) ← "무엇을 하는가" (혐오 판정)
└─ HuggingFaceClientAdapter ← "무엇으로 하는가" (HF 호출)
AI 제공자를 교체하면 어댑터만 갈아끼우면 됩니다. 같은 이유로 moderation 모듈(Redis 큐/컨슈머)과 huggingface 모듈(AI 호출)을 분리했습니다.
증상 — 로그인 후 채팅을 보내도 서버가 반응하지 않음. CONNECT 시점 로그에는 인증 정보가 정상적으로 찍히는데, SEND 시점에는 null.
원인 — ChannelInterceptor 에서 CONNECT 프레임의 getSessionAttributes() 에 담은 값이 이후 프레임(SEND)으로 전파되지 않음.
해결 — 신원을 세션 attribute 가 아닌 Principal 로 전환.
// Before — 이후 프레임에서 읽히지 않음
accessor.getSessionAttributes().put(USER_ID, userId);
// After — 세션 전체에서 유지됨
accessor.setUser(new StompPrincipal(userId, nickname));증상 — 블라인드 처리가 화면에 반영되지 않고, 대신 빈 익명 메시지가 채팅에 추가됨.
원인 — Jackson 은 record 의 컴포넌트만 직렬화합니다. type() 을 메서드로 구현한 ChatMessage·BlindMessage 는 type 이 JSON 에 포함되지 않았고, 클라이언트의 기본값 폴백(body.type ?? 'CHAT')에 걸려 BLIND 가 CHAT 으로 처리되었습니다.
// type 이 record 컴포넌트인 PresenceMessage 는 정상 동작 → JOIN 만 잘 되던 이유
public record PresenceMessage(String type, ...) { }
// type 이 메서드라 직렬화 누락
public record ChatMessage(String streamId, ...) {
@Override public String type() { return "CHAT"; } // ← JSON 에 없음
}해결 — type() 메서드에 @JsonProperty("type") 부여.
증상 — 채팅 저장 시 예외 발생.
원인 — 부모 테이블만 PARTITION BY RANGE 로 생성되고, 자식 파티션이 없었음. PostgreSQL 은 해당 범위의 파티션이 없으면 INSERT 를 거부합니다.
해결 — 월별 파티션 + DEFAULT 파티션 생성. (자동 생성은 [향후 과제](#향후-과제))
CREATE TABLE chat.chat_logs_2026_07 PARTITION OF chat.chat_logs
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');원인 두 가지가 겹침
- Principal 에
userId만 담고nickname을 담지 않음 - 서버의
ChatMessage.nickname과 클라이언트가 읽는senderNickname필드명 불일치
PresenceMessage 는 필드명이 일치해 JOIN 알림만 정상 동작했고, 이 때문에 원인 파악이 늦어졌습니다.
해결 — StompPrincipal(userId, nickname) 으로 닉네임을 함께 전달하고, 필드명을 통일.
| 과제 | 내용 |
|---|---|
| 정규식 · 금칙어 1차 필터 | AI 호출 전에 명백한 위반을 차단. 노출 시간 자체를 제거하고 AI 호출 비용 절감 |
| 파티션 자동 생성 | 매일 배치로 향후 N개월 파티션 선행 생성(멱등). 현재는 수동 |
| 오래된 파티션 아카이빙 | 보관 기간 경과 파티션을 DROP (필요 시 S3 export) |
| 반복 위반자 자동 제재 |
chat_logs.is_blocked 누적 → blacklist 등록 (AI_PROFANITY_STRIKE_OUT) |
| 컨슈머 수평 확장 | 컨슈머 그룹에 인스턴스별 고유 컨슈머명 부여. 현재는 단일 워커 |
-
채팅 엔드포인트 — WebSocket
/ws/chat(native, SockJS 미사용) -
발행 —
/app/streams/{streamId}/chat -
구독 —
/topic/streams/{streamId} -
Redis 채널 —
stream:events:{streamId}(도메인 공용) -
모더레이션 큐 — Redis Streams
moderation:chat, 컨슈머 그룹moderation-workers
- 🏗️ Hexagonal Architecture
- 🧩 Multi Module
- 🔄 CQRS
- 🌐 gRPC
- ⚙️ Batch Architecture
- ⚙️ Batch Core
- 📡 gRPC Core
- 🗄️ JPA Core
- 🌍 Common
- 🚨 Exception