# 리프레시 가드 — 새로고침·복귀가 보던 화면을 되찾는다

> 2026-08-30 · 대상: `widget-ui/src/views/p2p.vue`, `open-api` P2P 위젯 응답
> 짝: [재진입 가드](./P2P_REENTRY_DUPLICATE_ORDER_GUIDE.md) — **다른 문제를 막는 다른 장치다.**

## 1. 두 가드의 경계 (헷갈리면 둘 다 잘못 고친다)

| | 막는 것 | 수단 | 상태 |
|---|---|---|---|
| **재진입 가드** | 활성 주문이 있는데 **새 주문**을 만드는 것 | 서버가 활성 주문을 찾아 `1035` + 그 주문으로 재개 | 배포 완료 |
| **리프레시 가드**(이 문서) | 같은 주문 안에서 **보던 화면**을 잃는 것 | URL 이 화면·레그를 기억 | 미구현 |

재진입 가드는 *어느 주문인가*를 지킨다. 리프레시 가드는 *어느 화면인가*를 지킨다.

## 2. 증상 (2026-08-30 화면 기록으로 확인)

계좌가 노출된 상태에서 새로고침하거나 다른 앱에 다녀오면 **"거래 상대를 찾았어요 / 입금 시작"**
으로 되돌아간다. 사용자는 계좌가 사라졌거나 새 계좌가 나올 것으로 오해한다.

실제로는 **같은 계좌**다. 같은 매칭이고 `markTransferStarted` 는 멱등이라 두 번째 `/start` 는 0 행이다.
사용자만 그것을 알 수 없다.

## 3. 원인 — 세 겹이다. 하나만 고치면 안 고쳐진다

**(1) URL 이 화면을 안 들고 있다.** `p2p.vue` 가 URL 에 쓰는 것은 `p2pOrderCode` 하나다
(`replaceState` 2곳: `:3167`, `:3487`). "어느 주문"은 복원되고 "어느 화면"은 사라진다.

**(2) 진입 시 무조건 홈으로 돌린다.** `applyDerivedStep()`(`:2444`):
```js
transferMatchCode.value = null      // ← 보던 레그를 지운다
transferChecked.value = false
goToStep('p2p-deposit')             // §5 "이체는 '입금 시작'을 지나야 한다"
```

**(3) 위젯이 착수 여부를 알 방법이 없다.** 상태 A/B 판정이 **레그 상태**만 본다(`:1985`):
```js
const hasReportedAnyLeg = computed(() =>
  displayMatches.value.some(m => m.status !== 'CREATED'))
```
그런데 `입금 시작`은 **주문**의 `transfer_started_at` 만 찍고 레그는 `CREATED` 그대로다.
레그가 움직이는 것은 "보냈습니다" 체크(→`BANK_PENDING`) 때다.

☠️ **폴링 응답 `P2pWidgetMatchResponse` 에 `transferStartedAt` 이 아예 없다.**
유일한 간접 신호 `startRemainingSeconds` 는 착수했을 때도 null 이고 **가드가 꺼져 있을 때도 null**
이라(`P2pWidgetController:909-923`) 구분이 안 된다. 이 필드 없이는 위젯이 판단할 수 없다.

## 4. 불변식

> **새로고침·복귀는 사용자가 보던 화면을 되찾는다. 계좌는 몇 개든 같은 규칙이다.**

## 5. 설계

### 5-1. URL 이 화면을 기억한다
```
?p2pOrderCode=pdo_xxx&step=transfer&matchCode=pm_yyy
```
- `p2p-transfer` 로 들어갈 때 `replaceState` 로 기록, 나올 때 제거
- `parseUrlParams()`(`:2297`)는 전량 패스스루라 별도 화이트리스트 작업이 없다
  (같은 이유로 `mode`/`redirectUrl` 도 라우터 이동을 넘어 살아남는다)

