Repository: KNU-HAEDAL-Website/frontend-v2
Pull request #144: Feature: 회원정보 찾기 (아이디 찾기, 비밀번호 재설정)
Author: login: jijun0129
Labels: name: 🎉 feat
Created: 2025-03-26T16:50:55Z | Merged: 2025-03-27T08:30:02Z

### 📝 상세 내용
회원정보 찾기 페이지를 추가하고 아이디 찾기 비밀번호 찾기를 구형하였습니다. 
비밀번호 찾기는 인증번호 받기를 누르면 인증번호 입력 폼이 나오도록 하였습니다. 
비밀번호 재설정을 하고 나면 메인 페이지로 이동하고 토스트 팝업으로 알려주도록 하였습니다. 

### #️⃣ 이슈 번호
- close #141 

### 🔗 참고 자료



### 📷 스크린샷(선택)

<img width="1378" alt="아이디 찾기" src="https://github.com/user-attachments/assets/59f6cf97-d65e-47ad-8aae-92a387fcf1a7" />
아이디 찾기
<img width="1389" alt="비밀번호 찾기" src="https://github.com/user-attachments/assets/c4a47679-f6a3-4a86-b0ef-3cc3b1ba708c" />
비밀번호 찾기

Linked issue #141: 회원정보 찾기 기능 추가(아이디 찾기, 비밀번호 찾기)
## Description

>학번, 이름으로 아이디를 찾을 수 있는 페이지 제작

## To Do

- [x] 회원 정보 찾기 페이지
- [x] 아이디 찾기 form
- [x] 비밀번호 찾기 form

## ETC


