Skip to content

[GMSS-124] feat: 대화 컨텍스트 압축본 전송 및 채팅 메시지·대댓글 연동 - #14

Merged
seunghee17 merged 40 commits into
developfrom
feature/chat-messaging
Aug 10, 2026
Merged

[GMSS-124] feat: 대화 컨텍스트 압축본 전송 및 채팅 메시지·대댓글 연동#14
seunghee17 merged 40 commits into
developfrom
feature/chat-messaging

Conversation

@soyeonLee126

@soyeonLee126 soyeonLee126 commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

작업 개요

대화가 길어져도 서버가 캐릭터 답장을 만들 수 있도록, 클라이언트가 대화 컨텍스트 압축본을 만들어
메시지 전송에 함께 보냅니다. 이 PR의 목적은 그 압축본(currentConversationSummary) 전송을 실제로
태워 보는 것입니다. 압축본을 만들려면 대화 자체가 필요해서 채팅 전송과 대댓글 경로도 함께 들어 있습니다.

압축본은 서버에 저장되지 않고 답장 생성 컨텍스트로만 쓰입니다. 상한은 2000자입니다.

압축 전략은 첫 발화 원문 + 확정 청크들 + 최근 3발화 원문입니다. 첫 발화는 대화 주제를, 최근 3발화는
답장이 직접 이어받는 맥락을 붙들고 있어 원문으로 남깁니다. 그 사이만 온디바이스 요약기(kobart)로
압축하고, 한 번 확정한 청크는 다시 요약하지 않습니다. 청크는 요약기 인코더 입력 한계인 512 토큰에
맞춰 끊습니다.

