NutriNotes의 목표했던 기능들을 모두 마무리하고 회고를 쓰려고 노션에 항목들을 정리해 두었지만, 쓰고 싶은 내용이 너무 많아 엄두가 나지 않았다. 프로젝트를 진행하며 마주했던 문제와 해결책들을 기록하며, 다시 한번 복기해 보는 시간을 가지기로 했다. 라이브러리 선택 이유 및 이전에 구현된 기능은 중간 회고에 작성되어 있다. 중간 회고 이후로 약 3개월이 지났는데, 약 2개월은 프로젝트를 더 발전시켰고, 그 이후에는 가끔 필요한 부분만 수정하였다. 밤새 끙끙대다 배포를 성공해서 내가 만든 사이트에 접속되었을 때의 기쁨을 잊을 수 없다..>! (내용이 길어져서 빈 부분은 차차 채워나갈 예정입니다..!)
프로젝트 소개(+배포 링크): NutriNotes
NutriNotes는 건강에 관심을 기울이는 현대인을 위한 식단 관리 서비스이다. 식단을 검색, 탐색 및 기록할 수 있고, 일자별 식단의 영양 성분을 차트로 보여준다.
중간 회고 이후 약 2달간 진행한 작업은 아래와 같다.
- 번들 사이즈 감소 및 웹 페이지 성능 최적화를 통한 초기 로딩 시간 32% 단축
- 웹 접근성과 웹 표준을 고려한 마크업으로 LightHouse 지표 19.76% 향상
- Suspense, ErrorBoundary, Skeleton을 사용한 Concurrent UI pattern 적용
- Chart.js를 활용한 일자별 영양 성분 시각화
- Mongoose를 활용한 MongoDB CRUD API 구현
- AWS EC2, AWS S3를 통한 프론트엔드, 백엔드 배포
- ELB, CloudFront를 통한 프론트엔드, 백엔드 https 적용
- 일부(랜딩/로그인 및 회원가입) 페이지 반응형 적용
- 검색 및 무한 스크롤 구현
- 레시피 카드 저장 및 취소 구현
배포 링크
라이브러리 선택 이유
이전 회고에서 작성한 React & TypeScript, Vite 등을 제외하고, 새롭게 도입한 라이브러리에 대해 알아보자.
- 유효성 검증: Zod
- TypeScript와 호환성이 좋은 Zod를 선택하였다.
- Ajv는 JSON 스키마 유효성 검증을 위한 라이브러리로, Node.js 서버에서 주로 사용된다.
- Joi도 서버 사이드에서 사용하기 좋은 다양한 유효성 검증 방법들을 제공하여, Node.js 서버에서 주로 사용된다.
- Yup은 React와의 호환성이 좋아 주로 클라이언트 사이드에서 React 및 React Native 폼 유효성 검증 시 유저에게 즉각적 피드백을 주어야 하는 경우 사용하기 좋다.
- Zod는 TypeScript를 타겟으로 만들어진 스키마 선언 및 유효성 검증 라이브러리로, 타 라이브러리들에 비해 모든 TypeScript의 기능을 지원하고, 타입 안전성을 보장한다.
- 런타임 유효성 검증 뿐만 아니라 infer를 이용해 타입 추론 및 타입 생성도 가능하다.
- 따라서, NutriNotes는 TypeScript를 주로 사용하였기 때문에 Zod를 선택했다.
- TypeScript와 호환성이 좋은 Zod를 선택하였다.
- DB: MongoDB와 Mongoose
- JavaScript 언어를 사용하여 전체 애플리케이션을 개발할 수 있는 MERN 스택(MongoDB, Express.js, React, Node.js)을 선택하여 전체 개발 프로세스를 효과적으로 간소화하였다.
- mongoose를 사용해 JavaScript로 DB를 다루면서 스키마를 이용해 정형화된 데이터를 보장하도록 했다.
- 배포: AWS S3(프론트엔드), AWS EC2(백엔드)
- 러닝커브가 있다고 하지만 이전에 사용했던 Vercel 이외 현업에서 사용되는 다른 배포 방법을 시도해 보았다.
- AWS의 방대한 생태계를 활용해 CloudFront와 같은 다른 서비스와 통합해 사용하기 위해 선택하였다.
- 차트: chart.js
- 차트를 그리는 데 최적화되어있고, 흔히 사용되는 차트 템플릿을 가져다 빠르게 사용할 수 있다.
- 프로젝트에서는 단순히 영양성분을 시각적으로 보여주는 바 차트만 필요했기 때문에 복잡한 커스터마이제이션이 필요하지 않았다.
- npm trends에서도 chart.js가 최근 가장 활발하게 사용되고 있는 것을 확인했다.
- d3는 DOM를 직접 조작해 세세한 커스터마이징이 가능하기 때문에 러닝커브가 높고, 복잡한 차트에 유용하다.
문제, 고민, 그리고 해결
#1 모달의 발전 - 공통 컴포넌트가 된 모달
#2.1 성능 최적화 - 번들 사이즈 감소와 코드 스플리팅
어플리케이션이 커지면서 초기 로딩 속도도 느려지고, 빌드를 했는데 Some chunks are larger than 500KiB warning이 발생했다. 이를 해결하기 위해 가능한 트리쉐이킹을 통해 사용하지 않는 코드를 제거하고, 코드 분할(Code Splitting)을 통해 router 기반으로 지연 로딩을 했다.
트리 쉐이킹(Tree Shaking)이란?
트리 쉐이킹은 사용하지 않는 코드를 제거하여 JavaScript 번들 사이즈를 줄이는 최적화 기법이다. 이를 통해 어플리케이션의 로드 속도와 성능이 향상된다.
vite-bundler-analyzer를 사용해 번들 구성을 확인할 수 있다. 사용하지 않는 패키지를 정리하고, 큰 부분을 차지하던 lodash의 경우 named import하면 트리쉐이킹이 안되기 때문에 default import하여 번들 사이즈를 줄였다. 이렇게 필요한 메서드만 가져와 사용하는 것을 cherry picking이라고 하고, 이 방법 말고도 lodash-es를 사용할 수도 있다. lodash-es는 es-module로 export되어 트리 쉐이킹이 가능하다.
코드 분할(Code Splitting)이란?
SPA(Single Page Application)는 초기 실행 시 번들링을 통해 한 번에 필요한 모든 자원을 로드하는데, 애플리케이션이 커지면 이 번들도 커지게 된다. 이로 인해 로드 시간이 길어지는 문제가 생길 수 있다. 해결책은 번들을 "나누는" 것이다. 런타임에 여러 번들을 동적으로 만들어 초기 로딩 시 모든 자원을 로드하지 않고, 필요한 시점에 로드하는 최적화 기법이다.
아래의 loadLazy 함수를 통해 각 router에서 컴포넌트를 지연로드 하도록 했다.
import { Suspense, lazy } from 'react';
import { Loader } from '../components/index';
const loadLazy = (element: string) => {
const LazyElement = lazy(() => import(`../pages/${element}.tsx`));
return (
<Suspense fallback={<Loader />}>
<LazyElement />
</Suspense>
);
};
export default loadLazy;
문제는 이렇게 했는데도, 해당 warning이 사라지지 않았다는 것.. 그래서 좀 더 찾아보니 node_modules 하위 코드를 파일별로 쪼개는 방법이 있었다. rollup의 옵션을 사용해 적접 청크를 나눌 기준을 명시하는 함수 manualChunk가 있는데, 이를 활용하여 node_modules/ 하위 라이브러리 파일들을 나누는 방법이었다.
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
const PORT = 8000;
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
return id.toString().split('node_modules/')[1].split('/')[0].toString();
}
},
},
},
},
});
현재는 이렇게 되어 있지만, 좀 더 생각해봐야할 문제들이 있다.
하나의 600KB 청크 vs. 20KB 청크 여러 개, 뭐가 더 나을까요?
1. 하나의 600KB 청크
[장점] HTTP 요청을 적게하고, 초기 로딩 사이즈가 크기 때문에 이후에 로딩 속도가 빠를 수 있다.
[단점] 초기 로딩 속도가 오래 걸릴 수 있다.
2. 20KB 청크 여러 개
[장점] 초기 로딩 속도가 빠르고, 유저가 빠르게 컨텐츠를 볼 수 있다.
[단점] HTTP 요청이 많아 오버헤드가 발생할 수 있다.
단순히 warning을 없애는 게 문제가 아니라, 고려해야 할 사항들이 많았다.
1. 초기 HTML이나 CSS는 지연이 발생하지 않도록 하나의 큰 파일로 전달하는 것이 좋다.
2. 네트워크가 느린 경우, 더 작은 청크가 나을 수 있다.
3. 작은 청크를 사용하는 경우, 캐싱에 유리할 수 있다.
4. 이미지와 같이 점진적으로 로드할 수 있는 요소의 경우, 작은 청크가 성능 면에서 이점이 있을 수 있다.
따라서, 유저의 네트워크 환경, 컨텐츠 타입(HTML/CSS, 이미지, 대량의 데이터 등), 캐싱 전략에 따라 추후 번들을 나누는 기준을 정해볼 수 있을 것 같다.
#2.2 성능 최적화 - 이미지 레이지 로드
프론트에서 가장 최적화에 신경을 써야하는 부분은 이미지라고 한다. imagemin 라이브러리나 imagemin-pngquant 플러그인을 사용해 프로젝트에 있는 이미지 자체를 최적화할 수 있다. 하지만 NutriNotes는 외부 api(edamam)에서 음식 이미지를 받아오고 있고, 전체 데이터를 한 번에 받아올 수 있는 api도 없었다. 따라서 받아온 이미지 데이터를 로드할 때의 성능을 개선할 수 있는 방법을 찾는 것이 좋다고 생각했고, 이 부분은 이미지 레이지 로드를 통해 해결했다.
먼저, useObserver라는 커스텀 훅에서 옵저버를 생성하고 뷰포트에 이미지 요소가 들어오면(isIntersecting)하면 이미지 기존 src를 dataset.src로 변경하도록 했다. 그리고 레이지 로딩할 이미지 컴포넌트에서는 상위에서 내려 받은 옵저버로 해당 컴포넌트를 observe한다. 초기 src에는 저해상도의 사진을 dataset.src에는 고해상도의 사진을 저장해두어 변경한다.
// useObserver.ts
// LazyImg의 상위 컴포넌트에서 옵저버 만들어서 내려준다.
import { useState, useEffect } from 'react';
import { Recipe, SavedRecipesByDate } from '../types/types';
const useObserver = (data: Recipe[] | SavedRecipesByDate | undefined) => {
const [observer, setObserver] = useState<IntersectionObserver | null>(null);
useEffect(() => {
if (data) {
const observerInst = new IntersectionObserver(
entries =>
entries.forEach(entry => {
const { isIntersecting, target } = entry;
if (isIntersecting && target instanceof HTMLImageElement) {
target.src = target.dataset.src || '';
}
}),
{ rootMargin: '0px 0px 10px 0px' }
);
setObserver(observerInst);
}
}, [data]);
return observer;
};
export default useObserver;
// LazyImg.tsx
import React, { useRef, useEffect, SyntheticEvent } from 'react';
import styled from 'styled-components';
interface LazyImgProps {
imgSrc: {
default: string; // 저화질
dataSrc: string; // 고화질
};
image: string; // 기본
alt: string;
handleImgClick: (e: React.MouseEvent) => void;
observer: IntersectionObserver | null;
}
function LazyImg({ imgSrc, image, alt, handleImgClick, observer }: LazyImgProps) {
const observerRef = useRef(null);
useEffect(() => {
if (observer && observerRef.current) {
observer && observer.observe(observerRef.current);
}
return () => {
if (observer && observerRef.current) {
observer.unobserve(observerRef.current);
}
};
}, [observer]);
return (
<RecipeImg
ref={observerRef}
src={imgSrc.default || '/images/placeholder.png'}
alt={alt}
onClick={handleImgClick}
data-src={imgSrc.dataSrc || image}
onError={(e: SyntheticEvent<HTMLImageElement, Event>) => {
e.currentTarget.src = '/images/no_img.svg';
}}
/>
);
}
const RecipeImg = styled.img`
width: 15rem;
height: 15rem;
border-top-right-radius: 1.2rem;
border-top-left-radius: 1.2rem;
object-fit: cover;
cursor: pointer;
`;
export default LazyImg;
#3 레시피 카드 저장과 삭제 - 클라이언트와 서버 사이드 상태 동기화
레시피 저장 및 삭제와 같은 데이터 mutation시 유의할 점은 서버에 보낸 데이터가 클라이언트에도 반영되도록 해야한다는 점이다. 서버 데이터는 snapshot이기 때문에 유저가 보는 화면에 자동으로 반영되지 않는다.
1. 처음에는 유저 정보에 유저가 저장한 레시피를 저장했다. 유저가 레시피를 저장하면, 새로운 레시피 정보를 Recoil로 관리하는 전역 상태인 UserState를 업데이트하고, 서버에 새로운 레시피 정보를 보내 서버 상태를 업데이트한다. 이때 문제는 유저 업데이트 로직이 복잡했다는 점이다. DB도 따로 사용하고 있지 않았기 때문에 클라이언트에서 id를 생성해 저장했다.
// AddRecipeModal.tsx
//...
const handleCloseButtonClick = (e: React.MouseEvent) => {
if (onAddModalClick) onAddModalClick({ isOpen: false });
};
const generateNewRecipeId = () => {
if (user) return Math.max(...user.savedRecipes.map(({ recipeId }) => recipeId), 0) + 1;
};
const handleConfirmButtonClick = async (e: React.MouseEvent) => {
try {
const newlySavedRecipe = { user: userData, recipe, date: selected ? selected : new Date(), savedAt: Date.now() };
await postSavedRecipe(newlySavedRecipe);
if (user) {
setUser({
...user,
...{ ...newlySavedRecipe, recipeId: generateNewRecipeId() },
});
}
handleCloseButtonClick(e);
} catch (e) {
console.error(e);
}
};
return (
<Container>
{/* ... */}
<ConfirmButton onClick={handleConfirmButtonClick}>Confirm</ConfirmButton>
</Container>
);
};
// ...
export default AddRecipeModal;
2. 복잡한 로직으로 전역 상태 관리하지 않고, 대신 React Query로 클라이언트 상태를 관리한다. 유저가 저장한 레시피 데이터는 서버에도 보내서 서버 상태를 관리한다. savedRecipe 컬렉션은 ref 속성에 따라 user 필드에 user._id를 기준으로 유저의 정보를 담고 있다. 따라서 필요한 user._id와 저장하는 레시피, 저장한 시간을 서버로 보내고, 클라이언트 쿼리를 invalidate하여 서버 상태와 동기화한다. 훨씬 로직도 간단하고, 직관적이어서 이해하기도 쉽다.
// AddRecipeModal.tsx
// ...
const handleConfirmButtonClick = async (e: React.MouseEvent) => {
if (!user) return;
try {
const newlySavedRecipe = {
userId: user._id,
recipe,
savedAt: selected ?? new Date(),
};
await postSavedRecipe(newlySavedRecipe);
queryClient.invalidateQueries([...userRecipesKey, user._id]);
close();
} catch (e) {
console.error(e);
}
};
// useUserRecipes.ts
// ...
const useUserRecipes = (userId: string | undefined) => {
if (!userId) return;
return useQuery<UserRecipe[], AxiosError>([...userRecipesKey, userId], getUserRecipes(userId), {
onError: (error: AxiosError) => console.error(error),
});
};
export default useUserRecipes;
// schemas/savedRecipes.js
const mongoose = require('mongoose');
const { recipeSchema } = require('./recipes');
const { Schema } = mongoose;
const {
Types: { ObjectId },
} = Schema;
const savedRecipesSchema = new Schema({
userId: {
type: ObjectId,
ref: 'User',
required: true,
},
recipe: {
type: recipeSchema,
required: true,
},
savedAt: {
type: Date,
default: Date.now,
},
});
module.exports = mongoose.model('savedRecipes', savedRecipesSchema);
// routes/users.js
router.post('/', async (req, res) => {
try {
const { recipe, savedAt, userId } = req.body;
const savedRecipe = await SavedRecipes.create({
userId,
recipe,
savedAt,
});
const savedRecipeResult = await SavedRecipes.populate(savedRecipe, {
path: 'userId',
});
res
.status(201)
.send({ message: 'Recipe saved successfully', savedRecipeResult });
} catch (error) {
console.error(error);
res.status(500).send({ message: 'Internal server error' });
}
});
#4 URL 기반 검색
#5 AWS 배포와 https 적용
그 외 어떤 일들이 있었나?
위에서 언급한 사항들 이외에 프로젝트를 진행하며 구현했던 내용이나 마주했던 에러를 정리해 봤다
브라우저 & JavaScript
#1 CORS(Cross-Origin Resource Sharing) 에러 해결
[문제]
이 문제는 면접 가면 꼭 물어보는 질문 중 하나였다. CORS는 HTTP 헤더 기반의 메커니즘으로, 브라우저가 필요한 자원을 가진 origin을 서버가 지정할 수 있도록 한다. 브라우저의 기본 동작은 보안 상의 이유로 동일한 origin에 대한 요청만 허용한다(SOP, same-origin policy). 여기에서 origin의 판단 기준은 프로토콜, 도메인, 포트이다. 이 중 하나라도 다르다면, 다른 origin으로 판단하고 브라우저에서 CORS에러가 발생하게 된다. 중요한 점은 이러한 출처 비교와 차단을 서버가 아닌 브라우저가 한다는 사실이다.
따라서, 이러한 CORS 헤더가 설정되어있지 않은 경우 다른 origin(프로토콜, 도메인, 또는 포트가 다른 경우)에
이를 통해 브라우저는 origin이 다른 서버로부터 필요한 자원을 로드할 수 있다.
[해결]
CORS는 개발 환경에서는 proxy 설정으로, 배포 환경에서는 서버 측에서 허용할 origin을 헤더에 설정해 처리할 수 있다.
1. 프록시 설정
origin에 대한 판단과 차단을 브라우저가 하기 때문에 브라우저가 아닌 서버와 통신을 할 때는 다른 origin에 대해 차단하지 않는다. 따라서, 해당 요청을 모든 출처를 허용하고 있는 다른 서버에 넘기고, 해당 서버가 필요한 자원 요청을 하는 방법이 있는데, 이를 프록시(proxy) 서버라고 한다. 프록시는 Vite와 같은 클라이언트 사이드 또는 Node.js나 Docker와 같은 서버 사이드 모두에서 설정이 가능하다. 예를 들어, Vite에서는 아래와 같이 vite.config 파일에서 설정할 수 있다.
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'https://nutrinotes.net',
changeOrigin: true,
secure: false,
rewrite: path => path.replace(/^\/api/, ''),
},
},
},
});
2. 서버 헤더 설정
직접 서버에서 HTTP의 Access-Control-Allow-Origin 헤더에 허용할 origin을 명시해 줄 수 있다. 프로젝트에서 사용한 Express에서는 cors 미들웨어를 사용해 간편하게 설정할 수 있었다.
const express = require('express');
const cors = require('cors');
const app = express();
// ...
const corsOptions = {
origin: 'https://nutrinotes.net',
methods: 'GET,PUT,POST,DELETE',
allowedHeaders: 'Content-Type,Authorization',
};
app.use(cors(corsOptions));
#2 Date 비교: getUTCMonth? getFullYear?
[문제]
mongoose를 사용해 날짜별로 데이터를 가져올 때, new Date()는 UTC 기준, getFullYear, getMonth, getDate는 로컬 시간 기준이어서 예상치 못한 비교 결과가 나왔다.
[해결]
getUTCFullYear, getUTCMonth, getUTCDate 등을 사용해 시간을 모두 UTC 기준으로 맞춰주고, 프론트에서 렌더링 할 때만 toLocaleString을 사용해 로컬 시간 기준으로 보여준다. 또, mongoose에서 날짜 기준으로 데이터를 검색할 때 $gte, $lt를 사용해 범위를 정해주는 방식을 사용했다.
// date는 dateString
const searchDate = new Date(date);
const y = searchDate.getUTCFullYear();
const m = searchDate.getUTCMonth();
const d = searchDate.getUTCDate();
const startDate = new Date(y, m, d);
const endDate = new Date(y, m, d + 1);
const savedRecipes = await SavedRecipes.find({
userId,
savedAt: {
$gte: startDate,
$lt: endDate,
},
});
React
#1 Suspense를 사용한 스켈레톤 적용하기
React Query에서 suspense를 사용하려면 먼저 옵션을 지정해줘야 한다. 아래처럼 suspense를 true로 주면 된다.
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { RouterProvider } from 'react-router-dom';
import router from './router/router';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 0, // default 3
suspense: true,
refetchOnWindowFocus: false,
},
},
});
const App = () => {
return (
<>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</>
);
};
export default App;
해당 프로젝트에서는 컴포넌트를 레이지 로딩할 때, 그리고 전체 애플리케이션의 템플릿 역할을 하는 Root 컴포넌트, 그리고 캐러셀과 검색하는 영역을 Suspense로 감싸주어 로딩 시에 로더 또는 스켈레톤을 보여줄 수 있도록 했다.
Suspense는 children이 로딩 중이면 가장 가까운 부모 Suspense의 fallback 컴포넌트를 보여주고, children의 데이터가 준비되면 children을 보여준다. 예를 들어, 전체 어플리케이션의 fallback인 Loading이 있고, 내부 라우터가 포함하고 있는 컴포넌트인 캐러셀의 fallback인 CarouselSkeleton이 있다면, 캐러셀 컴포넌트가 로딩 중인 경우 CarouselSkeleton을 보여준다. 이를 통해 계층적으로 로딩 화면을 구성할 수 있다.
// loadLazy.tsx
import { Suspense, lazy } from 'react';
import { Loader } from '../components/index';
const loadLazy = (element: string) => {
const LazyElement = lazy(() => import(`../pages/${element}.tsx`));
return (
<Suspense fallback={<Loader />}>
<LazyElement />
</Suspense>
);
};
export default loadLazy;
// router.tsx
const router = createBrowserRouter([
{
path: '/',
element: loadLazy('Root'),
children: [
{
path: '/',
element: loadLazy('Home'),
},
{
path: 'main',
element: loadLazy('Main'),
},
// ...
],
},
]);
export default router;
// Root.tsx
// ...
const Root = () => {
const { pathname } = useLocation();
return (
<>
<Header />
<Main $hasMargin={hasMargin(pathname)}>
<ErrorBoundary FallbackComponent={ErrorFallback} onError={handleError}>
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
</ErrorBoundary>
</Main>
</>
);
};
const Main = styled.main<{ $hasMargin: boolean }>`
margin-top: ${({ $hasMargin }) => ($hasMargin ? '5rem' : '')};
width: 80%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
`;
export default Root;
// MainContent.tsx
// ...
const MainContent = () => {
return (
<Container aria-labelledby="categorized recipes">
<Title id="categorized recipes" className="sr-only">
Categorized Recipes
</Title>
{categories.map(category => (
<Suspense key={category} fallback={<CarouselSkeleton category={category} />}>
<Carousel category={category} />
</Suspense>
))}
</Container>
);
};
const Container = styled.section``;
const Title = styled.h2``;
export default MainContent;
#2 styled-component에서 공통 컴포넌트를 사용하는 각 컴포넌트에 다른 스타일 적용하기
공통 컴포넌트를 사용하는 자식 컴포넌트에서 다른 스타일도 overwrite 하고 싶은 경우, 1. 자식 컴포넌트에서 덮어쓸 스타일을 우선순위를 높여 적용하거나 2. 스타일 객체를 props로 넘겨주고, 공통 컴포넌트에서 css에서 다른 스타일과 ${props.style}을 적용해 줄 수 있다.&& {}를 사용하면 스타일을 덮어쓰도록 우선순위를 높일 수 있다.
버튼처럼 간단한 컴포넌트는 props로 스타일을 하나씩 넘겨주는 것보다 해당하는 컴포넌트에 css를 작성해 주는 것이 직관적이라고 판단되어 1번으로 사용했다. 하지만 모달의 경우 내부 구조에서 특정 컴포넌트(Container)에만 스타일을 적용하는 것이므로 반대로 props로 스타일을 받아 적용하도록 2번 방법으로 했다. 물론 className 등을 활용해 마찬가지로 우선순위를 높여 스타일을 적용할 수도 있겠지만, className이 겹치지 않도록 생성해 주는 styled-components의 장점이 사라진다고 생각해 사용하지 않았다.
이때 style 객체를 넘겨서 적용할 수 있다. 이때 넘겨받는 스타일 객체의 타입 지정이 어려웠다.. 공식문서에서 찾지 못해 챗gpt한테도 물어봤지만 depricated 된 타입만 알려줘서 계속 그런 타입은 없다는 에러만 봤다. 결국 타입 파일을 직접 확인하고 알 수 있었다. 넘겨주는 타입은 styled-components/dist/types에서 Styles를 import 해 지정하면 된다.
먼저, 공통 컴포넌트로 사용한 버튼을 살펴보자.
// Button.tsx
import React from 'react';
import { styled, css } from 'styled-components';
interface ButtonProps {
children?: React.ReactNode;
type?: string;
onClick?: () => void;
}
const buttonStyle = css`
background: transparent;
border: none;
border-radius: 1rem;
width: 5rem;
height: 2.4rem;
font-size: 1rem;
font-weight: 400;
font-family: 'Rubik';
color: #000;
`;
// props를 풀어줘야 styled-components에서 사용하는 className을 받을 수 있음!
const Button = ({ type = 'button', children, onClick, ...props }: ButtonProps) => {
return (
<CommonButton type={type} onClick={onClick} {...props}>
{children}
</CommonButton>
);
};
const CommonButton = styled.button<ButtonProps>`
${buttonStyle}
`;
export default Button;
// AuthForm.tsx
const AuthForm = ({ formType = 'login' }: AuthFormProps) => {
// ...
return (
<Form onSubmit={handleSubmit(submitFn)} name={formType}>
<Title>NutriNotes</Title>
<FormTitle>{formType.toUpperCase()}</FormTitle>
<Input
name="email"
type="text"
control={control}
trigger={trigger}
onUpdate={(isDuplicated: boolean) => setIsEmailDuplicated(isDuplicated)}
formType={formType}
/>
<Input name="password" type="password" control={control} trigger={trigger} formType={formType} />
// ...
<BottomContainer>
<Message to={isSignUp ? '/signin' : '/signup'}>
{isSignUp ? 'Already have an account?' : 'Create an account'}
</Message>
<ConfirmButton disabled={isSignUp ? !isValid || isDuplicated : false} type="submit">
Next
</ConfirmButton>
</BottomContainer>
</Form>
);
};
// ...
interface ConfirmButtonProps {
disabled: boolean;
}
// 우선 순위 높여 css 적용
const ConfirmButton = styled(Button)<ConfirmButtonProps>`
&& {
color: #fff;
font-weight: 500;
border-radius: 1rem;
background-color: ${({ disabled }) => (disabled ? 'var(--border)' : 'var(--button-point-color)')};
}
`;
export default AuthForm;
그리고 스타일을 넘겨준 모달을 살펴보자.
// Modal.tsx
// ...
import { Styles } from 'styled-components/dist/types';
interface ModalProps {
children: React.ReactNode;
close: () => void;
styles?: Styles<object>; // 넘겨받은 스타일의 타입 지정
}
const Modal = ({ children, close, styles }: ModalProps) => {
// ...
return (
<>
{createPortal(
<>
<Container $styles={styles}>
<CloseButton onClick={close} id="close button" />
{children}
</Container>
<Dimmed onClick={close} />
</>,
document.body
)}
</>
);
};
// ...
const Container = styled.section<{ $styles?: Styles<object> }>`
min-width: 24rem;
max-width: 24rem;
background: #fff;
border: 1px solid #eee;
box-shadow: rgba(0, 0, 0, 0.16) 0px 3px 6px, rgba(0, 0, 0, 0.23) 0px 3px 6px;
border-radius: 1rem;
position: fixed;
top: 50%;
left: 50%;
transform: translate3d(-50%, -50%, 0);
padding: 2rem;
z-index: 999;
font-family: 'Rubik';
font-size: 1.4rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
${containerStyles} // 자식 컴포넌트에서 받은 스타일 적용
`;
export default Modal;
// AddRecipeModal.tsx
// ...
const AddRecipeModal = ({ recipe, close }: AddModalProps) => {
//...
return (
// 스타일을 넘겨준다
<Modal close={close} styles={{ minWidth: '24rem', maxWidth: '24rem' }}>
<Text>Please select a date you would like to add this dish to your dashboard.</Text>
<Divider />
<Label>{recipe?.label}</Label>
<RecipeImg
src={recipe?.image}
onError={(e: SyntheticEvent<HTMLImageElement, Event>) => {
e.currentTarget.src = '/images/no_img.svg';
}}
/>
<DatePicker selected={selected} setSelected={setSelected} direction="top" />
<ConfirmButton onClick={handleConfirmButtonClick}>Confirm</ConfirmButton>
</Modal>
);
};
// ...
export default AddRecipeModal;
#3 chart.js에서 데이터 레이블 표시하려면?
chartjs-plugin-datalabel 플러그인을 사용하면 된다. 차트를 만들 때 전달해 주는 data props에 dataset과 label을 지정할 수 있다. 이때 formatter를 이용해 하나의 차트에 다른 값 레이블을 넣을 수도 있다. 이때 type을 지정해주지 않으면 에러가 발생하는데, Context 타입을 import 해서 사용할 수 있다. (참고)
datalabels에서 사용할 수 있는 속성은 아래와 같다.
- anchor: 차트의 방향(가로, 세로 등)에 따라 결정되는 시작점. 아래 예시에서는 바 차트는 시작점에서, 라인 차트는 끝점에서 시작하도록 설정했다.
- center, start, end
- clamp: true이면 도형이 보이는 지점을 시작 위치 anchor로 설정한다.
- align: anchor 위치와 방향을 기준으로 레이블의 위치를 결정한다. 숫자나 시계방향 각도로 표시할 수 있다.
- center, start, end, right, bottom, left, top 등..
- formatter: formatter 함수는 value와 context를 인자로 받는 함수로, 기본값을 덮어쓸 수 있다. value는 현재 데이터 값이고, context는 option context이다. 데이터 값을 원하는 대로 format 해 return 하면 된다. option context는 관련 요소가 hover 되었는지, 관련된 차트, 데이터의 index 등의 정보를 담고 있는 객체로, 이를 활용해서 format 할 수도 있다.
이외 속성들은 여기를 참고하자.
// NutriionInfo.tsx
// ...
import { Chart } from 'react-chartjs-2';
import { Context } from 'chartjs-plugin-datalabels';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import {
Chart as ChartJS,
LinearScale,
CategoryScale,
BarElement,
PointElement,
LineElement,
Legend,
Tooltip,
LineController,
BarController,
Title as ChartTitle,
} from 'chart.js';
interface NutritionInfoProps {
savedRecipes: SavedRecipesByDate;
}
ChartJS.register(
LinearScale,
CategoryScale,
BarElement,
PointElement,
LineElement,
Legend,
Tooltip,
ChartTitle,
LineController,
BarController,
ChartDataLabels
);
const NutritionInfo = ({ savedRecipes }: NutritionInfoProps) => {
const data = {
labels: ['Energy', 'Carbs', 'Protein', 'Fat'],
datasets: [
{
type: 'line' as const,
data: savedRecipes.totalDailyByDate.map(n => Number(n?.quantity)),
borderColor: 'lightgray',
datalabels: {
anchor: 'end' as const,
align: 'right' as const,
formatter: function (value: number) {
return `${value} %`;
},
font: {
weight: 'bold' as const,
size: 16,
},
},
},
{
type: 'bar' as const,
data: savedRecipes.totalDailyByDate.map(n => Number(n?.quantity)),
backgroundColor: ['#DCD0E6', '#DEE0EF', '#D9E5ED', '#d9ede0'],
borderRadius: 1.2,
datalabels: {
anchor: 'start' as const,
align: 'end' as const,
formatter: function (value: number, context: Context) {
return (
savedRecipes.totalNutrientsByDate[context.dataIndex]?.quantity +
savedRecipes.totalNutrientsByDate[context.dataIndex]?.unit!
);
},
font: {
weight: 'bold' as const,
size: 16,
},
},
},
],
};
// ...
return (
<Container aria-labelledby="main nutrition intake">
<Title id="main nutrition intake">Main Nutrition Intake</Title>
<Chart type="bar" data={data} options={options} />
</Container>
);
};
// ...
export default NutritionInfo;
각 차트에 원하는 레이블을 formatter에서 data를 받아 계산해 표시하면 아래처럼 표시된다.
#4 chart.js에서 레이블 숫자가 잘리는 문제?
[문제]
차트를 예쁘게 만들어놨는데 바 차트가 100%가 되면 레이블이 잘리는 문제가 있었다.
[해결]
options에서 layout 주고, padding을 주면 된다. 차트 자체의 크기는 해당 Chart 컴포넌트를 감싸고 있는 Container 컴포넌트의 width, height로 조정할 수 있다. 프로젝트에서는 차트 크기를 고정하기 위해 maintainAspectRatio를 false로 주고, 감싸고 있는 Container의 사이즈를 고정해 차트가 깨지지 않도록 했다.
const NutritionInfo = ({ savedRecipes }: NutritionInfoProps) => {
//...
const options = {
responsive: true, // 반응형 여부
maintainAspectRatio: false, // 시작 비율 유지 여부, false면 container 전체를 차치한다. 연속적으로 resize가 발생하지 않도록 설정했음
elements: {
bar: {
borderWidth: 2,
outerWidth: 1,
},
},
interaction: {
mode: 'index' as const, // 툴팁 보여주는 위치
},
indexAxis: 'y' as const, // dataset의 기준 축
plugins: {
legend: {
display: false, // 범례 표시
},
},
parsing: {
key: 'percent',
},
scales: { // x축, y축에 대한 설정
x: {
grid: {
display: false, // x축 grid 선 표시 여부
},
max: 100,
},
y: {},
},
layout: { // global chart layout 설정(autoPadding, padding)
padding: {
left: 20,
right: 60,
},
},
};
return (
<Container aria-labelledby="main nutrition intake">
<Title id="main nutrition intake">Main Nutrition Intake</Title>
<Chart type="bar" data={data} options={options} />
</Container>
);
};
CSS
#1 flexbox에서 여러 줄일 때 마지막 줄 왼쪽 정렬하기
[문제]
flexbox 내에서 wrap 시키고, 각 라인은 전체 화면에서 일정하게 배열하기 위해 justify-content: space-around 했다. 레시피 카드들이 남는 자리를 채우면서 잘 들어가는데, 마지막 라인은 원하는 대로 되지 않고, 중앙에 위치하는 문제가 있었다.
[해결]
이는 마지막 줄의 남는 공간을 채워주어야 한다. 데이터 개수와 정렬되어야 하는 줄 수를 계산해 마지막에 보이지 않는 div를 남는 개수만큼 이용하거나 마지막 child를 margin-right: auto해주는 방법이 있다.
#2 반응형을 위한 미디어 쿼리 적용
모바일과 데스크톱을 구분하는 방법으로 CSS에서는 미디어쿼리, JS에서는 matchMedia(’(max-width: 768px)’).matches(), 그리고 자바스크립트에서 터치 가능한 기기를 구분하는 함수를 사용할 수 있다.
프로젝트에서는 헤더에 있는 네비게이션을 모바일 화면에서는 메뉴를 통해 열고 닫을 수 있어야 하고, 데스크톱에서는 flex로 배치해야 했다. 모바일에서는 전체를 차지하는 Dimmed를 깔고, 그 위에 column 방향으로 링크들을 배치했다.
const NavContainer = styled.div<{ $isopen: boolean }>`
display: flex;
width: 75%;
justify-content: space-between;
align-items: center;
${mobileQuery} {
display: ${({ $isopen }) => ($isopen ? 'flex' : 'none')};
flex-direction: column;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: rgba(14, 25, 38, 0.9);
padding: 2rem;
z-index: 99;
& * {
color: white;
}
}
`;
중간 회고 때 이후로 계속해서 프로젝트 일지를 기록하며 진행하였고, 더 자세한 내용은 아래에서 확인할 수 있다.
https://hexagonal-protocol-50e.notion.site/NutriNotes-Daily-Notes-b5849218a04f4e3da0a87905c033698e?pvs=4
추후 개선 사항
원하는 기능을 모두 구현한 후 프로젝트를 보면서 개선사항이나 추가하고 싶은 기능에 대해 생각해 보았다. 다른 사람들의 피드백도 들어보고 싶어 커피챗을 통해 선배 개발자 분들께 조언을 구하기도 하고, 면접을 보면서 여쭤보기도 했다. 현재는 취준에 집중하면서 코테, 과제 및 면접으로 시간을 보내고 있지만, 이를 기반으로 조만간 시간을 내서 프로젝트 마이그레이션을 해볼 예정이다. 현재 React & TypeScript & Styled-Components로 되어있는데, SSR을 적용해 Next & vanilla-extract로 마이그레이션 할 계획을 세워뒀다. 이를 실행하고 회고를 작성하기 위해 글또에도 가입해서 다시 한번 포부를 다졌다.
또한 Lighthouse에서 제공하는 지표들 이외에 좀 더 디테일하게 접근성을 향상하고, 모바일 및 태블릿 화면을 위한 반응형도 마저 적용하고 싶다. 최근 <이펙티브 타입스크립트>를 읽기 시작했는데, 무엇보다 TypeScript를 좀 더 제대로 써보고 싶다.
느낀 점
이렇게 회고를 쓰고 보니 지나간 두 달간 참 많은 일들이 있었던 것 같다. 프로젝트를 키워나가면서 추상화에 대한 어려움을 느꼈다. 모달을 공통 컴포넌트로 만들거나, 이미지 레이지 로딩을 위한 옵저버를 커스텀 훅으로 추출하는 작업들이 어려웠다. 미리 어떤 동작을 해야할지 예상을 할 수 있어야 구현할 수 있기 때문이다. 이 부분은 계속해서 경험을 쌓아나가면서 특정 상황에 필요한 요소들을 파악하는 능력을 키워나가야 할 것 같다. 최근에 원티드 프리온보딩에 참여하면서 컴포넌트를 나누는 기준이나 커스텀 훅과 함수를 나누는 기준처럼 당연하게 해왔던 것들에 대한 고민을 하게 되었다. 프로젝트를 시작하기 전에 전체 구조를 파악하고 큰 틀을 보는 것이 중요하다는 생각이 들었다. 또한 구조의 일관성을 뒷받침 할 수 있는 코드 컨벤션에 대해서도 생각해볼 수 있는 시간이었다.
또 AWS로 처음 배포를 진행했는데, Ubuntu로 서버 컴퓨터에 필요한 파일을 업로드하거나, 로컬에서 해당 컴퓨터로 파일을 전송하는 과정 또는 CloudFront나 ELB를 연결하는 과정을 이해하려면 네트워크 지식이 필요하다고 절실히 느꼈다. 일단 가지고 있는 <HTTP network basic>을 올해 안에 읽고, 내년에는 근택님께서 추천해주신 <그림으로 공부하는 IT인프라 구조>, 그리고 <HTTP 완벽 가이드>를 읽어야겠다는 목표를 잡았다.
뿐만 아니라 이번 프로젝트에서 LightHouse를 활용해보면서 네트워크의 로딩 속도 등을 체크하면서 웹 표준과 성능에 대해 이해하고, 주의를 기울이게 되었다. 기능 구현을 넘어 프론트 단에서 할 수 있는 최적화에 대한 공부를 더 해야겠다.
배움은 끝이 없지만, 그 과정에서 나아지는 모습이 매일의 원동력인 것 같다. 코딩을 하면서 만난 좋은 인연들과 매일 조금씩 더 성장하려는 노력을 통해 꾸준히 발전하는 중이다. 남은 2023년 화이팅.
REF
[브라우저]
https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-CORS-%F0%9F%92%AF-%EC%A0%95%EB%A6%AC-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95-%F0%9F%91%8F
[DB]
https://inpa.tistory.com/entry/ODM-%F0%9F%93%9A-%EB%AA%BD%EA%B5%AC%EC%8A%A4-%EC%82%AC%EC%9A%A9%EB%B2%95-%EC%A0%95%EB%A6%AC
https://jie0025.tistory.com/532
https://fierycoding.tistory.com/30
https://choboit.tistory.com/95
[웹 성능 개선]
https://tech.kakao.com/2023/06/13/fe-performance-improvement-2/
https://tech.kakao.com/2023/06/13/fe-performance-improvement-1/
https://joshua1988.github.io/vue-camp/advanced/code-splitting.html
https://github.com/vitejs/vite/discussions/9440
[Concurrent UI]
https://tech.kakaopay.com/post/skeleton-ui-idea/
https://tech.kakaopay.com/post/react-query-2/
[Chart.js]
https://www.chartjs.org/docs/latest/
https://blog.logrocket.com/using-chart-js-react/
https://velog.io/@treejy/React%EC%97%90%EC%84%9C-Chart.js-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-with-TypeScript
https://stackoverflow.com/questions/19847582/chart-js-canvas-resize
'Projects' 카테고리의 다른 글
[NutriNotes] 프론트엔드 프로젝트 성능 개선기 (21) | 2024.02.18 |
---|---|
[NutriNotes] 시맨틱 태그 적용하기 (0) | 2023.08.22 |
[NutriNotes] 프론트엔드 프로젝트 중간 회고 (2) | 2023.08.03 |