개요
백엔드와 협업한 미니 프로젝트의 소개, 진행 과정, 제가 구현한 기능들 그리고 회고를 다룹니다.
프로젝트 소개
론즐리(Lonsily)는 REST API
를 이용한 대출 상품 추천
서비스입니다. 론즐리는 loan과 easily를 합친 말로 대출을 쉽게라는 뜻을 갖고
있습니다. 즉 손쉽게 대출 상품을 찾고 신청하는 서비스입니다.
프로젝트는 프론트엔드 4명 그리고 백엔드 4명 총 8명이 한 팀으로 구성이 되었고, 개발 기간은 2023.02.13부터 2023.02.24까지 진행하였습니다. 저는 프로젝트의 팀장과 전체 프로젝트 세팅, 마이페이지, 관심 상품, 구매내역, 유저정보, 권한별 라우트, 인증 로직 그리고 배포를 맡았습니다.
프로젝트 진행 과정
금융상품 쇼핑몰을 만들기라는 주제가 정해진 프로젝트로 그중 어떤 금융상품을 선택할지는 자유였습니다. 그중 저희는 대출 상품을 추천하는 서비스로 방향을 잡았습니다. 이때 백엔드 측에서 금융감독원의 Open API의 대출 상품 데이터를 활용해 서버와 데이터를 구성하였습니다. 프로젝트에는 필수로 구현해야 하는 기능들이 있었는데, 그 기능은 다음과 같습니다.
- 로그인/회원가입
- 금융상품 정보 조회 및 필터링
- 특정 금융상품 관심 상품으로 등록/해제
- 관심 상품으로 등록된 상품 조회
- 특정 금융상품 장바구니 담기
각각의 기능별로 페이지를 나누는 방식으로 기능을 구현하기로 하였습니다.
짧은 개발 기간이므로 main
과 feature
브랜치만을 사용하는 브랜치 전략인 GitHub Flow
를 채택하여 사용하였습니다. 또 fork 하여 작업하는 방식이 아닌 clone을 통해 작업을 진행하였습니다.
기능 분담과 브랜치 전략을 정하고 저희는 기술 스택을 정하기로 하였습니다. 기술 스택과 선정 이유는 다음과 같습니다.
- React (CRA)
- 빠르게 작업하는 게 중요한 프로젝트이니
esbuild
기반의vite
를 사용할까 고민하였는데,Redux-toolkit
을 사용해 프로젝트를 진행하기 위해 템플릿이 있는CRA
툴체인을 활용하여 리액트 앱을 초기화하였습니다.
- 빠르게 작업하는 게 중요한 프로젝트이니
- TypeScript
- 정적 타이핑을 통해 런타임이 아닌 컴파일 시 에러를 잡을 수 있다는 장점, 인텔리센스를 통한 코드 생산성 증가로 인해 선택하였습니다. 또 저도 그렇고 팀원들이 익숙해지기 위해 선택하였습니다.
- Styled-Components
TailwindCSS
,SCSS
등 다양한 선택지가 있었지만, 팀원들이 가장 익숙한Styled-Components
를 사용하기로 하였습니다. Styled-Components의 장점은 props를 활용한 조건부 스타일링, 스타일의 컴포넌트화, SCSS 지원, 유니크한 클래스 등이 있습니다.
- Redux-Toolkit
- 프로젝트의 요구 사항이고 리액트에서 클라이언트 상태 관리는 필수이므로 선택하였습니다. 기존의
Redux
보다 간결하게 상태 관리를 할 수 있다는 장점이 있습니다.
- 프로젝트의 요구 사항이고 리액트에서 클라이언트 상태 관리는 필수이므로 선택하였습니다. 기존의
- React-Query
- 대부분의 전역 상태 관리의 경우 서버 상태를 관리하는 경우일 때가 많습니다. 이렇게 되면
store
의 역할이 너무 비대해지고 비효율을 초래합니다. 클라이언트 상태와 서버 상태를 분리하여 관리하기 위해React-Query
를 도입하였습니다. 또한 캐시를 활용하여 API 호출을 줄이며 서버에 대한 부하를 감소합니다.
- 대부분의 전역 상태 관리의 경우 서버 상태를 관리하는 경우일 때가 많습니다. 이렇게 되면
- React-Hook-Form
- 로그인, 회원가입 그리고 유저정보 수정 등에서 폼을 활용하는데, 제어 컴포넌트로 폼을 다루기 위해선 상태를 만들어야 하고 그 상태를 변경시키기 위한 핸들러 함수들을 만들어야 합니다. 이렇게 되면 간단한 폼이라도 너무 많은 상태와 함수가 필요하게 됩니다. 이런 문제점을 해결하기 위해
폼을 쉽고 효율적으로 관리할 수 있는
React-Hook-Form
을 도입하였습니다.
- 로그인, 회원가입 그리고 유저정보 수정 등에서 폼을 활용하는데, 제어 컴포넌트로 폼을 다루기 위해선 상태를 만들어야 하고 그 상태를 변경시키기 위한 핸들러 함수들을 만들어야 합니다. 이렇게 되면 간단한 폼이라도 너무 많은 상태와 함수가 필요하게 됩니다. 이런 문제점을 해결하기 위해
폼을 쉽고 효율적으로 관리할 수 있는
- Zod
Zod
는TypeScript
타입 스키마와 유효성 검증을 위한 라이브러리로 폼의 유효성 검증을 쉽게 하기 위해 도입하였습니다. 또한infer
를 통해 스키마를 타입으로 추출할 수 있습니다.
추가적으로 각 팀원들의 코드를 통일하기 위해 eslint
와 prettier
를 적용하고 husky
와 lint-staged
를 통해 커밋전에 코드의 통일을 자동화하였습니다.
피그마를 다룰 줄 아는 팀원이 대략적인 UI를 만든 후 본격적으로 작업에 들어갔습니다.
😵 프로젝트를 진행하며 UI/UX가 얼마나 중요한지 알게 되는 계기였습니다..
기능 구현
마이페이지
const MyPage = () => {
const { auth } = useAppSelector((state) => state);
const { birth } = auth;
const gender = birth?.slice(-1);
return (
// ...
<ProfileWrapper>
{gender === "1" || gender === "3" ? <MaleAvatar /> : <FemaleAvatar />}
<div>
<Username>{auth?.name}</Username>님 환영합니다.
<div>
<LogOutBtn onClick={handleClickLogout}>로그아웃</LogOutBtn>
</div>
</div>
</ProfileWrapper>
// ...
마이페이지의 전체적인 레이아웃을 구성하였는데 이때 React-Router-Dom
의 중첩 라우팅을 활용하여 하위 라우트를 구성하였습니다.
또 Redux-Toolkit
을 활용해 로그인 시 store
에 저장된 유저 정보를 조회할 수 있도록 하였습니다.
관심 상품
const ProductDetail = () => {
const like = useAppSelector((state) => state.like);
const dispatch = useAppDispatch();
const { data: detail } = useQuery(["data"], () => getDetail(id as string));
const isLiked = like.find((item) => item.productId === detail?.productId);
// ...
// mutate
const queryClient = useQueryClient();
const likeMutation = useMutation((id: string) => postLikeLists(id), {
onSuccess(data) {
queryClient.invalidateQueries(["like"]);
dispatch(
addLike({
likeId: data.likeId,
productId: data.productId,
}),
);
},
});
const deleteLikeMutation = useMutation((id: number) => deleteLikeList(id), {
onSuccess(data, id) {
queryClient.invalidateQueries(["like"]);
dispatch(deleteLike(id));
},
});
return (
<div>
{detail && (
<ProductBox>
<ProductTitle>{detail?.productName}</ProductTitle>
{isLiked?.likeId ? (
<button
onClick={() =>
deleteLikeMutation.mutate(isLiked.likeId as number)
}
>
<HiHeart />
</button>
) : (
<button onClick={() => likeMutation.mutate(detail?.productId)}>
<HiOutlineHeart />
</button>
)}
</ProductBox>
// ...
버튼 클릭 시 React-Query
의 useMutation
훅의 mutate
를 사용해 관심 상품을 추가하고 삭제하는 기능을 구현하였습니다.
또 쿼리를 무효화해 새로 데이터를 fetch
해오는 invalidateQueries()
를 사용해 데이터를 최신화하도록 구현하였습니다.
이때 관심 상품을 Redux-Toolkit
으로 관리하였습니다. 먼저 상태안에 관심 상품의 productId
와 데이터를 fetch
한 상품 상세 정보의 productId
가 같은지 find
메서드를 활용하여 찾습니다.
이렇게 찾아진 관심 상품의 상태 값을 isLiked
변수에 담아줍니다. 그 후 관심 상품이 있을 경우 색이 칠해진 아이콘과 삭제하는 deleteLikeMutation.mutate()
를 없을 경우 빈 아이콘과 추가하는 likeMutation.mutate()
를
조건부 렌더링하도록 구현하였습니다.
🤔 당시에는
React-Query
에 익숙지 않고 기능을 구현하는 것에 급급해 가독성이 떨어지는 코드를 작성했던 것 같습니다. 만약 다시 작성한다면mutatate
를 담당하는 기능들을 커스텀 훅으로 분리하였을 것 같습니다. 또isLiked
의 경우 불리언 변수에 어울리는 컨벤션인데 컨벤션을 제대로 지키지 못하고 코드를 작성하였습니다.
const Likes = () => {
const queryClient = useQueryClient();
const dispatch = useAppDispatch();
const {
data: likeItem,
isLoading,
isError,
} = useQuery<Item[]>({
queryKey: ["like"],
queryFn: getLikeLists,
});
const deleteMutation = useMutation((id: number) => deleteLikeList(id), {
onSuccess(data, id) {
queryClient.invalidateQueries(["like"]);
dispatch(deleteLike(id));
},
});
// ...
};
관심 상품 페이지에서 useQuery
훅을 사용하여 관심 상품의 목록을 조회할 수 있도록 구현하였습니다.
위와 마찬가지로 관심 상품을 삭제하는 mutatate
가 존재하는데, 커스텀 훅으로 만들었다면 코드의 재사용성이 훨씬 높아졌을 텐데라는 아쉬움이 있습니다.
구매 내역
const Order = () => {
const queryClient = useQueryClient();
const { data, isLoading, isError } = useQuery<Item[]>({
queryKey: ["order"],
queryFn: getOrderLists,
});
const deleteMutation = useMutation((id: number) => deleteOrder(id), {
onSuccess(data) {
queryClient.invalidateQueries(["order"]);
},
});
// ...
구매 내역 역시 React-Query
의 useQuery
훅을 사용하여 데이터를 fetch
하였습니다. 또 구매 내역 삭제를 위해 관심 상품과 마찬가지로 mutatate
를 활용하였습니다.
커스텀 훅으로 만들지 못한 아쉬움이 있습니다.
유저 정보
interface ITextFiled {
id:
| "name"
| "password"
| "confirmPassword"
| "asset"
| "income"
| "job"
| "region"
| "joinType";
label:
| "이름 *"
| "비밀번호 변경 *"
| "비밀번호 확인 *"
| "총 자산"
| "소득"
| "직업"
| "지역"
| "가입목적";
type?: "text" | "password" | "select";
error?: string;
placeholder?: string;
register?: {
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
onBlur: (e: FocusEvent<HTMLInputElement>) => void;
ref: RefCallback<HTMLInputElement>;
name: string;
min?: string | number;
max?: string | number;
maxLength?: number;
minLength?: number;
pattern?: string;
required?: boolean;
disabled?: boolean;
};
}
const TextFiled = ({
id,
label,
type,
error,
placeholder,
register,
}: ITextFiled) => {
return (
<>
<InputWrapper>
<Label htmlFor={id}>{label}</Label>
<input
{...(register ?? {})}
type={type ?? "text"}
id={id}
placeholder={placeholder}
/>
</InputWrapper>
{error ? <ErrorMessage>{error}</ErrorMessage> : null}
</>
);
};
export default TextFiled;
유저 정보의 경우 여러 개의 인풋들을 관리해야 했습니다. 그렇기에 컴포넌트의 재사용성에 대해 저 스스로 고심하였고
TextFiled
컴포넌트를 만들어 인풋을 관리하였습니다. React-Hook-Form
에서 제공되는 register
의 타입인 UseFormRegister<TFieldValues>
를 사용하지 않고 모든 옵션을 일일이 타이핑한 것이 코드의 가독성을 저하시키는 것 같습니다.
또 id
나 label
도 타입을 만들어 관리를 한다면 가독성이 좀 더 좋은 코드가 될 수 있을 것 같습니다.
const validationSchema = z
.object({
name: z.string().min(2, "2글자 이상 입력해주세요."),
password: z
.string()
.min(8, "비밀번호는 8자 이상 입력해주세요.")
.max(20, "비밀번호는 20자 이하로 입력해주세요.")
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*])[A-Za-z\d!@#$%^&*]{8,20}$/,
"8-20자 영문, 숫자, 특수문자를 사용하세요."
),
confirmPassword: z
.string()
.min(8, "비밀번호는 8자 이상 입력해주세요.")
.max(20, "비밀번호는 20자 이하로 입력해주세요.")
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*])[A-Za-z\d!@#$%^&*]{8,20}$/,
"8-20자 영문, 숫자, 특수문자를 사용하세요."
),
asset: z.string().optional(),
income: z.string().optional(),
job: z.string().optional(),
region: z.string().optional(),
joinType: z.string().optional(),
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: "비밀번호가 일치하지 않습니다.",
});
export type EditValues = z.infer<typeof validationSchema>;
const UserForm = () => {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<EditValues>({
resolver: zodResolver(validationSchema),
});
const dispatch = useAppDispatch();
const navigate = useNavigate();
const { auth } = useAppSelector((state) => state);
const deleteMutation = useMutation(() => deleteUser(), {
onSuccess(data) {
dispatch(logoutAction());
alert("탈퇴 성공");
navigate("/");
},
});
const editMutation = useMutation((data: EditValues) => editUser(data), {
onSuccess(data) {
navigate("/");
},
});
const onSubmit = (data: EditValues) => {
editMutation.mutate(data);
dispatch(
updateUser({
...auth,
name: data.name,
asset: data.asset,
income: data.income,
job: data.job,
region: data.region,
joinType: Number(data.joinType),
}),
);
};
// ...
이때 React-Hook-Form
과 Zod
를 활용하여 인풋 필드 관리와 유효성 검사를 효율적으로 처리하였습니다.
유효성 검증을 위한 validationSchema
를 만들고 useForm
훅의 resolver
옵션에 추가합니다.
그리고 Zod
의 infer
를 활용하여 스키마에서 타입을 추출하여 폼의 타입을 쉽게 지정할 수 있었습니다.
수정과 탈퇴의 경우 react-Query
의 mutate
를 통해 구현하였습니다. 각각의 mutatate
가 성공하면 dispatch
를 통해 유저 정보를 업데이트하도록 하였습니다.
권한별 라우터
권한별 라우터의 경우 두 가지 경우로 처리하기로 하였습니다.
1. PublicRouter
const PublicRouter = ({ children }: { children: ReactElement }) => {
const {
auth: { accessToken },
} = useAppSelector((state) => state);
return accessToken ? <Navigate to="/" /> : children;
};
PublicRouter
의 경우 로그인이 하지 않은 유저가 접근할 수 있는 라우트(회원가입, 로그인 등)입니다.
우선 로그인 시 store
저장된 토큰을 가져옵니다. 그리고 토큰의 여부에 따라 <Navigate />
를 활용해 메인 페이지로 보낼지, children
을 렌더링 할지 삼항 연산자로 판단합니다.
2. PrivateRouter
const PrivateRouter = ({ children }: { children: ReactElement }) => {
const {
auth: { accessToken },
} = useAppSelector((state) => state);
return accessToken ? children : <Navigate to="/signin" />;
};
PrivateRouter
의 경우 로그인된 유저가 접근할 수 있는 라우트(장바구니, 마이페이지, 관심 상품 등)입니다.
토큰이 있을 시 children
을 렌더링하고, 없을 시 로그인을 할 수 있도록 /signin
으로 리다이렉트 시키도록 구현하였습니다.
axios interceptors를 활용한 인증 로직
지난 원티드 프리온보딩 과정을 준비하며, 알게 된 interceptors
를 프로젝트에 적용해 보기로 하였습니다.
요청을 위한 request
와 응답을 위한 response
두 경우에 서로 다른 로직을 구현하였습니다.
1. request
client.interceptors.request.use((config) => {
if (!config.headers) return config;
if (accessToken !== null) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
request
는 요청이 전달되기 전에 로직을 추가할 수 있게 해줍니다.
토큰으로 인증이 필요한 엔드 포인트들이 많기에 요청이 전달되기 전에 토큰 여부에 따라 토큰을 Authorization
에 주입하는 로직을 추가하였습니다.
2. response
client.interceptors.response.use(
(response) => response,
async (error) => {
const {
config,
response: { status },
} = error;
if (status !== 401 || config.url !== "/refresh") {
return Promise.reject(error);
}
if (accessToken && status === 401) {
const { data: newAccessToken } = await axios.post(
`${config.baseURL}/refresh`
);
config.headers.Authorization = `Bearer ${newAccessToken}`;
}
return axios(config);
}
);
토큰이 만료되어 응답 오류가 있을 경우 로직을 구현하였습니다.
응답 데이터가 정상이라면 response
를 반환합니다.
만약 토큰이 존재하고 유효한 자격이 없을 때 나타나는 응답 상태 코드가 401
일 경우 create
로 만든 인스턴스가 아닌 axios
를 통해 새로운 토큰을 발급받습니다.
이렇게 발급된 토큰을 Authorization
에 주입한 후 axios(config)
를 반환합니다.
위와 같이 처리하게 되면 인증이 만료된 경우 자동으로 새로운 토큰을 발급 하여 새로운 토큰을 주입하게 됩니다.
마주한 문제
http와 https
Vercel을 이용해 배포를 진행하였는데, 배포 주소는 암호화된 https
이고 백엔드 서버의 주소는 암호화되지 않은 http
로 Mixed content
에러가 발생하였습니다. 제가 시도한 해결법은 다음과 같습니다.
-
upgrade-insecure-requests 추가
upgrade-insecure-requests는 네트워크 요청을 하기 전에 안전하지 않은 URL을 업그레이드하도록 브라우저에 알려주는 지시문입니다.
<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">
메타 태그에 위와 같은 태그를 추가하여 에러 해결을 시도하였으나, 여전히 에러는 사라지지 않았습니다. -
브라우저 안전하지 않은 콘텐츠 설정
두 번째로 시도한 해결법은 브라우저 자체의 설정을 바꾸는 것이었습니다. 안전하지 않은 콘텐츠 설정을 허용으로 바꾸어 https를 사용하지 않도록 하였습니다. 그 결과 http 서버 통신이 가능하게 되었습니다.
당장 프로젝트를 제출하고 시연해야하는데 오류를 발견하고 아찔했던 기억이 있습니다. 아무래도 다른 파트와의 협업이 처음이라 각자의 개발만 하느라 위와 같은 사항은 고려를 못 했던 것 같습니다. 오류를 통해 http와 https의 중요성에 알게되었습니다. 🥲
프로젝트 회고 ✨
처음 회고를 작성할 때, 벌써 진행한지 2개월이 지난 프로젝트라 기억이 잘 안 나면 어쩌지 하는 마음으로 회고를 작성하기 시작하였습니다. 역시나..! "이해가 하나도 안 가는데, 이게 내가 짠 코드가 맞아?!"라는 생각이 드는 코드들이 가득했습니다. 코드들을 천천히 읽어보며, 기억을 더듬어 갔습니다. 그러다 보니 코드의 흐름들이 보였고 개선할 사항들이 제법 눈에 밟혔습니다. 내가 진짜 이해하지 못하고 프로젝트를 그저 완성시키기 위해 휘갈긴 코드들도 있었고 충분히 재사용할 수 있는 로직들도 많았습니다. 물론 프로젝트 직후 보다, 다른 파트와 협업하여 하나의 프로젝트를 완성한 그 성취감은 희미해졌지만, 시간이 지난 후 코드들을 다시 읽고 문제점을 발견하며 그동안 내가 정체해있지만은 않았다는 것을 느꼈습니다.
프로젝트를 진행하며 가장 아쉬웠던 부분을 하나 꼽자면 팀원들과 소통을 활발히 못한 점을 꼽고 싶습니다. 아무래도 다른 파트와의 협업이 처음이고 서로에 대해 잘 모르다 보니 무엇을 요구해야 하는지, 어떤 식으로 작업을 진행해야 하는지, http/https는 어떻게 처리해야 하는지와 같은 의문이 계속 생기게 되었던 기억이 있습니다. 그뿐만 아니라 같은 파트의 팀원들과도 각자 개발을 진행하느라 충분히 소통을 못한 아쉬움이 있습니다. 개발자라는 직업은 자신의 기술을 가지고 개발만 잘하면 된다고 생각한 제 자신의 생각을 되돌아보는 계기가 되었고 개발자에게 있어서 의사소통 능력은 필수불가결하다는 것을 몸소 알게 되었습니다.
프로젝트를 진행하며 새로운 기술들을 도입하기 위해 부단히 노력했던 것 같습니다.
처음 도입하는 기술들이라 부족한 부분들도 많았지만, 이런 프로젝트에 사용을 안 한다면 더 익숙해지기 어렵고 앞으로 접할 기회가 줄어들 것 같은 마음이 컸습니다.
그래서 폼 관리를 위한 React-Hook-Form
, 스키마와 유효성을 위한 Zod
그리고 서버 상태 관리를 위한 React-Query
등을 도입하였는데, 정말 만족스러운 개발 경험을 가질 수 있었습니다.
단순히 일회성이 아닌, 좀 더 공부하여 효율적으로 사용하고 싶은 마음이 커졌습니다. 점차 사용해 보지 않은 기술들을 지속적으로 접하며 적용할 것입니다.
앞으로 많은 프로젝트를 맡고 진행하게 될 텐데, 기록을 더 열심히 해야겠습니다. 단순히 "~기능 구현"이 아닌 왜 그런 식으로 구현하였는지 이유를 염두에 두며 기록하도록 노력해야겠습니다.
또 필히 결과물을 gif나 영상으로 남겨야겠습니다!
결과물은 이곳에서 확인하실 수 있습니다.