대화 종료와 감정 카드 생성은 diff가 커져서 별도 PR로 분리했습니다(#15).

리뷰 가이드

파일이 60개라 묶어서 보시는 편이 빠릅니다. 아래 순서를 권합니다.

1. 압축본 (이 PR의 핵심, 여기만 보셔도 됩니다)

domain/conversation/ConversationSummaryPolicy.kt에 정책 상수 세 개가 있습니다.
domain/conversation/ConversationSummaryStore.kt가 압축본을 증분으로 만듭니다.
domain/conversation/ConversationSession.kt가 "서버에 확정된 발화만 미러링한다"는 순서 규칙을 갖습니다.
domain/summary/UtteranceTokenCounter.ktdata/summary/KobartTokenCounter.kt가 청크 경계를 잽니다.
domain/test/.../ConversationSummaryStoreTest.kt가 위 정책을 전부 고정합니다.

2. 전송 계약

domain/conversation/ConversationRepository, SendMessageUseCase, SentMessage
data/remote/conversation/의 요청·응답 DTO입니다. data/remote/GamssJson.ktencodeDefaults
설정이 "필드 생략 = 새 채팅방"을 성립시키는 지점이라 함께 보시면 좋습니다.

3. 화면

feature/chat/ChatRoomViewModel, ChatRoomState, ChatRoomScreen입니다.
domain/conversation/CommentRevealPolicy.kt가 순차 노출 간격 정책이고,
feature/chat/test/.../ChatRoomRevealTest.kt가 노출 순서와 압축본 적재를 검증합니다.

4. 앱 배선과 빌드 게이팅

core/common/BuildInfo.kt, app/di/BuildInfoModule.kt, app/navigation/TopLevelDestination.kt,
app/main/MainScreen.kt, app/build.gradle.kts입니다. 디버그 판별이 한 곳으로 모이는 구조입니다.

5. 용량 (내용 확인 불필요)

data/src/debug/assets/models/ 아래 kobart 모델 3개는 경로만 옮긴 것이라 diff를 열지 않으셔도 됩니다.
data/build.gradle.ktsapp/build.gradle.kts의 packaging 설정만 보시면 됩니다.

6. 그 외

감정 집계를 순수 함수로 분리한 domain/emotion/EmotionScoreAggregation.kt, AppResult의 취소 전파
수정, 테스트 fixture 정리입니다.

작업 유형

  • feat: 새 기능
  • fix: 버그 수정
  • refactor: 리팩토링 (동작 변경 없음)
  • build: 빌드/의존성 설정
  • chore: 기타 (문서, 설정 등)
  • test: 테스트 추가/수정

변경 사항

압축본

ConversationSummaryStore가 대화가 진행되는 동안 압축본을 증분으로 만듭니다. 청크 요약을 캐시해
같은 텍스트를 두 번 요약하지 않습니다.

ConversationSession이 조회 시 초기화, 전송 성공 시에만 적재, 전송 시 압축본 동봉을 갖습니다.
이 규칙이 ViewModel에 흩어져 있으면 진입점이 늘 때마다 복제됩니다.

SaveMessageRequest.currentConversationSummary를 추가했습니다. encodeDefaults = false라 값이
없으면 필드가 아예 빠집니다. conversationId 생략은 새 채팅방 생성을 뜻합니다.

채팅 전송과 대댓글

POST /api/conversations/messagesGET /api/conversations/{id}/messages를 연동했습니다.

캐릭터 댓글은 1~3초 랜덤 간격으로 순차 노출합니다. 한 번에 다 뿌리면 채팅 느낌이 없어서 정책으로
뒀고, 첫 댓글은 서버 왕복이 곧 대기 시간이라 즉시 붙입니다. 노출 중 새로 전송하면 남은 댓글을
한꺼번에 붙이고 시작합니다.

성능

요약을 스토어 락 밖으로 뺐습니다. 기존에는 요약이 상태 락 안에서 돌아, 청크가 닫히는 전송마다 다음
전송의 압축본 조회가 요약이 끝날 때까지 대기했습니다.

재진입 복원이 서버 히스토리 전체를 청킹해 발화 수에 비례한 요약을 연속 실행하던 문제를 고쳤습니다.
발화 70개면 진입만으로 요약이 약 10회 돌았습니다. restore()는 중간 발화를 원문 청크로 두고 상한은
길이로 맞춥니다.

토큰 계산을 배치로 묶었습니다. 발화마다 디스패치와 락을 잡던 것을 한 번에 처리합니다.

LiteRT가 끌고 오는 ai-delivery를 제외했습니다. 모델을 assets에 직접 넣으므로 AI Pack 배포 경로를
쓰지 않는데, 그것 때문에 콜드스타트마다 WorkManagerInitializer가 돌고 있었습니다.

용량

release APK를 339.66 MiB에서 171.51 MiB로 줄였습니다.

kobart 요약 모델 asset 3개를 data/src/debug/assets로 옮겼습니다. 요약 체인은 카드 생성 경로에서만
쓰이는데 카드가 별도 PR로 빠지면서 release 소비자가 없어졌습니다. DJL 토크나이저가 함께 배포하는
윈도우와 macOS 바이너리도 packaging에서 제외했습니다.

빌드 게이팅

채팅 화면은 debug 빌드에서만 진입할 수 있습니다. 처음에는 :feature:chatdebugImplementation으로
두었는데, 그러면 release 빌드가 이 모듈을 아예 컴파일하지 않아 회귀가 release 경로에서 검증되지
않았습니다. 모듈은 항상 컴파일하고 진입점만 막는 방식으로 바꿨습니다.

디버그 여부는 core:commonBuildInfo로 추상화하고 app이 자기 BuildConfig.DEBUG로 구현을
제공합니다. BuildConfig는 모듈마다 따로 생성되므로 data와 app이 각자의 상수를 보고 있었습니다.

기타

local.propertiesDEV_BASE_URL이나 PROD_BASE_URL이 없으면 빌드를 실패시킵니다.
AppResult.of가 코루틴 취소를 삼키던 문제를 고쳤습니다. 감정 집계를 순수 함수로 분리해 배치 분류와
증분 누적이 같은 코드를 쓰게 했습니다.

관련 이슈

  • 없음

관련 작업 (Notion)

체크리스트

  • base 브랜치가 develop으로 설정되어 있다
  • 로컬 빌드 및 실행을 확인했다
  • 커밋 메시지가 컨벤션(feat:, fix: 등)을 따른다
  • 불필요한 로그/주석/디버그 코드를 제거했다
  • 리뷰어가 이해할 수 있도록 설명을 작성했다

테스트

단위 테스트 72개 통과, detekt 통과, debug와 release 빌드 모두 성공했습니다.

실기기(SM-S911N)에서 새 방으로 세 번 전송하며 요청 본문을 그대로 확인했습니다.

1차 본문은 {"content":"the morning meeting ran long"}으로 conversationId와 압축본이 둘 다
빠집니다. 2차 본문에는 conversationId:35currentConversationSummary:"the morning meeting ran long"이 실립니다. 3차 본문의 압축본은 "the morning meeting ran long then my lead added more work"
누적됩니다. 응답은 세 번 다 200이었습니다.

순차 노출과 캐릭터 매핑도 화면에서 확인했습니다. 감정 분류 화면도 정상 동작합니다.
ai-delivery 제외가 LiteRT를 깨뜨리지 않는다는 것을 이 경로로 검증했습니다.

리뷰 요청 사항

네 가지 관점(아키텍처와 컨벤션, 코루틴과 스레드와 메모리, 용량과 콜드스타트, 가독성과 주석)으로
코드 리뷰를 진행하고 지적사항을 반영했습니다. 반영하지 않은 것을 아래에 적습니다.

의도적으로 제외한 것

core:designsystem 토큰을 적용하지 않았습니다. 이 레포는 MaterialTheme(...)을 호출하는 곳이 없어
채팅 화면의 MaterialTheme.colorScheme 참조가 M3 baseline 기본값으로 해석되고 다크모드에 반응하지
않습니다. 검증용 debug 화면이라 이번에는 넘겼습니다.

baseline profile은 macrobenchmark 모듈과 CI 기기가 필요해 이 PR 범위를 넘습니다.

develop에 이미 있던 요약기 내부 문제도 범위 밖으로 두었습니다. greedyDecode 루프에 ensureActive()
없어 화면을 떠나도 남은 스텝이 끝까지 돌고, argmaxLastRow가 요약 1회당 약 192만 건의 Long 박싱을
만들며, ORT 세션을 SessionOptions 없이 만들어 인코더와 디코더가 각각 스레드풀을 갖습니다.

알려진 한계

채팅 목록이 더미라 ChatRoomKey가 항상 null로 열립니다. 그래서 재진입 복원 경로 전체가 UI에서
도달 불가입니다. GetMessagesUseCase, ConversationSession.restore(), ChatRoomState.isLoading
분기가 모두 단위 테스트로만 덮여 있습니다. 목록 조회 API가 붙어야 해소됩니다.

conversationId가 화면 state에만 있어 프로세스 사망이나 뒤로가기 후 재진입 시 새 대화가 됩니다.

스펙으로 확인된 사항

압축본 상한 2000은 스펙의 maxLength: 2000이라 문자 수 기준입니다. 현재 문자 수 기반 검증이 맞습니다.
압축본은 서버가 저장하지 않고 생성 컨텍스트로만 쓰므로 매 전송마다 누적본 전체를 보내야 합니다.

…ging

# Conflicts:
#	.github/workflows/ci.yml
#	data/build.gradle.kts
#	data/src/main/java/com/gamss/android/data/di/NetworkModule.kt
#	data/src/main/java/com/gamss/android/data/di/RepositoryModule.kt
#	domain/build.gradle.kts
#	domain/src/main/kotlin/com/gamss/android/domain/card/GenerateCardInputUseCase.kt
#	domain/src/main/kotlin/com/gamss/android/domain/emotion/ClassifyUserEmotionUseCase.kt
#	domain/src/main/kotlin/com/gamss/android/domain/summary/SummarizeDiaryUseCase.kt
서버 error.code 가 보존되어 이미 종료된 방과 이미 만들어진 카드를
HTTP 상태 대신 코드로 구분한다.
feature/chat-card 브랜치에 보존했다. 이 브랜치는 메시지 전송과
컨텍스트 압축본 전송에 집중한다.
압축본 = 첫 발화 원문 + 확정 청크 요약들 + 최근 3턴 원문.
청크는 512 토큰을 채울 때 한 번만 요약하고 캐시해 같은 텍스트를
다시 요약하지 않는다. 토큰 측정은 절단 없는 토크나이저를 따로 쓴다.
요약기 입력 한계(512 토큰)를 넘긴 뒤 요약해 청크마다 마지막 발화가 절단되던 문제를
넘기기 전에 확정하도록 바꿨다. 요약이 실패하면 청크를 원문으로 확정해 재시도가 누적되지
않게 했고, 상한 초과 시 첫 발화와 최근 발화를 남기고 오래된 청크부터 버린다.

압축 실패가 전송·조회를 막지 않도록 요약·토큰 계산 실패를 격리하고 취소만 다시 던진다.
orbit intent 가 네트워크 응답 스레드에서 재개되므로 상태 변이를 Mutex 로 직렬화하고,
온디바이스 요약이 화면 갱신을 막지 않게 압축 갱신을 reduce 뒤로 옮겼다.
채팅 화면을 debugImplementation 으로 내리면서 kobart 요약 체인의 release 소비자가
사라졌는데 asset 은 main 에 남아 있었다. asset 을 debug 소스셋으로 옮긴다.

DJL 토크나이저가 배포하는 윈도우·macOS 바이너리도 packaging 에서 제외한다.
안드로이드는 lib/arm64-v8a 만 사용한다.

release APK 339.66 MiB -> 171.84 MiB. debug 는 요약 모델을 유지한다.
요약이 스토어 락 안에서 돌아, 청크가 닫히는 전송마다 다음 전송의 current() 가
요약이 끝날 때까지 락에서 대기했다. 상태 변이 락과 요약 직렬화 락을 나눠
전송 경로가 요약을 기다리지 않게 한다.

재진입 복원은 서버 히스토리 전체를 청킹해 발화 수에 비례한 요약을 연속 실행했다.
발화 70개면 요약 10회가 진입만으로 돈다. restore() 는 중간 발화를 원문 청크로 두고
상한은 길이로 맞춘다. reset + addAll 을 한 번의 락으로 묶어 복원 중 전송이 끼어들지 못한다.

테스트: orbit-test 는 상태와 side effect 를 한 스트림으로 합치므로 skipItems 대신
타입을 고정하는 API 로 바꿨다. expectInitialState 는 11 에서 아무것도 소비하지 않아 제거했다.
debugImplementation 은 release 빌드가 :feature:chat 을 아예 컴파일하지 않게 만들어,
이 화면의 회귀가 release 경로에서 검증되지 않았다. main 소스셋이 variant 소스셋 심볼에
의존하는 구조라 build type 이 늘 때마다 빈 스텁을 하나씩 더 만들어야 했다.

모듈은 항상 컴파일하고 진입점만 막는다. debug/release 스텁 파일 두 개가 사라진다.
코드를 읽으면 알 수 있는 주석과 같은 정책이 여러 파일에 중복된 주석을 지웠다.
isReceiving 은 전송 중에도 true 라 상태를 잘못 말해 isAwaitingComments 로 바꿨다.
코드를 읽으면 알 수 있는 설명, 같은 정책의 중복 기술, 상수의 배경 설명을 지웠다.
남긴 것은 지우면 다음 사람이 코드를 고치다 깨뜨리는 일곱 개다.
encodeDefaults, kobart asset 소스셋, 토크나이저 절단, 요약 예산 초과,
인셋 이중 적용, reduce 의 CAS 재실행, orbit-test 의 단일 스트림.
BuildConfig 는 모듈마다 따로 생성되어 data 와 app 이 각자의 DEBUG 를 보고 있었다.
core:common 에 BuildInfo 를 두고 app 이 자기 BuildConfig.DEBUG 로 구현을 제공한다.
HTTP 로깅 레벨과 채팅 진입점이 같은 값을 본다.

BuildConfig.DEBUG 직접 참조는 BuildInfoModule 한 곳만 남는다.
@soyeonLee126 soyeonLee126 changed the title feat: 대화 컨텍스트 압축본 전송 및 채팅 메시지·대댓글 연동 [GMSS-124] feat: 대화 컨텍스트 압축본 전송 및 채팅 메시지·대댓글 연동 Aug 6, 2026
압축본이 서버에 확정된 발화만 미러링해야 한다는 규칙이 ViewModel 안에 흩어져 있었다.
조회 시 초기화, 전송 성공 시에만 적재, 전송 시 압축본 동봉을 ConversationSession 이 갖는다.
진입점이 늘어도 규칙이 복제되지 않는다.

적재와 압축을 나눴다. 압축은 요약기를 태울 수 있어 화면 갱신 뒤에 호출자가 부른다.

MessageBubble 이 사용자 메시지를 부정형으로 네 번 반복하던 것을 isFromUser 하나로 모으고,
Compose 함수에 modifier 파라미터를 관례대로 추가했다. ChatRoomActions 는 @immutable 로
표시해 재구성 스킵이 유지되게 한다.
토큰 계산이 발화마다 디스패치와 락을 잡아, 복원 70발화면 컨텍스트 전환이 70번 일어났다.
countAll 로 한 번에 처리한다.

LiteRT 가 끌고 오는 ai-delivery 를 제외했다. 모델을 assets 에 직접 넣으므로 AI Pack 배포를
쓰지 않는데, 그것 때문에 콜드스타트마다 WorkManagerInitializer 가 돌고 있었다.
병합 매니페스트에서 사라진 것을 확인했다.

목록이 실제 방 ID 를 넘길 수 있도록 onChatClick 시그니처를 바꿨다. 목록 조회 API 가 붙으면
더미에 ID 만 채우면 되고 app 배선은 그대로다.
@soyeonLee126
soyeonLee126 marked this pull request as ready for review August 6, 2026 07:49
요약 저장소가 들고 있던 failSafe 를 domain/common 으로 옮긴다. "온디바이스 모델 실패는
삼키되 취소는 삼키지 않는다" 는 하나의 정책이라, 복사본에서 ensureActive 를 빠뜨리면
취소가 성공처럼 보여 조용히 틀린다.
대화를 끝내고 그 대화의 감정 카드를 만든다. 종료와 카드 생성은 묶지 않는다. 종료는
되돌릴 수 없어서, 종료는 됐고 카드만 실패한 상태를 화면이 들고 있어야 한다.

감정 누적기와 종료·카드 생성을 ConversationSession 에 넣는다. 누적기도 압축본과 같이
서버에 확정된 USER 발화만 미러링해야 해서, 순서 규칙을 한 곳에 두면 화면은 세션 하나만
의존한 채로 남는다. 누적기는 압축본과 같은 append/compact 로 나눠, 전송 응답이 온디바이스
추론을 기다리지 않고 화면에 붙는다.

종료 흐름 상태는 sealed interface EndFlow 한 축으로 둔다. 불리언으로 나누면 "종료 전인데
카드가 있음" 같은 조합이 타입상 표현되고 화면이 매번 유효 조합을 다시 계산해야 한다.
재시도해도 결과가 같은 실패는 CardNotRetryableException 으로 묶어 재시도 경로를 닫는다.
영구히 같은 오류를 반복시키지 않기 위해서다.

분류가 실패한 발화는 두 번까지 다시 시도하고 그 뒤엔 건너뛴다. 무한히 붙들면 뒤 발화가
영영 누적되지 않는다. 분류 실패는 대화를 막지 않도록 격리한다.

종료 응답은 ApiResponse<Unit> 으로 받는다. 본문을 읽지 않으므로 서버 스키마가 바뀌어도
종료가 실패로 뒤집히지 않는다.

@seunghee17 seunghee17 left a comment

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.

ChatRoomViewModel.onInputChange()에서 text.take(MAX_MESSAGE_LENGTH)를 사용하고 있는데 경계에 이모지가 걸리는 경우도 확인해 보셨을까요? take()는 Kotlin의 Char 단위로 자르지만 이모지는 하나가 두 개의 Char로 구성되기도 해서 140자 경계에서 이모지의 절반만 남을 가능성이 있어 보입니다. 이모지가 포함된 긴 텍스트를 직접 입력하거나 붙여넣을 때 입력창 표시와 전송 결과에 문제가 없는지 한 번 확인해 보면 좋을 것 같습니다!

Comment thread README.md Outdated
Comment thread data/src/main/java/com/gamss/android/data/summary/KobartTokenCounter.kt Outdated
Comment thread feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt Outdated

@seunghee17 seunghee17 left a comment

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.

확인했습니다! 고생하셨습니다!👍

@seunghee17
seunghee17 merged commit afdb1a9 into develop Aug 10, 2026
6 checks passed
@seunghee17
seunghee17 deleted the feature/chat-messaging branch August 10, 2026 13:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants