Skip to content

feat(mindrecord): 감정 분석 하위 상태를 화면까지 전달한다 — 대기·실패를 0건으로 확정하지 않는다 - #947

Merged
Sadturtleman merged 5 commits into
developfrom
feat/725-emotion-analysis-status
Aug 24, 2026
Merged

Sadturtleman merged 5 commits into
developfrom
feat/725-emotion-analysis-status

Conversation

@Sadturtleman

Copy link
Copy Markdown
Collaborator

Closes #725

먼저: 이 이슈는 더 이상 blocked 가 아니다

이슈는 선행 계약을 Afternote-BE#117 로 두고 blocked by 로 연결돼 있었다. 그 계약이 이미 배포돼 있다.

GET /v3/api-docs → components.schemas.EmotionAnalysisSummary
  total      분석 대상 기록 수
  succeeded  분석 성공 수
  pending    분석 대기/재시도 중 수
  failed     분석 실패(재시도 소진) 수

WeeklyMindRecordResponse.emotionAnalysis 로 실려 오고, emotions 의 설명도 "해당 주 상위 감정 키워드 (최대 3, 분석 성공분만)" 로 명시돼 있다. 즉 빈 배열 하나로는 세 상태가 구분되지 않는다는 것이 명세에 적혀 있는데, 앱이 그 값을 읽지 않고 있었다.

실서버 재현 (2026-08-23)

일기 1건을 저장하고 8초 간격으로 1분간 관찰했다.

t=0s   emotionAnalysis={"total":1,"succeeded":0,"pending":1,"failed":0}  emotions=[]
t=8s   (동일)
...
t=56s  (동일)

분석 대기 중인데 emotions=[] — 이슈가 기술한 그 상태다.

바꾼 것

계약 → 도메인 → UI 상태

층 추가
DTO EmotionAnalysisSummaryDto + WeeklyReportDto.emotionAnalysis (기본값 없음)
domain EmotionAnalysis + EmotionAnalysisStatus (상태 판단 한 곳)
UI state WeeklyReportUiState.Success.emotionAnalysisStatus

emotionAnalysis 에 기본값을 두지 않았다. 이 필드가 없으면 "분석 대기" 가 다시 "분석할 것 없음" 으로 접혀 이 이슈가 고치려는 상태 소실이 그대로 재현되기 때문이다. 계약 테스트로 고정했다.

상태 판단

total == 0                   -> NOTHING_TO_ANALYZE  // 이때의 0건만 실제 0건
pending > 0                  -> PENDING             // 확정하면 안 됨
succeeded == 0 && failed > 0 -> FAILED              // 아무것도 못 건짐
else                         -> COMPLETED

화면

  • PENDING → "0" 을 찍지 않는다. 같은 자리에 흐린 자리표시만 두고 기록을 분석하고 있어요. 잠시 뒤 이 자리에 키워드가 채워집니다.
  • FAILED → 감정 분석에 실패했어요. 다시 시도해 주세요. + 다시 시도 버튼
  • NOTHING_TO_ANALYZE / COMPLETED 0건 → 종전 빈 상태 문구 (여기서만 "0" 을 찍는다)
  • COMPLETED 키워드 있음 → 종전대로 키워드 + 서버 요약

완료 대기

분석 완료를 알려주는 채널이 없어 화면에 머무는 동안 제한된 재조회로 기다린다 — 8초 × 8회(약 1분). 무한 폴링은 하지 않고, 그 뒤에는 화면 이탈·복귀(refreshOnReturn)와 재시도 버튼에 맡긴다. 다른 주를 고르면 loadJob 취소가 대기까지 함께 끊고, 폴링 중 주가 바뀌었으면 결과를 덮지 않는다.

시연 (에뮬레이터 + 실서버, 같은 데이터)

서버를 대기 상태로 고정해 두고 앱만 교체했다.

서버: emotionAnalysis={"total":1,"succeeded":0,"pending":1,"failed":0}  emotions=[]
TOP KEYWORDS 영역
수정 전 (origin/develop 빌드) 나의 감정 키워드 | **0** | 이번 주 adamtia 님의 기록에서는 키워드가 나오지 않았어요.
수정 후 나의 감정 키워드 | 기록을 분석하고 있어요. 잠시 뒤 이 자리에 키워드가 채워집니다.

수정 전에는 분석이 진행 중인데도 0 과 "키워드가 나오지 않았어요" 로 확정했다. 수정 후에는 0 이 사라지고 분석 중임이 드러난다.

테스트

:feature:mindrecord:data 23개, :feature:mindrecord:presentation 28개 통과.

