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 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에서 꺼내 함께 보내므로 요청에는 이번 질문만 담는다.") + 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 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 +) {} 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/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/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..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 answeringMembers = ConcurrentHashMap.newKeySet(); + + /** + * 이 메서드에 {@code @Transactional}을 붙이지 않는다. + *

+ * 답변 생성이 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 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 (!collector.isCompleted() || 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(); + + /** {@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 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 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(); + } +}