Repository navigation
Conversation
답변을 조각으로 받아 넘기는 클라이언트를 만든다. 다른 외부 클라이언트와 달리 응답을 객체로 변환해 받지 않고 본문을 흘러오는 대로 읽는다. RestClient의 exchange는 변환하지 않고 응답을 그대로 넘겨주므로 그 안에서 입력 스트림을 직접 읽는다. 변환을 맡기면 전체가 도착할 때까지 기다려 스트리밍이 사라진다. - ChatClient 인터페이스는 core에 둔다. 서비스가 구현체를 알면 core가 infrastructure를 향하게 되어 Gradle 경계 검사에서 막힌다 - ChatEventListener로 delta·bubble·done·error 네 이벤트를 그대로 받는다 - TerminalGuard로 done/error가 정확히 한 번만 나가게 한다. 한 번도 안 나가면 학생 쪽 연결이 영원히 열려 있고, 두 번 나가면 예외가 난다 - 깨진 data 한 줄이 대화 전체를 끊지 않도록 JSON 파싱 예외는 삼키고 건너뛴다 - ChatTurn은 ChatMessage를 그대로 보내지 않기 위해 둔다. AI 서버는 누가 물었는지 알 필요가 없다 - read-timeout은 조각 하나를 기다리는 시간이다. 전체 응답 시간으로 잡으면 AI 서버가 멈췄을 때도 그만큼 기다린다 - 질문 원문은 로그에 남기지 않는다. 학생의 개인 사정이 섞여 들어올 수 있다
chat_messages 테이블은 V3에 있으나 저장소 계층이 비어 있었다. - findRecentByMemberId는 최신순으로 돌려준다. '최근 N개'를 자르려면 그 방향으로 정렬해야 한다. AI 서버에 보낼 때는 쓰는 쪽에서 뒤집는다 - message_id로 정렬해 idx_chat_messages_member_id를 그대로 쓴다. InnoDB가 세컨더리 인덱스 끝에 PK를 붙이므로 (member_id, message_id) 정렬이 인덱스로 커버된다. created_at으로 정렬하면 인덱스에 없어 filesort가 생긴다 - created_at은 insertable = false로 DB 기본값만 채워지고 같은 초에 여러 건이 들어올 수 있어 정렬 기준으로도 PK가 안전하다
중계와 저장을 함께 한다. 조각을 그냥 통과시키면 chat_messages에 남길 답변 전체가 없고, 모으기만 하면 스트리밍이 사라진다. AnswerCollector가 둘을 같이 한다. @transactional을 붙이지 않았다. 답변 생성이 10초쯤 걸리는데 트랜잭션으로 감싸면 그 시간 동안 DB 커넥션을 쥐고 있게 되고, 동시에 질문하는 학생이 몇 명만 되어도 커넥션 풀이 말라 챗봇과 무관한 화면까지 멈춘다. 저장은 각자 짧게 하고 긴 스트리밍은 트랜잭션 밖에서 한다. 그 대가로 질문만 남고 답변이 안 남는 경우가 생긴다. 생성이 실패한 경우이므로 기록으로는 그게 맞다 - 무엇을 물었는지는 남아야 나중에 오답을 찾을 수 있다. - 말풍선 경계는 빈 줄로 이어 붙여 저장한다. 프론트가 보여주는 모양과 같아진다 - 이력은 최근 10건. 전부 보내면 학기가 갈수록 토큰 비용이 늘어난다. 설정값으로 빼지 않은 이유는 이 저장소가 core 도메인 모듈에 설정 바인딩을 두지 않기 때문이다(@ConfigurationProperties는 gateway와 infrastructure에만 있다) - core 도메인 모듈에는 slf4j가 없어 로그를 남기지 않는다. 실패 로그는 클라이언트가 남긴다
POST /v1/app/chat/messages. 이 API만 ApiResponse 껍데기를 쓰지 않는다. 껍데기는 완성된 응답 하나에 한 번 붙는 구조인데 답변은 조각으로 나눠 오므로 담을 자리가 없다. 조각마다 감싸면 껍데기가 수십 개가 되고 조각마다 '요청에 성공했습니다'가 실려 나간다. 그래서 에러가 두 갈래로 갈린다. - 스트림 시작 전(검증·인증 실패): 기존대로 ApiResponse 에러와 상태 코드 - 스트림 시작 후(AI 서버 실패·타임아웃): event: error 첫 바이트가 나간 뒤에는 상태 코드를 바꿀 수 없어 GlobalExceptionHandler가 잡지 못한다. SseEmitter 대신 StreamingResponseBody를 썼다. SseEmitter는 먼저 돌려준 뒤 다른 스레드에서 이벤트를 넣는 방식이라 스레드 풀을 따로 관리해야 하고 완료 처리를 빠뜨리면 연결이 남는다. StreamingResponseBody는 스프링이 콜백을 비동기 스레드에서 돌려주고 콜백이 끝나면 연결도 닫힌다. SSE 형식은 직접 쓴다. AI 서버가 보낸 형식을 그대로 중계하는 일이라 다시 조립할 이유가 없다. - Cache-Control: no-cache, X-Accel-Buffering: no 를 붙여 중간 프록시가 응답을 모아두지 못하게 막는다. 모아두면 조각이 한 번에 도착해 스트리밍이 사라진다. AI 서버도 같은 헤더를 보낸다 - 조각마다 flush 한다. 모아두면 학생이 10초 동안 빈 화면을 본다 - 학생이 화면을 닫으면 쓰기가 실패하는데, 오류가 아니라 정상 종료로 처리한다 - 요청 본문은 이번 질문만 받는다. 지난 대화는 서버가 DB에서 꺼내 붙인다 - 질문 길이를 500자로 제한한다. 토큰 비용과 프롬프트 인젝션을 심을 여지를 줄인다
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configuration
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
StreamingResponseBody로 만들었는데 조각이 흘러가지 않았다. 실측으로 확인했다. 300ms 간격으로 다섯 번 쓰고 매번 flush() 해도 클라이언트는 한 번에 받았고, 조각을 4KB로 키우자 8KB 경계에서만 끊겨 나갔다(7969 / 8184 / 4064 바이트). flush()가 무시되고 톰캣 버퍼가 찰 때만 나간다는 뜻이다. 같은 내용을 response.getOutputStream()에 직접 쓰면 300ms 간격 그대로 도착한다. Spring Security 필터와 로깅 필터는 응답을 감싸지 않아 원인이 아니었고, ResponseEntity 제거로도 바뀌지 않았다. 실측 (같은 질문, 같은 측정 방법) - AI 서버 직접: 수신 12회, 0.85초에 걸쳐 퍼짐 - 백엔드 경유(수정 전): 수신 1회, 0.00초 - 백엔드 경유(수정 후): 수신 11회, 0.24초 대가로 비동기가 아니라 답변이 끝날 때까지 요청 스레드를 쥔다. 톰캣 기본 최대 스레드가 200이라 동시 대화 200건까지는 버틴다. 그보다 늘어나면 SseEmitter를 다시 시험할 자리다. 함께: Jackson 주입을 고쳤다. 스프링 부트 4가 만들어 주는 매퍼는 Jackson 3(tools.jackson)인데 com.fasterxml(Jackson 2) 타입으로 주입받아 기동이 실패했다. 컨트롤러는 Jackson 3 빈을 받고, 클라이언트는 이 모듈이 선언한 Jackson 2 인스턴스를 직접 만들어 쓴다.
실제로 띄워서 끝까지 확인했다 — 그리고 버그를 하나 잡았다로컬에 MySQL(도커)과 AI 서버(도커)를 띄우고 프론트 → 백엔드 → AI 서버 경로를 실제로 흘려봤다. 1.
|
| 경로 | 수신 횟수 | 퍼짐 |
|---|---|---|
| AI 서버 직접 | 12회 | 0.85초 |
| 백엔드 경유 (수정 전) | 1회 | 0.00초 |
| 백엔드 경유 (수정 후) | 11회 | 0.24초 |
같은 질문, 같은 측정 방법이다. 서버 내부 로그로는 694ms에 걸쳐 쓰고 있었는데 소켓으로는 한 번에 나갔다.
원인을 좁힌 과정
- 응답 헤더는 정상 (
Transfer-Encoding: chunked,text/event-stream) → 헤더 문제 아님 ResponseEntity제거 → 변화 없음- Spring Security 필터 제외 경로로 테스트 → 변화 없음
- 로깅 필터 3개 모두 응답을 감싸지 않음 (
ContentCachingResponseWrapper없음) → 필터 아님 - 300ms 간격으로 5번 쓰는 최소 실험 → 한 번에 도착. 중계 코드와 무관
- 조각을 4KB로 키우니 8KB 경계에서만 끊겨 나갔다 (7969 / 8184 / 4064 바이트)
6번이 결정적이었다. flush()가 무시되고 톰캣 버퍼가 찰 때만 나간다는 뜻이다.
같은 내용을 response.getOutputStream()에 직접 쓰면 300ms 간격 그대로 도착한다.
A: StreamingResponseBody 수신 1회 (버퍼링)
B: response.getOutputStream() 수신 6회 0.01 → 1.55s, 정확히 300ms 간격
그래서 B로 바꿨다. 대가가 있다 — 비동기가 아니라서 답변이 끝날 때까지 요청 스레드를 쥔다. 톰캣 기본 최대 스레드가 200이라 동시 대화 200건까지는 버티고, 그보다 늘어나면 SseEmitter를 다시 시험할 자리다. 지금 규모에서는 문제가 아니라고 봤는데, 더 나은 방법을 알면 알려주세요.
2. Jackson 주입이 틀려서 기동이 안 됐다
com.fasterxml.jackson.databind.ObjectMapper로 주입받았는데 빈이 없어 기동 실패했다.
스프링 부트 4가 만들어 주는 매퍼는 Jackson 3(tools.jackson) 이다. 컨트롤러는 Jackson 3 빈을 받고, 클라이언트는 그 모듈이 선언한 Jackson 2 인스턴스를 직접 만들어 쓰도록 고쳤다.
저장소에 ObjectMapper를 주입받는 코드가 아직 없어서 참고할 선례가 없었다. 버전 카탈로그에는 Jackson 2(jacksonDatabind)만 있는데 프레임워크 기본은 Jackson 3이라, 어느 쪽으로 통일할지 정해두면 좋겠다.
3. 확인한 것
/actuator/health 200 UP
Flyway 14개 마이그레이션 적용
POST /v1/app/chat/messages 200, SSE 11회 수신
chat_messages user / model 행 저장됨 (utf8mb4 정상)
말풍선 경계 \n\n 로 이어 붙어 저장됨
대화 이력이 실제로 작동한다. "빌릴게 어떻게 써요?" 다음에 "그럼 그거 몇 층이야?" 를 물으니 미래관 4층 학생회실에 있어요라고 답했다. 앞 답변을 참조한 것이다.
로컬에서 띄우는 방법 (참고)
저장소에 로컬 DB를 띄우는 수단이 없어서 이렇게 했다. compose 파일을 추가할지는 따로 정하자.
docker run -d --name stream-mysql -p 127.0.0.1:3307:3306 \
-e MYSQL_ROOT_PASSWORD=rootpw -e MYSQL_DATABASE=stream \
-e MYSQL_USER=stream -e MYSQL_PASSWORD=streampw mysql:8기동할 때 챗봇과 무관한 더미 값이 필요했다. R2_ENDPOINT가 비어 있으면 s3Client 빈 생성이 실패해 앱이 아예 안 뜬다(The URI scheme of endpointOverride must not be null). 로컬 개발 편의상 비어 있을 때 건너뛰게 하는 게 좋을지도 모르겠다 — 이건 내 범위가 아니라 건드리지 않았다.
There was a problem hiding this comment.
사용자가 개인 데이터를 물어볼 때는 어떻게 처리할지 궁금합니다 (ex 내 행사 신청 내역을 물어봤을때)
현재 chat이 welfare 안에 있어서 다른 도메인의 데이터를 조회하기 어려운데 백엔드에서 데이터를 넘겨줘야 한다면 app-api UseCase에서 필요한 데이터를 조회해 ChatContext record로 만들어 answer에 넘기는 방식으로 수정하면 어떨까요?
ai 서버가 직접 조회하는 구조라면 상관없겠지만 어떤 방식으로 하시는건지 확인하고싶습니다!
There was a problem hiding this comment.
좋은 질문 감사합니다! 개인 데이터는 AI 서버가 질문을 보고 필요할 때 백엔드 내부 API를 호출하는 구조로 가려고 합니다.
백엔드가 미리 조회해서 ChatContext로 넘기는 방식을 택하지 않은 이유는 다음과 같습니다.
- 백엔드는 질문만 보고 어떤 데이터가 필요한지 판단하기 어렵습니다. 그러면 "우산 어떻게 빌려요?" 같은 질문에도 신청 내역·대여·사물함을 전부 조회해 넘기게 되고, 그 데이터가 그대로 외부 LLM(Gemini)으로 나갑니다. 개인정보는 질문에 필요한 만큼만 보내려고 합니다.
- 개인 데이터가 필요 없는 질문에도 매번 조회 비용이 붙습니다.
말씀하신 "chat이 welfare 안에 있어서 다른 도메인을 조회하기 어렵다"는 문제는 이 구조에서도 풀립니다. 조회는 chat 서비스가 아니라 AI 서버 전용 내부 API(내부 토큰 검증·내부망 한정)가 맡게 됩니다.
회원 ID는 모델이 정하지 않고, 백엔드가 보낸 X-User-Id 헤더 값만 쓰도록 할 예정입니다. 그래야 다른 학생 정보가 조회되지 않습니다.
인증 헤더 계약을 먼저 확정해야 해서 이번 PR 범위에서는 빼고 별도 이슈로 진행하겠습니다.
| String answer = collector.answer(); | ||
| // 실패했거나 답변이 비었다. 빈 답변을 남기면 다음 질문의 이력에 빈 턴이 섞인다. | ||
| // 실패 원인은 클라이언트가 이미 로그에 남겼다. core 도메인 모듈에는 로거가 없다. | ||
| if (answer.isBlank()) { |
There was a problem hiding this comment.
주석에는 실패했거나 답변이 빈 것에 대해 처리한다고 써있는데, 코드는 isBlank()만 판단하는 것으로 보입니다. 실패 여부 판단도 같이 넣어서 빈 턴으로 처리하는 것이 어떨까요?
There was a problem hiding this comment.
좋은 지적 감사합니다! 맞습니다. 몇 조각 받은 뒤에 실패하면 텍스트가 비어 있지 않아서, 중간에 끊긴 답변이 저장되고 다음 질문의 이력에도 섞일 수 있었습니다.
done을 받았는지 표시하는 값을 두고, 정상 종료이면서 비어 있지 않을 때만 저장하도록 고쳤습니다. 클라이언트가 done과 error 중 하나만 보내도록 보장하고 있어서, error로 끝나면 저장하지 않습니다.
반영 커밋: 5e65cad
| chatMessageRepository.save(ChatMessage.of(null, memberId, ChatTurn.USER, question)); | ||
|
|
||
| AnswerCollector collector = new AnswerCollector(listener); | ||
| chatClient.stream(memberId, turns, collector); |
There was a problem hiding this comment.
현재는 연결 제한이 없어서 한 사람이 여러 개의 응답 세션(스트림)을 열 수 있어보이는데, 이러면 챗봇 로직 상에서 문제가 생길 수 있을 것 같습니다. 한 사람당 한 개의 연결만 허용하는 로직을 추가하면 어떨까요?
There was a problem hiding this comment.
맞습니다. 두 요청이 동시에 돌면 둘 다 같은 이력을 읽어 가고 저장 순서도 꼬일 수 있었습니다.
회원당 답변 스트림을 하나만 허용하도록 고쳤습니다.
- 지금 답변 중인 회원 ID를 서버 메모리(
ConcurrentHashMap.newKeySet())에 두고, 이미 답변 중이면event: error로 거절합니다. 이때 질문도 저장하지 않습니다. - 학생이 화면을 닫아 중간에 끊겨도
finally에서 반드시 풀어 줍니다.
409 대신 error 이벤트로 거절한 이유: 컨트롤러가 서비스를 부르기 전에 이미 text/event-stream 헤더를 정해 두어서, 그 뒤에 ApiResponse JSON 에러를 쓰면 제대로 나가지 않습니다. 프론트는 error 이벤트를 이미 처리하니 추가 작업도 없습니다.
한계: 메모리 기준이라 백엔드가 한 대일 때만 정확합니다. 여러 대로 늘리면 Redis 같은 공용 저장소로 옮겨야 합니다. 주석에도 남겨 두었습니다.
반영 커밋: a0326dc
|
|
#️⃣연관된 이슈
Closes #95
📝작업 내용
학생 앱 챗봇 대화창이 쓸
POST /v1/app/chat/messages를 만들었다.AI 서버(
billilge/stream-ai, FastAPI)가 답변을 SSE로 조각씩 보내므로 백엔드가 모아두지 않고 그대로 중계한다.chat_messages테이블과ChatMessage도메인 객체는 V3에 이미 있었고 그 위가 비어 있었다. 네 층을 채웠다.core:domain:welfareChatTurn,ChatClient/ChatEventListener,ChatMessageRepository,ChatService+implinfrastructure:clientStreamAiChatClient,StreamAiProperties,StreamAiClientConfiginfrastructure:dbChatMessageRepositoryImpl,ChatMessageJpaRepositoryapi:app-apiAppChatApi,AppChatController,ChatMessageCreateRequest1. 이 API만
ApiResponse껍데기를 쓰지 않는다 — 먼저 봐주세요껍데기는 완성된 응답 하나에 한 번 붙는 구조인데, 답변은 조각으로 나눠 오므로 담을 자리가 없다.
조각마다 감싸면 껍데기가 수십 개가 되고 조각마다
"요청에 성공했습니다"가 실려 나간다.그래서 에러가 두 갈래로 갈린다.
ApiResponse에러 + 상태 코드event: error첫 바이트가 나간 뒤에는 상태 코드를 바꿀 수 없어
GlobalExceptionHandler가 잡지 못한다.컨벤션에 없던 패턴이라 이 결정만 따로 봐주시면 좋겠다.
2.
SseEmitter대신StreamingResponseBodySseEmitter는 먼저 돌려준 뒤 다른 스레드에서 이벤트를 넣는 방식이라 스레드 풀을 따로 관리해야 하고, 완료 처리를 빠뜨리면 연결이 남는다.StreamingResponseBody는 스프링이 콜백을 비동기 스레드에서 돌려주고, 콜백이 끝나면 연결도 닫힌다.SSE 형식(
event:/data:/ 빈 줄)은 직접 쓴다. AI 서버가 보낸 형식을 그대로 중계하는 일이라 프레임워크가 다시 조립할 이유가 없다.3.
@Transactional을 붙이지 않았다답변 생성이 10초쯤 걸린다. 트랜잭션으로 감싸면 그 시간 동안 DB 커넥션을 쥐고 있게 되고, 동시에 질문하는 학생이 몇 명만 되어도 커넥션 풀이 말라 챗봇과 무관한 화면까지 멈춘다.
저장은 각자 짧게 하고 긴 스트리밍은 트랜잭션 밖에서 한다.
그 대가로 질문만 남고 답변이 안 남는 경우가 생긴다. 생성이 실패한 경우이므로 기록으로는 그게 맞다고 봤다 — 무엇을 물었는지는 남아야 나중에 오답을 찾을 수 있다.
4. 중계와 저장을 같이 한다
조각을 그냥 통과시키면 저장할 답변 전체가 없고, 모으기만 하면 스트리밍이 사라진다.
AnswerCollector가 흘려보내면서 동시에 모은다.말풍선 경계는 빈 줄로 이어 붙여 저장해 프론트가 보여주는 모양과 같게 만든다.
5. 연결이 끝나는 걸 두 겹으로 보장한다
TerminalGuard—done/error가 정확히 한 번만 나간다. 한 번도 안 나가면 학생 쪽 연결이 영원히 열려 있고, 두 번 나가면 예외가 난다. 스트림이done없이 끊긴 경우도error로 닫는다6. 설정
read-timeout은 조각 하나를 기다리는 시간이고 전체 응답 시간이 아니다. 전체 길이에 맞춰 길게 잡으면 AI 서버가 멈췄을 때도 그만큼 기다린다.internal-token은 AI 서버가 "백엔드를 거친 요청"만 처리하기 위해 확인하는 값이다. 아직 AI 서버 쪽 검증이 미구현이라 보내기만 한다.infrastructure:client에core:domain:welfare의존성을 추가했다. 없으면 모듈 경계 때문에 컴파일되지 않는다.확인한 것
ChatServiceImpl은 package-private으로service/impl에 뒀다.아직 실제로 돌려보지는 않았다. 로컬에 MySQL을 띄우는 수단이 저장소에 없어서 컴파일과 경계 검사까지만 확인했다. 실연동 확인은 AI 서버를 도커로 띄운 상태에서 함께 해야 한다.
💬리뷰 요구사항(선택)
ApiResponse미사용)이 제일 중요하다. 다른 선택지로 (a) 조각마다 껍데기를 씌우기 (b) 스트리밍을 포기하고 한 번에 주기가 있었는데, (a)는 실익이 없고 (b)는 학생이 10초 빈 화면을 봐서 제외했다. 더 나은 방법이 있으면 알려주세요@ConfigurationProperties를 쓰려면core:domain:welfare에 spring-boot 의존성을 넣어야 하는데, 지금 어떤 core 모듈에도 없어서 의도된 경계로 봤다. 조절이 필요해지면StreamAiProperties로 옮기고 클라이언트가 자르는 쪽이 맞을까core도메인 모듈에 slf4j가 없어 서비스에서 로그를 못 남겼다. 다른 서비스들도 안 남기는 것 같은데 맞나/chat/messages로 적혀 있는데 기존 규칙(/v1/app/**)에 맞춰/v1/app/chat/messages로 했다. 명세는 내가 고치겠다