Diff:
diff --git a/frontend/src/pages/auth/find/FindAccountPage.tsx b/frontend/src/pages/auth/find/FindAccountPage.tsx
new file mode 100644
index 00000000..11e9b6af
--- /dev/null
+++ b/frontend/src/pages/auth/find/FindAccountPage.tsx
@@ -0,0 +1,40 @@
+import { useEffect } from 'react'
+import { useSearchParams } from 'react-router'
+
+import { LinkButton } from '@/components/common'
+
+import { AuthCardLayout } from '../components'
+import { FindType, FindUserIdForm, ResetPasswordForm } from './components'
+
+export default function FindAccountPage() {
+  const [searchParams, setSearchParams] = useSearchParams()
+
+  useEffect(() => {
+    if (!searchParams.get('findType')) {
+      setSearchParams({ findType: 'id' }, { replace: true })
+    }
+  }, [searchParams, setSearchParams])
+
+  const handleFindType = (type: 'id' | 'password') => {
+    setSearchParams({ findType: type }, { replace: true })
+  }
+
+  return (
+    <div className="flex w-full flex-col items-center gap-2">
+      <AuthCardLayout title="회원정보 찾기 to HAEDAL">
+        <FindType
+          onChange={handleFindType}
+          findType={searchParams.get('findType') || 'id'}
+        />
+        {searchParams.get('findType') === 'id' ? (
+          <FindUserIdForm />
+        ) : (
+          <ResetPasswordForm />
+        )}
+      </AuthCardLayout>
+      <LinkButton linkTo="/auth/login">
+        <div>로그인하기</div>
+      </LinkButton>
+    </div>
+  )
+}
diff --git a/frontend/src/pages/auth/find/components/form/ErrorMessageBox.tsx b/frontend/src/pages/auth/find/components/form/ErrorMessageBox.tsx
new file mode 100644
index 00000000..c946547c
--- /dev/null
+++ b/frontend/src/pages/auth/find/components/form/ErrorMessageBox.tsx
@@ -0,0 +1,16 @@
+import { CrossCircledIcon } from '@radix-ui/react-icons'
+
+interface FindErrorMessageProps {
+  message: string
+}
+
+export const FindErrorMessage = ({ message }: FindErrorMessageProps) => {
+  if (!message) return null
+
+  return (
+    <div className="flex items-center justify-center gap-2 rounded-lg border border-red-200 bg-red-50 p-4 text-sm font-semibold text-red-600">
+      <CrossCircledIcon />
+      <p>{message}</p>
+    </div>
+  )
+}
diff --git a/frontend/src/pages/auth/find/components/form/FindType.tsx b/frontend/src/pages/auth/find/components/form/FindType.tsx
new file mode 100644
index 00000000..bc7a8cf6
--- /dev/null
+++ b/frontend/src/pages/auth/find/components/form/FindType.tsx
@@ -0,0 +1,45 @@
+import { Input, Label } from '@/components/ui'
+
+interface FindTypeProps {
+  onChange: (value: 'id' | 'password') => void
+  findType: string
+}
+
+export const FindType = ({ onChange, findType }: FindTypeProps) => {
+  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    onChange(e.target.value as 'id' | 'password')
+  }
+
+  return (
+    <div className="mb-4 flex w-full items-center gap-4">
+      <div className="flex gap-0.5">
+        <Input
+          className="size-4"
+          type="radio"
+          id="id"
+          name="findType"
+          value="id"
+          defaultChecked={findType === 'id'}
+          onChange={handleChange}
+        />
+        <Label className="hover:cursor-pointer" htmlFor="id">
+          아이디 찾기
+        </Label>
+      </div>
+      <div className="flex gap-0.5">
+        <Input
+          className="size-4"
+          type="radio"
+          id="password"
+          name="findType"
+          value="password"
+          defaultChecked={findType === 'password'}
+          onChange={handleChange}
+        />
+        <Label className="hover:cursor-pointer" htmlFor="password">
+          비밀번호 찾기
+        </Label>
+      </div>
+    </div>
+  )
+}
diff --git a/frontend/src/pages/auth/find/components/form/FindUserIdForm.tsx b/frontend/src/pages/auth/find/components/form/FindUserIdForm.tsx
new file mode 100644
index 00000000..680ee67e
--- /dev/null
+++ b/frontend/src/pages/auth/find/components/form/FindUserIdForm.tsx
@@ -0,0 +1,84 @@
+import { useState } from 'react'
+import { useForm } from 'react-hook-form'
+
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation } from '@tanstack/react-query'
+import { AxiosError } from 'axios'
+
+import { Button, Input, Label } from '@/components/ui'
+import { API_ERROR_MESSAGES } from '@/constant'
+import { findUserIdApi } from '@/service/api'
+import { FindUserId, FindUserIdSchema } from '@/service/schema'
+
+import { FindErrorMessage } from './ErrorMessageBox'
+import { FindSuccessMessage } from './SuccessMessageBox'
+
+export const FindUserIdForm = () => {
+  const { mutate: find, isPending } = useMutation({
+    mutationFn: findUserIdApi,
+    onSuccess: (data) => {
+      setUserId(data.userId + '')
+    },
+    onError: (error) => {
+      onErrorFind(error)
+    },
+  })
+
+  const form = useForm<FindUserId>({
+    resolver: zodResolver(FindUserIdSchema),
+    mode: 'onSubmit',
+    defaultValues: {
+      studentNumber: '',
+      userName: '',
+    },
+  })
+
+  const [userId, setUserId] = useState('')
+  const [message, setMessage] = useState('')
+
+  const onSubmit = form.handleSubmit(
+    (values) => {
+      find({
+        studentNumber: parseInt(values.studentNumber),
+        name: values.userName,
+      })
+      setMessage('')
+    },
+    (errors) => {
+      const errorMessage =
+        Object.values(errors).flatMap((error) => error.message)[0] || ''
+      setMessage(errorMessage)
+    },
+  )
+
+  const onErrorFind = (error: Error) => {
+    if (error instanceof AxiosError) {
+      const res = error.response
+
+      if (res?.status === 404) {
+        setMessage(res.data.message)
+        return
+      }
+    }
+
+    setMessage(API_ERROR_MESSAGES.UNKNOWN_ERROR)
+  }
+
+  return (
+    <form onSubmit={onSubmit} className="flex w-full flex-col gap-4">
+      <div className="flex flex-col gap-2">
+        <Label htmlFor="studentNumber">학번</Label>
+        <Input {...form.register('studentNumber')} name="studentNumber" />
+      </div>
+      <div className="flex flex-col gap-2">
+        <Label htmlFor="userName">이름</Label>
+        <Input {...form.register('userName')} name="userName" />
+        <Button className="mt-8" disabled={isPending} type="submit">
+          확인
+        </Button>
+      </div>
+      <FindSuccessMessage userId={userId} />
+      <FindErrorMessage message={message} />
+    </form>
+  )
+}
diff --git a/frontend/src/pages/auth/find/components/form/ResetPasswordForm.tsx b/frontend/src/pages/auth/find/components/form/ResetPasswordForm.tsx
new file mode 100644
index 00000000..8b423822
--- /dev/null
+++ b/frontend/src/pages/auth/find/components/form/ResetPasswordForm.tsx
@@ -0,0 +1,94 @@
+import { useState } from 'react'
+import { useForm } from 'react-hook-form'
+
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation } from '@tanstack/react-query'
+import { AxiosError } from 'axios'
+
+import { Button, Input, Label } from '@/components/ui'
+import { API_ERROR_MESSAGES } from '@/constant'
+import { resetPasswordApi } from '@/service/api'
+import { ResetPassword, ResetPasswordSchema } from '@/service/schema'
+
+import { FindErrorMessage } from './ErrorMessageBox'
+import { SendCodeMessage } from './SuccessMessageBox'
+import { VerifyCodeForm } from './VerifyCodeForm'
+
+export const ResetPasswordForm = () => {
+  const { mutate: resetPassword, isPending } = useMutation({
+    mutationFn: resetPasswordApi,
+    onSuccess: () => {
+      setSendCode(true)
+    },
+    onError: (error) => {
+      onErrorReset(error)
+    },
+  })
+
+  const form = useForm<ResetPassword>({
+    resolver: zodResolver(ResetPasswordSchema),
+    mode: 'onSubmit',
+    defaultValues: {
+      userId: '',
+      studentNumber: '',
+    },
+  })
+
+  const [message, setMessage] = useState('')
+  const [sendCode, setSendCode] = useState(false)
+  const [sendUserId, setSendUserId] = useState('')
+
+  const onSubmit = form.handleSubmit(
+    (values) => {
+      resetPassword({
+        userId: values.userId,
+        studentNumber: parseInt(values.studentNumber),
+      })
+      setSendUserId(values.userId)
+      setMessage('')
+    },
+    (errors) => {
+      const errorMessage =
+        Object.values(errors).flatMap((error) => error.message)[0] || ''
+      setMessage(errorMessage)
+    },
+  )
+
+  const onErrorReset = (error: Error) => {
+    if (error instanceof AxiosError) {
+      const res = error.response
+
+      if (res?.status === 201 || res?.status === 404 || res?.status === 429) {
+        setMessage(res.data.message)
+        return
+      }
+    }
+
+    setMessage(API_ERROR_MESSAGES.UNKNOWN_ERROR)
+  }
+
+  return (
+    <>
+      <form onSubmit={onSubmit} className="flex w-full flex-col gap-4">
+        <div className="flex flex-col gap-2">
+          <Label htmlFor="userId">아이디</Label>
+          <Input {...form.register('userId')} name="userId" />
+        </div>
+        <div className="flex flex-col gap-2">
+          <Label htmlFor="studentNumber">학번</Label>
+          <Input {...form.register('studentNumber')} name="studentNumber" />
+          <Button
+            className="mt-8"
+            disabled={isPending || sendCode}
+            type="submit"
+          >
+            인증번호 받기
+          </Button>
+        </div>
+        <FindErrorMessage message={message} />
+        <SendCodeMessage sendCode={sendCode} />
+      </form>
+      {sendCode && <VerifyCodeForm userId={sendUserId} />}
+    </>
+  )
+}
diff --git a/frontend/src/pages/auth/find/components/form/SuccessMessageBox.tsx b/frontend/src/pages/auth/find/components/form/SuccessMessageBox.tsx
new file mode 100644
index 00000000..1500a375
--- /dev/null
+++ b/frontend/src/pages/auth/find/components/form/SuccessMessageBox.tsx
@@ -0,0 +1,40 @@
+import { CheckCircledIcon } from '@radix-ui/react-icons'
+
+interface FindSuccessMessageProps {
+  userId: string
+}
+
+export const FindSuccessMessage = ({ userId }: FindSuccessMessageProps) => {
+  if (!userId) return null
+
+  return (
+    <div className="flex items-center justify-center gap-2 rounded-lg border border-green-200 bg-green-50 p-4 text-sm font-semibold text-green-600">
+      <div className="flex flex-col items-center justify-center gap-2">
+        <div className="flex items-center justify-center gap-2">
+          <CheckCircledIcon />
+          <p>가입된 아이디는 아래와 같습니다.</p>
+        </div>
+        <p>ID: {userId}</p>
+      </div>
+    </div>
+  )
+}
+
+interface SendCodeMessageProps {
+  sendCode: boolean
+}
+
+export const SendCodeMessage = ({ sendCode }: SendCodeMessageProps) => {
+  if (!sendCode) return null
+
+  return (
+    <div className="flex items-center justify-center gap-2 rounded-lg border border-green-200 bg-green-50 p-4 text-sm font-semibold text-green-600">
+      <div className="flex flex-col items-center justify-center gap-2">
+        <div className="flex items-center justify-center gap-2">
+          <CheckCircledIcon />
+          <p>이메일로 인증번호를 발송했습니다.</p>
+        </div>
+      </div>
+    </div>
+  )
+}
diff --git a/frontend/src/pages/auth/find/components/form/VerifyCodeForm.tsx b/frontend/src/pages/auth/find/components/form/VerifyCodeForm.tsx
new file mode 100644
index 00000000..f74da423
--- /dev/null
+++ b/frontend/src/pages/auth/find/components/form/VerifyCodeForm.tsx
@@ -0,0 +1,89 @@
+import { useState } from 'react'
+import { useForm } from 'react-hook-form'
+import { useNavigate } from 'react-router'
+
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation } from '@tanstack/react-query'
+import { AxiosError } from 'axios'
+import { toast } from 'sonner'
+
+import { Button, Input, Label } from '@/components/ui'
+import { API_ERROR_MESSAGES } from '@/constant'
+import { verifyResetPasswordCodeApi } from '@/service/api'
+import { VerifyCode, VerifyCodeSchema } from '@/service/schema'
+
+import { FindErrorMessage } from './ErrorMessageBox'
+
+interface VerifyEmailCodeProps {
+  userId: string
+}
+
+export const VerifyCodeForm = ({ userId }: VerifyEmailCodeProps) => {
+  const { mutate: verifyResetPasswordCode, isPending } = useMutation({
+    mutationFn: verifyResetPasswordCodeApi,
+    onSuccess: () => {
+      toast.success('이메일로 전송된 임시 비밀번호를 사용해주세요. ', {
+        duration: 3000,
+      })
+      navigate('/')
+    },
+    onError: (error) => {
+      onErrorReset(error)
+    },
+  })
+
+  const form = useForm<VerifyCode>({
+    resolver: zodResolver(VerifyCodeSchema),
+    mode: 'onChange',
+    defaultValues: {
+      code: '',
+    },
+  })
+
+  const navigate = useNavigate()
+  const [message, setMessage] = useState('')
+
+  const onErrorReset = (error: Error) => {
+    if (error instanceof AxiosError) {
+      const res = error.response
+      console.log(res)
+
+      if (res?.status === 400 || res?.status === 404) {
+        setMessage(res.data.message)
+        return
+      }
+    }
+
+    setMessage(API_ERROR_MESSAGES.UNKNOWN_ERROR)
+  }
+
+  const onSubmit = form.handleSubmit(
+    (values) => {
+      verifyResetPasswordCode({
+        userId: userId,
+        code: values.code,
+      })
+      setMessage('')
+    },
+    (errors) => {
+      const errorMessage =
+        Object.values(errors).flatMap((error) => error.message)[0] || ''
+      setMessage(errorMessage)
+    },
+  )
+
+  return (
+    <form onSubmit={onSubmit} className="flex w-full flex-col gap-4">
+      <div className="mt-4 flex flex-col gap-2">
+        <Label htmlFor="code">인증번호</Label>
+        <div className="flex gap-2">
+          <Input {...form.register('code')} name="code" />
+          <Button type="submit" disabled={isPending}>
+            인증번호 확인
+          </Button>
+        </div>
+      </div>
+      <FindErrorMessage message={message} />
+    </form>
+  )
+}
diff --git a/frontend/src/pages/auth/find/components/form/index.ts b/frontend/src/pages/auth/find/components/form/index.ts
new file mode 100644
index 00000000..d4a84e45
--- /dev/null
+++ b/frontend/src/pages/auth/find/components/form/index.ts
@@ -0,0 +1,3 @@
+export { FindUserIdForm } from './FindUserIdForm'
+export { ResetPasswordForm } from './ResetPasswordForm'
+export { FindType } from './FindType'
diff --git a/frontend/src/pages/auth/find/components/index.ts b/frontend/src/pages/auth/find/components/index.ts
new file mode 100644
index 00000000..54151771
--- /dev/null
+++ b/frontend/src/pages/auth/find/components/index.ts
@@ -0,0 +1 @@
+export * from './form'
diff --git a/frontend/src/pages/auth/find/index.ts b/frontend/src/pages/auth/find/index.ts
new file mode 100644
index 00000000..31a1d198
--- /dev/null
+++ b/frontend/src/pages/auth/find/index.ts
@@ -0,0 +1 @@
+export { default as FindAccountPage } from './FindAccountPage'
diff --git a/frontend/src/pages/auth/index.ts b/frontend/src/pages/auth/index.ts
index 081fb6b0..2e0af5fa 100644
--- a/frontend/src/pages/auth/index.ts
+++ b/frontend/src/pages/auth/index.ts
@@ -1,2 +1,3 @@
 export * from './login'
 export * from './singup'
