Repository navigation
refactor(mindrecord): DTO 기본값·불필요 nullable 제거 — 누락 계약을 정상값으로 은폐하지 않는다 #933
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,8 +25,11 @@ data class DiaryCreateRequestDto( | |
| @SerialName("content") val content: String, | ||
| @SerialName("isDraft") val isDraft: Boolean, | ||
| @SerialName("todayMood") val todayMood: TodayMoodDto, | ||
| @SerialName("imageUrl") val imageUrl: String? = null, | ||
| @SerialName("receiverIds") val receiverIds: List<Long>? = null, | ||
| // 응답 계약에 없는 필드 — 타입만 nullable 로 두고 기본값은 두지 않는다 (#789). | ||
| @SerialName("imageUrl") val imageUrl: String?, | ||
| // 생성 API 에서 `null` 과 빈 목록은 모두 "수신자 없음" 으로 정규화되고, 작성 UI 는 항상 | ||
| // 목록을 갖고 있다. 빈 목록을 그대로 보내면 되므로 nullable 로 낮추지 않는다 (#789). | ||
| @SerialName("receiverIds") val receiverIds: List<Long>, | ||
| ) | ||
|
|
||
| @Serializable | ||
|
|
@@ -36,23 +39,20 @@ data class DiaryUpdateRequestDto( | |
| @SerialName("isDraft") val isDraft: Boolean, | ||
| @SerialName("todayMood") val todayMood: TodayMoodDto, | ||
| @SerialName("date") val date: String, | ||
| @SerialName("imageUrl") val imageUrl: String? = null, | ||
| @SerialName("imageUrl") val imageUrl: String?, | ||
| ) | ||
|
|
||
| /** | ||
| * `/diary` 목록 항목 (Swagger `DiaryResponse` 실측, 2026-08-03). | ||
| * `/diary` 목록 항목 (Swagger `DiaryResponse`, 실서버 응답 실측 2026-08-23). | ||
| * | ||
| * 식별자를 뺀 나머지에 기본값을 두는 이유는 **실패의 폭**이다. 이 DTO 는 목록 원소라, | ||
| * 필드 하나가 비어 `MissingFieldException` 이 나면 그 항목이 아니라 **그 달 목록 전체**가 | ||
| * 날아간다. 반면 값이 없을 때 잃는 것은 카드 한 장의 표시 요소뿐이다. | ||
| * | ||
| * `todayMood` 를 nullable 로 둔 것도 같은 맥락이다. non-null 이면 클라가 모르는 기분 값 | ||
| * 하나에 목록이 통째로 사라진다 (`coerceInputValues` 는 기본값이 있어야 동작한다). | ||
| * 실제로 `emotion` 은 한글로 내려오는 중이다 (#591). | ||
| * 식별자를 뺀 나머지에도 기본값을 두지 않는다. 이 필드들은 서버가 항상 채워 보내는 값이라, | ||
| * 기본값이 있으면 응답 키 누락과 계약 변경이 파싱 실패가 아니라 **정상적인 빈 값**으로 바뀐다. | ||
| * 특히 `isDraft` 가 `false` 로 접히면 임시저장 일기가 목록에 노출되고, `date` 가 빈 문자열이 | ||
| * 되면 캘린더에서 기록이 통째로 사라진다 (#789). | ||
| * | ||
| * (문서의 `required` 목록은 근거로 쓰지 않는다 — springdoc 은 `@Schema(requiredMode)` 가 | ||
| * 없으면 non-null 프로퍼티도 required 에 넣지 않아, 비어 있다는 사실이 서버가 그 필드를 | ||
| * 생략한다는 신호가 아니다. 응답 필드의 nullable 판단 기준 자체는 #676 소관.) | ||
| * 생략한다는 신호가 아니다. 판단은 실제 응답과 저장 컬럼 필수 여부를 기준으로 한다.) | ||
| * | ||
| * `id` 는 노션 명세("Diary 조회") 예시의 키다. Swagger 에는 없지만 두 문서가 갈려 있어 | ||
| * 대체 키로 함께 받는다 — 실제 응답에 `id` 가 없어 충돌하지 않는다. | ||
|
|
@@ -62,30 +62,36 @@ data class DiaryListItemDto( | |
| @SerialName("diaryId") | ||
| @JsonNames("id") | ||
| val diaryId: Long, | ||
| @SerialName("title") val title: String = "", | ||
| @SerialName("content") val content: String = "", | ||
| @SerialName("title") val title: String, | ||
| @SerialName("content") val content: String, | ||
| /** | ||
| * 사용자가 고른 **일기의 날짜** (`format: date`, 예 `"2026-03-21"`). | ||
| * | ||
| * [createdAt] 과 **별개 필드**다 — 작성 화면의 날짜 선택 값이 여기 들어가고, | ||
| * `createdAt` 은 레코드가 만들어진 시각이다. 캘린더에 찍어야 하는 쪽은 이 값이다. | ||
| * 둘을 `@JsonNames` 로 한 프로퍼티에 묶으면 서버의 키 순서에 따라 값이 뒤바뀐다. | ||
| */ | ||
| @SerialName("date") val date: String? = null, | ||
| @SerialName("createdAt") val createdAt: String = "", | ||
| @SerialName("date") val date: String, | ||
| @SerialName("createdAt") val createdAt: String, | ||
| // Swagger `DiaryResponse` 에 없는 필드 — 서버가 주기 시작하면 쓰이고, 아니면 계속 null. | ||
| @SerialName("imageUrl") val imageUrl: String? = null, | ||
| @SerialName("todayMood") val todayMood: TodayMoodDto? = null, | ||
| // 저장 컬럼이 필수라 응답도 항상 채워진다. AI 가 매기는 `emotion` 과 달리 사용자가 직접 | ||
| // 고른 값이고, 한글 값이 관측된 쪽도 `emotion` 이지 이 필드가 아니다 (#591, #789). | ||
| @SerialName("todayMood") val todayMood: TodayMoodDto, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [normal] 같은 응답 안에서 이 필드만 엄격 — 값 하나에 그 달 전체. 기본값이 없어
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [normal] 같은 응답 안에서 todayMood 만 엄격합니다 — 값 하나에 그 달 전체가 죽습니다. 이 필드는 기본값이 없어
|
||
| @SerialName("isDraft") | ||
| @JsonNames("draft") | ||
| val isDraft: Boolean = false, | ||
| val isDraft: Boolean, | ||
| ) | ||
|
|
||
| // `/diary` 응답의 `data` 는 객체 — `diaries` 외에 조회 대상 달의 비-임시 다이어리 수 | ||
| // (`monthDiaryCount`)와 최근 7일 최빈 기분(`weeklyDominantMood`)이 함께 내려옴. | ||
| @Serializable | ||
| data class DiaryListDto( | ||
| @SerialName("diaries") val diaries: List<DiaryListItemDto> = emptyList(), | ||
| @SerialName("monthDiaryCount") val monthDiaryCount: Int = 0, | ||
| @SerialName("weeklyDominantMood") val weeklyDominantMood: TodayMoodDto? = null, | ||
| @SerialName("diaries") val diaries: List<DiaryListItemDto>, | ||
| @SerialName("monthDiaryCount") val monthDiaryCount: Int, | ||
| // 그 주에 기록이 없으면 서버가 `null` 을 **명시적으로** 보낸다. 값이 없다는 뜻이 실제로 | ||
| // 있으므로 nullable 은 유지하되, 키 자체는 계약이라 기본값은 두지 않는다 (#789). | ||
| @SerialName("weeklyDominantMood") | ||
| @Serializable(with = NullableTodayMoodSerializer::class) | ||
| val weeklyDominantMood: TodayMoodDto?, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable | |
| /** `GET /receiver-auth/daily-question` 응답 (`data`). */ | ||
| @Serializable | ||
| data class ReceiverDailyQuestionListDto( | ||
| @SerialName("dailyQuestions") val dailyQuestions: List<ReceiverDailyQuestionItemDto> = emptyList(), | ||
| @SerialName("dailyQuestions") val dailyQuestions: List<ReceiverDailyQuestionItemDto>, | ||
| ) | ||
|
|
||
| @Serializable | ||
|
|
@@ -17,26 +17,32 @@ data class ReceiverDailyQuestionItemDto( | |
| @SerialName("content") val content: String, | ||
| // "yyyy.MM.dd 요일" 형식. | ||
| @SerialName("createdAt") val createdAt: String, | ||
| // Swagger `DailyQuestionListResponse` 계약에 없는 필드 — 기본값을 유지한다 (#789). | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [normal→정정] |
||
| @SerialName("imageUrl") val imageUrl: String? = null, | ||
| ) | ||
|
|
||
| /** `GET /receiver-auth/diary` 응답 (`data`). */ | ||
| @Serializable | ||
| data class ReceiverDiaryListDto( | ||
| @SerialName("diaries") val diaries: List<ReceiverDiaryItemDto> = emptyList(), | ||
| @SerialName("diaries") val diaries: List<ReceiverDiaryItemDto>, | ||
| ) | ||
|
|
||
| /** | ||
| * 수신자용 일기 항목. 발신자 목록과 **같은 `DiaryResponse` 계약**을 쓰므로 | ||
| * nullability 와 기본값도 [DiaryListItemDto] 와 동일하게 맞춘다 (#789). | ||
| */ | ||
| @Serializable | ||
| data class ReceiverDiaryItemDto( | ||
| @SerialName("diaryId") val diaryId: Long, | ||
| @SerialName("title") val title: String, | ||
| @SerialName("content") val content: String, | ||
| @SerialName("isDraft") val isDraft: Boolean = false, | ||
| @SerialName("isDraft") val isDraft: Boolean, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [normal] 수신자 DTO — "같은 계약" 동기화가 절반입니다.
|
||
| // 응답 계약에 없는 필드 — 기본값을 유지한다. | ||
| @SerialName("imageUrl") val imageUrl: String? = null, | ||
| @SerialName("todayMood") val todayMood: TodayMoodDto? = null, | ||
| @SerialName("todayMood") val todayMood: TodayMoodDto, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [normal] "같은 계약" 동기화가 절반이다.
|
||
| // 작성일 (yyyy-MM-dd). | ||
| @SerialName("date") val date: String = "", | ||
| @SerialName("date") val date: String, | ||
| // "yyyy.MM.dd 요일" 형식. | ||
| @SerialName("createdAt") val createdAt: String = "", | ||
| @SerialName("updatedAt") val updatedAt: String = "", | ||
| @SerialName("createdAt") val createdAt: String, | ||
| @SerialName("updatedAt") val updatedAt: String, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| package com.afternote.feature.mindrecord.data.dto | ||
|
|
||
| import kotlinx.serialization.KSerializer | ||
| import kotlinx.serialization.descriptors.PrimitiveKind | ||
| import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor | ||
| import kotlinx.serialization.descriptors.SerialDescriptor | ||
| import kotlinx.serialization.descriptors.nullable | ||
| import kotlinx.serialization.encoding.Decoder | ||
| import kotlinx.serialization.encoding.Encoder | ||
|
|
||
| /** | ||
| * 값 자체가 조건부인 **nullable 기분 필드** 전용 직렬화기. | ||
| * | ||
| * `coerceInputValues` 는 생성자 기본값이 있어야 동작한다. #789 로 기본값을 걷어내면서 | ||
| * 이 필드들도 함께 엄격해졌는데, 그 결과 "키 누락" 뿐 아니라 "클라가 모르는 **값**" 까지 | ||
| * 파싱 실패가 됐다. 두 상황은 성격이 다르다. | ||
| * | ||
| * - 키 누락 → 계약이 바뀐 것. 실패해야 드러난다. | ||
| * - 모르는 값 → 서버가 기분 종류를 늘렸거나 표기가 바뀐 것. 그 주/그 달 목록 전체를 | ||
| * 날릴 일이 아니라 이모지 한 칸만 비우면 된다 (#591 에서 감정 값이 한글로 관측된 전례). | ||
| * | ||
| * 그래서 키의 존재는 계약으로 두고(`= null` 기본값 없음), 값만 관대하게 접는다. | ||
| */ | ||
| object NullableTodayMoodSerializer : KSerializer<TodayMoodDto?> { | ||
| override val descriptor: SerialDescriptor = | ||
| PrimitiveSerialDescriptor("com.afternote.mindrecord.NullableTodayMood", PrimitiveKind.STRING).nullable | ||
|
|
||
| override fun deserialize(decoder: Decoder): TodayMoodDto? { | ||
| if (!decoder.decodeNotNullMark()) { | ||
| return decoder.decodeNull() | ||
| } | ||
| val raw = decoder.decodeString() | ||
| return TodayMoodDto.entries.firstOrNull { it.name == raw } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [normal] @SerialName 우회 — 와이어 표기 정본이 둘이 된다.
부수 2건: 비문자열 입력(숫자·객체)은
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [normal] 이 직렬화기가
부수 2건: 비문자열 입력(숫자·객체)은 |
||
| } | ||
|
|
||
| override fun serialize( | ||
| encoder: Encoder, | ||
| value: TodayMoodDto?, | ||
| ) { | ||
| if (value == null) encoder.encodeNull() else encoder.encodeString(value.name) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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로 고정 중이다. 레포 안에 요청 직렬화 규약이 두 개가 됐다.DailyQuestionUpdatePayload/DailyQuestionCreatePayload/DiaryCreatePayload가 해당 필드의= null기본값을 유지해 생략 자리가 한 층 위로 옮겨졌을 뿐이고(DailyQuestionWriteViewModel.kt:147이 실제로 date 를 생략), 와이어에서는 "보내지 않음" 표현 자체가 사라졌다.