Skip to content

feat: 응답 대기 화면 단계별 안내와 취소 (S2 1단계) - #7

Draft
jk030430-coder wants to merge 8 commits into
developfrom
feat/waiting-ux
Draft

jk030430-coder wants to merge 8 commits into
developfrom
feat/waiting-ux

Conversation

@jk030430-coder

@jk030430-coder jk030430-coder commented Sep 29, 2026 •

Copy link
Copy Markdown

작업 내용

S2 "응답 대기 UX 개선 + 해설 속도 최적화"의 1단계입니다. (담당 김재겸 · 9/21 비대면 회의 #2, 9/29 피드백 반영)

답을 기다리는 화면이 스피너 한 줄뿐이라 멈춘 건지 알 수 없었고, 오래 걸려도 멈출 방법이 없었습니다. 이 PR은 대기 화면을 단계별 안내로 바꾸고 취소를 추가합니다. 해설 속도 개선의 본 작업(해설 스트리밍)은 앱·프록시 계약을 함께 바꿔야 해서 2단계로 나누고, 이번에는 측정 도구만 넣었습니다.

구분 내용
추가 단계별 대기 안내 · 캡처 미리보기 · 해설 자리 스켈레톤 · 요청 3초 후부터 취소 (패널 · 토스트)
추가 토스트: 스피너 대신 원형 진행 표시(기다린 시간 기반 추정) · 대기 방식 선택 단계 안내(기본) / 간단히
추가 API 지연 시간 기준선 측정 스크립트 scripts/measure-latency.sh
고침 풀이를 기다리는 중 기록을 열면, 늦게 도착한 새 답이 그 기록을 덮어써 저장되던 버그
고침 기록에서 해설을 다시 요청하면 기록의 과목이 아니라 현재 설정의 과목 힌트가 들어가던 버그
고침 "해설 보기"를 빠르게 연속 클릭하면 해설 요청이 여러 번 나갈 수 있던 문제

추가한 것

1. 기다린 시간에 따라 바뀌는 대기 화면

아래 화면은 실제 스크린샷이 아니라, 앱 코드의 WaitingStage 문구 · 아이콘으로 같은 레이아웃을 그린 시안입니다.

기존 vs 개선

기존 대기 화면과 개선된 대기 화면

정답 대기 — 0–3초 / 3초~(취소) / 8–15초 / 15초~(오래 걸림 안내)

정답 대기 네 단계

해설 대기 — 정답은 먼저, 해설 자리는 스켈레톤, 3초 후 취소

해설 대기 화면
  • 0–3초 "문제를 읽고 있어요" → 3–8초 "풀이를 떠올리는 중이에요" → 8–15초 "답을 정리하고 있어요" → 15초~ "조금 오래 걸리고 있어요" + "복잡한 문제일수록 시간이 더 걸려요."
  • 취소는 요청 시작 3초 후부터 문구 줄 오른쪽에 나타납니다(정답 · 해설 대기 모두). 자리를 처음부터 잡아 두어 버튼이 생겨도 화면이 튀지 않고, 3초 전에는 클릭 · 키보드 · VoiceOver로 실행되지 않습니다. 영역 선택 중에는 기존대로 Esc로 취소합니다.
  • 8–15초 문구를 "거의 다 됐어요"에서 "답을 정리하고 있어요"로 바꿨습니다. 실제 진행률을 모르는데 곧 끝난다고 약속하지 않기 위해서입니다.
  • 정답 대기 중에는 캡처한 문제의 작은 미리보기를 보여줍니다. 원본(최대 2000px) 대신 240px 썸네일을 따로 만들어 캐시와 함께 지웁니다.
  • 해설 대기 중에는 정답을 먼저 보여주고, 해설이 들어갈 자리를 스켈레톤으로 잡아 둡니다.
  • 시스템 설정의 "동작 줄이기"를 켜면 새 대기 애니메이션이 즉시 멈춥니다.
  • 단계 경계와 취소 시점은 WaitingStage.answerStage(elapsed:) / explanationStage(elapsed:) / allowsCancel(elapsed:) 순수 함수로 두고 테스트했습니다.

2. 토스트: 원형 진행 표시와 대기 방식 선택

토스트 대기 표시 — 단계 안내와 간단히
  • 스피너 자리에 기다린 시간으로 채우는 원을 넣었습니다. 서버가 답을 한 번에 보내 실제 진행률은 알 수 없으므로 0.9 × (1 − e^(−t/5))로 처음엔 빠르게, 점점 느리게 채우고 90%에서 멈춥니다(3초 ≈ 41%, 8초 ≈ 72%, 15초 ≈ 86%). 답이 오면 바로 정답으로 바뀌고, 100%를 채우는 연출로 정답을 늦추지 않습니다. WaitingProgress.estimatedFraction(elapsed:)
  • τ = 5초는 임시값입니다. 측정 스크립트로 응답 시간 분포를 본 뒤 조정합니다.
  • 설정 → 결과 표시 → 기다리는 동안에서 고릅니다. 설정 화면에 "진행 원은 기다린 시간으로 그린 표시예요"라고 밝힙니다.
    • 단계 안내(기본): 원 안에 단계 아이콘 + 단계 문구 + 3초 후 "취소"
    • 간단히: 원 + "푸는 중…"(15초부터 "조금 오래 걸리고 있어요") + 3초 후 작은 ×
    • 설정이 없던 기존 사용자와 알 수 없는 값은 단계 안내로 둡니다.
  • 원호만 30Hz로 다시 그리고 문구 · 버튼은 1초 단위로 바꿉니다. 동작 줄이기가 켜지면 원도 1초 단위로만 움직입니다.
  • 토스트는 뜰 때 한 번만 크기를 잽니다. 그래서 대기 방식은 토스트를 띄우는 순간에 고정하고(대기 중 설정 변경은 다음 풀이부터), 가장 긴 문구와 취소 자리가 들어가는 고정 폭을 씁니다.
  • 새 캡처를 시작하면 이전 정답 토스트를 먼저 닫습니다. 작은 정답 토스트("③")가 남은 채 넓은 대기 화면으로 바뀌면 잘릴 수 있었습니다.

3. 취소와 무료 횟수 경계 ⚠️ 사용량 정책

취소와 무료 횟수 경계 표
  • 규칙: MainActor에서 정답 확정과 취소 중 먼저 처리된 쪽을 따릅니다.
    • 확정 전 취소 → 도착한 응답을 버리고 차감하지 않습니다.
    • 확정 후 취소 → 정답과 1회 차감은 유지되고, 취소는 해설에만 적용됩니다. ("해설 보기"로 다시 요청 가능)
  • usage.recordUse()의 위치와 조건은 바꾸지 않았습니다. 확정 직전에 요청 소유권 검사만 추가했습니다.
  • 새 SolveRequestGate가 요청마다 ID를 발급하고, 모든 await 뒤에서 "아직 유효한 요청인지" 확인합니다. 취소 · 기록 전환 · 새 요청 뒤에 도착한 응답은 화면 · 기록 · 사용량을 바꾸지 못합니다.
  • URLError.cancelled를 GeminiError.network로 감싸지 않고 CancellationError로 분류해, 취소가 "네트워크 오류" 화면으로 보이지 않게 했습니다.
  • 범위 밖: 영역 선택 중 취소는 기존 Esc 그대로, 패널 Esc는 기존처럼 숨기기입니다. 앱에서 취소해도 프록시의 Gemini 생성은 멈추지 않습니다 (2단계에서 다룸).

4. API 지연 시간 측정 스크립트

  • scripts/measure-latency.sh: 앱과 같은 프롬프트로 정답 → 해설을 호출해 DNS · TCP · TLS · 첫 바이트 · 총 시간을 CSV로 남기고 p50/p90을 냅니다. 앱에 로깅을 넣지 않기로 한 규칙 때문에 프록시를 직접 호출합니다.
  • 같은 연결에서 GET으로 먼저 핸드셰이크한 뒤 정답을 요청하는 연결 예열 실험도 함께 잽니다. GET은 토큰 · 사용량 검사 전에 405로 끝나 Gemini를 호출하지 않습니다.
  • PAXO_PROXY_URL, PAXO_TOKEN 환경 변수를 쓰고, 토큰은 헤더 파일로 넘겨 프로세스 목록과 결과물에 남지 않습니다. 결과 CSV(latency-*.csv)는 .gitignore에 추가했습니다.
  • 측정용 문제 이미지(scripts/fixtures/sample-problem.jpg)는 직접 만든 것입니다.

5. 문서

  • docs/decisions.md: 4건을 "제안" 상태로 추가 (대기 화면 방식과 3초 취소, 속도 개선 1·2단계 분할, 취소 시 과금 경계, 토스트 원형 진행 · 대기 방식 선택). 확정되면 상태를 바꿉니다.
  • docs/architecture.md: 풀이 흐름에 취소 경로(3초부터) 한 줄.
  • README.md: 토스트 대기 방식을 고를 수 있다는 한 줄.

고친 것 (기존 버그)

버그 1. 풀이 중 기록을 열면 그 기록이 새 답으로 덮어써짐

재현: ⌥⌘S로 캡처 → 답을 기다리는 동안 메뉴바에서 이전 기록 항목 클릭 → 새 답이 도착하면 방금 연 기록의 정답이 새 문제의 답으로 바뀌고, 그대로 저장됨.

원인: showFromHistory가 current를 기록 항목으로 바꾸는데, 진행 중이던 runSolve는 응답이 오면 그때의 current에 답을 쓰고 upsertHistory()로 저장했습니다.

sequenceDiagram
    participant U as 사용자
    participant A as AppState
    participant P as 프록시
    U->>A: ⌥⌘S 캡처 (새 문제 N)
    A->>P: 정답 요청 (N)
    U->>A: 메뉴에서 기록 H 클릭
    A->>A: current = H
    P-->>A: N의 정답 도착
    A->>A: current(H).answer = N의 정답 ❌
    A->>A: upsertHistory() → H가 틀린 답으로 저장 ❌
Loading

수정: 기록을 열 때 진행 중인 요청을 먼저 취소하고(정답 확정 전이면 미차감), 응답은 자기 요청 ID가 유효할 때만 반영합니다.

sequenceDiagram
    participant U as 사용자
    participant A as AppState
    participant P as 프록시
    U->>A: ⌥⌘S 캡처 (새 문제 N, 요청 #1)
    A->>P: 정답 요청 (N)
    U->>A: 메뉴에서 기록 H 클릭
    A->>A: 요청 #1 취소 → current = H
    P-->>A: N의 정답 도착
    A->>A: 요청 #1은 무효 → 버림 ✅ (H 그대로, 차감 없음)
Loading

버그 2. 기록에서 해설을 다시 요청하면 과목 힌트가 어긋남

재현: 수학 프리셋으로 문제를 풀고 → 설정에서 프리셋을 "자격시험"으로 변경 → 메뉴에서 그 수학 기록을 열어 "해설 보기" → 해설 프롬프트에 "자격시험 문제입니다…" 힌트가 들어감.

원인과 수정: 해설 요청이 기록의 프리셋이 아니라 현재 설정값을 썼습니다. (별도 커밋 c383281)

-                .explain(imageData: image, answer: answer, preset: preset)
+                .explain(imageData: image, answer: answer, preset: current.preset)

문제 3. "해설 보기" 연속 클릭 시 중복 요청 가능

첫 클릭에 버튼이 스켈레톤으로 바뀌어 실제로는 드물지만, 막는 장치가 없어 같은 프레임 안의 연속 클릭은 해설 API를 두 번 호출할 수 있었습니다. 이미 진행 중인 요청이 있으면 무시하도록 했습니다.

변경 및 영향 범위

  • 앱 기능 (Capture / AI / Storage / UI)
  • StoreKit·사용량 정책 — 취소 시 차감 경계 (차감 코드 자체는 그대로)
  • API·프록시 — 프록시 코드 변경 없음
  • 앱 설정·권한·릴리스
  • 테스트·CI·개발 도구 — 신규 테스트, 측정 스크립트
  • 규칙·문서 (AGENTS.md, docs/) — docs/decisions.md 제안 3건, docs/architecture.md
  • 기타:

사전 식별 리스크

  • 과금 경계: 취소가 정답 확정과 겹치는 순간의 규칙을 새로 정했습니다. 과금 담당 확인이 필요합니다.
  • 프록시: 1단계는 프록시 코드를 바꾸지 않습니다. 측정 스크립트의 예열 GET은 프록시 로그에 405로 남습니다.
  • 서버 쪽 생성은 계속됨: 앱에서 취소해도 프록시가 Gemini 응답을 끝까지 받습니다. 연결 종료 연동은 2단계 스트리밍에서 다룹니다.

결과 및 검증

기대 효과

  • 기다리는 동안 앱이 멈춘 게 아니라는 걸 알 수 있고, 무엇을 캡처했는지 바로 확인할 수 있습니다.
  • 오래 걸리면 취소하고 다시 시도할 수 있으며, 정답을 받기 전이면 무료 횟수가 줄지 않습니다.
  • 기록 보기 중 다른 풀이 결과가 끼어들지 않습니다.

실제 반영 결과

  • 위 기능은 모두 코드로 반영됐습니다.
  • 실제 앱에서 수동 검증을 마쳤습니다 (아래 표). 본문 이미지는 시안이지만, 실제 화면도 시안과 같게 나오는 것을 확인했습니다.
  • 검증 중 찾은 버그를 고쳤습니다 (546ac35): 취소 · 오류로 토스트를 닫을 때 0.3초 페이드 사이에 내용이 빈 자리("—")로 바뀌고 창이 58×48로 줄어드는 현상. 정답 없이 닫힐 때는 즉시 닫습니다.
  • 기준선 측정은 아직 실행하지 않았습니다. 새 프록시(api.paxo.co.kr) 토큰이 필요합니다.

검증

xcrun swift-format lint --recursive --strict --configuration .swift-format Paxo PaxoTests   # 경고 없음
xcodebuild -project Paxo.xcodeproj -scheme Paxo -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO test
# Test run with 42 tests in 8 suites passed
  • 신규 테스트: WaitingStageTests(단계 경계 · 3초 취소 경계 · 완료를 약속하는 문구 없음), WaitingProgressTests(0 ≤ 값 ≤ 0.9 · 줄지 않음 · 주요 시점 값), CodableRoundTripTests에 토스트 대기 방식 rawValue와 기본값 복구, SolveRequestGateTests(새 요청이 이전 요청 무효화 · 취소 후 응답 거부 · 늦은 종료가 새 요청을 지우지 않음), GeminiServiceTests에 취소 분류 2건
  • 측정 스크립트는 로컬 더미 서버로 동작(집계 · 토큰 미노출)만 확인했습니다.
  • 과금 경계와 화면은 AppState가 싱글턴이라 단위 테스트 대신 실제 앱으로 확인했습니다. 응답 없는 로컬 서버로 대기 상태를 재현하고, 토스트 창 크기를 0.05초 간격으로 기록하면서 화면을 찍었습니다.
상황 기대 결과
정답 대기 3초 전 취소 버튼 없음(자리만), 클릭 · Tab · VoiceOver로 실행 안 됨 ✅ 클릭으로 취소 안 됨 (Tab · VoiceOver는 코드로만 확인)
정답 대기 중 취소 (3초 후) 창 닫힘, 남은 무료 횟수 그대로 ✅ 즉시 닫힘, 횟수 그대로
정답 확정 후 해설 대기 중 취소 정답 유지, 횟수 1회만 감소, "해설 보기" 재요청 가능 ✅ ③ 유지, 1회만 감소, 재요청한 해설 정상 도착
해설 실패 후 다시 시도 추가 감소 없음 ➖ 실패는 재현 안 함. 해설 취소 후 재요청 시 추가 감소 없음은 확인
풀이 중 기록 항목 선택 진행 중 요청 취소, 기록 그대로 ➖ 이번엔 미확인
토스트 모드 해설 미호출 (기존 그대로) ✅ 정답 토스트만, 10초 후 닫힘
대기 중 동작 줄이기 켜기 애니메이션 즉시 정지, 원은 1초 단위로만 움직임 ✅ 원 영역 변화 29/29 프레임 → 8/29 (1초 간격). 3초의 아이콘 전환만 짧은 페이드
토스트 · 단계 안내 / 간단히 원이 90%를 넘지 않음, 3초 후 취소 · ×, 폭 변화 없음, 설정이 재시작 후에도 유지 ✅ 364×54 / 294×54로 24초간 고정, 3초 후 취소 · ×, 15초 문구 전환. 저장된 설정을 시작 시 읽는 것 확인 (설정 화면에서 바꾸기는 미확인)
패널 대기 3초에 취소가 문구 오른쪽에, 15초에 안내 한 줄 ✅ 380×420 고정, 문구 위치 그대로
앱이 비활성일 때 원이 계속 움직임 ✅ 다른 앱이 앞에 있는 동안에도 계속 참
정답 토스트가 떠 있을 때 다시 캡처 이전 토스트가 닫히고 새 대기 토스트가 잘리지 않음 ✅ 이전 토스트 즉시 닫힘, 새 대기 토스트 정상

리스크·결정·리뷰

  • 일반 변경
  • Crtical Path 변경 — StoreKit·사용량 정책 (취소 시 차감 경계). 결정 기록(2026-09-09)에 따라 머지 전 승인이 필요합니다.

트러블슈팅 및 회고

  • 계획과 구현을 각각 Codex로 교차 검토했습니다.
    • 계획 리뷰(조건부 승인): 취소가 네트워크 오류로 감싸지는 문제, 늦은 응답의 덮어쓰기, 과금 경계 모호, 예열 효과 미검증, 토스트 크기 고정 → 반영 후 구현
    • 코드 리뷰(수정 필요): 영역 선택 중 기록 전환 시 소유권 우회, 실행 전 취소된 해설 작업, 동작 줄이기 즉시 반영, 측정 집계의 405 → 4건 반영. 테스트 함수명을 영어로 바꾸라는 지적은 기존 테스트가 모두 한국어 함수명이라 관례를 따라 반영하지 않았습니다.
  • 로컬 서명(Team None)으로 다시 빌드할 때마다 화면 기록 권한이 풀립니다. 확인할 때 tccutil reset ScreenCapture com.hyeseong.Paxo 후 재허용이 필요합니다.

메모

  • Draft입니다. 과금 경계와 결정 기록 제안이 확정되면 Ready로 바꿉니다.
  • 9/29 피드백 반영(커밋 57528cf, 2967ba3): 취소 3초부터 · 토스트 원형 진행 표시 · 대기 방식 선택. 계획은 Codex 리뷰(조건부 승인)를 거쳐 캡처 중 취소 금지, 숨긴 버튼 입력 차단, 토스트 크기 정책을 보강했습니다.
  • 검증 중 찾은 기존 문제(이 PR 전부터 있음): 정답 토스트 폭이 120보다 좁으면(짧은 답) 오른쪽 여백에서 최대 약 30pt 안쪽에 뜹니다. ToastController가 최소 폭 120으로 위치를 잡은 뒤 SwiftUI가 창을 내용 크기로 줄이기 때문입니다. 크기 결정을 컨트롤러로 모으면 해결되며, 별도 PR로 제안합니다.
  • README 사용법의 "해설이 스트리밍된다"는 표현은 현재 코드(해설을 한 번에 받음)와 맞지 않습니다. 2단계 전까지 문구 조정이 필요해 보입니다.
  • 2단계(해설 스트리밍) 스펙: 프록시에 POST /generate-stream 신설(기존 /generate 유지), SSE 중계, 앱 연결 종료 시 upstream abort, 스트리밍 중 텍스트와 완료된 해설 분리. /proxy-change 절차로 별도 PR 예정.

- 기록에서 해설을 다시 요청하면 현재 설정의 프리셋이 들어가 힌트가 어긋났다
- 기다린 시간에 따라 문구·아이콘이 바뀐다 (읽는 중 → 떠올리는 중 → 거의 다 됨 → 오래 걸림)
- 정답 대기 중 캡처 미리보기, 해설 대기 중 스켈레톤을 보여준다
- 15초가 넘으면 안내와 취소 버튼을 연다 (패널·토스트)
- 요청 소유권(SolveRequestGate)으로 취소·기록 전환 뒤 도착한 늦은 응답을 버린다
- 정답 확정 전 취소는 무료 횟수를 차감하지 않는다. 확정 후 취소는 해설에만 적용된다
- URLError.cancelled를 네트워크 오류가 아닌 취소로 분류한다
- 해설 요청 연타를 무시하고, 오래된 토스트 타이머가 새 화면을 지우지 않게 한다
- 정답 → 해설 2단계 요청을 앱과 같은 프롬프트로 재현해 구간별 시간을 CSV로 남긴다
- 같은 연결에서 GET으로 먼저 핸드셰이크하는 예열 실험을 함께 잰다
- 토큰은 헤더 파일로 넘겨 프로세스 목록과 결과물에 남지 않게 한다
- 측정용 문제 이미지는 직접 만든 것이라 저작권 문제가 없다
- 요청 소유권을 캡처 전부터 잡아, 영역 선택 중 기록으로 바꾸면 이전 풀이가 끼어들지 않게 한다
- 실행 전에 취소된 해설 작업이 새 화면의 상태를 바꾸지 않게 진입 시 소유권을 확인한다
- 대기 도중 동작 줄이기를 켜면 스켈레톤 반복 애니메이션을 즉시 멈춘다
- 측정 스크립트에서 405는 예열 GET만 정상으로 보고 POST에서는 실패로 집계한다
- 정답 · 해설 대기 모두 요청 시작 3초 후부터 취소할 수 있다 (영역 선택 중에는 기존 Esc)
- 취소 자리를 처음부터 잡아 두어 버튼이 생겨도 화면이 튀지 않고, 3초 전에는 클릭 · 키보드 · VoiceOver로 실행되지 않는다
- 토스트 스피너를 기다린 시간으로 채우는 원형 진행 표시로 바꾼다 (실제 진행률을 몰라 90%에서 멈추는 추정치)
- 설정에서 토스트 대기 방식을 고른다: 단계 안내(기본) / 간단히. 간단히도 15초부터 오래 걸린다고 알리고 3초 후 × 취소를 쓴다
- 대기 방식은 토스트를 띄울 때 고정하고, 새 캡처를 시작하면 이전 정답 토스트를 닫아 넓은 대기 화면이 잘리지 않게 한다
- 곧 끝난다고 약속하는 "거의 다 됐어요"를 "답을 정리하고 있어요"로 바꾼다
- 취소 · 오류 · 새 캡처 · 기록 열기로 토스트를 닫을 때 페이드하면, 그 0.3초 사이 내용이 빈 자리("—")로 바뀌고 창도 그 크기로 줄어 작은 말풍선이 번쩍였다
- 정답을 보여준 뒤 자동으로 사라질 때만 페이드를 유지한다

This branch has not been deployed

No deployments
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.

1 participant