From 2ced6e235eeee08f00d2bd584f0d202e35cc2025 Mon Sep 17 00:00:00 2001 From: wlrnjs Date: Sun, 27 Sep 2026 00:29:48 +0900 Subject: [PATCH] =?UTF-8?q?fix(login):=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=A1=9C=EA=B3=A0=EA=B0=80=20=EB=8A=A6?= =?UTF-8?q?=EA=B2=8C=20=ED=91=9C=EC=8B=9C=EB=90=98=EB=8D=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그인 페이지를 서버 컴포넌트로 바꿔 reason과 callbackUrl 쿼리를 서버에서 읽어 폼에 넘깁니다. 폼 훅에서 useSearchParams를 걷어내 로고와 입력칸이 첫 HTML에 포함되도록 했습니다. 로고는 Icon의 지연 로딩 대신 정적으로 가져와 별도 청크 요청을 없앴습니다. Co-Authored-By: Claude Opus 5.5 --- .../AdminLoginForm/AdminLoginForm.tsx | 19 ++++++++--- .../AdminLoginFormProvider.tsx | 33 +++++++++++++++++++ src/app/(auth)/login/_components/index.ts | 1 + src/app/(auth)/login/_docs/plan.md | 10 ++++++ .../(auth)/login/_hooks/useAdminLoginForm.ts | 10 +++--- .../login/_hooks/useLoginReasonToast.ts | 7 ++-- .../getSingleSearchParam.test.ts | 15 +++++++++ .../getSingleSearchParam.ts | 16 +++++++++ src/app/(auth)/login/page.tsx | 27 +++++++-------- 9 files changed, 108 insertions(+), 30 deletions(-) create mode 100644 src/app/(auth)/login/_components/AdminLoginFormProvider/AdminLoginFormProvider.tsx create mode 100644 src/app/(auth)/login/_utils/getSingleSearchParam/getSingleSearchParam.test.ts create mode 100644 src/app/(auth)/login/_utils/getSingleSearchParam/getSingleSearchParam.ts diff --git a/src/app/(auth)/login/_components/AdminLoginForm/AdminLoginForm.tsx b/src/app/(auth)/login/_components/AdminLoginForm/AdminLoginForm.tsx index ed83192..763d978 100644 --- a/src/app/(auth)/login/_components/AdminLoginForm/AdminLoginForm.tsx +++ b/src/app/(auth)/login/_components/AdminLoginForm/AdminLoginForm.tsx @@ -1,6 +1,7 @@ "use client"; -import { Button, Icon, InputText } from "@/components"; +import { Button, InputText } from "@/components"; +import Logo from "@/assets/logo.svg"; import useAdminLoginForm from "../../_hooks/useAdminLoginForm"; import useLoginReasonToast from "../../_hooks/useLoginReasonToast"; @@ -25,23 +26,31 @@ const LOGIN_INPUTS = [ }, ] as const; +interface AdminLoginFormProps { + /** 로그인 화면으로 보내진 이유(`reason` 쿼리) */ + reason?: string; + /** 로그인 후 돌아갈 경로(`callbackUrl` 쿼리) */ + callbackUrl?: string; +} + /** * 관리자 로그인 폼입니다. * * @remarks * - 상위에서 `FormProvider`로 감싸야 합니다. * - 회원가입과 비밀번호 찾기는 운영 앱에서만 제공하므로 링크를 두지 않습니다. + * - 로고는 첫 화면에 바로 보여야 하므로 `Icon`의 지연 로딩을 거치지 않고 정적으로 가져옵니다. */ -const AdminLoginForm = () => { - const { onSubmitLogin, isPending } = useAdminLoginForm(); - useLoginReasonToast(); +const AdminLoginForm = ({ reason, callbackUrl }: AdminLoginFormProps) => { + const { onSubmitLogin, isPending } = useAdminLoginForm(callbackUrl); + useLoginReasonToast(reason); return (
- +
로그인 정보 입력 diff --git a/src/app/(auth)/login/_components/AdminLoginFormProvider/AdminLoginFormProvider.tsx b/src/app/(auth)/login/_components/AdminLoginFormProvider/AdminLoginFormProvider.tsx new file mode 100644 index 0000000..e22aa92 --- /dev/null +++ b/src/app/(auth)/login/_components/AdminLoginFormProvider/AdminLoginFormProvider.tsx @@ -0,0 +1,33 @@ +"use client"; +"use no memo"; + +import { FormProvider, useForm } from "react-hook-form"; +import { AdminLoginFormType } from "../../_types/AdminLoginFormType"; +import AdminLoginForm from "../AdminLoginForm/AdminLoginForm"; + +interface AdminLoginFormProviderProps { + /** 로그인 화면으로 보내진 이유(`reason` 쿼리) */ + reason?: string; + /** 로그인 후 돌아갈 경로(`callbackUrl` 쿼리) */ + callbackUrl?: string; +} + +/** + * 관리자 로그인 폼의 `react-hook-form` 상태를 만들고 폼에 내려줍니다. + * + * @remarks + * - 페이지가 서버 컴포넌트라 폼 상태를 이 클라이언트 컴포넌트에서 만듭니다. + */ + +export const AdminLoginFormProvider = ({ reason, callbackUrl }: AdminLoginFormProviderProps) => { + const methods = useForm({ + mode: "onChange", + reValidateMode: "onChange", + }); + + return ( + + + + ); +}; diff --git a/src/app/(auth)/login/_components/index.ts b/src/app/(auth)/login/_components/index.ts index 37024f4..2b8fa9b 100644 --- a/src/app/(auth)/login/_components/index.ts +++ b/src/app/(auth)/login/_components/index.ts @@ -1 +1,2 @@ export { default as AdminLoginForm } from "./AdminLoginForm/AdminLoginForm"; +export { AdminLoginFormProvider } from "./AdminLoginFormProvider/AdminLoginFormProvider"; diff --git a/src/app/(auth)/login/_docs/plan.md b/src/app/(auth)/login/_docs/plan.md index f7e5532..0927b90 100644 --- a/src/app/(auth)/login/_docs/plan.md +++ b/src/app/(auth)/login/_docs/plan.md @@ -1,3 +1,13 @@ # login 작업 계획 - [x] 로그인 폼 최소 높이를 헤더 전제의 `h-base`(`calc(100dvh - 56px)`)에서 `min-h-dvh`로 바꿔, body 좌우 테두리가 화면 맨 아래까지 이어지게 한다 + +## 로고 로딩 개선 (2026-09-27) + +로고가 늦게 뜨는 원인은 두 가지다. 폼 훅이 `useSearchParams`를 써서 `` 안쪽 폼 전체가 서버에서 그려지지 않았고, 로고는 `Icon`의 `React.lazy`로 폼 JS 실행 뒤 별도 청크로 한 번 더 받았다. + +- [x] A. 로그인 폼 로고를 `Icon` 대신 `@/assets/logo.svg` 정적 import로 바꾼다 +- [x] B-1. `useForm`/`FormProvider`를 `_components/AdminLoginFormProvider`로 옮기고 `reason`, `callbackUrl`을 props로 받는다 +- [x] B-2. `useLoginReasonToast`, `useAdminLoginForm`에서 `useSearchParams`를 걷어내고 인자로 값을 받는다 +- [x] B-3. `page.tsx`를 서버 컴포넌트로 바꿔 `searchParams`를 읽어 넘기고 ``를 없앤다 +- [x] B-4. `searchParams` 값이 배열로 올 수 있어 `_utils/getSingleSearchParam`으로 첫 값만 쓰게 한다 diff --git a/src/app/(auth)/login/_hooks/useAdminLoginForm.ts b/src/app/(auth)/login/_hooks/useAdminLoginForm.ts index 54ad469..020d894 100644 --- a/src/app/(auth)/login/_hooks/useAdminLoginForm.ts +++ b/src/app/(auth)/login/_hooks/useAdminLoginForm.ts @@ -1,5 +1,5 @@ import { useRef, useState, type BaseSyntheticEvent } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; import { useFormContext } from "react-hook-form"; import { AxiosError } from "axios"; import { useQueryClient } from "@tanstack/react-query"; @@ -20,12 +20,13 @@ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; * - 로그인에 성공하면 `callbackUrl`이 안전한 내부 경로일 때 그곳으로, 아니면 `/`로 이동합니다. * `/`에서는 미들웨어가 계정 권한에 맞는 영역으로 보냅니다. * - 요청 중이거나 이동 중일 때는 중복 제출을 막습니다. + * + * @param callbackUrl - 로그인 후 돌아갈 경로(`callbackUrl` 쿼리) */ -const useAdminLoginForm = () => { +const useAdminLoginForm = (callbackUrl?: string) => { const { handleSubmit } = useFormContext(); const router = useRouter(); - const searchParams = useSearchParams(); const queryClient = useQueryClient(); const { addToast } = useToast(); const { mutateAsync: emailLoginMutateAsync, isPending } = useApiEmailLogin(); @@ -44,8 +45,7 @@ const useAdminLoginForm = () => { setIsRedirecting(true); queryClient.clear(); - const rawCallback = searchParams.get("callbackUrl"); - router.replace(isValidCallbackUrl(rawCallback) ? rawCallback : "/"); + router.replace(isValidCallbackUrl(callbackUrl) ? callbackUrl : "/"); } catch (error) { const errorCode = (error as AxiosError>).response?.data?.code; const toast = (errorCode && LOGIN_ERROR_TOAST[errorCode]) || LOGIN_ERROR_TOAST.COMMON500; diff --git a/src/app/(auth)/login/_hooks/useLoginReasonToast.ts b/src/app/(auth)/login/_hooks/useLoginReasonToast.ts index fd0918c..ebe39ce 100644 --- a/src/app/(auth)/login/_hooks/useLoginReasonToast.ts +++ b/src/app/(auth)/login/_hooks/useLoginReasonToast.ts @@ -1,5 +1,4 @@ import { useEffect, useRef } from "react"; -import { useSearchParams } from "next/navigation"; import { useToast } from "@/context/ToastContext"; import { LOGIN_REASON_TOAST } from "../_constants/LOGIN_REASON_TOAST"; @@ -9,13 +8,13 @@ import { LOGIN_REASON_TOAST } from "../_constants/LOGIN_REASON_TOAST"; * @remarks * - 세션 만료(`session-expired`)와 권한 없음(`forbidden`)을 안내합니다. * - 쿠키 정리는 미들웨어가 담당합니다. + * + * @param reason - 로그인 화면으로 보내진 이유(`reason` 쿼리) */ -const useLoginReasonToast = () => { +const useLoginReasonToast = (reason?: string) => { const { addToast } = useToast(); - const searchParams = useSearchParams(); const isShown = useRef(false); - const reason = searchParams.get("reason"); useEffect(() => { const toast = reason ? LOGIN_REASON_TOAST[reason] : undefined; diff --git a/src/app/(auth)/login/_utils/getSingleSearchParam/getSingleSearchParam.test.ts b/src/app/(auth)/login/_utils/getSingleSearchParam/getSingleSearchParam.test.ts new file mode 100644 index 0000000..4fffec1 --- /dev/null +++ b/src/app/(auth)/login/_utils/getSingleSearchParam/getSingleSearchParam.test.ts @@ -0,0 +1,15 @@ +import { getSingleSearchParam } from "./getSingleSearchParam"; + +describe("getSingleSearchParam", () => { + it("문자열은 그대로 돌려준다", () => { + expect(getSingleSearchParam("forbidden")).toBe("forbidden"); + }); + + it("배열이면 첫 번째 값을 돌려준다", () => { + expect(getSingleSearchParam(["/admin", "/admin/notice"])).toBe("/admin"); + }); + + it("값이 없으면 undefined를 돌려준다", () => { + expect(getSingleSearchParam(undefined)).toBeUndefined(); + }); +}); diff --git a/src/app/(auth)/login/_utils/getSingleSearchParam/getSingleSearchParam.ts b/src/app/(auth)/login/_utils/getSingleSearchParam/getSingleSearchParam.ts new file mode 100644 index 0000000..726387a --- /dev/null +++ b/src/app/(auth)/login/_utils/getSingleSearchParam/getSingleSearchParam.ts @@ -0,0 +1,16 @@ +/** + * 페이지 `searchParams`의 값 하나를 문자열로 좁힙니다. + * + * @remarks + * - 같은 키가 여러 번 오면 배열이 되므로 첫 번째 값을 씁니다. + * + * @example + * ```ts + * getSingleSearchParam("forbidden"); // "forbidden" + * getSingleSearchParam(["a", "b"]); // "a" + * getSingleSearchParam(undefined); // undefined + * ``` + */ + +export const getSingleSearchParam = (value: string | string[] | undefined) => + Array.isArray(value) ? value[0] : value; diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 00f1d93..7967510 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,26 +1,21 @@ -"use client"; -"use no memo"; +import { AdminLoginFormProvider } from "./_components"; +import { getSingleSearchParam } from "./_utils/getSingleSearchParam/getSingleSearchParam"; -import { Suspense } from "react"; -import { FormProvider, useForm } from "react-hook-form"; -import { AdminLoginForm } from "./_components"; -import { AdminLoginFormType } from "./_types/AdminLoginFormType"; +interface LoginPageProps { + searchParams: Promise>; +} -const LoginPage = () => { - const methods = useForm({ - mode: "onChange", - reValidateMode: "onChange", - }); +const LoginPage = async ({ searchParams }: LoginPageProps) => { + const { reason, callbackUrl } = await searchParams; return ( <>

관리자 로그인

- - - - - + ); };