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/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..38106037
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatApi.java
@@ -0,0 +1,42 @@
+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 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;
+import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode;
+import kr.ac.kookmin.stream.common.CommonErrorCode;
+
+/**
+ * 학생 앱 챗봇 API의 문서 명세. 구현은 {@link AppChatController}가 맡는다.
+ *
+ * 이 API만 {@code ApiResponse} 껍데기를 쓰지 않는다. 답변이 조각으로 나눠 오므로 한 번 감싸는 껍데기를 붙일 자리가 없다.
+ * 그래서 에러가 두 갈래로 갈린다.
+ *
+ * - 스트림 시작 전(검증·인증 실패) — 다른 API와 같은 {@code ApiResponse} 에러와 상태 코드
+ * - 스트림 시작 후(AI 서버 실패·타임아웃) — {@code event: error}. 첫 바이트가 나간 뒤에는 상태 코드를 바꿀 수 없다
+ *
+ */
+@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에서 꺼내 함께 보내므로 요청에는 이번 질문만 담는다.")
+ @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
+ 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
new file mode 100644
index 00000000..6cb3984a
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatController.java
@@ -0,0 +1,45 @@
+package kr.ac.kookmin.stream.api.app.welfare.chat;
+
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.Valid;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+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.service.ChatService;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.MediaType;
+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 tools.jackson.databind.ObjectMapper;
+
+@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;
+
+ @Override
+ @PostMapping(value = "/messages", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+ public void sendMessage(
+ AppApiUser apiUser,
+ @Valid @RequestBody ChatMessageCreateRequest request,
+ HttpServletResponse response
+ ) throws IOException {
+ ChatSseWriter writer = ChatSseWriter.of(response, objectMapper);
+ try {
+ chatService.answer(apiUser.userId(), request.message(), writer);
+ } catch (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/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
+) {}
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) {}
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));
}
}
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..c446c33d
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/client/ChatClient.java
@@ -0,0 +1,19 @@
+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}에 둔다.
+ */
+public interface ChatClient {
+
+ /**
+ * 대화 이력을 보내고 답변을 조각으로 받는다. 스트림이 끝날 때까지 돌아오지 않는다.
+ *
+ * @param memberId 질문한 학생. AI 서버가 요청자를 식별하는 헤더로 쓴다
+ * @param history 이번 질문까지 포함한 대화 전체. AI 서버는 대화를 저장하지 않으므로 요청마다 통째로 보낸다
+ * @param listener 받은 이벤트를 넘길 곳. 계약은 {@link ChatEventListener} 참고
+ */
+ 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..906dcdca
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/client/ChatEventListener.java
@@ -0,0 +1,27 @@
+package kr.ac.kookmin.stream.welfare.domain.chat.client;
+
+/**
+ * AI 서버가 보내는 답변 이벤트를 받는 쪽. 답변을 모아서 한 번에 넘기지 않고 조각마다 알려, 받는 즉시 학생에게 흘려보낼 수 있게 한다.
+ *
+ * 마지막에는 {@code onDone} 또는 {@code onError} 중 하나가 정확히 한 번 온다. {@link ChatClient} 구현체가 보장한다.
+ *
+ * 더 받을 수 없으면(학생이 연결을 끊는 등) 구현체는 unchecked 예외를 던져 중계를 멈출 수 있다.
+ * {@link ChatClient} 구현체는 이 예외를 AI 서버 실패로 다루지 않고 그대로 전파한다.
+ */
+public interface ChatEventListener {
+
+ /** 답변 조각. 여러 번 온다. 빈 줄은 들어 있지 않다. */
+ void onDelta(String text);
+
+ /** 여기서부터 새 말풍선. 첫 말풍선 앞과 마지막 말풍선 뒤에는 오지 않는다. */
+ void onBubble();
+
+ /** 정상 종료. */
+ void onDone();
+
+ /**
+ * 실패. 스트림이 시작된 뒤에는 상태 코드를 바꿀 수 없어 예외 대신 이 통로로 알린다.
+ * 상세 원인은 로그에만 남기므로 여기 오는 글은 학생에게 보여줄 안내문이다.
+ */
+ void onError(String message);
+}
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
new file mode 100644
index 00000000..fd39c706
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/domain/ChatTurn.java
@@ -0,0 +1,41 @@
+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 {
+ 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);
+ }
+
+ 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
new file mode 100644
index 00000000..b3a0a04b
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/repository/ChatMessageRepository.java
@@ -0,0 +1,15 @@
+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 {
+
+ /**
+ * 한 학생의 최근 대화를 최신순으로 가져온다. "최근 N개"를 자르려면 이 방향으로 정렬해야 하므로,
+ * 오래된 것부터 필요한 쪽(AI 서버에 보낼 때)은 쓰는 쪽에서 뒤집는다.
+ */
+ 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
new file mode 100644
index 00000000..bbc33cdd
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/ChatService.java
@@ -0,0 +1,14 @@
+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}에 남긴다.
+ *
+ * @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/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
new file mode 100644
index 00000000..fd4e2587
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java
@@ -0,0 +1,75 @@
+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 {
+
+ // 질문·답변 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;
+
+ /**
+ * {@code @Transactional}을 붙이지 않는다. 답변 생성이 10초쯤 걸려 그동안 DB 커넥션을 쥐면 챗봇과 무관한 화면까지 멈춘다.
+ * 저장은 각자 짧게 하고 스트리밍은 트랜잭션 밖에서 한다. 그래서 답변이 실패하면 질문만 남는다.
+ *
+ * 이미 답변 중인 회원의 질문은 {@code error} 이벤트로 거절하고 저장하지 않는다. 컨트롤러가 SSE 헤더를 먼저 정해 두어
+ * 상태 코드로는 거절할 수 없다.
+ */
+ @Override
+ public void answer(Long memberId, String question, ChatEventListener listener) {
+ if (!chatAnswerLock.tryAcquire(memberId)) {
+ listener.onError(BUSY_MESSAGE);
+ return;
+ }
+ // 학생이 연결을 끊으면 예외가 올라오므로 반드시 finally에서 푼다. 안 풀면 재시작 전까지 질문할 수 없다
+ try {
+ answerInTurn(memberId, question, listener);
+ } finally {
+ chatAnswerLock.release(memberId);
+ }
+ }
+
+ private void answerInTurn(Long memberId, String question, ChatEventListener listener) {
+ List turns = loadRecentTurns(memberId);
+ turns.add(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.create(memberId, ChatTurn.MODEL, answer)));
+ }
+
+ /** 최근 대화를 오래된 것부터 돌려준다. 저장소는 최신순으로 준다. */
+ private List loadRecentTurns(Long memberId) {
+ List recent = chatMessageRepository.findRecentByMemberId(memberId, HISTORY_LIMIT);
+
+ 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(ChatTurn.from(message));
+ }
+ return turns;
+ }
+}
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 831a31c7..5865b1ae 100644
--- a/infrastructure/client/build.gradle.kts
+++ b/infrastructure/client/build.gradle.kts
@@ -10,11 +10,13 @@ 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)
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
new file mode 100644
index 00000000..6815770d
--- /dev/null
+++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatClient.java
@@ -0,0 +1,108 @@
+package kr.ac.kookmin.stream.client.chat.streamai;
+
+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.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;
+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 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_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 = TerminalGuard.from(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(StreamAiChatRequest.from(history))
+ .exchange((request, response) -> {
+ if (response.getStatusCode().isError()) {
+ // 질문 원문은 남기지 않는다. 학생의 개인 사정이 섞여 들어올 수 있다
+ log.error("AI 서버가 오류를 돌려줬습니다. status={}", response.getStatusCode());
+ guard.onError(FALLBACK_ERROR_MESSAGE);
+ return null;
+ }
+ SseEventReader.read(response.getBody(), event -> dispatch(event, guard));
+ return null;
+ });
+ } catch (RestClientException e) {
+ // 리스너가 던진 예외(학생 연결 끊김)는 AI 서버 실패가 아니므로 잡지 않고 그대로 올려 보낸다
+ log.error("AI 서버 호출에 실패했습니다.", e);
+ guard.onError(FALLBACK_ERROR_MESSAGE);
+ }
+
+ // done도 error도 없이 스트림이 끊긴 경우. 이미 끝났으면 무시된다
+ guard.onError(FALLBACK_ERROR_MESSAGE);
+ }
+
+ private void dispatch(SseEvent event, TerminalGuard guard) {
+ switch (event.name()) {
+ case EVENT_DELTA -> {
+ String text = readField(event.data(), "text");
+ if (text != null && !text.isEmpty()) {
+ guard.onDelta(text);
+ }
+ }
+ case EVENT_BUBBLE -> guard.onBubble();
+ case EVENT_DONE -> guard.onDone();
+ case EVENT_ERROR -> {
+ String message = readField(event.data(), "message");
+ guard.onError(message == null || message.isBlank() ? FALLBACK_ERROR_MESSAGE : message);
+ }
+ // 계약에 없는 이벤트가 추가되어도 중계가 멈추지 않게 한다
+ default -> log.debug("모르는 SSE 이벤트를 건너뜁니다. event={}", event.name());
+ }
+ }
+
+ /** 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.asString();
+ } catch (JacksonException e) {
+ log.debug("SSE data를 읽지 못해 건너뜁니다. field={}", field);
+ return null;
+ }
+ }
+}
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/StreamAiClientConfig.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiClientConfig.java
new file mode 100644
index 00000000..172bf274
--- /dev/null
+++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiClientConfig.java
@@ -0,0 +1,37 @@
+package kr.ac.kookmin.stream.client.chat.streamai;
+
+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 빈 설정. SSE 응답을 흘러오는 대로 읽어야 하므로 응답을 모아두지 않는
+ * {@link SimpleClientHttpRequestFactory}를 쓴다.
+ */
+@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())
+ .requestFactory(requestFactory)
+ .build();
+ }
+}
diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiProperties.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiProperties.java
new file mode 100644
index 00000000..7455df22
--- /dev/null
+++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiProperties.java
@@ -0,0 +1,35 @@
+package kr.ac.kookmin.stream.client.chat.streamai;
+
+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) 연동 설정. internalToken은 AI 서버가 백엔드를 거친 요청인지 확인하는 값이라 환경 변수로만 받는다.
+ *
+ * readTimeout은 전체 답변 시간이 아니라 조각 하나를 기다리는 시간이다. 전체 길이에 맞춰 길게 잡으면 AI 서버가 멈췄을 때도 그만큼 기다린다.
+ */
+@ConfigurationProperties(prefix = "chat.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/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);
+ }
+}
diff --git a/infrastructure/client/src/main/resources/application-infrastructure-client.yml b/infrastructure/client/src/main/resources/application-infrastructure-client.yml
index 7526df14..fa79542b 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은 전체 답변 시간이 아니라 조각 사이 간격 기준으로 잡는다
+chat:
+ 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
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..77d86c7c
--- /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();
+ }
+}