Skip to content
Choi Jeong Nam edited this page Jul 14, 2026 · 3 revisions

Chat 도메인

실시간 채팅과 AI 기반 욕설 모더레이션을 담당하는 도메인입니다.

  • 실시간 채팅 — STOMP over WebSocket, Redis Pub/Sub fan-out
  • AI 모더레이션 — Redis Streams 기반 비동기 워커 + Hugging Face 혐오표현 분류

목차

  1. 설계 목표
  2. 모듈 구조
  3. 실시간 채팅 흐름
  4. AI 모더레이션 파이프라인
  5. 인증
  6. 데이터 모델
  7. 주요 설계 결정
  8. 트러블슈팅
  9. 향후 과제

설계 목표

목표 접근
채팅 지연 최소화 모더레이션을 채팅 전송 경로에서 분리 (사후 처리)
수평 확장 WebSocket 세션은 인스턴스에 종속 → Redis Pub/Sub 로 인스턴스 간 fan-out
AI 지연 흡수 Redis Streams 큐 + 별도 컨슈머 → 외부 AI 호출이 채팅을 막지 않음
대용량 로그 chat_logscreated_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 모더레이션 파이프라인

사후 모더레이션을 택한 이유

방식 지연 노출
사전 검열 채팅 전송이 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)
                │
                └─ isHate == true 이면
                        ├─ chat_logs.is_blocked = true, block_reason = 라벨
                        └─ BLIND 브로드캐스트 (chatId)
                                    │
                                    ▼
                            [클라이언트가 해당 chatId 메시지를 가림]

Redis Streams 를 택한 이유

후보 평가
Pub/Sub 구독자가 없을 때 메시지 유실. 워커 재시작 시 그 사이 채팅이 검사되지 않음
List (LPUSH/BRPOP) 소비 즉시 삭제되어 재처리 불가. 컨슈머 그룹 없음
Streams 메시지 보존, 컨슈머 그룹으로 수평 확장, ACK/PENDING 으로 재처리 가능

AI 모델

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)에서 신원 참조
대상 시청·구독 채팅 전송
로그인 사용자
비로그인(익명)

ChatStompApiPrincipalStompPrincipal 인지 확인하고, 아니면 전송을 거부합니다. 클라이언트가 보낸 값을 신원으로 신뢰하지 않습니다.


데이터 모델

chat_logs (파티션 테이블)

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 이 포함된 것은 파티션 키 제약 때문입니다.

닉네임을 스냅샷으로 저장하는 이유 — 사용자가 닉네임을 바꿔도 과거 채팅은 당시 닉네임으로 남아야 합니다.

blacklist

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 를 분리

AI 호출은 수백 ms ~ 수 초가 걸립니다. 이를 전송 경로에 두면 채팅이 그만큼 느려지고, 외부 API 장애가 곧 채팅 장애가 됩니다. 큐를 사이에 두어 채팅 지연과 AI 지연을 격리했습니다.

relay 채널을 도메인 무관하게

stream:events:{id} 는 chat 전용이 아닙니다. stream(시청자 수·방송 종료), donation(후원) 도 같은 채널로 발행하고, core 의 RelaySubscriber 가 STOMP 토픽으로 전달합니다.

덕분에 클라이언트는 토픽 하나만 구독하면 되고, 새 도메인 이벤트를 추가할 때 WebSocket 인프라를 건드릴 필요가 없습니다.

포트는 도메인 언어, 어댑터는 기술

ModerationPort.classify(message)          ← "무엇을 하는가" (혐오 판정)
    └─ HuggingFaceClientAdapter           ← "무엇으로 하는가" (HF 호출)

AI 제공자를 교체하면 어댑터만 갈아끼우면 됩니다. 같은 이유로 moderation 모듈(Redis 큐/컨슈머)과 huggingface 모듈(AI 호출)을 분리했습니다.


트러블슈팅

1. STOMP 세션 attribute 가 이후 프레임으로 전파되지 않음

증상 — 로그인 후 채팅을 보내도 서버가 반응하지 않음. CONNECT 시점 로그에는 인증 정보가 정상적으로 찍히는데, SEND 시점에는 null.

원인ChannelInterceptor 에서 CONNECT 프레임의 getSessionAttributes() 에 담은 값이 이후 프레임(SEND)으로 전파되지 않음.

해결 — 신원을 세션 attribute 가 아닌 Principal 로 전환.

// Before — 이후 프레임에서 읽히지 않음
accessor.getSessionAttributes().put(USER_ID, userId);

// After — 세션 전체에서 유지됨
accessor.setUser(new StompPrincipal(userId, nickname));

2. relay 메시지의 type 필드가 JSON 에서 누락

증상 — 블라인드 처리가 화면에 반영되지 않고, 대신 빈 익명 메시지가 채팅에 추가됨.

원인 — Jackson 은 record 의 컴포넌트만 직렬화합니다. type() 을 메서드로 구현한 ChatMessage·BlindMessagetype 이 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") 부여.

3. chat_logs INSERT 실패 — no partition of relation found

증상 — 채팅 저장 시 예외 발생.

원인 — 부모 테이블만 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');

4. 닉네임이 "익명" 으로 표시

원인 두 가지가 겹침

  1. Principal 에 userId 만 담고 nickname 을 담지 않음
  2. 서버의 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

🚀 RAIO Backend

Home


📖 Getting Started

🏛️ Architecture

🧱 Core Modules

💳 Domains

📚 Guides

Clone this wiki locally