Skip to content

refactor(mindrecord): DTO 기본값·불필요 nullable 제거 — 누락 계약을 정상값으로 은폐하지 않는다 - #933

Merged
Sadturtleman merged 2 commits into
developfrom
refactor/789-dto-defaults
Aug 24, 2026
Merged

Sadturtleman merged 2 commits into
developfrom
refactor/789-dto-defaults

Conversation

@Sadturtleman

Copy link
Copy Markdown
Collaborator

Closes #789

무엇이 문제였나

release API 가 항상 채우는 응답 키까지 false·0·""·emptyList() 로 보정하거나
nullable 로 낮춘 지점이 있었다. 이 상태에서는 응답 키 누락과 계약 변경이 파싱 실패로
드러나지 않고 정상적인 빈 값
으로 바뀐다.

  • isDraft = false → 임시저장 글이 목록에 그대로 노출
  • 수치 0, emptyList() → 잘못된 주간 리포트가 정상 화면으로 보임
  • 빈 날짜·nullable 날짜 → 캘린더에서 기록이 조용히 사라짐

실서버 응답 실측 (2026-08-23)

기본값을 뗄지 말지는 문서의 required 표기가 아니라 실제 응답을 기준으로 정했다.
(springdoc 은 @Schema(requiredMode) 가 없으면 non-null 프로퍼티도 required 에 넣지 않아,
required 가 비어 있다는 사실이 서버가 그 필드를 생략한다는 신호가 아니다.)

엔드포인트 실제로 내려온 키
GET /diary diaries, monthDiaryCount, weeklyDominantMood, yearMonth
GET /diary 항목 diaryId, title, content, isDraft, emotion, todayMood, date, createdAt, updatedAt, receivers
GET /daily-questions/today questionId, day, content, isAnswered, isDraft, receivers
GET /mind-record diaryAmount, dailyQuestionAmount, deepThoughtAmount, summaryText, week, daily-question, emotions, emotionAnalysis
GET /mind-record week[] day, diaryId, type, emotion

바꾼 것

1. 네 DTO 파일의 기본 파라미터 제거

  • DailyQuestionDto — 요청 DTO 기본값, 응답의 isDraft·isAnswered
  • DiaryDto — 요청 DTO 기본값, 목록 항목의 title/content/date/createdAt/isDraft/todayMood, 래퍼의 emptyList()·0·null
  • MindRecordDto — 수신 목록 래퍼와 일기 응답의 isDraft/date/createdAt/updatedAt/todayMood
  • WeeklyReportDto — 카운트·요약·목록·일자·질문·감정 비율

응답 계약에 존재하지 않는 imageUrl 만 기본값을 유지한다. 여기서 기본값을 떼면
멀쩡한 응답의 파싱이 깨지고, 필드 자체의 정리는 해당 기능 계약 이슈 소관이다.

2. DTO 밖까지 전파된 불필요한 nullable 제거

  • DiaryListItemDto.date/todayMood → domain Diary.date/todayMood 까지 non-null
  • ReceiverDiaryItemDto.todayMood — 같은 DiaryResponse 계약이라 함께 맞춤
  • DiaryCreatePayload.receiverIds / DiaryCreateRequestDto.receiverIds → List<Long>
    (작성 UI 는 항상 목록을 갖고 있고, 생성 API 는 null 과 [] 를 모두 "수신자 없음" 으로 정규화)
  • TodayDailyQuestionDto.day 는 이미 non-null 이라 그대로 둠

3. 부수 효과 하나를 따로 막았다 — NullableTodayMoodSerializer

coerceInputValues 는 생성자 기본값이 있어야 동작한다. 그래서 기본값을 걷어내면
"키 누락" 뿐 아니라 "클라가 모르는 값" 까지 함께 파싱 실패가 된다. 실제로 이 PR 중간
상태에서 week[].emotion: "슬픔" 하나에 그 주 전체가 날아갔다.

