[회고록] 코드잇 스프린트 FE 단기심화 9기

windowook·2025년 6월 25일
post-thumbnail

참여 기간

2025.04.24 ~ 2025.06.24 8주, 월-목 09:00 ~ 19:00 / 금 09:00 ~ 18:00 (총 349시간)

참여 내용

2주 - 기술 스택 강의

  • Tailwind CSS v4
  • CSS Animation (Motion)
  • Jest 프레임워크
  • React 컴포넌트 테스트 (Testing Library, MSW)
  • CI/CD (Git Actions, AWS, Vercel, Sentry)

6주 - 팀 프로젝트

  • 프로젝트 주제 선택: ‘솔리드 Todo List’(투두리스트 플랫폼) or ‘같이 달램’(모임 플랫폼)
  • 기능 및 구현 요구사항 제공
  • 피그마 디자인 예시 제공
  • 백엔드 API Swagger 제공

프로젝트 개요

소개

🍃 가볍게 시작하는 특별한 만남 Meet Meet
서울의 2030이라면 Meet Meet에서 모임을 만들고 새로운 친구를 만들어보세요!

서비스 & Github 링크

서비스 https://meet-meet-psi.vercel.app

Github https://github.com/window-ook/meet-meet

팀 구성 (2인)

멤버역할
본인팀장, 중간/최종 발표, 패턴 설계, 프로젝트 세팅, 배포, 문서화
팀원발표 자료 작성, 디자인

목표

✅ Next.js 15의 클라이언트, 서버별 최적화된 비동기 흐름 제어 패턴 설계
✅ 요구사항 100% 완수
✅ 유저 피드백 반영 및 유저 친화적인 UI/UX 추가
✅ 새로운 기술 스택 실전 적용
⏳ 성능 최적화

기술 스택

프레임워크 & 라이브러리

  • Next.js 15 App Router
  • React 19
  • TypeScript 5
  • axios
  • zod 3

상태 관리

  • Context API
  • Tanstack Query 5
  • React Hook Form 7

CSS 프레임워크

  • Tailwind CSS 4
  • shadcn/ui
  • Lucide React
  • Motion

코드 품질

  • ESLint 9
  • Prettier

테스트

  • Vitest
  • MSW

CI/CD

  • Git Actions
  • Vercel

주요 기능

1. 모임 찾기

다른 사용자들이 게시한 모임을 둘러볼 수 있습니다.
모임은 메인, 서브 카테고리로 분류되어 있습니다. 필터링으로 원하는 조건의 모임을 찾을 수 있습니다.

  • 북적북적: 외향적인 / 활동적인 성격의 모임입니다!
    • 엔터테인먼트 ex) 스포츠 관람, 공연 관람, 축제 참가, 다같이 모여서 맥주 한잔 등
    • 액티비티 ex) 풋살, 농구, 러닝 크루, 실내 사격, 클라이밍 등
  • 도란도란: 내향적인 성격의 모임입니다! ex) 같이 카공하기, 취미 모임, 독서 모임 등

2. 모임 상세 정보 확인

모임에 누가 참여하고 있는지 확인하고, 다른 사용자가 남긴 리뷰도 확인할 수 있습니다.

  • 모임 이름, 모임 장소, 모임 날짜, 모임 게시자(id) 확인, 모임 참여자 정보 및 참여자 수 확인
  • 찜하기, 참여하기, 참여 취소하기, 모임 삭제하기(게시자), 공유하기

3. 모임 만들기

원하는 조건의 모임을 생성할 수 있습니다.

  • 이름, 모집 장소, 모임 타입, 모임 날짜, 모집 마감 날짜, 모집 정원

4. 찜한 모임 확인

모임 찾기, 모임 상세 정보 페이지에서 찜한 모임 목록을 확인할 수 있습니다.

모임 카테고리 필터링으로 원하는 조건의 모임만 확인할 수 있습니다.

5. 모든 리뷰 확인

모든 모임의 리뷰 히스토리를 확인할 수 있습니다.

날짜, 모임 카테고리 필터링으로 원하는 조건의 모임의 리뷰만 확인할 수 있습니다.

6. 마이페이지

