From 6b7240f9b6584118d90e43a169834dcb5386c9b4 Mon Sep 17 00:00:00 2001 From: overhyeon Date: Mon, 5 Oct 2026 00:37:45 +0900 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20AI=20=EC=84=9C=EB=B2=84=20SSE=20?= =?UTF-8?q?=EC=A4=91=EA=B3=84=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 답변을 조각으로 받아 넘기는 클라이언트를 만든다. 다른 외부 클라이언트와 달리 응답을 객체로 변환해 받지 않고 본문을 흘러오는 대로 읽는다. RestClient의 exchange는 변환하지 않고 응답을 그대로 넘겨주므로 그 안에서 입력 스트림을 직접 읽는다. 변환을 맡기면 전체가 도착할 때까지 기다려 스트리밍이 사라진다. - ChatClient 인터페이스는 core에 둔다. 서비스가 구현체를 알면 core가 infrastructure를 향하게 되어 Gradle 경계 검사에서 막힌다 - ChatEventListener로 delta·bubble·done·error 네 이벤트를 그대로 받는다 - TerminalGuard로 done/error가 정확히 한 번만 나가게 한다. 한 번도 안 나가면 학생 쪽 연결이 영원히 열려 있고, 두 번 나가면 예외가 난다 - 깨진 data 한 줄이 대화 전체를 끊지 않도록 JSON 파싱 예외는 삼키고 건너뛴다 - ChatTurn은 ChatMessage를 그대로 보내지 않기 위해 둔다. AI 서버는 누가 물었는지 알 필요가 없다 - read-timeout은 조각 하나를 기다리는 시간이다. 전체 응답 시간으로 잡으면 AI 서버가 멈췄을 때도 그만큼 기다린다 - 질문 원문은 로그에 남기지 않는다. 학생의 개인 사정이 섞여 들어올 수 있다 --- .../domain/chat/client/ChatClient.java | 26 ++ .../domain/chat/client/ChatEventListener.java | 30 +++ .../welfare/domain/chat/domain/ChatTurn.java | 37 +++ infrastructure/client/build.gradle.kts | 1 + .../client/chat/StreamAiChatClient.java | 228 ++++++++++++++++++ .../client/chat/StreamAiClientConfig.java | 39 +++ .../client/chat/StreamAiProperties.java | 40 +++ .../application-infrastructure-client.yml | 10 + 8 files changed, 411 insertions(+) create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/client/ChatClient.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/client/ChatEventListener.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/domain/ChatTurn.java create mode 100644 infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiChatClient.java create mode 100644 infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiClientConfig.java create mode 100644 infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiProperties.java diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/client/ChatClient.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/client/ChatClient.java new file mode 100644 index 00000000..939584c4 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/client/ChatClient.java @@ -0,0 +1,26 @@ +package kr.ac.kookmin.stream.welfare.domain.chat.client; + +import java.util.List; +import kr.ac.kookmin.stream.welfare.domain.chat.domain.ChatTurn; + +/** + * 답변을 만들어 주는 AI 서버. 구현체는 {@code infrastructure:client}에 둔다. + *

+ * 인터페이스를 core에 두는 이유는 의존 방향 때문이다. 서비스가 구현체를 직접 알면 + * core가 infrastructure를 향하게 되어 Gradle 경계 검사에서 막힌다. + */ +public interface ChatClient { + + /** + * 대화 이력을 보내고 답변을 조각으로 받는다. 이 메서드는 스트림이 끝날 때까지 돌아오지 않는다. + *

+ * 구현체는 {@code listener}의 {@code onDone} 또는 {@code onError}를 정확히 한 번 부른다. + * 둘 다 안 불리면 학생 쪽 연결이 끝나지 않고 계속 열려 있게 된다. + * + * @param memberId 질문한 학생. AI 서버가 요청자를 식별하는 헤더로 쓴다 + * @param history 이번 질문까지 포함한 대화 전체. 마지막 턴이 이번 질문이다. + * AI 서버는 대화를 저장하지 않으므로 요청마다 통째로 보내야 한다. + * @param listener 받은 이벤트를 넘길 곳 + */ + void stream(long memberId, List history, ChatEventListener listener); +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/client/ChatEventListener.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/client/ChatEventListener.java new file mode 100644 index 00000000..ee59e826 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/client/ChatEventListener.java @@ -0,0 +1,30 @@ +package kr.ac.kookmin.stream.welfare.domain.chat.client; + +/** + * AI 서버가 보내는 SSE 이벤트를 받는 쪽. 이벤트 네 종류를 그대로 메서드로 둔다. + *

+ * 답변 전체를 모아서 한 번에 넘기지 않고 조각마다 알리는 이유는, 중계하는 쪽이 받는 즉시 + * 학생에게 흘려보내야 하기 때문이다. 모아두면 학생이 10초 동안 빈 화면을 본다. + *

+ * 구현하는 쪽은 조각을 흘려보내면서 동시에 모아야 한다. 모으지 않으면 + * {@code chat_messages}에 저장할 답변 전체가 남지 않는다. + */ +public interface ChatEventListener { + + /** 답변 조각. 여러 번 온다. 빈 줄은 들어 있지 않다. */ + void onDelta(String text); + + /** 여기서부터 새 말풍선. 첫 말풍선 앞과 마지막 말풍선 뒤에는 오지 않는다. */ + void onBubble(); + + /** 정상 종료. */ + void onDone(); + + /** + * 실패. 상세 원인은 AI 서버 로그에만 남으므로 여기 오는 글은 학생에게 보여줄 안내문이다. + *

+ * 스트림이 시작된 뒤의 실패는 상태 코드를 바꿀 수 없어 예외로 올릴 수 없다. + * 그래서 성공 경로와 같은 통로로 알린다. + */ + void onError(String message); +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/domain/ChatTurn.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/domain/ChatTurn.java new file mode 100644 index 00000000..6c1b27e9 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/domain/ChatTurn.java @@ -0,0 +1,37 @@ +package kr.ac.kookmin.stream.welfare.domain.chat.domain; + +/** + * AI 서버에 보내는 대화 한 턴. 역할과 글만 담는다. + *

+ * {@link ChatMessage}를 그대로 보내지 않는 이유는 그쪽에 id와 memberId가 들어 있어서다. + * AI 서버는 누가 물었는지 알 필요가 없고, 저장용 식별자를 외부로 내보낼 이유도 없다. + *

+ * {@code role} 값은 AI 서버 계약과 {@code chat_messages.sender_type} 양쪽에서 같은 글자를 쓴다. + * 중간에 변환하면 한쪽만 고쳤을 때 조용히 어긋난다. + */ +public record ChatTurn(String role, String text) { + + /** 학생이 쓴 글. */ + public static final String USER = "user"; + + /** 챗봇이 쓴 글. Gemini 계약이 assistant가 아니라 model이다. */ + public static final String MODEL = "model"; + + public ChatTurn { + if (role == null || role.isBlank()) { + throw new IllegalArgumentException("role이 비어 있습니다."); + } + // AI 서버가 빈 글에 422를 돌려준다. 보내기 전에 막아 원인을 여기서 알 수 있게 한다. + if (text == null || text.isBlank()) { + throw new IllegalArgumentException("text가 비어 있습니다."); + } + } + + public static ChatTurn user(String text) { + return new ChatTurn(USER, text); + } + + public static ChatTurn model(String text) { + return new ChatTurn(MODEL, text); + } +} diff --git a/infrastructure/client/build.gradle.kts b/infrastructure/client/build.gradle.kts index 831a31c7..24d0523f 100644 --- a/infrastructure/client/build.gradle.kts +++ b/infrastructure/client/build.gradle.kts @@ -10,6 +10,7 @@ dependencies { implementation(project(":core:domain:file")) implementation(project(":core:domain:internal")) implementation(project(":core:domain:member")) + implementation(project(":core:domain:welfare")) implementation(platform(libs.springBootDependenciesBom)) implementation(libs.springBootStarter) diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiChatClient.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiChatClient.java new file mode 100644 index 00000000..8ce36ee8 --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiChatClient.java @@ -0,0 +1,228 @@ +package kr.ac.kookmin.stream.client.chat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatClient; +import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatEventListener; +import kr.ac.kookmin.stream.welfare.domain.chat.domain.ChatTurn; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +/** + * AI 서버(stream-ai)에 질문을 보내고 답변을 SSE로 받아 넘긴다. + *

+ * 다른 외부 클라이언트와 다른 점이 하나 있다. 응답을 객체로 변환해 돌려받지 않고 + * 본문을 흘러오는 대로 읽는다. {@code RestClient}의 {@code exchange}는 응답을 변환하지 않고 + * 그대로 넘겨주므로 그 안에서 입력 스트림을 직접 읽는다. 변환을 맡기면 전체가 도착할 때까지 + * 기다리게 되어 스트리밍이 사라진다. + */ +@Component +@RequiredArgsConstructor +public class StreamAiChatClient implements ChatClient { + + private static final Logger log = LoggerFactory.getLogger(StreamAiChatClient.class); + + /** 학생에게 보여줄 실패 안내. 내부 원인은 로그에만 남긴다. */ + private static final String FALLBACK_ERROR_MESSAGE = "답변 생성 중 오류가 발생했습니다."; + + private static final String EVENT_PREFIX = "event:"; + private static final String DATA_PREFIX = "data:"; + + private static final String EVENT_DELTA = "delta"; + private static final String EVENT_BUBBLE = "bubble"; + private static final String EVENT_DONE = "done"; + private static final String EVENT_ERROR = "error"; + + @Qualifier("streamAiRestClient") + private final RestClient streamAiRestClient; + + private final StreamAiProperties properties; + private final ObjectMapper objectMapper; + + @Override + public void stream(long memberId, List history, ChatEventListener listener) { + // 종료 이벤트를 두 번 보내지 않도록 한 곳에서 관리한다. 학생 쪽 연결을 두 번 닫으면 예외가 난다. + TerminalGuard guard = new TerminalGuard(listener); + + try { + streamAiRestClient.post() + .uri(properties.chatPath()) + .header("X-Internal-Token", properties.internalToken()) + .header("X-User-Id", String.valueOf(memberId)) + .accept(MediaType.TEXT_EVENT_STREAM) + .body(toRequestBody(history)) + .exchange((request, response) -> { + if (response.getStatusCode().isError()) { + // 질문 원문은 남기지 않는다. 학생의 개인 사정이 섞여 들어올 수 있다. + log.error("AI 서버가 오류를 돌려줬습니다. status={}", response.getStatusCode()); + guard.error(FALLBACK_ERROR_MESSAGE); + return null; + } + readEvents(response.getBody(), guard); + return null; + }, false); + } catch (Exception e) { + log.error("AI 서버 호출에 실패했습니다.", e); + guard.error(FALLBACK_ERROR_MESSAGE); + } + + // 스트림이 done도 error도 없이 끊긴 경우. 이게 없으면 학생 쪽 연결이 영원히 열려 있다. + guard.errorIfNotFinished(FALLBACK_ERROR_MESSAGE); + } + + /** AI 서버 계약: {@code { "messages": [ { "role": ..., "text": ... } ] }} */ + private Map toRequestBody(List history) { + List> messages = history.stream() + .map(turn -> Map.of("role", turn.role(), "text", turn.text())) + .toList(); + return Map.of("messages", messages); + } + + /** + * SSE 본문을 줄 단위로 읽는다. 형식은 {@code event: 이름}, {@code data: JSON}, 빈 줄 한 번이다. + *

+ * 빈 줄이 이벤트 하나의 끝이다. {@code data}의 JSON은 줄바꿈이 {@code \\n}으로 escape되어 들어오므로 + * 한 줄을 넘지 않는다. 그래서 줄 단위로 읽어도 쪼개지지 않는다. + */ + private void readEvents(InputStream body, TerminalGuard guard) throws IOException { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(body, StandardCharsets.UTF_8))) { + String event = null; + String data = null; + + String line; + while ((line = reader.readLine()) != null) { + if (line.isEmpty()) { + dispatch(event, data, guard); + event = null; + data = null; + continue; + } + if (line.startsWith(EVENT_PREFIX)) { + event = line.substring(EVENT_PREFIX.length()).trim(); + } else if (line.startsWith(DATA_PREFIX)) { + data = line.substring(DATA_PREFIX.length()).trim(); + } + // 그 외 줄(주석 등)은 무시한다. 계약에 없지만 와도 깨지지 않아야 한다. + } + + // 마지막 이벤트 뒤에 빈 줄이 없이 끊긴 경우를 처리한다. + dispatch(event, data, guard); + } + } + + private void dispatch(String event, String data, TerminalGuard guard) { + if (event == null) { + return; + } + switch (event) { + case EVENT_DELTA -> { + String text = readField(data, "text"); + if (text != null && !text.isEmpty()) { + guard.delta(text); + } + } + case EVENT_BUBBLE -> guard.bubble(); + case EVENT_DONE -> guard.done(); + case EVENT_ERROR -> { + String message = readField(data, "message"); + guard.error(message == null || message.isBlank() ? FALLBACK_ERROR_MESSAGE : message); + } + // 계약에 없는 이벤트가 추가되어도 중계가 멈추지 않게 한다. + default -> log.debug("모르는 SSE 이벤트를 건너뜁니다. event={}", event); + } + } + + /** JSON 한 줄에서 필드 하나를 꺼낸다. 깨진 줄 하나가 대화 전체를 끊지 않도록 예외를 삼킨다. */ + private String readField(String data, String field) { + if (data == null || data.isBlank()) { + return null; + } + try { + JsonNode node = objectMapper.readTree(data).get(field); + return node == null || node.isNull() ? null : node.asText(); + } catch (Exception e) { + log.debug("SSE data를 읽지 못해 건너뜁니다. field={}", field); + return null; + } + } + + /** + * 종료 이벤트가 한 번만 나가도록 감싼다. + *

+ * {@code done}과 {@code error}는 둘 중 하나만, 한 번만 불려야 한다. 학생 쪽 연결을 + * 두 번 닫으려 하면 예외가 나고, 한 번도 닫지 않으면 연결이 계속 열려 있다. + */ + private static final class TerminalGuard implements ChatEventListener { + + private final ChatEventListener delegate; + private boolean finished; + + private TerminalGuard(ChatEventListener delegate) { + this.delegate = delegate; + } + + @Override + public void onDelta(String text) { + delta(text); + } + + @Override + public void onBubble() { + bubble(); + } + + @Override + public void onDone() { + done(); + } + + @Override + public void onError(String message) { + error(message); + } + + void delta(String text) { + if (!finished) { + delegate.onDelta(text); + } + } + + void bubble() { + if (!finished) { + delegate.onBubble(); + } + } + + void done() { + if (finished) { + return; + } + finished = true; + delegate.onDone(); + } + + void error(String message) { + if (finished) { + return; + } + finished = true; + delegate.onError(message); + } + + void errorIfNotFinished(String message) { + error(message); + } + } +} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiClientConfig.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiClientConfig.java new file mode 100644 index 00000000..7d92f539 --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiClientConfig.java @@ -0,0 +1,39 @@ +package kr.ac.kookmin.stream.client.chat; + +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +/** + * {@link StreamAiChatClient}가 쓰는 RestClient 빈 설정. + *

+ * 응답 본문을 통째로 읽지 않고 흘러오는 대로 읽어야 하므로, 버퍼링을 하는 요청 팩터리를 쓰지 않는다. + * {@link SimpleClientHttpRequestFactory}는 응답을 모아두지 않아 SSE에 쓸 수 있다. + */ +@Configuration +public class StreamAiClientConfig { + + private static final Logger log = LoggerFactory.getLogger(StreamAiClientConfig.class); + + @Bean + public RestClient streamAiRestClient(StreamAiProperties properties) { + // 로컬에서 AI 서버 없이도 기동은 되게 두고, 빠진 설정만 알려준다. 챗봇을 쓰면 실패한다 + List missingSettings = properties.missingSettings(); + if (!missingSettings.isEmpty()) { + log.warn("AI 서버 설정이 비어 있어 챗봇을 쓸 수 없습니다: {}", missingSettings); + } + + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(properties.connectTimeout()); + requestFactory.setReadTimeout(properties.readTimeout()); + + return RestClient.builder() + .baseUrl(properties.baseUrl() == null ? "" : properties.baseUrl()) + .requestFactory(requestFactory) + .build(); + } +} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiProperties.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiProperties.java new file mode 100644 index 00000000..1e8ff60b --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiProperties.java @@ -0,0 +1,40 @@ +package kr.ac.kookmin.stream.client.chat; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * AI 서버(stream-ai) 연동 설정. + *

+ * {@code internalToken}은 AI 서버가 "백엔드를 거친 요청"만 처리하기 위해 확인하는 값이다. + * 코드에 두지 않고 환경 변수로만 받는다. + *

+ * {@code readTimeout}은 조각 하나를 기다리는 시간이고 전체 응답 시간이 아니다. + * SSE는 연결을 열어 둔 채 조각을 계속 보내므로, 조각 사이 간격보다 넉넉하면 된다. + * 전체 길이에 맞춰 길게 잡으면 AI 서버가 멈췄을 때도 그만큼 기다린다. + */ +@ConfigurationProperties(prefix = "client.stream-ai") +public record StreamAiProperties( + String baseUrl, + String chatPath, + String internalToken, + Duration connectTimeout, + Duration readTimeout +) { + + /** 비어 있는 필수 설정의 키 이름. 로그에 남기므로 값은 담지 않는다. */ + public List missingSettings() { + Map required = new LinkedHashMap<>(); + required.put("base-url", baseUrl); + required.put("chat-path", chatPath); + required.put("internal-token", internalToken); + + return required.entrySet().stream() + .filter(entry -> entry.getValue() == null || entry.getValue().isBlank()) + .map(Map.Entry::getKey) + .toList(); + } +} diff --git a/infrastructure/client/src/main/resources/application-infrastructure-client.yml b/infrastructure/client/src/main/resources/application-infrastructure-client.yml index 7526df14..05e79fb6 100644 --- a/infrastructure/client/src/main/resources/application-infrastructure-client.yml +++ b/infrastructure/client/src/main/resources/application-infrastructure-client.yml @@ -29,3 +29,13 @@ push: type: ${PUSH_TYPE:log} fcm: credentials-base64: ${FIREBASE_CREDENTIALS_BASE64:} + +# AI 서버(stream-ai). 내부 토큰은 저장소에 남기지 않는다. +# read-timeout은 조각 하나를 기다리는 시간이다. 전체 답변 시간이 아니라 조각 사이 간격 기준으로 잡는다. +client: + stream-ai: + base-url: ${STREAM_AI_BASE_URL:http://localhost:8000} + chat-path: ${STREAM_AI_CHAT_PATH:/chat} + internal-token: ${STREAM_AI_INTERNAL_TOKEN:} + connect-timeout: 3s + read-timeout: 30s From 94712713831e04ff0808b684b9ee338fecaad65f Mon Sep 17 00:00:00 2001 From: overhyeon Date: Mon, 5 Oct 2026 00:38:02 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20=EC=B1=97=EB=B4=87=20=EB=8C=80?= =?UTF-8?q?=ED=99=94=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chat_messages 테이블은 V3에 있으나 저장소 계층이 비어 있었다. - findRecentByMemberId는 최신순으로 돌려준다. '최근 N개'를 자르려면 그 방향으로 정렬해야 한다. AI 서버에 보낼 때는 쓰는 쪽에서 뒤집는다 - message_id로 정렬해 idx_chat_messages_member_id를 그대로 쓴다. InnoDB가 세컨더리 인덱스 끝에 PK를 붙이므로 (member_id, message_id) 정렬이 인덱스로 커버된다. created_at으로 정렬하면 인덱스에 없어 filesort가 생긴다 - created_at은 insertable = false로 DB 기본값만 채워지고 같은 초에 여러 건이 들어올 수 있어 정렬 기준으로도 PK가 안전하다 --- .../repository/ChatMessageRepository.java | 20 +++++++++++++ .../db/welfare/ChatMessageJpaRepository.java | 18 ++++++++++++ .../db/welfare/ChatMessageRepositoryImpl.java | 29 +++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/repository/ChatMessageRepository.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ChatMessageJpaRepository.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ChatMessageRepositoryImpl.java diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/repository/ChatMessageRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/repository/ChatMessageRepository.java new file mode 100644 index 00000000..fa23d38f --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/repository/ChatMessageRepository.java @@ -0,0 +1,20 @@ +package kr.ac.kookmin.stream.welfare.domain.chat.repository; + +import java.util.List; +import kr.ac.kookmin.stream.welfare.domain.chat.domain.ChatMessage; + +public interface ChatMessageRepository { + + /** + * 한 학생의 최근 대화를 최신순으로 가져온다. + *

+ * AI 서버는 대화를 저장하지 않으므로 요청마다 이력을 통째로 보내야 한다. 전부 보내면 + * 학기가 갈수록 토큰 비용이 계속 늘어나므로 최근 것만 자른다. + *

+ * 돌려주는 순서가 최신순인 이유는 "최근 N개"를 자르려면 그 방향으로 정렬해야 하기 때문이다. + * AI 서버에 보낼 때는 오래된 것부터 와야 하므로 쓰는 쪽에서 뒤집는다. + */ + List findRecentByMemberId(long memberId, int limit); + + ChatMessage save(ChatMessage message); +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ChatMessageJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ChatMessageJpaRepository.java new file mode 100644 index 00000000..45347d5b --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ChatMessageJpaRepository.java @@ -0,0 +1,18 @@ +package kr.ac.kookmin.stream.db.welfare; + +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChatMessageJpaRepository extends JpaRepository { + + /** + * {@code message_id}로 정렬하는 이유는 {@code idx_chat_messages_member_id}를 그대로 쓰기 위해서다. + * InnoDB가 세컨더리 인덱스 끝에 PK를 붙이므로 (member_id, message_id) 정렬이 인덱스로 커버된다. + * {@code created_at}으로 정렬하면 인덱스에 없어 filesort가 생긴다. + *

+ * {@code created_at}은 DB 기본값으로만 채워지고(insertable = false) 같은 초에 여러 건이 들어올 수 있어 + * 정렬 기준으로도 PK가 더 안전하다. + */ + List findByMemberIdOrderByIdDesc(Long memberId, Pageable pageable); +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ChatMessageRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ChatMessageRepositoryImpl.java new file mode 100644 index 00000000..70f8f00f --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ChatMessageRepositoryImpl.java @@ -0,0 +1,29 @@ +package kr.ac.kookmin.stream.db.welfare; + +import java.util.List; +import kr.ac.kookmin.stream.welfare.domain.chat.domain.ChatMessage; +import kr.ac.kookmin.stream.welfare.domain.chat.repository.ChatMessageRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ChatMessageRepositoryImpl implements ChatMessageRepository { + + private final ChatMessageJpaRepository chatMessageJpaRepository; + + @Override + public List findRecentByMemberId(long memberId, int limit) { + return chatMessageJpaRepository + .findByMemberIdOrderByIdDesc(memberId, PageRequest.of(0, limit)) + .stream() + .map(ChatMessageJpaEntity::toDomain) + .toList(); + } + + @Override + public ChatMessage save(ChatMessage message) { + return chatMessageJpaRepository.save(ChatMessageJpaEntity.from(message)).toDomain(); + } +} From 34f8d8ac36f5259f7539b17140674e1ad45de277 Mon Sep 17 00:00:00 2001 From: overhyeon Date: Mon, 5 Oct 2026 00:38:02 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20=EC=B1=97=EB=B4=87=20=EC=A7=88?= =?UTF-8?q?=EB=AC=B8=C2=B7=EB=8B=B5=EB=B3=80=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 중계와 저장을 함께 한다. 조각을 그냥 통과시키면 chat_messages에 남길 답변 전체가 없고, 모으기만 하면 스트리밍이 사라진다. AnswerCollector가 둘을 같이 한다. @Transactional을 붙이지 않았다. 답변 생성이 10초쯤 걸리는데 트랜잭션으로 감싸면 그 시간 동안 DB 커넥션을 쥐고 있게 되고, 동시에 질문하는 학생이 몇 명만 되어도 커넥션 풀이 말라 챗봇과 무관한 화면까지 멈춘다. 저장은 각자 짧게 하고 긴 스트리밍은 트랜잭션 밖에서 한다. 그 대가로 질문만 남고 답변이 안 남는 경우가 생긴다. 생성이 실패한 경우이므로 기록으로는 그게 맞다 - 무엇을 물었는지는 남아야 나중에 오답을 찾을 수 있다. - 말풍선 경계는 빈 줄로 이어 붙여 저장한다. 프론트가 보여주는 모양과 같아진다 - 이력은 최근 10건. 전부 보내면 학기가 갈수록 토큰 비용이 늘어난다. 설정값으로 빼지 않은 이유는 이 저장소가 core 도메인 모듈에 설정 바인딩을 두지 않기 때문이다(@ConfigurationProperties는 gateway와 infrastructure에만 있다) - core 도메인 모듈에는 slf4j가 없어 로그를 남기지 않는다. 실패 로그는 클라이언트가 남긴다 --- .../domain/chat/service/ChatService.java | 16 +++ .../chat/service/impl/ChatServiceImpl.java | 119 ++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/ChatService.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/ChatService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/ChatService.java new file mode 100644 index 00000000..f0bf30c3 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/ChatService.java @@ -0,0 +1,16 @@ +package kr.ac.kookmin.stream.welfare.domain.chat.service; + +import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatEventListener; + +public interface ChatService { + + /** + * 질문을 AI 서버에 보내고 답변을 조각으로 흘려보낸다. 스트림이 끝날 때까지 돌아오지 않는다. + *

+ * 질문과 답변을 {@code chat_messages}에 남긴다. 9/21 회의에서 문의기록을 화면에서 빼고 + * DB에만 남기기로 한 것이 이 저장이다. + * + * @param listener 받은 이벤트를 넘길 곳. 보통 컨트롤러가 학생 쪽 연결에 그대로 쓴다 + */ + void answer(long memberId, String question, ChatEventListener listener); +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java new file mode 100644 index 00000000..981387e5 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java @@ -0,0 +1,119 @@ +package kr.ac.kookmin.stream.welfare.domain.chat.service.impl; + +import java.util.ArrayList; +import java.util.List; +import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatClient; +import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatEventListener; +import kr.ac.kookmin.stream.welfare.domain.chat.domain.ChatMessage; +import kr.ac.kookmin.stream.welfare.domain.chat.domain.ChatTurn; +import kr.ac.kookmin.stream.welfare.domain.chat.repository.ChatMessageRepository; +import kr.ac.kookmin.stream.welfare.domain.chat.service.ChatService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +class ChatServiceImpl implements ChatService { + + /** + * AI 서버에 함께 보낼 지난 대화 건수. 질문·답변 한 쌍이 2건이므로 5번의 주고받음에 해당한다. + *

+ * 전부 보내면 학기가 갈수록 토큰 비용이 계속 늘어난다. 이 정도면 "그럼 언제까지야?" 같은 + * 후속 질문은 이어진다. + *

+ * 설정값으로 빼지 않은 이유는 이 저장소가 core 도메인 모듈에 설정 바인딩을 두지 않기 때문이다 + * ({@code @ConfigurationProperties}는 gateway와 infrastructure에만 있다). + * 운영 중에 조절할 필요가 생기면 {@code StreamAiProperties}로 옮기고 클라이언트가 자르게 한다. + */ + private static final int HISTORY_LIMIT = 10; + + private final ChatMessageRepository chatMessageRepository; + private final ChatClient chatClient; + + /** + * 이 메서드에 {@code @Transactional}을 붙이지 않는다. + *

+ * 답변 생성이 10초쯤 걸리는데 트랜잭션으로 감싸면 그 시간 동안 DB 커넥션을 쥐고 있게 된다. + * 동시에 질문하는 학생이 몇 명만 되어도 커넥션 풀이 말라 챗봇과 무관한 화면까지 멈춘다. + * 그래서 저장은 각자 짧게 하고, 긴 스트리밍은 트랜잭션 밖에서 한다. + *

+ * 그 대가로 질문만 남고 답변이 안 남는 경우가 생길 수 있다. 답변 생성이 실패한 경우이므로 + * 기록으로는 그게 맞다 — 무엇을 물었는지는 남아야 나중에 오답을 찾을 수 있다. + */ + @Override + public void answer(long memberId, String question, ChatEventListener listener) { + List turns = loadRecentTurns(memberId); + turns.add(ChatTurn.user(question)); + + chatMessageRepository.save(ChatMessage.of(null, memberId, ChatTurn.USER, question)); + + AnswerCollector collector = new AnswerCollector(listener); + chatClient.stream(memberId, turns, collector); + + String answer = collector.answer(); + // 실패했거나 답변이 비었다. 빈 답변을 남기면 다음 질문의 이력에 빈 턴이 섞인다. + // 실패 원인은 클라이언트가 이미 로그에 남겼다. core 도메인 모듈에는 로거가 없다. + if (answer.isBlank()) { + return; + } + chatMessageRepository.save(ChatMessage.of(null, memberId, ChatTurn.MODEL, answer)); + } + + /** 최근 대화를 오래된 것부터 순서대로 돌려준다. */ + private List loadRecentTurns(long memberId) { + List recent = chatMessageRepository.findRecentByMemberId(memberId, HISTORY_LIMIT); + + // 저장소는 최신순으로 준다. AI 서버는 대화 순서대로 받아야 하므로 뒤집는다. + List turns = new ArrayList<>(recent.size() + 1); + for (int i = recent.size() - 1; i >= 0; i--) { + ChatMessage message = recent.get(i); + if (message.getContent() == null || message.getContent().isBlank()) { + continue; + } + turns.add(new ChatTurn(message.getSenderType(), message.getContent())); + } + return turns; + } + + /** + * 조각을 그대로 흘려보내면서 동시에 답변 전체를 모은다. + *

+ * 둘을 같이 하는 이유는, 통과만 시키면 저장할 답변이 남지 않고 모으기만 하면 스트리밍이 사라지기 때문이다. + */ + private static final class AnswerCollector implements ChatEventListener { + + private final ChatEventListener delegate; + private final StringBuilder collected = new StringBuilder(); + + private AnswerCollector(ChatEventListener delegate) { + this.delegate = delegate; + } + + @Override + public void onDelta(String text) { + collected.append(text); + delegate.onDelta(text); + } + + @Override + public void onBubble() { + // 말풍선 경계는 빈 줄로 이어 붙인다. 프론트가 화면에 보여주는 모양과 같아진다. + collected.append("\n\n"); + delegate.onBubble(); + } + + @Override + public void onDone() { + delegate.onDone(); + } + + @Override + public void onError(String message) { + delegate.onError(message); + } + + String answer() { + return collected.toString().strip(); + } + } +} From ed89b2622ae36da0bc00167bcaa7c38fddbb49d0 Mon Sep 17 00:00:00 2001 From: overhyeon Date: Mon, 5 Oct 2026 00:38:16 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20=EC=B1=97=EB=B4=87=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=A0=84=EC=86=A1=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /v1/app/chat/messages. 이 API만 ApiResponse 껍데기를 쓰지 않는다. 껍데기는 완성된 응답 하나에 한 번 붙는 구조인데 답변은 조각으로 나눠 오므로 담을 자리가 없다. 조각마다 감싸면 껍데기가 수십 개가 되고 조각마다 '요청에 성공했습니다'가 실려 나간다. 그래서 에러가 두 갈래로 갈린다. - 스트림 시작 전(검증·인증 실패): 기존대로 ApiResponse 에러와 상태 코드 - 스트림 시작 후(AI 서버 실패·타임아웃): event: error 첫 바이트가 나간 뒤에는 상태 코드를 바꿀 수 없어 GlobalExceptionHandler가 잡지 못한다. SseEmitter 대신 StreamingResponseBody를 썼다. SseEmitter는 먼저 돌려준 뒤 다른 스레드에서 이벤트를 넣는 방식이라 스레드 풀을 따로 관리해야 하고 완료 처리를 빠뜨리면 연결이 남는다. StreamingResponseBody는 스프링이 콜백을 비동기 스레드에서 돌려주고 콜백이 끝나면 연결도 닫힌다. SSE 형식은 직접 쓴다. AI 서버가 보낸 형식을 그대로 중계하는 일이라 다시 조립할 이유가 없다. - Cache-Control: no-cache, X-Accel-Buffering: no 를 붙여 중간 프록시가 응답을 모아두지 못하게 막는다. 모아두면 조각이 한 번에 도착해 스트리밍이 사라진다. AI 서버도 같은 헤더를 보낸다 - 조각마다 flush 한다. 모아두면 학생이 10초 동안 빈 화면을 본다 - 학생이 화면을 닫으면 쓰기가 실패하는데, 오류가 아니라 정상 종료로 처리한다 - 요청 본문은 이번 질문만 받는다. 지난 대화는 서버가 DB에서 꺼내 붙인다 - 질문 길이를 500자로 제한한다. 토큰 비용과 프롬프트 인젝션을 심을 여지를 줄인다 --- .../api/app/welfare/chat/AppChatApi.java | 45 +++++++ .../app/welfare/chat/AppChatController.java | 120 ++++++++++++++++++ .../request/ChatMessageCreateRequest.java | 18 +++ 3 files changed, 183 insertions(+) create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatApi.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatController.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/request/ChatMessageCreateRequest.java diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatApi.java new file mode 100644 index 00000000..c1409aa4 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatApi.java @@ -0,0 +1,45 @@ +package kr.ac.kookmin.stream.api.app.welfare.chat; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.ac.kookmin.stream.api.app.AppApiUser; +import kr.ac.kookmin.stream.api.app.welfare.chat.request.ChatMessageCreateRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +/** + * 학생 앱 챗봇 API의 문서 명세. 구현은 {@link AppChatController}가 맡는다. + *

+ * 이 API만 {@code ApiResponse} 껍데기를 쓰지 않는다. 껍데기는 완성된 응답 하나에 한 번 붙는 + * 구조인데, 답변은 조각으로 나눠 오므로 담을 자리가 없다. 조각마다 감싸면 껍데기가 수십 개가 되고 + * 조각마다 "요청에 성공했습니다"가 실려 나간다. + *

+ * 그래서 에러가 두 갈래로 갈린다. + *

+ * 첫 바이트가 나간 뒤에는 상태 코드를 바꿀 수 없어 {@code GlobalExceptionHandler}가 잡지 못한다. + */ +@Tag(name = "챗봇", description = "학생 앱 챗봇 질문·답변") +public interface AppChatApi { + + /** + * 질문을 보내고 답변을 SSE로 받는다. + *

+ * 응답은 {@code text/event-stream}이고 이벤트는 네 종류다. + *

+     * event: delta   data: { "text": "부분 응답" }     0회 이상 반복
+     * event: bubble  data: {}                          여기서부터 새 말풍선
+     * event: done    data: {}                          정상 종료
+     * event: error   data: { "message": "오류 안내" }   실패
+     * 
+ * 프론트는 텍스트에서 빈 줄을 직접 찾지 않는다. 스트림 조각이 줄바꿈 중간에서 끊기면 판단이 + * 흔들리므로 서버가 문단 경계마다 {@code bubble}을 보낸다. + */ + @Operation(summary = "챗봇 질문 전송 (SSE)", + description = "질문을 보내고 답변을 조각으로 받는다. 응답은 JSON 한 번이 아니라 " + + "text/event-stream이며 delta·bubble·done·error 네 이벤트가 온다. " + + "지난 대화는 서버가 DB에서 꺼내 함께 보내므로 요청에는 이번 질문만 담는다.") + ResponseEntity sendMessage(AppApiUser apiUser, ChatMessageCreateRequest request); +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatController.java new file mode 100644 index 00000000..2cc0dcf2 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatController.java @@ -0,0 +1,120 @@ +package kr.ac.kookmin.stream.api.app.welfare.chat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.Valid; +import java.io.IOException; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import kr.ac.kookmin.stream.api.app.AppApiUser; +import kr.ac.kookmin.stream.api.app.welfare.chat.request.ChatMessageCreateRequest; +import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatEventListener; +import kr.ac.kookmin.stream.welfare.domain.chat.service.ChatService; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +@RestController +@RequestMapping("/v1/app/chat") +@RequiredArgsConstructor +public class AppChatController implements AppChatApi { + + private static final Logger log = LoggerFactory.getLogger(AppChatController.class); + + private final ChatService chatService; + private final ObjectMapper objectMapper; + + /** + * {@code SseEmitter} 대신 {@link StreamingResponseBody}를 쓴다. + *

+ * {@code SseEmitter}는 먼저 돌려준 뒤 다른 스레드에서 이벤트를 넣는 방식이라 스레드 풀을 따로 + * 관리해야 하고, 완료 처리를 빠뜨리면 연결이 남는다. {@code StreamingResponseBody}는 스프링이 + * 콜백을 비동기 스레드에서 돌려주므로 그 안에서 순서대로 쓰면 되고, 콜백이 끝나면 연결도 닫힌다. + *

+ * 대신 SSE 형식을 직접 쓴다. 어차피 AI 서버가 보낸 형식을 그대로 중계하는 일이라 + * 프레임워크가 다시 조립할 이유가 없다. + */ + @Override + @PostMapping(value = "/messages", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public ResponseEntity sendMessage( + AppApiUser apiUser, + @Valid @RequestBody ChatMessageCreateRequest request + ) { + long memberId = apiUser.userId(); + + StreamingResponseBody body = output -> { + SseWriter writer = new SseWriter(output, objectMapper); + try { + chatService.answer(memberId, request.message(), writer); + } catch (UncheckedIOException e) { + // 학생이 화면을 닫으면 쓰기가 실패한다. 오류가 아니라 정상 종료다. + log.debug("학생이 연결을 끊어 중계를 멈춥니다. memberId={}", memberId); + } + }; + + return ResponseEntity.ok() + // 아래 두 헤더는 중간에 끼는 프록시가 응답을 모아두지 못하게 막는다. + // 모아두면 조각이 한 번에 도착해 스트리밍이 사라진다. AI 서버도 같은 헤더를 보낸다. + .header(HttpHeaders.CACHE_CONTROL, "no-cache") + .header("X-Accel-Buffering", "no") + .contentType(MediaType.TEXT_EVENT_STREAM) + .body(body); + } + + /** + * 받은 이벤트를 학생 쪽 연결에 SSE 형식으로 쓴다. + *

+ * 형식은 {@code event: 이름}, {@code data: JSON}, 빈 줄 한 번이다. 조각마다 즉시 flush 한다. + * 모아두면 학생이 10초 동안 빈 화면을 본다. + */ + private static final class SseWriter implements ChatEventListener { + + private final OutputStream output; + private final ObjectMapper objectMapper; + + private SseWriter(OutputStream output, ObjectMapper objectMapper) { + this.output = output; + this.objectMapper = objectMapper; + } + + @Override + public void onDelta(String text) { + write("delta", Map.of("text", text)); + } + + @Override + public void onBubble() { + write("bubble", Map.of()); + } + + @Override + public void onDone() { + write("done", Map.of()); + } + + @Override + public void onError(String message) { + write("error", Map.of("message", message)); + } + + private void write(String event, Map data) { + try { + String frame = "event: " + event + "\ndata: " + objectMapper.writeValueAsString(data) + "\n\n"; + output.write(frame.getBytes(StandardCharsets.UTF_8)); + output.flush(); + } catch (IOException e) { + // 서비스까지 올려 보내 중계를 멈춘다. 받을 사람이 없는데 계속 만들 이유가 없다. + throw new UncheckedIOException(e); + } + } + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/request/ChatMessageCreateRequest.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/request/ChatMessageCreateRequest.java new file mode 100644 index 00000000..8ff0462f --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/request/ChatMessageCreateRequest.java @@ -0,0 +1,18 @@ +package kr.ac.kookmin.stream.api.app.welfare.chat.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * 챗봇에 보낼 질문. 지난 대화는 서버가 DB에서 꺼내 붙이므로 이번 질문만 받는다. + *

+ * 길이를 제한하는 이유는 두 가지다. 긴 글을 그대로 넘기면 토큰 비용이 그만큼 늘고, + * 프롬프트 인젝션을 길게 심을 여지를 줄인다. + */ +public record ChatMessageCreateRequest( + @Schema(description = "학생이 입력한 질문", example = "빌릴게 어떻게 써요?") + @NotBlank(message = "질문을 입력해주세요.") + @Size(max = 500, message = "질문은 500자까지 입력할 수 있습니다.") + String message +) {} From e3a90201f7ed15fc552a889a91653d5edc15fb98 Mon Sep 17 00:00:00 2001 From: overhyeon Date: Mon, 5 Oct 2026 00:57:00 +0900 Subject: [PATCH 05/14] =?UTF-8?q?fix:=20=EC=9D=91=EB=8B=B5=20=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A6=BC=EC=97=90=20=EC=A7=81=EC=A0=91=20=EC=8D=A8?= =?UTF-8?q?=EC=84=9C=20=EC=A1=B0=EA=B0=81=EC=9D=B4=20=EC=8B=A4=EC=A0=9C?= =?UTF-8?q?=EB=A1=9C=20=ED=9D=98=EB=9F=AC=EA=B0=80=EA=B2=8C=20=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StreamingResponseBody로 만들었는데 조각이 흘러가지 않았다. 실측으로 확인했다. 300ms 간격으로 다섯 번 쓰고 매번 flush() 해도 클라이언트는 한 번에 받았고, 조각을 4KB로 키우자 8KB 경계에서만 끊겨 나갔다(7969 / 8184 / 4064 바이트). flush()가 무시되고 톰캣 버퍼가 찰 때만 나간다는 뜻이다. 같은 내용을 response.getOutputStream()에 직접 쓰면 300ms 간격 그대로 도착한다. Spring Security 필터와 로깅 필터는 응답을 감싸지 않아 원인이 아니었고, ResponseEntity 제거로도 바뀌지 않았다. 실측 (같은 질문, 같은 측정 방법) - AI 서버 직접: 수신 12회, 0.85초에 걸쳐 퍼짐 - 백엔드 경유(수정 전): 수신 1회, 0.00초 - 백엔드 경유(수정 후): 수신 11회, 0.24초 대가로 비동기가 아니라 답변이 끝날 때까지 요청 스레드를 쥔다. 톰캣 기본 최대 스레드가 200이라 동시 대화 200건까지는 버틴다. 그보다 늘어나면 SseEmitter를 다시 시험할 자리다. 함께: Jackson 주입을 고쳤다. 스프링 부트 4가 만들어 주는 매퍼는 Jackson 3(tools.jackson)인데 com.fasterxml(Jackson 2) 타입으로 주입받아 기동이 실패했다. 컨트롤러는 Jackson 3 빈을 받고, 클라이언트는 이 모듈이 선언한 Jackson 2 인스턴스를 직접 만들어 쓴다. --- .../api/app/welfare/chat/AppChatApi.java | 7 ++- .../app/welfare/chat/AppChatController.java | 59 ++++++++++--------- .../client/chat/StreamAiChatClient.java | 8 ++- 3 files changed, 41 insertions(+), 33 deletions(-) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatApi.java index c1409aa4..0e181445 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatApi.java @@ -4,8 +4,8 @@ import io.swagger.v3.oas.annotations.tags.Tag; import kr.ac.kookmin.stream.api.app.AppApiUser; import kr.ac.kookmin.stream.api.app.welfare.chat.request.ChatMessageCreateRequest; -import org.springframework.http.ResponseEntity; -import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; /** * 학생 앱 챗봇 API의 문서 명세. 구현은 {@link AppChatController}가 맡는다. @@ -41,5 +41,6 @@ public interface AppChatApi { description = "질문을 보내고 답변을 조각으로 받는다. 응답은 JSON 한 번이 아니라 " + "text/event-stream이며 delta·bubble·done·error 네 이벤트가 온다. " + "지난 대화는 서버가 DB에서 꺼내 함께 보내므로 요청에는 이번 질문만 담는다.") - ResponseEntity sendMessage(AppApiUser apiUser, ChatMessageCreateRequest request); + void sendMessage(AppApiUser apiUser, ChatMessageCreateRequest request, HttpServletResponse response) + throws IOException; } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatController.java index 2cc0dcf2..5960ae36 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatController.java @@ -1,6 +1,6 @@ package kr.ac.kookmin.stream.api.app.welfare.chat; -import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import java.io.IOException; import java.io.OutputStream; @@ -16,12 +16,11 @@ import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; +import tools.jackson.databind.ObjectMapper; @RestController @RequestMapping("/v1/app/chat") @@ -31,43 +30,45 @@ public class AppChatController implements AppChatApi { private static final Logger log = LoggerFactory.getLogger(AppChatController.class); private final ChatService chatService; + + /** 스프링 부트 4가 만들어 주는 매퍼(Jackson 3). 앱 전체가 쓰는 것과 같은 설정을 따른다. */ private final ObjectMapper objectMapper; /** - * {@code SseEmitter} 대신 {@link StreamingResponseBody}를 쓴다. + * 응답 스트림에 직접 쓴다. {@code StreamingResponseBody}도 {@code SseEmitter}도 쓰지 않는다. *

- * {@code SseEmitter}는 먼저 돌려준 뒤 다른 스레드에서 이벤트를 넣는 방식이라 스레드 풀을 따로 - * 관리해야 하고, 완료 처리를 빠뜨리면 연결이 남는다. {@code StreamingResponseBody}는 스프링이 - * 콜백을 비동기 스레드에서 돌려주므로 그 안에서 순서대로 쓰면 되고, 콜백이 끝나면 연결도 닫힌다. + * {@code StreamingResponseBody}로 먼저 만들었는데 조각이 흘러가지 않았다. 300ms 간격으로 + * 다섯 번 쓰고 매번 {@code flush()}해도 클라이언트는 한 번에 받았고, 조각을 4KB로 키우자 + * 8KB 경계에서만 끊겨 나갔다. 즉 {@code flush()}가 무시되고 톰캣 버퍼가 찰 때만 + * 나간다는 뜻이다. 같은 내용을 이 방식으로 쓰면 300ms 간격 그대로 도착한다(아래 측정값). *

- * 대신 SSE 형식을 직접 쓴다. 어차피 AI 서버가 보낸 형식을 그대로 중계하는 일이라 - * 프레임워크가 다시 조립할 이유가 없다. + * 대가가 있다. 비동기가 아니므로 답변이 끝날 때까지 요청 스레드를 쥐고 있는다. 톰캣 기본 + * 최대 스레드가 200이라 동시 대화 200건까지는 버티지만, 그보다 늘어나면 다른 요청이 밀린다. + * 지금 규모에서는 문제가 아니고, 늘어나면 {@code SseEmitter}를 다시 시험해 볼 자리다. */ @Override @PostMapping(value = "/messages", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - public ResponseEntity sendMessage( + public void sendMessage( AppApiUser apiUser, - @Valid @RequestBody ChatMessageCreateRequest request - ) { + @Valid @RequestBody ChatMessageCreateRequest request, + HttpServletResponse response + ) throws IOException { long memberId = apiUser.userId(); - StreamingResponseBody body = output -> { - SseWriter writer = new SseWriter(output, objectMapper); - try { - chatService.answer(memberId, request.message(), writer); - } catch (UncheckedIOException e) { - // 학생이 화면을 닫으면 쓰기가 실패한다. 오류가 아니라 정상 종료다. - log.debug("학생이 연결을 끊어 중계를 멈춥니다. memberId={}", memberId); - } - }; - - return ResponseEntity.ok() - // 아래 두 헤더는 중간에 끼는 프록시가 응답을 모아두지 못하게 막는다. - // 모아두면 조각이 한 번에 도착해 스트리밍이 사라진다. AI 서버도 같은 헤더를 보낸다. - .header(HttpHeaders.CACHE_CONTROL, "no-cache") - .header("X-Accel-Buffering", "no") - .contentType(MediaType.TEXT_EVENT_STREAM) - .body(body); + response.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + // 아래 두 헤더는 중간에 끼는 프록시가 응답을 모아두지 못하게 막는다. + // 모아두면 조각이 한 번에 도착해 스트리밍이 사라진다. AI 서버도 같은 헤더를 보낸다. + response.setHeader(HttpHeaders.CACHE_CONTROL, "no-cache"); + response.setHeader("X-Accel-Buffering", "no"); + + SseWriter writer = new SseWriter(response.getOutputStream(), objectMapper); + try { + chatService.answer(memberId, request.message(), writer); + } catch (UncheckedIOException e) { + // 학생이 화면을 닫으면 쓰기가 실패한다. 오류가 아니라 정상 종료다. + log.debug("학생이 연결을 끊어 중계를 멈춥니다. memberId={}", memberId); + } } /** diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiChatClient.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiChatClient.java index 8ce36ee8..e074cd4d 100644 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiChatClient.java +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiChatClient.java @@ -49,7 +49,13 @@ public class StreamAiChatClient implements ChatClient { private final RestClient streamAiRestClient; private final StreamAiProperties properties; - private final ObjectMapper objectMapper; + + /** + * 빈을 주입받지 않고 직접 만든다. 스프링이 만들어 주는 매퍼는 Jackson 3(tools.jackson)인데 + * 이 모듈이 선언한 의존성은 Jackson 2(com.fasterxml)다. 여기서 하는 일은 고정된 모양의 + * JSON 한 줄에서 필드 하나를 꺼내는 것뿐이라 앱의 직렬화 설정과 무관하다. + */ + private final ObjectMapper objectMapper = new ObjectMapper(); @Override public void stream(long memberId, List history, ChatEventListener listener) { From 5e65cad807fd21c0888c3d744470bba1fd29d99a Mon Sep 17 00:00:00 2001 From: overhyeon Date: Thu, 8 Oct 2026 18:14:47 +0900 Subject: [PATCH 06/14] =?UTF-8?q?fix:=20=EC=A4=91=EA=B0=84=EC=97=90=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=ED=95=9C=20=EB=8B=B5=EB=B3=80=EC=9D=80=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=ED=95=98=EC=A7=80=20=EC=95=8A=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chat/service/impl/ChatServiceImpl.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java index 981387e5..5b534f8a 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java @@ -51,9 +51,11 @@ public void answer(long memberId, String question, ChatEventListener listener) { chatClient.stream(memberId, turns, collector); String answer = collector.answer(); - // 실패했거나 답변이 비었다. 빈 답변을 남기면 다음 질문의 이력에 빈 턴이 섞인다. + // 실패했거나 답변이 비었다. 둘 다 저장하지 않는다. + // 몇 조각 받은 뒤 실패하면 텍스트는 있지만 중간에 끊긴 답변이다. 남기면 다음 질문의 이력에 섞인다. + // 빈 답변을 남기면 다음 질문의 이력에 빈 턴이 섞인다. // 실패 원인은 클라이언트가 이미 로그에 남겼다. core 도메인 모듈에는 로거가 없다. - if (answer.isBlank()) { + if (!collector.isCompleted() || answer.isBlank()) { return; } chatMessageRepository.save(ChatMessage.of(null, memberId, ChatTurn.MODEL, answer)); @@ -85,6 +87,9 @@ private static final class AnswerCollector implements ChatEventListener { private final ChatEventListener delegate; private final StringBuilder collected = new StringBuilder(); + /** {@code done}을 받았는지. {@code error}로 끝났으면 false로 남는다. */ + private boolean completed; + private AnswerCollector(ChatEventListener delegate) { this.delegate = delegate; } @@ -104,6 +109,7 @@ public void onBubble() { @Override public void onDone() { + completed = true; delegate.onDone(); } @@ -115,5 +121,9 @@ public void onError(String message) { String answer() { return collected.toString().strip(); } + + boolean isCompleted() { + return completed; + } } } From a0326dc34b91fb4cb128d8868fb81f7ed896217f Mon Sep 17 00:00:00 2001 From: overhyeon Date: Thu, 8 Oct 2026 18:15:06 +0900 Subject: [PATCH 07/14] =?UTF-8?q?fix:=20=ED=95=9C=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=EB=8B=B9=20=EB=8B=B5=EB=B3=80=20=EC=8A=A4=ED=8A=B8=EB=A6=BC?= =?UTF-8?q?=EC=9D=84=20=ED=95=98=EB=82=98=EB=A7=8C=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/service/impl/ChatServiceImpl.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java index 5b534f8a..ea5a682c 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java @@ -2,6 +2,8 @@ import java.util.ArrayList; import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatClient; import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatEventListener; import kr.ac.kookmin.stream.welfare.domain.chat.domain.ChatMessage; @@ -27,9 +29,21 @@ class ChatServiceImpl implements ChatService { */ private static final int HISTORY_LIMIT = 10; + private static final String BUSY_MESSAGE = "이전 답변이 아직 끝나지 않았습니다. 답변이 끝난 뒤에 다시 질문해 주세요."; + private final ChatMessageRepository chatMessageRepository; private final ChatClient chatClient; + /** + * 지금 답변을 받고 있는 회원. 한 사람당 답변 하나만 진행한다. + *

+ * 둘이 동시에 돌면 두 요청이 같은 이력을 읽어 가고, 저장 순서가 꼬여 다음 질문의 이력이 뒤섞인다. + *

+ * 서버 메모리에 두므로 백엔드가 한 대일 때만 정확하다. 여러 대로 늘리면 Redis 같은 + * 공용 저장소로 옮겨야 한다. + */ + private final Set answeringMembers = ConcurrentHashMap.newKeySet(); + /** * 이 메서드에 {@code @Transactional}을 붙이지 않는다. *

@@ -39,9 +53,27 @@ class ChatServiceImpl implements ChatService { *

* 그 대가로 질문만 남고 답변이 안 남는 경우가 생길 수 있다. 답변 생성이 실패한 경우이므로 * 기록으로는 그게 맞다 — 무엇을 물었는지는 남아야 나중에 오답을 찾을 수 있다. + *

+ * 이미 답변 중인 회원이 또 질문하면 {@code error} 이벤트로 거절하고 질문도 저장하지 않는다. + * 409 같은 상태 코드로 거절하지 않는 이유는, 컨트롤러가 이 메서드를 부르기 전에 이미 + * {@code text/event-stream} 헤더를 정해 두어 JSON 에러 응답을 쓸 수 없기 때문이다. */ @Override public void answer(long memberId, String question, ChatEventListener listener) { + if (!answeringMembers.add(memberId)) { + listener.onError(BUSY_MESSAGE); + return; + } + // 학생이 화면을 닫아 쓰기가 실패해도 예외가 올라오므로, 반드시 finally에서 풀어준다. + // 안 풀면 그 학생은 서버를 재시작할 때까지 질문할 수 없다. + try { + answerInTurn(memberId, question, listener); + } finally { + answeringMembers.remove(memberId); + } + } + + private void answerInTurn(long memberId, String question, ChatEventListener listener) { List turns = loadRecentTurns(memberId); turns.add(ChatTurn.user(question)); From dd5fc22b22f1c2252bd39b9a3c5e1db12a852012 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:19:22 +0900 Subject: [PATCH 08/14] =?UTF-8?q?refactor:=20AI=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5/=EB=B2=A4=EB=8D=94=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=EB=A1=9C=20=EC=98=AE=EA=B8=B0=EA=B3=A0=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=82=A4=EB=A5=BC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 다른 외부 클라이언트(oauth/kconnect, file/s3, push/fcm)처럼 client/chat/streamai로 옮기고, 설정 prefix도 기능이 앞에 오도록 client.stream-ai에서 chat.stream-ai로 바꿨다. 환경 변수 이름(STREAM_AI_*)은 그대로다. - .env.example에 STREAM_AI_* 세 항목을 추가했다 - yml에 기본값이 있어 필요 없는 baseUrl null 가드를 뺐다 --- .env.example | 6 ++++++ .../chat/{ => streamai}/StreamAiChatClient.java | 2 +- .../chat/{ => streamai}/StreamAiClientConfig.java | 12 +++++------- .../chat/{ => streamai}/StreamAiProperties.java | 13 ++++--------- .../resources/application-infrastructure-client.yml | 6 +++--- 5 files changed, 19 insertions(+), 20 deletions(-) rename infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/{ => streamai}/StreamAiChatClient.java (99%) rename infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/{ => streamai}/StreamAiClientConfig.java (66%) rename infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/{ => streamai}/StreamAiProperties.java (58%) diff --git a/.env.example b/.env.example index 3a2f8b41..9ac1c74e 100644 --- a/.env.example +++ b/.env.example @@ -51,3 +51,9 @@ PUSH_TYPE=log # type=fcm일 때. Firebase 콘솔 > 프로젝트 설정 > 서비스 계정에서 받은 비공개 키 JSON을 base64로 인코딩한 값 # 예: base64 < firebase-adminsdk.json | tr -d '\n' FIREBASE_CREDENTIALS_BASE64=replace-with-base64-encoded-service-account-json + +### infrastructure:client — AI 서버(stream-ai) 챗봇 ### +STREAM_AI_BASE_URL=http://localhost:8000 +STREAM_AI_CHAT_PATH=/chat +# AI 서버가 백엔드를 거친 요청인지 확인하는 값. 코드·로그에 남기지 않는다 +STREAM_AI_INTERNAL_TOKEN=replace-with-stream-ai-internal-token diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiChatClient.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatClient.java similarity index 99% rename from infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiChatClient.java rename to infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatClient.java index e074cd4d..cd98ab34 100644 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiChatClient.java +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatClient.java @@ -1,4 +1,4 @@ -package kr.ac.kookmin.stream.client.chat; +package kr.ac.kookmin.stream.client.chat.streamai; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiClientConfig.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiClientConfig.java similarity index 66% rename from infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiClientConfig.java rename to infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiClientConfig.java index 7d92f539..172bf274 100644 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiClientConfig.java +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiClientConfig.java @@ -1,4 +1,4 @@ -package kr.ac.kookmin.stream.client.chat; +package kr.ac.kookmin.stream.client.chat.streamai; import java.util.List; import org.slf4j.Logger; @@ -9,10 +9,8 @@ import org.springframework.web.client.RestClient; /** - * {@link StreamAiChatClient}가 쓰는 RestClient 빈 설정. - *

- * 응답 본문을 통째로 읽지 않고 흘러오는 대로 읽어야 하므로, 버퍼링을 하는 요청 팩터리를 쓰지 않는다. - * {@link SimpleClientHttpRequestFactory}는 응답을 모아두지 않아 SSE에 쓸 수 있다. + * {@link StreamAiChatClient}가 쓰는 RestClient 빈 설정. SSE 응답을 흘러오는 대로 읽어야 하므로 응답을 모아두지 않는 + * {@link SimpleClientHttpRequestFactory}를 쓴다. */ @Configuration public class StreamAiClientConfig { @@ -21,7 +19,7 @@ public class StreamAiClientConfig { @Bean public RestClient streamAiRestClient(StreamAiProperties properties) { - // 로컬에서 AI 서버 없이도 기동은 되게 두고, 빠진 설정만 알려준다. 챗봇을 쓰면 실패한다 + // 로컬처럼 AI 서버 설정 없이도 기동은 되게 두고, 빠진 설정을 알려만 준다. 챗봇을 쓰면 실패한다 List missingSettings = properties.missingSettings(); if (!missingSettings.isEmpty()) { log.warn("AI 서버 설정이 비어 있어 챗봇을 쓸 수 없습니다: {}", missingSettings); @@ -32,7 +30,7 @@ public RestClient streamAiRestClient(StreamAiProperties properties) { requestFactory.setReadTimeout(properties.readTimeout()); return RestClient.builder() - .baseUrl(properties.baseUrl() == null ? "" : properties.baseUrl()) + .baseUrl(properties.baseUrl()) .requestFactory(requestFactory) .build(); } diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiProperties.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiProperties.java similarity index 58% rename from infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiProperties.java rename to infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiProperties.java index 1e8ff60b..7455df22 100644 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiProperties.java +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiProperties.java @@ -1,4 +1,4 @@ -package kr.ac.kookmin.stream.client.chat; +package kr.ac.kookmin.stream.client.chat.streamai; import java.time.Duration; import java.util.LinkedHashMap; @@ -7,16 +7,11 @@ import org.springframework.boot.context.properties.ConfigurationProperties; /** - * AI 서버(stream-ai) 연동 설정. + * AI 서버(stream-ai) 연동 설정. internalToken은 AI 서버가 백엔드를 거친 요청인지 확인하는 값이라 환경 변수로만 받는다. *

- * {@code internalToken}은 AI 서버가 "백엔드를 거친 요청"만 처리하기 위해 확인하는 값이다. - * 코드에 두지 않고 환경 변수로만 받는다. - *

- * {@code readTimeout}은 조각 하나를 기다리는 시간이고 전체 응답 시간이 아니다. - * SSE는 연결을 열어 둔 채 조각을 계속 보내므로, 조각 사이 간격보다 넉넉하면 된다. - * 전체 길이에 맞춰 길게 잡으면 AI 서버가 멈췄을 때도 그만큼 기다린다. + * readTimeout은 전체 답변 시간이 아니라 조각 하나를 기다리는 시간이다. 전체 길이에 맞춰 길게 잡으면 AI 서버가 멈췄을 때도 그만큼 기다린다. */ -@ConfigurationProperties(prefix = "client.stream-ai") +@ConfigurationProperties(prefix = "chat.stream-ai") public record StreamAiProperties( String baseUrl, String chatPath, diff --git a/infrastructure/client/src/main/resources/application-infrastructure-client.yml b/infrastructure/client/src/main/resources/application-infrastructure-client.yml index 05e79fb6..fa79542b 100644 --- a/infrastructure/client/src/main/resources/application-infrastructure-client.yml +++ b/infrastructure/client/src/main/resources/application-infrastructure-client.yml @@ -30,9 +30,9 @@ push: fcm: credentials-base64: ${FIREBASE_CREDENTIALS_BASE64:} -# AI 서버(stream-ai). 내부 토큰은 저장소에 남기지 않는다. -# read-timeout은 조각 하나를 기다리는 시간이다. 전체 답변 시간이 아니라 조각 사이 간격 기준으로 잡는다. -client: +# AI 서버(stream-ai). 내부 토큰은 저장소에 남기지 않는다 +# read-timeout은 전체 답변 시간이 아니라 조각 사이 간격 기준으로 잡는다 +chat: stream-ai: base-url: ${STREAM_AI_BASE_URL:http://localhost:8000} chat-path: ${STREAM_AI_CHAT_PATH:/chat} From 5e55cdcd28bf51807e9f0105413fc5d52429dc52 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:19:38 +0900 Subject: [PATCH 09/14] =?UTF-8?q?fix:=20=ED=95=99=EC=83=9D=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EB=81=8A=EA=B9=80=EC=9D=84=20AI=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EC=9E=A5=EC=95=A0=EB=A1=9C=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 학생이 화면을 닫으면 SSE 쓰기가 UncheckedIOException을 던진다. RestClient의 exchange는 이 예외를 감싸지 않고 다시 던지는데, 클라이언트가 catch (Exception)으로 받아 "AI 서버 호출에 실패했습니다" ERROR 로그를 남기고 이미 끊긴 연결에 error 이벤트를 쓰려 했다. AI 서버 쪽 실패만 잡도록 RestClientException으로 좁혔다. exchange 안에서 나는 IOException은 RestClient가 ResourceAccessException으로 감싸므로 그대로 잡힌다. 리스너가 예외를 던져 중계를 멈출 수 있다는 계약은 ChatEventListener에 적었다. 함께: exchange(..., false)라 AI 서버가 에러 상태를 돌려줄 때 응답을 아무도 닫지 않았다. 기본값(닫음)으로 바꿨다. RestClient는 닫을 때 남은 본문을 끝까지 읽으므로, 중간에 멈출 때는 지금처럼 리더가 먼저 닫아야 AI 서버 스트림이 바로 끊긴다. --- .../domain/chat/client/ChatEventListener.java | 15 ++++++--------- .../client/chat/streamai/StreamAiChatClient.java | 8 ++++++-- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/client/ChatEventListener.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/client/ChatEventListener.java index ee59e826..906dcdca 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/client/ChatEventListener.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/client/ChatEventListener.java @@ -1,13 +1,12 @@ package kr.ac.kookmin.stream.welfare.domain.chat.client; /** - * AI 서버가 보내는 SSE 이벤트를 받는 쪽. 이벤트 네 종류를 그대로 메서드로 둔다. + * AI 서버가 보내는 답변 이벤트를 받는 쪽. 답변을 모아서 한 번에 넘기지 않고 조각마다 알려, 받는 즉시 학생에게 흘려보낼 수 있게 한다. *

- * 답변 전체를 모아서 한 번에 넘기지 않고 조각마다 알리는 이유는, 중계하는 쪽이 받는 즉시 - * 학생에게 흘려보내야 하기 때문이다. 모아두면 학생이 10초 동안 빈 화면을 본다. + * 마지막에는 {@code onDone} 또는 {@code onError} 중 하나가 정확히 한 번 온다. {@link ChatClient} 구현체가 보장한다. *

- * 구현하는 쪽은 조각을 흘려보내면서 동시에 모아야 한다. 모으지 않으면 - * {@code chat_messages}에 저장할 답변 전체가 남지 않는다. + * 더 받을 수 없으면(학생이 연결을 끊는 등) 구현체는 unchecked 예외를 던져 중계를 멈출 수 있다. + * {@link ChatClient} 구현체는 이 예외를 AI 서버 실패로 다루지 않고 그대로 전파한다. */ public interface ChatEventListener { @@ -21,10 +20,8 @@ public interface ChatEventListener { void onDone(); /** - * 실패. 상세 원인은 AI 서버 로그에만 남으므로 여기 오는 글은 학생에게 보여줄 안내문이다. - *

- * 스트림이 시작된 뒤의 실패는 상태 코드를 바꿀 수 없어 예외로 올릴 수 없다. - * 그래서 성공 경로와 같은 통로로 알린다. + * 실패. 스트림이 시작된 뒤에는 상태 코드를 바꿀 수 없어 예외 대신 이 통로로 알린다. + * 상세 원인은 로그에만 남기므로 여기 오는 글은 학생에게 보여줄 안내문이다. */ void onError(String message); } diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatClient.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatClient.java index cd98ab34..cd1218a9 100644 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatClient.java +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatClient.java @@ -19,6 +19,7 @@ import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; /** * AI 서버(stream-ai)에 질문을 보내고 답변을 SSE로 받아 넘긴다. @@ -78,8 +79,9 @@ public void stream(long memberId, List history, ChatEventListener list } readEvents(response.getBody(), guard); return null; - }, false); - } catch (Exception e) { + }); + } catch (RestClientException e) { + // 리스너가 던진 예외(학생 연결 끊김)는 AI 서버 실패가 아니므로 잡지 않고 그대로 올려 보낸다 log.error("AI 서버 호출에 실패했습니다.", e); guard.error(FALLBACK_ERROR_MESSAGE); } @@ -103,6 +105,8 @@ private Map toRequestBody(List history) { * 한 줄을 넘지 않는다. 그래서 줄 단위로 읽어도 쪼개지지 않는다. */ private void readEvents(InputStream body, TerminalGuard guard) throws IOException { + // 다 읽기 전에 멈추면(리스너가 예외를 던지면) 여기서 닫아야 AI 서버 스트림이 바로 끊긴다. + // RestClient가 응답을 닫을 때는 남은 본문을 끝까지 읽어 버려 답변이 다 만들어질 때까지 기다리게 된다 try (BufferedReader reader = new BufferedReader(new InputStreamReader(body, StandardCharsets.UTF_8))) { String event = null; String data = null; From af08235e05b982d6c8183c8db0231c640c4cb2b1 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:19:51 +0900 Subject: [PATCH 10/14] =?UTF-8?q?refactor:=20AI=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=EC=97=90=EC=84=9C?= =?UTF-8?q?=20SSE=20=ED=8C=8C=EC=8B=B1=EA=B3=BC=20=EC=A2=85=EB=A3=8C=20?= =?UTF-8?q?=EB=B3=B4=EC=9E=A5=EC=9D=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StreamAiChatClient가 HTTP 호출, SSE 줄 파싱, 이벤트 매핑, 종료 보장을 모두 맡고 있었다. 호출과 AI 계약 매핑만 남기고 나머지는 package-private 클래스로 뺐다. - SseEventReader·SseEvent: SSE 줄을 읽어 (event, data)로 넘긴다. AI 계약은 모른다 - TerminalGuard: 중첩 클래스에서 파일로 뺐다. delta()·errorIfNotFinished()처럼 같은 동작을 두 이름으로 갖던 메서드를 정리했다 - StreamAiChatRequest: Map.of로 조립하던 요청 바디를 record로 바꿨다 - JSON 매퍼를 new ObjectMapper()(Jackson 2)로 만들지 않고 스프링이 만드는 Jackson 3 빈을 주입받는다. 컨트롤러와 같은 매퍼를 쓴다 --- gradle/libs.versions.toml | 2 + infrastructure/client/build.gradle.kts | 1 + .../stream/client/chat/streamai/SseEvent.java | 4 + .../client/chat/streamai/SseEventReader.java | 56 ++++++ .../chat/streamai/StreamAiChatClient.java | 184 +++--------------- .../chat/streamai/StreamAiChatRequest.java | 19 ++ .../client/chat/streamai/TerminalGuard.java | 52 +++++ 7 files changed, 161 insertions(+), 157 deletions(-) create mode 100644 infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/SseEvent.java create mode 100644 infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/SseEventReader.java create mode 100644 infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatRequest.java create mode 100644 infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/TerminalGuard.java diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6b967646..388de397 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,6 +41,8 @@ springTx = { module = "org.springframework:spring-tx" } jakartaServletApi = { module = "jakarta.servlet:jakarta.servlet-api" } jacksonDatabind = { module = "com.fasterxml.jackson.core:jackson-databind" } +# Jackson 3 — Spring Boot 4가 만드는 매퍼 빈과 같은 계열 +jackson3Databind = { module = "tools.jackson.core:jackson-databind" } slf4jApi = { module = "org.slf4j:slf4j-api" } flywayMysql = { module = "org.flywaydb:flyway-mysql" } diff --git a/infrastructure/client/build.gradle.kts b/infrastructure/client/build.gradle.kts index 24d0523f..5865b1ae 100644 --- a/infrastructure/client/build.gradle.kts +++ b/infrastructure/client/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { implementation(libs.springBootStarter) implementation(libs.springWeb) implementation(libs.jacksonDatabind) + implementation(libs.jackson3Databind) implementation(platform(libs.awsSdkBom)) implementation(libs.awsS3) diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/SseEvent.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/SseEvent.java new file mode 100644 index 00000000..368e1566 --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/SseEvent.java @@ -0,0 +1,4 @@ +package kr.ac.kookmin.stream.client.chat.streamai; + +// SSE 이벤트 하나. data는 없을 수 있다 +record SseEvent(String name, String data) {} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/SseEventReader.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/SseEventReader.java new file mode 100644 index 00000000..5f4d4779 --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/SseEventReader.java @@ -0,0 +1,56 @@ +package kr.ac.kookmin.stream.client.chat.streamai; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.function.Consumer; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * SSE 본문을 줄 단위로 읽어 이벤트마다 넘긴다. 형식은 {@code event: 이름}, {@code data: JSON}, 빈 줄 한 번이다. + *

+ * {@code data}의 JSON은 줄바꿈이 {@code \\n}으로 escape되어 와서 한 줄을 넘지 않으므로 줄 단위로 읽어도 쪼개지지 않는다. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +final class SseEventReader { + + private static final String EVENT_PREFIX = "event:"; + private static final String DATA_PREFIX = "data:"; + + // 다 읽기 전에 멈추면(handler가 예외를 던지면) 여기서 닫아야 AI 서버 스트림이 바로 끊긴다. + // RestClient가 응답을 닫을 때는 남은 본문을 끝까지 읽어 버려 답변이 다 만들어질 때까지 기다리게 된다 + static void read(InputStream body, Consumer handler) throws IOException { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(body, StandardCharsets.UTF_8))) { + String name = null; + String data = null; + + String line; + while ((line = reader.readLine()) != null) { + if (line.isEmpty()) { + emit(name, data, handler); + name = null; + data = null; + continue; + } + if (line.startsWith(EVENT_PREFIX)) { + name = line.substring(EVENT_PREFIX.length()).trim(); + } else if (line.startsWith(DATA_PREFIX)) { + data = line.substring(DATA_PREFIX.length()).trim(); + } + // 그 외 줄(주석 등)은 무시한다. 계약에 없지만 와도 깨지지 않아야 한다 + } + + // 마지막 이벤트 뒤에 빈 줄 없이 끊긴 경우 + emit(name, data, handler); + } + } + + private static void emit(String name, String data, Consumer handler) { + if (name != null) { + handler.accept(new SseEvent(name, data)); + } + } +} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatClient.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatClient.java index cd1218a9..69a58bfa 100644 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatClient.java +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatClient.java @@ -1,14 +1,6 @@ package kr.ac.kookmin.stream.client.chat.streamai; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; import java.util.List; -import java.util.Map; import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatClient; import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatEventListener; import kr.ac.kookmin.stream.welfare.domain.chat.domain.ChatTurn; @@ -20,14 +12,15 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; /** * AI 서버(stream-ai)에 질문을 보내고 답변을 SSE로 받아 넘긴다. *

- * 다른 외부 클라이언트와 다른 점이 하나 있다. 응답을 객체로 변환해 돌려받지 않고 - * 본문을 흘러오는 대로 읽는다. {@code RestClient}의 {@code exchange}는 응답을 변환하지 않고 - * 그대로 넘겨주므로 그 안에서 입력 스트림을 직접 읽는다. 변환을 맡기면 전체가 도착할 때까지 - * 기다리게 되어 스트리밍이 사라진다. + * 응답을 객체로 변환하지 않고 {@code exchange} 안에서 본문을 흘러오는 대로 읽는다. 변환을 맡기면 전체가 도착할 때까지 기다리게 되어 + * 스트리밍이 사라진다. */ @Component @RequiredArgsConstructor @@ -38,9 +31,6 @@ public class StreamAiChatClient implements ChatClient { /** 학생에게 보여줄 실패 안내. 내부 원인은 로그에만 남긴다. */ private static final String FALLBACK_ERROR_MESSAGE = "답변 생성 중 오류가 발생했습니다."; - private static final String EVENT_PREFIX = "event:"; - private static final String DATA_PREFIX = "data:"; - private static final String EVENT_DELTA = "delta"; private static final String EVENT_BUBBLE = "bubble"; private static final String EVENT_DONE = "done"; @@ -50,18 +40,11 @@ public class StreamAiChatClient implements ChatClient { private final RestClient streamAiRestClient; private final StreamAiProperties properties; - - /** - * 빈을 주입받지 않고 직접 만든다. 스프링이 만들어 주는 매퍼는 Jackson 3(tools.jackson)인데 - * 이 모듈이 선언한 의존성은 Jackson 2(com.fasterxml)다. 여기서 하는 일은 고정된 모양의 - * JSON 한 줄에서 필드 하나를 꺼내는 것뿐이라 앱의 직렬화 설정과 무관하다. - */ - private final ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectMapper objectMapper; @Override public void stream(long memberId, List history, ChatEventListener listener) { - // 종료 이벤트를 두 번 보내지 않도록 한 곳에서 관리한다. 학생 쪽 연결을 두 번 닫으면 예외가 난다. - TerminalGuard guard = new TerminalGuard(listener); + TerminalGuard guard = TerminalGuard.from(listener); try { streamAiRestClient.post() @@ -69,170 +52,57 @@ public void stream(long memberId, List history, ChatEventListener list .header("X-Internal-Token", properties.internalToken()) .header("X-User-Id", String.valueOf(memberId)) .accept(MediaType.TEXT_EVENT_STREAM) - .body(toRequestBody(history)) + .body(StreamAiChatRequest.from(history)) .exchange((request, response) -> { if (response.getStatusCode().isError()) { - // 질문 원문은 남기지 않는다. 학생의 개인 사정이 섞여 들어올 수 있다. + // 질문 원문은 남기지 않는다. 학생의 개인 사정이 섞여 들어올 수 있다 log.error("AI 서버가 오류를 돌려줬습니다. status={}", response.getStatusCode()); - guard.error(FALLBACK_ERROR_MESSAGE); + guard.onError(FALLBACK_ERROR_MESSAGE); return null; } - readEvents(response.getBody(), guard); + SseEventReader.read(response.getBody(), event -> dispatch(event, guard)); return null; }); } catch (RestClientException e) { // 리스너가 던진 예외(학생 연결 끊김)는 AI 서버 실패가 아니므로 잡지 않고 그대로 올려 보낸다 log.error("AI 서버 호출에 실패했습니다.", e); - guard.error(FALLBACK_ERROR_MESSAGE); + guard.onError(FALLBACK_ERROR_MESSAGE); } - // 스트림이 done도 error도 없이 끊긴 경우. 이게 없으면 학생 쪽 연결이 영원히 열려 있다. - guard.errorIfNotFinished(FALLBACK_ERROR_MESSAGE); - } - - /** AI 서버 계약: {@code { "messages": [ { "role": ..., "text": ... } ] }} */ - private Map toRequestBody(List history) { - List> messages = history.stream() - .map(turn -> Map.of("role", turn.role(), "text", turn.text())) - .toList(); - return Map.of("messages", messages); - } - - /** - * SSE 본문을 줄 단위로 읽는다. 형식은 {@code event: 이름}, {@code data: JSON}, 빈 줄 한 번이다. - *

- * 빈 줄이 이벤트 하나의 끝이다. {@code data}의 JSON은 줄바꿈이 {@code \\n}으로 escape되어 들어오므로 - * 한 줄을 넘지 않는다. 그래서 줄 단위로 읽어도 쪼개지지 않는다. - */ - private void readEvents(InputStream body, TerminalGuard guard) throws IOException { - // 다 읽기 전에 멈추면(리스너가 예외를 던지면) 여기서 닫아야 AI 서버 스트림이 바로 끊긴다. - // RestClient가 응답을 닫을 때는 남은 본문을 끝까지 읽어 버려 답변이 다 만들어질 때까지 기다리게 된다 - try (BufferedReader reader = new BufferedReader(new InputStreamReader(body, StandardCharsets.UTF_8))) { - String event = null; - String data = null; - - String line; - while ((line = reader.readLine()) != null) { - if (line.isEmpty()) { - dispatch(event, data, guard); - event = null; - data = null; - continue; - } - if (line.startsWith(EVENT_PREFIX)) { - event = line.substring(EVENT_PREFIX.length()).trim(); - } else if (line.startsWith(DATA_PREFIX)) { - data = line.substring(DATA_PREFIX.length()).trim(); - } - // 그 외 줄(주석 등)은 무시한다. 계약에 없지만 와도 깨지지 않아야 한다. - } - - // 마지막 이벤트 뒤에 빈 줄이 없이 끊긴 경우를 처리한다. - dispatch(event, data, guard); - } + // done도 error도 없이 스트림이 끊긴 경우. 이미 끝났으면 무시된다 + guard.onError(FALLBACK_ERROR_MESSAGE); } - private void dispatch(String event, String data, TerminalGuard guard) { - if (event == null) { - return; - } - switch (event) { + private void dispatch(SseEvent event, TerminalGuard guard) { + switch (event.name()) { case EVENT_DELTA -> { - String text = readField(data, "text"); + String text = readField(event.data(), "text"); if (text != null && !text.isEmpty()) { - guard.delta(text); + guard.onDelta(text); } } - case EVENT_BUBBLE -> guard.bubble(); - case EVENT_DONE -> guard.done(); + case EVENT_BUBBLE -> guard.onBubble(); + case EVENT_DONE -> guard.onDone(); case EVENT_ERROR -> { - String message = readField(data, "message"); - guard.error(message == null || message.isBlank() ? FALLBACK_ERROR_MESSAGE : message); + String message = readField(event.data(), "message"); + guard.onError(message == null || message.isBlank() ? FALLBACK_ERROR_MESSAGE : message); } - // 계약에 없는 이벤트가 추가되어도 중계가 멈추지 않게 한다. - default -> log.debug("모르는 SSE 이벤트를 건너뜁니다. event={}", event); + // 계약에 없는 이벤트가 추가되어도 중계가 멈추지 않게 한다 + default -> log.debug("모르는 SSE 이벤트를 건너뜁니다. event={}", event.name()); } } - /** JSON 한 줄에서 필드 하나를 꺼낸다. 깨진 줄 하나가 대화 전체를 끊지 않도록 예외를 삼킨다. */ + /** JSON 한 줄에서 필드 하나를 꺼낸다. 깨진 줄 하나가 대화 전체를 끊지 않도록 파싱 실패는 삼킨다. */ private String readField(String data, String field) { if (data == null || data.isBlank()) { return null; } try { JsonNode node = objectMapper.readTree(data).get(field); - return node == null || node.isNull() ? null : node.asText(); - } catch (Exception e) { + return node == null || node.isNull() ? null : node.asString(); + } catch (JacksonException e) { log.debug("SSE data를 읽지 못해 건너뜁니다. field={}", field); return null; } } - - /** - * 종료 이벤트가 한 번만 나가도록 감싼다. - *

- * {@code done}과 {@code error}는 둘 중 하나만, 한 번만 불려야 한다. 학생 쪽 연결을 - * 두 번 닫으려 하면 예외가 나고, 한 번도 닫지 않으면 연결이 계속 열려 있다. - */ - private static final class TerminalGuard implements ChatEventListener { - - private final ChatEventListener delegate; - private boolean finished; - - private TerminalGuard(ChatEventListener delegate) { - this.delegate = delegate; - } - - @Override - public void onDelta(String text) { - delta(text); - } - - @Override - public void onBubble() { - bubble(); - } - - @Override - public void onDone() { - done(); - } - - @Override - public void onError(String message) { - error(message); - } - - void delta(String text) { - if (!finished) { - delegate.onDelta(text); - } - } - - void bubble() { - if (!finished) { - delegate.onBubble(); - } - } - - void done() { - if (finished) { - return; - } - finished = true; - delegate.onDone(); - } - - void error(String message) { - if (finished) { - return; - } - finished = true; - delegate.onError(message); - } - - void errorIfNotFinished(String message) { - error(message); - } - } } diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatRequest.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatRequest.java new file mode 100644 index 00000000..b8433d8d --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatRequest.java @@ -0,0 +1,19 @@ +package kr.ac.kookmin.stream.client.chat.streamai; + +import java.util.List; +import kr.ac.kookmin.stream.welfare.domain.chat.domain.ChatTurn; + +// AI 서버 요청 바디: { "messages": [ { "role": ..., "text": ... } ] } +record StreamAiChatRequest(List messages) { + + record Message(String role, String text) { + + static Message from(ChatTurn turn) { + return new Message(turn.role(), turn.text()); + } + } + + static StreamAiChatRequest from(List history) { + return new StreamAiChatRequest(history.stream().map(Message::from).toList()); + } +} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/TerminalGuard.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/TerminalGuard.java new file mode 100644 index 00000000..c8d7665d --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/TerminalGuard.java @@ -0,0 +1,52 @@ +package kr.ac.kookmin.stream.client.chat.streamai; + +import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatEventListener; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +/** + * {@code done}·{@code error} 중 하나만, 한 번만 넘기고 끝난 뒤의 이벤트는 버린다. + * 학생 쪽 연결을 두 번 닫으려 하면 예외가 나고, 한 번도 닫지 않으면 연결이 계속 열려 있다. + */ +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +final class TerminalGuard implements ChatEventListener { + + private final ChatEventListener delegate; + private boolean finished; + + static TerminalGuard from(ChatEventListener delegate) { + return new TerminalGuard(delegate); + } + + @Override + public void onDelta(String text) { + if (!finished) { + delegate.onDelta(text); + } + } + + @Override + public void onBubble() { + if (!finished) { + delegate.onBubble(); + } + } + + @Override + public void onDone() { + if (finished) { + return; + } + finished = true; + delegate.onDone(); + } + + @Override + public void onError(String message) { + if (finished) { + return; + } + finished = true; + delegate.onError(message); + } +} From 1302474a5af4754b9f5fce94efc256be97c21b67 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:20:03 +0900 Subject: [PATCH 11/14] =?UTF-8?q?refactor:=20=EC=B1=97=EB=B4=87=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=EC=97=90=EC=84=9C=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=20=EB=8B=B5=EB=B3=80=20=EC=B0=A8=EB=8B=A8=EA=B3=BC=20?= =?UTF-8?q?=EB=8B=B5=EB=B3=80=20=EC=88=98=EC=A7=91=EC=9D=84=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatServiceImpl이 흐름 조립만 하도록 나눴다. - ChatAnswerLock: 한 회원당 답변 하나만 진행하게 막는 부분. 서버 메모리라 백엔드가 여러 대가 되면 포트로 올린다 - AnswerCollector: 중첩 클래스에서 파일로 뺐다. 저장할지 판단하던 조건을 completedAnswer()로 모았다 --- .../chat/service/impl/AnswerCollector.java | 56 +++++++++ .../chat/service/impl/ChatAnswerLock.java | 25 ++++ .../chat/service/impl/ChatServiceImpl.java | 114 +++--------------- 3 files changed, 95 insertions(+), 100 deletions(-) create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/AnswerCollector.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatAnswerLock.java diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/AnswerCollector.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/AnswerCollector.java new file mode 100644 index 00000000..1a0c35da --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/AnswerCollector.java @@ -0,0 +1,56 @@ +package kr.ac.kookmin.stream.welfare.domain.chat.service.impl; + +import java.util.Optional; +import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatEventListener; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +/** + * 조각을 그대로 흘려보내면서 답변 전체를 모은다. 흘려보내기만 하면 저장할 답변이 남지 않고, 모으기만 하면 스트리밍이 사라진다. + */ +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +final class AnswerCollector implements ChatEventListener { + + private final ChatEventListener delegate; + private final StringBuilder collected = new StringBuilder(); + + /** {@code done}을 받았는지. {@code error}로 끝났으면 false로 남는다. */ + private boolean completed; + + static AnswerCollector from(ChatEventListener delegate) { + return new AnswerCollector(delegate); + } + + @Override + public void onDelta(String text) { + collected.append(text); + delegate.onDelta(text); + } + + @Override + public void onBubble() { + // 말풍선 경계는 빈 줄로 잇는다. 프론트가 보여주는 모양과 같아진다 + collected.append("\n\n"); + delegate.onBubble(); + } + + @Override + public void onDone() { + completed = true; + delegate.onDone(); + } + + @Override + public void onError(String message) { + delegate.onError(message); + } + + /** {@code done}으로 끝났고 비어 있지 않을 때만 답변을 준다. */ + Optional completedAnswer() { + String answer = collected.toString().strip(); + if (!completed || answer.isBlank()) { + return Optional.empty(); + } + return Optional.of(answer); + } +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatAnswerLock.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatAnswerLock.java new file mode 100644 index 00000000..6bf64ed4 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatAnswerLock.java @@ -0,0 +1,25 @@ +package kr.ac.kookmin.stream.welfare.domain.chat.service.impl; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.stereotype.Component; + +/** + * 한 회원당 답변 하나만 진행하게 막는다. 둘이 동시에 돌면 같은 이력을 읽어 가고 저장 순서가 꼬여 다음 질문의 이력이 뒤섞인다. + *

+ * 서버 메모리에 두므로 백엔드가 한 대일 때만 정확하다. 여러 대가 되면 포트로 올리고 Redis 같은 공용 저장소 구현을 둔다. + */ +@Component +class ChatAnswerLock { + + private final Set answeringMembers = ConcurrentHashMap.newKeySet(); + + /** 이미 답변 중이면 false. */ + boolean tryAcquire(Long memberId) { + return answeringMembers.add(memberId); + } + + void release(Long memberId) { + answeringMembers.remove(memberId); + } +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java index ea5a682c..1364fc84 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java @@ -2,8 +2,6 @@ import java.util.ArrayList; import java.util.List; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatClient; import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatEventListener; import kr.ac.kookmin.stream.welfare.domain.chat.domain.ChatMessage; @@ -17,87 +15,53 @@ @RequiredArgsConstructor class ChatServiceImpl implements ChatService { - /** - * AI 서버에 함께 보낼 지난 대화 건수. 질문·답변 한 쌍이 2건이므로 5번의 주고받음에 해당한다. - *

- * 전부 보내면 학기가 갈수록 토큰 비용이 계속 늘어난다. 이 정도면 "그럼 언제까지야?" 같은 - * 후속 질문은 이어진다. - *

- * 설정값으로 빼지 않은 이유는 이 저장소가 core 도메인 모듈에 설정 바인딩을 두지 않기 때문이다 - * ({@code @ConfigurationProperties}는 gateway와 infrastructure에만 있다). - * 운영 중에 조절할 필요가 생기면 {@code StreamAiProperties}로 옮기고 클라이언트가 자르게 한다. - */ + // 질문·답변 5쌍. 전부 보내면 학기가 갈수록 토큰 비용이 늘고, 이 정도면 후속 질문은 이어진다 private static final int HISTORY_LIMIT = 10; private static final String BUSY_MESSAGE = "이전 답변이 아직 끝나지 않았습니다. 답변이 끝난 뒤에 다시 질문해 주세요."; private final ChatMessageRepository chatMessageRepository; private final ChatClient chatClient; + private final ChatAnswerLock chatAnswerLock; /** - * 지금 답변을 받고 있는 회원. 한 사람당 답변 하나만 진행한다. - *

- * 둘이 동시에 돌면 두 요청이 같은 이력을 읽어 가고, 저장 순서가 꼬여 다음 질문의 이력이 뒤섞인다. - *

- * 서버 메모리에 두므로 백엔드가 한 대일 때만 정확하다. 여러 대로 늘리면 Redis 같은 - * 공용 저장소로 옮겨야 한다. - */ - private final Set answeringMembers = ConcurrentHashMap.newKeySet(); - - /** - * 이 메서드에 {@code @Transactional}을 붙이지 않는다. + * {@code @Transactional}을 붙이지 않는다. 답변 생성이 10초쯤 걸려 그동안 DB 커넥션을 쥐면 챗봇과 무관한 화면까지 멈춘다. + * 저장은 각자 짧게 하고 스트리밍은 트랜잭션 밖에서 한다. 그래서 답변이 실패하면 질문만 남는다. *

- * 답변 생성이 10초쯤 걸리는데 트랜잭션으로 감싸면 그 시간 동안 DB 커넥션을 쥐고 있게 된다. - * 동시에 질문하는 학생이 몇 명만 되어도 커넥션 풀이 말라 챗봇과 무관한 화면까지 멈춘다. - * 그래서 저장은 각자 짧게 하고, 긴 스트리밍은 트랜잭션 밖에서 한다. - *

- * 그 대가로 질문만 남고 답변이 안 남는 경우가 생길 수 있다. 답변 생성이 실패한 경우이므로 - * 기록으로는 그게 맞다 — 무엇을 물었는지는 남아야 나중에 오답을 찾을 수 있다. - *

- * 이미 답변 중인 회원이 또 질문하면 {@code error} 이벤트로 거절하고 질문도 저장하지 않는다. - * 409 같은 상태 코드로 거절하지 않는 이유는, 컨트롤러가 이 메서드를 부르기 전에 이미 - * {@code text/event-stream} 헤더를 정해 두어 JSON 에러 응답을 쓸 수 없기 때문이다. + * 이미 답변 중인 회원의 질문은 {@code error} 이벤트로 거절하고 저장하지 않는다. 컨트롤러가 SSE 헤더를 먼저 정해 두어 + * 상태 코드로는 거절할 수 없다. */ @Override public void answer(long memberId, String question, ChatEventListener listener) { - if (!answeringMembers.add(memberId)) { + if (!chatAnswerLock.tryAcquire(memberId)) { listener.onError(BUSY_MESSAGE); return; } - // 학생이 화면을 닫아 쓰기가 실패해도 예외가 올라오므로, 반드시 finally에서 풀어준다. - // 안 풀면 그 학생은 서버를 재시작할 때까지 질문할 수 없다. + // 학생이 연결을 끊으면 예외가 올라오므로 반드시 finally에서 푼다. 안 풀면 재시작 전까지 질문할 수 없다 try { answerInTurn(memberId, question, listener); } finally { - answeringMembers.remove(memberId); + chatAnswerLock.release(memberId); } } private void answerInTurn(long memberId, String question, ChatEventListener listener) { List turns = loadRecentTurns(memberId); turns.add(ChatTurn.user(question)); - chatMessageRepository.save(ChatMessage.of(null, memberId, ChatTurn.USER, question)); - AnswerCollector collector = new AnswerCollector(listener); + AnswerCollector collector = AnswerCollector.from(listener); chatClient.stream(memberId, turns, collector); - String answer = collector.answer(); - // 실패했거나 답변이 비었다. 둘 다 저장하지 않는다. - // 몇 조각 받은 뒤 실패하면 텍스트는 있지만 중간에 끊긴 답변이다. 남기면 다음 질문의 이력에 섞인다. - // 빈 답변을 남기면 다음 질문의 이력에 빈 턴이 섞인다. - // 실패 원인은 클라이언트가 이미 로그에 남겼다. core 도메인 모듈에는 로거가 없다. - if (!collector.isCompleted() || answer.isBlank()) { - return; - } - chatMessageRepository.save(ChatMessage.of(null, memberId, ChatTurn.MODEL, answer)); + // 중간에 끊긴 답변이나 빈 답변은 다음 질문의 이력에 섞이므로 남기지 않는다. 실패 원인은 클라이언트가 로그로 남긴다 + collector.completedAnswer() + .ifPresent(answer -> chatMessageRepository.save(ChatMessage.of(null, memberId, ChatTurn.MODEL, answer))); } - /** 최근 대화를 오래된 것부터 순서대로 돌려준다. */ + /** 최근 대화를 오래된 것부터 돌려준다. 저장소는 최신순으로 준다. */ private List loadRecentTurns(long memberId) { List recent = chatMessageRepository.findRecentByMemberId(memberId, HISTORY_LIMIT); - // 저장소는 최신순으로 준다. AI 서버는 대화 순서대로 받아야 하므로 뒤집는다. List turns = new ArrayList<>(recent.size() + 1); for (int i = recent.size() - 1; i >= 0; i--) { ChatMessage message = recent.get(i); @@ -108,54 +72,4 @@ private List loadRecentTurns(long memberId) { } return turns; } - - /** - * 조각을 그대로 흘려보내면서 동시에 답변 전체를 모은다. - *

- * 둘을 같이 하는 이유는, 통과만 시키면 저장할 답변이 남지 않고 모으기만 하면 스트리밍이 사라지기 때문이다. - */ - private static final class AnswerCollector implements ChatEventListener { - - private final ChatEventListener delegate; - private final StringBuilder collected = new StringBuilder(); - - /** {@code done}을 받았는지. {@code error}로 끝났으면 false로 남는다. */ - private boolean completed; - - private AnswerCollector(ChatEventListener delegate) { - this.delegate = delegate; - } - - @Override - public void onDelta(String text) { - collected.append(text); - delegate.onDelta(text); - } - - @Override - public void onBubble() { - // 말풍선 경계는 빈 줄로 이어 붙인다. 프론트가 화면에 보여주는 모양과 같아진다. - collected.append("\n\n"); - delegate.onBubble(); - } - - @Override - public void onDone() { - completed = true; - delegate.onDone(); - } - - @Override - public void onError(String message) { - delegate.onError(message); - } - - String answer() { - return collected.toString().strip(); - } - - boolean isCompleted() { - return completed; - } - } } From 4d389c8cca0725275d80125fffb1cebefa4f164c Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:20:15 +0900 Subject: [PATCH 12/14] =?UTF-8?q?refactor:=20=EC=B1=97=EB=B4=87=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B0=9D=EC=B2=B4=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=9D=84=20=EC=A0=95=EC=A0=81=20=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 ChatMessage를 of(null, ...) 대신 create(...)로 만든다 - ChatMessage를 ChatTurn으로 바꿀 때 생성자 대신 ChatTurn.from을 쓴다 - ChatTurn 검증을 validate~ 메서드로 뺐고, 쓰는 곳이 없는 model()을 지웠다 - 챗봇 코드만 long이던 memberId를 다른 코드처럼 Long으로 맞췄다 --- .../domain/chat/client/ChatClient.java | 15 +++----- .../domain/chat/domain/ChatMessage.java | 4 +++ .../welfare/domain/chat/domain/ChatTurn.java | 34 +++++++++++-------- .../repository/ChatMessageRepository.java | 11 ++---- .../domain/chat/service/ChatService.java | 6 ++-- .../chat/service/impl/ChatServiceImpl.java | 12 +++---- .../chat/streamai/StreamAiChatClient.java | 2 +- .../db/welfare/ChatMessageRepositoryImpl.java | 2 +- 8 files changed, 40 insertions(+), 46 deletions(-) diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/client/ChatClient.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/client/ChatClient.java index 939584c4..c446c33d 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/client/ChatClient.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/client/ChatClient.java @@ -5,22 +5,15 @@ /** * 답변을 만들어 주는 AI 서버. 구현체는 {@code infrastructure:client}에 둔다. - *

- * 인터페이스를 core에 두는 이유는 의존 방향 때문이다. 서비스가 구현체를 직접 알면 - * core가 infrastructure를 향하게 되어 Gradle 경계 검사에서 막힌다. */ public interface ChatClient { /** - * 대화 이력을 보내고 답변을 조각으로 받는다. 이 메서드는 스트림이 끝날 때까지 돌아오지 않는다. - *

- * 구현체는 {@code listener}의 {@code onDone} 또는 {@code onError}를 정확히 한 번 부른다. - * 둘 다 안 불리면 학생 쪽 연결이 끝나지 않고 계속 열려 있게 된다. + * 대화 이력을 보내고 답변을 조각으로 받는다. 스트림이 끝날 때까지 돌아오지 않는다. * * @param memberId 질문한 학생. AI 서버가 요청자를 식별하는 헤더로 쓴다 - * @param history 이번 질문까지 포함한 대화 전체. 마지막 턴이 이번 질문이다. - * AI 서버는 대화를 저장하지 않으므로 요청마다 통째로 보내야 한다. - * @param listener 받은 이벤트를 넘길 곳 + * @param history 이번 질문까지 포함한 대화 전체. AI 서버는 대화를 저장하지 않으므로 요청마다 통째로 보낸다 + * @param listener 받은 이벤트를 넘길 곳. 계약은 {@link ChatEventListener} 참고 */ - void stream(long memberId, List history, ChatEventListener listener); + void stream(Long memberId, List history, ChatEventListener listener); } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/domain/ChatMessage.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/domain/ChatMessage.java index 12de3390..1520b6da 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/domain/ChatMessage.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/domain/ChatMessage.java @@ -18,4 +18,8 @@ public class ChatMessage { public static ChatMessage of(Long id, Long memberId, String senderType, String content) { return new ChatMessage(id, memberId, senderType, content); } + + public static ChatMessage create(Long memberId, String senderType, String content) { + return new ChatMessage(null, memberId, senderType, content); + } } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/domain/ChatTurn.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/domain/ChatTurn.java index 6c1b27e9..fd39c706 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/domain/ChatTurn.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/domain/ChatTurn.java @@ -1,13 +1,9 @@ package kr.ac.kookmin.stream.welfare.domain.chat.domain; /** - * AI 서버에 보내는 대화 한 턴. 역할과 글만 담는다. + * AI 서버에 보내는 대화 한 턴. {@link ChatMessage}의 id·memberId는 AI 서버가 알 필요가 없어 역할과 글만 담는다. *

- * {@link ChatMessage}를 그대로 보내지 않는 이유는 그쪽에 id와 memberId가 들어 있어서다. - * AI 서버는 누가 물었는지 알 필요가 없고, 저장용 식별자를 외부로 내보낼 이유도 없다. - *

- * {@code role} 값은 AI 서버 계약과 {@code chat_messages.sender_type} 양쪽에서 같은 글자를 쓴다. - * 중간에 변환하면 한쪽만 고쳤을 때 조용히 어긋난다. + * {@code role} 값은 AI 서버 계약과 {@code chat_messages.sender_type}에 같은 글자로 쓴다. 중간에 변환하면 한쪽만 고쳤을 때 조용히 어긋난다. */ public record ChatTurn(String role, String text) { @@ -18,20 +14,28 @@ public record ChatTurn(String role, String text) { public static final String MODEL = "model"; public ChatTurn { - if (role == null || role.isBlank()) { - throw new IllegalArgumentException("role이 비어 있습니다."); - } - // AI 서버가 빈 글에 422를 돌려준다. 보내기 전에 막아 원인을 여기서 알 수 있게 한다. - if (text == null || text.isBlank()) { - throw new IllegalArgumentException("text가 비어 있습니다."); - } + validateRole(role); + validateText(text); + } + + public static ChatTurn from(ChatMessage message) { + return new ChatTurn(message.getSenderType(), message.getContent()); } public static ChatTurn user(String text) { return new ChatTurn(USER, text); } - public static ChatTurn model(String text) { - return new ChatTurn(MODEL, text); + private static void validateRole(String role) { + if (role == null || role.isBlank()) { + throw new IllegalArgumentException("role이 비어 있습니다."); + } + } + + // AI 서버가 빈 글에 422를 돌려준다. 보내기 전에 막아 원인을 여기서 알 수 있게 한다 + private static void validateText(String text) { + if (text == null || text.isBlank()) { + throw new IllegalArgumentException("text가 비어 있습니다."); + } } } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/repository/ChatMessageRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/repository/ChatMessageRepository.java index fa23d38f..b3a0a04b 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/repository/ChatMessageRepository.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/repository/ChatMessageRepository.java @@ -6,15 +6,10 @@ public interface ChatMessageRepository { /** - * 한 학생의 최근 대화를 최신순으로 가져온다. - *

- * AI 서버는 대화를 저장하지 않으므로 요청마다 이력을 통째로 보내야 한다. 전부 보내면 - * 학기가 갈수록 토큰 비용이 계속 늘어나므로 최근 것만 자른다. - *

- * 돌려주는 순서가 최신순인 이유는 "최근 N개"를 자르려면 그 방향으로 정렬해야 하기 때문이다. - * AI 서버에 보낼 때는 오래된 것부터 와야 하므로 쓰는 쪽에서 뒤집는다. + * 한 학생의 최근 대화를 최신순으로 가져온다. "최근 N개"를 자르려면 이 방향으로 정렬해야 하므로, + * 오래된 것부터 필요한 쪽(AI 서버에 보낼 때)은 쓰는 쪽에서 뒤집는다. */ - List findRecentByMemberId(long memberId, int limit); + List findRecentByMemberId(Long memberId, int limit); ChatMessage save(ChatMessage message); } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/ChatService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/ChatService.java index f0bf30c3..bbc33cdd 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/ChatService.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/ChatService.java @@ -6,11 +6,9 @@ public interface ChatService { /** * 질문을 AI 서버에 보내고 답변을 조각으로 흘려보낸다. 스트림이 끝날 때까지 돌아오지 않는다. - *

- * 질문과 답변을 {@code chat_messages}에 남긴다. 9/21 회의에서 문의기록을 화면에서 빼고 - * DB에만 남기기로 한 것이 이 저장이다. + * 질문과 답변은 {@code chat_messages}에 남긴다. * * @param listener 받은 이벤트를 넘길 곳. 보통 컨트롤러가 학생 쪽 연결에 그대로 쓴다 */ - void answer(long memberId, String question, ChatEventListener listener); + void answer(Long memberId, String question, ChatEventListener listener); } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java index 1364fc84..fd4e2587 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java @@ -32,7 +32,7 @@ class ChatServiceImpl implements ChatService { * 상태 코드로는 거절할 수 없다. */ @Override - public void answer(long memberId, String question, ChatEventListener listener) { + public void answer(Long memberId, String question, ChatEventListener listener) { if (!chatAnswerLock.tryAcquire(memberId)) { listener.onError(BUSY_MESSAGE); return; @@ -45,21 +45,21 @@ public void answer(long memberId, String question, ChatEventListener listener) { } } - private void answerInTurn(long memberId, String question, ChatEventListener listener) { + private void answerInTurn(Long memberId, String question, ChatEventListener listener) { List turns = loadRecentTurns(memberId); turns.add(ChatTurn.user(question)); - chatMessageRepository.save(ChatMessage.of(null, memberId, ChatTurn.USER, question)); + chatMessageRepository.save(ChatMessage.create(memberId, ChatTurn.USER, question)); AnswerCollector collector = AnswerCollector.from(listener); chatClient.stream(memberId, turns, collector); // 중간에 끊긴 답변이나 빈 답변은 다음 질문의 이력에 섞이므로 남기지 않는다. 실패 원인은 클라이언트가 로그로 남긴다 collector.completedAnswer() - .ifPresent(answer -> chatMessageRepository.save(ChatMessage.of(null, memberId, ChatTurn.MODEL, answer))); + .ifPresent(answer -> chatMessageRepository.save(ChatMessage.create(memberId, ChatTurn.MODEL, answer))); } /** 최근 대화를 오래된 것부터 돌려준다. 저장소는 최신순으로 준다. */ - private List loadRecentTurns(long memberId) { + private List loadRecentTurns(Long memberId) { List recent = chatMessageRepository.findRecentByMemberId(memberId, HISTORY_LIMIT); List turns = new ArrayList<>(recent.size() + 1); @@ -68,7 +68,7 @@ private List loadRecentTurns(long memberId) { if (message.getContent() == null || message.getContent().isBlank()) { continue; } - turns.add(new ChatTurn(message.getSenderType(), message.getContent())); + turns.add(ChatTurn.from(message)); } return turns; } diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatClient.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatClient.java index 69a58bfa..6815770d 100644 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatClient.java +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatClient.java @@ -43,7 +43,7 @@ public class StreamAiChatClient implements ChatClient { private final ObjectMapper objectMapper; @Override - public void stream(long memberId, List history, ChatEventListener listener) { + public void stream(Long memberId, List history, ChatEventListener listener) { TerminalGuard guard = TerminalGuard.from(listener); try { diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ChatMessageRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ChatMessageRepositoryImpl.java index 70f8f00f..77d86c7c 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ChatMessageRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ChatMessageRepositoryImpl.java @@ -14,7 +14,7 @@ public class ChatMessageRepositoryImpl implements ChatMessageRepository { private final ChatMessageJpaRepository chatMessageJpaRepository; @Override - public List findRecentByMemberId(long memberId, int limit) { + public List findRecentByMemberId(Long memberId, int limit) { return chatMessageJpaRepository .findByMemberIdOrderByIdDesc(memberId, PageRequest.of(0, limit)) .stream() From 7c88c1af6daf6d9f378ad11a73cc5616a8d35206 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:20:28 +0900 Subject: [PATCH 13/14] =?UTF-8?q?refactor:=20SSE=20=EC=93=B0=EA=B8=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSE 헤더 설정과 프레임 쓰기를 ChatSseWriter로 옮겨 컨트롤러에는 라우팅·바인딩과 서비스 호출만 남겼다. 응답 스트림에 직접 쓰고 조각마다 flush 하는 방식은 그대로다. delta·error 이벤트의 data는 Map.of 대신 ChatDeltaResponse·ChatErrorResponse record로 만든다. bubble·done은 빈 객체라 "{}"를 그대로 쓴다. --- .../api/app/welfare/chat/AppChatApi.java | 21 ++--- .../app/welfare/chat/AppChatController.java | 84 +------------------ .../api/app/welfare/chat/ChatSseWriter.java | 72 ++++++++++++++++ .../chat/response/ChatDeltaResponse.java | 4 + .../chat/response/ChatErrorResponse.java | 4 + 5 files changed, 91 insertions(+), 94 deletions(-) create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/ChatSseWriter.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/response/ChatDeltaResponse.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/response/ChatErrorResponse.java diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatApi.java index 0e181445..167bb0b1 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatApi.java @@ -2,40 +2,33 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import kr.ac.kookmin.stream.api.app.AppApiUser; -import kr.ac.kookmin.stream.api.app.welfare.chat.request.ChatMessageCreateRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import kr.ac.kookmin.stream.api.app.AppApiUser; +import kr.ac.kookmin.stream.api.app.welfare.chat.request.ChatMessageCreateRequest; /** * 학생 앱 챗봇 API의 문서 명세. 구현은 {@link AppChatController}가 맡는다. *

- * 이 API만 {@code ApiResponse} 껍데기를 쓰지 않는다. 껍데기는 완성된 응답 하나에 한 번 붙는 - * 구조인데, 답변은 조각으로 나눠 오므로 담을 자리가 없다. 조각마다 감싸면 껍데기가 수십 개가 되고 - * 조각마다 "요청에 성공했습니다"가 실려 나간다. - *

+ * 이 API만 {@code ApiResponse} 껍데기를 쓰지 않는다. 답변이 조각으로 나눠 오므로 한 번 감싸는 껍데기를 붙일 자리가 없다. * 그래서 에러가 두 갈래로 갈린다. *

- * 첫 바이트가 나간 뒤에는 상태 코드를 바꿀 수 없어 {@code GlobalExceptionHandler}가 잡지 못한다. */ @Tag(name = "챗봇", description = "학생 앱 챗봇 질문·답변") public interface AppChatApi { /** - * 질문을 보내고 답변을 SSE로 받는다. - *

- * 응답은 {@code text/event-stream}이고 이벤트는 네 종류다. + * 질문을 보내고 답변을 SSE로 받는다. 응답은 {@code text/event-stream}이고 이벤트는 네 종류다. *

      * event: delta   data: { "text": "부분 응답" }     0회 이상 반복
      * event: bubble  data: {}                          여기서부터 새 말풍선
      * event: done    data: {}                          정상 종료
      * event: error   data: { "message": "오류 안내" }   실패
      * 
- * 프론트는 텍스트에서 빈 줄을 직접 찾지 않는다. 스트림 조각이 줄바꿈 중간에서 끊기면 판단이 - * 흔들리므로 서버가 문단 경계마다 {@code bubble}을 보낸다. + * 조각이 줄바꿈 중간에서 끊길 수 있어 프론트가 빈 줄을 직접 찾지 않도록 서버가 문단 경계마다 {@code bubble}을 보낸다. */ @Operation(summary = "챗봇 질문 전송 (SSE)", description = "질문을 보내고 답변을 조각으로 받는다. 응답은 JSON 한 번이 아니라 " diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatController.java index 5960ae36..6cb3984a 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatController.java @@ -3,18 +3,13 @@ import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import java.io.IOException; -import java.io.OutputStream; import java.io.UncheckedIOException; -import java.nio.charset.StandardCharsets; -import java.util.Map; import kr.ac.kookmin.stream.api.app.AppApiUser; import kr.ac.kookmin.stream.api.app.welfare.chat.request.ChatMessageCreateRequest; -import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatEventListener; import kr.ac.kookmin.stream.welfare.domain.chat.service.ChatService; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -30,22 +25,8 @@ public class AppChatController implements AppChatApi { private static final Logger log = LoggerFactory.getLogger(AppChatController.class); private final ChatService chatService; - - /** 스프링 부트 4가 만들어 주는 매퍼(Jackson 3). 앱 전체가 쓰는 것과 같은 설정을 따른다. */ private final ObjectMapper objectMapper; - /** - * 응답 스트림에 직접 쓴다. {@code StreamingResponseBody}도 {@code SseEmitter}도 쓰지 않는다. - *

- * {@code StreamingResponseBody}로 먼저 만들었는데 조각이 흘러가지 않았다. 300ms 간격으로 - * 다섯 번 쓰고 매번 {@code flush()}해도 클라이언트는 한 번에 받았고, 조각을 4KB로 키우자 - * 8KB 경계에서만 끊겨 나갔다. 즉 {@code flush()}가 무시되고 톰캣 버퍼가 찰 때만 - * 나간다는 뜻이다. 같은 내용을 이 방식으로 쓰면 300ms 간격 그대로 도착한다(아래 측정값). - *

- * 대가가 있다. 비동기가 아니므로 답변이 끝날 때까지 요청 스레드를 쥐고 있는다. 톰캣 기본 - * 최대 스레드가 200이라 동시 대화 200건까지는 버티지만, 그보다 늘어나면 다른 요청이 밀린다. - * 지금 규모에서는 문제가 아니고, 늘어나면 {@code SseEmitter}를 다시 시험해 볼 자리다. - */ @Override @PostMapping(value = "/messages", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public void sendMessage( @@ -53,69 +34,12 @@ public void sendMessage( @Valid @RequestBody ChatMessageCreateRequest request, HttpServletResponse response ) throws IOException { - long memberId = apiUser.userId(); - - response.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE); - response.setCharacterEncoding(StandardCharsets.UTF_8.name()); - // 아래 두 헤더는 중간에 끼는 프록시가 응답을 모아두지 못하게 막는다. - // 모아두면 조각이 한 번에 도착해 스트리밍이 사라진다. AI 서버도 같은 헤더를 보낸다. - response.setHeader(HttpHeaders.CACHE_CONTROL, "no-cache"); - response.setHeader("X-Accel-Buffering", "no"); - - SseWriter writer = new SseWriter(response.getOutputStream(), objectMapper); + ChatSseWriter writer = ChatSseWriter.of(response, objectMapper); try { - chatService.answer(memberId, request.message(), writer); + chatService.answer(apiUser.userId(), request.message(), writer); } catch (UncheckedIOException e) { - // 학생이 화면을 닫으면 쓰기가 실패한다. 오류가 아니라 정상 종료다. - log.debug("학생이 연결을 끊어 중계를 멈춥니다. memberId={}", memberId); - } - } - - /** - * 받은 이벤트를 학생 쪽 연결에 SSE 형식으로 쓴다. - *

- * 형식은 {@code event: 이름}, {@code data: JSON}, 빈 줄 한 번이다. 조각마다 즉시 flush 한다. - * 모아두면 학생이 10초 동안 빈 화면을 본다. - */ - private static final class SseWriter implements ChatEventListener { - - private final OutputStream output; - private final ObjectMapper objectMapper; - - private SseWriter(OutputStream output, ObjectMapper objectMapper) { - this.output = output; - this.objectMapper = objectMapper; - } - - @Override - public void onDelta(String text) { - write("delta", Map.of("text", text)); - } - - @Override - public void onBubble() { - write("bubble", Map.of()); - } - - @Override - public void onDone() { - write("done", Map.of()); - } - - @Override - public void onError(String message) { - write("error", Map.of("message", message)); - } - - private void write(String event, Map data) { - try { - String frame = "event: " + event + "\ndata: " + objectMapper.writeValueAsString(data) + "\n\n"; - output.write(frame.getBytes(StandardCharsets.UTF_8)); - output.flush(); - } catch (IOException e) { - // 서비스까지 올려 보내 중계를 멈춘다. 받을 사람이 없는데 계속 만들 이유가 없다. - throw new UncheckedIOException(e); - } + // 학생이 화면을 닫으면 쓰기가 실패한다. 오류가 아니라 정상 종료다 + log.debug("학생이 연결을 끊어 중계를 멈춥니다. memberId={}", apiUser.userId()); } } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/ChatSseWriter.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/ChatSseWriter.java new file mode 100644 index 00000000..bc8d7343 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/ChatSseWriter.java @@ -0,0 +1,72 @@ +package kr.ac.kookmin.stream.api.app.welfare.chat; + +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import kr.ac.kookmin.stream.api.app.welfare.chat.response.ChatDeltaResponse; +import kr.ac.kookmin.stream.api.app.welfare.chat.response.ChatErrorResponse; +import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatEventListener; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import tools.jackson.databind.ObjectMapper; + +/** + * 받은 이벤트를 학생 쪽 연결에 SSE 형식({@code event: 이름}, {@code data: JSON}, 빈 줄)으로 쓰고 조각마다 바로 flush한다. + *

+ * 응답 스트림에 직접 쓴다. {@code StreamingResponseBody}로는 flush가 무시되어 톰캣 버퍼가 찰 때만 조각이 나갔다. + * 대신 답변이 끝날 때까지 요청 스레드를 쥐므로, 동시 대화가 톰캣 최대 스레드(기본 200)에 가까워지면 {@code SseEmitter}를 다시 시험한다. + */ +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +final class ChatSseWriter implements ChatEventListener { + + // bubble·done은 data가 빈 객체다. 필드 없는 record는 직렬화 설정에 따라 예외가 날 수 있어 그대로 쓴다 + private static final String EMPTY_DATA = "{}"; + + private final OutputStream output; + private final ObjectMapper objectMapper; + + /** 응답 헤더를 SSE로 정하고 출력 스트림을 연다. */ + static ChatSseWriter of(HttpServletResponse response, ObjectMapper objectMapper) throws IOException { + response.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + // 중간 프록시가 응답을 모아두면 조각이 한 번에 도착해 스트리밍이 사라진다. AI 서버도 같은 헤더를 보낸다 + response.setHeader(HttpHeaders.CACHE_CONTROL, "no-cache"); + response.setHeader("X-Accel-Buffering", "no"); + return new ChatSseWriter(response.getOutputStream(), objectMapper); + } + + @Override + public void onDelta(String text) { + write("delta", objectMapper.writeValueAsString(new ChatDeltaResponse(text))); + } + + @Override + public void onBubble() { + write("bubble", EMPTY_DATA); + } + + @Override + public void onDone() { + write("done", EMPTY_DATA); + } + + @Override + public void onError(String message) { + write("error", objectMapper.writeValueAsString(new ChatErrorResponse(message))); + } + + private void write(String event, String data) { + try { + String frame = "event: " + event + "\ndata: " + data + "\n\n"; + output.write(frame.getBytes(StandardCharsets.UTF_8)); + output.flush(); + } catch (IOException e) { + // 학생이 연결을 끊었다. 받을 사람이 없으니 서비스까지 올려 보내 중계를 멈춘다 + throw new UncheckedIOException(e); + } + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/response/ChatDeltaResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/response/ChatDeltaResponse.java new file mode 100644 index 00000000..dd4ccbc2 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/response/ChatDeltaResponse.java @@ -0,0 +1,4 @@ +package kr.ac.kookmin.stream.api.app.welfare.chat.response; + +/** SSE {@code delta} 이벤트의 data. 답변 조각 하나. */ +public record ChatDeltaResponse(String text) {} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/response/ChatErrorResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/response/ChatErrorResponse.java new file mode 100644 index 00000000..b474b915 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/response/ChatErrorResponse.java @@ -0,0 +1,4 @@ +package kr.ac.kookmin.stream.api.app.welfare.chat.response; + +/** SSE {@code error} 이벤트의 data. 학생에게 보여줄 안내문. */ +public record ChatErrorResponse(String message) {} From c96121bdee700c978a83fe6b8e45a5292eb753b7 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:20:39 +0900 Subject: [PATCH 14/14] =?UTF-8?q?fix:=20=EC=B1=97=EB=B4=87=20API=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=8B=A4=ED=8C=A8=EB=A5=BC=20ApiResponse?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=91=EB=8B=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 챗봇 API는 produces = text/event-stream이다. 이러면 핸들러 매핑이 만들 수 있는 응답 타입을 text/event-stream으로 요청에 남기고, 예외 처리 때도 이 값이 지워지지 않는다. 그래서 GlobalExceptionHandler가 돌려준 ApiResponse를 text/event-stream으로 쓸 컨버터를 찾지 못해 검증 실패 본문이 나가지 않는다. Spring 소스로 확인했고 실행 재현은 하지 않았다. 에러 응답의 Content-Type을 JSON으로 미리 정해 콘텐츠 협상을 건너뛰게 했다. 다른 API는 원래 JSON이라 바뀌는 게 없다. 이제 문서와 동작이 맞으므로 AppChatApi에 INVALID_INPUT을 문서화했다. --- .../stream/api/app/welfare/chat/AppChatApi.java | 3 +++ .../stream/api/common/GlobalExceptionHandler.java | 13 ++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatApi.java index 167bb0b1..38106037 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatApi.java @@ -6,6 +6,8 @@ import java.io.IOException; import kr.ac.kookmin.stream.api.app.AppApiUser; import kr.ac.kookmin.stream.api.app.welfare.chat.request.ChatMessageCreateRequest; +import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; +import kr.ac.kookmin.stream.common.CommonErrorCode; /** * 학생 앱 챗봇 API의 문서 명세. 구현은 {@link AppChatController}가 맡는다. @@ -34,6 +36,7 @@ public interface AppChatApi { description = "질문을 보내고 답변을 조각으로 받는다. 응답은 JSON 한 번이 아니라 " + "text/event-stream이며 delta·bubble·done·error 네 이벤트가 온다. " + "지난 대화는 서버가 DB에서 꺼내 함께 보내므로 요청에는 이번 질문만 담는다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) void sendMessage(AppApiUser apiUser, ChatMessageCreateRequest request, HttpServletResponse response) throws IOException; } diff --git a/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/GlobalExceptionHandler.java b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/GlobalExceptionHandler.java index 276fb937..7b66ad2f 100644 --- a/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/GlobalExceptionHandler.java +++ b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/GlobalExceptionHandler.java @@ -10,12 +10,18 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +/** + * 에러 응답은 항상 JSON으로 Content-Type을 미리 정한다. 미리 정해 두면 콘텐츠 협상을 건너뛴다. + * 그러지 않으면 {@code produces = text/event-stream}인 챗봇 API처럼 JSON을 만들지 않는 핸들러에서 + * {@code ApiResponse}를 쓸 컨버터를 찾지 못해 에러 본문이 나가지 않는다. + */ @RestControllerAdvice public class GlobalExceptionHandler { @@ -31,7 +37,9 @@ public ResponseEntity> handleBusiness(BusinessException e) { log.warn("[requestId={}, userId={}] ({}) {}", MDC.get(REQUEST_ID), MDC.get(USER_ID), e.getErrorCode().name(), e.getMessage()); } - return ResponseEntity.status(e.getErrorCode().status()).body(ApiResponse.error(e)); + return ResponseEntity.status(e.getErrorCode().status()) + .contentType(MediaType.APPLICATION_JSON) + .body(ApiResponse.error(e)); } @ExceptionHandler(MethodArgumentNotValidException.class) @@ -42,6 +50,7 @@ public ResponseEntity> handleValidation(MethodArgumentNotValid log.warn("[requestId={}, userId={}] (INVALID_INPUT) {}", MDC.get(REQUEST_ID), MDC.get(USER_ID), message); return ResponseEntity.status(CommonErrorCode.INVALID_INPUT.status()) + .contentType(MediaType.APPLICATION_JSON) .body(ApiResponse.error(CommonErrorCode.INVALID_INPUT.name(), message)); } @@ -50,6 +59,7 @@ public ResponseEntity> handleIllegalArgument(IllegalArgumentEx log.warn("[requestId={}, userId={}] (INVALID_INPUT) {}", MDC.get(REQUEST_ID), MDC.get(USER_ID), e.getMessage()); return ResponseEntity.status(CommonErrorCode.INVALID_INPUT.status()) + .contentType(MediaType.APPLICATION_JSON) .body(ApiResponse.error(CommonErrorCode.INVALID_INPUT.name(), e.getMessage())); } @@ -58,6 +68,7 @@ public ResponseEntity> handleInternal(Exception e) { log.error("[requestId={}, userId={}] {}", MDC.get(REQUEST_ID), MDC.get(USER_ID), e.getMessage(), e); return ResponseEntity.status(ErrorStatus.INTERNAL_SERVER_ERROR) + .contentType(MediaType.APPLICATION_JSON) .body(ApiResponse.error(CommonErrorCode.INTERNAL_SERVER_ERROR)); } }