FireDrago
[채팅 감정 분석] 5. MVP 완성 및 트러블 슈팅 (설계 변경) 본문
--------------------------------------------------
접속하고 싶은 채널 id를 입력해주세요
ex) https://chzzk.naver.com/live/{채널 id}
> b044e3a3b9259246bc92e863e7d3f3b8
--------------------------------------------------
[INFO] i.g.h.c.c.c.w.ChzzkWebsocketClient : >>> Websocket 연결 성공! 인증 패킷 전송 시작...
[INFO] i.g.h.c.c.c.w.ChzzkMessageHandler : >>> 치지직 웹소켓 서버 접속 승인 완료
# 1차 배치 분석 결과
[INFO] i.g.h.chatpipeline.buffer.ChatBuffer : [----] 버퍼처리 개수: 12, 현재 버퍼 대기 : 0
[INFO] i.g.h.c.analyzer.ChatEmotionAnalyzer : [---] 배치처리 데이터:
"스폰지밥 시체 질질 끌고 다니는 것 같누/ 제발 마녀모자 제발/ 시작됐다 그의 도박이..."
[INFO] i.g.h.c.analyzer.ChatEmotionAnalyzer : LLM 분석 결과:
{
"sentiment": "JOY",
"score": 8,
"summary": "스폰지밥 시체 끌고 다니는 거 실화냐? 청호모자 쓴 거 보니까 시작됐다는 느낌이 ㄷㄷ"
}
# 2차 배치 분석 결과 (아이템 획득 및 세트 완성 상황)
[INFO] i.g.h.chatpipeline.buffer.ChatBuffer : [----] 버퍼처리 개수: 26, 현재 버퍼 대기 : 0
[INFO] i.g.h.c.analyzer.ChatEmotionAnalyzer : [---] 배치처리 데이터:
"청호 후드2돌/ 마녀 가마솥/ 청호 세트 모은건가/ 근본마녀?/ 오 마녀모자?..."
[INFO] i.g.h.c.analyzer.ChatEmotionAnalyzer : LLM 분석 결과:
{
"sentiment": "JOY",
"score": 8,
"summary": "청호 후드2돌 풀세트라니 ㅋㅋㅋ 마녀모자까지 ㄷㄷ 역시 근본마녀다!"
}
# 3차 배치 분석 결과 (캐릭터 외형 반응)
[INFO] i.g.h.chatpipeline.buffer.ChatBuffer : [----] 버퍼처리 개수: 24, 현재 버퍼 대기 : 0
[INFO] i.g.h.c.analyzer.ChatEmotionAnalyzer : [---] 배치처리 데이터:
"고독한 늑대/ 오오옹/ 무쌩겨써/ 눈에서 불나네/ 오 안어울려/ 간지..."
[INFO] i.g.h.c.analyzer.ChatEmotionAnalyzer : LLM 분석 결과:
{
"sentiment": "JOY",
"score": 8,
"summary": "무쌩겨써 ㅋㅋㅋ 간지나긴 하는데 오 안어울려 하는 거 개웃기네"
}
ollama를 통해 gemma3 12b 로컬 llm 모델로
배치처리된 채팅들의 감정을 분석하고 숫자로 표현할 수 있다.
이 과정에서 설계가 변경되고, 배치처리 로직을 수정했다.
트러블 슈팅 과정과 로컬 llm과 프롬프팅에 대해 배운점을 정리했다.
https://github.com/Firedrago95/chat-analyzer
1. 설계문제


