diff --git a/.changeset/fep-2598-react-loader-core-v3.md b/.changeset/fep-2598-react-loader-core-v3.md new file mode 100644 index 000000000..ff5a4c008 --- /dev/null +++ b/.changeset/fep-2598-react-loader-core-v3.md @@ -0,0 +1,5 @@ +--- +"@stackflow/react": patch +--- + +fix: Refresh loader data for renderable activities restored from core v3 snapshots. diff --git a/integrations/react/src/loader/loaderPlugin.tsx b/integrations/react/src/loader/loaderPlugin.tsx index 41aac45c9..59087dd0c 100644 --- a/integrations/react/src/loader/loaderPlugin.tsx +++ b/integrations/react/src/loader/loaderPlugin.tsx @@ -2,19 +2,22 @@ import type { ActivityDefinition, RegisteredActivityName, } from "@stackflow/config"; +import type { Stack } from "@stackflow/core"; import type { ActivityComponentType } from "../BaseActivityComponentType"; import type { StackflowReactPlugin } from "../StackflowReactPlugin"; import { getContentComponent, isStructuredActivityComponent, } from "../StructuredActivityComponentType"; -import { isPromiseLike } from "../utils/isPromiseLike"; +import type { StackflowInput } from "../stackflow"; import { + defer, inspect, PromiseStatus, resolve, + type SyncInspectableDeferred, + type SyncInspectablePromise, } from "../utils/SyncInspectablePromise"; -import type { StackflowInput } from "../stackflow"; export function loaderPlugin< T extends ActivityDefinition, @@ -26,15 +29,111 @@ export function loaderPlugin< loadData: (activityName: string, activityParams: {}) => unknown, ): StackflowReactPlugin { return () => { + const loadPathDeferreds = new WeakMap< + SyncInspectablePromise, + SyncInspectableDeferred + >(); + + const resolveDeferredLoaderData = ({ + activityName, + activityParams, + loaderData, + }: { + activityName: string; + activityParams: {}; + loaderData: SyncInspectablePromise | undefined; + }) => { + const matchActivity = input.config.activities.find( + (candidate) => candidate.name === activityName, + ); + const deferred = loaderData + ? loadPathDeferreds.get(loaderData) + : undefined; + + if (!matchActivity?.loader || !loaderData || !deferred) { + return; + } + + loadPathDeferreds.delete(loaderData); + + Promise.allSettled([loaderData]).then(([loaderDataPromiseResult]) => { + printLoaderDataPromiseError({ + promiseResult: loaderDataPromiseResult, + activityName: matchActivity.name, + }); + }); + + try { + deferred.resolve(loadData(activityName, activityParams)); + } catch (error) { + deferred.reject(error); + } + }; + + const resolveRestoredStackLoaderData = (stack: Stack) => { + stack.activities + .filter((activity) => activity.transitionState !== "exit-done") + .forEach((activity) => { + resolveDeferredLoaderData({ + activityName: activity.name, + activityParams: activity.params, + loaderData: (activity.context as any)?.loaderData, + }); + }); + }; + + const resolvePausedEventLoaderData = ( + pausedEvents: Stack["pausedEvents"], + ) => { + pausedEvents?.forEach((event) => { + if (event.name !== "Pushed" && event.name !== "Replaced") { + return; + } + + resolveDeferredLoaderData({ + activityName: event.activityName, + activityParams: event.activityParams, + loaderData: (event.activityContext as any)?.loaderData, + }); + }); + }; + return { key: "plugin-loader", - overrideInitialEvents({ initialEvents, initialContext }) { + overrideInitialEvents({ initialEvents, initialContext, initInfo }) { if (initialEvents.length === 0) { return []; } + if (initInfo?.kind === "load") { + return initialEvents.map((event) => { + if (event.name !== "Pushed" && event.name !== "Replaced") { + return event; + } + + const matchActivity = input.config.activities.find( + (activity) => activity.name === event.activityName, + ); + + if (!matchActivity?.loader) { + return event; + } + + const loaderData = defer(); + loadPathDeferreds.set(loaderData.promise, loaderData); + + return { + ...event, + activityContext: { + ...event.activityContext, + loaderData: loaderData.promise, + }, + }; + }); + } + return initialEvents.map((event) => { - if (event.name !== "Pushed") { + if (event.name !== "Pushed" && event.name !== "Replaced") { return event; } @@ -78,6 +177,15 @@ export function loaderPlugin< }; }); }, + onInit({ actions, initInfo }) { + if (initInfo?.kind !== "load") { + return; + } + + const stack = actions.getStack(); + resolveRestoredStackLoaderData(stack); + resolvePausedEventLoaderData(stack.pausedEvents); + }, onBeforePush: createBeforeRouteHandler(input, loadData), onBeforeReplace: createBeforeRouteHandler(input, loadData), }; @@ -88,6 +196,7 @@ type OnBeforeRoute = NonNullable< | ReturnType["onBeforePush"] | ReturnType["onBeforeReplace"] >; + function createBeforeRouteHandler< T extends ActivityDefinition, R extends { diff --git a/integrations/react/src/utils/SyncInspectablePromise.ts b/integrations/react/src/utils/SyncInspectablePromise.ts index 1c4847b0d..a80468cac 100644 --- a/integrations/react/src/utils/SyncInspectablePromise.ts +++ b/integrations/react/src/utils/SyncInspectablePromise.ts @@ -6,6 +6,12 @@ export interface SyncInspectablePromise extends Promise { reason?: unknown; } +export interface SyncInspectableDeferred { + promise: SyncInspectablePromise; + resolve: (value: T | PromiseLike) => void; + reject: (reason: unknown) => void; +} + export const PromiseStatus = { PENDING: "pending", FULFILLED: "fulfilled", @@ -96,3 +102,80 @@ export function reject(error: unknown): SyncInspectablePromise { reason: error, }) as SyncInspectablePromise; } + +export function defer(): SyncInspectableDeferred { + let resolvePromise!: (value: T | PromiseLike) => void; + let rejectPromise!: (reason: unknown) => void; + let isSettled = false; + + const promise = Object.assign( + new Promise((resolvePromise_, rejectPromise_) => { + resolvePromise = resolvePromise_; + rejectPromise = rejectPromise_; + }), + { status: PromiseStatus.PENDING }, + ) as SyncInspectablePromise; + + const fulfill = (value: T) => { + if (promise.status !== PromiseStatus.PENDING) { + return; + } + + promise.status = PromiseStatus.FULFILLED; + promise.value = value; + resolvePromise(value); + }; + + const rejectPromiseState = (reason: unknown) => { + if (promise.status !== PromiseStatus.PENDING) { + return; + } + + promise.status = PromiseStatus.REJECTED; + promise.reason = reason; + rejectPromise(reason); + }; + + const reject = (reason: unknown) => { + if (isSettled) { + return; + } + + isSettled = true; + rejectPromiseState(reason); + }; + + return { + promise, + resolve(value) { + if (isSettled) { + return; + } + + isSettled = true; + + try { + const source = resolve(value) as SyncInspectablePromise; + + if (source === promise) { + rejectPromiseState( + new TypeError("A promise cannot be resolved with itself"), + ); + return; + } + + const state = inspect(source); + if (state.status === PromiseStatus.FULFILLED) { + fulfill(state.value); + } else if (state.status === PromiseStatus.REJECTED) { + rejectPromiseState(state.reason); + } else { + source.then(fulfill, rejectPromiseState); + } + } catch (error) { + rejectPromiseState(error); + } + }, + reject, + }; +} diff --git a/plans/fep-2598-react-loader-core-v3/plan.md b/plans/fep-2598-react-loader-core-v3/plan.md new file mode 100644 index 000000000..897836ea1 --- /dev/null +++ b/plans/fep-2598-react-loader-core-v3/plan.md @@ -0,0 +1,125 @@ +# FEP-2598 작업 계획 — react 내장 plugin-loader의 core v3 지원 + +- 이슈: [FEP-2598](https://linear.app/daangn/issue/FEP-2598) `stackflow/react에서 stackflow/core v3 지원하기` +- 상태: 스펙 확정 (2026-07-21, 인터뷰 완료) + +## 배경 + +core v3에서 스냅샷 기반 초기화(load path)가 추가되면서 `overrideInitialEvents`가 받는 +이벤트가 `PushedEvent | StepPushedEvent`에서 `SnapshotEvent`(전체 replay 시퀀스 + +`initInfo` 판별자)로 확장됐다. react 내장 loaderPlugin +(`integrations/react/src/loader/loaderPlugin.tsx`)은 v2 시절 가정(초기 이벤트 = 진입 +`Pushed` 몇 개)으로 작성되어, load path에서 다음이 깨진다: + +- 죽은 activity(이후 pop/replace로 사라진)의 `Pushed`에도 loader가 전부 실행된다 — + 불필요한 네트워크 요청 등 사이드이펙트. +- `Replaced`로 진입한 alive activity는 loaderData를 받지 못해 `useLoaderData`가 깨진다. +- SSR용 `initialContext.initialLoaderData`가 replay 내 모든 `Pushed`에 잘못 붙는다. + +참고: react의 peerDep은 이미 `core ^2 || ^3`(FEP-2590)이고, `StackflowReactPlugin`은 +core 플러그인 타입을 그대로 확장하므로 v3 훅 타입 표면은 자동으로 따라온다. 이번 작업의 +대상은 loaderPlugin의 동작이다. + +## 확정 스펙 + +1. **불변식**: 복원된 스택에서 렌더 가능한 모든 activity는, loader가 정의돼 있다면 + fresh한 `loaderData`를 가진다. 이벤트 단위가 아닌 **최종 재구성 스택의 activity + 단위** 보장이다. +2. **렌더 가능 경계**: `transitionState !== "exit-done"` — 렌더러 + (`basicRendererPlugin`)의 실제 렌더 기준과 일치. `exit-active`(mid-pop 복원) 포함. +3. **경로 판별**: `initInfo?.kind === "load"`일 때만 load 동작. `initInfo` 부재 + (= core v2 런타임)나 `"create"`는 기존 create 동작을 바이트 단위로 보존한다. + 버전/기능 감지는 하지 않는다. +4. **stale 데이터 불신**: 스냅샷에 저장된 `activityContext.loaderData`는 절대 + 재사용하지 않고 fresh loader 실행으로 덮어쓴다. `activityContext`의 나머지 필드는 + 보존. loader가 없는 activity의 이벤트는 stale 필드가 있어도 건드리지 않는다. +5. **`initialLoaderData`는 create 전용**: load path에서는 완전히 무시한다. 서버가 + 계산한 대상과 복원 스택의 대응을 일반화할 수 없고, 그로 인한 hydration mismatch는 + snapshot provider의 책임 영역이다. +6. **loader 실패 ≠ load 실패**: `SnapshotLoadError`로 승격하지 않고 `onLoadError`도 + 트리거하지 않는다. create path와 동일하게 콘솔 에러 + 렌더 시점 에러 바운더리로 + 처리한다. +7. **create path도 `Replaced`를 진입 이벤트로 처리한다**: v3에서 + `overrideInitialEvents` 체인의 타입이 `SnapshotEvent[]`로 넓어져 create path에서도 + 앞선 플러그인이 `Replaced`를 포함한 시퀀스를 반환할 수 있다. 기존 **eager 방식 + 그대로** `Replaced`를 `Pushed`와 동일하게 취급한다(`initialLoaderData` 부착 규칙 + 포함) — deferred+onInit으로 통일하지 않는 이유는 create path의 SSR이 `init()` + 없이 `overrideInitialEvents`의 eager 부착에 의존하기 때문. `[Pushed A, + Replaced B]`처럼 죽는 activity(A)의 loader가 실행되는 낭비는 수용한다 — + aliveness 판정에 core 재구성 로직 복제가 필요해지는 것보다 낫고, create + 시퀀스는 짧다. v2 타입상 create 체인에 `Replaced`가 등장할 수 없으므로 v2-합법 + 입력에 대한 동작은 변하지 않는다(스펙 3과 양립). + +## load path 메커니즘 + +두 단계로 나뉜다: `overrideInitialEvents`에서 deferred를 심고, `onInit`에서 core가 +실제로 계산한 최종 스택을 보고 resolve한다. react 플러그인 안에서 static 이벤트 +합성·backdate·aggregate로 core의 load 재구성 로직을 복제하지 않기 위함이다 — +aliveness 판정의 진실 원천은 core가 계산한 스택 하나뿐이다. + +1. **`overrideInitialEvents` (load일 때만)**: loader가 정의된 activity의 모든 + `Pushed`/`Replaced` 이벤트의 `activityContext.loaderData`에 **sync-inspectable + deferred**를 심는다. loader가 없는 activity의 이벤트와 그 외 이벤트(`Popped`/ + step 계열/`Paused`/`Resumed`)는 무조건 통과. 이벤트의 id/date/순서/구성원은 + 절대 변경하지 않는다. +2. **`onInit` (load일 때만)**: `getStack()`으로 core가 계산한 최종 스택을 읽어, + `transitionState !== "exit-done"`이고 loader가 정의된 activity마다 loader를 + **1회** 실행한다 — 인자는 해당 activity의 **최종** name/params. 실행 결과로 + `activity.context.loaderData`(= 1에서 심은 deferred)를 resolve한다. aggregate가 + 진입 이벤트의 `activityContext`를 `activity.context`로 채택하므로 + (`makeActivityFromEvent` 확인) activityId 부기는 불필요하다. +3. **죽은 activity의 deferred는 pending으로 방치한다.** exit-done은 렌더되지 않아 + 소비자가 없고, `undefined` resolve보다 "이 데이터는 오지 않는다"를 정직하게 + 표현한다. 이벤트 로그가 앱 수명 동안 유지되므로 메모리 델타도 없다. +4. loader promise 실패는 create path와 동일하게 `printLoaderDataPromiseError`로 + 출력한다. + +### 타이밍·의미 보존 근거 + +- `store.init()`(→ `onInit`)은 `stackflow.tsx`에서 store 생성 직후 같은 `useMemo` + 안에서 동기 호출된다 — activity 첫 렌더 전에 resolve가 완료된다. +- `useLoaderData`는 `useThenable`로 promise의 `status` 필드를 동기 inspect한다. + 따라서 deferred는 naked Promise가 아니라 **SyncInspectablePromise 규약을 따르는 + 커스텀 deferred**여야 한다: resolve 시 비-thenable 값이면 `status`/`value`를 + 동기로 갱신한다. 이로써 동기 loader가 첫 렌더에서 suspend 없이 그려지는 create + path와의 의미 대칭이 유지된다 (naked Promise는 adoption이 마이크로태스크를 거쳐 + 동기 loader조차 Suspense fallback이 한 번 번쩍인다). +- `onInit`은 `initInfo`를 받으므로 load 게이팅이 가능하고, v2에서는 `initInfo` + 부재로 자연스럽게 비활성화된다(스펙 3과 일관). + +## 비목표 + +- 런타임 훅(`onBeforePush`/`onBeforeReplace`)의 pause/resume·lazy preload 동작 변경 +- create path 동작 변경 — 단, `Replaced` 진입 이벤트 처리 확장(스펙 7)은 예외. + `initialLoaderData`를 모든 진입 이벤트에 붙이는 기존 quirk은 유지 +- 시퀀스 재구성/re-dating — "settled 복원 보장" 같은 load policy는 제공하지 않음 + (snapshot provider나 별도 플러그인의 몫) +- load path에서의 lazy 컴포넌트 preload — 초기화엔 보호할 전환이 없음, Suspense가 처리 +- plugin-history-sync의 load path 대응 (FEP-2001 영역) +- **load path + SSR** — `store.init()`은 `isBrowser()`일 때만 호출되므로 서버 렌더 + 중 스냅샷이 제공되면 loader deferred가 pending으로 남는다. 스냅샷 복원은 + 본질적으로 클라이언트 시나리오이고(`initialLoaderData`를 create 전용으로 정한 + 것과 일관) 현 persister도 서버에선 스냅샷을 제공하지 않으므로 미지원으로 명시 + +## 테스트 계획 + +`makeCoreStore` + `provideSnapshot` 플러그인으로 실제 load path를 구동(`store.init()` +호출 포함)하는 spec 추가: + +- alive activity에만 loader 실행 (죽은 activity의 loader 미실행 검증) +- `Replaced`로 진입한 alive activity의 loaderData resolve +- 동기 loader의 결과가 `init()` 직후 동기 inspect로 FULFILLED (suspend 없는 첫 렌더 + 보장 — create path와의 의미 대칭) +- 죽은 activity의 deferred는 pending 유지 +- 저장된 stale loaderData 덮어쓰기 / loader 없는 activity 통과 +- load path에서 `initialLoaderData` 무시, create path에서는 기존 동작 유지 +- create path에서 `Replaced` 포함 시퀀스: `Replaced` 진입 activity에 fresh loader + 실행 및 `initialLoaderData` 부착 (eager — `init()` 호출 없이도 동작) +- `initInfo` 부재 시(v2 시뮬레이션) create 동작 보존 (deferred 미주입) +- loader reject가 `SnapshotLoadError`로 승격되지 않음 + +## 릴리즈·커밋 + +- changeset: `@stackflow/react` **patch** (`fix:` — 선언된 `^2 || ^3` 호환성 대비 + 버그픽스, FEP-2590 선례와 동일) +- 커밋은 작은 의미 단위로 분리: 구현+테스트 → changeset diff --git a/plans/fep-2598-react-loader-core-v3/run-plan.md b/plans/fep-2598-react-loader-core-v3/run-plan.md new file mode 100644 index 000000000..a00b3a465 --- /dev/null +++ b/plans/fep-2598-react-loader-core-v3/run-plan.md @@ -0,0 +1,42 @@ +# Run Plan — FEP-2598 react 내장 loaderPlugin의 core v3 load path 지원 구현 + +## 1. 과제 스펙 · 판단 기준 + +**과제 스펙**: `plans/fep-2598-react-loader-core-v3/plan.md`(확정 스펙, 2026-07-21 인터뷰 완료)가 스펙 정본이다. 요지: + +- `integrations/react/src/loader/loaderPlugin.tsx`를 core v3 load path에 대응시킨다 — `overrideInitialEvents`(load일 때만)에서 loader 있는 activity의 `Pushed`/`Replaced`에 sync-inspectable deferred를 심고, `onInit`에서 core가 계산한 최종 스택 기준(`transitionState !== "exit-done"`)으로 alive activity에만 loader를 1회 실행해 resolve한다. +- 확정 스펙 1–7(불변식·렌더 가능 경계·`initInfo` 경로 판별·stale 불신·`initialLoaderData` create 전용·loader 실패 비승격·create path의 `Replaced` eager 처리)과 비목표를 그대로 따른다. +- **테스트는 작성하지 않는다**(사용자 지시 — plan.md의 테스트 계획 섹션은 이번 run의 산출 범위에서 제외). 테스트 미작성은 검증 삭제가 아니라 검증 책임의 이관이다: worker는 산출물을 실제로 로드·실행해 핵심 경로(load path의 alive-only loader 실행·sync-inspect FULFILLED, create path 무회귀)가 성립함을 실측한다 — 임시 스크립트 허용, 테스트 파일 작성 금지. 게이트의 실측 축이 이를 독립 재검증한다. +- 산출: 구현, `@stackflow/react` patch changeset(`fix:`), 작은 의미 단위 커밋(구현 → changeset), 작업 브랜치 `feature/fep-2598`. 게이트 통과(전원 APPROVE) 후 Draft PR 생성. + +**이번 작업 고유 판단 기준**: 확정 스펙(plan.md)의 스펙 1–7과 load path 메커니즘에 대한 정합 — 특히 ① `initInfo` 부재/`"create"` 경로에서 기존 create 동작이 v2-합법 입력에 대해 보존되는가(무회귀), ② deferred가 SyncInspectablePromise 규약(동기 loader의 suspend 없는 첫 렌더)을 지키는가. 검증은 코드 독해로 대체할 수 없다 — 산출물이 전제하는 core v3 사실(SnapshotEvent/`initInfo`/aggregate의 `activityContext` 채택 등)은 core 소스와 직접 대조하고, 핵심 경로의 성립·무회귀는 실제 로드·실행으로 실측한다(임시 스크립트 허용, 테스트 파일 작성 금지). + +## 2. 워크플로우 + +`review-loop` (자산 — `~/.agents/orchestration/workflows/review-loop.md`). 오버라이드 없음(라운드 캡 5, 종료 = reviewer 전원 APPROVE). + +확정 설계 문서 구현의 harness-first 선례가 있으나 이번엔 채택하지 않는다: 테스트를 작성하지 않는 run이라 하니스 단계 자체가 성립하지 않고, 대상이 기존 패키지 내 플러그인 1개 수정으로 소규모라 단일 review-loop 축소 선례(FEP-2584)에 가깝다. 독립 안전망은 게이트의 실측 축이 담당한다. + +## 3. 세션 테이블 + +| 논리 세션명 | 슬롯 | role | 런타임 | 모델 | 세부 지침 | lens | +|---|---|---|---|---|---|---| +| impl-worker-codex | worker | worker | Codex | GPT-5.6 sol xhigh, fast mode | 작은 의미 단위 커밋(구현 → changeset 분리). 테스트 파일 작성 금지 — 대신 산출물을 실제 로드·실행해 핵심 경로 성립을 실측(임시 스크립트 허용)한 뒤 완료 보고. yarn Berry 레포 — npm 금지 | implementation, react, architecture | +| review-reviewer-claude1 | reviewer | reviewer | Claude | claude-fable-5[1m] xhigh, ultracode | **축 A — 스펙 전수 대조·ground truth**: plan.md 확정 스펙 1–7·load path 메커니즘·비목표와 산출물을 항목 단위로 전수 대조. 산출물이 전제하는 core v3 소스 사실(SnapshotEvent 타입, `initInfo` 전달 경로, aggregate의 `activityContext` 채택, `store.init()` 타이밍)을 core 소스와 직접 대조해 검증 | — | +| review-reviewer-claude2 | reviewer | reviewer | Claude | claude-fable-5[1m] xhigh, ultracode | **축 B — 실측·렌즈 전방위**: 산출물을 실제 로드·실행해 load path(alive-only loader 실행, 동기 loader의 sync-inspect FULFILLED, 죽은 activity pending 유지)와 create path 무회귀를 실측 — 코드 독해로 대체 금지, 임시 스크립트 허용·테스트 파일 작성 금지. 그 위에 렌즈 위반 전방위 탐색 | implementation, react | + +## 4. 인라인 자산 정의 + +해당 없음 — 워크플로우·role·lens 모두 자산 참조. + +## 5. 설계 메타 + +- **적용된 디폴트**: 게이트 2명(합의 게이트 슬롯 디폴트 — 단 런타임은 교차 대신 Claude 듀얼 + 축 분담, 아래 근거). worker 1명(일반 슬롯 디폴트). 라운드 캡 5(워크플로우 기본). +- **설계 근거**: 산출물(코드)을 만들고 독립 검증 게이트를 통과시키는 과제 → review-loop. 게이트 상보성은 런타임 다양성 대신 **검사 방법 축 분리**(정적 전수 대조+소스 사실 / 실측 구동+렌즈)로 확보하고 런타임 교차는 worker(Codex)↔reviewer(Claude) 사이에 둔다 — FEP-2546/2521/2584에서 사용자 교정으로 수렴한 바인딩 패턴. 두 리뷰어 모두 ultracode(사용자 지정 — FEP-2521의 "실측 축 비-ultracode" 교정을 이번 run에서 오버라이드). +- **사용자 오버라이드**: worker fast mode 구동 / 테스트 미작성·test 렌즈 전면 제거 / worker에 architecture 렌즈 / 축 B도 ultracode. +- **메모리 반영**: + - `confirmed-design-doc-impl-runs-harness-first…`(seen 5): harness-first를 기본 제안으로 검토했으나 테스트 미작성·소규모라 미채택(2절에 근거 명시). 저작=Codex sol xhigh, 리뷰어=Claude 1m 바인딩 패턴은 채택. + - `confirmed-fep2584-small-package-spec-impl-single-reviewloop-no-tests`(seen 2): 소규모 과제의 단일 review-loop 축소 + 테스트 미작성 시 검증 책임을 실측·게이트로 이관(worker·게이트에 "실제 로드·실행 실측, 임시 스크립트 허용" 명시) + 리뷰어 fable-5 xhigh ultracode 선호 + Draft PR은 게이트 통과 후 생성. + - `empirical-behavioral-review-catches-input-defects-logic-review-approves`(seen 11): 게이트에 ground-truth 축(core 소스 대조)과 실행 실측 축을 명시적으로 편성 — 논리 리뷰가 구조적으로 못 보는 결함 대비. 테스트 없는 run이므로 실측 축이 게이트 안에 필수. + - `worker-context-exhaustion…`(seen 12): 리뷰어를 1m으로 바인딩(worker는 Codex라 해당 없음). + - 사용자 메모리: "fable 5 = 1M variant"(bare fable-5 금지), 작은 커밋 단위 선호.