Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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}가 맡는다.
* <p>
* <b>이 API만 {@code ApiResponse} 껍데기를 쓰지 않는다.</b> 껍데기는 완성된 응답 하나에 한 번 붙는
* 구조인데, 답변은 조각으로 나눠 오므로 담을 자리가 없다. 조각마다 감싸면 껍데기가 수십 개가 되고
* 조각마다 "요청에 성공했습니다"가 실려 나간다.
* <p>
* 그래서 에러가 두 갈래로 갈린다.
* <ul>
* <li>스트림 시작 <b>전</b>(검증·인증 실패) — 기존대로 {@code ApiResponse} 에러와 상태 코드</li>
* <li>스트림 시작 <b>후</b>(AI 서버 실패·타임아웃) — {@code event: error}</li>
* </ul>
* 첫 바이트가 나간 뒤에는 상태 코드를 바꿀 수 없어 {@code GlobalExceptionHandler}가 잡지 못한다.
*/
@Tag(name = "챗봇", description = "학생 앱 챗봇 질문·답변")
public interface AppChatApi {

/**
* 질문을 보내고 답변을 SSE로 받는다.
* <p>
* 응답은 {@code text/event-stream}이고 이벤트는 네 종류다.
* <pre>
* event: delta data: { "text": "부분 응답" } 0회 이상 반복
* event: bubble data: {} 여기서부터 새 말풍선
* event: done data: {} 정상 종료
* event: error data: { "message": "오류 안내" } 실패
* </pre>
* 프론트는 텍스트에서 빈 줄을 직접 찾지 않는다. 스트림 조각이 줄바꿈 중간에서 끊기면 판단이
* 흔들리므로 서버가 문단 경계마다 {@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;
}
Original file line number Diff line number Diff line change
@@ -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;

/**
* 응답 스트림에 <b>직접</b> 쓴다. {@code StreamingResponseBody}도 {@code SseEmitter}도 쓰지 않는다.
* <p>
* {@code StreamingResponseBody}로 먼저 만들었는데 조각이 흘러가지 않았다. 300ms 간격으로
* 다섯 번 쓰고 매번 {@code flush()}해도 클라이언트는 한 번에 받았고, 조각을 4KB로 키우자
* <b>8KB 경계에서만</b> 끊겨 나갔다. 즉 {@code flush()}가 무시되고 톰캣 버퍼가 찰 때만
* 나간다는 뜻이다. 같은 내용을 이 방식으로 쓰면 300ms 간격 그대로 도착한다(아래 측정값).
* <p>
* 대가가 있다. 비동기가 아니므로 답변이 끝날 때까지 요청 스레드를 쥐고 있는다. 톰캣 기본
* 최대 스레드가 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 형식으로 쓴다.
* <p>
* 형식은 {@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<String, ?> 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);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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에서 꺼내 붙이므로 이번 질문만 받는다.
* <p>
* 길이를 제한하는 이유는 두 가지다. 긴 글을 그대로 넘기면 토큰 비용이 그만큼 늘고,
* 프롬프트 인젝션을 길게 심을 여지를 줄인다.
*/
public record ChatMessageCreateRequest(
@Schema(description = "학생이 입력한 질문", example = "빌릴게 어떻게 써요?")
@NotBlank(message = "질문을 입력해주세요.")
@Size(max = 500, message = "질문은 500자까지 입력할 수 있습니다.")
String message
) {}
Original file line number Diff line number Diff line change
@@ -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}에 둔다.
* <p>
* 인터페이스를 core에 두는 이유는 의존 방향 때문이다. 서비스가 구현체를 직접 알면
* core가 infrastructure를 향하게 되어 Gradle 경계 검사에서 막힌다.
*/
public interface ChatClient {

/**
* 대화 이력을 보내고 답변을 조각으로 받는다. 이 메서드는 스트림이 끝날 때까지 돌아오지 않는다.
* <p>
* 구현체는 {@code listener}의 {@code onDone} 또는 {@code onError}를 <b>정확히 한 번</b> 부른다.
* 둘 다 안 불리면 학생 쪽 연결이 끝나지 않고 계속 열려 있게 된다.
*
* @param memberId 질문한 학생. AI 서버가 요청자를 식별하는 헤더로 쓴다
* @param history 이번 질문까지 포함한 대화 전체. 마지막 턴이 이번 질문이다.
* AI 서버는 대화를 저장하지 않으므로 요청마다 통째로 보내야 한다.
* @param listener 받은 이벤트를 넘길 곳
*/
void stream(long memberId, List<ChatTurn> history, ChatEventListener listener);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package kr.ac.kookmin.stream.welfare.domain.chat.client;

/**
* AI 서버가 보내는 SSE 이벤트를 받는 쪽. 이벤트 네 종류를 그대로 메서드로 둔다.
* <p>
* 답변 전체를 모아서 한 번에 넘기지 않고 조각마다 알리는 이유는, 중계하는 쪽이 받는 즉시
* 학생에게 흘려보내야 하기 때문이다. 모아두면 학생이 10초 동안 빈 화면을 본다.
* <p>
* 구현하는 쪽은 조각을 흘려보내면서 동시에 모아야 한다. 모으지 않으면
* {@code chat_messages}에 저장할 답변 전체가 남지 않는다.
*/
public interface ChatEventListener {

/** 답변 조각. 여러 번 온다. 빈 줄은 들어 있지 않다. */
void onDelta(String text);

/** 여기서부터 새 말풍선. 첫 말풍선 앞과 마지막 말풍선 뒤에는 오지 않는다. */
void onBubble();

/** 정상 종료. */
void onDone();

/**
* 실패. 상세 원인은 AI 서버 로그에만 남으므로 여기 오는 글은 학생에게 보여줄 안내문이다.
* <p>
* 스트림이 시작된 뒤의 실패는 상태 코드를 바꿀 수 없어 예외로 올릴 수 없다.
* 그래서 성공 경로와 같은 통로로 알린다.
*/
void onError(String message);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package kr.ac.kookmin.stream.welfare.domain.chat.domain;

/**
* AI 서버에 보내는 대화 한 턴. 역할과 글만 담는다.
* <p>
* {@link ChatMessage}를 그대로 보내지 않는 이유는 그쪽에 id와 memberId가 들어 있어서다.
* AI 서버는 누가 물었는지 알 필요가 없고, 저장용 식별자를 외부로 내보낼 이유도 없다.
* <p>
* {@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);
}
}
Original file line number Diff line number Diff line change
@@ -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 {

/**
* 한 학생의 최근 대화를 <b>최신순</b>으로 가져온다.
* <p>
* AI 서버는 대화를 저장하지 않으므로 요청마다 이력을 통째로 보내야 한다. 전부 보내면
* 학기가 갈수록 토큰 비용이 계속 늘어나므로 최근 것만 자른다.
* <p>
* 돌려주는 순서가 최신순인 이유는 "최근 N개"를 자르려면 그 방향으로 정렬해야 하기 때문이다.
* AI 서버에 보낼 때는 오래된 것부터 와야 하므로 쓰는 쪽에서 뒤집는다.
*/
List<ChatMessage> findRecentByMemberId(long memberId, int limit);

ChatMessage save(ChatMessage message);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사용자가 개인 데이터를 물어볼 때는 어떻게 처리할지 궁금합니다 (ex 내 행사 신청 내역을 물어봤을때)
현재 chat이 welfare 안에 있어서 다른 도메인의 데이터를 조회하기 어려운데 백엔드에서 데이터를 넘겨줘야 한다면 app-api UseCase에서 필요한 데이터를 조회해 ChatContext record로 만들어 answer에 넘기는 방식으로 수정하면 어떨까요?
ai 서버가 직접 조회하는 구조라면 상관없겠지만 어떤 방식으로 하시는건지 확인하고싶습니다!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋은 질문 감사합니다! 개인 데이터는 AI 서버가 질문을 보고 필요할 때 백엔드 내부 API를 호출하는 구조로 가려고 합니다.

백엔드가 미리 조회해서 ChatContext로 넘기는 방식을 택하지 않은 이유는 다음과 같습니다.

