Skip to content

release(platform): 누적 188 PR을 Firebase QA로 배포한다 (#2241) - #2242

Merged
OnlyReadOneBook merged 908 commits into
mainfrom
develop
Oct 2, 2026
Merged

OnlyReadOneBook merged 908 commits into
mainfrom
develop

Conversation

@OnlyReadOneBook

@OnlyReadOneBook OnlyReadOneBook commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

먼저 볼 파일: .github/workflows/release-distribution.yml, app/build.gradle.kts, docs/release/distribution.md.

배포 범위

마지막 성공 Firebase 배포 이후 develop에 머지된 PR 188건을 main으로 승격합니다. 배포 시점 자동 판정 #2240을 포함하며 이 PR에서 새 앱 구현은 추가하지 않습니다. 연결된 완료 이슈는 GitHub의 실제 closingIssuesReferences를 전량 대조한 202건입니다. PR 제목에만 있는 이슈는 완료 목록에 임의로 추가하지 않았습니다.

  • 마지막 성공 배포: run 33685058482, source 36c4bc56d1288d18b7448b811433a91227a65721
  • 후보 source: 8db2d75dad2f881b5b33a1e189bed64a6be65b9b
  • main base: f5ad611b23a32c6db2de4795bd9cee9fb32aacf0
  • main과 합친 결과 tree 258210245e1bd88203be58a7b7767b29b2fc429a는 develop tree와 같으며 충돌이 없습니다.

Collect Release Scope의 50 PR 제한으로 자동 수집이 이 누적 범위를 처리하지 못합니다. 아래 목록은 전체 first-parent 이력 188개와 각 PR의 실제 MERGED 상태·base·merge SHA, 페이지 끝까지 조회한 이슈 연결을 대조한 결과입니다. 일부만 잘라낸 목록이 아닙니다.

검증

  • 후보의 앱·빌드 입력이 같은 756e456f에서 Release AAB Preflight가 빌드·기동 검증에 성공했습니다. 이후 #2240은 판정 CI와 운영 문서만 추가했습니다.
  • ci(platform): 댓글 없는 QA 배포 시점 판정 (#2239) #2240 최신 HEAD의 단위 테스트·보안 분석·문서 검사 및 관련 저장소 체크가 완료됐습니다.
  • 후보 source의 QA Distribution Decision은 미배포 앱 관련 PR 144건·모듈군 11개·최장 대기 30일로 배포 필요를 권고했습니다. 이것은 통합 앱의 수동 QA 완료를 뜻하지 않습니다.
  • 후보 source의 Develop Validation은 전체 단위 테스트와 스크린샷 검사에 성공했습니다. 이번 PR의 Release AAB Preflight는 릴리스 빌드·기동에 성공했고, Baseline Profile 검증도 성공했습니다.
  • API 26·30·34·36 계측은 10월 3일 정기 실행에서 실제 테스트 실행까지 모두 성공했습니다. 이 source 756e456f와 후보 사이의 차이는 새 배포 판정 CI 3개 파일과 운영 문서뿐이며 앱·빌드·계측 입력은 같습니다. 이번 PR의 계측 재실행은 본문 갱신과 이전 인프라 재시도의 concurrency로 취소가 섞였으므로, 취소를 통과로 표기하지 않고 동일 입력의 완료된 검증을 사용합니다.
  • 아래 수동 QA 항목은 배포본으로 실행할 계획이며 통과 결과로 표시하지 않습니다.
  • 통합 범위의 Repository Quality는 develop에 이미 있던 미연결 비밀번호 찾기 화면·ViewModel 선언 9개를 검출해 실패했습니다(job). 참조 오탐으로 분류하지 않으며 해당 진입 흐름은 이번 완료 기능에 포함하지 않습니다. 요청된 현재 develop의 QA 배포에서 이 품질 실패와 수집 제한 실패를 명시적인 제한으로 기록하고, 앱 빌드·기동·계측 결과는 별도로 확인합니다.

릴리스 승격의 담당 검사

현재 모듈 담당 가드에는 develop→main 승격 예외가 없어 이미 develop에 머지된 다른 담당 모듈 165개 파일을 새 담당 외 구현으로 판정합니다. 이 PR은 해당 트리를 그대로 승격하므로 기존 issue-assignee-exempt 라벨로 담당 검사만 제외합니다. 대표 이슈 #2241의 실제 담당자는 1hyok이며, 앱 검증과 배포 산출물 검증은 실행합니다.

관련 이슈

Closes #2241

포함 이슈

QA 포인트

대상은 develop 8db2d75dad2f881b5b33a1e189bed64a6be65b9b에서 만드는 Firebase release APK입니다. 아래 항목은 이번 통합 APK에서 실행할 계획이며, 기존 PR의 단위·계측 테스트 결과나 과거 기기 QA를 이번 APK의 통과 결과로 대체하지 않습니다. 계정 생성·비밀번호 변경·수신자 수정·노트 저장·첨부 삭제·녹음은 테스트 계정과 테스트 자료로만 수행합니다.

범위 해석과 미검증 조건

  • 위 목록의 현재 상태는 모두 미실행이다. 실행자는 APK·source SHA, 기기/API, 테스트 계정 구분, 사전조건, 실제 결과, 실패 재현 화면·민감정보를 가린 로그를 항목별로 남긴다. 실패 주입·이전 APK·특정 기기를 확보하지 못한 항목은 미검증으로 유지한다.
  • 비밀번호 찾기 화면 구현 PR #1624는 포함되지만 최종 OnboardingNavHost.kt의 로그인 링크는 FindIdRoute로 이동하고 FindIdScreen.onNextClick은 빈 콜백이다. 비밀번호 재설정의 프로덕션 진입 연결을 이번 완성 기능으로 안내하지 않는다. 설정의 로그인 사용자 비밀번호 변경 PR #2094와 구분한다.
  • 알림 목적지 계약 PR #1641은 Intent에서 큐까지의 전달 범위다. 실제 목적지 탭 이동은 후속 #1795이므로 이번 QA에서 완성된 화면 이동으로 가정하지 않는다.
  • ForceUpdateGate는 스토어 배포 플래그가 없는 Firebase APK를 막지 않는다. Firebase QA의 기대결과는 업데이트 팝업 없이 정상 진입하는 것이다. Play 산출물의 강제 업데이트 동작은 별도 검증 대상이다.
  • 원본 PR에서 과거 단위·기기 검증을 보고한 사실과 이번 통합 APK의 검증 상태를 분리한다. full 계측 통과도 실제 서버·업그레이드 설치·실기기 생체 인증·FCM 도달을 대신하지 않는다.
  • 저장·삭제·동의 변경 결과는 화면 재진입만으로 판정하지 않는다. 로컬 캐시와 구분해 실제 테스트 서버 재조회 결과를 대조한다. 같은 QA 항목 안에서도 정상·실패·지연·취소·기기별 분기의 실행 상태를 각각 남긴다.

CI Test Plan

{
  "androidTest": {
    "mode": "full",
    "reason": "마지막 Firebase 배포 이후 develop 누적을 main으로 승격하는 통합 릴리스입니다. 전역 Gradle 설정·build-logic·app 및 계측 하네스 변경이 포함되어 전체 계측 레인을 검증합니다.",
    "tests": []
  }
}

OnlyReadOneBook and others added 30 commits September 13, 2026 17:03
루트 Nav2 `NavHost` 가 `popEnterTransition`·`popExitTransition` 을 넘기지 않아 pop 기본값인
`fadeIn`/`fadeOut(tween(700))` 을 그대로 썼다. 앞 화면이 줄지 않고 제자리에서 흐려지기만 해,
제스처 진행 중 두 화면이 같은 크기로 겹쳐 그려졌다. 실기기(S25, Android 16)에서 재현된 증상이다.

값은 새로 정하지 않고 navigation3-ui 1.1.6 의 `defaultPredictivePopTransitionSpec()` 을
복제했다. 피처의 Nav3 로컬 스택은 `transitionSpec` 을 안 넘겨 이미 그 기본값으로 축소되므로,
같은 값을 core/ui 한 곳에 두고 두 엔진이 나눠 쓴다. 루트가 `NavDisplay` 로 바뀔 때(#1702)
같은 상수를 `predictivePopTransitionSpec` 에 그대로 넘길 수 있다.

부작용이 하나 딸린다. navigation-compose 2.9.8 은
`composeNavigator.isPop.value || inPredictiveBack` 으로 같은 popEnter/popExit 을
고른다(`NavHost.kt:573,587`). 제스처가 아닌 pop, 즉 화면 안 뒤로가기 버튼과 시스템 back 의
애니메이션도 fade 에서 축소로 함께 바뀐다. Nav3 는 `popTransitionSpec` 과
`predictivePopTransitionSpec` 이 갈려 있지만 Nav2 는 갈림이 없어 우회로가 없다.

모서리 라운딩은 붙이지 않았다. `ExitTransition` 은 clip 이나 shape 를 표현하지 못하고 Nav3
기본값도 `scaleOut` 뿐이라, `docs/qa/predictive-back.md` 의 "모서리가 둥글어지며" 기대 문구를
실제 기준에 맞춰 고쳤다.

회귀는 konsist 가드 하나로 잠갔다. 두 인자는 기본값이 있어 지워도 컴파일이 통과하고, 어긋난 축이
그리기 하나라 기존 `PredictiveBackNavigationTest` 5건이 전부 초록으로 지나간다. 인자 제거,
값 교체, app 안 재정의, core/ui 상수 이름 변경 네 가지 변이로 실제로 빨개지는 것을 확인했다.
App Distribution 배포가 전부 versionCode 1 이라 Crashlytics 에서 배포본을
구분할 수 없다(#1865). distribution.md 의 "App Distribution 이 versionCode 로
구분하지 않으므로 올릴 이유가 없다" 는 근거는 참이지만 결론이 틀렸다. 이 채널의
APK 가 크래시를 보내는 곳은 같은 Firebase 프로젝트의 Crashlytics 이고, 거기서는
배포본이 versionName (versionCode) 로 묶인다. release versionName 은 고정이라
가를 축이 versionCode 하나뿐이다.

두 채널이 각자 run_number 로 같은 식을 쓰면 값이 겹친다. release-distribution.yml
(main push 자동)과 release-play-internal.yml(수동)의 run 카운터가 독립이기
때문이다. 겹치면 Crashlytics 가 두 채널 빌드를 한 버전으로 묶고, 겹친 값이 Play
원장에 들어가면 resolve-play-version-code.mjs 의 최댓값 비교 때문에 run_number 가
따라잡을 때까지 Play 배포가 빌드 전에 거부된다.

그래서 대역을 1,000,000,000 에서 가르고 Firebase 를 아래, Play 를 위에 둔다.
순서가 이쪽이어야 Firebase 산출물이 Play 원장에 들어가도 최댓값 비교를 막지
못한다. Play 원장이 비어 있을 때와 첫 수동 업로드가 1 일 때 모두 성립하고,
Firebase 수열의 최솟값 101 과도 겹치지 않는다. 승격 자체는 따로 금지한다. 근거는
문서에 이미 있는 "Firebase 는 출시 뒤에도 내부 QA 전용" 과 2026-09-06 별도 설치
채널 결정(인증서 상이)이다.

착수 게이트였던 회귀 판정 축은 Firebase 문서로 확인한 만큼만 적었다. 재개봉은
"닫을 때 Crashlytics 가 알지 못하던 앱 버전" 에서의 재발만 본다는 것이 명시돼
있고, Android 에서 그 버전의 정체가 versionName·versionCode 중 무엇인지는 명시가
없다. 확정(1.0 (1) 고정인 동안 재개봉은 일어날 수 없다)과 미확정(versionCode 만
올려서 되살아나는지)을 갈라 적고, 실측 완료 조건이 남는 이유로 뒀다. 콘솔 토스트
문구는 근거로 쓰지 않았다.

구현(Firebase 번호 주입·Play 대역 하한·워크플로)은 별도 범위다. 선행으로 #1990 을
적었다. 지금 develop 은 versionCode != 기본값만으로 storeDistributedBuild 를
세우고 그 값이 ForceUpdateGate 에 걸려 있어, 먼저 주입하면 QA 빌드에 강제 업데이트
관문이 켜진다.
여섯 필드만 고르던 비교라 고르지 않은 필드로 두 자리가 갈려도 그대로 지나갔다.
bodySmallB 한쪽에만 letterSpacing = (-0.0025).em 을 넣고 실측하니 옛 잠금은 4건이
전부 초록이었고, TextStyle 전체를 비교하도록 바꾸자 bodySmall 이 letterSpacing
차이로 실패했다. 두 쌍은 나머지 필드가 전부 기본값이라 비교 범위를 넓혀도 현재 값은
그대로 통과한다. 렌더 변화가 없으니 골든 재생성도 없다.
「이 파일의 스타일은 전부 그 아래」가 사실이 아니었다. 기본 설정(밀도 480 · 글꼴 1.0)
하한인 약 19sp 위에 h1 28sp · h2 24sp · h3 20sp 가 있고, 위 표를 잰 기기(밀도 420 ·
글꼴 배율 0.8, 하한 약 28sp)에서도 h1 은 58.8px 로 하한을 넘긴다. 하한만으로 닫히는
것은 18sp 이하고 제목이 Regular 인 근거는 획 대비가 아니라 시안이라는 것을 적었다.
바로 다음 문단의 시안 실측이 그 근거다. #2061
주석이 "gray5 로도 WCAG AA 4.5:1 은 못 넘는다(2.57:1)" 라고 적고 있었다.
2.57:1 은 교체 전 #9E9E9E 의 값이다. 라이트 gray5 는 #1798 이 #6E6E6E 로
내린 뒤라 gray1(#FAFAFA) 위 대비가 4.885:1 이고 본문 최소 4.5:1 을 넘는다.
같은 문단의 "gray5(#9E9E9E)" 표기도 라이트에서는 맞지 않아 걷어냈다.

결정 주체도 "디자이너 결정이 필요하고" 로 남아 있었다. #1636 은 2026-09-12
본문 개정에서 PM 확인으로 바뀌었고 디자이너에게 따로 묻지 않는다고 못 박았다.

수치는 WCAG 2.x 상대 휘도 식으로 재계산했다. #6E6E6E 4.8851:1,
#9E9E9E 2.5668:1, 종전 gray4 #BDBDBD 1.8000:1.

주석만 바뀐다. 색은 그대로 두었다. 비선택 탭 색과 선택 구분 방식은 #1636 의
PM 대기 사항이다. 렌더된 스크린샷 81장이 base 와 바이트 동일해 골든은 불변이다.
notify-affected-pull-requests job 이 코멘트를 남기는 대상은 언제나 PR 이다.
GitHub 은 issues/{n}/comments 의 필요 스코프를 대상 타입으로 가르므로 PR 에
쓰려면 pull-requests 쓰기가 필요하다. read 로 두면 목록 GET 은 통과하고 첫
POST 에서 403 이 나 스택 상위 PR 안내가 통째로 막힌다.

merge-order-guard-policy.test.mjs 는 issues 쓰기만 계약으로 박아 두고
pull-requests 칸은 비워 뒀다. 그 빈칸이 read 로 머문 채 머지된 경로라
같은 자리에 단언을 더한다. 권한을 read 로 되돌리면 그 테스트가 실패한다.
PlusBadgeButton 이 원을 colors.black(#000000)으로 칠해 시안보다 한 단계
진하다. 시안 컴포넌트 52:7886 의 fill 은 #212121 이고, 그 값은 팔레트의
Gray9 와 같다.

Black 토큰 정의는 건드리지 않는다. 이 컴포넌트가 고르는 토큰만 바꾼다.
core 의 다른 어두운 원형 버튼인 PenFloatingActionButton 과
AfternoteFloatingActionButton 이 이미 gray9 를 쓰고 있어, 시안 정합과
core 내부 일관성이 같은 방향이다.

새 시맨틱 토큰은 만들지 않는다. 한 컴포넌트를 위한 토큰이고 gray9 가
이미 이 역할의 기존 토큰이다.
브랜치를 지우면 그 브랜치를 base 로 하던 열린 PR 이 GitHub 정책에 따라 즉시 닫힌다.
닫는 코멘트도 리뷰도 남지 않아, 승인까지 받은 #1626 이 사라진 사실이 이틀 동안
드러나지 않았다. 삭제 자체는 막지 않고 닫힘만 되돌린다.

on: delete 워크플로가 삭제 이벤트를 받아, 타임라인에서 마지막 closed 바로 앞이
base_ref_deleted 인 미병합 PR 만 다시 열고 경위를 코멘트로 남긴다. 같은 이름의
브랜치가 다시 올라오지 않아 base 가 없으면 되살리는 대신 끊긴 사슬을 추적 이슈로
기록한다. 이미 머지된 PR 의 base 삭제 같은 정상 정리에는 반응하지 않는다.

판정과 문구는 recover-base-deleted-pull-requests.mjs 에 두고 정책 테스트로 고정한다.
워크플로 YAML 에는 분기가 없다. PR 코멘트가 issues 엔드포인트로 나가도 대상이 PR 이면
pull-requests: write 가 필요하다는 #2068 의 교훈을 처음부터 권한에 반영했다.
…n-guard

maintenance(platform): base 브랜치 삭제로 자동 폐쇄된 PR 을 되살린다 (#2076)
fix(platform): 스택 안내 코멘트에 pull-requests 쓰기 권한을 준다 (#2068)
ListItem.isDraft, getPagedDrafts, draftOnly 쿼리를 제거한다. 셋 다 소비처가
없었다. ListItem.isDraft 의 KDoc 은 목록에서 여는 방향이 갈린다고 적었지만
그 분기를 하는 화면 코드가 없고, develop 에는 임시저장 기능 자체가 없다
(요청 payload 의 isDraft 도 presentation 의 참조도 0건).

목록에서 임시저장을 구분해 보여줄지는 #1792 가 PM 에게 물어 둔 결정이고 아직
답이 없다. 그 답에 따라 기능이 안 들어갈 수도 있어 지금 계약을 박으면 죽은
코드가 된다.

남기는 것은 #1745·#1762 의 범위인 조회·요청 계약이다. DraftDetail 과
getDraftDetail 은 이어쓰기 프리필이 발행 상세 매퍼의 requireNotNull(playlist)
에 걸리는 것을 푸는 본체이고, 요청 payload 의 isDraft 는 임시저장으로 저장하는
계약이다. 둘 다 소비자는 #808 이다.

AfternotePagingSource 의 pageSize 고정은 유지한다. Refresh/Append 힌트가
서버 offset 을 어긋내는 별개 버그 수정이다. 그 회귀 테스트도 draft 축만 걷어내고
남겼다.
제목의 `(#N)` 하나만 보던 탓에, 한 PR 이 본문에 닫는 이슈를 둘 적어도 두 번째
이슈는 라벨도 링크 코멘트도 받지 못했다. Repository Quality 가 제목에 `(#N)` 을
정확히 하나만 허용하므로 제목에서 나오는 이슈는 구조적으로 최대 한 건이고,
GraphQL 질의는 본문을 가져오지도 않았다.

base 가 기본 브랜치가 아닌 스택 PR 은 closingIssuesReferences 가 비어
Development 칸까지 비므로 라벨이 유일한 신호다. PR #2051 의 두 번째 이슈
#2045 가 그렇게 묻혀 미착수 조사에서 미착수로 읽혔다. 지금 저장소 전체에
같은 이유로 신호가 빠진 이슈가 여섯 건이다.

판정은 validate-pr-issue-link 의 extractClosingIssueNumbers 를 그대로 쓴다.
게이트가 인정하는 링크와 라벨이 인정하는 링크가 갈리지 않게 하려는 것이고,
merge-order-guard 가 본문에서 닫는 이슈를 뽑는 패턴과도 같은 모양이다.
Refs·Part of·Related to 는 종전대로 줍지 않으므로, 참고로 언급했을 뿐인
이슈에 라벨이 붙지 않는다는 종전 결정은 그대로다.

Closes #2082
설정의 비밀번호 변경이 이용 불가 안내 대신 실제 화면으로 간다. 서버·네트워크·데이터·도메인
계약(`AccountRepository.passwordChange` → `POST auth/password/change`)은 이미 develop 에 들어와
있었고 소비처만 없었다. 이 커밋이 그 첫 소비처다.

화면은 서버 계약과 같은 두 칸(현재·새 비밀번호)이다. 새 비밀번호 규칙은 BE
`PasswordValidation.REGEX` 를 그대로 옮겨 제출 전에 막는다. 숫자를 `[0-9]` 로 명시한 이유는
Android 정규식의 `\d` 가 유니코드 숫자까지 세어 서버 판정과 갈리기 때문이다(#1628). 현재
비밀번호에는 규칙을 걸지 않는다. 옛 규칙으로 만든 비밀번호를 쓰는 계정이 변경 자체를 못 하게
되기 때문이다.

`AccountRepositoryImpl.passwordChange` 만 `.mapAccountFailure()` 가 빠져 있어 1206(새 비밀번호가
기존과 같음)과 1702(소셜 가입 계정)가 원본 예외로 새고 있었다. 붙이고 회귀 테스트로 잠갔다.

ViewModel 은 konsist 규칙 B 에 따라 `MviViewModel` 을 상속한다. 그 결과 `MviViewModel` 과
`ObserveSignal` 이 첫 프로덕션 참조를 얻어 `INTENTIONAL_PRODUCTION_CONTRACTS` 의 두 항목이
stale 이 되므로 함께 지운다.

base 는 develop 이 아니라 PR #2002 의 `fix/2000-setting-menu-feedback` 이다. 그 PR 이 같은 6줄을
`onUnavailableClick` 으로 몰아넣고 `SettingScreen` 시그니처에서 파라미터를 걷어내는 중이라
develop 기준으로 짜면 같은 줄을 두 번 고치게 된다. `SettingFlowTest` 의 이용 불가 목록에서도
비밀번호 변경을 뺐다.

나머지 5개 목적지(고객센터, 문의, FAQ, 이용약관, 개인정보 처리방침)는 제품 결정 대기라 그대로
둔다. 시안 대조는 하지 못했다. 이 화면 전용 시안을 열 수단이 없어 배치는 회원가입 3단계와
비밀번호 찾기의 선례를 따랐다. 현재 비밀번호 불일치(1202)의 실패 번역은 PR #2020 이 그 번역표를
파일째 옮기는 중이라 이 커밋의 범위에서 뺐다.

Closes #2083
Refs #564
설정 > 수신자 목록이 0건이면 LazyColumn 이 아무것도 그리지 않아 상단바만 남은 빈
화면이 됐다. 노션 2차 QA 6번이 짚은 빈 목록 안내와 등록 진입점 부재 그대로다.

receivers 가 비면 목록 대신 안내 문구와 [수신자 등록하기] 버튼을 그린다. 분기 모양은
같은 모듈 NoticeListScreen 의 0건 분기와 같고, 문구·일러스트 구성은 확정 시안
4163:20979 을 구현한 애프터노트 SelectReceiverEmpty 를 준거로 삼았다. 등록 CTA 는
Scaffold bottomBar 가 아니라 본문 안에 둔다. 뒤에 목록 상태 슬롯(#1281)으로 이 본체를
통째로 옮길 때 빈 상태만 CTA 를 잃지 않게 하려는 것이다.

범위는 #556 에서 떼어 낸 목록 0건 축 하나다. 이 커밋으로 #556 이 닫히지 않으므로 대표
이슈를 #2086 으로 새로 판다. 나머지 두 축은 이번 범위 밖이다. 검색 결과 0건은 검색어를
private 스캐폴드가 쥐고 있어 core:ui ReceiverSelectScreen 안에서 닫아야 하는데 그 items
블록을 #2057 이 재작성 중이고, 설정 사후 전달 조건 선택 화면의 0건은 #1998 PM 답변에 걸린
#2051 의 목록 상태 분기에 얹힌다. 타임레터 RecipientListScreen 은 공용 컴포넌트를 쓰지
않는 별개 구현이라 별도 이슈다.

Closes #2086
Refs #556
서버는 10MiB 를 넘는 업로드를 code 1803(FILE_SIZE_EXCEEDED)으로 거절하는데 FE 에 번역
자리가 없어 소비처가 일반 실패로 받아 재시도를 권한다. 파일 크기가 그대로인 한 재시도는
같은 거절을 다시 받는다.

- core:domain 에 FileUploadFailure 루트와 FileSizeExceeded 하위 타입을 둔다. 사진·영상 두
  저장소가 같은 POST /files/presigned-url 에 원본 크기를 그대로 실어 보내 같은 코드를 맞으므로
  매체별로 가르지 않는다.
- core:data 의 mapUploadFailure 가 code 1803 만 치환하고 사유를 확인하지 못한 실패는 원본
  그대로 흘린다. 전송 실패와 S3 PUT 실패는 재시도가 실제로 통하는 경로라 건드리지 않는다.
  PushSettingFailureMapper 가 세운 별도 파일 관례를 따른다.
- PhotoUploadRepositoryImpl·VideoUploadRepositoryImpl 이 runCatchingCancellable 결과에
  번역을 체인한다.

표시 문구는 이 커밋 범위가 아니다. 애프터노트 에디터 연결은 시안 정본(4327:72071) 대조가
남아 #1868 에 그대로 있고, 마음의 기록은 #2080, 타임레터는 #2081 이 맡는다.

Closes #2089
Refs #1868
발행 상세 매퍼는 PLAYLIST 에 playlist 가 없으면 던진다. 임시저장은 카테고리별 필수값 검증을
건너뛰므로 곡을 한 곡도 안 담으면 서버가 playlist 자체를 생략하고, 그 응답이 발행 매퍼로 가면
이어쓰기가 열리지 않는다. 같은 GET /afternotes/{id} 를 관용해서 읽는 경로를 따로 둔다.

- core 도메인에 DraftDetail 과 DraftContent 를 둔다. 공통값만 직접 소유하고 종류별 값은 content
  가 배타적으로 표현하는 구조는 발행 Detail·DetailContent 와 같다. 다른 것은 필수값 보장뿐이다 —
  여기서는 아직 안 쓴 칸이 null·빈 목록으로 온다.
- 종류별 값을 평평하게 늘어놓지 않은 이유는 그렇게 두면 ESTATE 에 계정 정보 칸이, GALLERY 에 곡
  목록 칸이 생기기 때문이다. 서버가 그 값을 실어 보내도 아무도 모르게 버려지고 잘못된 조합이
  컴파일된다. 소비처(에디터 프리필)도 결국 종류로 갈라 sealed 를 만들므로 접는 지점을 도메인에 둔다.
- 발행용 DetailContent 를 재사용하지 않는 것은 그쪽 credentials 가 non-null 계약이라 「아직 안 씀」
  을 표현할 자리가 없어서다. 재사용하려면 발행 계약을 임시저장 사정으로 느슨하게 만들어야 한다.
- toDraftDomain 은 종류 해석만 엄격하다. 해석 못 하는 category 는 폼을 못 고른다(#1048).

Closes #1745
임시저장 상세 조회(#1745)를 밑단 PR 로 떼면서 그 몫을 이 브랜치에서 걷어낸다. 충돌 세 곳은 전부
밑단의 sealed 구조를 취했다 — DraftDetail·DraftContent, 그 매퍼, 매퍼 회귀 테스트.

이 브랜치에 남는 것은 #1762 의 저장 payload 분리와 목록 요청 계약이다.
앞 주석은 기본값을 두면 append 가 한 번 더 돈다고 적었는데 근거가 없다. AfternotePagingSource 가
loadSize 힌트를 무시하므로 이 값은 서버 요청도 수신 개수도 바꾸지 않는다. AfternotePagingRequestTest
가 초기 30개 힌트에도 (0..4) to 10 으로 요청된다는 것을 이미 고정하고 있다.
AfternotePagingSource 가 loadSize 힌트를 무시하고 size 를 PAGE_SIZE 로 고정하므로 이 값은 서버
요청도 수신 개수도 바꾸지 않는다. 동작에 영향이 없는 값을 config 에 두면 그것이 동작을 정한다고
읽힌다. 페이지 크기를 고정하는 이유는 AfternotePagingSource 의 주석이 적고, 힌트를 무시한다는
사실은 AfternotePagingRequestTest 가 고정한다.
feat(afternote): 임시저장 조회와 저장 payload를 분리한다 (#1762)
제목과 작성일이 둘 다 가중치 없이 한 Row 에 놓여 있어, 긴 제목이 가용 폭을
모두 쓰면 뒤에 측정되는 작성일이 0dp 를 받았다. 글자 배율 2.0 과 8글자 이름이
겹치는 조합에서 「2026.09.13」 이 열 줄로 한 글자씩 쪼개지고 카드 높이가
400dp 대까지 늘었다. 날짜 노드의 경계도 0 이라 접근성에서도 짚이지 않았다.

Row 는 가중치 없는 자식을 먼저 재므로 제목에만 weight 를 준다 — 작성일이 제
폭을 온전히 받고 제목이 남은 폭을 쓴다. fill = false 라 짧은 이름에서는
SpaceBetween 이 종전과 같은 자리에 둘을 놓는다. 형식이 고정된 작성일은
maxLines = 1 로 두어 어떤 배율에서도 줄이 나뉘지 않게 한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY5b56rba9yXz5JJCd5P26
수정 저장은 PATCH 라 보낸 값이 기존 기록을 덮는다. 프리필이 실패하면 화면은
빈 폼이므로, 그 상태의 저장은 사용자가 한 번도 보지 못한 원본을 자기 입력으로
갈아치운다. 종전 가드 `isEditingDraft && draftLoadError != null` 의
isEditingDraft 는 route.isDraft 일 때만 섰고, 저장 경로는 임시저장 이어쓰기와
정식 기록 수정이 똑같이 PATCH 라 정식 수정만 가드 밖에 있었다.

두 진입을 함께 세는 isEditingExistingRecord 로 바꾸고, 판정을 「실패했는가」가
아니라 「읽었는가」(draftLoaded)로 옮긴다. 실패 플래그만 보면 조회가 끝나지
않은 창에서 오류도 없고 프리필도 없는 상태가 열린다.

DiaryRecordDateWireTest 의 날짜 생략 단언은 조회 실패 대신 「조회는 성공했으나
그 기록의 날짜를 해석할 수 없는」 경우로 세운다 — 조회 실패는 이제 저장 자체가
막혀 그 단언에 도달하지 못한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY5b56rba9yXz5JJCd5P26
submit() 은 「대상 레코드도 없고 오늘 질문도 없다」를 만나면 조회를 다시 걸어
사용자가 재시도할 수 있게 한다 (#565). 그 복구가 진입을 보지 않고 언제나
loadTodayQuestion() 을 불러, 과거 답변 수정에서 프리필이 실패하면 questionId 가
오늘 것으로 채워졌다. 다음 저장은 원래 답변의 PATCH 가 아니라 오늘 질문의 신규
POST 로 나가 고치던 글이 다른 질문에 남았다.

복구가 부를 대상을 진입이 정하게 한다 — editingAnswerId 가 있으면 같은 대상을
다시 조회한다. 신규 작성의 오늘 질문 재시도는 그대로다.

함께, 수정 진입은 대상을 읽은 뒤에만 저장을 연다. 못 읽은 채 저장하면 draftId 가
null 이라 PATCH 가 아니라 POST 로 나간다 — 일기 화면(#2027)과 같은 「실패했는가」가
아니라 「읽었는가」 판정이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY5b56rba9yXz5JJCd5P26
일기와 데일리답변이 복수 첨부를 Boolean 하나(isUploadingImage)로 잠그고 있었다.
두 가지가 어긋났다.

먼저 끝난 하나가 잠금을 통째로 풀었다 — 첨부 A·B 를 잇따라 고르고 A 만 끝나면
B 가 아직 올라가는 중인데 저장이 열렸고, 그 저장에는 B 가 빠진 본문이 실렸다
(#2029). 그리고 취소에는 내려놓을 자리가 없었다 — 업로드 코루틴은 작성 화면의
rememberCoroutineScope() 가 소유하므로 구성 변경이면 코루틴만 끊기고 Result 는
오지 않는다. 잠금이 참으로 굳어, 이미 쓴 내용을 저장하려면 관계없는 첨부를 한 번
더 성공시켜야 했다 (#2030).

잠금을 uploadingImageCount 로 세고 isUploadingImage 를 그 파생값으로 둔다.
올리고 내리는 짝은 try/finally 라 성공·실패·취소 어느 쪽으로 끝나든 자기 몫만
내려놓는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY5b56rba9yXz5JJCd5P26
fix(receiver): 발신자 상세 VM 이 route 를 assisted 로 받아 진입 크래시를 없앤다 (#2168)
build(app): app 이 직접 쓰지 않는 feature:afternote:domain 의존을 빼고 Nav2 회귀 주석을 고친다 (#2172)
…onse

fix(core): 수신자 상세 응답 계약을 맞춰 수정 화면 조회 실패를 막는다 (#2155)
refactor(setting): Nav3와 수신자 목록 MVI 진입을 선행으로 정리한다 (#1695)
…ue-entry

fix(core,app): Nav3 화면의 predictive back 배경을 불투명하게 유지한다 (#2145)
…ination

fix(setting): 설정의 비밀번호 변경 목적지를 연결한다 (#2083)
…로 올린다 (#2045)

설정이 애프터노트 것을 베껴 만든 조회 실패 안내와 갱신 실패 배너를 core:ui 의 ReceiverListLoadFailure·ListRefreshErrorBanner 로 올리고 문구를 core_ui_ 리소스로 옮긴다.
설정 사본과 애프터노트 원본(수신자 선택의 private 안내, shared 배너), 그 setting_·afternote_ 문구를 지우고 두 기능이 core 것을 쓴다.
배너는 애프터노트 홈 목록도 쓰는 범용이라 receiver 가 아니라 loading 패키지에 둔다. 두 쪽 렌더는 그대로다.
fix(core): 수신자 목록 캐시의 로그인 세션 경계 보장 (#2135)
fix(core): 초성 점프 인덱스를 렌더되는 행에서 파생 (#939)
…apper

refactor(afternote): 임시저장 프리필 계약의 이름과 매퍼 모양을 정리한다 (#2115)
refactor(afternote): 비교 스냅샷을 카테고리별로 분리한다 (#2163)
# Conflicts:
#	core/data/src/main/java/com/afternote/core/data/repoimpl/UserReceiverRepositoryImpl.kt
…tate

fix(core,setting): Nav3 목록의 조회 상태와 MVI 재시도를 연결한다 (#2045)
…elop/github-actions-updates-cb21f7ad26

chore(deps): bump the github-actions-updates group with 4 updates (#2139)
fix(ci): CI 파서의 정규식 처리 지연과 XML 이중 해제 수정 (#2218)
…ncy-graph

fix(ci): 소스 변경만 있는 스택 부모의 의존성 기준선 생성 (#2185)
…cision

ci(platform): 댓글 없는 QA 배포 시점 판정 (#2239)
@OnlyReadOneBook OnlyReadOneBook added the issue-assignee-exempt 담당자 대조 가드 우회 — 대표 Issue 담당자가 아니어도 이 PR 은 통과한다 label Oct 2, 2026
@github-actions
github-actions Bot requested a review from Sadturtleman October 2, 2026 23:15
@github-actions github-actions Bot added the android-test PR head에서 Android Managed Device 계측 테스트 실행 label Oct 2, 2026
@OnlyReadOneBook
OnlyReadOneBook merged commit e4efb80 into main Oct 2, 2026
82 of 103 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

android-test PR head에서 Android Managed Device 계측 테스트 실행 issue-assignee-exempt 담당자 대조 가드 우회 — 대표 Issue 담당자가 아니어도 이 PR 은 통과한다

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants