From af39915ce366788632eac892f12a7578b035e4e8 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Wed, 26 Aug 2026 10:14:02 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=9B=84=20=EB=B3=B4=ED=98=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=A0=89=ED=8A=B8=20=EC=95=88=20?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 문제: router.replace()로 이동 시 Next.js RSC 요청에 새 쿠키가 즉시 반영 안 됨 → 미들웨어가 쿠키 없다고 판단 → 다시 /signin으로 리다이렉트 - 해결: window.location.href로 하드 네비게이션 → 브라우저가 새 Set-Cookie를 확실히 포함하여 요청 - 영향: 로그인 후 페이지 전체 리로드 발생 (UX 상 자연스러움) --- components/pages/signin/signin-form.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/pages/signin/signin-form.tsx b/components/pages/signin/signin-form.tsx index bfe6de2..2ce81cb 100644 --- a/components/pages/signin/signin-form.tsx +++ b/components/pages/signin/signin-form.tsx @@ -103,8 +103,8 @@ const SigninForm = ({ returnUrl }: SinginFormProps) => { returnUrl && returnUrl.startsWith("/") && !returnUrl.startsWith("//") ? returnUrl : "/"; - router.replace(targetUrl); - router.refresh(); + // 하드 네비게이션: 미들웨어가 새 쿠키를 확실히 인식하도록 + window.location.href = targetUrl; } catch (error) { // FetchError: 로그인 실패(401/400)도 여기로 옴 (fetchData가 non-OK에서 throw) if (error instanceof FetchError) { From 1b6ed60bc4e353fcea2b9b03b1fcf56fc4732541 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Wed, 26 Aug 2026 10:14:43 +0900 Subject: [PATCH 2/6] =?UTF-8?q?chore:=20handleSubmit=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=AF=B8=EC=82=AC=EC=9A=A9=20router=20dependency=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/pages/signin/signin-form.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/pages/signin/signin-form.tsx b/components/pages/signin/signin-form.tsx index 2ce81cb..c6eeff6 100644 --- a/components/pages/signin/signin-form.tsx +++ b/components/pages/signin/signin-form.tsx @@ -125,7 +125,7 @@ const SigninForm = ({ returnUrl }: SinginFormProps) => { } setLoading(false); } - }, [emailValue.value, passwordValue.value, loading, errors, router, returnUrl, setUser, toast]); + }, [emailValue.value, passwordValue.value, loading, errors, returnUrl, setUser, toast]); // Enter 키 핸들러 useEffect(() => { From 5d9700a1dabc1783c4d6fc29536c8e16ff2bfd9f Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Wed, 26 Aug 2026 10:19:46 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83?= =?UTF-8?q?=20=ED=9B=84=20=EC=9D=B4=EC=A0=84=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=EA=B0=80=20=EB=82=A8=EC=95=84=EC=9E=88?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 문제: router.replace('/')로 소프트 네비게이션 → Next.js Router Cache에 이전 서버 컴포넌트 결과 잔류 - 해결: window.location.href = '/'로 하드 네비게이션 → 캐시 완전 무효화 - 다른 페이지 이동 시 서버에서 새로 fetch → 로그아웃 상태 즉시 반영 --- components/pages/config/user-setting.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/pages/config/user-setting.tsx b/components/pages/config/user-setting.tsx index 57d53b0..a5151e7 100644 --- a/components/pages/config/user-setting.tsx +++ b/components/pages/config/user-setting.tsx @@ -31,8 +31,8 @@ const UserSetting = () => { // API 실패 또는 타임아웃: 무시하고 진행 } - // 3. 홈으로 이동 - router.replace("/"); + // 3. 홈으로 이동 (하드 네비게이션 — Next.js Router Cache 무효화) + window.location.href = "/"; }; const handleResign = () => { From 0ede7fffd356d97fba55edb4ed784dd2a7fc150e Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Wed, 26 Aug 2026 14:16:02 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20logout=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20window.location.href=20=EB=B0=A9=EC=8B=9D=EC=97=90?= =?UTF-8?q?=20=EB=A7=9E=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mockReplace 대신 window.location.href 확인으로 변경 - getByText('로그아웃') → getByRole('button', {name: /로그아웃/}) (중복 텍스트 방지) - window.location mock 설정 추가 --- .../logout-postcondition.property.test.tsx | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/__tests__/components/logout-postcondition.property.test.tsx b/__tests__/components/logout-postcondition.property.test.tsx index ef145fc..8d30a7f 100644 --- a/__tests__/components/logout-postcondition.property.test.tsx +++ b/__tests__/components/logout-postcondition.property.test.tsx @@ -74,14 +74,26 @@ const userArbitrary: fc.Arbitrary = fc.record({ }); describe("Feature: auth-flow-improvements, Property 12: Logout post-condition invariant", () => { + let locationHrefSpy: ReturnType; + beforeEach(() => { mockSignout.mockReset(); mockReplace.mockReset(); sessionStorage.clear(); + // window.location.href mock + locationHrefSpy = vi.spyOn(window, "location", "get").mockReturnValue({ + ...window.location, + href: "http://localhost", + } as Location); + Object.defineProperty(window, "location", { + writable: true, + value: { ...window.location, href: "" }, + }); }); afterEach(() => { cleanup(); + locationHrefSpy?.mockRestore?.(); }); it( @@ -108,12 +120,12 @@ describe("Feature: auth-flow-improvements, Property 12: Logout post-condition in render(); // Find and click the logout button - const logoutButton = screen.getByText("로그아웃"); + const logoutButton = screen.getByRole("button", { name: /로그아웃/ }); fireEvent.click(logoutButton); - // Wait for the handler to complete + // Wait for the handler to complete (window.location.href 설정 확인) await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith("/"); + expect(window.location.href).toBe("/"); }); // Post-condition: store.user SHALL be null @@ -123,7 +135,7 @@ describe("Feature: auth-flow-improvements, Property 12: Logout post-condition in expect(sessionStorage.getItem(SESSION_CACHE_KEY)).toBeNull(); cleanup(); - mockReplace.mockReset(); + window.location.href = ""; } ), { numRuns: 100 } From 5ebd55fb5ee9cd7ced00b500898c2f3b4d1f131e Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 8 Sep 2026 08:51:33 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=EC=9D=B4=20=ED=95=98=EB=93=9C=20=EB=84=A4?= =?UTF-8?q?=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20keepalive=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 문제: Promise.race의 timeout(5s)이 이기면 window.location.href로 페이지가 언로드되면서 진행 중이던 signout fetch가 취소 → 서버 세션 무효화 안 될 수 있음 - 해결: signout fetch에 keepalive:true 추가 → 페이지 언로드 후에도 요청 완료 보장 - timeout race는 UX 지연 방지용으로 유지 (요청 자체는 백그라운드 완료) --- components/pages/config/user-setting.tsx | 7 +++++-- lib/api/auth/signout.ts | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/components/pages/config/user-setting.tsx b/components/pages/config/user-setting.tsx index a5151e7..f736730 100644 --- a/components/pages/config/user-setting.tsx +++ b/components/pages/config/user-setting.tsx @@ -19,7 +19,10 @@ const UserSetting = () => { setUser(null); clearSessionCache(); - // 2. 서버 로그아웃 API 호출 (5초 타임아웃, 실패해도 진행) + // 2. 서버 로그아웃 API 호출 + // signout은 keepalive:true라서 이후 하드 네비게이션으로 페이지가 + // 언로드되어도 요청이 취소되지 않고 서버 세션 무효화가 보장된다. + // UX 지연 방지를 위해 최대 5초까지만 대기한다. try { await Promise.race([ signout(), @@ -28,7 +31,7 @@ const UserSetting = () => { ), ]); } catch { - // API 실패 또는 타임아웃: 무시하고 진행 + // API 실패 또는 타임아웃: keepalive 요청은 백그라운드에서 계속 진행 } // 3. 홈으로 이동 (하드 네비게이션 — Next.js Router Cache 무효화) diff --git a/lib/api/auth/signout.ts b/lib/api/auth/signout.ts index ee6ef08..4cf9478 100644 --- a/lib/api/auth/signout.ts +++ b/lib/api/auth/signout.ts @@ -4,6 +4,8 @@ const signout = async () => { const response = await fetchData(`/api/v1/auth/logout`, { method: "POST", credentials: "include", + // 페이지가 언로드(하드 네비게이션)되어도 요청이 취소되지 않도록 보장 + keepalive: true, }); const data = response.json(); From 89a88ef7fd781c2e0b0cf8f80d1af1acc09f591d Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 8 Sep 2026 08:53:16 +0900 Subject: [PATCH 6/6] =?UTF-8?q?test:=20logout=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20window.location=20mock=20=EB=8B=A8=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=84=EB=9E=B5=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vi.spyOn + Object.defineProperty 혼용 제거 (서로 충돌하던 문제) - 원본 location 보관 후 afterEach에서 완전 복원 - 후속 테스트의 mock 설정이 정상 동작하도록 보장 --- .../logout-postcondition.property.test.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/__tests__/components/logout-postcondition.property.test.tsx b/__tests__/components/logout-postcondition.property.test.tsx index 8d30a7f..7ff19ab 100644 --- a/__tests__/components/logout-postcondition.property.test.tsx +++ b/__tests__/components/logout-postcondition.property.test.tsx @@ -74,26 +74,29 @@ const userArbitrary: fc.Arbitrary = fc.record({ }); describe("Feature: auth-flow-improvements, Property 12: Logout post-condition invariant", () => { - let locationHrefSpy: ReturnType; + let originalLocation: Location; beforeEach(() => { mockSignout.mockReset(); mockReplace.mockReset(); sessionStorage.clear(); - // window.location.href mock - locationHrefSpy = vi.spyOn(window, "location", "get").mockReturnValue({ - ...window.location, - href: "http://localhost", - } as Location); + // 원본 location 보관 후, href 기록 가능한 스텁으로 교체 + originalLocation = window.location; Object.defineProperty(window, "location", { + configurable: true, writable: true, - value: { ...window.location, href: "" }, + value: { ...originalLocation, href: "" }, }); }); afterEach(() => { cleanup(); - locationHrefSpy?.mockRestore?.(); + // 원본 location getter 완전 복원 + Object.defineProperty(window, "location", { + configurable: true, + writable: true, + value: originalLocation, + }); }); it(