  • 백엔드는 질문만 보고 어떤 데이터가 필요한지 판단하기 어렵습니다. 그러면 "우산 어떻게 빌려요?" 같은 질문에도 신청 내역·대여·사물함을 전부 조회해 넘기게 되고, 그 데이터가 그대로 외부 LLM(Gemini)으로 나갑니다. 개인정보는 질문에 필요한 만큼만 보내려고 합니다.
  • 개인 데이터가 필요 없는 질문에도 매번 조회 비용이 붙습니다.

말씀하신 "chat이 welfare 안에 있어서 다른 도메인을 조회하기 어렵다"는 문제는 이 구조에서도 풀립니다. 조회는 chat 서비스가 아니라 AI 서버 전용 내부 API(내부 토큰 검증·내부망 한정)가 맡게 됩니다.
회원 ID는 모델이 정하지 않고, 백엔드가 보낸 X-User-Id 헤더 값만 쓰도록 할 예정입니다. 그래야 다른 학생 정보가 조회되지 않습니다.

인증 헤더 계약을 먼저 확정해야 해서 이번 PR 범위에서는 빼고 별도 이슈로 진행하겠습니다.

Original file line number Diff line number Diff line change
@@ -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 서버에 보내고 답변을 조각으로 흘려보낸다. 스트림이 끝날 때까지 돌아오지 않는다.
* <p>
* 질문과 답변을 {@code chat_messages}에 남긴다. 9/21 회의에서 문의기록을 화면에서 빼고
* DB에만 남기기로 한 것이 이 저장이다.
*
* @param listener 받은 이벤트를 넘길 곳. 보통 컨트롤러가 학생 쪽 연결에 그대로 쓴다
*/
void answer(long memberId, String question, ChatEventListener listener);
}
Loading