Repository: Techeer-Hogwarts/frontend
Pull request #78: refact : 회원가입 페이지 리팩토링
Author: login: HSCHEOL
Labels: name: 🚀 REFACTOR
description: 입출력 값은 일치, 코드 내부 성능 개선 및 클린업
Created: 2025-05-04T11:27:13Z | Merged: 2025-05-18T15:41:21Z

## 작업 내용

- 비즈니스 로직과 뷰 로직 분리
- 코드 가독성 향상을 위해 상수 따로 관리, 반복되는 코드 컴포넌트로 관리
- any 타입 제거


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
    - 회원가입 폼에 블로그 URL 입력을 위한 재사용 가능한 컴포넌트가 추가되었습니다.
    - 학교, 학년, 기수, 포지션 선택지가 표준화된 목록으로 제공됩니다.
    - 회원가입 단계별 유효성 검사 및 상태 관리가 개선되었습니다.
    - 인턴십 및 정규직 경력 입력 기능이 추가되었습니다.

- **Refactor**
    - 회원가입 페이지가 프레젠테이널 컴포넌트로 단순화되고, 모든 내부 로직이 커스텀 훅으로 분리되었습니다.
    - 반복적인 입력 블록이 재사용 컴포넌트로 통합되었습니다.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx
index 9d1ba3d4..367ff21e 100644
--- a/src/app/signup/page.tsx
+++ b/src/app/signup/page.tsx
@@ -1,7 +1,5 @@
 'use client'
-
 import React, { useState, useRef, useEffect } from 'react'
-import { useRouter } from 'next/navigation'
 import Select from '@/components/signup/Select'
 import PositionSelect from '@/components/signup/PositionSelect'
 import ExperienceSection from '@/components/signup/ExperienceSection'
@@ -11,364 +9,32 @@ import EmailVerification from '@/components/common/EmailVerification'
 import Link from 'next/link'
 import Lottie from 'lottie-react'
 import loading from '../../../public/loading.json'
