NutriNotes는 제로베이스 커넥투 부트캠프에서 팀원들과 진행한 MYCHELIN 프로젝트를 마무리한 이후 혼자서 처음부터 끝까지 책임지고 구현해보고 싶다는 생각이 들어 시작한 프로젝트이다. 로그인, 회원가입부터 간단한 서버 작업까지 직접 해보았다. 처음에는 정말 간단하게 React, TypeScript로 만들어보려고 했는데 기획을 하다 보니 욕심이 생겨서 기능을 더 추가하게 되었다.
프로젝트 소개 : NutriNotes, 간편한 식단 기록 어플리케이션
해당 프로젝트는 간편한 식단 기록 어플리케이션으로, 유저가 다양한 식단을 둘러보고 날짜와 식단을 선택하면 대시보드에 기록할 수 있다.
약 3주간의 기간동안 디자인을 포함해 진행한 작업은 아래와 같다.
- Node.js & Express 서버 초기 세팅
- JWT를 활용한 회원가입, 로그인, 로그아웃 기능 구현
- React Query를 활용한 비동기 처리
- Recoil을 활용한 전역 상태 관리
- 디바운스를 사용한 input validation 구현
사용한 기술 스택은 아래와 같다.
- 번들러 : Vite
- 라이브러리 : React
- 비동기 처리 : React Query
- 전역 상태 관리 : Recoil
- 스타일링 : Styled Component
- 서버 : Express
- 유효성 검사 : Zod, React-hook-form
기술 스택 선정 이유
- React : 방대한 생태계와 빠른 속도.
- Vite: CRA는 번들할 때 webpack을 사용하지만, Vite는 ESbuild를 사용하므로 CRA보다 10-100배 빠르다고 한다. Vite는 개발 중에는 번들링을 하지 않고, 필요한 파일만 컴파일하여 메모리에 저장하므로 로딩도 빠르다는 장점이 있다.
- Recoil :
- atom이라는 상태 단위를 가지고 해당 atom을 구독하는 컴포넌트들만 선택적으로 리렌더링하며, 페이스북에서 만들어 훅을 사용하는 등 리액트와 사용법이 유사하다.
- Store를 다루는 Redux보다 러닝 커브가 낮고, 대규모 어플리케이션이 아니라는 점에서 Redux보다는 Recoil이 적합하다.
- ContextAPI의 경우, 전역적으로 리렌더링되므로 전역적으로 거의 변화가 없는 값에만 사용하는 것이 적절하므로 부적합하다.
- Styled Component : CSS-in-CSS 방식보다 속도는 조금 느릴 수 있지만, CSS-in-JS 방식으로 리액트가 지향하는 컴포넌트에 가장 적합하다고 생각했다.
- React Query : 클라이언트의 전역 상태와 비동기 상태를 분리하고, 필요 시 캐싱 기능을 사용할 수 있다는 장점이 있다.
중간 시연 영상
아직 배포하지 못한 프로젝트라 이력서에 넣어둘 중간 데모 영상을 만들어봤다.
어려웠던 점 & 해결
프로젝트를 진행하는 동안 노션에 계속해서 해야 할 일, 어려웠던 점, 배운 점 등을 기록했다.
https://hexagonal-protocol-50e.notion.site/Daily-Notes-b5849218a04f4e3da0a87905c033698e?pvs=4
그중 가장 기억에 남았던 부분은 크게 1. 로그인/회원가입 구현 2. 모달 상태 관리였다.
1. 로그인 & 회원가입
- 어려웠던 점 :
- JWT와 서버에 대한 이해가 부족했다.
- 폼 유효성 체크 시 발생하는 에러와 username, email 필드에서의 중복확인 시 발생하는 에러를 따로 관리하는 부분이 헷갈렸다.
- 해결 및 배운 점 :
- 서버의 미들웨어, 라우터, JWT에 대한 자료를 읽으면서 큰 틀은 다음과 같음을 이해했다. 유저가 로그인하면, 입력한 정보를 서버가 받아서 토큰의 유효기간이 지정된 jwt 토큰을 만든다. 해당 토큰을 쿠키에 저장하고, 클라이언트에서는 로컬 스토리지에 저장한다. 로그아웃 시에는 쿠키를 지우고, 클라이언트에서는 로컬스토리지에서 삭제한다. 로그인할 때는 쿠키 또는 헤더에서 값을 읽어서 확인하므로 이때 이 값이 없으면 로그인할 수 없다. 이 프로젝트의 경우, Recoil의 atom으로 user 데이터를 전역 상태로 관리하고, effects로 localStorageEffect를 주어 user 데이터가 변경될 때마다 로컬 스토리지에 저장된 값도 업데이트하도록 했다.
- 미들웨어란 웹 요청과 응답에 대한 정보를 사용해 필요한 처리를 순차적으로 진행할 수 있도록 분리된 함수이다. 경로를 명시하면, 해당 경로로 요청이 들어왔을 때 실행하고, 명시하지 않은 경우 URL에 관계없이 매번 실행한다. app.use()는 express 앱에서 항상 실행하는 미들웨어 역할이고, 모르고 작성했던 app.post('/path', 실행할 함수)도 마찬가지로 path로 시작하는 POST 요청에서 실행할 미들웨어를 작성한 것임을 알게 되었다.
- express.Router()를 사용해 라우터, 즉 경로를 분리할 수 있다. router 파일 아래 관련된 http 메서드를 작성하고, app.js에서 app.use()로 다른 경로에 대한 미들웨어를 장착하여 사용할 수 있다. 각 경로에 따른 미들웨어는 해당하는 파일을 require()해서 가져와야 한다.
- 폼 유효성 체크 시 발생하는 에러는 useForm의 각 컨트롤러를 관리하는 useController에서 알 수 있다. 이와 별개로 중복 체크 시 발생하는 에러는 username 필드, email 필드의 중복 여부, 그리고 중복 시 에러 메시지 상태를 따로 관리한다. 에러를 상태로 관리하니 한 번 set 되면 다시 api 요청을 보낼 때까지 해당 에러를 보여주는 문제가 있었고, 이를 해결하기 위해 필드가 수정되었을 때(change 이벤트 발생 시), 상태 값이 있으면 null로 만들어줬다.
const {
field: { value, onChange },
fieldState: { error, invalid, isDirty },
} = useController({
name,
control,
});
// AuthForm.tsx
const [isEmailDuplicated, setIsEmailDuplicated] = useState(false);
const [isUsernameDuplicated, setIsUsernameDuplicated] = useState(false);
const isDuplicated = isEmailDuplicated || isUsernameDuplicated;
return (
//... 생략
<Input
name="email"
type="text"
control={control}
trigger={trigger}
onUpdate={(isDuplicated: boolean) => setIsEmailDuplicated(isDuplicated)}
formType={formType}
/>;
)
// Input.tsx
const Input = ({ name, control, trigger, onUpdate, formType, disabled = false }: InputProps) => {
// 중복체크 에러 메시지
const [duplicatedResult, setDuplicatedResult] = useState<string | null>(null);
const {
field: { value, onChange },
fieldState: { error, invalid, isDirty },
} = useController({
name,
control,
});
// 중복 체크
const checkDuplicated = (field: string) => async () => {
try {
const checkFn = emailRegex.test(field) ? checkEmailDuplicated : checkUsernameDuplicated;
await checkFn(field);
setDuplicatedResult('success');
if (onUpdate) onUpdate(false);
} catch (error) {
if (error instanceof AxiosError) {
setDuplicatedResult(error.response?.data.message);
if (onUpdate) onUpdate(true);
}
}
};
const deboucedPwCheckTrigger = useDebounce(() => trigger('passwordConfirm'), TRIGGER_DEBOUNCE_DELAY_TIME);
const debouncedTrigger = useDebounce(() => trigger(name), TRIGGER_DEBOUNCE_DELAY_TIME);
return (
<>
<Label htmlFor={name} className="sr-only">
{name}
</Label>
<TextInputField>
<TextInput
id={name}
name={name}
value={value || ''}
// 입력할 때마다 유효성 검사
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
if (name === 'password') {
deboucedPwCheckTrigger();
}
// 중복체크한 결과 있을 시 제거
if (duplicatedResult) setDuplicatedResult(null);
debouncedTrigger();
}}
disabled={disabled}
/>
{(formType === 'signup' && name === 'email') || name === 'username' ? (
<>
<CheckButton $isvalid={isDirty && !invalid} onClick={checkDuplicated(value)}>
Check
</CheckButton>
// 중복 체크 에러 표시
{isDirty && !error && (
<Error $isvalid={duplicatedResult === 'success'}>
{duplicatedResult !== null && duplicatedResult === 'success' ? `Confirmed` : duplicatedResult}
</Error>
)}
</>
) : undefined}
// 유효성 검사 에러 표시
{formType === 'signup' && isDirty && error?.message && !disabled && <Error>{error?.message}</Error>}
</TextInputField>
</>
);
};
- 참고한 자료
- https://inpa.tistory.com/entry/EXPRESS-%F0%9F%93%9A-%EB%9D%BC%EC%9A%B0%ED%84%B0-Router
- https://inpa.tistory.com/entry/EXPRESS-%F0%9F%93%9A-%EB%AF%B8%EB%93%A4%EC%9B%A8%EC%96%B4-%F0%9F%92%AF-%EC%9D%B4%ED%95%B4-%EC%A0%95%EB%A6%AC
- https://www.daleseo.com/js-jwt/
- https://medium.com/withj-kr/nodejs-express%EB%A1%9C-%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%84%B1%ED%95%98%EA%B8%B0-1-express-%EA%B8%B0%EB%B3%B8%EA%B8%B0-c0245b4120bc
2. 모달 상태 관리
- 문제점 : 모달을 2개 이상 사용하기 시작하니 상태 관리가 복잡해지는 문제가 생겼다.
- 해결 & 배운 점 :
- 현재는 모달을 2개 사용하고 있어 한 모달 당 { isOpen, content }의 상태를 주어 관리한다. 추후에 useModal 같은 훅으로 모달 상태를 관리할 수 있도록 리팩터링 할 예정이다.
- 상세 또는 추가 모달이 열리는 각 레시피 카드가 독립적으로 모달을 가지고 있지 않고, 상위 컴포넌트 캐러셀이 모달의 상태를 가지고 있도록 한다. 상위에서 상태를 가지고 있고, 하위에 변경할 새로운 상태를 set 할 수 있는 함수를 내려준다. 이때 객체로 상태를 관리하고 있으니 불변성을 지키는 것에 유의하여 스프레드 연산자를 사용한다.
이외 배운 점
- React + Vite + TypeScript에서 환경 변수 사용하기
- .env 파일에 VITE_로 시작하는 변수 선언
- 접근은 import.meta.env.VITE_변수명으로 한다.
- import.meta의 타입을 추론하지 못할 경우 src 폴더 하위에 env.d.ts 파일을 만들어준다.
interface ImportMetaEnv {
readonly VITE_EDAMA_APP_KEY: string;
readonly VITE_EDAMA_APP_ID: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
- Date 타입은 리액트에서 children으로 사용할 수 없다. 따라서 toString()과 같은 메서드로 만들어서 사용
- React Query를 사용해 staleTime을 줘도 페이지를 리로드하면 데이터가 유지되지 않는다. 캐시는 메모리에 저장되기 때문에 브라우저를 리로드하면 날아간다. 따라서 리로드해도 계속 캐시를 유지하고 싶으면 persisQueryClient를 사용할 수 있다. 하지만 실험적인 케이스이므로 생각해보기..
- Styled component를 상속하여 사용했을 때 스타일이 적용되지 않는 문제. styled 함수가 동작하려면 className을 props로 받아야 한다. 따라서 상속받을 컴포넌트에 props를 받아줘야 한다. 이걸 안 받으면 스타일은 만들어지지만 받지 않아서 표시를 못 하는 불상사가 발생한다. 또 우선순위를 높여 적용하려면 && {} 과 같이 작성하면 된다.
https://stackoverflow.com/questions/71005696/styled-component-multiple-override-css-no-effect
추가할 기능
추가해야 할 기능과 프로젝트를 진행하면서 주변 사람들에게 구한 피드백을 반영해 계속해서 추가해 나갈 예정이다.
- 대시보드 날짜마다 영양소 섭취량/섭취율 계산해 표시
- Recipes 페이지 랜덤 무한 스크롤
- Recipes 페이지 검색 기능
- About 페이지
- Dashboard 사진 못 가져오는 문제 해결
- 아침/점심/저녁/간식 구분
- 레시피 상세 모달에서 영양소 섭취량과 섭취율 기준 명시
- 기록 시 g 수 또는 인분을 지정할 수 있도록
- 배포
추후 개선 사항
- 웹 접근성
- 모바일 대응 위한 반응형 웹
- lighthouse를 이용한 성능 개선
느낀 점 & 개선할 사항
프로젝트를 진행하다 보니 깔끔한 UI와 사용성까지 고려해 만들고 싶어졌다. 그러다 보니 시간이 더 걸리게 되었다. 이제부터 다시 손놓았던 알고리즘과 이력서 준비를 하게 될 텐데 혼자 한다고 늘어지지 말고 조금 더 시간을 효율적으로 써야겠다는 생각이 들었다. 라이브러리 하나를 쓸 때도 어떤 것을 선택할지 고민하고, 새로운 것들을 마주해 사용하는 과정이 쉽지 않을 때도 있었지만, 전보다 코드의 구조나 동작 과정을 이해하고 작성하는 것 같아 신기하고 보람 찼다.
매일 회고를 작성하며 어려웠던 것과 배운 점을 정리한 것은 좋았지만, 그럼에도 아직 해결하지 못한 문제들이 남아있어 아쉽다. 앞으로도 계속해서 프로젝트를 진행하면서 해결해 나갈 수 있도록 틈틈이 자료를 더 찾아봐야겠다. 얼른 완성해서 배포하고 싶다..!
관련 링크
https://github.com/minjidev/react-ts-diet-frontend
https://github.com/minjidev/react-ts-diet-backend
'Projects' 카테고리의 다른 글
[NutriNotes] 프론트엔드 프로젝트 성능 개선기 (21) | 2024.02.18 |
---|---|
[NutriNotes] 프론트엔드 개인 프로젝트 최종 회고(프론트&백&DB&AWS배포까지 완!료!) (13) | 2023.11.27 |
[NutriNotes] 시맨틱 태그 적용하기 (0) | 2023.08.22 |