+export * from './find'
diff --git a/frontend/src/pages/auth/login/LoginPage.tsx b/frontend/src/pages/auth/login/LoginPage.tsx
index 4d99099b..92aec112 100644
--- a/frontend/src/pages/auth/login/LoginPage.tsx
+++ b/frontend/src/pages/auth/login/LoginPage.tsx
@@ -9,9 +9,14 @@ export default function LoginPage() {
       <AuthCardLayout title="Sign in to HAEDAL">
         <LoginForm />
       </AuthCardLayout>
-      <LinkButton linkTo="/auth/signup">
-        <div>회원가입</div>
-      </LinkButton>
+      <div className="flex w-full items-center justify-center gap-28">
+        <LinkButton linkTo="/auth/signup">
+          <div>회원가입</div>
+        </LinkButton>
+        <LinkButton linkTo="/auth/find">
+          <div>회원정보 찾기</div>
+        </LinkButton>
+      </div>
     </div>
   )
 }
diff --git a/frontend/src/routes/Router.tsx b/frontend/src/routes/Router.tsx
index 195767ac..fd1b6819 100644
--- a/frontend/src/routes/Router.tsx
+++ b/frontend/src/routes/Router.tsx
@@ -14,6 +14,7 @@ import {
   EditActivityPostPage,
   EditBoardPage,
   EditNoticePostPage,
+  FindAccountPage,
   LoginPage,
   MainPage,
   MakerPage,
@@ -47,6 +48,7 @@ export const Router = () => {
         <Route path="/auth" element={<AuthRoute />}>
           <Route path="login" element={<LoginPage />} />
           <Route path="signup" element={<SignupPage />} />
+          <Route path="find" element={<FindAccountPage />} />
         </Route>
 
         <Route path="/admin" element={<AdminRoute />}>
diff --git a/frontend/src/service/api/auth/find.ts b/frontend/src/service/api/auth/find.ts
new file mode 100644
index 00000000..f152927a
--- /dev/null
+++ b/frontend/src/service/api/auth/find.ts
@@ -0,0 +1,43 @@
+import { BACKEND_API } from '@/service/config'
+import {
+  GetUserIdRequest,
+  ResetPasswordRequest,
+  Users,
+  VerifyResetPasswordCodeRequest,
+} from '@/service/model'
+
+export const findUserIdApi = async ({
+  studentNumber,
+  name,
+}: GetUserIdRequest) => {
+  const findClient = new Users(BACKEND_API)
+  const response = await findClient.getUserId({ studentNumber, name })
+
+  return response.data
+}
+
+export const resetPasswordApi = async ({
+  userId,
+  studentNumber,
+}: ResetPasswordRequest) => {
+  const resetPasswordClient = new Users(BACKEND_API)
+  const response = await resetPasswordClient.resetPassword({
+    userId,
+    studentNumber,
+  })
+
+  return response.data
+}
+
+export const verifyResetPasswordCodeApi = async ({
+  userId,
+  code,
+}: VerifyResetPasswordCodeRequest) => {
+  const verifyResetPasswordCodeClient = new Users(BACKEND_API)
+  const response = await verifyResetPasswordCodeClient.verifyResetPasswordCode({
+    userId,
+    code,
+  })
+
+  return response.data
+}
diff --git a/frontend/src/service/api/auth/index.ts b/frontend/src/service/api/auth/index.ts
index 7f26db6d..69ec2598 100644
--- a/frontend/src/service/api/auth/index.ts
+++ b/frontend/src/service/api/auth/index.ts
@@ -1,3 +1,4 @@
 export * from './reissue'
 export * from './login'
 export * from './signup'
+export * from './find'
diff --git a/frontend/src/service/model/Users.ts b/frontend/src/service/model/Users.ts
index e5685edc..dc71c12a 100644
--- a/frontend/src/service/model/Users.ts
+++ b/frontend/src/service/model/Users.ts
@@ -15,12 +15,17 @@ import {
   GetProfileData,
   GetProfilesData,
   GetUserData,
+  GetUserIdData,
   GetUsersData,
   ProfileRequestDto,
+  ResetPasswordData,
+  ResetPasswordEmailCodeRequestDto,
+  ResetPasswordRequestDto,
   UpdateProfileData,
   UpdateProfileImage1Data,
   UpdateProfileImageData,
   UpdateProfileImagePayload,
+  VerifyResetPasswordCodeData,
 } from './data-contracts'
 
 export class Users<
@@ -204,4 +209,77 @@ export class Users<
       secure: true,
       ...params,
     })