두 상황은 성격이 다르다.

그래서 nullable 기분 필드(weeklyDominantMood, week[].emotion)에는 전용 직렬화기를
달아 키의 존재는 계약으로 두고 값만 관대하게 접는다.

⚠️ 비-nullable 인 일기 항목의 todayMood 는 이슈 지시대로 non-null 이라, 모르는 값이
오면 그 달 목록 전체가 실패한다. 사용자가 3종 중 고르는 값이고 저장 컬럼이 필수이며
실서버가 "SOSO" 형태로 내려주는 것은 확인했지만, 남는 위험이라 docs/qa/assumptions.md
(#897) 에 뒤집는 방법과 함께 적어 뒀다.

시연

에뮬레이터 + 실서버. 엄격해진 DTO 로 기존 화면이 모두 그대로 뜨는지부터 확인했다.

화면 결과
홈 TODAY'S QUESTION 영화를 본다면 팝콘은 달콤한 맛? 고소한 맛? — 엄격해진 isAnswered/isDraft 로 정상 파싱
기록 › 일기 2026.08.23 일 · 😐 · DTO 실측 — non-null date/todayMood/isDraft 정상
기록 › 주간리포트 0 데일리 질문 / 1 일기, 주간 요약문, week[] 점, TOP KEYWORDS 평온 100 모두 표시

수신자를 고르지 않고 일기를 저장해 receiverIds 변경을 직접 확인했다.

--> POST https://afternote.kro.kr/api/v1/diary
{"title":"789-empty-receivers","content":"<p>no-receiver-body</p>",
 "isDraft":false,"todayMood":"HAPPY","imageUrl":null,"receiverIds":[]}
<-- 200

서버 monthDiaryCount 1 → 2, 목록에 😊 789-empty-receivers 추가됨. 확인 후 테스트
데이터(9739, 9740)는 삭제했다.

테스트

:feature:mindrecord:data 31개 통과 (기존 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

Diary.date 가 non-null 이 되면서 매퍼 테스트의 date = null 케이스를 빈 문자열로 바꿨다.
머지 순서에 따라 충돌 가능하니 먼저 들어가는 쪽 기준으로 리베이스가 필요하다.

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

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

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 실측으로 BE DiaryUpdateRequest 는 이미 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건:
    1. 임시저장 date — DiaryCreateRequestDto 에는 date 필드가 없는데 non-null 승격이 안전한가: POST /diary(isDraft=true, date 미전송) → 응답·draftOnly 목록 모두 date = 생성일 로 채워짐. "서버가 항상 채운다"가 draft 케이스까지 실증됐다.
    2. PATCH 명시 null — 앱 동일 바디("date":null,"imageUrl":null 포함)로 PATCH /daily-questions/24 → 서버가 null 필드를 무시하고 date 보존(GET /mind-record 의 daily-question[].date 및 ?date= 쿼리 조회 생존). 와이어 변화 지적(인라인, DailyQuestionDto.kt)이 현재는 무해하다는 근거.
    3. emotion 키 — 기록 있는 주의 week[] non-diary 항목이 "emotion": null 을 명시 포함({"diaryId":24,"day":23,"type":"DAILY_QUESTION","emotion":null}), 기록 0건 주도 summaryText 가 placeholder 문자열로 채워짐. 필수화가 현 서버 거동과 정합.
  • 테스트 데이터는 생성 후 삭제·원복 완료(일기 9746 삭제, 데일리질문 24 content 원복).
데일리질문 목록 일기 (빈 상태) 주간리포트
기록 탭 데일리질문 목록 일기 탭 빈 상태 주간리포트 화면

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

본문 리뷰의 [normal] 지적 1~8 을 해당 위치의 인라인 스레드로 재배치했다. 총평·실측 검증·별도 이슈 제안은 본문 리뷰 참조.

// 응답 계약에 없는 필드 — 기본값을 유지한다.
@SerialName("imageUrl") val imageUrl: String? = null,
@SerialName("todayMood") val todayMood: TodayMoodDto? = null,
@SerialName("todayMood") val todayMood: TodayMoodDto,

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] "같은 계약" 동기화가 절반이다.

  • 쌍둥이 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,

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] 같은 응답 안에서 이 필드만 엄격 — 값 하나에 그 달 전체.

