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..0e181445 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatApi.java @@ -0,0 +1,46 @@ +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 jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * 학생 앱 챗봇 API의 문서 명세. 구현은 {@link AppChatController}가 맡는다. + *
+ * 이 API만 {@code ApiResponse} 껍데기를 쓰지 않는다. 껍데기는 완성된 응답 하나에 한 번 붙는 + * 구조인데, 답변은 조각으로 나눠 오므로 담을 자리가 없다. 조각마다 감싸면 껍데기가 수십 개가 되고 + * 조각마다 "요청에 성공했습니다"가 실려 나간다. + *
+ * 그래서 에러가 두 갈래로 갈린다. + *
+ * 응답은 {@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에서 꺼내 함께 보내므로 요청에는 이번 질문만 담는다.")
+ 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..5960ae36
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatController.java
@@ -0,0 +1,121 @@
+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.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;
+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;
+
+ /** 스프링 부트 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( + AppApiUser apiUser, + @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); + try { + chatService.answer(memberId, 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
+ * 길이를 제한하는 이유는 두 가지다. 긴 글을 그대로 넘기면 토큰 비용이 그만큼 늘고,
+ * 프롬프트 인젝션을 길게 심을 여지를 줄인다.
+ */
+public record ChatMessageCreateRequest(
+ @Schema(description = "학생이 입력한 질문", example = "빌릴게 어떻게 써요?")
+ @NotBlank(message = "질문을 입력해주세요.")
+ @Size(max = 500, message = "질문은 500자까지 입력할 수 있습니다.")
+ String message
+) {}
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
+ * 답변 전체를 모아서 한 번에 넘기지 않고 조각마다 알리는 이유는, 중계하는 쪽이 받는 즉시
+ * 학생에게 흘려보내야 하기 때문이다. 모아두면 학생이 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/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
+ * 질문과 답변을 {@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..ea5a682c
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java
@@ -0,0 +1,161 @@
+package kr.ac.kookmin.stream.welfare.domain.chat.service.impl;
+
+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;
+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 static final String BUSY_MESSAGE = "이전 답변이 아직 끝나지 않았습니다. 답변이 끝난 뒤에 다시 질문해 주세요.";
+
+ private final ChatMessageRepository chatMessageRepository;
+ private final ChatClient chatClient;
+
+ /**
+ * 지금 답변을 받고 있는 회원. 한 사람당 답변 하나만 진행한다.
+ *
+ * 둘이 동시에 돌면 두 요청이 같은 이력을 읽어 가고, 저장 순서가 꼬여 다음 질문의 이력이 뒤섞인다.
+ *
+ * 서버 메모리에 두므로 백엔드가 한 대일 때만 정확하다. 여러 대로 늘리면 Redis 같은
+ * 공용 저장소로 옮겨야 한다.
+ */
+ private final Set
+ * 답변 생성이 10초쯤 걸리는데 트랜잭션으로 감싸면 그 시간 동안 DB 커넥션을 쥐고 있게 된다.
+ * 동시에 질문하는 학생이 몇 명만 되어도 커넥션 풀이 말라 챗봇과 무관한 화면까지 멈춘다.
+ * 그래서 저장은 각자 짧게 하고, 긴 스트리밍은 트랜잭션 밖에서 한다.
+ *
+ * 그 대가로 질문만 남고 답변이 안 남는 경우가 생길 수 있다. 답변 생성이 실패한 경우이므로
+ * 기록으로는 그게 맞다 — 무엇을 물었는지는 남아야 나중에 오답을 찾을 수 있다.
+ *
+ * 이미 답변 중인 회원이 또 질문하면 {@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
+ * 둘을 같이 하는 이유는, 통과만 시키면 저장할 답변이 남지 않고 모으기만 하면 스트리밍이 사라지기 때문이다.
+ */
+ 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;
+ }
+ }
+}
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..e074cd4d
--- /dev/null
+++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/StreamAiChatClient.java
@@ -0,0 +1,234 @@
+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;
+
+ /**
+ * 빈을 주입받지 않고 직접 만든다. 스프링이 만들어 주는 매퍼는 Jackson 3(tools.jackson)인데
+ * 이 모듈이 선언한 의존성은 Jackson 2(com.fasterxml)다. 여기서 하는 일은 고정된 모양의
+ * JSON 한 줄에서 필드 하나를 꺼내는 것뿐이라 앱의 직렬화 설정과 무관하다.
+ */
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ @Override
+ public void stream(long memberId, List