+  /**
+   * No description
+   *
+   * @tags 유저 API
+   * @name GetUserId
+   * @summary 아이디 찾기
+   * @request GET:/users/find-id
+   * @secure
+   * @response `200` `GetUserIdData` OK
+   * @response `404` `void`
+   */
+  getUserId = (
+    query: {
+      /** @format int32 */
+      studentNumber: number
+      name: string
+    },
+    params: RequestParams = {},
+  ) =>
+    this.request<GetUserIdData, void>({
+      path: `/users/find-id`,
+      method: 'GET',
+      query: query,
+      secure: true,
+      ...params,
+    })
+  /**
+   * No description
+   *
+   * @tags 유저 API
+   * @name ResetPassword
+   * @summary 비밀번호 찾기 (인증코드 전송)
+   * @request POST:/users/password/reset
+   * @secure
+   * @response `200` `ResetPasswordData` OK
+   * @response `201` `void`
+   * @response `404` `void`
+   * @response `429` `void`
+   */
+  resetPassword = (data: ResetPasswordRequestDto, params: RequestParams = {}) =>
+    this.request<ResetPasswordData, void>({
+      path: `/users/password/reset`,
+      method: 'POST',
+      body: data,
+      secure: true,
+      type: ContentType.Json,
+      ...params,
+    })
+  /**
+   * No description
+   *
+   * @tags 유저 API
+   * @name VerifyResetPasswordCode
+   * @summary 비밀번호 찾기 (인증코드 확인 및 랜덤 비밀번호 전송)
+   * @request PATCH:/users/password/verify
+   * @secure
+   * @response `200` `VerifyResetPasswordCodeData`
+   * @response `400` `void`
+   * @response `404` `void`
+   */
+  verifyResetPasswordCode = (
+    data: ResetPasswordEmailCodeRequestDto,
+    params: RequestParams = {},
+  ) =>
+    this.request<VerifyResetPasswordCodeData, void>({
+      path: `/users/password/verify`,
+      method: 'PATCH',
+      body: data,
+      secure: true,
+      type: ContentType.Json,
+      format: 'json',
+      ...params,
+    })
 }
