Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ data class TodayDailyQuestionDto(
@Serializable
data class DailyQuestionAnswerResponseDto(
@SerialName("userDailyQuestionId") val userDailyQuestionId: Long,
@SerialName("content") val content: String = "",
@SerialName("isDraft") val isDraft: Boolean = false,
// 기본값을 두지 않는다 — 서버가 항상 채우는 값이라, 기본값이 있으면 키 누락과 계약
// 변경이 파싱 실패가 아니라 «정상적인 빈 값» 으로 바뀐다. 특히 isDraft 가 false 로
// 접히면 임시저장이 정식 답변으로 보인다 (#789).

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 에서는 일어나지 않는다.

DailyQuestionAnswerResponseDto 의 content·isDraft 를 읽는 코드가 없다 — 생성·수정 둘 다 DailyQuestionRepositoryImpl 에서 .requireData().userDailyQuestionId 하나만 꺼내고 나머지는 버린다. isDraft 가 false 로 접혀도 «임시저장이 정식 답변으로 보이는» 경로 자체가 없다. 그 오표시는 목록을 그리는 DailyQuestionListItemDto.isDraft(이 파일 위쪽)에서 일어나고, 거기는 이미 non-null + @JsonNames("draft") 로 막혀 있다.

반대로 이 자리에서 기본값을 떼면 실제로 바뀌는 것이 하나 있는데 본문에 없다 — 서버가 저장을 마친 뒤에 앱만 실패로 표시한다. 두 키 중 하나라도 빠지면 MissingFieldException → runCatchingCancellable → SubmitState.Failed(저장 실패) 다. 게다가 정식 저장(isDraft=false)의 첫 시도가 이렇게 실패하면 draftId 가 채워지지 않아 재시도도 POST 로 나가고, BE 는 isAnswered && !isDraft 를 보고 DAILY_QUESTION_ALREADY_ANSWERED 로 막는다(release 8bfe43d1 DailyQuestionService#createAnswer). 그 답변은 앱에서 영영 «저장 실패» 로 남는다.

요청은 둘 중 하나다.

  • 최소 — 주석을 이 자리의 실제 실패 모드로 고친다. 계약 회귀를 fail fast 로 드러내는 값이 그 대가보다 크다는 판단이면, 그 판단을 그대로 적어 두면 된다.
  • 더 나은 선택지 — 소비하지 않는 두 필드를 DTO 에서 걷는다. ignoreUnknownKeys = true 라 응답에 남아 있어도 무해하고, fix(mindrecord): 데일리질문 생성·수정 응답의 userDailyQuestionId 를 BaseResponse<Unit> 으로 버림 #573 이 살리려던 값은 userDailyQuestionId 하나였다. 계약 회귀 감시는 실제 소비처인 목록·오늘 질문 DTO 가 이미 맡고 있다.

@SerialName("content") val content: String,
@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] 이 파일의 형제 둘이 막아 둔 키 변형에 여기만 무방비다.

같은 파일의 DailyQuestionListItemDto.isDraft 와 TodayDailyQuestionDto.isDraft 는 둘 다 @JsonNames("draft") 를 달고 있다. 각자 주석이 밝힌 대로 과거 QA logcat 에서 draft 로 관측된 이력과 #548(키 불일치로 getToday() 가 항상 실패)이 근거다. 이 자리만 대체 키가 없다.

기본값이 있던 동안에는 키가 draft 로 와도 false 로 접혀 저장 흐름 자체는 살았다. 이제는 같은 변형이 저장 전체를 실패시킨다 — 방어는 낮은 채로 실패 강도만 올라간 조합이다.

현재 계약이 isDraft 인 것은 실측으로 확인했다(실서버 OpenAPI, BE release 8bfe43d1 의 boolean isDraft). 그래도 형제 둘과 같은 대체 키를 붙여 두는 편이 이 파일의 관례에 맞다. BE record 에서 이 필드에만 Lombok @Getter 가 없다는 점도 눈에 걸린다 — @Getter 가 붙으면 Jackson 이 is-getter 를 draft 로 깎을 수 있는 자리다.

)
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.afternote.feature.mindrecord.data.dto

import com.afternote.core.network.model.BaseResponse
import kotlinx.serialization.MissingFieldException
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test

/**
Expand Down Expand Up @@ -80,4 +82,42 @@ class DailyQuestionAnswerResponseContractTest {

assertEquals(true, failed)
}

@Test
fun `isDraft 가 빠지면 정식 답변으로 접히지 않고 실패한다`() {

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 은 ignoreUnknownKeys = true + coerceInputValues = true 인데(core/network/.../di/NetworkModule.kt), 이 테스트의 Json { ignoreUnknownKeys = true } 에는 coerce 가 없다. 이 PR 에서는 그 차이가 실질이다 — coerceInputValues 는 기본값이 있는 필드에 한해 null 을 기본값으로 접기 때문에, 종전 코드에서는 "content": null · "isDraft": null 이 와도 ""/false 로 조용히 통과했다. 기본값을 떼면 그 경로가 예외로 바뀐다.

즉 이 PR 이 실제로 닫는 구멍은 본문이 든 «키 누락» 보다 «명시적 null» 쪽이 크다. 그런데 추가된 두 건은 키 누락만 고정한다. 프로덕션과 같은 설정의 Json 으로 "isDraft": null 한 건을 더 고정하면 가드가 실제 거동을 덮는다.

(지금 실서버에서 content: null 이 나오는 조합은 없다 — 유일한 PATCH 호출부인 DailyQuestionWriteViewModel 이 항상 content 를 실어 보내고, BE 는 content 나 isDraft 가 오면 엔티티를 갱신한 뒤 응답을 만든다. 이 지적은 «지금 깨진다» 가 아니라 «가드가 덮는 범위» 에 대한 것이다.)

// false 로 접히면 임시저장이 정식 답변으로 보인다 — 계약 변경이 «정상적인 빈 값» 으로
// 둔갑하는 자리라 기본값을 두지 않는다 (#789).
val body =
"""
{
"status": 200, "code": 200, "message": "성공",
"data": { "userDailyQuestionId": 19, "content": "응답 계약 실측" }
}
""".trimIndent()

assertThrows(MissingFieldException::class.java) {
json.decodeFromString(
BaseResponse.serializer(DailyQuestionAnswerResponseDto.serializer()),
body,
)
}
}

@Test
fun `content 가 빠져도 빈 문자열로 성공하지 않는다`() {
val body =
"""
{
"status": 200, "code": 200, "message": "성공",
"data": { "userDailyQuestionId": 19, "isDraft": false }
}
""".trimIndent()

assertThrows(MissingFieldException::class.java) {
json.decodeFromString(
BaseResponse.serializer(DailyQuestionAnswerResponseDto.serializer()),
body,
)
}
}
}
Loading