
Linkbrary는 내가 즐겨찾기 해둔 사이트들을 카드 형식으로 모아 하나의 페이지에서 관리하고 필요하다면 공유 기능까지 제공하는 간단한 프로젝트다.
작업 기간은 대략 2주정도 걸렸다. 학원에서 초급 프로젝트가 끝나고 중급 프로젝트를 시작하기 이전에 시간이 붕 뜨는 느낌을 받아 "비교적 쉬운 난이도의 프로젝트를 하나라도 더 해보자!" 하는 취지였기 때문에 집중력 있게 진행이 됐고 덕분에 빨리 끝낼 수 있었던 것 같다.
박문균 (👑 PM) | 전상민 | 구민지 | 정준영 | 홍예림 | |
|---|---|---|---|---|---|
| 역할 | 팀장으로 프로젝트 참여 로그인 / 회원가입 기능 간편 로그인 / 회원가입 | 랜딩 페이지 구현 유저 정보 관리 소셜 공유 | 즐겨찾기 페이지 즐겨찾기 기능 링크 수정 / 삭제 기능 | 링크 페이지 구현 | 폴더 / 링크 관리 모달 구현 |
| 이메일 | mungyun1234@naver.com | venise5224@gmail.com | rnalswl96@naver.com | wn8624@naver.com | hongggy@gmail.com |
| GitHub | mungyun | venise5224 | 99minji | junjeeong | hongggyelim |
함께한 팀원들이다. 코드잇 sprint 9기에서 만난 친구들이고 따로 스터디를 만들어 진행한 프로젝트가 linkbrary였다.
내가 담당한 페이지는 "/link" 페이지였다. 주 컨텐츠였던 만큼 다양한 상호작용 요소들이 존재했다. 해당 페이지에서 구현해야 했던 기능들은 다음과 같았다.
우선 이번 프로젝트는 Next 기반 프로젝트였다. Next가 줄 수 있는 장점 중 ServerSideRendering을 활용하고자 했기 때문이다.
ServerSideRendering을이라 함은 기존에 ClientSideRendering이 갖고 있었던 "FCP(First-Contentfull-Paint)가 느리다, 브라우저가 JS를 실행할 동안 사용자는 빈 화면을 경험해야 한다"라는 단점을 보완하기 위해 서버에서 정적인 요소만 있는 HTML을 완성해 우선적으로 브라우저에게 제공하는 강력한 메커니즘이다.
나 또한 이 부분을 적극 활용하고 싶었고 실제로 사용자가 페이지 최초 접속시에는 해당 페이지에서 필요로 하는 1)링크 목록과 2)폴더 목록을 fetch 하는 로직을 getServerSideProps 함수안에 정의해 주었다. 코드는 다음과 같다.
// /link 페이지 접속시에 초기렌더링 데이터(전체 폴더, 전체링크리스트)만 fetch해서 client로 전달.
export const getServerSideProps = async (
context: GetServerSidePropsContext
) => {
const { req } = context;
const cookies = parse(req.headers.cookie || "");
const accessToken = cookies.accessToken;
// accessToken이 없으면 클라이언트에서 실행될 때 /login 페이지로 이동시킴.
if (!accessToken) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
const fetchData = async (endpoint: string) => {
const res = await axiosInstance.get(endpoint, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return res.data;
};
const [links, folders] = await Promise.all([
fetchData("/links"),
fetchData("/folders"),
]);
return {
props: {
linkList: links.list || [],
folderList: folders || [],
totalCount: links.totalCount || 0,
},
};
};
Litehouse로 측정한 FCP 수치이다. Seo 수치는 많이 낮게 나와 최적화가 필요해 보인다.
프로젝트에서 로그인/회원가입 기능을 제공하다 보니 자연스레 회원 정보(ex.accessToken, 사용자 이름,아이디,비밀번호,핸드폰번호) 보안이슈를 신경쓰지 않을 수 없었다.
더구나 사용자 정보를 저장할 때 전역으로 접근이 가능한 브라우저 스토리지인 세션 스토리지나 로컬 스토리지에 저장하는 행위는 XSS 공격에 취약하다라는 정보를 알게 되었다.
이를 위한 대책으로 Next에서 제공해 주는 API Routing 기능을 활용해 사용자가 로그인을 했을 시에는 만들어 둔 API 핸들러에 요청을 보내 HttpOnly, Secure 옵션이 달린 쿠키에 사용자 정보를 담게 해주었고
이후에 브라우저와 서버간에 인증/인가 절차가 필요한 요청은 모두 API 핸들러를 우회하게 하였고 Next 서버에서 쿠키를 열람한 이후에 기존에 백엔드 서버로 요청을 대신 보내주는 일종의 프록시 서버를 구축하는 방법을 선택했다.
아래는 로그인 시 브라우저에게 요청을 받는 API handler 함수이다. 파일 경로는 /pages/api/auth/sign-in/index.ts이다.
import { serialize } from "cookie";
import axiosInstance from "@/lib/api/axiosInstanceApi";
import { NextApiRequest, NextApiResponse } from "next";
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "POST") {
try {
const { email, password } = req.body;
//string으로 들어와서 에러 날수도 있음
const response = await axiosInstance.post("/auth/sign-in", {
email,
password,
});
const token = response.data.accessToken;
if (token) {
// 토큰을 쿠키에 저장 (httpOnly, secure 설정)
res.setHeader(
"Set-Cookie",
serialize("accessToken", token, {
httpOnly: true, // 클라이언트에서 접근 불가
sameSite: "lax",
maxAge: 60 * 60 * 24, // 1일 동안 쿠키 유지
path: "/", // 루트 경로에 쿠키 적용
// secure
})
);
return res.status(200).json({ message: "로그인 성공" });
} else {
return res.status(401).json({ message: "권한 없음" });
}
} catch (error) {
return res.status(500).json({ message: "서버 오류" });
}
} else {
res.status(405).json({ message: "허용되지 않은 접근 방법" });
}
};
export default handler;
사용자 경험 개선을 위해 가장 중요시 여겼던 것은 사용자가 혹여나 빈 화면을 목격하거나 아무런 동작을 하지 않는 경험을 해서는 안된다 점이었다. 이를 개선하기 위한 방법 중 하나로 "스켈레톤 UI"라는 개념을 알게 되었고, 즉시 이를 채택하였다.
내가 맡은 페이지에서 스켈레톤 UI를 적용한 이벤트는 "사용자가 폴더를 클릭하는 시점"이었다. 폴더를 클릭하면 해당 링크 목록을 다시 서버에서 요청하여 화면을 리렌더링하기 때문이다. 이때 서버 요청 함수는 비동기 요청이므로, 중간에 isLoading이라는 상태 값을 정의할 필요가 있었고 isLoading이 true일 경우에는 미리 만들어 둔 LinkCardSkeleton라는 컴포넌트를 렌더링하도록 설정했다. 결과물은 다음과 같다.
사용자가 linkbrary를 사용하는 목적은 뚜렷하다. 내가 즐겨찾기 해 둔 링크들을 하나의 웹 페이지에서 바로 보고자 하는 것! 하지만 홈페이지에서 "링크 추가하기" 버튼을 누르니, 왠걸 로그인 페이지로 넘어간다.
우리가 북마크를 사용할 때 크롬에 로그인을 했던가? 아니다. 그만큼 링크를 관리하고 이를 페이지에서 바로 보여주는 단순한 기능의 프로젝트라면, 주 기능을 경험하기까지의 진입 시간이 최대한 빠르고, 경험하는 UI 또한 최대한 단순해야 한다고 생각한다. 주 기능 페이지로 들어갈려고 했는데 로그인을 해야 한다고 턱 막혀버리면 이는 분명 사용자 경험에 치명적인 요소로 작용할 것이다.