EmotionAnalysisContractTest (신규 7개) — 상태 판단 경계

  • 분석 대상 없음 / 대기 / 성공분이 있어도 대기가 남으면 대기 / 전부 실패 / 부분 실패는 완료 / 전부 성공
  • emotionAnalysis 키가 빠지면 기본값으로 성공하지 않고 실패한다

WeeklyReportEmotionAnalysisTest (신규 6개) — 이슈가 요구한 상태 전이

  • 분석 대기는 키워드 0건으로 확정되지 않는다
  • 대기 → 완료 로 바뀌면 머무른 화면에 결과가 반영된다
  • 대기 → 실패 로 바뀌면 정상 빈 상태로 덮지 않는다
  • 실패 → 재시도 하면 다시 조회해 완료로 바뀐다
  • 분석 대상이 없으면 그 자리에서 완료된 빈 결과다
  • 대기가 끝나지 않아도 무한히 재조회하지 않는다

회귀 방향 고정 확인

status 판단에서 pending > 0 분기를 없애면 두 모듈에서 6개가 실패한다.

✗ 대기 중인 기록이 하나라도 있으면 대기다        | expected:<PENDING> but was:<NOTHING_TO_ANALYZE>
✗ 성공분이 있어도 대기가 남아 있으면 대기다      | expected:<PENDING> but was:<COMPLETED>
✗ 분석 대기는 키워드 0건으로 확정되지 않는다     | expected:<PENDING> but was:<NOTHING_TO_ANALYZE>
✗ 대기에서 완료로 바뀌면 ...                     | expected:<COMPLETED> but was:<NOTHING_TO_ANALYZE>
✗ 대기에서 실패로 바뀌면 ...                     | expected:<FAILED> but was:<NOTHING_TO_ANALYZE>
✗ 대기가 끝나지 않아도 무한히 재조회하지 않는다  | 최초 조회 + 제한된 재조회만 있어야 한다

추측으로 진행한 항목

docs/qa/assumptions.md (#897) 에 뒤집는 방법과 함께 적었다.

  1. 부분 실패(succeeded > 0 && failed > 0)를 완료로 본다 — 실패 화면으로 덮으면 이미 건진 키워드까지 가리기 때문. 이슈는 네 상태만 요구하고 부분 실패는 정하지 않았다.
  2. 폴링 8초 × 8회 — 분석 소요 시간이 명세에 없고 실측에서는 1분 넘게 끝나지 않았다. "무한히 하지 않는다" 만 확실했다.

남는 것 / 서버 쪽

FE 의 "다시 시도" 는 재조회일 뿐 서버 측 재분석 트리거가 아니다. failed(재시도 소진) 상태에서 분석을 다시 걸 방법은 아직 없어, Afternote-BE#117 에 남는다. 이슈의 완료 기준 중 "분석 실패 시 … 재시도할 수 있다" 는 FE 가 할 수 있는 범위(재조회)까지 구현했다.

겹치는 PR

WeeklyReportDto.kt 는 #933(refactor/789-dto-defaults)이 기본값을 걷어내는 파일이라 충돌한다. 이 PR 이 추가하는 emotionAnalysis 는 처음부터 기본값이 없어 #789 의 방향과 같으니, 먼저 들어가는 쪽 기준으로 리베이스하면 된다. WeeklyReportViewModel.kt 는 #716·#751 과도 겹친다.

주간리포트 요청 자체는 HTTP 200 이라, AI 감정 분석이 대기 중이거나 실패해도 앱은
`emotions=[]` 만 보고 "키워드 0건" 으로 확정했다. `summaryText` 의 일반 격려 문구도
개인화된 요약이 완료된 것처럼 같은 자리에 표시됐다.

서버가 `emotionAnalysis`(total/succeeded/pending/failed)를 이미 내려주고 있어
(OpenAPI `EmotionAnalysisSummary`, Afternote-BE#117), 이 값을 DTO·domain·UI 상태까지
손실 없이 전달한다. 네 상태를 서로 다른 화면으로 가른다.

- 분석 대기: "0" 을 찍지 않고 분석 중 안내를 보여준다
- 분석 실패: 정상 빈 상태로 덮지 않고 재시도 버튼을 둔다
- 분석 완료: 종전대로 키워드와 요약을 보여준다
- 분석 대상 없음: 이때의 0건만 실제 0건이다

탭에 머무르는 동안 분석이 끝나면 반영되도록 제한된 재조회(8초 × 8회)를 둔다.
완료 신호 채널이 없어 폴링으로 기다리며, 그 뒤에는 화면 이탈·복귀와 재시도에 맡긴다.

Closes #725

@OnlyReadOneBook OnlyReadOneBook 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.

분석 하위 상태를 계약→도메인→UI로 끌어올린 방향, 실서버 재현 근거, 계약 테스트로 회귀 방향을 고정한 것 모두 좋습니다. 다만 이 PR이 막으려는 오표시가 부분 성공 경로에 그대로 남아 있고(BE 실코드로 확인), androidTest 컴파일을 새로 깨뜨려 [blocking] 2건으로 변경 요청 드립니다. 줄에 앵커되는 지적은 인라인 코멘트로 남겼고, 본문에는 앵커 불가한 2번 전문과 검증만 둡니다.

  1. [blocking] 부분 성공(PENDING+키워드 존재)에서 분석 중 신호 소실 — fallback 요약이 최종 요약처럼 노출 → WeeklyReportScreen.kt:205 인라인 코멘트.

  2. [blocking] androidTest 2파일 3곳이 컴파일되지 않습니다. (이 PR이 건드리지 않은 파일이라 인라인 불가 — 여기 전문) WeeklyReport에 기본값 없는 emotionAnalysis가 추가됐는데 TimeLetterMindRecordCompletionAndroidTest.kt 920·945행, MindRecordLifecycleAndroidTest.kt 233행의 생성자가 미갱신입니다. :app:compileDebugAndroidTestKotlin 실측으로 확인했고, develop의 기존 44건(#948→#949 수리 중)과는 별개로 이 PR이 새로 추가하는 3건입니다(develop 대조 실측: 44 vs 47건). #949가 PR 컴파일 게이트를 추가 중이라 머지 순서가 어긋나면 어느 쪽이든 게이트가 빨간불이 됩니다. 수리 시 함정 하나 — 이 fake들은 큐가 비면 requireNotNull로 던지는 구현이고 폴링 경로(awaitEmotionAnalysis)는 load와 달리 runCatching 밖이라, 픽스처에 pending>0을 넣으면 에러 화면이 아니라 계측 프로세스가 죽습니다. pending=0으로 채우거나 폴링 간격·횟수에 테스트 주입 seam을 두는 걸 권합니다.

  3. [normal] 폴링 1회 실패 시 조용한 영구 종료 — PENDING 화면에 복구·통보 없음 → WeeklyReportViewModel.kt:169 인라인 코멘트.

  4. [normal] "다시 시도" 결과 피드백 전무 — 동일 응답이 기본 경로라 방출 0 → WeeklyReportViewModel.kt:76 인라인 코멘트.

  5. [normal] 폴링 시작·지속 반경 과대 — 탭 미진입·바텀탭 이탈·백그라운드·과거 주 → WeeklyReportViewModel.kt:126 인라인 코멘트.

  6. [normal] emotionAnalysis 필수화의 실패 반경이 화면 전체 + 영문 예외 원문 노출 → WeeklyReportDto.kt:17 인라인 코멘트.

  7. [normal] FAILED에도 PendingBubble — 대기·실패 시각 동일, TalkBack 무음 → EmotionKeywordCard.kt:88 인라인 코멘트.

  8. [normal] retryEmotionAnalysis ≒ refreshOnReturn 복제 → WeeklyReportViewModel.kt:74 인라인 코멘트.

  9. [normal] Json 설정 수동 복제 + @OptIn(ExperimentalSerializationApi) 누락 → EmotionAnalysisContractTest.kt:22 인라인 코멘트.

  10. [normal] 테스트 조임 여지 — 2..12→정확히 9, fake date 미검증, 핵심 조합 미커버 → WeeklyReportEmotionAnalysisTest.kt:151 인라인 코멘트.

  11. [normal] NOTHING_TO_ANALYZE 화면 효과 0 — COMPLETED 0건과 동일, 문구 부적합 → EmotionKeywordCard.kt:60 인라인 코멘트.

검증

  • 로컬 실측(워크트리): :feature:mindrecord:data:testDebugUnitTest · :feature:mindrecord:presentation:testDebugUnitTest · :app:compileDebugUnitTestKotlin 통과. :app:compileDebugAndroidTestKotlin 실패 — 이 PR 고유 3건(위 2번), develop 대조 빌드로 기존 44건(#949 대상)과 분리 확인.
  • 실서버(2026-08-23 21시대, QA 계정): GET /mind-record?date=2026-08-17 → "emotionAnalysis":{"total":0,"succeeded":0,"pending":0,"failed":0} 실존 — 필수화의 전제(계약 배포됨)를 실측 확인.
  • BE 대조(Afternote-BE 체크아웃): WeeklyMindRecordService.buildEmotionAnalysisSummary가 분석 행 미생성분을 pending에 합산(missing = max(0, sourceTotal - emotions.size()); pending += missing)해 합==total이 구조 보장 — "합 미달 시 COMPLETED 오확정" 우려는 이 근거로 기각했습니다. 반면 buildTopEmotions엔 완료 게이트가 없음 — 1번 blocking의 근거.
  • 겹침 실측(merge-tree): #933(WeeklyReportDto·WeeklyReportContractTest 충돌), #937(WeeklyReportViewModel 충돌) — 본문에 적으신 대로 선진입 기준 리베이스 필요. #946·#949는 git 충돌 없음(#949 게이트와의 의미 충돌은 위 2번 참고).
  • 시안 대조: 주간리포트 852:11543 확인 — 대기·실패 상태 시안은 존재하지 않아 신규 문구·자리표시는 FE 재량 범위(블로커 아님). mindrecord:presentation은 스크린샷 테스트 태스크 자체가 없는 모듈이라 신규 상태 UI는 시각 회귀망 밖입니다.
  • 에뮬레이터 실측 생략 — 수정 전/후 비교 시연이 본문에 있고, 이번 쟁점(부분 성공 조합·컴파일·폴링 반경)은 위 정적 분석·서버 실측으로 판정 가능했습니다.

@Composable
private fun emotionCardDescription(state: WeeklyReportUiState.Success): String =
when {
state.emotionKeywords.isNotEmpty() -> {

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.

[blocking] 이 첫 분기가 상태보다 키워드 유무를 먼저 봅니다. succeeded>0 && pending>0(부분 성공)이면 성공분 키워드가 emotions에 실려 내려오므로(BE buildTopEmotions에 완료 게이트 없음 — 실코드 확인) 여기서 fallback summaryText가 최종 요약처럼 확정되고, 아래 PENDING 분기는 도달 불가가 됩니다. KDoc "분석이 끝난 뒤에만 도달하는 자리다"는 사실과 다르고, 이 케이스용으로 추가된 mindrecord_weekly_report_summary_pending은 참조 0건입니다. 분기 순서를 상태 우선으로 바꾸거나, 카드 표시 상태를 VM에서 sealed 하나로 계산해 내려주세요.

val report =
repository
.getWeeklyReport(date = monday.format(API_DATE_FORMATTER))
.getOrNull() ?: return

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.

[normal] 폴링 8회 중 1회만 조회가 실패해도 여기서 남은 시도를 전부 버리고 조용히 끝납니다. PENDING 화면에는 재시도 버튼이 없어(카드는 FAILED 전용) 화면에 머무는 동안은 복구·통보 수단이 없습니다 — 탭을 나갔다 와야만 refreshOnReturn으로 복구됩니다. 조회 실패를 시도 소모로 처리해 계속 돌거나, PENDING에도 갱신 어피던스를 두는 걸 권합니다.

*/
fun retryEmotionAnalysis() {
val current = (internalState.value.loadPhase as? LoadPhase.Loaded)?.monday ?: return
load(current, showsLoading = false, keepsStateOnFailure = true)

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.

[normal] BE#117(재분석 트리거) 전에는 재조회가 같은 FAILED를 돌려주는 게 기본 경로인데, 그 경우 데이터가 동일해 StateFlow 중복 제거로 방출 자체가 없고 showsLoading=false라 진행 표시도 없습니다 — 눌러도 픽셀이 안 바뀌어 버튼이 죽은 듯 보이고 연타를 유발합니다(멱등 GET이라 위험은 트래픽뿐). 진행 표시 + 결과 동일 시 안내를 권하고, 라벨도 재분석을 약속하는 "다시 시도"보다 "다시 확인" 쪽이 정직합니다.

* FE 가 할 수 있는 것은 **재조회**뿐이다 — 서버 측 재분석 트리거는 Afternote-BE#117
* 소관이다. 보고 있던 주를 그대로 다시 불러 상태를 갱신한다 (#725).
*/
fun retryEmotionAnalysis() {

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.

[normal] 바로 아래 refreshOnReturn과 본문이 사실상 같습니다(Loaded.monday를 꺼내 같은 인자로 load). 위임하거나 공통 함수로 접으면 재조회 정책(로딩·실패 유지·중복 가드)이 한 곳에 남습니다.

internalState.update {
it.copy(loadPhase = LoadPhase.Loaded(monday, report, profile.name))
}
awaitEmotionAnalysis(monday, report.emotionAnalysis.status)

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.

[normal] 폴링이 이 위치(loadJob 안, viewModelScope)에 있어 화면 구독과 무관하게 돕니다 — 주간리포트 탭 미진입(HomeScreen 호이스팅 프리페치, 주석의 "조회 1회" 전제가 최대 9회로 stale), 바텀탭 이탈(saveState=true라 VM 생존), 앱 백그라운드에도 8초×8회가 이어지고, 과거 주(최대 4주 전) 선택에도 폴링합니다. 폴링 근거("저장 직후 비동기 분석")는 이번 주에만 성립하니, 구독/lifecycle에 결속하고 대상 주를 한정하는 걸 권합니다.

@SerialName("emotions") val emotions: List<WeeklyReportEmotionDto> = emptyList(),
// 기본값을 두지 않는다 — 이 필드가 없으면 "분석 대기" 와 "분석할 것이 없음" 이 다시
// 구분 불가능해져 이 이슈가 고치려는 상태 소실이 그대로 재현된다 (#725).
@SerialName("emotionAnalysis") val emotionAnalysis: EmotionAnalysisSummaryDto,

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.

[normal] 필수화(기본값 없음)의 실패 반경이 감정 카드가 아니라 화면 전체입니다 — 필드 누락·null이면 MissingFieldException으로 주간리포트 탭 전체가 ErrorBox가 되고, UiText.DynamicOrResource가 e.message 우선이라 영문 예외 원문이 그대로 노출됩니다. "기본값이 상태를 접는다"는 우려는 EmotionAnalysisSummaryDto? + 매퍼에서 null→UNKNOWN(PENDING처럼 0 확정 안 함) 낮춤으로 똑같이 지키면서 반경을 카드 한 장으로 줄일 수 있습니다. 현 실서버가 이 필드를 내려주는 건 실측 확인했습니다(본문 검증 참조).

) {
if (capped.isEmpty()) {
EmptyBubble()
if (confirmsEmptyCount) EmptyBubble() else PendingBubble()

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.

[normal] FAILED도 이 else로 떨어져 PendingBubble이 그려집니다 — 대기·실패가 시각적으로 동일한 alpha 0.3 원이 되고, PendingBubble의 이름·KDoc("분석이 끝나지 않았을 때")과 어긋납니다. 또 빈 Box라 TalkBack이 아무것도 읽지 않습니다(semantics 부재).

* 분석이 끝나지 않았을 때의 자리 표시. [EmptyBubble] 과 크기·위치는 같지만 **"0" 을 찍지
* 않는다** — 키워드가 없다고 확정하는 표시이기 때문이다 (#725).
*/
@Composable

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.

[normal] EmptyBubble과 배경 alpha·"0" 유무만 다른 복붙입니다 — showsZero: Boolean 파라미터 하나로 접을 수 있고, 자리표시 목적이면 core/ui의 Modifier.shimmerLoadingPlaceholder()(이 모듈에서 이미 3곳 사용)가 로딩 어피던스도 살립니다.

onRetry: (() -> Unit)? = null,
) {
val capped = keywords.take(MAX_KEYWORDS)
val confirmsEmptyCount =

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.

[normal] NOTHING_TO_ANALYZE가 여기서 COMPLETED와 OR로 접혀 화면에서 관측 가능한 효과가 0입니다(문구·버블 동일). 기록이 0건인 주에 "이번 주 ○○ 님의 기록에서는 키워드가 나오지 않았어요"는 어긋난 문구라 이 상태 전용 유도 문구를 넣을 자리이기도 합니다 — 시안에 정의가 없어 FE 재량 범위의 제안입니다.

* Json 설정은 `NetworkModule.provideJson` 과 동일 (ignoreUnknownKeys + coerceInputValues).
*/
class EmotionAnalysisContractTest {
private val json =

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.

[normal] NetworkModule.provideJson()을 직접 호출할 수 있습니다(object라 테스트에서 호출 가능, 선례 NetworkModuleTest) — 수동 복제는 프로덕션 설정이 바뀌면 조용히 어긋납니다(기존 복제 7곳에 8번째 추가). 또 이 파일만 @OptIn(ExperimentalSerializationApi::class)이 없습니다 — MissingFieldException이 experimental이라 형제 계약 테스트 2개 파일엔 달려 있고, 현재는 warning입니다.

advanceUntilIdle()

assertEquals("폴링이 끝난 뒤에는 더 조회하지 않는다", callsAfterPolling, calls)
assertTrue("최초 조회 + 제한된 재조회만 있어야 한다", calls in 2..12)

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.

[normal] 호출 수는 결정적(최초 1 + 폴링 8 = 9)이라 assertEquals(9, calls)로 조일 수 있습니다 — 2..12는 폴링 횟수를 3으로 줄이거나 11로 늘려도 통과해 회귀를 못 잡습니다. generateSequence…take(50)도 fake가 마지막 응답을 반복하므로 잉여입니다. 겸사겸사 fake getWeeklyReport가 date 인자를 무시해 "보던 주를 조회한다"는 계약이 미검증이고, 첫 번째 blocking(PENDING+키워드 공존)과 동일 응답 재시도를 덮는 테스트가 없습니다.

OnlyReadOneBook and others added 3 commits August 24, 2026 13:42
부분 성공(일부 완료 + 일부 대기)에서는 완료분 키워드가 emotions 에 실려 내려온다 —
BE buildTopEmotions 에 완료 게이트가 없다. 키워드 유무를 먼저 보면 아직 분석 중인데도
폴백 요약이 최종 요약처럼 확정되고 대기 분기는 도달 불가가 됐다.

함께 정리한 것:

- emotionAnalysis 를 필수에서 nullable 로 낮추고 null 을 UNKNOWN 으로 옮긴다. 필수면
  필드 하나가 빠졌을 때 탭 전체가 오류 화면이 되고 예외 원문까지 노출된다. 0 으로
  접지 않는다는 목적은 UNKNOWN 이 그대로 지킨다
- FAILED 가 PendingBubble 로 떨어져 대기와 시각적으로 같던 것을 가르고, 빈 Box 라
  TalkBack 이 아무것도 읽지 않던 자리에 설명을 붙인다. 대기 버블은 shimmer 로 로딩임을
  알린다
- 폴링 한 번 실패가 남은 시도를 전부 버리던 것을 시도 소모로 바꾼다. 지난 주를 보는
  동안에는 폴링하지 않는다 — 근거가 «저장 직후 비동기 분석» 이라 이번 주에만 성립한다
- 재시도를 refreshOnReturn 에 위임해 재조회 정책을 한 곳에 두고, 사용자가 누른 갱신은
  진행 표시를 낸다. 라벨도 «다시 시도» → «다시 확인» 으로 — FE 가 할 수 있는 것은
  재조회뿐이다
- 계약 테스트가 프로덕션 Json 설정을 복제하지 않고 NetworkModule.provideJson() 을 쓴다
- 폴링 횟수 단언을 범위에서 결정값(9)으로 조인다
- androidTest 3곳의 WeeklyReport 생성자 갱신

리뷰 지적.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Sadturtleman

Copy link
Copy Markdown
Collaborator Author

지적 전부 반영했습니다. 특히 첫 번째는 정확한 지적이었습니다 — 부분 성공이 실제로 도달 가능한 상태인데 KDoc 이 «분석이 끝난 뒤에만 도달하는 자리» 라고 잘못 적혀 있었고, 그래서 mindrecord_weekly_report_summary_pending 이 참조 0건이었습니다.

1. [blocking] 부분 성공에서 분석 중 신호 소실 — 분기를 상태 우선으로 뒤집었습니다. 대기 중에는 키워드가 이미 몇 개 있어도 «지금 보이는 것이 전부가 아님» 을 알립니다(그 미사용 문자열이 이 자리입니다).

UI 단 가드를 붙였습니다(EmotionCardDescriptionTest, Robolectric+Compose). 되돌리면 잡힙니다.

키워드 우선 분기로 되돌림 → 3 tests completed, 2 failed
  키워드가 이미 나왔어도 대기 중이면 최종 요약으로 확정하지 않는다
  상태를 모르면 키워드 0건이라고 확정하지 않는다
원복 → 3개 통과

VM 단에도 성공분 키워드가 이미 있어도 대기 중이면 대기로 알린다 를 추가했습니다.

2. [blocking] androidTest 3곳 — 갱신했습니다. :app:compileDebugAndroidTestKotlin 에서 emotionAnalysis 관련 오류 0건이고, 남은 44건은 develop 기존 드리프트(#949 소관)입니다.

3. DTO 필수화의 실패 반경 — 지적하신 방식 그대로 낮췄습니다. EmotionAnalysisSummaryDto? = null + 매퍼 null 통과 + EmotionAnalysisStatus.UNKNOWN 신설. 0 으로 접지 않는다는 목적은 UNKNOWN 이 지키고, 실패 반경은 카드 한 장이 됩니다. 계약 테스트도 «필수라 던진다» 에서 «null 이어도 0 으로 접히지 않고 나머지 화면은 성한다» 로 바꿨습니다.

4. FAILED 가 PendingBubble 로 떨어짐 — FailedBubble·UnknownBubble 로 갈랐고, 공통 PlaceholderBubble 로 접으면서 contentDescription 을 붙였습니다(빈 Box 라 TalkBack 이 아무것도 안 읽던 자리). 대기 버블에는 shimmerLoadingPlaceholder() 를 얹었습니다.

5. 폴링 1회 실패가 남은 시도를 전부 버림 — 시도 소모로 바꿨습니다(return@repeat).

6. 폴링이 대상 주와 무관하게 돎 — 이번 주가 아니면 폴링하지 않습니다. 근거가 «저장 직후 비동기 분석» 이라 지난 주에는 성립하지 않는다는 지적 그대로입니다.

7. 재시도 버튼이 죽은 듯 보임 / 라벨 — refreshOnReturn(showsLoading = true) 로 위임했습니다. 재조회 정책이 한 곳에 남고(지적 8), 사용자가 누른 갱신에는 진행 표시가 납니다. 라벨은 «다시 확인» 으로 바꾸고 실패 문구도 맞췄습니다.

8. 계약 테스트의 Json 복제 — NetworkModule.provideJson() 을 직접 씁니다. @OptIn 은 MissingFieldException 을 더 이상 쓰지 않게 되면서 필요가 없어졌습니다.

9. 폴링 횟수 단언 — assertEquals("최초 조회 1회 + 폴링 8회", 9, calls) 로 조였고 take(50) 도 걷었습니다.

남긴 것 하나 — NOTHING_TO_ANALYZE 전용 유도 문구는 넣지 않았습니다. 시안에 정의가 없고 문구 자체가 기획 판단이라, 상태는 이미 구분되어 있으니 문구가 정해지면 그 자리에 넣는 편이 낫다고 봤습니다.

폴링의 lifecycle 결속은 이 PR 에서 하지 않았습니다 — 대상 주 한정으로 «과거 주 폴링» 은 닫혔지만, 탭 미진입·백그라운드에서 도는 문제는 uiState 구독에 결속해야 하고 그건 VM 구조 변경이라 별건이 맞다고 봅니다. 원하시면 이슈로 떼겠습니다.

@OnlyReadOneBook OnlyReadOneBook 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.

승인 — blocking 2건 실측 해소, 지적 11건 전부 반영

30ddbdd8 로 재확인했다.

1. [blocking] 부분 성공에서 분석 중 신호 소실 — 해소. 분기가 상태 우선으로 뒤집혔다(showsKeywords = COMPLETED || NOTHING_TO_ANALYZE). 대기·실패·모름에서는 완료분 키워드가 있어도 최종 요약으로 확정하지 않고, 참조 0건이던 mindrecord_weekly_report_summary_pending 이 제 자리를 찾았다. UI 단 가드(EmotionCardDescriptionTest 3개)와 VM 단 가드가 양쪽에 붙었다.

2. [blocking] androidTest 3곳 — 해소. 실측했다.

:app:compileDebugAndroidTestKotlin  → 오류 44건
그중 emotionAnalysis · WeeklyReport 관련 → 0건

직전 리뷰의 «develop 44 vs 이 PR 47» 이 44 = 44 가 됐다. 이 PR 이 새로 만들던 3건이 사라졌고 남은 44건은 develop 기존 드리프트(#949 소관)다.

3. DTO 필수화의 실패 반경 — EmotionAnalysisSummaryDto? = null + UNKNOWN 신설이 정확한 해법이다. «0 으로 접지 않는다» 는 목적은 UNKNOWN 이 지키고, 필드 하나 누락이 탭 전체를 오류로 만들던 반경은 카드 한 장으로 줄었다. WeeklyReport.analysisStatus 확장을 presentation 에 private 으로 둬서 null 처리가 한 곳에 모인 것도 좋다.

4~11 — FAILED/UNKNOWN 버블 분리와 contentDescription(빈 Box 라 TalkBack 이 침묵하던 자리), 폴링 실패의 시도 소모(return@repeat 위치가 delay 뒤라 «다음 간격을 기다린다» 가 실제로 성립한다), 이번 주 한정 폴링, refreshOnReturn(showsLoading = true) 위임, NetworkModule.provideJson() 사용, 폴링 횟수 결정값 단언 — 전부 확인했다.

특히 7번 재시도 위임은 부수 효과까지 맞다. 재시도 버튼은 FAILED 에만 뜨고(EmotionKeywordCard:117) FAILED 에서는 폴링이 status != PENDING 으로 즉시 빠지므로, refreshOnReturn 의 loadJob.isActive 가드가 버튼을 삼키는 조합이 생기지 않는다.

남긴 것에 대한 답

  • 폴링 lifecycle 결속 — 별건이 맞다. 대상 주 한정으로 «과거 주 폴링» 이 닫혔으니 남은 것은 «탭 미진입·백그라운드에서 도는» 구간이고, 그건 uiState 구독 결속이라 VM 구조 변경이다. 이슈로 떼 주면 좋겠다 — 마음의 기록 모듈이니 어사인까지.
  • NOTHING_TO_ANALYZE 유도 문구 — 수용한다. 상태는 이미 갈라져 있으므로 문구가 정해지면 그 자리에 넣으면 된다.
  • UNKNOWN 에 재조회 버튼이 없는 것 — 지금 선택도 방어 가능하다(계약상 오지 않을 자리). 다만 일시적 서버 이상이면 재조회로 풀리는 상태이기도 하니, 나중에 실사용에서 UNKNOWN 이 관측되면 그때 붙이면 된다. 이 PR 에서 할 일은 아니다.

검증

로컬 워크트리, head 30ddbdd8.

:feature:mindrecord:presentation:testDebugUnitTest → 32 tests, 0 failures  (EmotionCardDescriptionTest 3 · WeeklyReportEmotionAnalysisTest 7)
:feature:mindrecord:data:testDebugUnitTest         → 24 tests, 0 failures  (EmotionAnalysisContractTest 8)
:app:compileDebugUnitTestKotlin
ktlintCheck (전 모듈)
→ BUILD SUCCESSFUL in 20s

머지 전에 두 가지

(1) 최신 head 에 CI 가 한 번도 돌지 않았다. 마지막 실행은 fb489191(05:48)이고 30ddbdd8(08:05)에는 워크플로 런 자체가 생성되지 않았다.

$ gh pr checks 947
no checks reported on the 'feat/725-emotion-analysis-status' branch

이 레포에서 필수 체크는 guard 하나뿐이라 Unit Test 가 없는 채로도 머지가 가능하다. 빈 커밋 push 든 close/reopen 이든 한 번 재트리거해서 green 을 확인하고 머지해 달라. (같은 현상을 #954 에서도 봤다 — 07:43 이후 push 부터라 브랜치 문제는 아닌 듯하다.)

(2) develop 충돌 4건. WeeklyReportDto.kt · WeeklyReportContractTest.kt · presentation/build.gradle.kts · WeeklyReportViewModel.kt.

WeeklyReportViewModel 쪽은 이 PR 이 새로 넣는 awaitEmotionAnalysis 와 develop 의 인접 변경이 부딪히는 것이라 이 PR 쪽을 취하면 되고(import java.time.DayOfWeek 가 함께 살아야 한다), build.gradle.kts 는 양쪽 의존 병기다. 주의할 곳은 DTO 다 — #789 가 dailyQuestions·emotions 의 = emptyList() 를 걷어냈으므로, 그 정책(기본값 없음)을 살리면서 이 PR 의 emotionAnalysis: EmotionAnalysisSummaryDto? = null 만 얹어야 한다. WeeklyReportContractTest 의 «키가 없으면 빈 목록으로 접힌다» 계열 테스트는 그 정책과 정면으로 만나므로 병합 때 함께 정리가 필요하다.

기계적으로 합쳐 보니 컴파일이 서지 않았다 — 사람 판단이 필요한 병합이라는 뜻이라 미리 알린다.

QA · 시안 대조 — 직전 리뷰와 같다. 대기·실패 상태 시안이 존재하지 않아 신규 문구·자리표시는 FE 재량 범위이고, mindrecord:presentation 은 스크린샷 테스트 태스크가 없는 모듈이라 새 상태 UI 는 시각 회귀망 밖이다. shimmer·contentDescription 추가분도 같다.

@github-actions

Copy link
Copy Markdown
Contributor

이 PR 은 develop 와 충돌 상태이고, 그래서 CI 가 돌지 않는다.

pull_request 워크플로는 base 와 head 를 합친 가상 머지 커밋(refs/pull/<N>/merge)에서
실행되는데, 충돌이면 GitHub 이 그 ref 를 만들지 못해 워크플로가 트리거조차 되지 않는다.
체크가 실패하는 것이 아니라 아예 생성되지 않으므로, 새 커밋을 올려도 no checks reported
인 채 최신 커밋이 한 번도 검증되지 않은 상태로 남는다 (#1028).

develop 를 병합해 충돌을 풀면 검증이 다시 돈다. 해소되면 이 라벨은 자동으로 떨어진다.

# Conflicts:
#	feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/WeeklyReportDto.kt
#	feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/WeeklyReportContractTest.kt
#	feature/mindrecord/presentation/build.gradle.kts
#	feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt
@Sadturtleman
Sadturtleman merged commit 6b381db into develop Aug 24, 2026
12 checks passed
@Sadturtleman
Sadturtleman deleted the feat/725-emotion-analysis-status branch August 24, 2026 13:06
@github-actions

Copy link
Copy Markdown
Contributor

🚀 QA 배포 권장

QA 포인트

이 판단은 배포를 자동 실행하지 않습니다. deploy일 때 Release Distribution을 수동 실행하세요.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

conflict base 와 충돌 — CI 가 트리거되지 않는다. base 를 병합하면 자동으로 떨어진다

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(mindrecord): AI 분석 대기·실패를 정상 주간리포트로 오표시 — 감정 키워드 0건·폴백 요약

2 participants