기본값이 없어 coerceInputValues 가 무력하고, 모르는 값 하나에 SerializationException 으로 그 달 목록 전체가 실패한다(유닛 실측: does not contain element with name 'SMILE' at $.data.diaries[0].todayMood). 같은 응답의 파생값인 weeklyDominantMood 에만 관대 직렬화기가 붙었다. 이슈 지시가 non-null 이고 assumptions.md(#897) 에 수용 리스크·되돌리는 방법이 기록된 것은 확인했다. 다만:

  • 실패 시 홈은 GetHomeSummaryUseCase 의 ?: 0 폴백으로 에러 표시 없이 "이번 달 0건" 이 된다 — PR 이 막겠다던 "정상적인 빈 값"이 홈에서 재현된다.
  • NullableTodayMoodSerializer KDoc("이모지 한 칸 대신 그 주/그 달 목록 전체를 날릴 일이 아니다")과 정면 모순이라, 다음 수정자가 어느 쪽이 정책인지 알 수 없다.
  • 구조 제안: 관대 처리를 TodayMoodDto 클래스 레벨 직렬화기로 올리면 필드별 어노테이션 없이 사용처 전부가 자동 커버되고, 생성자 기본값이 없는 한 키 누락은 여전히 MissingFieldException 이라 이 PR 의 목적과 상충하지 않는다.

fun `diaries 키가 없으면 빈 목록으로 접힌다`() {
// 0 건과 "키 자체가 없음" 이 같은 결과로 수렴하는 지점 — 회귀 시 여기부터 의심한다.
val body = """{ "status": 200, "code": 200, "data": { "monthDiaryCount": 0 } }"""
fun `클라가 모르는 최빈 기분 값은 그 칸만 비우고 목록은 살아남는다`() {

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] 삭제된 가드 자리에 이름만 비슷한 테스트.

구 클라가 모르는 기분 값이 와도 그 항목만 기분이 비고 목록은 살아남는다(항목 단위 가드)가 삭제되고 이 테스트가 들어왔는데, 픽스처가 diaries: [] 라 "목록 생존"을 전혀 증명하지 않는다. DiaryListItemDto.todayMood 쪽 회귀(DiaryDto.kt:80 스레드)가 나도 이 테스트는 green — 커버리지가 사라진 사실이 이름에 가려진다.

return decoder.decodeNull()
}
val raw = decoder.decodeString()
return TodayMoodDto.entries.firstOrNull { it.name == raw }

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] @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?,

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] 요청 와이어 변화 — 키 생략 → "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 `수신자 일기 목록은 발신자와 같은 계약이라 키가 빠지면 실패한다`() {

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] 키 누락 가드가 격리에 실패한다.

픽스처가 todayMood 와 date 를 동시에 뺐다 — 한쪽만 기본값이 부활해도 남은 쪽의 MissingFieldException 으로 계속 통과한다. DiaryListContractTest 의 date/isDraft/todayMood 가드처럼 키-하나당-테스트-하나로 맞추면 된다.

}

@Test
fun `diaries 키가 없으면 빈 목록으로 접히지 않고 실패한다`() {

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] 이 가드도 격리에 실패한다.

픽스처에 weeklyDominantMood 도 없다 — diaries 에 = emptyList() 가 부활해도 weeklyDominantMood 누락의 MissingFieldException 으로 계속 통과한다. "weeklyDominantMood": null, "monthDiaryCount": 0 을 채우고 diaries 만 빠진 픽스처로 만들어야 이 PR 의 핵심 회귀 방향이 실제로 가드된다.

