Skip to content

[NM-449] 알약 식별 흐름의 spec 불일치 3건 (분석 실패 버튼 · 한도 팝업 · 탭 진입 게이트) - #166

Merged
ParkMazorika merged 4 commits into
developfrom
feature/NM-449-pill-identify-flow
Sep 9, 2026
Merged

ParkMazorika merged 4 commits into
developfrom
feature/NM-449-pill-identify-flow

Conversation

@ParkMazorika

@ParkMazorika ParkMazorika commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

관련 이슈

  • NM-449

변경 요약

알약 식별 흐름에서 spec과 어긋난 화면 동작 3건을 맞춘다. 커밋 3개로 나눠 두었고 각각 독립적으로 빌드된다.

변경 내용

1. [NM-449] fix: 분석 실패 화면의 두 버튼을 spec 대로 되돌리고 오프라인 문구를 다듬는다

spec 의 분기 (feature/pill-recognition/README.md §식별 → 인식 결과):

E -->|재시도| B   재시도는 인식으로 되돌아간다 — 같은 사진으로 다시 분석
E -->|뒤로| X     뒤로는 촬영/미리보기로 복귀

둘 다 어긋나 있었다. 재시도가 pop 이라 사실상 spec 의 '뒤로' 였고(분석을 다시 하지 않는다), 뒤로는 홈으로 나가 사진을 다시 고를 기회 없이 흐름이 끊겼다. "이 화면엔 돌아갈 이전 단계가 없다"고 적혀 있었지만 showPreview 가 push 한 PhotoPreviewVC 가 스택에 그대로 남아 있다 — 실제로 재시도가 그리로 가고 있었다.

  • startIdentification → makeLoadingVC(image:) 로 분리. 재시도는 실패 화면을 새 로딩 VC 로 replace 해 온디바이스 탐지부터 다시 돌린다(VM 은 종료 상태를 방출한 뒤라 재사용 불가).
  • 뒤로(네비바·푸터 모두)는 pop 으로 미리보기에 되돌린다.

곁가지 — 오프라인 문구가 원문 그대로 나갔다.

URLSessionTask failed with error: The Internet connection appears to be offline.
   ↓
인터넷 연결을 확인해 주세요.

BaseService.interpret 은 응답 본문이 있어야 APIError.network 를 만드는데, 서버에 닿지 못한 요청은 응답 자체가 없어 그 분기를 못 타고 원문을 돌려줬다. APIError.offline 을 추가하고 Moya → AFError → URLError 체인을 풀어 연결 계열 코드를 여기로 모은다. 타임아웃은 제외 — 연결은 됐는데 느린 것이라 "인터넷 연결을 확인하라"가 맞는 안내가 아니다(pill-attributes 는 서버 Gemini 처리로 60초 타임아웃).

2. [NM-449] fix: 한도 팝업을 확인해도 찍은 사진을 잃지 않는다

spec 은 "확인 시 현재 화면에 머무른다"(§식별 횟수 제한 §한도 안내 팝업). 지금은 popToRootViewController + 홈 탭 전환이라 찍은 사진이 통째로 날아간다.

옛 근거는 "미리보기에 남겨두면 재촬영·이 사진 사용 둘 다 막혀 막다른 길" 이었다. 그런데 미리보기는 남은 횟수를 0회·경고색으로 표시하는 화면이라(§남은 횟수 표시) 막다른 길이 아니라 왜 막혔는지 보이는 자리다. 사진을 버려서 얻는 게 없다.

호출부가 둘이고 서 있는 자리가 달라 각각 처리했다.

경로 처리
요청 전 게이트 (이 사진 사용 시 remaining 0) 이미 미리보기라 화면을 건드리지 않고 팝업만
서버 429 로딩은 머무를 수 없는 자리(PillLoadingVC 가 나갈 버튼을 숨긴다) → 미리보기로 pop 후 같은 팝업

spec 이 "요청이 429 로 거부된 경우도 같은 팝업으로 처리한다" 고 한 그대로, 두 경로가 같은 자리에서 끝난다.

3. [NM-449] fix: 탭으로 알약에 들어와도 남은 횟수를 확인한다

spec 은 게이트 위치를 "식별 진입 시점(홈 '알약 식별' 카드/탭)" 으로 못박는다. 홈 카드는 HomeViewModel 이 막는데 탭 경로엔 게이트가 없어 0회여도 촬영까지 그대로 진행됐다.

두 경로는 결국 같은 자리로 모인다 — 홈 카드도 .selectPillTab → 탭 전환 → .pillTabSelected 로 이어져 presentCameraIfAppropriate() 를 탄다. 게이트를 그 합류 지점 한 곳에 두어 두 경로가 자동으로 같은 규칙을 타게 했다.

  • 진입 시 fetchPillUsage() — 홈을 거치지 않고 탭으로 바로 들어온 경우에도 값을 최신으로(조회 전용이라 카운트 미증가, NM-331)
  • 잔여 0 이면 카메라를 띄우지 않고 팝업. 값을 모르면 통과 — 최종 판정은 서버 429(spec: "잔여 미확인 시 통과")

