Repository navigation
refactor(mindrecord): DTO 기본값·불필요 nullable 제거 — 누락 계약을 정상값으로 은폐하지 않는다 - #933
Conversation
release API 가 항상 채우는 응답 키까지 `false`·`0`·`""`·`emptyList()` 로 보정하고 있어서, 응답 키 누락과 계약 변경이 파싱 실패가 아니라 정상적인 빈 값으로 바뀌었다. `isDraft=false` 는 임시저장 노출로, 수치 `0` 은 잘못된 주간 리포트로, 빈 날짜는 캘린더에서 기록이 사라지는 것으로 이어진다. DailyQuestionDto / DiaryDto / MindRecordDto / WeeklyReportDto 네 파일의 기본 파라미터를 필드별 계약과 생성 호출부 기준으로 제거하고, DTO 밖까지 번진 불필요한 nullable(`Diary.date`, `Diary.todayMood`, `DiaryCreatePayload.receiverIds`)을 non-null 로 좁혔다. 응답 계약에 없는 `imageUrl` 만 기본값을 유지한다. 기본값을 걷어내면 `coerceInputValues` 가 동작하지 않아 "모르는 enum 값" 까지 함께 실패하게 되는데, 키 누락(계약 변경)과 값 확장(표기 변경)은 성격이 다르다. nullable 기분 필드에는 `NullableTodayMoodSerializer` 를 달아 키의 존재는 계약으로 두고 값만 관대하게 접는다 — 이모지 한 칸 때문에 그 주/그 달 목록 전체를 날리지 않는다. Closes #789
There was a problem hiding this comment.
PR 의 방향 — "서버가 항상 채우는 키의 기본값 보정은 계약 변경을 은폐한다" — 은 실측 근거가 충실하고, 아래 재검증도 같은 결론을 지지한다. 엄격해진 DTO 로 실서버 4개 화면이 전부 정상임을 재확인했고, PR 본문이 남긴 미확정 지점 3곳(임시저장 date · PATCH 명시 null · emotion 키)을 실서버로 추가 실측해 전부 안전함을 확인했다. blocking 없음.
[normal] 지적 8건은 해당 파일 위치의 인라인 스레드로 달았다 — 수신자 DTO 동기화 절반(MindRecordDto.kt), todayMood 엄격/관대 비대칭(DiaryDto.kt), 이름만 남은 가드 테스트·격리 실패 픽스처(계약 테스트 3곳), 직렬화기 @SerialName 우회(NullableTodayMoodSerializer.kt), 요청 와이어 "key":null 신규 송신(DailyQuestionDto.kt), 테스트 Json 복붙, 도달 불가 픽스처(MindRecordUiMapperTest.kt). 본문에는 파일 줄에 앵커되지 않는 잔여만 남긴다.
본문 잔여 지적
[normal] diff 밖 문서 정오 2건
WeeklyReportContractTest.kt:15헤더가 존재하지 않는GET /mind-record/weekly를 인용한다 — 정본은WeeklyReportApiService의@GET("mind-record")이고, 같은 PR 이WeeklyReportDto.kt:7에 올바른 경로를 새로 적어 한 PR 안에서 문서가 갈렸다.- PR 본문의 "응답 계약에 없는
imageUrl만 기본값을 유지한다"는 문면 그대로는 요청 DTO 3곳(DailyQuestionCreateRequestDto·DiaryCreateRequestDto·DiaryUpdateRequestDto)의 imageUrl 기본값 제거와 충돌한다 — "응답 DTO 중에서는" 으로 읽어야 맞다.
참고 — emotion 필수화와 OpenAPI required 의 괴리
WeeklyReportDayDto KDoc 이 인용한 required 목록 [day, diaryId, type] 에 emotion 이 없는데 필수 키로 승격됐다. 실서버 실측(아래 검증 3)으로 현 거동은 명시 null 송신이라 정합함을 확인했지만, 서버가 Jackson NON_NULL 직렬화로 바꾸면 키 생략 즉시 주간리포트 전체가 죽는 베팅이다. BE 에 @Schema(requiredMode) 명세 정정(emotion·weeklyDominantMood 를 required 로)을 요청해 문서와 실거동을 일치시켜 두길 권한다.
이 PR 밖 (별도 이슈로 발행 완료)
- 이어쓰기 등록 시 수신자 유실 → #955:
DiaryUpdatePayload에 receiverIds 필드 자체가 없어, draft 이어쓰기에서 수신자를 골라 등록해도 PATCH 에 실리지 않는다. Swagger 실측으로 BEDiaryUpdateRequest는 이미receiverIds를 받는 것을 확인 — FE 만 안 보내는 상태다. - 수신자 데일리질문
isDraft = false하드코딩 → #956:ReceiverMindRecordViewModel의filterNot { it.isDraft }방어를 구조적으로 무효화한다. Swagger 실측으로 수신자 응답이 발신자와 같은DailyQuestionListResponse스키마(isDraft 포함)를 재사용함을 확인 — 매퍼 주석의 "isDraft 미노출" 전제가 반증됐다. - mindrecord 밖 보정형 기본값 → #957 (afternote 판):
AfternotePageDto.hasNext = false(#789 가 잡은isDraft = false와 같은 실패 모양 — 페이지네이션이 조용히 멈춤) 등 응답 DTO 13건. timeletter 몫(ReceivedTimeLetterDto.blocks)은 기존 #790 소관, nullable 판단 기준·재발 방지 가드는 #676 소관으로 정리했다(#932 와의 방향 조율 필요성은 #957 본문에 명시).
검증
시안 대조: 해당 없음 (UI 변경 없는 데이터 레이어 PR).
실기동·실서버 (에뮬레이터 Pixel_7_Claude_QA + afternote.kro.kr, QA823 계정, 증거 .codex/qa-evidence/emulator/b765ddb6….json):
- 빌드: PR 워크트리에서
:app:assembleDebug+:app:compileDebugUnitTestKotlin통과 (#911 류:app테스트 fake 파손 없음). - 화면 4종 정상 렌더: 홈 TODAY'S QUESTION(엄격해진
isAnswered/isDraft파싱), 기록›데일리질문 목록, 기록›일기(빈 상태 — draft 미노출로isDraft거동 확인), 기록›주간리포트(week[]의emotion:null이 신규 직렬화기 경유, 카운트·요약 표시). 아래 스크린샷. - PR 미확정 지점 실서버 실측 3건:
- 임시저장 date —
DiaryCreateRequestDto에는 date 필드가 없는데 non-null 승격이 안전한가:POST /diary(isDraft=true, date 미전송) → 응답·draftOnly 목록 모두date = 생성일로 채워짐. "서버가 항상 채운다"가 draft 케이스까지 실증됐다. - PATCH 명시 null — 앱 동일 바디(
"date":null,"imageUrl":null포함)로PATCH /daily-questions/24→ 서버가 null 필드를 무시하고 date 보존(GET /mind-record의daily-question[].date및?date=쿼리 조회 생존). 와이어 변화 지적(인라인, DailyQuestionDto.kt)이 현재는 무해하다는 근거. - emotion 키 — 기록 있는 주의
week[]non-diary 항목이"emotion": null을 명시 포함({"diaryId":24,"day":23,"type":"DAILY_QUESTION","emotion":null}), 기록 0건 주도summaryText가 placeholder 문자열로 채워짐. 필수화가 현 서버 거동과 정합.
- 임시저장 date —
- 테스트 데이터는 생성 후 삭제·원복 완료(일기 9746 삭제, 데일리질문 24 content 원복).
| 데일리질문 목록 | 일기 (빈 상태) | 주간리포트 |
|---|---|---|
![]() |
![]() |
![]() |
OnlyReadOneBook
left a comment
There was a problem hiding this comment.
본문 리뷰의 [normal] 지적 1~8 을 해당 위치의 인라인 스레드로 재배치했다. 총평·실측 검증·별도 이슈 제안은 본문 리뷰 참조.
| // 응답 계약에 없는 필드 — 기본값을 유지한다. | ||
| @SerialName("imageUrl") val imageUrl: String? = null, | ||
| @SerialName("todayMood") val todayMood: TodayMoodDto? = null, | ||
| @SerialName("todayMood") val todayMood: TodayMoodDto, |
There was a problem hiding this comment.
[normal] "같은 계약" 동기화가 절반이다.
- 쌍둥이
DiaryListItemDto가 가진@JsonNames("draft")/@JsonNames("id")대체 키가 복제되지 않았다. 종전에는= false기본값이 (틀리게나마) 흡수했지만, 이제 서버가 과거 QA logcat 에서 실관측된draft키로 응답하면 발신자 목록은 정상 파싱되고 수신자 일기 화면만 전멸한다 — 두 사본의 동작 격차를 이 PR 이 오히려 벌렸다. todayMood·updatedAt은MindRecordMapper가 읽지 않는 필드인데(도메인MindRecordSummary에 대응 항목 없음) 필수·닫힌 enum 으로 올라, 화면에 표시되지도 않는 값이 수신자 목록 전체를 죽일 수 있게 됐다.MindRecordReceiverRepositoryImpl.getAll()이 단일runCatching이라 데일리질문 목록까지 연쇄로 사라진다.- 참고로
/receiver-auth/diary는 dev 에서 403(전달 조건 미충족)이라 실측 검증이 불가능한 경로다(리뷰 중 authCode 로 재확인). 실측 못 하는 계약일수록 대체 키·관대성은 발신자 쪽과 같게 가져가는 편이 안전하다.
| @SerialName("todayMood") val todayMood: TodayMoodDto? = null, | ||
| // 저장 컬럼이 필수라 응답도 항상 채워진다. AI 가 매기는 `emotion` 과 달리 사용자가 직접 | ||
| // 고른 값이고, 한글 값이 관측된 쪽도 `emotion` 이지 이 필드가 아니다 (#591, #789). | ||
| @SerialName("todayMood") val todayMood: TodayMoodDto, |
There was a problem hiding this comment.
[normal] 같은 응답 안에서 이 필드만 엄격 — 값 하나에 그 달 전체.
기본값이 없어 coerceInputValues 가 무력하고, 모르는 값 하나에 SerializationException 으로 그 달 목록 전체가 실패한다(유닛 실측: does not contain element with name 'SMILE' at $.data.diaries[0].todayMood). 같은 응답의 파생값인 weeklyDominantMood 에만 관대 직렬화기가 붙었다. 이슈 지시가 non-null 이고 assumptions.md(#897) 에 수용 리스크·되돌리는 방법이 기록된 것은 확인했다. 다만:
- 실패 시 홈은
GetHomeSummaryUseCase의?: 0폴백으로 에러 표시 없이 "이번 달 0건" 이 된다 — PR 이 막겠다던 "정상적인 빈 값"이 홈에서 재현된다. NullableTodayMoodSerializerKDoc("이모지 한 칸 대신 그 주/그 달 목록 전체를 날릴 일이 아니다")과 정면 모순이라, 다음 수정자가 어느 쪽이 정책인지 알 수 없다.- 구조 제안: 관대 처리를
TodayMoodDto클래스 레벨 직렬화기로 올리면 필드별 어노테이션 없이 사용처 전부가 자동 커버되고, 생성자 기본값이 없는 한 키 누락은 여전히MissingFieldException이라 이 PR 의 목적과 상충하지 않는다.
| fun `diaries 키가 없으면 빈 목록으로 접힌다`() { | ||
| // 0 건과 "키 자체가 없음" 이 같은 결과로 수렴하는 지점 — 회귀 시 여기부터 의심한다. | ||
| val body = """{ "status": 200, "code": 200, "data": { "monthDiaryCount": 0 } }""" | ||
| fun `클라가 모르는 최빈 기분 값은 그 칸만 비우고 목록은 살아남는다`() { |
There was a problem hiding this comment.
[normal] 삭제된 가드 자리에 이름만 비슷한 테스트.
구 클라가 모르는 기분 값이 와도 그 항목만 기분이 비고 목록은 살아남는다(항목 단위 가드)가 삭제되고 이 테스트가 들어왔는데, 픽스처가 diaries: [] 라 "목록 생존"을 전혀 증명하지 않는다. DiaryListItemDto.todayMood 쪽 회귀(DiaryDto.kt:80 스레드)가 나도 이 테스트는 green — 커버리지가 사라진 사실이 이름에 가려진다.
| return decoder.decodeNull() | ||
| } | ||
| val raw = decoder.decodeString() | ||
| return TodayMoodDto.entries.firstOrNull { it.name == raw } |
There was a problem hiding this comment.
[normal] @SerialName 우회 — 와이어 표기 정본이 둘이 된다.
it.name == raw(이 줄) · value.name(40행)은 TodayMoodDto 가 상수마다 명시한 @SerialName 을 무시한다. 오늘은 두 표기가 우연히 일치하지만, 서버 표기 변경에 맞춰 @SerialName 만 고치는 순간 이 직렬화기가 붙은 두 필드만 예외 없이 전부 null(= "모르는 값" 정상 동작)이 되어 테스트로도 안 잡힌다. TodayMoodDto.serializer() 위임(또는 descriptor 기반 매칭)으로 정본을 하나로 유지하길 권한다.
부수 2건: 비문자열 입력(숫자·객체)은 decodeString 에서 응답 전체를 죽여 "값만 관대" 의도와 갈리고, 이 파일만 @file:OptIn(ExperimentalSerializationApi::class) 이 없어 opt-in 경고 3건이 새로 유입된다(같은 패키지 관례와 어긋남).
| @SerialName("date") val date: String? = null, | ||
| @SerialName("questionId") val questionId: Long? = null, | ||
| @SerialName("imageUrl") val imageUrl: String? = null, | ||
| @SerialName("content") val content: String?, |
There was a problem hiding this comment.
[normal] 요청 와이어 변화 — 키 생략 → "key":null 명시 송신.
= null 기본값 제거로, encodeDefaults=false 에서 생략되던 키가 explicitNulls(kotlinx 기본 true)로 항상 나간다. 유닛 실측: PATCH 본문이 {"content","isDraft","questionId"} → + "date":null, "imageUrl":null. 실서버 실측으로 현 서버는 null 을 무시함을 확인해(리뷰 본문 검증 2) 당장 깨지진 않지만:
DeliveryVerificationRequestDto(ReceiverAuthDto.kt, feat(afternote): 사망 증빙 서류를 둘 중 하나만 제출해도 열람 신청 가능하도록 완화 #380)가 정확히 반대 관용구 — "= null기본값은encodeDefaults=false와 맞물려 키 자체가 생략된다(\"...\":null을 명시 전송하지 않음)" — 를 KDoc 으로 선언하고DeliveryVerificationContractTest로 고정 중이다. 레포 안에 요청 직렬화 규약이 두 개가 됐다.- 위 KDoc 의 "모든 생성 인자를 명시해야 '보내지 않음'과 '명시적 null' 이 구분된다"는 성립하지 않는다: 도메인
DailyQuestionUpdatePayload/DailyQuestionCreatePayload/DiaryCreatePayload가 해당 필드의= null기본값을 유지해 생략 자리가 한 층 위로 옮겨졌을 뿐이고(DailyQuestionWriteViewModel.kt:147이 실제로 date 를 생략), 와이어에서는 "보내지 않음" 표현 자체가 사라졌다.
| // ---------- 수신자 목록 ---------- | ||
|
|
||
| @Test | ||
| fun `수신자 일기 목록은 발신자와 같은 계약이라 키가 빠지면 실패한다`() { |
There was a problem hiding this comment.
[normal] 키 누락 가드가 격리에 실패한다.
픽스처가 todayMood 와 date 를 동시에 뺐다 — 한쪽만 기본값이 부활해도 남은 쪽의 MissingFieldException 으로 계속 통과한다. DiaryListContractTest 의 date/isDraft/todayMood 가드처럼 키-하나당-테스트-하나로 맞추면 된다.
| } | ||
|
|
||
| @Test | ||
| fun `diaries 키가 없으면 빈 목록으로 접히지 않고 실패한다`() { |
There was a problem hiding this comment.
[normal] 이 가드도 격리에 실패한다.
픽스처에 weeklyDominantMood 도 없다 — diaries 에 = emptyList() 가 부활해도 weeklyDominantMood 누락의 MissingFieldException 으로 계속 통과한다. "weeklyDominantMood": null, "monthDiaryCount": 0 을 채우고 diaries 만 빠진 픽스처로 만들어야 이 PR 의 핵심 회귀 방향이 실제로 가드된다.
| private val json = | ||
| Json { | ||
| ignoreUnknownKeys = true | ||
| coerceInputValues = true |
There was a problem hiding this comment.
[normal] Json { … } 복붙이 이 PR 로 10곳째다.
주석으로만 "provideJson 과 동일"을 단언하는 구조라 강제 장치가 없다. 이 모듈은 이미 core.network 를 의존하고 NetworkModuleTest 가 provideJson() 을 직접 호출하는 선례도 있다. provideJson 에 explicitNulls=false 가 추가되는 순간(PATCH 와이어 스레드의 자연스러운 후속 수순) 이 파일의 직렬화 테스트들이 낡은 로컬 설정으로 계속 통과해, 계약 테스트가 검증한다고 주장하는 대상과 실제 검증 대상이 갈린다.
| assertNull(diary(date = null, createdAt = "").toUi()) | ||
| // #789 이후 두 필드 모두 계약이라 빈 응답은 파싱 단계에서 걸러지지만, 표시 단계가 | ||
| // 여전히 날짜 없는 항목을 오늘로 메우지 않는다는 것은 그대로 고정해 둔다. | ||
| assertNull(diary(date = "", createdAt = "").toUi()) |
There was a problem hiding this comment.
[normal] 이제 도달 불가능한 픽스처다.
주석 스스로 "파싱 단계에서 걸러진다"고 인정하는 입력(date = "")을 세 테스트가 계속 만든다. 실서버 실측(리뷰 본문 검증 1)으로 임시저장에도 서버가 date 를 항상 채움을 확인했으니, 프로덕션 쪽의 parseLocalDateOrNull(raw: String?) nullable 시그니처와 ?: parseLocalDateOrNull(createdAt) 폴백, DiaryWriteViewModel.kt:173 "서버가 날짜를 주지 않은 임시저장" 주석도 함께 죽은 경로다 — non-null 승격과 두 이야기가 공존한다. 같이 정리하거나 최소한 주석은 새 계약에 맞춰 달라.
| @SerialName("content") val content: String, | ||
| // "yyyy.MM.dd 요일" 형식. | ||
| @SerialName("createdAt") val createdAt: String, | ||
| // Swagger `DailyQuestionListResponse` 계약에 없는 필드 — 기본값을 유지한다 (#789). |
There was a problem hiding this comment.
[normal→정정] 이 주석이 인용하는 — 철회: Swagger 실측(#956 조사 중) 결과 DailyQuestionListResponse 는 발신자 GET /daily-questions 의 스키마라 근거가 다른 엔드포인트에 걸려 있다GET /receiver-auth/daily-question 응답 항목이 발신자와 같은 DailyQuestionListResponse 스키마를 재사용함을 확인했다. 인용은 정당하다. 다만 "수신자 응답이 이 스키마를 재사용한다"는 한 마디가 주석에 있으면 오인용으로 읽히지 않겠다. (재사용은 isDraft 도 이 응답 계약에 있다는 뜻이라, 매퍼의 isDraft = false 하드코딩 건 #956 으로 이어졌다.)
| @SerialName("title") val title: String, | ||
| @SerialName("content") val content: String, | ||
| @SerialName("isDraft") val isDraft: Boolean = false, | ||
| @SerialName("isDraft") val isDraft: Boolean, |
There was a problem hiding this comment.
[normal] 수신자 DTO — "같은 계약" 동기화가 절반입니다.
ReceiverDiaryItemDto 는 "발신자 목록과 같은 DiaryResponse 계약이라 동일하게 맞춘다"고 선언하지만:
- 쌍둥이
DiaryListItemDto가 가진@JsonNames("draft")/@JsonNames("id")대체 키는 복제되지 않았습니다. 종전에는= false기본값이 (틀리게나마) 흡수했지만, 이제 서버가 과거 QA logcat 에서 실관측된draft키로 응답하면 발신자 목록은 정상 파싱되고 수신자 일기 화면만 전멸합니다 — 두 사본의 동작 격차를 이 PR 이 오히려 벌렸습니다. todayMood·updatedAt은MindRecordMapper가 읽지 않는 필드인데(도메인MindRecordSummary에 대응 항목 없음) 필수·닫힌 enum 으로 올라, 화면에 표시되지도 않는 값이 수신자 목록 전체를 죽일 수 있게 됐습니다.MindRecordReceiverRepositoryImpl.getAll()이 단일runCatching이라 데일리질문 목록까지 연쇄로 사라집니다.- 참고로
/receiver-auth/diary는 dev 에서 403(전달 조건 미충족)이라 실측 검증이 불가능한 경로입니다(리뷰 시점 authCode 로 재확인). 실측 못 하는 계약일수록 대체 키·관대성은 발신자 쪽과 같게 가져가는 편이 안전합니다.
| @SerialName("todayMood") val todayMood: TodayMoodDto? = null, | ||
| // 저장 컬럼이 필수라 응답도 항상 채워진다. AI 가 매기는 `emotion` 과 달리 사용자가 직접 | ||
| // 고른 값이고, 한글 값이 관측된 쪽도 `emotion` 이지 이 필드가 아니다 (#591, #789). | ||
| @SerialName("todayMood") val todayMood: TodayMoodDto, |
There was a problem hiding this comment.
[normal] 같은 응답 안에서 todayMood 만 엄격합니다 — 값 하나에 그 달 전체가 죽습니다.
이 필드는 기본값이 없어 coerceInputValues 가 무력하고, 모르는 값 하나에 SerializationException 으로 그 달 목록 전체가 실패합니다(유닛 실측: does not contain element with name 'SMILE' at $.data.diaries[0].todayMood). 같은 응답의 파생값인 weeklyDominantMood 에만 관대 직렬화기가 붙었습니다. 이슈 지시가 non-null 이고 assumptions.md(#897)에 수용 리스크·되돌리는 방법이 기록된 것은 확인했습니다. 다만 이 PR 안에서 정리할 가치가 있는 것:
- 실패 시 홈은
GetHomeSummaryUseCase의?: 0폴백으로 에러 표시 없이 "이번 달 0건" 이 됩니다 — PR 이 막겠다던 "정상적인 빈 값"이 홈에서 재현됩니다. NullableTodayMoodSerializerKDoc("이모지 한 칸 대신 그 주/그 달 목록 전체를 날릴 일이 아니다")과 정면 모순이라, 다음 수정자가 어느 쪽이 정책인지 알 수 없습니다.- 구조 제안: 관대 처리를
TodayMoodDto클래스 레벨 직렬화기로 올리면 필드별 어노테이션 없이 사용처 전부가 자동 커버되고, 생성자 기본값이 없는 한 키 누락은 여전히MissingFieldException이라 이 PR 의 목적과 상충하지 않습니다.
| fun `diaries 키가 없으면 빈 목록으로 접힌다`() { | ||
| // 0 건과 "키 자체가 없음" 이 같은 결과로 수렴하는 지점 — 회귀 시 여기부터 의심한다. | ||
| val body = """{ "status": 200, "code": 200, "data": { "monthDiaryCount": 0 } }""" | ||
| fun `클라가 모르는 최빈 기분 값은 그 칸만 비우고 목록은 살아남는다`() { |
There was a problem hiding this comment.
[normal] 삭제된 가드 자리에 이름만 비슷한 테스트가 들어왔습니다.
구 클라가 모르는 기분 값이 와도 그 항목만 기분이 비고 목록은 살아남는다(항목 단위 가드)가 삭제되고 이 테스트가 들어왔는데, 픽스처가 diaries: [] 라 "목록 생존"을 전혀 증명하지 않습니다. DiaryDto.kt:80 인라인에 남긴 회귀(todayMood 엄격화)가 나도 이 테스트는 green — 커버리지가 사라진 사실이 이름에 가려집니다.
| return decoder.decodeNull() | ||
| } | ||
| val raw = decoder.decodeString() | ||
| return TodayMoodDto.entries.firstOrNull { it.name == raw } |
There was a problem hiding this comment.
[normal] 이 직렬화기가 TodayMoodDto 의 @SerialName 을 우회합니다.
it.name == raw(33행) · value.name(40행)은 상수마다 명시된 @SerialName 을 무시합니다. 오늘은 두 표기가 우연히 일치하지만, 서버 표기 변경에 맞춰 @SerialName 만 고치는 순간 이 직렬화기가 붙은 두 필드만 예외 없이 전부 null(= "모르는 값" 정상 동작)이 되어 테스트로도 안 잡힙니다. TodayMoodDto.serializer() 위임(또는 descriptor 기반 매칭)으로 와이어 표기 정본을 하나로 유지하길 권합니다.
부수 2건: 비문자열 입력(숫자·객체)은 decodeString 에서 응답 전체를 죽여 "값만 관대" 의도와 갈리고, 이 파일만 @file:OptIn(ExperimentalSerializationApi::class) 이 없어 opt-in 경고 3건이 새로 유입됩니다(같은 패키지 관례와 어긋남).
| } | ||
|
|
||
| @Test | ||
| fun `PATCH 는 생략과 명시적 null 이 코드에서 구분된다`() { |
There was a problem hiding this comment.
[normal] 이 PATCH 계약 테스트는 이름이 약속한 것을 단언하지 않습니다.
단언이 non-null 필드 2건의 contains 뿐이라, 기본값 복원·explicitNulls=false 어느 방향의 회귀에도 green 입니다. 본문 리뷰 5번의 와이어 형태를 고정하려면 "date":null 의 존재(혹은 부재)를 명시적으로 단언해야 합니다.
| // ---------- 수신자 목록 ---------- | ||
|
|
||
| @Test | ||
| fun `수신자 일기 목록은 발신자와 같은 계약이라 키가 빠지면 실패한다`() { |
There was a problem hiding this comment.
[normal] 키 누락 가드가 격리에 실패합니다 — todayMood 와 date 를 동시에 뺐습니다.
한쪽만 기본값이 부활해도 남은 쪽의 MissingFieldException 으로 계속 통과합니다. 같은 파일의 date/isDraft/todayMood 가드처럼 키-하나당-테스트-하나로 맞추면 됩니다. (DiaryListContractTest.kt:183 의 diaries 가드도 같은 문제 — 해당 줄 인라인 참고.)
| } | ||
|
|
||
| @Test | ||
| fun `diaries 키가 없으면 빈 목록으로 접히지 않고 실패한다`() { |
There was a problem hiding this comment.
[normal] 키 누락 가드가 격리에 실패합니다 — 픽스처에 weeklyDominantMood 도 없습니다.
diaries 에 = emptyList() 가 부활해도 weeklyDominantMood 쪽 MissingFieldException 으로 계속 통과합니다. 키-하나당-테스트-하나로 분리하면 됩니다. (MindRecordDtoContractTest.kt:65 의 가드도 같은 문제.)
OnlyReadOneBook
left a comment
There was a problem hiding this comment.
승인한다. 앞선 리뷰에서 "blocking 없음" 으로 판정해 놓고 결정 상태를 남기지 않아, PR 이 승인·변경요청 어느 쪽도 아닌 채로 비어 있었다. 판정대로 결정을 붙인다.
- 승인 대상 head:
b765ddb6— 앞선 리뷰(2026-08-23 12:33) 이후 새 커밋 없음, 그때 실측한 상태 그대로다. - [normal] 지적 8건은 각 파일 위치의 인라인 스레드에 남아 있다. 전부 blocking 이 아니므로 반영 여부는 작성자 판단에 맡긴다.
「항목을 해소하는 PR」 과 「목록에서 빼는 PR」 은 서로 다른 브랜치라 충돌이 나지 않는다. 그래서 어느 쪽이 먼저 머지되든 그 순간부터 develop 의 `:konsist:test` 가 red 가 되고, 필수 체크가 `guard` 하나뿐이라 머지를 막지도 못한 채 red 만 남는다. #933(#789 의 26건 해소)이 먼저 머지되며 실제로 그렇게 됐다. 목록이 썩는 것을 막는 값어치는 「신규 추가 금지」 쪽이 순서와 무관하게 담당하므로 그쪽만 실패시키고, stale 은 갱신 시점을 알리는 경고로 낮춘다. 경고가 CI 로그에 남도록 konsist 테스트의 표준 출력을 켠다. #933 으로 해소된 MINDRECORD 26건은 목록에서 뺀다.
BE `DailyQuestionAnswerResponse` record 에 content·isDraft 가 있고 Jackson 의 NON_NULL 설정이 없어 두 키는 항상 나간다. content 는 `UserDailyQuestion.content` 컬럼이 nullable = false 라 값도 항상 있다 (Afternote-BE main 0fb9e0b 실코드). `= ""` · `= false` 는 서버가 키를 빼도 파싱을 성공시켜 계약 누락을 은폐한다. #933 이 같은 파일의 나머지 DTO 를 같은 원칙으로 정리했지만, 이 DTO 는 #573 이 뒤에 머지돼 남아 있었다 — 이 PR 이 들이는 가드가 처음 잡아냈다.



Closes #789
무엇이 문제였나
release API 가 항상 채우는 응답 키까지
false·0·""·emptyList()로 보정하거나nullable 로 낮춘 지점이 있었다. 이 상태에서는 응답 키 누락과 계약 변경이 파싱 실패로
드러나지 않고 정상적인 빈 값으로 바뀐다.
isDraft = false→ 임시저장 글이 목록에 그대로 노출0,emptyList()→ 잘못된 주간 리포트가 정상 화면으로 보임실서버 응답 실측 (2026-08-23)
기본값을 뗄지 말지는 문서의
required표기가 아니라 실제 응답을 기준으로 정했다.(springdoc 은
@Schema(requiredMode)가 없으면 non-null 프로퍼티도 required 에 넣지 않아,required가 비어 있다는 사실이 서버가 그 필드를 생략한다는 신호가 아니다.)GET /diarydiaries,monthDiaryCount,weeklyDominantMood,yearMonthGET /diary항목diaryId,title,content,isDraft,emotion,todayMood,date,createdAt,updatedAt,receiversGET /daily-questions/todayquestionId,day,content,isAnswered,isDraft,receiversGET /mind-recorddiaryAmount,dailyQuestionAmount,deepThoughtAmount,summaryText,week,daily-question,emotions,emotionAnalysisGET /mind-recordweek[]day,diaryId,type,emotion바꾼 것
1. 네 DTO 파일의 기본 파라미터 제거
DailyQuestionDto— 요청 DTO 기본값, 응답의isDraft·isAnsweredDiaryDto— 요청 DTO 기본값, 목록 항목의title/content/date/createdAt/isDraft/todayMood, 래퍼의emptyList()·0·nullMindRecordDto— 수신 목록 래퍼와 일기 응답의isDraft/date/createdAt/updatedAt/todayMoodWeeklyReportDto— 카운트·요약·목록·일자·질문·감정 비율응답 계약에 존재하지 않는
imageUrl만 기본값을 유지한다. 여기서 기본값을 떼면멀쩡한 응답의 파싱이 깨지고, 필드 자체의 정리는 해당 기능 계약 이슈 소관이다.
2. DTO 밖까지 전파된 불필요한 nullable 제거
DiaryListItemDto.date/todayMood→ domainDiary.date/todayMood까지 non-nullReceiverDiaryItemDto.todayMood— 같은DiaryResponse계약이라 함께 맞춤DiaryCreatePayload.receiverIds/DiaryCreateRequestDto.receiverIds→List<Long>(작성 UI 는 항상 목록을 갖고 있고, 생성 API 는
null과[]를 모두 "수신자 없음" 으로 정규화)TodayDailyQuestionDto.day는 이미 non-null 이라 그대로 둠3. 부수 효과 하나를 따로 막았다 —
NullableTodayMoodSerializercoerceInputValues는 생성자 기본값이 있어야 동작한다. 그래서 기본값을 걷어내면"키 누락" 뿐 아니라 "클라가 모르는 값" 까지 함께 파싱 실패가 된다. 실제로 이 PR 중간
상태에서
week[].emotion: "슬픔"하나에 그 주 전체가 날아갔다.두 상황은 성격이 다르다.
관측된 전례). → 이모지 한 칸만 비운다.
그래서 nullable 기분 필드(
weeklyDominantMood,week[].emotion)에는 전용 직렬화기를달아 키의 존재는 계약으로 두고 값만 관대하게 접는다.
시연
에뮬레이터 + 실서버. 엄격해진 DTO 로 기존 화면이 모두 그대로 뜨는지부터 확인했다.
TODAY'S QUESTION영화를 본다면 팝콘은 달콤한 맛? 고소한 맛?— 엄격해진isAnswered/isDraft로 정상 파싱2026.08.23 일 · 😐 · DTO 실측— non-nulldate/todayMood/isDraft정상0 데일리 질문 / 1 일기, 주간 요약문,week[]점,TOP KEYWORDS 평온 100모두 표시수신자를 고르지 않고 일기를 저장해
receiverIds변경을 직접 확인했다.서버
monthDiaryCount1 → 2, 목록에😊 789-empty-receivers추가됨. 확인 후 테스트데이터(
9739,9740)는 삭제했다.테스트
:feature:mindrecord:data31개 통과 (기존 16 → 31).MindRecordDtoContractTest(신규) — 데일리질문 목록isDraft누락 실패, 계약에 없는imageUrl누락은 통과, 수신자 목록이 발신자와 같은 계약임, 래퍼 키 누락 실패,receiverIds빈 목록이[]로 직렬화됨, PATCH 의 생략/명시적 null 구분DiaryListContractTest—date/isDraft/todayMood/diaries키 누락이 각각 실패,명시적
null최빈 기분은 정상, 모르는 최빈 기분 값은 그 칸만 비움WeeklyReportContractTest—week/카운트/emotion키 누락이 각각 실패, 명시적emotion: null은 정상, 모르는 값은 그 원소만 비움TodayDailyQuestionContractTest—isAnswered/isDraft키 누락이 각각 실패기존에 "빠져도 살아남는다" 를 고정하던 테스트들은 이번 계약에 맞춰 실패를 고정하는
쪽으로 뒤집었다. 되돌리면 이 테스트들이 먼저 깨진다.
:feature:mindrecord:presentation,:app단위 테스트, 세 모듈 ktlint 모두 통과.겹치는 PR
fix/751-date-parse-fallback) —MindRecordUiMapper.kt/MindRecordUiMapperTest.ktfix/716-failure-recovery) —WeeklyReportViewModel.kt계열Diary.date가 non-null 이 되면서 매퍼 테스트의date = null케이스를 빈 문자열로 바꿨다.머지 순서에 따라 충돌 가능하니 먼저 들어가는 쪽 기준으로 리베이스가 필요하다.