private val json =
Json {
ignoreUnknownKeys = true
coerceInputValues = 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] Json { … } 복붙이 이 PR 로 10곳째다.

주석으로만 "provideJson 과 동일"을 단언하는 구조라 강제 장치가 없다. 이 모듈은 이미 core.network 를 의존하고 NetworkModuleTest 가 provideJson() 을 직접 호출하는 선례도 있다. provideJson 에 explicitNulls=false 가 추가되는 순간(PATCH 와이어 스레드의 자연스러운 후속 수순) 이 파일의 직렬화 테스트들이 낡은 로컬 설정으로 계속 통과해, 계약 테스트가 검증한다고 주장하는 대상과 실제 검증 대상이 갈린다.

assertNull(diary(date = null, createdAt = "").toUi())
// #789 이후 두 필드 모두 계약이라 빈 응답은 파싱 단계에서 걸러지지만, 표시 단계가
// 여전히 날짜 없는 항목을 오늘로 메우지 않는다는 것은 그대로 고정해 둔다.
assertNull(diary(date = "", createdAt = "").toUi())

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] 이제 도달 불가능한 픽스처다.

주석 스스로 "파싱 단계에서 걸러진다"고 인정하는 입력(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).

@OnlyReadOneBook OnlyReadOneBook Aug 23, 2026 •

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→정정] 이 주석이 인용하는 DailyQuestionListResponse 는 발신자 GET /daily-questions 의 스키마라 근거가 다른 엔드포인트에 걸려 있다 — 철회: Swagger 실측(#956 조사 중) 결과 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,

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] 수신자 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,

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] 같은 응답 안에서 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 이 막겠다던 "정상적인 빈 값"이 홈에서 재현됩니다.
  • NullableTodayMoodSerializer KDoc("이모지 한 칸 대신 그 주/그 달 목록 전체를 날릴 일이 아니다")과 정면 모순이라, 다음 수정자가 어느 쪽이 정책인지 알 수 없습니다.
  • 구조 제안: 관대 처리를 TodayMoodDto 클래스 레벨 직렬화기로 올리면 필드별 어노테이션 없이 사용처 전부가 자동 커버되고, 생성자 기본값이 없는 한 키 누락은 여전히 MissingFieldException 이라 이 PR 의 목적과 상충하지 않습니다.