diff --git a/frontend/src/service/model/data-contracts.ts b/frontend/src/service/model/data-contracts.ts
index f8c2cac8..2f41c5b7 100644
--- a/frontend/src/service/model/data-contracts.ts
+++ b/frontend/src/service/model/data-contracts.ts
@@ -124,6 +124,25 @@ export interface BoardRequestDto {
   participants: string[]
 }
 
+export interface ResetPasswordRequestDto {
+  /**
+   * 유저 아이디
+   * @minLength 6
+   * @maxLength 12
+   * @pattern ^[A-Za-z0-9]+$
+   * @example "haedal12"
+   */
+  userId?: string
+  /**
+   * 유저 학번
+   * @format int32
+   * @min 1900000000
+   * @max 2100000000
+   * @example 2024111234
+   */
+  studentNumber?: number
+}
+
 export interface PostImageResponseDto {
   /**
    * 게시글 이미지 id
@@ -240,6 +259,24 @@ export interface ActivityRequestDto {
   activityName: string
 }
 
+export interface ResetPasswordEmailCodeRequestDto {
+  /**
+   * 유저 아이디
+   * @minLength 6
+   * @maxLength 12
+   * @pattern ^[A-Za-z0-9]+$
+   * @example "haedal12"
+   */
+  userId?: string
+  /**
+   * 인증 코드
+   * @minLength 6
+   * @maxLength 6
+   * @example "ABcD12"
+   */
+  code: string
+}
+
 export interface UpdateRoleRequestDto {
   /**
    * 유저 권한
@@ -370,6 +407,14 @@ export interface ActivityResponseDto {
   semesterId: number
 }
 
+export interface FindUserIdResponseDto {
+  /**
+   * 유저 아이디
+   * @example "haedal12"
+   */
+  userId?: string
+}
+
 export interface CommentResponseDto {
   /**
    * 댓글 id
@@ -832,3 +877,9 @@ export type RejectUserData = any
 export type RemoveSemesterData = any
 
 export type RemoveActivityData = any
+
+export type GetUserIdData = FindUserIdResponseDto
+
+export type ResetPasswordData = SuccessResponse
+
+export type VerifyResetPasswordCodeData = any
diff --git a/frontend/src/service/model/request.d.ts b/frontend/src/service/model/request.d.ts
index 612fe032..f4385139 100644
--- a/frontend/src/service/model/request.d.ts
+++ b/frontend/src/service/model/request.d.ts
@@ -234,3 +234,18 @@ export type UpdateCommentRequest = {
   commentId: number
   data: CommentRequestDto
 }
+
+export type GetUserIdRequest = {
+  studentNumber: number
+  name: string
+}
+
+export type ResetPasswordRequest = {
+  userId: string
+  studentNumber: number
+}
+
+export type VerifyResetPasswordCodeRequest = {
+  userId: string
+  code: string
+}
diff --git a/frontend/src/service/schema/auth.ts b/frontend/src/service/schema/auth.ts
index c6edb541..8ca573a5 100644
--- a/frontend/src/service/schema/auth.ts
+++ b/frontend/src/service/schema/auth.ts
@@ -40,5 +40,34 @@ export const SignupSchema = z.object({
     }),
   }),
 })
-
 export type Signup = z.infer<typeof SignupSchema>
+
+export const FindUserIdSchema = z.object({
+  studentNumber: z.string().length(10, {
+    message: '학번은 10자여야 합니다.',
+  }),
+  userName: z
+    .string()
+    .min(2, { message: '이름은 2자 이상여야 합니다.' })
+    .max(5, { message: '이름은 5자 이하여야 합니다.' }),
+})
+export type FindUserId = z.infer<typeof FindUserIdSchema>
+
+export const ResetPasswordSchema = z.object({
+  userId: z
+    .string()
+    .min(6, { message: 'ID는 6자 이상여야 합니다.' })
+    .max(12, { message: 'ID는 12자 이하여야 합니다.' })
+    .regex(/^[A-Za-z0-9]+$/, {
+      message: 'ID는 영어와 숫자만 입력할 수 있습니다.',
+    }),
+  studentNumber: z.string().length(10, {
+    message: '학번은 10자여야 합니다.',
+  }),
+})
+export type ResetPassword = z.infer<typeof ResetPasswordSchema>
+
+export const VerifyCodeSchema = z.object({
+  code: z.string().length(6, { message: '코드 형식이 올바르지 않습니다.' }),
+})
+export type VerifyCode = z.infer<typeof VerifyCodeSchema>


Reviews:
user: login: Dobbymin
body: 고생하셨습니다! 전체적인 코드를 다 살펴보고, 코드를 작성하셨다는 점이 느껴지네요!
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-27T00:51:11Z
user: login: ppochaco
body: 첫 contribute 감사합니다🥹 컴포넌트 설계나 코드 관련해서는 수정이 필요 없을 정도로 잘 작성해주셨습니다! 다만, 강민님께서 언급하셨듯이 컴포넌트 네이밍이 조금 더 구체적이면 좋을 것 같아요. API 부분도 가이드 문서가 없으면 코드 읽기가 어려웠을 텐데, 너무 잘 해주셔서 깜짝 놀랐습니다ㅎㅎ.. 정말 고생 많으셨습니다! 아래의 사항만 수정 부탁드립니당



- 페이지 컴포넌트, schema, api 네이밍을 구체적으로 변경해주세요.
  - 예시: FindPage -> FindAcountPage, findApi -> findUserIdApi 등
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T04:36:46Z
user: login: ppochaco
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T04:47:17Z
user: login: ppochaco
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T04:53:14Z
user: login: jijun0129
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-27T07:51:41Z
user: login: ppochaco
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T07:55:54Z
user: login: ppochaco
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T08:01:31Z
user: login: jijun0129
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-27T08:27:50Z

Review comments:
diff_hunk: @@ -0,0 +1,84 @@
+import { useState } from 'react'
+import { useForm } from 'react-hook-form'
+
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation } from '@tanstack/react-query'
+import { AxiosError } from 'axios'
+
+import { Button, Input, Label } from '@/components/ui'
+import { API_ERROR_MESSAGES } from '@/constant'
+import { findApi } from '@/service/api'
+import { Find, FindSchema } from '@/service/schema'
+
+import { FindErrorMessage } from './ErrorMessageBox'
+import { FindSuccessMessage } from './SuccessMessageBox'
+
+export const FindForm = () => {
path: frontend/src/pages/auth/find/components/form/FindForm.tsx
user: login: Dobbymin
body: FindForm이라고 컴포넌트 이름을 명명한다면 어떤 것을 찾는 폼인지 잘 모를것 같아요. 저는 FindMemeberInfoForm 이나 FindInfoForm 과 같이 이름이 조금 길어지더라도 직관적인 이름을 사용하는 편입니다.
created_at: 2025-03-27T00:45:05Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,94 @@
+import { useState } from 'react'
+import { FormProvider, useForm } from 'react-hook-form'
+
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation } from '@tanstack/react-query'
+import { AxiosError } from 'axios'
+
+import { Button, Input, Label } from '@/components/ui'
+import { API_ERROR_MESSAGES } from '@/constant'
+import { resetPasswordApi } from '@/service/api'
+import { Reset, ResetSchema } from '@/service/schema'
+
+import { FindErrorMessage } from './ErrorMessageBox'
+import { SendCodeMessage } from './SuccessMessageBox'
+import { VerifyCodeForm } from './VerifyCodeForm'
+
+export const ResetPasswordForm = () => {
+  const { mutate: resetPassword, isPending } = useMutation({
+    mutationFn: resetPasswordApi,
+    onSuccess: () => {
+      setSendCode(true)
+    },
+    onError: (error) => {
+      onErrorReset(error)
+    },
+  })
+
+  const form = useForm<Reset>({
+    resolver: zodResolver(ResetSchema),
+    mode: 'onSubmit',
+    defaultValues: {
+      userId: '',
+      studentNumber: '',
+    },
+  })
+
+  const [message, setMessage] = useState('')
+  const [sendCode, setSendCode] = useState(false)
+  const [sendUserId, setSendUserId] = useState('')
+
+  const onSubmit = form.handleSubmit(
+    (values) => {
+      resetPassword({
+        userId: values.userId,
+        studentNumber: parseInt(values.studentNumber),
+      })
+      setSendUserId(values.userId)
+      setMessage('')
+    },
+    (errors) => {
+      const errorMessage =
+        Object.values(errors).flatMap((error) => error.message)[0] || ''
+      setMessage(errorMessage)
+    },
+  )
+
+  const onErrorReset = (error: Error) => {
+    if (error instanceof AxiosError) {
+      const res = error.response
+
+      if (res?.status === 201 || res?.status === 404 || res?.status === 429) {
+        setMessage(res.data.message)
+        return
+      }
+    }
+
+    setMessage(API_ERROR_MESSAGES.UNKNOWN_ERROR)
+  }
+
+  return (
+    <FormProvider {...form}>
+      <form onSubmit={onSubmit} className="flex w-full flex-col gap-4">
+        <div className="flex flex-col gap-2">
+          <Label htmlFor="userId">아이디</Label>
+          <Input {...form.register('userId')} name="userId" />
+        </div>
+        <div className="flex flex-col gap-2">
+          <Label htmlFor="studentNumber">학번</Label>
+          <Input {...form.register('studentNumber')} name="studentNumber" />
+          <Button
+            className="mt-8"
+            disabled={isPending || sendCode}
+            type="submit"
+          >
+            인증번호 받기
+          </Button>
+        </div>
+        <FindErrorMessage message={message} />
+        <SendCodeMessage sendCode={sendCode} />
+      </form>
+      {sendCode && <VerifyCodeForm userId={sendUserId} />}
+    </FormProvider>
path: frontend/src/pages/auth/find/components/form/ResetPasswordForm.tsx
user: login: Dobbymin
body: Form이 아니라 FormProvider를 사용하신 이유가 궁금합니다.
created_at: 2025-03-27T00:47:33Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,39 @@
+import { CheckCircledIcon } from '@radix-ui/react-icons'
+
+interface FindSuccessMessageProps {
+  userId: string
+}
+interface SendCodeMessageProps {
+  sendCode: boolean
+}
path: frontend/src/pages/auth/find/components/form/SuccessMessageBox.tsx
user: login: Dobbymin
body: 타입 관련된 부분은 사용하는 컴포넌트 위에다 두는게 더 직관적일것 같아요!
created_at: 2025-03-27T00:48:10Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,40 @@
+import { useEffect } from 'react'
+import { useSearchParams } from 'react-router'
+
+import { LinkButton } from '@/components/common'
+
+import { AuthCardLayout } from '../components'
+import { FindForm, FindType, ResetPasswordForm } from './components'
+
+export default function FindPage() {
+  const [searchParams, setSearchParams] = useSearchParams()
+
+  useEffect(() => {
+    if (!searchParams.get('findType')) {
+      setSearchParams({ findType: 'id' }, { replace: true })
+    }
+  }, [searchParams, setSearchParams])
+
+  const handleFindType = (type: 'id' | 'password') => {
+    setSearchParams({ findType: type }, { replace: true })
+  }
path: frontend/src/pages/auth/find/FindPage.tsx
user: login: Dobbymin
body: 'handle'이라는 prefix에 대해 제가 작년에 멘토님께서 피드백 해주셨는데, 한번 고려해보면 좋을 것 같아요.

---

모든 팀에 대한 공통 피드백입니다.

```tsx
const handlePrevious = () => {
  if (currentPage > 1) onPageChange(currentPage - 1);
};const handleNext = () => {
  if (currentPage < totalPages) onPageChange(currentPage + 1);
};const handlePageClick = (page: number) => onPageChange(page);

```

모든 사람들이.. 다 handle prefix를 아무렇지도 않게 사용하고 있어요.그런데 이런 이름 때문에 함수의 사용이 제한된답니다 ㅎㅎ잘 보면 이벤트를 처리하는 코드는 하나도 없어요. 그냥 함수를 실행할 뿐입니다.그런데 왜 handle이 붙어야 할까요? 이렇게 했을 때의 이득이 있을까요?

- 이벤트를 받아서 처리하지도 않고
- 이름대로 동작하지도 않고 ( 가령, handlePrevious라는 이름은 previous 라는 행위가 발생했을 때 이를 처리해야 함. 그런데 지금은 previous 라는 행위 자체를 실행하는 중)

이럴 때는 그냥 previous, next, goToPage 이런식으로 표현해야 자연스러워요.Step3 기간 내내 강조했는데 반영되는 팀이 아예 없군요... ㅎㅎ여러분이 페이지네이션에 대한 라이브러리를 만들어서 제공한다고 생각해보세요.이름을 이렇게 지으면 문의가 미친듯이 쏟아진답니다.. ㅠㅠ

created_at: 2025-03-27T00:50:30Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,40 @@
+import { useEffect } from 'react'
+import { useSearchParams } from 'react-router'
+
+import { LinkButton } from '@/components/common'
+
+import { AuthCardLayout } from '../components'
+import { FindForm, FindType, ResetPasswordForm } from './components'
+
+export default function FindPage() {
+  const [searchParams, setSearchParams] = useSearchParams()
+
+  useEffect(() => {
+    if (!searchParams.get('findType')) {
+      setSearchParams({ findType: 'id' }, { replace: true })
+    }
+  }, [searchParams, setSearchParams])
+
+  const handleFindType = (type: 'id' | 'password') => {
+    setSearchParams({ findType: type }, { replace: true })
+  }
path: frontend/src/pages/auth/find/FindPage.tsx
user: login: ppochaco
body: 음... 제 생각에는 handleFindType을 수정할 필요가 없어보여요!

언급하신 부분은 handle 키워드가 일반 함수에 무분별하게 사용되어 문제가 된다는 의견인 것 같아요. 
하지만, 여기에서는 `onChange={handleFindType}`와 같이 실제로 이벤트 핸들러를 처리하고 있기 때문에,
handle prefix를 사용하는 것이 어색하지 않다고 생각합니당~
created_at: 2025-03-27T04:47:17Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,94 @@
+import { useState } from 'react'
+import { FormProvider, useForm } from 'react-hook-form'
+
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation } from '@tanstack/react-query'
+import { AxiosError } from 'axios'
+
+import { Button, Input, Label } from '@/components/ui'
+import { API_ERROR_MESSAGES } from '@/constant'
+import { resetPasswordApi } from '@/service/api'
+import { Reset, ResetSchema } from '@/service/schema'
+
+import { FindErrorMessage } from './ErrorMessageBox'
+import { SendCodeMessage } from './SuccessMessageBox'
+import { VerifyCodeForm } from './VerifyCodeForm'
+
+export const ResetPasswordForm = () => {
+  const { mutate: resetPassword, isPending } = useMutation({
+    mutationFn: resetPasswordApi,
+    onSuccess: () => {
+      setSendCode(true)
+    },
+    onError: (error) => {
+      onErrorReset(error)
+    },
+  })
+
+  const form = useForm<Reset>({
+    resolver: zodResolver(ResetSchema),
+    mode: 'onSubmit',
+    defaultValues: {
+      userId: '',
+      studentNumber: '',
+    },
+  })
+
+  const [message, setMessage] = useState('')
+  const [sendCode, setSendCode] = useState(false)
+  const [sendUserId, setSendUserId] = useState('')
+
+  const onSubmit = form.handleSubmit(
+    (values) => {
+      resetPassword({
+        userId: values.userId,
+        studentNumber: parseInt(values.studentNumber),
+      })
+      setSendUserId(values.userId)
+      setMessage('')
+    },
+    (errors) => {
+      const errorMessage =
+        Object.values(errors).flatMap((error) => error.message)[0] || ''
+      setMessage(errorMessage)
+    },
+  )
+
+  const onErrorReset = (error: Error) => {
+    if (error instanceof AxiosError) {
+      const res = error.response
+
+      if (res?.status === 201 || res?.status === 404 || res?.status === 429) {
+        setMessage(res.data.message)
+        return
+      }
+    }
+
+    setMessage(API_ERROR_MESSAGES.UNKNOWN_ERROR)
+  }
+
+  return (
+    <FormProvider {...form}>
+      <form onSubmit={onSubmit} className="flex w-full flex-col gap-4">
+        <div className="flex flex-col gap-2">
+          <Label htmlFor="userId">아이디</Label>
+          <Input {...form.register('userId')} name="userId" />
+        </div>
+        <div className="flex flex-col gap-2">
+          <Label htmlFor="studentNumber">학번</Label>
+          <Input {...form.register('studentNumber')} name="studentNumber" />
+          <Button
+            className="mt-8"
+            disabled={isPending || sendCode}
+            type="submit"
+          >
+            인증번호 받기
+          </Button>
+        </div>
+        <FindErrorMessage message={message} />
+        <SendCodeMessage sendCode={sendCode} />
+      </form>
+      {sendCode && <VerifyCodeForm userId={sendUserId} />}
+    </FormProvider>
path: frontend/src/pages/auth/find/components/form/ResetPasswordForm.tsx
user: login: ppochaco
body: shadcn의 Form 컴포넌트는 controlled 방식이고,
해당 부분은 uncontrolled 방식으로 form을 관리하고 있어서
react-hook-form의 FormProvider를 사용하신 것 같아요~

created_at: 2025-03-27T04:53:14Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,94 @@
+import { useState } from 'react'
+import { FormProvider, useForm } from 'react-hook-form'
+
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation } from '@tanstack/react-query'
+import { AxiosError } from 'axios'
+
+import { Button, Input, Label } from '@/components/ui'
+import { API_ERROR_MESSAGES } from '@/constant'
+import { resetPasswordApi } from '@/service/api'
+import { Reset, ResetSchema } from '@/service/schema'
+
+import { FindErrorMessage } from './ErrorMessageBox'
+import { SendCodeMessage } from './SuccessMessageBox'
+import { VerifyCodeForm } from './VerifyCodeForm'
+
+export const ResetPasswordForm = () => {
+  const { mutate: resetPassword, isPending } = useMutation({
+    mutationFn: resetPasswordApi,
+    onSuccess: () => {
+      setSendCode(true)
+    },
+    onError: (error) => {
+      onErrorReset(error)
+    },
+  })
+
+  const form = useForm<Reset>({
+    resolver: zodResolver(ResetSchema),
+    mode: 'onSubmit',
+    defaultValues: {
+      userId: '',
+      studentNumber: '',
+    },
+  })
+
+  const [message, setMessage] = useState('')
+  const [sendCode, setSendCode] = useState(false)
+  const [sendUserId, setSendUserId] = useState('')
+
+  const onSubmit = form.handleSubmit(
+    (values) => {
+      resetPassword({
+        userId: values.userId,
+        studentNumber: parseInt(values.studentNumber),
+      })
+      setSendUserId(values.userId)
+      setMessage('')
+    },
+    (errors) => {
+      const errorMessage =
+        Object.values(errors).flatMap((error) => error.message)[0] || ''
+      setMessage(errorMessage)
+    },
+  )
+
+  const onErrorReset = (error: Error) => {
+    if (error instanceof AxiosError) {
+      const res = error.response
+
+      if (res?.status === 201 || res?.status === 404 || res?.status === 429) {
+        setMessage(res.data.message)
+        return
+      }
+    }
+
+    setMessage(API_ERROR_MESSAGES.UNKNOWN_ERROR)
+  }
+
+  return (
+    <FormProvider {...form}>
+      <form onSubmit={onSubmit} className="flex w-full flex-col gap-4">
+        <div className="flex flex-col gap-2">
+          <Label htmlFor="userId">아이디</Label>
+          <Input {...form.register('userId')} name="userId" />
+        </div>
+        <div className="flex flex-col gap-2">
+          <Label htmlFor="studentNumber">학번</Label>
+          <Input {...form.register('studentNumber')} name="studentNumber" />
+          <Button
+            className="mt-8"
+            disabled={isPending || sendCode}
+            type="submit"
+          >
+            인증번호 받기
+          </Button>
+        </div>
+        <FindErrorMessage message={message} />
+        <SendCodeMessage sendCode={sendCode} />
+      </form>
+      {sendCode && <VerifyCodeForm userId={sendUserId} />}
+    </FormProvider>
path: frontend/src/pages/auth/find/components/form/ResetPasswordForm.tsx
user: login: jijun0129
body: 처음에 비밀번호 찾기 form을 하나로 관리하려고 FormProvider를 사용하였는데 이메일 보내는 것과 인증번호 입력하는 form을 분리하는 것이 맞는 것 같아 수정하였는데 여기 부분을 수정하지 않아 남아있었던 것 같아요
사용하지 않아 제거했습니다. 
created_at: 2025-03-27T07:51:41Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,40 @@
+import { useEffect } from 'react'
+import { useSearchParams } from 'react-router'
+
+import { LinkButton } from '@/components/common'
+
+import { AuthCardLayout } from '../components'
+import { FindForm, FindType, ResetPasswordForm } from './components'
+
+export default function FindPage() {
+  const [searchParams, setSearchParams] = useSearchParams()
+
+  useEffect(() => {
+    if (!searchParams.get('findType')) {
+      setSearchParams({ findType: 'id' }, { replace: true })
+    }
+  }, [searchParams, setSearchParams])
+
+  const handleFindType = (type: 'id' | 'password') => {
+    setSearchParams({ findType: type }, { replace: true })
+  }
path: frontend/src/pages/auth/find/FindPage.tsx
user: login: ppochaco
body: 방금 코드를 다시 보니, 해당 부분은 change 이벤트를 직접 받아서 처리하는 함수가 아니라, 컴포넌트 props로 전달된 onChange 함수네요! 
강민님의 의견처럼 findType같이 동작을 명확하게 드러내는 네이밍이 더 적절할 것 같아용

이 부분은 프로젝트 전반적으로 수정이 필요할 것 같아서, 이번 PR에서는 추가 수정 없이 그대로 두고, 
이후 별도로 이슈를 만들어서 정리해보겠습니다~
created_at: 2025-03-27T07:55:54Z
author_association: MEMBER
path: frontend/src/service/schema/auth.ts
user: login: ppochaco
body: 여기도 userId, password 붙여주세요~
created_at: 2025-03-27T08:00:05Z
author_association: MEMBER
path: frontend/src/service/schema/auth.ts
user: login: jijun0129
body: 스키마 부분 수정하였습니다. 
created_at: 2025-03-27T08:27:49Z
author_association: COLLABORATOR

PR comments:
user: login: jijun0129
created_at: 2025-03-27T07:53:10Z
body: 컴포넌트, api 역할에 맞게 네이밍 수정하였고 FormProvider 사용하지 않아 제거했습니다. 
author_association: COLLABORATOR
user: login: ppochaco
created_at: 2025-03-27T08:29:43Z
body: LGTM!! 추가 수정 없으면 머지하겠습니다~
author_association: MEMBER