+import { useSignupHandler } from '@/hooks/auth/useSignupHandler'
+import {
+  UNIVERSITIES,
+  GRADE,
+  GENERATION,
+  POSITION,
+} from '@/constants/signupInfo'
+import BlogComponent from '@/components/signup/BlogComponent'
 
 const Signup = () => {
-  const [signupError, setSignupError] = useState<string>('')
-  const router = useRouter()
-
-  const [step, setStep] = useState(1)
-  const [isLoading, setIsLoading] = useState(false)
-  const [formData, setFormData] = useState({
-    name: '',
-    email: '',
-    password: '',
-    confirmPassword: '',
-    isVerified: false,
-
-    school: '',
-    customSchool: '', // “해당 없음” 선택 시, 직접 입력한 학교명
-    classYear: '',
-    selectedBatch: '',
-    resumeTitle: '',
-    githubUrl: '',
-    tistoryUrl: '',
-    mediumUrl: '',
-    velogUrl: '',
-    selectedPositions: [] as string[],
-    recommendation: null as string | null,
-    employmentStatus: null as string | null,
-    // CareerToggle에서 "있어요" 버튼을 누르면 'yes'로 저장됩니다.
-    internshipExperience: null as string | null,
-    jobExperience: null as string | null,
-
-    // 경험 정보: 기존의 키 대신 unified한 키로 관리
-    internships: [] as {
-      companyName: string
-      position: string
-      startDate: string
-      endDate: string
-      isCurrentJob: boolean
-    }[],
-    fullTimes: [] as {
-      companyName: string
-      position: string
-      startDate: string
-      endDate: string
-      isCurrentJob: boolean
-    }[],
-
-    // 이력서 추가 정보
-    resumeFile: null as File | null,
-    resumeCategory: 'RESUME',
-    resumeIsMain: true,
-    resumePosition: '',
-  })
-
-  const handleChange = (
-    e:
-      | React.ChangeEvent<HTMLInputElement>
-      | React.ChangeEvent<HTMLSelectElement>,
-  ) => {
-    const { name, value } = e.target
-    setFormData((prev) => ({
-      ...prev,
-      [name]: value,
-    }))
-  }
-
-  const passwordsMatch =
-    formData.password === formData.confirmPassword &&
-    formData.password.length > 0
-
-  const handleNext = () => {
-    setSignupError('')
-    // 스텝 1 필드 검증: 각 필드가 비어있거나, isVerified가 false면 alert 후 진행하지 않음
-    if (!formData.name.trim()) {
-      alert('이름을 입력해주세요.')
-      return
-    }
-    if (!formData.email.trim()) {
-      alert('이메일을 입력해주세요.')
-      return
-    }
-    if (!formData.password) {
-      alert('비밀번호를 입력해주세요.')
-      return
-    }
-    const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&]).{8,}$/
-    if (!passwordRegex.test(formData.password)) {
-      alert(
-        '비밀번호 양식에 맞지 않습니다.\n 영어, 숫자, 특수문자를 각각 최소 한 개 이상 포함해주세요.\n 비밀번호는 8자 이상이어야 합니다.',
-      )
-      return
-    }
-    if (!formData.confirmPassword) {
-      alert('비밀번호 확인을 입력해주세요.')
-      return
-    }
-    if (!formData.isVerified) {
-      alert('이메일 인증을 완료해주세요.')
-      return
-    }
-    if (!passwordsMatch) {
-      alert('비밀번호가 일치하지 않습니다.')
-      return
-    }
-    // 모든 필드가 유효하면 스텝 진행
-    if (step < 2) {
-      setStep(step + 1)
-    }
-  }
-
-  const handleBack = () => {
-    setSignupError('')
-    if (step > 1) {
-      setStep(step - 1)
-    }
-  }
-
-  const handleSignup = async () => {
-    setSignupError('')
-
-    if (!formData.school.trim()) {
-      alert('대학교를 선택해주세요.')
-      return
-    }
-    if (formData.school === '해당 없음' && !formData.customSchool.trim()) {
-      alert('학교명을 직접 입력해주세요.')
-      return
-    }
-    if (!formData.classYear.trim()) {
-      alert('학년을 선택해주세요.')
-      return
-    }
-    if (!formData.selectedBatch.trim()) {
-      alert('기수를 선택해주세요.')
-      return
-    }
-    if (!formData.resumeTitle.trim()) {
-      alert('이력서 제목을 입력해주세요.')
-      return
-    }
-    if (!formData.resumeFile) {
-      alert('이력서 파일을 업로드해주세요.')
-      return
-    }
-    if (!formData.resumeCategory.trim()) {
-      alert('이력서 카테고리를 선택해주세요.')
-      return
-    }
-    if (!formData.resumePosition) {
-      alert('이력서 포지션을 선택해주세요.')
-      return
-    }
-    if (formData.selectedPositions.length === 0) {
-      alert('포지션을 선택해주세요.')
-      return
-    }
-    if (!formData.recommendation) {
-      alert('추천 여부를 선택해주세요.')
-      return
-    }
-    if (!formData.githubUrl.trim()) {
-      alert('깃허브 URL을 입력해주세요.')
-      return
-    }
-
-    if (!formData.employmentStatus) {
-      alert('경력 여부를 선택해주세요.')
-      return
-    }
-    if (formData.employmentStatus === 'yes') {
-      if (!formData.internshipExperience) {
-        alert('인턴 경험 여부를 선택해주세요.')
-        return
-      }
-      if (!formData.jobExperience) {
-        alert('정규직 경험 여부를 선택해주세요.')
-        return
-      }
-    }
-    if (!formData.isVerified) {
-      alert('이메일 인증을 완료해주세요.')
-      return
-    }
-
-    // 모든 검증 통과 후 로딩 시작
-    setIsLoading(true)
-
-    // 경험 데이터를 백엔드 형식으로 통합
-    const experiences: {
-      companyName: string
-      position: string
-      startDate: string
-      endDate: string
-      category: string
-    }[] = []
-
-    if (formData.internshipExperience === 'yes') {
-      experiences.push(
-        ...formData.internships.map((item) => ({
-          companyName: item.companyName || '',
-          position: item.position || formData.selectedPositions[0] || '',
-          startDate: item.startDate || '',
-          endDate: item.isCurrentJob ? '' : item.endDate || '',
-          category: '인턴',
-        })),
-      )
-    }
-
-    if (formData.jobExperience === 'yes') {
-      experiences.push(
-        ...formData.fullTimes.map((item) => ({
-          companyName: item.companyName || '',
-          position: item.position || formData.selectedPositions[0] || '',
-          startDate: item.startDate || '',
-          endDate: item.isCurrentJob ? '' : item.endDate || '',
-          category: '정규직',
-        })),
-      )
-    }
-
-    const finalSchool =
-      formData.school === '해당 없음'
-        ? formData.customSchool.trim()
-        : formData.school
-
-    const createUserRequest: any = {
-      mainPosition: formData.selectedPositions[0] || '',
-      subPosition: formData.selectedPositions[1] || '',
-      name: formData.name,
-      githubUrl: formData.githubUrl,
-      isLft: formData.recommendation === 'yes',
-      school: finalSchool,
-      grade: formData.classYear,
-      password: formData.password,
-      email: formData.email,
-      year: parseInt(formData.selectedBatch),
-    }
-
-    if (formData.tistoryUrl) {
-      createUserRequest.tistoryUrl = formData.tistoryUrl
-    }
-    if (formData.mediumUrl) {
-      createUserRequest.mediumUrl = formData.mediumUrl
-    }
-    if (formData.velogUrl) {
-      createUserRequest.velogUrl = formData.velogUrl
-    }
-
-    const requestPayload = {
-      createUserRequest,
-      createUserExperienceRequest: {
-        experiences,
-      },
-      createResumeRequest: {
-        category: formData.resumeCategory,
-        position: formData.resumePosition,
-        title: formData.resumeTitle,
-        isMain: formData.resumeIsMain,
-      },
-    }
-
-    try {
-      const formDataToSend = new FormData()
-      if (formData.resumeFile) {
-        formDataToSend.append('file', formData.resumeFile)
-      }
-      formDataToSend.append(
-        'createUserWithResumeRequest',
-        JSON.stringify(requestPayload),
-      )
-
-      const response = await fetch('/api/v1/users/signup', {
-        method: 'POST',
-        body: formDataToSend,
-      })
-
-      if (!response.ok) {
-        const errorData = await response.json().catch(() => null)
-
-        if (response.status === 400) {
-          alert('필수 항목을 모두 입력해주세요.')
-          return
-        }
-
-        if (response.status === 500) {
-          alert('이미 등록된 이메일입니다.')
-          return
-        }
-
-        if (response.status === 401) {
-          alert(errorData.message)
-          return
-        }
-      }
-
-      router.push('/login?form=signup')
-    } catch (err: any) {
-      alert('네트워크 오류가 발생했습니다.')
-    } finally {
-      setIsLoading(false)
-    }
-  }
-
-  const handlePositionSelect = (position: string) => {
-    if (formData.selectedPositions.includes(position)) {
-      setFormData((prev) => ({
-        ...prev,
-        selectedPositions: prev.selectedPositions.filter(
-          (item) => item !== position,
-        ),
-      }))
-    } else if (formData.selectedPositions.length < 2) {
-      setFormData((prev) => ({
-        ...prev,
-        selectedPositions: [...prev.selectedPositions, position],
-      }))
-    }
-  }
-
-  const setInternships = (internships: any[]) => {
-    setFormData((prev) => ({
-      ...prev,
-      internships,
-    }))
-  }
-
-  const setFullTimes = (fullTimes: any[]) => {
-    setFormData((prev) => ({
-      ...prev,
-      fullTimes,
-    }))
-  }
-
-  const formRef = useRef<HTMLDivElement>(null)
-  const bottomRef = useRef<HTMLDivElement>(null)
-
-  useEffect(() => {
-    const formElement = formRef.current
-    if (formElement && bottomRef.current) {
-      if (
-        formData.employmentStatus === 'yes' ||
-        formData.internshipExperience === 'yes' ||
-        formData.jobExperience === 'yes' ||
-        formData.internships.length > 0 ||
-        formData.fullTimes.length > 0
-      ) {
-        formElement.scrollTo({
-          top: bottomRef.current.offsetTop,
-          behavior: 'smooth',
-        })
-      }
-    }
-  }, [
-    formData.employmentStatus,
-    formData.internshipExperience,
-    formData.jobExperience,
-    formData.internships.length,
-    formData.fullTimes.length,
-  ])
+  const {
+    formData,
+    setFormData,
+    setInternships,
+    setFullTimes,
+    handleChange,
+    handlePositionSelect,
+    step,
+    isLoading,
+    handleSignup,
+    passwordsMatch,
+    formRef,
+    bottomRef,
+    handleNext,
+    handleBack,
+  } = useSignupHandler()
 
   return (
     <div className="flex min-h-[calc(100vh-61px)] items-center justify-between">
@@ -442,35 +108,7 @@ const Signup = () => {
                 <div className="w-1/2">
                   <Select
                     title="대학교"
-                    options={[
-                      '강원대학교',
-                      '가톨릭대학교',
-                      '가천대학교',
-                      '광운대학교',
-                      '단국대학교',
-                      '대구가톨릭대학교',
-                      '덕성여자대학교',
-                      '동덕여자대학교',
-                      '서강대학교',
-                      '성결대학교',
-                      '세종대학교',
-                      '안양대학교',
-                      '연세대학교',
-                      '이화여자대학교',
-                      '인천대학교',
-                      '인하대학교',
-                      '중앙대학교',
-                      '창원대학교',
-                      '충남대학교',
-                      '충북대학교',
-                      '평택대학교',
-                      '부산대학교',
-                      '한국공학대학교',
-                      '한서대학교',
-                      '한성대학교',
-                      '호서대학교',
-                      '해당 없음',
-                    ]}
+                    options={UNIVERSITIES}
                     value={formData.school}
                     onChange={(value) =>
                       setFormData((prev) => ({ ...prev, school: value }))
@@ -497,14 +135,7 @@ const Signup = () => {
                 <div className="w-1/2">
                   <Select
                     title="학년"
-                    options={[
-                      '1학년',
-                      '2학년',
-                      '3학년',
-                      '4학년',
-                      '졸업',
-                      '해당 없음',
-                    ]}
+                    options={GRADE}
                     value={formData.classYear}
                     onChange={(value) =>
                       setFormData((prev) => ({ ...prev, classYear: value }))
@@ -521,17 +152,7 @@ const Signup = () => {
               <div className="w-[9.5rem]">
                 <Select
                   title="기수"
-                  options={[
-                    '1기',
-                    '2기',
-                    '3기',
-                    '4기',
-                    '5기',
-                    '6기',
-                    '7기',
-                    '8기',
-                    '9기',
-                  ]}
+                  options={GENERATION}
                   value={formData.selectedBatch}
                   onChange={(value) =>
                     setFormData((prev) => ({
@@ -611,13 +232,7 @@ const Signup = () => {
                     </label>
                     <Select
                       title="포지션"
-                      options={[
-                        'FRONTEND',
-                        'BACKEND',
-                        'DEVOPS',
-                        'FULL_STACK',
-                        'DATA_ENGINEER',
-                      ]}
+                      options={POSITION}
                       value={formData.resumePosition}
                       onChange={(value) =>
                         setFormData((prev) => ({
@@ -680,55 +295,34 @@ const Signup = () => {
             <div>
               <p className="block text-lg mb-2.5">링크를 입력해주세요</p>
               <div className="flex flex-col space-y-2">
-                <div className="flex justify-between space-x-5">
-                  <div className="flex items-center justify-center w-[10rem] h-10 rounded-[0.25rem] text-primary border border-primary">
-                    깃허브<span className="text-primary">*</span>
-                  </div>
-                  <input
-                    type="text"
-                    name="githubUrl"
-                    placeholder="https://github.com/user"
-                    className="w-full h-10 px-4 border border-gray rounded-[0.25rem] focus:outline-none focus:border-primary"
-                    value={formData.githubUrl}
-                    onChange={handleChange}
-                  />
-                </div>
-                <div className="flex justify-between space-x-5">
-                  <div className="flex items-center justify-center w-[10rem] h-10 rounded-[0.25rem] text-primary border border-primary">
-                    미디엄
-                  </div>
-                  <input
-                    type="text"
-                    name="mediumUrl"
-                    className="w-full h-10 px-4 border border-gray rounded-[0.25rem] focus:outline-none focus:border-primary"
-                    value={formData.mediumUrl}
-                    onChange={handleChange}
-                  />
-                </div>
-                <div className="flex justify-between space-x-5">
-                  <div className="flex items-center justify-center w-[10rem] h-10 rounded-[0.25rem] text-primary border border-primary">
-                    벨로그
-                  </div>
-                  <input
-                    type="text"
-                    name="velogUrl"
-                    className="w-full h-10 px-4 border border-gray rounded-[0.25rem] focus:outline-none focus:border-primary"
-                    value={formData.velogUrl}
-                    onChange={handleChange}
-                  />
-                </div>
-                <div className="flex justify-between space-x-5">
-                  <div className="flex items-center justify-center w-[10rem] h-10 rounded-[0.25rem] text-primary border border-primary">
-                    티스토리
-                  </div>
-                  <input
-                    type="text"
-                    name="tistoryUrl"
-                    className="w-full h-10 px-4 border border-gray rounded-[0.25rem] focus:outline-none focus:border-primary"
-                    value={formData.tistoryUrl}
-                    onChange={handleChange}
-                  />
-                </div>
+                <BlogComponent
+                  label="깃허브*"
+                  name="githubUrl"
+                  value={formData.githubUrl}
+                  handleChange={handleChange}
+                  placeholder={'https://github.com/user'}
+                />
+                <BlogComponent
+                  label="미디엄"
+                  name="mediumUrl"
+                  value={formData.mediumUrl}
+                  handleChange={handleChange}
+                  placeholder={'https://medium.com/@user'}
+                />
+                <BlogComponent
+                  label="벨로그"
+                  name="velogUrl"
+                  value={formData.velogUrl}
+                  handleChange={handleChange}
+                  placeholder={'https://velog.io/@user'}
+                />
+                <BlogComponent
+                  label="티스토리"
+                  name="tistoryUrl"
+                  value={formData.tistoryUrl}
+                  handleChange={handleChange}
+                  placeholder={'https://user.tistory.com/'}
+                />
               </div>
             </div>
 
diff --git a/src/components/signup/BlogComponent.tsx b/src/components/signup/BlogComponent.tsx
new file mode 100644
index 00000000..387305d3
--- /dev/null
+++ b/src/components/signup/BlogComponent.tsx
@@ -0,0 +1,29 @@
+import React from 'react'
+import { BlogType } from '@/types/signup/signup.type'
+
+const BlogComponent = ({
+  label,
+  value,
+  handleChange,
+  placeholder,
+  name,
+}: BlogType) => {
+  return (
+    <div className="flex justify-between space-x-5">
+      <div className="flex items-center justify-center w-[10rem] h-10 rounded-[0.25rem] text-primary border border-primary">
+        {label}
+        <span className="text-primary"></span>
+      </div>
+      <input
+        type="text"
+        name={name}
+        placeholder={placeholder}
+        className="w-full h-10 px-4 border border-gray rounded-[0.25rem] focus:outline-none focus:border-primary"
+        value={value}
+        onChange={handleChange}
+      />
+    </div>
+  )
+}
+
+export default BlogComponent
diff --git a/src/constants/signupInfo.ts b/src/constants/signupInfo.ts
new file mode 100644
index 00000000..35f071dd
--- /dev/null
+++ b/src/constants/signupInfo.ts
@@ -0,0 +1,50 @@
+export const UNIVERSITIES = [
+  '강원대학교',
+  '가톨릭대학교',
+  '가천대학교',
+  '광운대학교',
+  '단국대학교',
+  '대구가톨릭대학교',
+  '덕성여자대학교',
+  '동덕여자대학교',
+  '서강대학교',
+  '성결대학교',
+  '세종대학교',
+  '안양대학교',
+  '연세대학교',
+  '이화여자대학교',
+  '인천대학교',
+  '인하대학교',
+  '중앙대학교',
+  '창원대학교',
+  '충남대학교',
+  '충북대학교',
+  '평택대학교',
+  '부산대학교',
+  '한국공학대학교',
+  '한서대학교',
+  '한성대학교',
+  '호서대학교',
+  '해당 없음',
+]
+
+export const GRADE = ['1학년', '2학년', '3학년', '4학년', '졸업', '해당 없음']
+export const GENERATION = [
+  '1기',
+  '2기',
+  '3기',
+  '4기',
+  '5기',
+  '6기',
+  '7기',
+  '8기',
+  '9기',
+]
+
+export const POSITION = [
+  'FRONTEND',
+  'BACKEND',
+  'DEVOPS',
+  'FULL_STACK',
+  'DATA_ENGINEER',
+]
diff --git a/src/hooks/auth/useSignupForm.ts b/src/hooks/auth/useSignupForm.ts
new file mode 100644
index 00000000..6bdb0605
--- /dev/null
+++ b/src/hooks/auth/useSignupForm.ts
@@ -0,0 +1,45 @@
+import { useState } from 'react'
+import {
+  fulltimesType,
+  initialFormData,
+  internshipsType,
+} from '@/types/signup/signup.type'
+
+export const useSignupForm = () => {
+  const [formData, setFormData] =
+    useState<typeof initialFormData>(initialFormData)
+
+  const setInternships = (internships: internshipsType[]) => {
+    setFormData((prev) => ({ ...prev, internships }))
+  }
+
+  const setFullTimes = (fullTimes: fulltimesType[]) => {
+    setFormData((prev) => ({ ...prev, fullTimes }))
+  }
+
+  const handleChange = (
+    e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
+  ) => {
+    const { name, value } = e.target
+    setFormData((prev) => ({ ...prev, [name]: value }))
+  }
+
+  const handlePositionSelect = (position: string) => {
+    setFormData((prev) => {
+      const isSelected = prev.selectedPositions.includes(position)
+      const selected = isSelected
+        ? prev.selectedPositions.filter((p) => p !== position)
+        : [...prev.selectedPositions, position].slice(0, 2)
+      return { ...prev, selectedPositions: selected }
+    })
+  }
+
+  return {
+    formData,
+    setFormData,
+    setInternships,
+    setFullTimes,
+    handleChange,
+    handlePositionSelect,
+  }
+}
diff --git a/src/hooks/auth/useSignupHandler.ts b/src/hooks/auth/useSignupHandler.ts
new file mode 100644
index 00000000..323799bd
--- /dev/null
+++ b/src/hooks/auth/useSignupHandler.ts
@@ -0,0 +1,226 @@
+import { useState, useRef, useEffect } from 'react'
+import { useRouter } from 'next/navigation'
+import { useSignupForm } from './useSignupForm'
+
+export const useSignupHandler = () => {
+  const router = useRouter()
+  const [signupError, setSignupError] = useState<string>('')
+  const [step, setStep] = useState(1)
+  const [isLoading, setIsLoading] = useState(false)
+
+  const {
+    formData,
+    setFormData,
+    setInternships,
+    setFullTimes,
+    handleChange,
+    handlePositionSelect,
+  } = useSignupForm()
+
+  const passwordsMatch =
+    formData.password === formData.confirmPassword &&
+    formData.password.length > 0
+
+  const validateStepOne = () => {
+    if (!formData.name.trim()) return '이름을 입력해주세요.'
+    if (!formData.email.trim()) return '이메일을 입력해주세요.'
+    if (!formData.password) return '비밀번호를 입력해주세요.'
+    const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&]).{8,}$/
+    if (!passwordRegex.test(formData.password)) {
+      return '비밀번호 양식에 맞지 않습니다. 영어, 숫자, 특수문자를 포함해주세요. 비밀번호는 8자 이상이어야 합니다.'
+    }
+    if (!formData.confirmPassword) return '비밀번호 확인을 입력해주세요.'
+    if (!formData.isVerified) return '이메일 인증을 완료해주세요.'
+    if (!passwordsMatch) return '비밀번호가 일치하지 않습니다.'
+    return ''
+  }
+
+  const validateStepTwo = () => {
+    if (!formData.school.trim()) return '대학교를 선택해주세요.'
+    if (formData.school === '해당 없음' && !formData.customSchool.trim()) {
+      return '학교명을 직접 입력해주세요.'
+    }
+    if (!formData.classYear.trim()) return '학년을 선택해주세요.'
+    if (!formData.selectedBatch.trim()) return '기수를 선택해주세요.'
+    if (!formData.resumeTitle.trim()) return '이력서 제목을 입력해주세요.'
+    if (!formData.resumeFile) return '이력서 파일을 업로드해주세요.'
+    if (!formData.resumeCategory.trim())
+      return '이력서 카테고리를 선택해주세요.'
+    if (!formData.resumePosition) return '이력서 포지션을 선택해주세요.'
+    if (formData.selectedPositions.length === 0) return '포지션을 선택해주세요.'
+    if (!formData.recommendation) return '추천 여부를 선택해주세요.'
+    if (!formData.githubUrl.trim()) return '깃허브 URL을 입력해주세요.'
+    if (!formData.employmentStatus) return '경력 여부를 선택해주세요.'
+    if (formData.employmentStatus === 'yes') {
+      if (!formData.internshipExperience)
+        return '인턴 경험 여부를 선택해주세요.'
+      if (!formData.jobExperience) return '정규직 경험 여부를 선택해주세요.'
+    }
+    if (!formData.isVerified) return '이메일 인증을 완료해주세요.'
+    return ''
+  }
+
+  const handleNext = () => {
+    setSignupError('')
+    const error = validateStepOne()
+    if (error) return alert(error)
+    if (step < 2) setStep(step + 1)
+  }
+
+  const handleBack = () => {
+    setSignupError('')
+    if (step > 1) setStep(step - 1)
+  }
+
+  const createExperiences = () => {
+    const experiences: {
+      companyName: string
+      position: string
+      startDate: string
+      endDate: string
+      category: string
+    }[] = []
+
+    if (formData.internshipExperience === 'yes') {
+      experiences.push(
+        ...formData.internships.map((item) => ({
+          companyName: item.companyName || '',
+          position: item.position || formData.selectedPositions[0] || '',
+          startDate: item.startDate || '',
+          endDate: item.isCurrentJob ? '' : item.endDate || '',
+          category: '인턴',
+        })),
+      )
+    }
+
+    if (formData.jobExperience === 'yes') {
+      experiences.push(
+        ...formData.fullTimes.map((item) => ({
+          companyName: item.companyName || '',
+          position: item.position || formData.selectedPositions[0] || '',
+          startDate: item.startDate || '',
+          endDate: item.isCurrentJob ? '' : item.endDate || '',
+          category: '정규직',
+        })),
+      )
+    }
+
+    return experiences
+  }
+
+  const handleSignup = async () => {
+    setSignupError('')
+    const error = validateStepTwo()
+    if (error) return alert(error)
+    setIsLoading(true)
+
+    const experiences = createExperiences()
+    const finalSchool =
+      formData.school === '해당 없음'
+        ? formData.customSchool.trim()
+        : formData.school
+
+    const createUserRequest: any = {
+      mainPosition: formData.selectedPositions[0] || '',
+      subPosition: formData.selectedPositions[1] || '',
+      name: formData.name,
+      githubUrl: formData.githubUrl,
+      isLft: formData.recommendation === 'yes',
+      school: finalSchool,
+      grade: formData.classYear,
+      password: formData.password,
+      email: formData.email,
+      year: parseInt(formData.selectedBatch),
+    }
+
+    if (formData.tistoryUrl) createUserRequest.tistoryUrl = formData.tistoryUrl
+    if (formData.mediumUrl) createUserRequest.mediumUrl = formData.mediumUrl
+    if (formData.velogUrl) createUserRequest.velogUrl = formData.velogUrl
+
+    const requestPayload = {
+      createUserRequest,
+      createUserExperienceRequest: { experiences },
+      createResumeRequest: {
+        category: formData.resumeCategory,
+        position: formData.resumePosition,
+        title: formData.resumeTitle,
+        isMain: formData.resumeIsMain,
+      },
+    }
+
+    try {
+      const formDataToSend = new FormData()
+      if (formData.resumeFile)
+        formDataToSend.append('file', formData.resumeFile)
+      formDataToSend.append(
+        'createUserWithResumeRequest',
+        JSON.stringify(requestPayload),
+      )
+
+      const response = await fetch('/api/v1/users/signup', {
+        method: 'POST',
+        body: formDataToSend,
+      })
+
+      if (!response.ok) {
+        const errorData = await response.json().catch(() => null)
+        if (response.status === 400)
+          return alert('필수 항목을 모두 입력해주세요.')
+        if (response.status === 500) return alert('이미 등록된 이메일입니다.')
+        if (response.status === 401) return alert(errorData.message)
+      }
+
+      router.push('/login?form=signup')
+    } catch (err) {
+      alert('네트워크 오류가 발생했습니다.')
+    } finally {
+      setIsLoading(false)
+    }
+  }
+
+  const formRef = useRef<HTMLDivElement>(null)
+  const bottomRef = useRef<HTMLDivElement>(null)
+
+  useEffect(() => {
+    const formElement = formRef.current
+    if (formElement && bottomRef.current) {
+      if (
+        formData.employmentStatus === 'yes' ||
+        formData.internshipExperience === 'yes' ||
+        formData.jobExperience === 'yes' ||
+        formData.internships.length > 0 ||
+        formData.fullTimes.length > 0
+      ) {
+        formElement.scrollTo({
+          top: bottomRef.current.offsetTop,
+          behavior: 'smooth',
+        })
+      }
+    }
+  }, [
+    formData.employmentStatus,
+    formData.internshipExperience,
+    formData.jobExperience,
+    formData.internships.length,
+    formData.fullTimes.length,
+  ])
+
+  return {
+    signupError,
+    step,
+    isLoading,
+    formData,
+    handleChange,
+    handlePositionSelect,
+    handleNext,
+    handleBack,
+    handleSignup,
+    formRef,
+    bottomRef,
+    passwordsMatch,
+    setFormData,
+    setInternships,
+    setFullTimes,
+    setStep,
+  }
+}
diff --git a/src/types/signup/signup.type.ts b/src/types/signup/signup.type.ts
new file mode 100644
index 00000000..5e0e7952
--- /dev/null
+++ b/src/types/signup/signup.type.ts
@@ -0,0 +1,57 @@
+export type internshipsType = {
+  companyName: string
+  position: string
+  startDate: string
+  endDate: string
+  isCurrentJob: boolean
+}
+
+export type fulltimesType = {
+  companyName: string
+  position: string
+  startDate: string
+  endDate: string
+  isCurrentJob: boolean
+}
+
+export const initialFormData = {
+  name: '',
+  email: '',
+  password: '',
+  confirmPassword: '',
+  isVerified: false,
+
+  school: '',
+  customSchool: '', // “해당 없음” 선택 시, 직접 입력한 학교명
+  classYear: '',
+  selectedBatch: '',
+  resumeTitle: '',
+  githubUrl: '',
+  tistoryUrl: '',
+  mediumUrl: '',
+  velogUrl: '',
+  selectedPositions: [] as string[],
+  recommendation: null as string | null,
+  employmentStatus: null as string | null,
+  // CareerToggle에서 "있어요" 버튼을 누르면 'yes'로 저장됩니다.
+  internshipExperience: null as string | null,
+  jobExperience: null as string | null,
+
+  // 경험 정보: 기존의 키 대신 unified한 키로 관리
+  internships: [] as internshipsType[],
+  fullTimes: [] as fulltimesType[],
+
+  // 이력서 추가 정보
+  resumeFile: null as File | null,
+  resumeCategory: 'RESUME',
+  resumeIsMain: true,
+  resumePosition: '',
+}
+
+export type BlogType = {
+  label: string
+  value: string
+  handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
+  placeholder: string
+  name: string
+}


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 3**

<details>
<summary>🧹 Nitpick comments (9)</summary><blockquote>

<details>
<summary>src/constants/signupInfo.ts (3)</summary><blockquote>

`1-29`: **상수 배열에 `as const`를 붙여 불변·리터럴 타입을 보장하세요**  
현재 배열은 런타임에서 내용이 변경될 수 있고, 타입 추론도 `string[]`으로만 좁혀집니다. `as const`를 붙이면  
1) 의도치 않은 push/shift 등을 컴파일 단계에서 막을 수 있고  
2) 각 요소가 문자열 리터럴 타입으로 좁혀져 추후 Form ­validation 시 유니언 타입으로 활용하기 좋습니다.  

```diff
-export const UNIVERSITIES = [
+export const UNIVERSITIES = [
   /* ... */
-  '해당 없음',
-]
+  '해당 없음',
+] as const
```
같은 이유로 아래 `GRADE`, `GENERATION`, `POSITION`에도 동일하게 적용하는 것을 권장드립니다.

---

`31-43`: **`GRADE`, `GENERATION`도 불변 배열로 선언해 타입 안전성을 높이세요**  
`as const` 적용 시 `'1학년' | '2학년' | …` 처럼 유니언 타입으로 추론되어 폼 데이터의 타입 가드에 유용합니다.

---

`44-50`: **`POSITION` 상수는 enum 혹은 유니언 타입으로 관리하면 오타 방지에 도움이 됩니다**  
현재 문자열을 직접 비교‧대입하는 곳이 많습니다. 예) `'FULL_STACK'` → `'FULLSTACK'` 오타 발생 시 런타임 오류로 이어집니다.  

```ts
export const POSITION = ['FRONTEND', 'BACKEND', 'DEVOPS', 'FULL_STACK', 'DATA_ENGINEER'] as const
export type PositionType = typeof POSITION[number]
```
이후 `selectedPositions: PositionType[]` 같이 활용하면 IDE 자동완성 & 컴파일 체크를 얻을 수 있습니다.

</blockquote></details>
<details>
<summary>src/hooks/auth/useSignupForm.ts (1)</summary><blockquote>

`19-24`: **`handleChange` 이벤트 타입을 좀 더 포괄적으로 허용하세요**  
TextArea 나 `contentEditable` 컴포넌트 확장 시에도 재사용하려면 `HTMLTextAreaElement`를 포함하거나 제네릭으로 받을 수 있습니다.

```diff
-React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
+React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
```

</blockquote></details>
<details>
<summary>src/components/signup/BlogComponent.tsx (1)</summary><blockquote>

`12-24`: **라벨·인풋 연결 및 접근성 개선**  
현재 `<div>`를 라벨처럼 사용하고 있어 스크린리더가 필드 이름을 인식하지 못합니다. `label`-`htmlFor`를 사용해 시맨틱 마크업을 권장합니다.

```diff
-<div className="flex items-center ...">{label}</div>
-<input type="text" name={name} ... />
+<label
+  htmlFor={name}
+  className="flex items-center justify-center w-[10rem] h-10 rounded-[0.25rem] text-primary border border-primary"
+>
+  {label}
+</label>
+<input
+  id={name}
+  type="url"
+  name={name}
+  ... />
```
또한 `type="url"`을 사용하면 모바일 키보드와 기본 URL 검증 혜택을 받을 수 있습니다.

</blockquote></details>
<details>
<summary>src/app/signup/page.tsx (1)</summary><blockquote>

`298-325`: **블로그 입력 필드 반복을 데이터-드리븐 렌더링으로 단순화하세요**  
`BlogComponent`로 추출하긴 했지만 동일한 JSX가 네 번 반복되고 있어 유지보수성이 떨어집니다. 배열로 선언 후 `map`을 돌리면 필드 추가/삭제가 훨씬 쉽습니다.

```diff
-<BlogComponent label="깃허브" name="githubUrl" ... />
-<BlogComponent label="미디엄" name="mediumUrl" ... />
-<BlogComponent label="벨로그" name="velogUrl" ... />
-<BlogComponent label="티스토리" name="tistoryUrl" ... />
+{[
+  { label: '깃허브', name: 'githubUrl', placeholder: 'https://github.com/user' },
+  { label: '미디엄', name: 'mediumUrl', placeholder: 'https://medium.com/@user' },
+  { label: '벨로그', name: 'velogUrl', placeholder: 'https://velog.io/@user' },
+  { label: '티스토리', name: 'tistoryUrl', placeholder: 'https://user.tistory.com/' },
+].map(({ label, name, placeholder }) => (
+  <BlogComponent
+    key={name}
+    label={label}
+    name={name}
+    value={formData[name as keyof typeof formData] as string}
+    handleChange={handleChange}
+    placeholder={placeholder}
+  />
+))}
```
컴포넌트 수를 줄이고 새 블로그 플랫폼 추가 시 한 곳만 수정하면 됩니다.

</blockquote></details>
<details>
<summary>src/types/signup/signup.type.ts (1)</summary><blockquote>

`51-57`: **React 타입 사용 시 import 누락 가능성**

`BlogType` 내에서 `React.ChangeEvent` 를 참조하고 있지만 본 파일에 `import type { ChangeEvent } from 'react'` 가 없습니다. `@types/react`가 전역으로 주입되지 않는 설정에서는 컴파일 에러가 발생할 수 있으니 아래와 같이 타입 임포트를 명확히 추가해주세요.

```diff
+import type { ChangeEvent } from 'react'

 export type BlogType = {
   label: string
   value: string
-  handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
+  handleChange: (e: ChangeEvent<HTMLInputElement>) => void
   placeholder: string
   name: string
 }
```

</blockquote></details>
<details>
<summary>src/hooks/auth/useSignupHandler.ts (2)</summary><blockquote>

`24-36`: **이메일 형식 검증이 누락되었습니다**

`validateStepOne`에서 이메일 입력 여부만 확인하고 형식 검사는 생략되어 있습니다. 간단한 정규식을 추가하여 사용자 입력 오류를 조기에 방지하세요.

```diff
-    if (!formData.email.trim()) return '이메일을 입력해주세요.'
+    if (!formData.email.trim()) return '이메일을 입력해주세요.'
+    const emailRegex =
+      /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/
+    if (!emailRegex.test(formData.email))
+      return '이메일 형식이 올바르지 않습니다.'
```

---

`160-170`: **HTTP 오류 처리 범위 확장 제안**

현재 400·401·500만 처리하고 기타 상태코드는 무시됩니다. 예를 들어 409(Conflict)·422(Unprocessable) 등도 사용자에게 안내가 필요할 수 있습니다.

```diff
-        if (response.status === 400)
+        if (response.status === 400)
           return alert('필수 항목을 모두 입력해주세요.')
+        if (response.status === 409)
+          return alert('이미 사용 중인 닉네임 또는 이메일입니다.')
+        if (response.status === 422)
+          return alert('입력값을 다시 확인해주세요.')
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 199033f359e1fcfe2aef9ca5447e13de62b85783 and f4c023fa9952dd6cebb1e8cc9eaebbf21263aab0.

</details>

<details>
<summary>📒 Files selected for processing (6)</summary>

* `src/app/signup/page.tsx` (6 hunks)
* `src/components/signup/BlogComponent.tsx` (1 hunks)
* `src/constants/signupInfo.ts` (1 hunks)
* `src/hooks/auth/useSignupForm.ts` (1 hunks)
* `src/hooks/auth/useSignupHandler.ts` (1 hunks)
* `src/types/signup/signup.type.ts` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (3)</summary>

<details>
<summary>src/components/signup/BlogComponent.tsx (1)</summary><blockquote>

<details>
<summary>src/types/signup/signup.type.ts (1)</summary>

* `BlogType` (51-57)

</details>

</blockquote></details>
<details>
<summary>src/hooks/auth/useSignupForm.ts (1)</summary><blockquote>

<details>
<summary>src/types/signup/signup.type.ts (3)</summary>

* `initialFormData` (17-49)
* `internshipsType` (1-7)
* `fulltimesType` (9-15)

</details>

</blockquote></details>
<details>
<summary>src/app/signup/page.tsx (2)</summary><blockquote>

<details>
<summary>src/hooks/auth/useSignupHandler.ts (1)</summary>

* `useSignupHandler` (5-226)

</details>
<details>
<summary>src/constants/signupInfo.ts (4)</summary>

* `UNIVERSITIES` (1-29)
* `GRADE` (31-31)
* `GENERATION` (32-42)
* `POSITION` (44-50)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (3)</summary><blockquote>

<details>
<summary>src/types/signup/signup.type.ts (2)</summary>

`1-15`: **타입 네이밍 및 중복 구조 개선 제안**

`internshipsType`, `fulltimesType` 모두 snake-case·복수형·소문자 혼용으로 컨벤션이 일관되지 않습니다. 또 동일한 필드를 공유하므로 공통 인터페이스를 추출하면 중복을 줄일 수 있습니다.  

```diff
-export type internshipsType = {
-  companyName: string
-  position: string
-  startDate: string
-  endDate: string
-  isCurrentJob: boolean
-}
-
-export type fulltimesType = {
-  companyName: string
-  position: string
-  startDate: string
-  endDate: string
-  isCurrentJob: boolean
-}
+interface ExperienceBase {
+  companyName: string
+  position: string
+  startDate: string
+  endDate: string
+  isCurrentJob: boolean
+}
+
+export type Internship = ExperienceBase
+export type FullTime = ExperienceBase
```

이렇게 하면 타입 재사용성이 높아지고 가독성이 올라갑니다.  
[tag: suggest_essential_refactor]

---

`17-49`: **`initialFormData`에 명시적 타입 지정 필요**

현재 객체 리터럴에 직접 `as` 타입 단언을 사용해 필드를 정의하고 있지만, 전체 폼 상태를 표현하는 `SignupFormData` 인터페이스(또는 타입)를 선언한 뒤 `const initialFormData: SignupFormData = { … }` 로 명시해 주면:

1. 추후 필드 추가·삭제 시 컴파일 타임에 누락을 잡아낼 수 있습니다.  
2. `useSignupForm` 훅에서 `useState<SignupFormData>` 로 정확한 제네릭을 전달할 수 있어 안전성이 상승합니다.  
[tag: suggest_essential_refactor]

</details>
<details>
<summary>src/hooks/auth/useSignupHandler.ts (1)</summary>

`75-109`: **경험 배열이 비어있을 때 서버 오류 가능성**

`employmentStatus`가 `yes` 이지만 실제로 `internships/fullTimes` 배열이 비어 있을 경우, `createExperiences` 가 빈 배열을 반환합니다. 백엔드가 최소 1개의 경험을 기대한다면 400 오류가 발생할 수 있습니다. 유효성 검사를 추가하거나, 조건에 따라 `experiences` 필드를 생략하는 로직이 필요합니다.  
[tag: suggest_essential_refactor]

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-04T11:30:10Z
user: login: Ye0ngjun
body: 고생하셨습니다!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-14T09:59:25Z

Review comments:
diff_hunk: @@ -0,0 +1,44 @@
+import { useState } from 'react'
+import {
+  fulltimesType,
+  initialFormData,
+  internshipsType,
+} from '@/types/signup/signup.type'
+
+export const useSignupForm = () => {
+  const [formData, setFormData] = useState(initialFormData)
+
path: src/hooks/auth/useSignupForm.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**`useState` 제네릭 지정으로 폼 데이터 타입 안정성 확보**  
현재 `formData`는 `any`에 가깝게 추론됩니다. 제네릭을 명시하면 잘못된 키 접근·타입 대입을 컴파일 단계에서 잡아낼 수 있습니다.

```diff
-const [formData, setFormData] = useState(initialFormData)
+const [formData, setFormData] = useState<typeof initialFormData>(initialFormData)
```

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-04T11:30:09Z
author_association: NONE
diff_hunk: @@ -0,0 +1,44 @@
+import { useState } from 'react'
+import {
+  fulltimesType,
+  initialFormData,
+  internshipsType,
+} from '@/types/signup/signup.type'
+
+export const useSignupForm = () => {
+  const [formData, setFormData] = useState(initialFormData)
+
+  const setInternships = (internships: internshipsType[]) => {
+    setFormData((prev) => ({ ...prev, internships }))
+  }
+
+  const setFullTimes = (fullTimes: fulltimesType[]) => {
+    setFormData((prev) => ({ ...prev, fullTimes }))
+  }
+
+  const handleChange = (
+    e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
+  ) => {
+    const { name, value } = e.target
+    setFormData((prev) => ({ ...prev, [name]: value }))
+  }
+
+  const handlePositionSelect = (position: string) => {
+    setFormData((prev) => {
+      const isSelected = prev.selectedPositions.includes(position)
+      const selected = isSelected
+        ? prev.selectedPositions.filter((p) => p !== position)
+        : [...prev.selectedPositions, position].slice(0, 2)
+      return { ...prev, selectedPositions: selected }
+    })
path: src/hooks/auth/useSignupForm.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**포지션 3개 이상 클릭 시 최근 선택값이 아닌 첫 2개만 유지되는 버그**  
`slice(0, 2)`는 새로 추가한 값을 뒤에 붙인 뒤 앞 2개만 남기므로, 이미 2개 선택 상태에서 세 번째를 선택하면 방금 선택한 값이 잘려나갈 수 있습니다. 최근 선택값을 유지하려면 `slice(-2)`로 바꾸거나, 사전에 선택 수를 검사해 알림을 주는 편이 UX 측면에서 좋습니다.

```diff
-: [...prev.selectedPositions, position].slice(0, 2)
+: [...prev.selectedPositions, position].slice(-2)
```



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
  const handlePositionSelect = (position: string) => {
    setFormData((prev) => {
      const isSelected = prev.selectedPositions.includes(position)
      const selected = isSelected
        ? prev.selectedPositions.filter((p) => p !== position)
        : [...prev.selectedPositions, position].slice(-2)
      return { ...prev, selectedPositions: selected }
    })
  }
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-04T11:30:09Z
author_association: NONE
diff_hunk: @@ -0,0 +1,226 @@
+import { useState, useRef, useEffect } from 'react'
+import { useRouter } from 'next/navigation'
+import { useSignupForm } from './useSignupForm'
+
+export const useSignupHandler = () => {
+  const router = useRouter()
+  const [signupError, setSignupError] = useState<string>('')
+  const [step, setStep] = useState(1)
+  const [isLoading, setIsLoading] = useState(false)
+
+  const {
+    formData,
+    setFormData,
+    setInternships,
+    setFullTimes,
+    handleChange,
+    handlePositionSelect,
+  } = useSignupForm()
+
+  const passwordsMatch =
+    formData.password === formData.confirmPassword &&
+    formData.password.length > 0
+
+  const validateStepOne = () => {
+    if (!formData.name.trim()) return '이름을 입력해주세요.'
+    if (!formData.email.trim()) return '이메일을 입력해주세요.'
+    if (!formData.password) return '비밀번호를 입력해주세요.'
+    const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&]).{8,}$/
+    if (!passwordRegex.test(formData.password)) {
+      return '비밀번호 양식에 맞지 않습니다. 영어, 숫자, 특수문자를 포함해주세요. 비밀번호는 8자 이상이어야 합니다.'
+    }
+    if (!formData.confirmPassword) return '비밀번호 확인을 입력해주세요.'
+    if (!formData.isVerified) return '이메일 인증을 완료해주세요.'
+    if (!passwordsMatch) return '비밀번호가 일치하지 않습니다.'
+    return ''
+  }
+
+  const validateStepTwo = () => {
+    if (!formData.school.trim()) return '대학교를 선택해주세요.'
+    if (formData.school === '해당 없음' && !formData.customSchool.trim()) {
+      return '학교명을 직접 입력해주세요.'
+    }
+    if (!formData.classYear.trim()) return '학년을 선택해주세요.'
+    if (!formData.selectedBatch.trim()) return '기수를 선택해주세요.'
+    if (!formData.resumeTitle.trim()) return '이력서 제목을 입력해주세요.'
+    if (!formData.resumeFile) return '이력서 파일을 업로드해주세요.'
+    if (!formData.resumeCategory.trim())
+      return '이력서 카테고리를 선택해주세요.'
+    if (!formData.resumePosition) return '이력서 포지션을 선택해주세요.'
+    if (formData.selectedPositions.length === 0) return '포지션을 선택해주세요.'
+    if (!formData.recommendation) return '추천 여부를 선택해주세요.'
+    if (!formData.githubUrl.trim()) return '깃허브 URL을 입력해주세요.'
+    if (!formData.employmentStatus) return '경력 여부를 선택해주세요.'
+    if (formData.employmentStatus === 'yes') {
+      if (!formData.internshipExperience)
+        return '인턴 경험 여부를 선택해주세요.'
+      if (!formData.jobExperience) return '정규직 경험 여부를 선택해주세요.'
+    }
+    if (!formData.isVerified) return '이메일 인증을 완료해주세요.'
+    return ''
+  }
+
+  const handleNext = () => {
+    setSignupError('')
+    const error = validateStepOne()
+    if (error) return alert(error)
+    if (step < 2) setStep(step + 1)
+  }
+
+  const handleBack = () => {
+    setSignupError('')
+    if (step > 1) setStep(step - 1)
+  }
+
+  const createExperiences = () => {
+    const experiences: {
+      companyName: string
+      position: string
+      startDate: string
+      endDate: string
+      category: string
+    }[] = []
+
+    if (formData.internshipExperience === 'yes') {
+      experiences.push(
+        ...formData.internships.map((item) => ({
+          companyName: item.companyName || '',
+          position: item.position || formData.selectedPositions[0] || '',
+          startDate: item.startDate || '',
+          endDate: item.isCurrentJob ? '' : item.endDate || '',
+          category: '인턴',
+        })),
+      )
+    }
+
+    if (formData.jobExperience === 'yes') {
+      experiences.push(
+        ...formData.fullTimes.map((item) => ({
+          companyName: item.companyName || '',
+          position: item.position || formData.selectedPositions[0] || '',
+          startDate: item.startDate || '',
+          endDate: item.isCurrentJob ? '' : item.endDate || '',
+          category: '정규직',
+        })),
+      )
+    }
+
+    return experiences
+  }
+
+  const handleSignup = async () => {
+    setSignupError('')
+    const error = validateStepTwo()
+    if (error) return alert(error)
+    setIsLoading(true)
+
+    const experiences = createExperiences()
+    const finalSchool =
+      formData.school === '해당 없음'
+        ? formData.customSchool.trim()
+        : formData.school
+
+    const createUserRequest: any = {
+      mainPosition: formData.selectedPositions[0] || '',
+      subPosition: formData.selectedPositions[1] || '',
+      name: formData.name,
+      githubUrl: formData.githubUrl,
+      isLft: formData.recommendation === 'yes',
+      school: finalSchool,
+      grade: formData.classYear,
+      password: formData.password,
+      email: formData.email,
+      year: parseInt(formData.selectedBatch),
+    }
+
+    if (formData.tistoryUrl) createUserRequest.tistoryUrl = formData.tistoryUrl
+    if (formData.mediumUrl) createUserRequest.mediumUrl = formData.mediumUrl
+    if (formData.velogUrl) createUserRequest.velogUrl = formData.velogUrl
+
path: src/hooks/auth/useSignupHandler.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**`any` 사용 지양 및 `parseInt` 기수 지정**

1. `createUserRequest: any`는 타입 안전성을 떨어뜨립니다. 전용 인터페이스를 선언해 주세요.  
2. `parseInt(formData.selectedBatch)` 호출 시 두 번째 인수로 10진수를 명시해 의도치 않은 8진수 파싱을 방지하세요.  

```diff
-  const createUserRequest: any = {
+  const createUserRequest: CreateUserRequest = {
 ...
-  year: parseInt(formData.selectedBatch),
+  year: parseInt(formData.selectedBatch, 10),
```



> Committable suggestion skipped: line range outside the PR's diff.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-04T11:30:09Z
author_association: NONE

PR comments:
user: login: vercel[bot]
created_at: 2025-05-04T11:27:18Z
body: [vc]: #tx2Ar5u/E4zrh7LILus0xxxARovVfNRaQX3puOtvfVo=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJmcm9udGVuZCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJmcm9udGVuZC1naXQtc2NydW0tMzAwLXRlY2hlZXJ6aXAudmVyY2VsLmFwcCJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vdGVjaGVlcnppcC9mcm9udGVuZC82THpnN3hLRFgyN3lBb0hkM2Y1d3VTS3lWQzd4IiwicHJldmlld1VybCI6ImZyb250ZW5kLWdpdC1zY3J1bS0zMDAtdGVjaGVlcnppcC52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwicm9vdERpcmVjdG9yeSI6bnVsbH1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **frontend** | ✅ Ready ([Inspect](https://vercel.com/techeerzip/frontend/6Lzg7xKDX27yAoHd3f5wuSKyVC7x)) | [Visit Preview](https://frontend-git-scrum-300-techeerzip.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/frontend-git-scrum-300-techeerzip.vercel.app?via=pr-comment-feedback-link) | May 4, 2025 11:42am |


author_association: NONE
user: login: coderabbitai[bot]
created_at: 2025-05-04T11:27:19Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

"""
## Walkthrough

회원가입 페이지의 상태 관리와 이벤트 처리 로직이 컴포넌트에서 분리되어 `useSignupHandler` 커스텀 훅으로 이동되었습니다. 입력 값, 검증, 단계 전환, 제출 등 모든 내부 로직이 훅에서 처리되며, 컴포넌트는 프레젠테이셔널 역할만 담당합니다. 학교, 학년, 기수, 포지션 등 옵션 배열은 상수 파일에서 가져오도록 변경되었습니다. 반복적으로 작성되던 블로그 URL 입력란은 `BlogComponent`로 대체되어 재사용성을 높였습니다. 회원가입 경험 및 이력 관련 스크롤 처리도 훅 내부로 이동되었습니다. 새로운 타입, 상수, 훅 파일이 추가되었습니다.

## Changes

| 파일/경로                                                         | 변경 요약                                                                                                   |
|-------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------|
| src/app/signup/page.tsx                                           | 상태 관리 및 이벤트 처리 로직을 `useSignupHandler` 훅으로 이동, 상수 및 블로그 입력 컴포넌트 사용, 프레젠테이셔널 컴포넌트로 단순화 |
| src/components/signup/BlogComponent.tsx                           | 블로그 입력란을 위한 재사용 가능한 `BlogComponent` 컴포넌트 추가                                            |
| src/constants/signupInfo.ts                                       | 학교, 학년, 기수, 포지션 등 회원가입 옵션 상수 배열 정의                                                    |
| src/hooks/auth/useSignupForm.ts                                   | 회원가입 폼 데이터와 입력/포지션 선택 핸들러를 관리하는 `useSignupForm` 훅 추가                              |
| src/hooks/auth/useSignupHandler.ts                                | 회원가입 단계, 검증, 제출, 스크롤 등 전체 로직을 관리하는 `useSignupHandler` 훅 추가                         |
| src/types/signup/signup.type.ts                                   | 인턴/정규직 이력 타입, 폼 초기값, 블로그 입력 타입 등 회원가입 관련 타입 및 상수 정의                        |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant User
    participant SignupPage
    participant useSignupHandler
    participant useSignupForm
    participant API

    User->>SignupPage: 폼 입력/이벤트 발생
    SignupPage->>useSignupHandler: 입력/이벤트 핸들러 호출
    useSignupHandler->>useSignupForm: 폼 데이터/포지션/이력 등 상태 업데이트
    User->>SignupPage: "다음" 또는 "회원가입" 클릭
    SignupPage->>useSignupHandler: handleNext/handleSignup 호출
    useSignupHandler->>useSignupForm: 입력값 검증 및 데이터 조합
    useSignupHandler->>API: 회원가입 요청 (FormData)
    API-->>useSignupHandler: 응답 (성공/실패)
    useSignupHandler-->>SignupPage: 상태/에러/리디렉션 처리
```

## Possibly related PRs

- Techeer-Hogwarts/frontend#59: 회원가입 페이지 리팩토링 및 기수(GENERATION) 선택 기능 추가와 관련된 PR로, 본 PR과 동일한 옵션 상수 및 폼 구조 변경 사항이 적용됨.

## Suggested reviewers

- baekjiyun
- miyoung12

## Poem

> 🐇  
> 폼의 로직은 훅 속에,  
> 상수들은 한곳에 모여,  
> 블로그 입력은 컴포넌트로,  
> 타입과 이력도 깔끔하게!  
> 토끼도 깡총, 코드도 깔끔,  
> 회원가입 여정이 한층 더 산뜻해졌어요!  
> 🥕✨
"""

<!-- walkthrough_end -->


---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between ed639a525f343c55449e46a29ef83f5276908b5d and 3b7da47f88a1409119d4798dd32db5a409c55898.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `src/hooks/auth/useSignupForm.ts` (1 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* src/hooks/auth/useSignupForm.ts

</details>

</details>
<!-- internal state start -->


<!-- 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= -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details open="true">
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=Techeer-Hogwarts/frontend&utm_content=78):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

### Support

Need help? Create a ticket on our [support page](https://www.coderabbit.ai/contact-us/support) for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-05-04T11:41:50Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=Techeer-Hogwarts_frontend&pullRequest=78) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [17 New issues](https://sonarcloud.io/project/issues?id=Techeer-Hogwarts_frontend&pullRequest=78&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=Techeer-Hogwarts_frontend&pullRequest=78&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=Techeer-Hogwarts_frontend&pullRequest=78&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=Techeer-Hogwarts_frontend&pullRequest=78&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=Techeer-Hogwarts_frontend&pullRequest=78&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=Techeer-Hogwarts_frontend&pullRequest=78)


author_association: NONE