https://github.com/codestates-seb/seb44_main_012
코드스테이츠 프론트엔드 캠프의 메인 프로젝트로 PliP 프로젝트는 근 한달동안 진행된 프로젝트입니다.
연습을 하는 느낌이었던 프리프로젝트에 비해 좀 더 정성을 들여 만들었다는 느낌이 들어서
애정이 가는 프로젝트입니다.
저는 프론트엔드 측의 보안, 에러처리 전반 그리고 로그인, 회원가입과 같이 유저와 관련된 기능 개발을 했고
그 외에는 프론트엔드 배포, 디자인 시스템 구축을 맡았습니다.
https://www.youtube.com/watch?v=6rWULPkc6fM
기술 발표 영상은 위 유튜브에서 확인할 수 있습니다.
제 발표부분은 04:35초부터 06:18초 까지에요!
하면서도 많이 성장했다고 느껴지는 포인트들이 있었는데
아무래도 기능을 자연스럽게 구현하려고 하다보니
공부를 하면서 간단하게 동작만 하는 코드를 작성할 때 보다 더 디테일한 부분들이 필요했고
디테일을 채우다보니 그냥 혼자 연습 할 때보다 더 풍부한 경험을 한 것 같습니다.
어렴풋이 사용하고 있던 리액트 쿼리가 제공하는 다양한 기능들에 익숙해질 수 있었고
zod 라이브러리에서는 조금 흥미로운 버그를 발견하기도 했었는데요
이번 글에서는 프로젝트를 하며 만났던 흥미로운 상황이나
제가 이야기하고 싶었던 내용들을 가볍게 풀어나가볼까 합니다.
어찌보면 문제를 해결해나간 과정이라고도 볼 수 있을 것 같아요
😑 zod의 superRefine 메서드 이슈
프로젝트 마감일 하루 전 같은 팀원 분이 zod 라이브러리의 동작과 관련하여 도움을 요청하셨습니다.
상황이 아주 기묘했는데요
PliP 프로젝트는 zod, reacthookform을 resolver 라이브러리를 통해 통합시킨 형태로
인풋 유효성검사를 수행하고 있습니다.
export const signoutSchema = z
.object({
signout: z.string(),
})
.superRefine(({ signout }, ctx) => {
if (signout !== SIGNOUT_VALIDATION_STRING) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '문장을 똑같이 작성해주세요',
path: ['signout'],
});
}
});
이러한 형태로 유효성 검증을 수행할 수 있는데 기능을 구현하다보면 이런 경우가 발생합니다.
비밀번호를 작성하고 비밀번호확인란을 작성할 때 비밀번호확인란과 비밀번호란에 값이 다르다면
오류를 발생시켜줘야하는 경우가 빈번히 있습니다.
바로 이러한 경우 superRefine 메서드를 이용하여 zod에서 선언한 스키마프로퍼티들에게 접근할 수 있는데요
발생했던 상황은 superRefine을 제외한 부분은 문제없이 zod가 동작하는데
superRefine 부분만 동작이 되지 않는 이슈였습니다.
그런데 제가 작성한 스키마 코드를 그대로 복사해서 사용하고 있는데
어떻게 팀원 분이 작성한 컴포넌트에서만 동작하지 않을 수 있을까요?
이렇게 비밀번호가 서로 일치하지않을때에는 superRefine에 지정해둔 에러메시지가 표시되어야합니다.
하지만 다른 에러메시지는 잘 나오는 반면 superRefine만 그렇지 않은 상황이었죠!
제 코드와의 차이점을 생각해보았을 때 크게 두가지를 볼 수 있었는데요
1. react-hook-form의 default value를 사용하느냐, 그렇지 않느냐
2. input을 동적으로 렌더링하고 있느냐, 그렇지 않느냐
const editForm = useForm<EditProfileTypes>({
mode: 'all',
defaultValues: {
nickname: data?.data.data.nickname,
password: '',
checkpassword: '',
},
resolver: zodResolver(profileSchema),
});
const [isEditMode, setIsEditMode] = useState(false);
인것으로 보였습니다.
이 두 부분 중 어떤 것이 문제인것으로 보였어요
isEditMode가 true일 때만 인풋을 렌더링하는 방식을 택한 것과 defaultValues를 사용하는 것
그 이외에는 이렇다할 차이점이 없었습니다.
다만 defaultValues를 사용한다고해서 superRefine이 동작하지 않을 이유가 있을까?
라고 생각하면 그건 가능성이 미미할 것으로 보였습니다. 동적 렌더가 가장 의심스럽네요
하지만 혹시 모르는 것이니 바닥부터 다시 에러를 재현해보면 좀 더 명확하게
문제되는 지점을 찾을 수 있을 것이라 기대했습니다.
그래서 저는 새로운 컴포넌트를 만들고 처음부터 코드를 쌓아나가기 시작했어요
그리고 역시 동적으로 렌더하는 부분을 추가하자 잘동작하던 superRefine이 동작하지 않기 시작합니다.
이제 원인을 생각해볼 시간입니다.
라이브러리 코드가 어떻게 구성되어 있을지는 잘 모르겠지만 제 생각에 가장 가능성이 높아보이는 시나리오는
동적으로 렌더링을 하면서 참조가 꼬이는 경우가 발생할 수 있을 듯 합니다.
참조가 꼬이는게 문제일지 아닐지는 정확하지 않지만
인풋의 렌더링을 하위컴포넌트에게 위임하고 zod가 담긴 useForm return 객체를 prop으로 넘겨주면
문제가 해결될 수 있지 않을까? 라는 생각이 들었습니다.
{!isEditMode && (
<Button
type="button"
variant="optional"
hovercolor={'default'}
className="absolute -top-10 right-0 px-3 py-2 text-xs font-medium hover:bg-zinc-200"
onClick={setEditMode}
>
수정
<BiEditAlt size={15} />
</Button>
)}
기존 컴포넌트는 이와 같이 isEditMode의 상태에 따라 동적으로 요소를 렌더링하는 일 역시
하나의 컴포넌트에서 수행하고 있었는데 렌더링을 다른 컴포넌트로 분리하면 어떨까했어요
const MyPageEditInput = ({ returnForm }: MyPageEditInputProps) => {
return (
<>
<div className="flex justify-end">
<Paragraph variant={'red'} size="xs" className=" mt-1">
<span>{returnForm.formState.errors.password?.message}</span>
</Paragraph>
</div>
<div className="flex flex-col">
<div className="flex flex-col md:flex-row md:items-center md:gap-4">
<span className="w-20 text-sm text-[#4568DC] md:text-end">비밀번호 확인</span>
<Input
type={'password'}
placeholder="다시 한번 비밀번호를 입력해 주세요"
className="flex-grow py-2"
{...returnForm.register('checkpassword', {
onChange: () => console.log(returnForm.formState.errors),
})}
/>
</div>
<div className="flex justify-end">
<Paragraph variant={'red'} size="xs" className=" mt-1">
<p>{returnForm.formState.errors.checkpassword?.message}</p>
</Paragraph>
</div>
</div>
</>
);
};
그래서 이렇게 인풋만을 렌더링하는 컴포넌트를 작성해주었습니다.
이제 상위컴포넌트는 코드가 매우 간결해지겠죠?
{isEditMode && <MyPageEditInput returnForm={editForm} />}
useForm의 반환객체만 프랍으로 넘겨주는 형태로 구현하였습니다.
그런데 useForm 반환 객체의 타입을 어떻게 지정해줘야할까?라는 생각이 듭니다.
interface MyPageEditInputProps {
returnForm: UseFormReturn<EditProfileTypes, any, undefined>;
}
결론적으론 react-hook-form 라이브러리에서 제공해주는
리턴타입이 존재하니 이것을 사용해주면 되겠습니다. 다만 제네릭도 정확히 전달해주셔야해요
이제 이렇게 렌더링을 다른 컴포넌트에게 위임하고나니
정상적으로 superRefine이 동작하는 것을 확인할 수 있었습니다.
라이브러리에서 버그를 발견한 것은 이번이 처음이었는데
"내가 마법처럼 여기고 쓰고 있던 라이브러리도 결국은 다 사람이 짰던거구나.."
와 같은 생각이 들어 재밌는 기억으로 남았어요
🙄 디자인 시스템 구축하기
이번 프로젝트에서는 제 적극적인 주장으로 tailwindcss를 채택했습니다.
그런데 tailwindcss는 익숙한 경우 작업속도를 빠르게 향상시켜주긴 하지만
아무래도 tailwind에 익숙한 사람이 아니라면 오히려 개발생산성이 저해되는 결과가 있곤한데요
반면 mui와 같이 기본적으로 완성된 컴포넌트들을 조합하여 사용하면 생산성을 크게 상승시킬 수 있죠
직접 마크업을 하지 않고 스타일이 완성된 컴포넌트를 그저 불러와서 사용하기만 하면 되니까요
또 styledComponents, emotion과 같은 css-in-js 방식을 채택한 라이브러리,프레임워크들을 이용하면
변수에 따라 보여줄 스타일을 손쉽게 변경할 수 있어 재사용가능한 컴포넌트를 만들기 용이해요
이런 css-in-js 진영의 라이브러리들과는 달리
저는 tailwindcss가 재사용가능한 컴포넌트를 생산하는 데에 강점이 있다기보다는
빠르게 마크업을 할 수 있고 빌드타임에 모든 css가 생성된다는 점이 강점이라고 생각하는데요
국내에서 사용하시는 분은 많지 않아 보이긴합니다만..
(구글에 class variance authority를 검색하면 수많은 영어 틈 사이에서 제 블로그만 덩그러니 있습니다.)
cva는 tailwindcss에서도 손쉽게 재사용가능한 컴포넌트를 생산하는 것을 도와주는 라이브러리입니다.
위 사진과 같이 디자인 시안을 기반으로 열심히 정의해둔 atom 컴포넌트는
이후에 페이지를 빠르게 그려야할 때 그 존재감을 드러내는데요
<Button variant={'primary'} onClick={previous} className=" h-[24px] w-[24px] p-0">
위와 같은 형태로 커스터마이징에 자유로우면서도 통일성 있는 디자인의 버튼을
일일히 예전에 쳤던 css를 또 치는 것에서 벗어나 간단하게 작성할 수 있어집니다.
이러한 형태로 정의해둔 많은 아톰컴포넌트들을 적절히 활용하는 것을 통해
PliP팀은 페이지 마크업,스타일링에 들이는 시간을 최소화할 수 있었고
그만큼 로직 작성과 코드에 대한 고민을 할 수 있는 시간을 확보할 수 있었습니다.
🤗프로젝트 개발을 하면서 독서를 하는 사람이 있다?
프로젝트를 진행하면서 코드에 대한 고민이 깊어졌었습니다.
더 좋은 코드를 작성하고 싶은 마음은 있는데 지금 수행하고 있는 방법의 다음 단계를 모르는 기분이 들었어요
그래서 혹시 이정도 시점이라면 미뤄두었던 클린코드, 리팩터링을 읽어볼만한 때가 된 게 아닐까?
라는 생각이 들어 구매를 했고 결론적으로는 후회없는 선택이었던 것 같습니다.
물론 아직 두 책 모두 전부 읽지는 못했지만 개발을 하면서 아쉬웠던 부분들
이런 상황에선 어떻게 하면 좋을까? 라는 생각이 들었던 부분들이 어느정도 해소될 수 있었던 것 같아요
열심히 프로젝트를 한 다음.. 자기전에 조금씩 읽다가 졸려지면 잤는데
덕분에 책의 모든 내용이 세세하게 기억나지는 않는것같아서 다 읽어보면 다시 읽어볼 생각입니다.
여담으로 원래 열렬한 종이책 신봉자로서 e-book을 구매한게 저때가 처음이었는데
막상 e-book을 구매해보니까 e-book 신봉자가 되버렸습니다.
종이책보다 저렴하고 무거운 책을 들고다니는 대신 노트북하나만 들고다니면 되니
몸도 훨씬 편하더라구요
😶애니메이션은 어떻게 처리할건데?
tailwindcss를 채택하면서 또 고민이 되었던 부분은 애니메이션입니다.
tailwindcss는 애니메이션을 직접 적용하는게 굉장히 불편한 편에 속한다고 생각해요
클래스명에 엄청나게 긴 애니메이션 클래스들을 넣어놓다보면 가독성이 심각하게 망가지곤하죠
그렇다고해서 tailwindconfig.js에 미리 자주사용하는 애니메이션을 써두는것도 마음에 들지 않았습니다.
그렇게하는 경우엔 애니메이션을 많이 사용하는 만큼 tailwindconfig.js파일의 가독성도 해쳐질테니까요
그렇다고 간단한 애니메이션들을 위해 라이브러리를 쓰기에는
조금 의미없이 종속성만 늘리는 결과를 불러올 것 같아서 그냥 css를 이용해 관리하는 전략을 선택했고
애니메이션 라이브러리를 사용하기보다는 @keyframes 를 이용해 직접 만들어 사용하는 방식을 택했습니다.
.landing {
animation: landing 10s infinite;
animation-direction: alternate;
}
@keyframes landing {
25% {
background-image: url('/src/assets/imgs/gyeonggi.webp');
}
50% {
background-image: url('/src/assets/imgs/광안대교.webp');
}
75% {
background-image: url('/src/assets/imgs/제주도.webp');
}
100% {
background-image: url('/src/assets/imgs/서울.webp');
}
}
저희 메인페이지의 여러가지 사진이 자연스럽게 보이는 애니메이션 역시 이런식으로 작성해둔 것이에요!
😐 밸리데이션 지옥... 이메일 인증을 하라구요?
그렇게 어려운 로직이 들어가있는건 아니지만
요구사항이 많다보니 코드가 많이 지저분해졌던 페이지입니다.
이메일 인증 요청을 누르면 해당 이메일로 밸리데이션 코드가 전송되는 형태로 구성되어있는데요
사실 이 인증요청이라는게 한번에 여러개가 전송되면
이메일을 받는 사용자 입장에서 굉장히 짜증이 납니다.
따라서 이걸 처리해줄 로직도 넣어줄거고 인증을받지못하면 서브밋도 못하게해줘야할거고
이래저래 생각할 게 참 많았습니다.
1. post 요청만 3개가 필요(사용자 이메일 전송할 post, 인증코드 전송할 post , 회원가입 post)
2. 각각 인풋들에 대한 밸리데이션 및 적절한 에러메시지 띄워주기, toast 팝업 띄워주기
3. 네트워크요청에 대한 에러핸들링 및 toast 팝업 띄워주기
4. 이메일로 인증코드 받는 요청은 사용자가 여러번 눌러도 한번만 전송
5. 서버로부터 닉네임 중복 여부 확인 후 핸들링하기
간단히 생각나는것만으로도 충분히 어지러운 것 같아요.
이 부분을 구현하면서는 조금 마음의 타협을 하고 코드를 작성했던 기억이 있네요
그래도 결론적으로 잘 동작하는 코드를 작성해서 다행이라고 생각합니다.
한번만 인증코드를 요청하는 부분이 조금 까다로운 면이 있었는데요
바로 사용자가 이메일을 잘못 입력하고 인증 코드를 요청했을때를 고려하는 것이었습니다.
그냥 간단하게 이메일 요청 버튼을 누르면 더이상 이메일 요청을 할 수 없게 만들어버리는 경우에는
사용자 경험이 심각하게 저해될 위험이 있으니
사용자가 잘못 입력한 것을 깨닫고 이메일을 새로 작성하면 인증 코드 요청도 다시 할 수 있어야했던거죠
두가지 정도의 방법이 생각나는데
1. 이메일 요청 버튼을 클릭했을때 이메일 인풋의 상태를 캡처링해두고 비교하는데 사용하기
2. 이메일 요청 버튼을 클릭하면 특정한 상태를 true로 onChange가 발생하면 특정한 상태를 false로 바꿔 핸들링
결론적으로는 2번 방식을 채택했는데 간단하게 구현할 수 있어서 택했습니다.
다만 1번 방식이 좀 더 우아해 보이긴 하는 것 같아요
😏마치며
사실 이 회고에서 적은 내용 뿐만 아니라 더 많은 내용들이 있긴 하지만..
이미 충분히 많은 분량이라는 생각이 들어서 이만 줄이려고해요
프로젝트를 하는 동안에는 개인 공부 시간이 많이 줄어들어 아쉽다는 느낌도 있고
블로깅에 투자할 시간도 많이 줄어드는 게 느껴지긴 하지만
그만큼 내가 지금까지 공부해왔던 것들을 잘 활용할 수 있는지 테스트하고
빠르게 학습해서 새로운 것들을 적용해볼 수 있는 시간이 되다보니 끝나고나면 뿌듯함이 큰 것 같습니다.
읽어주셔서 감사합니다.
'프로젝트 회고' 카테고리의 다른 글
테오의 스프린트 17기 프론트엔드 참여 후기 (2) | 2024.04.09 |
---|---|
[ShoppingApp] 프로젝트 회고 (3) | 2023.05.19 |