탭을 전환하면서 해당 탭에 따른 내용을 확인할 수 있습니다.

  • 참여중인 모임: 현재 로그인한 계정으로 참여한 모임의 목록을 볼 수 있습니다.
    • 모집 마감 여부 / 이용 여부 / 개설 상태를 확인할 수 있습니다.
    • 리뷰를 남기거나 참여를 취소할 수 있습니다.
  • 나의 리뷰: 참여한 모임 중에서 리뷰 작성이 가능한 모임, 이미 작성한 리뷰를 필터링하여 볼 수 있습니다.
  • 내가 만든 모임: 내가 만든 모임 중 모집 취소되지 않은 모임을 확인할 수 있습니다.

프로젝트 기술적 핵심

Async Surf 패턴 설계

→ 클라이언트 / 서버 비동기 흐름 제어 최적화 패턴

👉 자세한 설명은 Async Surf 게시물에서 보기

렌더링 제어

<form> 을 사용하는 곳이 많은 서비스의 효율적인 상태 관리 <input> 유효성 검사 구현

주요 기능 구현을 위해 <form> 을 활용하는 컴포넌트가 5개 필요했습니다.
초기에 useState와 핸들러를 개별적으로 생성하여 비효율적인 방식으로 먼저 개발 후 React Hook FormZod를 사용하여 리팩토링을 하였습니다.

대표적인 예시로 회원가입 페이지에서 폼 컴포넌트인 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}
/>
...

결과

🔥 코드 감소

코드 라인이전 방식현재 방식개선 효과
총 라인17212030% 감소
상태useState 15개useForm 1개93% 감소
핸들러 함수'handle-' 8개onSubmit 1개87% 감소
유효성 검사60외부 스키마100% 감소 (분리)

🔥 성능 개선

성능 지표이전 방식현재 방식개선 효과
리렌더링 횟수타이핑 할 때마다useForm 내부 최적화80% 감소
유효성 검사onChange가 감지할 때마다필요한 시점에만70% 감소
DOM 조작상태가 변경될 때마다최적화된 조작60% 감소

회고

1. 기술적 성과

  • Feature Based Architecture 도입으로 협업 관점에서 SPEEDY & STABLE한 DX 확보
  • Async Surf 패턴을 창조하여 엄격하고 체계적인 비동기 흐름 관리 및 제어 구축
  • React Hook FormZod를 활용한 효율적인 폼 컴포넌트 렌더링 제어

2. 프로덕트 관점 개발

  • 유저의 시각적 관심을 유발하는 요소를 프로덕트 전반에 주입
  • 스프린터들의 피드백을 95% 이상 반영한 UI/UX 개선
  • 중간 평가에서 평균 4.17점이라는 높은 점수 달성 (5점 만점)
  • 문의를 남길 수 있도록 구글폼 링크도 포함하여 소통 채널 개설

3. 배운 점과 아쉬운 점

배운 점

  • 시간 효율적 & 비용 효율적인 개발에 필요한 아키텍쳐의 중요성을 알 수 있었습니다.
  • 사용자 피드백을 통한 실질적인 서비스 개선 경험이라는 값진 경험을 했습니다.
  • 새로운 기술 스택을 활용하면서 기존에 사용했던 개발 방식보다 더 좋은 성과를 낼 수 있었습니다.
  • 스크럼과 문서화 기반의 협업 관리 방식이 팀의 코드 일관성과 개발 속도에 기여함을 알 수 있었습니다.

아쉬운 점 & 향후 계획

  • 고정된 백엔드 API를 기반으로 진행하는 방식이라 한계로 인해 반영하지 못한 유저 피드백이 아쉽습니다.
  • 지금 코드보다 더 좋은 코드 구조를 생각하지 못해서 아쉽습니다.
  • LCP 점수를 감소시킨 CSS CRP 차단 문제와 폴리필 이슈를 해결하지 못해사 아쉽습니다.

4. 총평

기술적 완성도뿐만 아니라, 실제 사용자의 니즈를 파악하고 반영하는 과정의 중요성을 배웠습니다.
앞으로도 더 나은 DX를 위한 방법론에 대해 고민하며 공부하고, 유저친화적 UX를 제공하는 서비스를 만들어 나갈 것입니다.

profile
안녕하세요

0개의 댓글