### 5-2. 복원 조건 — **URL 과 서버가 둘 다 동의할 때만**
```
URL step=transfer   AND   서버 transferStartedAt != null   →  이체 화면 복원
```
☠️ **URL 만 보고 열지 마라.** `입금 시작`은 화면 전환이 아니라 **착수 신고**다. 이 신호가 없는 주문은
서버가 성립 3분 뒤 통째로 취소한다(`close_reason=START_TIMEOUT`). URL 을 손으로 고친 사용자가
그 관문을 건너뛰면, 취소될 주문의 계좌로 송금하게 된다.

### 5-3. 레그 선택 — 기존 로직을 쓴다. 새로 만들지 마라
`transferMatch`(`:1774`)가 이미 정확히 이 일을 한다:
```js
지정 레그가 아직 CREATED 면 그것 → 아니면 currentTransferLeg(남은 첫 CREATED) → 없으면 null
```
`applyDerivedStep()` 이 `transferMatchCode` 를 지워서 못 쓰고 있을 뿐이다.
**URL 값으로 `transferMatchCode` 를 시드하면 끝난다.** 취소·완료된 레그를 가리키면 자동으로 폴백된다.

### 5-4. 복원 대상은 `p2p-transfer` **하나뿐**이다
| 스텝 | 복원 | 이유 |
|---|---|---|
| `p2p-transfer` | ✅ | 이 문제의 전부 |
| `p2p-deposit`(홈) | — | 기본 착지점이라 URL 불필요 |
| `p2p-result-*` / `p2p-failed` | ❌ | 서버 상태에서 파생된다. URL 로 강제하면 서버와 어긋난 화면이 뜬다 |
| `p2p-support`(분쟁 신고) | ☠️ **금지** | 되돌릴 수 없는 행동의 입구다. 새로고침이 사용자를 그 앞에 다시 세우면 안 된다 |
| `p2p-amount` / `p2p-confirm` | ❌ | 주문 생성 전 단계 — 복원할 서버 상태가 없다 |

### 5-5. 계좌가 2개 이상일 때
같은 규칙이다. URL 이 `matchCode` 를 들고 있으므로 **보던 그 계좌**로 돌아간다.
분할이어도 홈으로 보낼 이유가 없다 — 홈은 "어느 계좌였는지" 를 모르는 상태의 차선책이었을 뿐이다.

참고: 레그가 하나라도 `BANK_PENDING` 으로 움직인 뒤에는 **지금도 정상 동작한다**
(`hasReportedAnyLeg` 가 참이 되어 상태 B `이어서 입금`). 깨진 구간은 **"착수했으나 아직 한 건도
안 보낸"** 창 하나다. 최근 7일 착수 296건 중 분할은 2건(0.7%)이지만, 규칙은 개수와 무관하게 하나다.

### 5-6. 재진입 복귀는 홈을 유지한다
재진입 가드가 사용자를 기존 주문으로 끌어올 때(`resumeFromOrder`)는 **다른 컨텍스트에서 온 것**이라
URL 에 `step` 이 없다. 그때는 홈이 맞다 — 사용자는 새 주문을 만들려던 참이었고, 무슨 일이
일어났는지 먼저 봐야 한다(토스트 안내가 이미 있다). **여기에 자동 이체 화면 진입을 끼워 넣지 마라.**

## 6. 변경 지점

### 서버 (`open-api`)
`P2pWidgetMatchResponse` 에 `transferStartedAt` 추가 + `P2pWidgetController.toMatchResponse()`
빌더에서 채운다(`order.getTransferStartedAt()`). **DDL 변경 없음** — 컬럼은 이미 있다.

⚠️ 같은 DTO 를 만드는 빌더는 **3곳**이다 — 위 폴링 응답과
`MatchingLinkWidgetController:262`, `P2pDepositLinkWidgetController:312`.
뒤 두 곳은 **의도적으로 채우지 않는다**(빠뜨린 것이 아니다): 둘 다 *주문 생성 직후* 경로라
`transfer_started_at` 이 정의상 NULL 이고, 위젯은 즉시 폴링으로 넘어가 정본을 받는다.
재진입 가드(`assertNoActiveOrder`)가 그 앞에 있어 **기존 주문을 이 경로로 되돌려주는 일도 없다.**
그 전제가 깨지면(링크 경로가 기존 주문을 반환하게 되면) 세 곳을 함께 채워야 한다.