리뷰에서 봐줬으면 하는 것 — 2번과 3번이 반대로 보인다

2번은 "홈으로 내보내지 마라", 3번은 "홈으로 보낸다". 의도된 차이다.

미리보기에서 막힘 탭 진입에서 막힘
화면에 있는 것 찍은 사진 + 잔여 0회 경고 빈 화면(카메라가 덮을 전제인 루트)
남기면 사진 보존 흰 화면만 남는다
spec "확인 시 현재 화면에 머무른다" 흐름도 `P -->

테스트

  • 로컬 빌드/실행 확인 — tuist build App 성공
  • 정상·에러 케이스 확인 — NetworksTests 6개 통과(오프라인 해석 신규 3개)
  • 실기기(시뮬) 확인 — 네트워크를 끄고 분석 실패 화면 재현. 수정 전 동작(재시도=미리보기로 pop · 뒤로=홈 · 영문 원문 노출)을 직접 확인한 뒤 고쳤다

남은 확인 (한도 관련은 계정 상태가 필요)

  • 게이트 경로 — 15회 소진 후 사진 찍고 이 사진 사용 → 팝업 → 확인 → 미리보기에 사진 유지
  • 429 경로 — 잔여를 다른 세션에서 소진시킨 뒤 요청 → 팝업 → 미리보기 복귀 + 사진 유지
  • 탭 게이트 — 소진 상태에서 알약 탭 직접 탭 → 카메라 안 뜨고 팝업 + 홈 복귀
  • 회귀 — 잔여가 남은 계정에서 두 진입 경로 모두 카메라 정상

체크리스트

  • PR 제목이 [NM-XXX] 요약 형식
  • 브랜치명 <type>/NM-XXX-요약 형식
  • 커밋 메시지 [NM-XXX] type: 요약 형식
  • 셀프 리뷰 완료

4. [NM-449] fix: 온디바이스 분석 실패의 원문을 화면 대신 Crashlytics 로 보낸다

CoreML 세그멘테이션 실패도 error.localizedDescription 을 그대로 띄웠다. 영문 시스템 메시지라 한국어 앱에 이런 문장이 나갔다.

Failed to build the model execution plan using a model architecture file
.../rfdetr_seg_small_fp32.mlmodelc/model.mil with error code: -5

가상의 예가 아니라 배포 빌드에서 실제로 났던 문구다 — CI 체크아웃에 lfs: true 가 빠져 모델 가중치가 포인터 파일로 번들된 사고. 로컬 빌드는 멀쩡해서 네트워크 문제로 오인하기 쉬운 부류인데, 사용자는 읽고 할 수 있는 게 없고 원인은 원인대로 화면에서 휘발됐다.

화면과 로그를 가른다 — 화면은 사진을 분석하지 못했어요. 잠시 후 다시 시도해 주세요., 원문은 FirebaseService.recordError 로 Crashlytics 비치명적 이슈에 단계(load/inference)와 함께 남긴다.

ParkMazorika and others added 3 commits September 10, 2026 01:31
spec 의 분기는 이렇다 (feature/pill-recognition/README.md §식별 → 인식 결과).

  E -->|재시도| B   재시도는 인식으로 되돌아간다 — 같은 사진으로 다시 분석
  E -->|뒤로| X     뒤로는 촬영/미리보기로 복귀

둘 다 어긋나 있었다. 재시도가 pop 이라 사실상 spec 의 '뒤로' 였고(분석을 다시 하지
않는다), 뒤로는 홈으로 나가 버려 사진을 다시 고를 기회 없이 흐름이 끊겼다.
"이 화면엔 돌아갈 이전 단계가 없다"고 적혀 있었지만 showPreview 가 push 한
PhotoPreviewVC 가 스택에 그대로 남아 있다 — 실제로 재시도가 그리로 가고 있었다.

- startIdentification 을 makeLoadingVC(image:) 로 쪼갠다. 재시도는 실패 화면을
  새 로딩 VC 로 replace 해 온디바이스 탐지부터 다시 돌린다 — VM 은 이미 종료 상태를
  방출한 뒤라 재사용할 수 없다.
- 뒤로(네비바·푸터 모두)는 pop 으로 미리보기에 되돌린다.

곁가지 — 오프라인 문구가 원문 그대로 나갔다.

  URLSessionTask failed with error: The Internet connection appears to be offline.

BaseService.interpret 은 응답 본문이 있어야 APIError.network 를 만드는데, 서버에
닿지 못한 요청은 응답 자체가 없어 그 분기를 못 타고 원문을 돌려줬다. APIError.offline
을 추가하고 Moya → AFError → URLError 체인을 풀어 연결 계열 코드를 여기로 모은다.
타임아웃은 제외한다 — 연결은 됐는데 느린 것이라 "인터넷 연결을 확인하라"가 맞는
안내가 아니다(pill-attributes 는 서버 Gemini 처리로 수십 초 걸려 60초로 잡혀 있다).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ch9vPGGKfeTUWMKEsZ3NWM
spec 은 "확인 시 **현재 화면에 머무른다**" 다
(feature/pill-recognition/README.md §식별 횟수 제한 §한도 안내 팝업).
지금은 popToRootViewController + 홈 탭 전환이라 찍은 사진이 통째로 날아간다.

"미리보기에 남겨두면 재촬영·이 사진 사용 둘 다 다시 막혀 막다른 길" 이라는 게 옛 근거였다.
그런데 미리보기는 남은 횟수를 0회·경고색으로 표시하는 화면이라 막다른 길이 아니라
**왜 막혔는지 보이는 자리**다. 사진을 버리는 대가로 얻는 게 없다 — 다시 하려면
촬영부터 다시 해야 한다.

호출부가 둘이고 서 있는 자리가 달라 각각 처리한다.

- **요청 전 게이트**(이 사진 사용 시 remaining 0): 이미 미리보기라 화면을 건드리지 않고
  팝업만 띄운다.
- **서버 429**: 로딩 화면은 머무를 수 있는 자리가 아니다(PillLoadingVC 가 나갈 버튼을
  숨긴다). 미리보기로 pop 해 게이트와 같은 자리에서 같은 팝업을 띄운다 — spec 이
  "요청이 429 로 거부된 경우도 같은 팝업으로 처리한다" 고 한 그대로다.

DSAlertCardView.presentOverWindow 는 루트(네비게이션 컨트롤러) 뷰에 붙으므로
pop 과 같은 런루프에 띄워도 알럿이 사라지지 않는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ch9vPGGKfeTUWMKEsZ3NWM
spec 은 게이트 위치를 "식별 진입 시점(홈 '알약 식별' 카드/**탭**)" 으로 못박는다
(feature/pill-recognition/README.md §식별 횟수 제한 §흐름 규칙). 홈 카드는
HomeViewModel 이 막고 있는데 탭 경로엔 게이트가 없어서, 탭으로 들어오면 0회여도
촬영까지 그대로 진행됐다.

두 경로는 결국 같은 자리로 모인다 — 홈 카드도 .selectPillTab → 탭 전환 →
.pillTabSelected 로 이어져 presentCameraIfAppropriate() 를 탄다. 그래서 게이트를
거기 한 곳에 둔다.

- 진입 시 fetchPillUsage() — 홈을 거치지 않고 탭으로 바로 들어온 경우에도 값을
  최신으로 만든다. 조회 전용이라 카운트는 늘지 않는다(NM-331).
- 잔여 0 이면 카메라를 띄우지 않고 한도 팝업. 값을 모르면 통과한다 —
  최종 판정은 서버 429 다(spec: "잔여 미확인 시 통과").

막을 때는 홈으로 되돌린다. 알약 탭 루트는 카메라가 덮는 걸 전제로 한 빈 화면이라
그대로 두면 흰 화면만 남는다. spec 흐름도도 팝업 확인 뒤를 홈으로 둔다(P -->|확인| H).
앞 커밋에서 미리보기는 반대로 화면을 유지하게 했는데, 거긴 찍은 사진이 있고 잔여 0회를
경고색으로 보여 주는 자리라 남길 이유가 있다 — 여기는 없다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ch9vPGGKfeTUWMKEsZ3NWM
@ParkMazorika ParkMazorika self-assigned this Sep 9, 2026
@ParkMazorika ParkMazorika added the bug Something isn't working label Sep 9, 2026
CoreML 세그멘테이션이 실패하면 error.localizedDescription 을 그대로 띄웠다.
CoreML 에러는 영문 시스템 메시지라 한국어 앱에 이런 문장이 나갔다.

  Failed to build the model execution plan using a model architecture file
  .../rfdetr_seg_small_fp32.mlmodelc/model.mil with error code: -5

가상의 예가 아니라 배포 빌드에서 실제로 났던 문구다 — CI 체크아웃에 lfs: true 가
빠져 모델 가중치가 포인터 파일로 번들된 사고. 로컬 빌드는 멀쩡해서 네트워크 문제로
오인하기 쉬운 부류인데, 사용자는 읽고 할 수 있는 게 없고 원인은 원인대로 화면에서
휘발됐다.

화면과 로그를 가른다.

- 화면: "사진을 분석하지 못했어요. 잠시 후 다시 시도해 주세요."
- 로그: FirebaseService.recordError(원문) + 단계(load/inference) 로그.
  Crashlytics 는 내부 빌드에서도 유지되고 dev 프로젝트로 분리돼 있어 운영 지표와
  섞이지 않는다(AppEnvironment 주석 참고).

모델 로드와 추론 두 자리가 같은 처리를 하므로 reportSegmentationFailure(_:stage:)
한 곳으로 모았다 — stage 로 어느 쪽인지 남는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ch9vPGGKfeTUWMKEsZ3NWM
@ParkMazorika
ParkMazorika merged commit dd998b0 into develop Sep 9, 2026
1 check passed
@ParkMazorika
ParkMazorika deleted the feature/NM-449-pill-identify-flow branch September 9, 2026 18:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant