이번 프로젝트의 주제는 그래프를 활용한 영화 추천 서비스인 CINEMATE입니다.
최근 온라인 스트리밍 플랫폼의 성장과 다양한 디지털 콘텐츠의 생산이 영화 소비 경험을 새로운 수준으로 끌어올렸습니다.
이렇게 다양한 볼거리가 존재함에 불구하고 사용자들은 오히려 선택의 어려움을 겪고있습니다. 이러한 문제를 해결하기위해 유의미한 영화 추천과 함께 관련영화 정보를 제공하는 서비스를 만들어보았습니다.
프론트엔드 1명, 백엔드 1명 , AI 1명
이번 프로젝트를 통해 기존에 했던 방식보다 새로운 라이브러리, 폴더구조 등 추가 해보면서 공부하고 싶었습니다.
저는 지금까지 프로젝트를 하면서 폴더 구조를 컴포넌트와 페이지로만 분류하고 컴포넌트도 페이지별,기능별로 뚜렸한방법이 있는게 아니라 그때그때마다 추가해서 만들었습니다. 앞으로 팀원들과 협업을 하게되면 이러한 부분은 문제가 되기때문에 고쳐나가고 싶어서 폴더구조에대해 알아보았습니다.
폴더구조에 대해 구글링을 해보니 아토믹 디자인 패턴이라는 방식에 대해 알게 되었습니다.
아토믹 디자인 패턴 방식은 화학적 관점에서 영감을 얻은 디자인 시스템이며 아래와 같이 이루어져 있습니다.
atom은 label,input, button과 같이 더이상 분해할 수 없는 컴포넌트입니다.
molecule은 인터페이스에서 하나의 단위로 함께 작동하는 단순한 UI요소 그룹입니다. 원자(atom)가 결합되면 다음과 같이 목적을 갖게됩니다.
organism은 molecule보다 좀더 복잡한 UI요소 그룹입니다.
atom, molecule, organism으로 구성될 수 있습니다. 한가지 목적이 아닌 다양한 목적을 갖게됩니다.
template는 component들을 layout에 배치하고 디자인의 기본 컨텐츠 구조를 명학화게 표현하는것입니다. 즉 페이지의 뼈대를 나타낼 수 있습니다.
page는 template에서 실제 컨텐츠들이 추가된 것입니다. 즉 완성본이라고 볼 수 있습니다.
아토믹 디자인 패턴에 대해 공부하다보니 구성을 보다 정확하고 쉽게 구현할 수 있을거같아서 선택하게 되었습니다.
다음과 같이 search창의 input과 로그인 page의 input이 있습니다.
저의 고민은 다음과 같았습니다.
그래서 저의 결론으로는 재사용성을 고려해 같은 atom으로 분리하면 하나의 input-component에 너무많은 기능을 담당해야하고,
또한 따로 분리하면 굳이 component의 개수만 많아지기만하고 재사용성이 없다고 판단해서 따로 atom으로 분리하지 않고 form component마다 따로 선언해서 사용했습니다.
이렇게 처음 적용하다보니 헷갈리는것도 많고 어떤게 효율적인지가 구분이 안되는경우가 조금 있었습니다.
지금까지 데이터를 받을때 따로 처리나 관리를 하지 않았습니다.
하지만 많은 개발자분들이 데이터 관리를 해야한다고해서 정리해서 적용해 보았습니다.
client state : 모달과 같은 데이터, input value를 보고 button 비활성화와 같은 데이터
server state : 서버에서 넘어오는 state(사용자 정보,각종 데이터)
사실 이것에 대해 정말 잘 이해가 되지 않았습니다..
도대체 분리하지 않으면 어떠한 문제가 생기는거지..?
client state는 recoil,jutai, Zustand...를 쓰고
server state는 react-query,swr... 을 써야하는거지??에 대한 의문점이 들어서 공부해보았습니다.
client 와 server state는 성질이 달라서입니다. server state는 클라이언트에서 관리하지 않고 서버에서 관리하기 때문입니다. 만약 client에서 관리하게 된다면 보안적인 문제와 server state는 모든 사용자와 공유되어야되는데 성능에 문제가 될 수 있습니다.
또한 여러 클라이언트가 동시에 server state를 변경할 수 있습니다. 클라이언트 상태는 독립적이며 서버 상태와 동기화되지 않을 수 있습니다.
즉 예를 들어 온라인 쇼핑몰의 재고 수량 server state인데 client state로 분리하지 않아서 여러 클라이언트가 동시에 이 데이터를 수정하려고 하면 수량에서 문제가 발생할 수 있기때문에 분리해야합니다.
- 애플리케이션에서 서버 상태 가져오기, 캐싱, 동기화 및 업데이트를 매우 쉽게 만들어주는 라이브러리
- React의 ContextAPI를 기반으로 동작합니다.
- 전역상태를 관리하는 QueryClient가 존재하는데, 해당 QueryClient는 우리가 Query를 사용할 때 명시하는 unique key를 기반으로 데이터를 저장합니다.
function Todos() {
const { isPending, isError, data, error } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodoList, //
})
if (isPending) {
return <span>Loading...</span>
}
if (isError) {
return <span>Error: {error.message}</span>
}
// We can assume by this point that `isSuccess === true`
return (
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}
isPending
, isError
,data
,error
를 통해 각각의 상태에 맞게 확인 할 수 있습니다. 아직은 Get요청인 useQuery에 대해서만 공부해서 추후에 다른 요청에 대해서 공부한다음 react-query에 대해 자세하게 정리해보겠습니다.
storybook은 UI 구성요소와 페이지를 독립적으로 구축하기 위한 tool입니다.
이번 프로젝트를 진행하면서 storybook을 추가한 이유는 다음과 같습니다.
storybook을 설치하게 되면 .storybook
폴더가생성되며, 이 폴더 내에는 main.ts
, preview.ts
가 있습니다. 이 두개의 팡일로 storybook설정을 할 수 있습니다.
import type { StorybookConfig } from "@storybook/react-webpack5";
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
//storybook 경로
addons: [ //addons 배열은 Storybook에 추가 기능을 제공하는 플러그인 목록입니다.
"@storybook/preset-create-react-app",
"@storybook/addon-onboarding",
"@storybook/addon-links",
"@storybook/addon-essentials",
"@chromatic-com/storybook",
"@storybook/addon-interactions",
],
framework: {
name: "@storybook/react-webpack5",
options: {},
},
docs: {
autodocs: "tag",
},
staticDirs: ["../public"],
};
export default config;
import type { Preview } from "@storybook/react";
import { withThemeFromJSXProvider } from '@storybook/addon-themes';
import { theme } from './../src/styles/theme';
import { ThemeProvider } from 'styled-components';
import GlobalStyle from '../src/styles/GlobalStyle'
const preview: Preview = {
parameters: { // storybook global parameter 설정
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
backgrounds: { // storybook 배경 설정
default: 'dark',
values:[
{
name: 'dark',
value: '#211F1F'
},
]
},
},
};
export default preview;
export const decorators = [
withThemeFromJSXProvider({
themes: {theme},
Provider: ThemeProvider, //Provider: ThemeProvider를 설정하여 테마를 제공하게 합니다.
GlobalStyles: GlobalStyle //GlobalStyles: GlobalStyle을 설정하여 전역 스타일을 적용합니다.
})];
이후 main.ts에서 설정한 폴더에서 해당 component에 맞는 storybook 파일을 생성하면됩니다.
//PrimaryButton.stories.ts
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; // 클릭 이벤트 핸들러
import PrimaryButton from '../components/atoms/PrimaryButton'; //해당하는 component import
// 메타 데이터
const meta: Meta<typeof PrimaryButton> = {
title: 'Components/Button', // 어디로 분류 할건지
component: PrimaryButton, // 렌더링 할 컴포넌트
};
export default meta;
type Story = StoryObj<typeof meta>; // story 객체 타입 정의
export const PrimaryBtn: Story = { // 각각의 props 초기값 설정
args: {
type: 'button',
children: '회원가입',
onClick: fn(),
state: true,
size: 'large',
},
};
storybook은 component 분류 및 화면에 나타내는 정도인 기본적인 기능만 구현해보았습니다.
프로젝트를 진행하면서 별거아니지만,,, 제가 겪었던 문제상황에 대하여 적어보려고합니다.
💡 react-hook-form을 사용하여 공통 컴포넌트에 validation을 적용하려고했는데 register를 사용하여 validation을 적용할 경우 동작하지 않습니다.
- onChange,required등 ref도 포함되어있어서 결국 props로 ref를 넘기고 있기 때문에 에러가 발생합니다.
ref를 props로 못넘기는이유
💡 ref는 react에서 DOM에 직접 접근하기 위해 사용되기 때문입니다. 따라서 일반적인 props로는 사용불가능합니다
export interface FormInputProps {
type: 'nickName' | 'password' | 'email';
value?: string;
placeholder?: string;
onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
validationStatus: 'default' | 'error' | 'success';
register?: UseFormRegisterReturn;
duplicatedStatus?: boolean;
}
const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
(
{
type,
placeholder,
value,
onChange,
validationStatus,
register,
}: FormInputProps,
ref,
) => {
let image;
if (type === 'nickName') {
image = nameSvg;
} else if (type === 'email') {
image = emailSvg;
} else if (type === 'password') {
image = passwordSvg;
}
return (
<InputContainer $validationStatus={validationStatus}>
<InputImg src={image} />
<InputField
ref={ref}
type={type}
placeholder={placeholder}
value={value}
{...register}
onChange={onChange}
/>
{validationStatus !== 'default' && (
<CheckImg
src={validationStatus === 'error' ? errorSvg : successSvg}
/>
)}
</InputContainer>
);
},
);
FormInput.displayName = 'FormInput';
export default FormInput;
💡 최근검색어 기능을 구현하기 위해 검색어를 로컬스토리지에 저장하였습니다.
검색창이 header로 구성되어있어서 검색어 값을 전역상태로 두어야하는 상태여서 검색어를 전역상태 + 로컬스토리지에 저장하려고 recoil-persist 사용했습니다. submit 버튼을 누르자 input의 state값이 초기화 되는 에러가 발생했습니다.
event.preventDefault()
에서 문제가되나...? 그럴리가...? 역시나 아니였습니다.버튼의 type설정을 잘하자...
💡 회원가입할때
input
의value
값이이메일,닉네임의 중복확인(API)
과정규식(react-hook-form)
에 부합하는지를onChange
가 될때마다 확인했어야합니다 .
하지만 어째서인지 onChange될때 react-hook-form의 validation 확인은 되지만 중복확인API요청을 보내지 않는 error가 발생했습니다.
디바운싱을 적용하지 않아 onChange
가 될때마다 중복확인API
요청을 짧은 시간안에 빠르게 보내는 문제로 인해 단순 느려짐인줄 알았지만...아니였습니다.
네트워크 창을 확인해보니 요청자체가 보내지지 않아서 API자체에 문제인줄 알았는데 아니였습니다...
react-hook-form
은 동작하는데 그러면 왜..? API요청만 동작이 되지않는지 모르겠어서 찾아보니 원인을 알게되었습니다.
react-hook-form
은 기본적으로 input
의 필드를 ref
로 비제어 컴포넌트로 관리하기때문에, onChange
로 input
의 value
값을 react상태
로 관리하려면 input
의 field
가 일관성이 사라져 충돌하는 문제가 발생한다는것을 알게되었습니다.
react-hook-form
의 Controller
를 사용하면 비제어 컴포넌트와 제어 컴포넌트를 같이 사용할 수 있다는것을 알게 되었습니다.
따라서 다음과같이 구현하였습니다.
export interface FormInputProps {
type: 'nickName' | 'password' | 'email';
value?: string;
control: Control<SignupInput>;
placeholder?: string;
onInputChange: (event: ChangeEvent<HTMLInputElement>) => void;
validationStatus: 'default' | 'error' | 'success';
duplicatedStatus?: boolean;
}
const CustomFormInput = ({
type,
placeholder,
control,
onInputChange,
validationStatus,
duplicatedStatus,
}: FormInputProps) => {
let image: string;
if (type === 'nickName') {
image = nameSvg;
} else if (type === 'email') {
image = emailSvg;
} else if (type === 'password') {
image = passwordSvg;
}
return (
<Controller
control={control}
name={type}
render={({ field: { onChange, value } }) => (
<InputContainer $validationStatus={validationStatus}>
<InputImg src={image} />
<InputField
name={type}
type={type}
placeholder={placeholder}
value={value || ''}
onChange={(event) => {
onChange(event.target.value);
onInputChange(event);
}}
/>
{validationStatus !== 'default' && (
<CheckImg
src={validationStatus === 'error' ? errorSvg : successSvg}
/>
)}
</InputContainer>
)}
/>
);
};
export default CustomFormInput;
💡 별점을 아래의 사진같이 색이 없는 별을 클릭하게 되었을때 별점이 등록되도록 설정하려고 하였고 별점을 0.5점 단위로 구현하려했습니다. 하지만 0.5점이면 클릭했을때를 어떻게 구별할 수 있을까..?
- 위에 보이는 별위에 가운데를 짤라서 세로로 button을 반개씩 두개 만들었습니다.
- 왼쪽을 클릭하면 현재 index보다 작은 index의 별들을 다 색칠하고 현재 index의 별은 반개만 색칠(점수는 현재 index-0.5점으로)
- 오른쪽을 클릭하면 현재 index까지 별들을 색칠(점수는 현재 index)
코드는 다음과 같습니다.
const GradeStar = ({
score,
movieId,
index,
setScore,
onRatingClick,
}: GradeStarProps) => {
const handleLeftClick = () => {
const rating = index - 0.5;
setScore(rating);
onRatingClick({
movieId: movieId,
rating,
});
};
const handleRightClick = () => {
setScore(index);
onRatingClick({
movieId: movieId,
rating: index,
});
};
const renderStar = () => {
if (index - score === 0.5) {
return <HalfStar />;
} else if (score >= index) {
return <FillStar />;
} else {
return <EmptyStar />;
}
};
return (
<StarContainer>
<ButtonContainer>
<LeftButton onClick={handleLeftClick} />
<RightButton onClick={handleRightClick} />
</ButtonContainer>
{renderStar()}
</StarContainer>
);
};
export default GradeStar;
디자인의 초반설계를 하게되면서 초반 설계
가 얼마나 중요한지 깨달았습니다.
아무래도 캡스톤디자인겸으로 만들어서 시간이 많지않아서 급하게 하다보니 대략 적으로만 설계하고 디테일한 부분들은 잘 고려하지 않았는데 그러한 부분때문 에 개발과정에서 계속적으로 수정해야하는 부분들이 늘어났습니다.
ex)회원가입할때 닉네임과 이메일같은 부분들은 중복확인이 필수인데... 생각을못해서 디자인적으로 버튼을 넣기가 애매해져서.... input의 value값이 onChange 될때마다 중복확인API요청을 보냈습니다... 물론 잘못된 방법은 아니지만 조금더 깔끔하게 진행할 수 있지 않았을까 생각이 들었습니다.
그리고 저희 서비스의 header가 총 3개로
뒤로 가는 버튼이 있는 header
logo가 있는 header
searchInput이 있는 header
제일 처음에는 main-layout에서 3개의 header를 page에 맞게 각각 불러왔지만,
한번의 header만을 불러서 header-component 안에서 각각의 page에 맞게 하는것이 더 가독성의 측면에서나 성능에서나 좋다고 생각했습니다.
하지만 search-input값을가지고 최근검색어 구현 및 각각의 page에 따른 뒤로가기 버튼 구현은 공통component로 구현하기에는 다소 복잡함이 있었습니다.
이러한 경험을 통해 초반에 어떠한기능이 정확하게 있고 어떤식으로 구현할지에 대해서 시간이 오래걸리더라도 정확하게 설계하고 들어가는것이 중요하다고 느꼈습니다.
이번 프로젝트를 마치고 PR창을 다시 보니 생각보다 가독성이 떨어진 느낌이 들었습니다... 중구난방이고 한번에 어떤 기능을 구현했고 어떤것을 만들었는지 불명확한 느낌이 들었습니다.
PR을 작성할때 이것도 마찬가지로 초반에 어떤식으로 작성할지에 대해 조금더 고민하고 많은 레퍼런스들을 참고해서 작성해 보도록 하겠습니다.
또한 Issue는 정말 어떠한 error나 issue가 생겼을때 작성하는거로 알고있었는데.... 알고보니 작업단위로도 나타낼 수 있어서 좀 더쉽게 볼 수 있는 기능인걸 알았습니다...
다음프로젝트를 진행할때는 이러한 부분도 조금더 공부해서 적용해 보도록 하겠습니다.
사실 아토믹디자인 패턴을 사용할때 초반에는 atom
,molecule
,organism
로 나눴을때 정말 분리가 잘되는 느낌을 받았었습니다.
하지만 컴포넌트의 개수가 점점 많아지다보니 각각의 폴더에서 원하는것을 찾기가 어려워졌습니다.
그렇다 보니 폴더구조의 패턴을 정해놓는것이 좀더 쉽게 파일을 찾기위해서인데 이렇게 찾기 어려우면 굳이 원칙대로 사용을 해야될까?? 라는 생각이 들었습니다.
그래서 앞으로는 아토믹 디자인 패턴을 사용한다면 아래와같이
아토믹 디자인 패턴 + 컴포넌트의 종류
로 사용할거 같습니다.
사실아직 캡디기간내에 기본적으로 구현할 수 있는 부분만 구현했습니다. 팀원들과 상의를 해보니 다들 7월까지는 정해진 일정이 있어서 7월말에서 ~8월에 추가기능 및 보완할점들을 구현할거 같습니다.
이후에 서비스를 완성하고 다시 회고록을 작성해 보도록 하겠습니다.
너무 멋지시네여 😳 저도 꼭 사용해보고 싶어요!