위에 사진은 홈페이지, 링크 페이지에서 목격되는 loading spinner 또는 skeleton UI이다.
loading indicator는 사용자로 하여금 페이지가 작동을 멈춘 것이라고 생각하지 못하게끔 중간에 잠깐 보여주는 녀석이다. 만약 사용자가 indicator가 보여지는 것이 인상에 남을 정도라고 한다면 그만큼 사용자가 페이지를 이탈할 여지를 주는 것이라고 생각한다.
코드를 까뒤집어보며 비동기 요청을 1ms라도 줄일 수 있는 방법을 찾아봐야 할 것 같다.
+@ 데이터가 없을 때에도 skeleton ui를 보여주고 있다. 사용자는 skeleton ui를 보며 해당 위치에 분명 컨텐츠가 띄워질 줄 예상하고 있으나 결과는 아무것도 없으니 적잖이 당황스러울 것이다.


링크 페이지에서 자체적으로 정의한 상태값들이다. 아무래도 서론에 이야기했듯이 링크 페이지가 주 컨텐츠 페이지이고 사용자와 상호작용 하는 요소들이 많다보니 자연스러운 현상이다. 하지만 너무 가독성이 떨어진다. useLinkPageState라는 이름의 커스텀 훅을 만들어 한번에 모든 상태를 반환받을 수 있게끔 추상화를 하면 좋을 것 같다.
return (
<>
<div className="bg-gray100 w-full h-[219px] flex justify-center items-center">
<AddLinkInput folderList={folderList} />
</div>
<Container>
<main className="mt-[40px] relative">
<SearchInput />
{search && <SearchResultMessage message={search} />}
<div className="flex justify-between mt-[40px]">
{folderList && <FolderTag folderList={folderList} />}
{!isMobile && <AddFolderButton setFolderList={setFolderList} />}
</div>
<div className="flex justify-between items-center my-[24px]">
{folder && (
<>
<h1 className="text-2xl ">{folderName as string}</h1>
<FolderActionsMenu
setFolderList={setFolderList}
folderId={folder}
linkCount={totalCount as number}
/>
</>
)}
</div>
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(3)].map((_, index) => (
<LinkCardSkeleton key={index} />
))}
</div>
) : linkCardList.length !== 0 ? (
<>
<CardsLayout>
{linkCardList.map((link) => (
<LinkCard key={link.id} info={link} />
))}
</CardsLayout>
<Pagination totalCount={totalCount as number} />
</>
) : (
<RenderEmptyLinkMessage />
)}
</main>
</Container>
{isOpen && <Modal />}
{isMobile && (
<AddFolderButton setFolderList={setFolderList} isModal={true} />
)}
</>
);
};
링크페이지 컴포넌트가 리턴하는 JSX 전체이다. 조건부 렌더링이 너무 많아 가독성이 많이 안 좋아진 상황이다. 상태를 정의하는 로직이 너무 많아 useLinkPageState로 추상화 해야할 필요가 있었듯이 return하는 JSX 문 또한 또 하나의 컴포넌트로 분리할 필요가 있어보인다.
공부를 할 때 자주 들었던 말이 있다면 "코딩에 정답은 없다."는 말이었다. 이 논제도 똑같지 않을까 싶다ㅎㅎ 중요한 것은 why이다. 어떤 경우에 컴포넌트로 분리하는 것이 좋을까? 컴포넌트로 분리하는 것을 고민할 때 어떤 것들을 고려해야 하는지 gpt에게 물어보았다. 답은 다음과 같았다.
1. 가독성 향상: 코드가 더 깔끔해지고, 각 컴포넌트의 역할이 명확해집니다. 특히, 복잡한 로직이 있을 경우 유용합니다.
2. 재사용성: 분리된 컴포넌트는 다른 곳에서도 재사용할 수 있어 코드 중복을 줄일 수 있습니다.
3. 유지보수 용이성: 특정 컴포넌트를 수정할 때, 전체 코드를 이해할 필요가 줄어들어 유지보수가 쉬워집니다.
컴포넌트로 분리했을 때 코드가 간결해진다거나, 분리하는 컴포넌트가 다른 곳에서도 사용할만한 여지가 있다거나, 부모 컴포넌트와 자식 컴포넌트간의 결합도가 그렇게 높지 않다면 충분히 분리할 만 하다는 이야기인 것 같다.
만약 그렇지 않고 단순 가독성 만을 위한 분리라면, 또 코드가 1-2줄밖에 되지 않는다면 반드시 분리할 필요는 없어보인다.
여차저차 해서 프로젝트는 결국 끝이 났다. 기능은 잘 돌아가 뿌듯하긴 하지만 왜인지 짜잘한 버그들, 개선해야 하는 부분들이 눈에 많이 들어와 완성도가 떨어지는 느낌이다... 불쾌하면서 또 한편으로는 재밌어서 좋다.
기능을 구현할 때 만큼 리팩토링 하는 과정 또한 개발적 사고를 키우기에 너무 좋은 시간이라고 생각이 든다.
다음 포스팅은 앞서 다뤘던 개선해야 할 부분들을 하나하나 리팩토링 해나가는 과정에 대해서 세세하게 다룰 예정이다.