From 6b7240f9b6584118d90e43a169834dcb5386c9b4 Mon Sep 17 00:00:00 2001 From: overhyeon Date: Mon, 5 Oct 2026 00:37:45 +0900 Subject: [PATCH 1/7] =?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 2/7] =?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 3/7] =?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 4/7] =?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 5/7] =?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 6/7] =?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 7/7] =?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));