### 위젯 (`widget-ui/src/views/p2p.vue`)
1. `p2p-transfer` 진입/이탈 시 URL 동기화 (`step`, `matchCode`) — 기존 `replaceState` 패턴 재사용
2. `applyDerivedStep()` — URL `step=transfer` + 서버 `transferStartedAt` 이면
   `transferMatchCode` 를 URL 값으로 시드하고 `goToStep('p2p-transfer')`
3. `hasReportedAnyLeg` 에 착수 신호를 반영해 상태 A/B 를 바로잡는다
   (착수했으면 `이어서 입금`. 홈에 남는 경우에도 문구가 사실과 맞아야 한다)

## 7. 하지 말 것

- ☠️ `startRemainingSeconds == null` 을 "착수했다"로 읽지 마라 — 가드 OFF 와 구분되지 않는다
- ☠️ `applyDerivedStep()` 의 `initialStepApplied` 1회성 가드를 없애지 마라 — 폴링마다 화면이
  튀게 된다. 시드는 그 가드 **안**에서 한 번만
- ☠️ `transferChecked` 를 URL 로 복원하지 마라. "보냈습니다" 는 사용자가 매번 직접 눌러야 하는
  선언이다. 복원하면 계좌를 안 보고 완료를 누르게 된다 (`:2489` 의 watch 가 같은 이유로 존재한다)
- 새 CSS 클래스·새 i18n 네임스페이스 도입 금지. 공용 키는 이 파일에서 `deposit.` 으로 부른다

## 8. 검증 (빌드 그린은 근거가 못 된다 — 브라우저에서 눌러라)

| # | 시나리오 | 기대 |
|---|---|---|
| 1 | 단일 계좌, `입금 시작` 후 새로고침 | **계좌 화면 그대로**, 같은 계좌번호 |
| 2 | 2계좌, 첫 계좌 보는 중 새로고침 | **그 첫 계좌**로 복원 |
| 3 | 2계좌, 계좌1 체크 후 계좌2 보는 중 새로고침 | **계좌2**로 복원 |
| 4 | 2계좌, 계좌1 체크 후 새로고침(홈) | 홈 · `이어서 입금` (기존 정상 동작 유지) |
| 5 | `입금 시작` 전에 새로고침 | 홈 · `입금 시작` — **착수 관문은 그대로** |
| 6 | URL 을 손으로 `step=transfer` 로 고침 (착수 전) | 홈 유지 — 서버가 거부 |
| 7 | `matchCode` 를 없는 값으로 고침 | 남은 첫 레그로 폴백, 빈 화면 없음 |
| 8 | 복원된 계좌 화면에서 "보냈습니다" | 체크는 **풀린 상태**로 시작 |
| 9 | 재진입 가드로 끌려온 경우 | 홈 + 토스트 (자동 이체 진입 없음) |
| 10 | `mode:'redirect'` 로 은행 앱 왕복 | 계좌 화면 복원 — 리다이렉트 모드의 목적이 여기서 완성된다 |

⚠️ **#10 은 두 경우가 섞여 있다.** 탭이 살아 있으면 `handleVisibilityChange` 가 폴링만 재개해
**이 변경 없이도 원래 정상**이었다. 이번 수정이 값을 하는 것은 모바일 브라우저가 백그라운드 탭을
죽여 **재로드**되는 경우다. 실측할 때 이 구분을 잊으면 "고쳐진 건지 원래 됐던 건지" 판정이 안 된다.

⚠️ **홈이 한 번 스친다.** 복원은 첫 폴링 응답(서버 `transferStartedAt`)이 와야 성립하므로
그 전까지 홈이 잠깐 보인다. 주문을 받아 오기 전에는 서버 동의를 알 수 없으니 불가피하다 —
없애려면 "첫 응답 전까지 화면을 확정하지 않는" 별도 작업이 필요하다.

⚠️ **양쪽이 다 배포되어야 성립한다.** 위젯만 나가면 `transferStartedAt` 이 undefined 라 복원이
조용히 no-op 이 된다(기존 동작 유지, 무해). 순서는 어느 쪽이 먼저여도 안전하다.