초기 설계에서는 수집 -> 매니저 -> 분석이라는 3단계 구조를 설계했지만,
mvp 완성후 실행 결과 여러 문제점이 발견되어
수집 -> 분석의 2단계 단일 스레드 기반 구조로 단순화했다.
1-1. 매니저 단계와 분석 스레드
// 17:51:19 - 첫 번째 데이터 수집 및 배치 처리 시작
[ctReadThread] [ >> ] 수집: 남자애는 좀 맞아야함
[ main ] [ .. ] 배치 처리: 남자애는 좀 맞아야함
// 17:51:20 - 뒤이어 온 "ㄹㅇ"이 worker1에 의해 먼저 분석 완료 (순서가 꼬이기 시작)
[ctReadThread] [ >> ] 수집: ㄹㅇ
[chat-worker1] [ OK ] 분석 완료: {"sentiment": "ANGER", "summary": "특정 캐릭터 불만..."}
// 17:51:21 - 다음 채팅들이 수집되는데...
[ctReadThread] [ >> ] 수집: 남자애는 잡긴 해야함
[ctReadThread] [ >> ] 수집: 힘으로 제압은해도 떄리면 안좋아
// 17:51:22 - 이전에 수집된 데이터의 분석 결과가 이제서야 튀어나옴 (데이터 일관성 결여)
[chat-worker2] [ OK ] 분석 완료: {"sentiment": "NEUTRAL", "summary": "무의미한 문자로 파악 어려움"}
🚫 문제 1. 멀티스레드 레이스 컨디션
처음의 구조대로 mvp를 완성했을때, 동작 로그이다.
멀티스레드(chat-worker)들이 각각 로컬 LLM API를 호출하면서 응답 속도 차이가 발생했다.
이로 인해 먼저 들어온 채팅 뭉치가 나중에 처리되는 결과 역전 현상이 발생했다.
🚫 문제 2. 내부 호출로 인한 프록시 미적용
로그를 보면 배치를 담당해야 할 chat-manager 스레드 대신 main 스레드가 일을 하고 있다.
동일 클래스 내 메서드 호출 시 Spring의 AOP 프록시가 적용되지 않아
@Async 등이 의도대로 동작하지 않았기 때문이다.
🚫 문제 3. 백프레셔(Backpressure) 부재 및 지연 누적
배치 처리 로직은 분석단의 진행 상황(LLM이 작업 중인지 여부)을 고려하지 않고
끊임없이 데이터를 밀어넣었다.
로컬 LLM은 하드웨어 자원의 한계로 한 번에 하나씩만 처리 가능한데,
요청만 계속 쌓이다 보니 분석 결과가 실시간성을 잃고 점점 뒤처지는 문제가 발생했다.
해결방법

매니저 단계는 분석단의 상황을 모르고 무자비하게
배치데이터를 때려박고 있으므로 제거하고,
분석단이 단일 스레드 기반으로 llm 호출 (I/O bound) 블로킹되고,
블로킹이 멈추면 그동안 ChatBuffer에 쌓인 채팅데이터를
직접 배치처리하도록 수정했다.
해결책을 직접 생각하고 적용했는데
이런 설계패턴이 생산자-소비자 패턴이라는 걸 알게됐다. (뿌듯)
@Slf4j
@Component
@RequiredArgsConstructor
public class ChatEmotionAnalyzer {
private final RestClient restClient;
private final ChatBuffer chatBuffer;
@Value("${ollama.model-name}")
private String modelName;
/**
* 분석 워커 루프: 단일 스레드에서 무한 루프를 돌며 데이터를 소비함
*/
@Async("chatWorkerThreadPoolTaskExecutor")
public void analyze() throws InterruptedException {
while (!Thread.interrupted()) {
// 1. [동적 배치] 버퍼에서 데이터 추출
// 최소 20개 ~ 최대 150개 사이의 채팅을 8초 대기 시간 내에서 유동적으로 가져옴
List<ChatMessage> chatMessages = chatBuffer.drainBatch(20, 150, 8000);
if (chatMessages.isEmpty()) continue;
String collect = chatMessages.stream()
.map(ChatMessage::message)
.collect(Collectors.joining("/ "));
log.info("[---] 배치처리 대상: {}", collect);
// 2. [프롬프트 생성] 수집된 채팅 목록을 LLM용 메시지로 변환
String prompt = createPrompt(chatMessages);
ChatEmotionAnalysisRequest request = ChatEmotionAnalysisRequest.from(modelName, prompt);
// 3. [LLM 호출 - I/O Bound]
// 로컬 LLM(Ollama)에 분석 요청. 응답이 올 때까지 현재 스레드는 블로킹됨(Backpressure 효과)
ChatEmotionAnalysisResponse emotionResult = restClient.post()
.uri("/api/generate")
.body(request)
.retrieve()
.body(ChatEmotionAnalysisResponse.class);
// 4. 분석 결과 출력 (JSON 형태의 리액션)
log.info("[ OK ] 분석 결과: {}", emotionResult.response());
}
}
private String createPrompt(List<ChatMessage> messages) {
String chatList = messages.stream()
.map(m -> "- " + m.message())
.collect(Collectors.joining("\n"));
// 프롬프트 세부 내용은 상수로 분리하여 관리 (블로그 가독성을 위해 간략화)
return String.format("다음 채팅 목록에 대해 시청자 말투로 한마디 리액션해줘:\n%s", chatList);
}
}'프로젝트' 카테고리의 다른 글
| [채팅 감정 분석] 4. 실시간 분석 데이터 파이프라인 설계 (0) | 2025.12.21 |
|---|---|
| [치지직 채팅] 3. 웹소켓 클라이언트와 스레드 (0) | 2025.12.13 |
| [치지직 채팅] 2. 치지직 채팅 웹소켓 접속하기 (0) | 2025.12.09 |
| [치지직 채팅] 1. 치지직 채팅 웹소켓 프로토콜 분석 (0) | 2025.12.05 |
| [Moment] 프로젝트 쿼리 실행속도 50초 단축하기 (0) | 2025.10.19 |