fun `diaries 키가 없으면 빈 목록으로 접힌다`() {
// 0 건과 "키 자체가 없음" 이 같은 결과로 수렴하는 지점 — 회귀 시 여기부터 의심한다.
val body = """{ "status": 200, "code": 200, "data": { "monthDiaryCount": 0 } }"""
fun `클라가 모르는 최빈 기분 값은 그 칸만 비우고 목록은 살아남는다`() {

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] 삭제된 가드 자리에 이름만 비슷한 테스트가 들어왔습니다.

구 클라가 모르는 기분 값이 와도 그 항목만 기분이 비고 목록은 살아남는다(항목 단위 가드)가 삭제되고 이 테스트가 들어왔는데, 픽스처가 diaries: [] 라 "목록 생존"을 전혀 증명하지 않습니다. DiaryDto.kt:80 인라인에 남긴 회귀(todayMood 엄격화)가 나도 이 테스트는 green — 커버리지가 사라진 사실이 이름에 가려집니다.

return decoder.decodeNull()
}
val raw = decoder.decodeString()
return TodayMoodDto.entries.firstOrNull { it.name == raw }

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] 이 직렬화기가 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 이 코드에서 구분된다`() {

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] 이 PATCH 계약 테스트는 이름이 약속한 것을 단언하지 않습니다.

단언이 non-null 필드 2건의 contains 뿐이라, 기본값 복원·explicitNulls=false 어느 방향의 회귀에도 green 입니다. 본문 리뷰 5번의 와이어 형태를 고정하려면 "date":null 의 존재(혹은 부재)를 명시적으로 단언해야 합니다.

// ---------- 수신자 목록 ----------

@Test
fun `수신자 일기 목록은 발신자와 같은 계약이라 키가 빠지면 실패한다`() {

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] 키 누락 가드가 격리에 실패합니다 — todayMood 와 date 를 동시에 뺐습니다.

한쪽만 기본값이 부활해도 남은 쪽의 MissingFieldException 으로 계속 통과합니다. 같은 파일의 date/isDraft/todayMood 가드처럼 키-하나당-테스트-하나로 맞추면 됩니다. (DiaryListContractTest.kt:183 의 diaries 가드도 같은 문제 — 해당 줄 인라인 참고.)

}

@Test
fun `diaries 키가 없으면 빈 목록으로 접히지 않고 실패한다`() {

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] 키 누락 가드가 격리에 실패합니다 — 픽스처에 weeklyDominantMood 도 없습니다.

diaries 에 = emptyList() 가 부활해도 weeklyDominantMood 쪽 MissingFieldException 으로 계속 통과합니다. 키-하나당-테스트-하나로 분리하면 됩니다. (MindRecordDtoContractTest.kt:65 의 가드도 같은 문제.)

@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 없음" 으로 판정해 놓고 결정 상태를 남기지 않아, PR 이 승인·변경요청 어느 쪽도 아닌 채로 비어 있었다. 판정대로 결정을 붙인다.

  • 승인 대상 head: b765ddb6 — 앞선 리뷰(2026-08-23 12:33) 이후 새 커밋 없음, 그때 실측한 상태 그대로다.
  • [normal] 지적 8건은 각 파일 위치의 인라인 스레드에 남아 있다. 전부 blocking 이 아니므로 반영 여부는 작성자 판단에 맡긴다.

@Sadturtleman
Sadturtleman merged commit f6c57d8 into develop Aug 24, 2026
13 checks passed
@Sadturtleman
Sadturtleman deleted the refactor/789-dto-defaults branch August 24, 2026 06:59
OnlyReadOneBook added a commit that referenced this pull request Aug 24, 2026
「항목을 해소하는 PR」 과 「목록에서 빼는 PR」 은 서로 다른 브랜치라 충돌이 나지
않는다. 그래서 어느 쪽이 먼저 머지되든 그 순간부터 develop 의 `:konsist:test` 가
red 가 되고, 필수 체크가 `guard` 하나뿐이라 머지를 막지도 못한 채 red 만 남는다.
#933(#789 의 26건 해소)이 먼저 머지되며 실제로 그렇게 됐다.

목록이 썩는 것을 막는 값어치는 「신규 추가 금지」 쪽이 순서와 무관하게 담당하므로
그쪽만 실패시키고, stale 은 갱신 시점을 알리는 경고로 낮춘다. 경고가 CI 로그에
남도록 konsist 테스트의 표준 출력을 켠다.

#933 으로 해소된 MINDRECORD 26건은 목록에서 뺀다.
OnlyReadOneBook added a commit that referenced this pull request Aug 24, 2026
BE `DailyQuestionAnswerResponse` record 에 content·isDraft 가 있고 Jackson 의
NON_NULL 설정이 없어 두 키는 항상 나간다. content 는 `UserDailyQuestion.content`
컬럼이 nullable = false 라 값도 항상 있다 (Afternote-BE main 0fb9e0b 실코드).

`= ""` · `= false` 는 서버가 키를 빼도 파싱을 성공시켜 계약 누락을 은폐한다.
#933 이 같은 파일의 나머지 DTO 를 같은 원칙으로 정리했지만, 이 DTO 는 #573 이
뒤에 머지돼 남아 있었다 — 이 PR 이 들이는 가드가 처음 잡아냈다.
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.

refactor(mindrecord): DTO 기본값·불필요 nullable 제거 — 누락 계약을 정상값으로 은폐

2 participants