Overview
This post covers a mini project I did in collaboration with the backend team: an introduction to it, how it went, the features I implemented, and my retrospective.
Project Introduction
Lonsily is a loan product recommendation service built on a REST API. The name Lonsily is a blend of "loan" and "easily," meaning getting a loan with ease. In other words, it's a service where you can find and apply for loan products with no hassle.
The team had 8 members in total, 4 frontend and 4 backend, and development ran from 2023.02.13 to 2023.02.24. I was the team leader, and I took charge of the overall project setup, the My Page, liked products, purchase history, user info, role-based routes, the authentication logic, and deployment.
How the Project Went
The theme was already decided: build a financial product shopping mall. Which kind of financial product to pick was up to us. We decided to go with a service that recommends loan products. The backend team built the server and data using the loan product data from the Financial Supervisory Service's Open API. There were some features we had to implement as requirements, and they were as follows.
- Login / sign-up
- Viewing and filtering financial product information
- Adding/removing a specific financial product to/from liked products
- Viewing products registered as liked products
- Adding a specific financial product to the cart
We decided to implement the features by splitting them up into pages, one per feature.
Since the development period was short, we adopted GitHub Flow, a branching strategy that uses only main and feature branches. We also worked by cloning the repo rather than forking it.
After settling on the division of features and the branching strategy, we moved on to choosing the tech stack. The tech stack and the reasons for choosing each are as follows.
- React (CRA)
- Getting work done quickly mattered in this project, so I considered
vite, which is based onesbuild. But since we wanted to useRedux-toolkit, I initialized the React app with theCRAtoolchain, which has a template for it.
- Getting work done quickly mattered in this project, so I considered
- TypeScript
- I chose it because static typing lets you catch errors at compile time instead of at runtime, and IntelliSense boosts code productivity. I also chose it so that I and my teammates could get more comfortable with it.
- Styled-Components
- There were plenty of options like
TailwindCSSandSCSS, but we decided to go withStyled-Components, which the team was most familiar with. Its advantages include conditional styling with props, turning styles into components, SCSS support, and unique class names.
- There were plenty of options like
- Redux-Toolkit
- It was a project requirement, and managing client state is a must in React anyway, so I chose it. Its advantage is that it lets you manage state more concisely than the original
Redux.
- It was a project requirement, and managing client state is a must in React anyway, so I chose it. Its advantage is that it lets you manage state more concisely than the original
- React-Query
- Most of the time, what goes into global state management is actually server state. When that happens, the
storetakes on too much responsibility and things get inefficient. I brought inReact-Queryto manage client state and server state separately. It also uses caching to cut down on API calls, which reduces the load on the server.
- Most of the time, what goes into global state management is actually server state. When that happens, the
- React-Hook-Form
- We use forms for login, sign-up, and editing user info. To handle a form with controlled components, you need to create state and write handler functions to change it. That means even a simple form ends up needing way too much state and too many functions. To solve this problem,
I brought in
React-Hook-Form, which lets you manage forms easily and efficiently.
- We use forms for login, sign-up, and editing user info. To handle a form with controlled components, you need to create state and write handler functions to change it. That means even a simple form ends up needing way too much state and too many functions. To solve this problem,
I brought in
- Zod
Zodis a library forTypeScripttype schemas and validation, and I brought it in to make form validation easier. You can also extract a schema as a type throughinfer.
On top of that, to keep everyone's code consistent, we set up eslint and prettier, and used husky and lint-staged to automate code consistency before commits.
A teammate who knew how to use Figma put together a rough UI, and then we got down to the real work.
😵 This project made me realize just how important UI/UX is..
Feature Implementation
My Page
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>
// ...I built the overall layout of the My Page, using nested routing in React-Router-Dom to set up the child routes.
I also used Redux-Toolkit so that the user info saved in the store at login can be looked up.
Liked Products
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>
// ...When the button is clicked, I used mutate from React-Query's useMutation hook to add and remove liked products.
I also used invalidateQueries(), which invalidates a query so it will fetch new data, to keep the data up to date.
Here I managed liked products with Redux-Toolkit. First, I use the find method to check whether the productId of a liked product in the state matches the productId of the product detail I fetched.
The state value of the liked product found this way goes into the isLiked variable. Then, I conditionally render a filled icon with deleteLikeMutation.mutate() for removing when the product is liked, and an empty icon with likeMutation.mutate() for adding when it isn't.
🤔 Back then I wasn't used to
React-Queryand was so focused on just getting features working that I think I wrote code that's hard to read. If I wrote it again, I'd split the features that handlemutatateout into custom hooks. Also,isLikedfollows a convention that suits a boolean variable, yet it isn't actually used that way, so I failed to follow the convention properly when writing the code.
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));
},
});
// ...
};On the liked products page, I used the useQuery hook so the list of liked products can be looked up.
Just like above, there's a mutatate for deleting a liked product, and I regret not making it a custom hook, since that would have made the code far more reusable.
Purchase History
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"]);
},
});
// ...For purchase history too, I used React-Query's useQuery hook to fetch the data. And to delete purchase history, I used mutatate just like with liked products.
I regret not turning that into a custom hook.
User Info
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;The user info page had to manage a bunch of inputs. So I thought hard on my own about component reusability,
and I made a TextFiled component to manage the inputs. I think typing out every option by hand, instead of using UseFormRegister<TFieldValues>, the type of register that React-Hook-Form provides, hurts the code's readability.
Also, if I made types for id and label and managed them that way, the code would be a bit more readable.
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),
}),
);
};
// ...
Here I used React-Hook-Form and Zod to handle input field management and validation efficiently.
I create a validationSchema for validation and add it to the resolver option of the useForm hook.
Then I used Zod's infer to extract a type from the schema, which made it easy to type the form.
For editing and leaving the service (account deletion), I implemented them with react-Query's mutate. When each mutatate succeeds, I dispatch to update the user info.
Role-Based Routers
For role-based routers, we decided to handle two cases.
1. PublicRouter
const PublicRouter = ({ children }: { children: ReactElement }) => {
const {
auth: { accessToken },
} = useAppSelector((state) => state);
return accessToken ? <Navigate to="/" /> : children;
};PublicRouter covers routes that users who aren't logged in can access (sign-up, login, etc.).
First, I grab the token saved in the store at login. Then, depending on whether the token exists, a ternary operator decides whether to send the user to the main page with <Navigate /> or render children.
2. PrivateRouter
const PrivateRouter = ({ children }: { children: ReactElement }) => {
const {
auth: { accessToken },
} = useAppSelector((state) => state);
return accessToken ? children : <Navigate to="/signin" />;
};PrivateRouter covers routes that logged-in users can access (cart, My Page, liked products, etc.).
If there's a token, it renders children, and if not, it redirects to /signin so the user can log in.
Authentication Logic with axios interceptors
While preparing for the Wanted Pre-Onboarding course, I learned about interceptors, and I decided to apply them to this project.
I implemented different logic for two cases: request for requests and response for responses.
1. request
client.interceptors.request.use((config) => {
if (!config.headers) return config;
if (accessToken !== null) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});request lets you add logic before a request is sent.
Since many endpoints require token-based authentication, I added logic that injects the token into Authorization before the request goes out, depending on whether a token exists.
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);
}
);This handles the case where the token has expired and the response comes back as an error.
If the response data is fine, it returns the response.
If a token exists and the response status code is 401, which shows up when the credentials are invalid, it gets a new token through axios rather than through the instance made with create.
After injecting the newly issued token into Authorization, it returns axios(config).
With this approach, when authentication expires, a new token is automatically issued and injected.
Problems I Ran Into
http and https
I deployed with Vercel, and the deployed address was an encrypted https one while the backend server's address was an unencrypted http one, so a Mixed content error popped up. Here's what I tried to fix it.
-
Adding upgrade-insecure-requests
upgrade-insecure-requests is a directive that tells the browser to upgrade insecure URLs before making network requests.
<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">I tried adding a meta tag like the one above to fix the error, but the error still didn't go away. -
Changing the browser's insecure content setting
The second fix I tried was changing the browser's own settings. I switched the insecure content setting to allow, so that https wouldn't be enforced. As a result, communication with the http server became possible.
We had to submit and demo the project right away, and I remember my heart dropping when I found this error. Since it was my first time collaborating with another part of the team, everyone was just busy with their own development, and I think we didn't consider something like this. This error taught me how important http and https are. 🥲
Project Retrospective ✨
When I started writing this retrospective, I was worried that it had already been 2 months since the project and I might not remember much. And sure enough..! There was plenty of code that made me think, "I don't understand any of this, did I really write it?!" I read through the code slowly and tried to jog my memory. As I did, I started to see the flow of the code, and quite a few things I could improve stood out to me. There was code I scribbled out just to get the project done without really understanding it, and plenty of logic that could easily have been reused. Of course, compared to right after the project, the sense of accomplishment from finishing a project together with another part of the team has faded, but reading the code again after some time and spotting its problems made me feel that I haven't been standing still all this while.
If I had to pick the one thing I regret most about the project, it would be that we didn't communicate actively with our teammates. Since it was my first time collaborating with another part of the team and we didn't know each other well, I remember questions kept popping up, like what to ask for, how to go about the work, and how to deal with http/https. On top of that, even with teammates on the same side, we were each busy with our own development and didn't communicate enough, which I regret. It made me reflect on my old belief that as a developer, you just need to be good at development with your own skills, and I learned firsthand that communication skills are indispensable for a developer.
During the project, I think I worked hard to bring in new technologies.
They were all technologies I was using for the first time, so there were many rough spots, but I strongly felt that if I didn't use them in a project like this, it would be hard to get more comfortable with them and I'd get fewer chances to encounter them later.
So I brought in React-Hook-Form for form management, Zod for schemas and validation, and React-Query for server state management, and I had a really satisfying development experience.
Rather than leaving it as a one-off, I want to study more and use them more efficiently. I'll keep encountering and applying technologies I haven't tried yet.
I'm going to take on and work through many more projects, and I need to be more diligent about documenting them. Rather than simply writing "implemented ~ feature," I'll make an effort to record why I implemented it the way I did.
Also, I must remember to keep the results as a gif or a video!
You can check out the results here.