2025.04.24 ~ 2025.06.24 8주, 월-목 09:00 ~ 19:00 / 금 09:00 ~ 18:00 (총 349시간)
🍃 가볍게 시작하는 특별한 만남 Meet Meet
서울의 2030이라면 Meet Meet에서 모임을 만들고 새로운 친구를 만들어보세요!
서비스 https://meet-meet-psi.vercel.app
Github https://github.com/window-ook/meet-meet
| 멤버 | 역할 |
|---|---|
| 본인 | 팀장, 중간/최종 발표, 패턴 설계, 프로젝트 세팅, 배포, 문서화 |
| 팀원 | 발표 자료 작성, 디자인 |
✅ Next.js 15의 클라이언트, 서버별 최적화된 비동기 흐름 제어 패턴 설계
✅ 요구사항 100% 완수
✅ 유저 피드백 반영 및 유저 친화적인 UI/UX 추가
✅ 새로운 기술 스택 실전 적용
⏳ 성능 최적화
다른 사용자들이 게시한 모임을 둘러볼 수 있습니다.
모임은 메인, 서브 카테고리로 분류되어 있습니다. 필터링으로 원하는 조건의 모임을 찾을 수 있습니다.
모임에 누가 참여하고 있는지 확인하고, 다른 사용자가 남긴 리뷰도 확인할 수 있습니다.
원하는 조건의 모임을 생성할 수 있습니다.
모임 찾기, 모임 상세 정보 페이지에서 찜한 모임 목록을 확인할 수 있습니다.
모임 카테고리 필터링으로 원하는 조건의 모임만 확인할 수 있습니다.
모든 모임의 리뷰 히스토리를 확인할 수 있습니다.
날짜, 모임 카테고리 필터링으로 원하는 조건의 모임의 리뷰만 확인할 수 있습니다.
탭을 전환하면서 해당 탭에 따른 내용을 확인할 수 있습니다.
<form> 을 사용하는 곳이 많은 서비스의 효율적인 상태 관리 <input> 유효성 검사 구현주요 기능 구현을 위해 <form> 을 활용하는 컴포넌트가 5개 필요했습니다.
초기에 useState와 핸들러를 개별적으로 생성하여 비효율적인 방식으로 먼저 개발 후 React Hook Form과 Zod를 사용하여 리팩토링을 하였습니다.
대표적인 예시로 회원가입 페이지에서 폼 컴포넌트인 SignUpForm.tsx 의 리팩토링 전과 후 코드를 비교하며 어떤 개선과 결과를 도출했는지 설명해드리겠습니다.
15개의 useState로 만든 상태
const [name, setUsername] = useState('');
const [email, setEmail] = useState('');
const [companyName, setCompanyName] = useState('');
const [password, setPassword] = useState('');
const [passwordCheck, setPasswordCheck] = useState('');
const [emailError, setEmailError] = useState(false);
const [passwordError, setPasswordError] = useState(false);
const [passwordCheckError, setPasswordCheckError] = useState(false);
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isPasswordCheckVisible, setIsPasswordCheckVisible] = useState(false);
const [errorResponseMessage, setErrorResponseMessage] = useState<string | null>(null);
필드마다 유효성 검사를 포함한 핸들러(8개) 구현
const handleUsernameValidation = (e: React.ChangeEvent<HTMLInputElement>) => {
/* 5줄 */
};
const handleEmailValidation = (e: React.ChangeEvent<HTMLInputElement>) => {
/* 5줄 */
};
const handlePasswordValidation = (e: React.ChangeEvent<HTMLInputElement>) => {
/* 5줄 */
};
const handlePasswordCheckValidation = (
e: React.ChangeEvent<HTMLInputElement>,
) => {
/* 5줄 */
};
const handlePasswordVisibility = (e: React.MouseEvent<HTMLButtonElement>) => {
/* 3줄 */
};
const handlePasswordCheckVisibility = (
e: React.MouseEvent<HTMLButtonElement>,
) => {
/* 3줄 */
};
const handleCompanyNameValidation = (
e: React.ChangeEvent<HTMLInputElement>,
) => {
/* 3줄 */
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
/* 25줄 */
};
인라인 유효성 조건문 hell
{
errorResponseMessage ? (
<span className="text-sm text-red-600">{errorResponseMessage}</span>
) : !email ? (
<span className="text-sm text-red-600">이메일을 입력해 주세요.</span>
) : email && emailError ? (
<span className="text-sm text-red-600">올바른 이메일 형식이 아닙니다.</span>
) : email && !emailError ? (
<span className="text-sm text-green-500">✓</span>
) : null;
}
→ 상태 관리, 가독성, 유지보수성 모두 나쁨
인풋 value 및 API param 상태 관리는 useForm 1개로 해결
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting, isSubmitted },
} = useForm<SignupFormSchemaType>({
resolver: zodResolver(signUpFormSchema),
});
난잡한 핸들러를 모두 제거하고 handleSubmit에 필요한 onSubmit 핸들러 1개로 해결
const onSubmit = async (data: SignUpFormSchemaType) => {
try {
await signUp({
email: escapeForXSS(data.email),
password: escapeForXSS(data.password),
name: escapeForXSS(data.name),
companyName: escapeForXSS(data.companyName),
});
} catch (error) {
if (axios.isAxiosError(error)) {
const serverError = error?.response?.data;
if (serverError?.message) setErrorResponseMessage(serverError.message);
else setErrorResponseMessage('회원가입 처리 중 오류가 발생했습니다.');
} else {
setErrorResponseMessage('알 수 없는 오류가 발생했습니다.');
}
}
};
Zod를 활용하여 유효성 검사 수행을 1줄로 깔끔히 정리
// useForm의 리졸버 -> 내부적으로 zod 스키마를 활용하여 유효성 검사 진행
resolver: zodResolver(signUpFormSchema)
// authSchema.ts
import { z } from 'zod';
export const signUpFormSchema = z.object({
name: z.string().min(1, '이름을 입력해 주세요.').max(20, '이름은 20자 이하로 입력해 주세요.'),
email: z.string().min(1, '이메일을 입력해 주세요.').max(30, '이메일은 30자 이하로 입력해 주세요.').email('올바른 이메일 형식이 아닙니다.'),
companyName: z.string().min(1, '크루 이름을 정확하게 입력해 주세요.').max(20, '크루 이름은 20자 이하로 입력해 주세요.'),
password: z.string().min(8, '비밀번호가 8자 이상이 되도록 해주세요.').refine(
(password) => {
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
const hasNumber = /\d/.test(password);
const hasLowercase = /[a-z]/.test(password);
return hasSpecialChar && hasNumber && hasLowercase;
},
{ message: '영문 소문자, 숫자, 특수문자를 포함해야 합니다.' }
),
});
export type SignUpFormSchemaType = z.infer<typeof signUpFormSchema>;
폼에서 재사용될 <InputField> 컴포넌트를 활용하여 props에 따른 조건부 UI 처리
const InputField = React.forwardRef<HTMLInputElement, InputFieldProps>(
({ label, labelSize = 'text-sm', id, type, placeholder, isError, errorResponseMessage, disabled, isPasswordVisible, handlePasswordVisibility, ...props }, ref) => (
<div className="w-full flex flex-col gap-2">
<label htmlFor={id} className={`block ${labelSize} font-bold dark:text-white`}>{label}</label>
<div className='relative'>
<input
ref={ref}
type={label === '비밀번호' ? (isPasswordVisible ? 'text' : 'password') : type}
id={id}
placeholder={placeholder}
aria-invalid={disabled ? (isError ? 'true' : 'false') : undefined}
className={`block w-full p-2.5 rounded-lg bg-gray-50 text-sm border-2 focus:outline-none ${isError || errorResponseMessage ? 'border-red-600' : 'focus:border-main-300'}`}
{...props}
/>
{label === '비밀번호' && (
<button
type="button"
className="absolute right-2.5 top-1/2 -translate-y-1/2 cursor-pointer hover:opacity-60"
onClick={handlePasswordVisibility}
tabIndex={-1}
>
<Image
src={isPasswordVisible ? "https://res.cloudinary.com/dbvzbdffi/image/upload/v1749713866/visibility_on_jh4sec.svg" : "https://res.cloudinary.com/dbvzbdffi/image/upload/v1749713865/visibility_off_qtspno.svg"}
alt="비밀번호 보기 숨김"
width={24}
height={24}
/>
</button>
)}
</div>
{errorResponseMessage ? (
<p className='text-red-600 text-sm'>{errorResponseMessage}</p>
) :
(isError && <p className='text-red-600 text-sm'>{isError}</p>)
}
</div>
)
);
InputField.displayName = 'InputField';
export default InputField;
// SignUpForm, SignInForm
<InputField
label="이름"
id="user-name"
type="text"
placeholder="이름 입력"
{...register('name')}
disabled={isSubmitted}
isError={errors.name?.message}
/>
<InputField
label="이메일"
id="email"
type="email"
placeholder="이메일 입력"
{...register('email')}
disabled={isSubmitted}
isError={errors.email?.message}
errorResponseMessage={errorResponseMessage}
/>
...
🔥 코드 감소
| 코드 라인 | 이전 방식 | 현재 방식 | 개선 효과 |
|---|---|---|---|
| 총 라인 | 172 | 120 | 30% 감소 |
| 상태 | useState 15개 | useForm 1개 | 93% 감소 |
| 핸들러 함수 | 'handle-' 8개 | onSubmit 1개 | 87% 감소 |
| 유효성 검사 | 60 | 외부 스키마 | 100% 감소 (분리) |
🔥 성능 개선
| 성능 지표 | 이전 방식 | 현재 방식 | 개선 효과 |
|---|---|---|---|
| 리렌더링 횟수 | 타이핑 할 때마다 | useForm 내부 최적화 | 80% 감소 |
| 유효성 검사 | onChange가 감지할 때마다 | 필요한 시점에만 | 70% 감소 |
| DOM 조작 | 상태가 변경될 때마다 | 최적화된 조작 | 60% 감소 |
배운 점
아쉬운 점 & 향후 계획
기술적 완성도뿐만 아니라, 실제 사용자의 니즈를 파악하고 반영하는 과정의 중요성을 배웠습니다.
앞으로도 더 나은 DX를 위한 방법론에 대해 고민하며 공부하고, 유저친화적 UX를 제공하는 서비스를 만들어 나갈 것입니다.