2024년이 되면서 2023년의 시작과 함께했던 프로젝트를 되돌아보고자 이번 회고록을 작성합니다. 이번 글에서는 아래 내용을 중점으로 이야기하려 해요.
- 프로젝트를 시작한 이유
- 프로젝트 관리 방법
- 도입한 기술 스택과 이유
- 기억에 남는 트러블 슈팅 경험
- 배운 점
프로젝트는 아래 url을 통해 직접 배포된 웹 페이지를 체험하거나 소스 코드를 확인하실 수 있습니다.
서비스 URL : https://likelionhongik.com
깃 레포지토리 URL : https://github.com/Likelion-HongikUniv/likelion-hongik-client
2022년, UX 디자인을 떠나, User Experience Driven Developer가 되겠다는 포부로 디자인을 그만두고 개발로 전향하며 교내 개발 동아리인 '홍익대학교 멋쟁이 사자처럼'에 합류했어요. 그동안 다져온 사용자 중심 디자인 지식과 경험을 보다 직접적으로 '구현'해내는 개발자가 되고 싶었어요.
그 목표를 위해 2022의 1년 동안, 멋사의 테크잇, 노마드코더 등의 강의를 통해 동아리 친구들과 함께 리액트 기반 웹 개발을 배우고 토이 프로젝트들을 경험했어요. 이렇게 프레임워크와 기술들을 하나하나 배운 뒤에, 실제로 사용자가 사용하는 프로젝트를 만들고자 동아리 커뮤니티 사이트를 기획하게 되었어요.
바로 멋쟁이사자처럼 2023년도 11기 신규 회원 모집을 위한 커뮤니티 홈페이지이었어요. 다음 기수 회장 친구의 권유로 시작해 동아리 내에서 공부한 걸 활용하기 위해 팀원들을 구성하고, 추가로 원활한 프로젝트 진행을 위해 디자이너와 프론트엔드 멘토를 영입했어요. 프로젝트에 프론트엔드 리드로 인턴 경험이 있는 친구가 멘토이자 리드로 합류하며 코드를 더욱 개발자 친화적으로, 가독성 높게 작성하는 방향으로 프로젝트를 진행할 수 있었어요.
여기서 저는 디자인 리드와 함께 프론트엔드 서브리드 포지션을 담당했어요. 프로덕트에 있어 가장 중요한 것은 "왜 이 프로덕트를 만드는가?"라고 생각해요. 프로덕트가 가진 목적과 방향성이 뚜렷해야 합류하는 동료들도 주체적인 의식을 갖고 더 나은 프로덕트에 기여한다고 믿고있어요.
그렇기 때문에 UX 디자인 프로세스인 '더블 다이아몬드 프로세스'를 통해 동아리원들의 사용자 경험을 바탕으로 커뮤니티를 기획하고자 했어요.
그래서 진행한 1단계 'Discover'를 통해 수집된 문제에는 '이전 기수가 다음 기수에게 인수인계 하기 편리해야 한다', '동아리원 전체의 정보가 DB에 남아있으면 좋겠다', '프로젝트 내용도 보존되면 좋겠다' 등이 있었어요.
10기로 활동했던 작년 한해를 돌아보았을 때, 이 문제점은 프로젝트 인원들의 큰 공감을 얻었어요. 저 스스로 또한 네이버 카페와 카카오톡 공지방, 일반방 등등으로 분리되어 있는 플랫폼에서 과제 보고와 활동 사진등을 따라잡아 추려내는데 어려움을 겪었었거든요.
모두의 공감을 얻은 문제점을 바탕으로, 노션과 피그마를 통해 더욱 세부적으로 개선된 웹 페이지 기획을 다듬는 'Define'단계로 접어들었어요.
가장 먼저, '11기 회원을 위한 커뮤니티'라는 큰 틀에서, 어떤 기능과 컨셉과 기술 스택을 활용해 개발할 것인가를 두고 약 한달 간 모든 개발 구성원들간의 1단계 'Discover' 기획 회의를 가졌어요.
그러면서 찾아낸 Pain point들을 바탕으로 로우파이 디자인과 화면 기획서등을 기획할 수 있었어요. 또, 문제들을 구체화하고 기능을 기획하며 DB나 서버 스펙등을 가늠하고 이를 문서들로 정리했어요.
그리고 정리한 문서를 토대로 각 구성원이 어떤 페이지와 기능을 개발할 것인지 순차적으로 역할을 분배했어요. 디자인 시스템은 피그마로 관리하고, 프로젝트 전체 관리는 노션과 디스코드를 활용해 각 주차별 공지와 자료 공유, 회의록 정리를 진행했어요.
피그마를 통해 정리한 페이지 사이트맵
2학기부터 웹 기획, 스터디를 병행하며 다음 년도 2월까지 개발과 테스팅을 진행했어요.
기능 명세서를 통해 구현할 API와 역할 분담을 체계적으로 진행했어요.
프로젝트에서 도입한 기술 스택은 아래와 같아요.
프레임워크 : React
스타일링 : Styled-Components
상태관리 : Recoil
데이터 캐시 : React-Query
코드 정적분석 : TypeScript, ESLint, Prettier
왜 이 기술들을 채택했냐 하면 그 이유는 두가지로 설명할 수 있어요.
첫째, 동아리에서 교육이 이루어진 기술들이에요. 프로젝트 구성원이 동아리원들로 이루어져 있고 동아리내 강의와 스터디로 학습한 내용을 실무로 적용해보자는 의미에서 채택하게 되었어요.
둘째, 생태계가 활발하고 진입 장벽이 낮아요. 선택한 기술들은 온라인 상에 학습 자료가 많이 배포되어 있어요. 따라서 동아리원들이 주도적으로 학습하고 어려움이 있을 때에 같이 해결 방법을 고민하고 페어 프로그래밍으로 해결할 수 있을거라 예상했어요.
특히 이런 고민이 빛을 발한 건 Recoil
을 도입하며 간편해진 컴포넌트 상태 관리 부분이에요.
상태관리 매니지먼트인 Recoil
을 도입하게 된 계기는 크게 3가지가 있어요.
- 컴포넌트 사이
Props Drilling
발생- Tree에서 레벨이 다른 컴포넌트 사이 State 공유
- 즉각적인 UI 동기화 필요
위와 같은 이유로 프로젝트에 Recoil을 쓰게 되었는데요, Mypage와 그리고 Header 컴포넌트의 state 관리, 마지막으로 로그인에 따른 게시글 페이지 관리 예시를 보여드릴게요.
위 이미지는 마이페이지의 UI 디자인이에요. 사이드바에서 유저의 닉네임과 팀을 노출하고 있고, 동시에 마이페이지에서 유저가 자신의 개인 정보를 변경하면 사이드바의 내용도 즉각 변경되도록 해야 했어요.
여기서 중요했던 점은 컴포넌트 상태에 따라 또 다른 컴포넌트의 상태도 변경될 때를 파악해야 했다는 점이에요. Props를 기반으로 컴포넌트 사이의 정보를 동기화하려 했더니 말로만 듣던 Props Drilling
이 무엇인지 체감할 수 있었어요.
멋사 커뮤니티 프로젝트의 디렉토리 구조는, src/component 하위에 페이지별로 필요한 component를 담고 있었어요. 그런데, Mypage같은 경우 페이지에 필요한 EditWrapper 내부에 각 세부 항목 별 Edit 컴포넌트들이 child로 들어가 있는 구조였어요.
그리고 각 NickEdit
과 MajorEdit
, PartEdit
안에서 수정한 state들을 다시 끌어올려서 부모인 EditPart
에 전달해 api로 보내야 했어요. 이를 위해서는 useState 단독으론 기능 구현하기에 어려움을 느껴, useRecoilState
를 통해 자식들과 부모 사이 상태 관리를 도입해 해결했습니다.
//EditPart.tsx
/* 초반 생략 */
// profile에 필요한 모든 정보는 editState에 저장되어 있어요.
const [info, setInfo] = useRecoilState(editState);
...
//state를 업데이트하기 위한 customHook에 recoil state의 값을 넣어주었어요.
const changeNickname = useNickInput(info.nickname);
const changeMajor = useInput(info.major);
const changePart = useSelect(info.part);
...
return (
<EditWrapper>
<BasicEdit />
<Flex>
<div>
<NickEdit {...changeNickname} />
<MajorEdit {...changeMajor} />
<EditTitle>멋사 정보 변경</EditTitle>
<PartEdit {...changePart} />
<SaveBtn
disabled={/* check logic*/}
onClick={onClickSave}
>
저장
</SaveBtn>
</div>
</Flex>
</EditWrapper>
);
}
이렇게 서로 다른 UI 컴포넌트 사이의 상태 변경을 즉각적으로 관리해야 하는 '프로필', '사이드바', '헤더', '게시글' 등의 컴포넌트를 관리하기 위해 Recoil을 이용한 상태 관리를 도입했어요. 다음으로 Header component를 살펴볼게요.
Header는 사용자의 로그인 여부에 따라 다른 UI를 보여주어야 했어요. 그리고 로그인 전후로 onClick event에 이어지는 로직도 달랐기 때문에 로그인 상태는 서비스의 모든 페이지에서 계속 관리되어야 했어요. 그래서 전체 html 바디 최상단에 Header컴포넌트를 렌더링하고, RecoilState를 통해 Login state를 트래킹했어요.
// useAutoLogin.ts
const setIsLoggedIn = useSetRecoilState(isLoggedInState);
const [userInfo, setUserInfo] = useRecoilState(userState);
// src/components/elements/Header.tsx
export function Header() {
useAutoLogin();
const setNavTag = useSetRecoilState<Tag>(tagState);
const [isLoggedIn, setIsLoggedIn] = useRecoilState(isLoggedInState);
/* 생략 */
}
위 이미지는 커뮤니티 게시글의 상세 화면이에요. 위에서 적었던 중요한 점과 동일하게 이 페이지에서도 사용자가 만약 댓글 작성
한다면 페이지 댓글 리스트 하단에 새로운 댓글 컴포넌트를 추가해야 했어요. 따라서 새로 등록한 댓글과 대댓글, 좋아요 수를 관리하기 위해 recoil state를 사용했어요.
export function PostPage() {
const [board, setBoardData] = useRecoilState<IBoard>(boardState);
const [comments, setCommentsData] = useRecoilState<IComment[]>(commentsListState);
...
return (
<Section>
<Column>
<Board {...board} />
<CommentsList {...comments} />
</Column>
</Section>
);
}
처음에는 위에 작성한 코드처럼 boardState
와 commentsListState
라는 Recoil state 두 개를 두고, 각각 게시글의 전체 상태와 댓글들의 상태를 관리했어요.
여기서 boardState
는 게시글의 좋아요를 컨트롤하기 위한 state였어요. 사용자가 좋아요 버튼을 클릭하면 Like fetch API를 통해 Likes 수가 변경되는데요, 이때 서버에서 데이터를 받아와 전체 페이지를 다시 렌더링하는게 아니라 일단 먼저 클라이언트에서 Like를 업데이트하도록 만들기 위해 boardState에 Recoil을 써보았어요. 그래서, 서버 상의 업데이트와 클라이언트 상 렌더링을 분리할 수 있었죠.
다음으로 댓글은 commentList
라는 이름의 Recoil state 배열로 관리해보았어요. 이 작업도 boardState
와 유사하게 서버와 클라이언트 렌더링을 분리하는 시도였어요. 신규 댓글이 작성되면 서버에서 데이터를 refetch하는 대신, 클라이언트에서 유저 데이터와 input value등 댓글에 들어갈 컨텐츠들을 묶어 state 배열에 추가했고 그에 따라 페이지 최하단에 새 댓글이 렌더링되도록 했어요.
하지만, Get API로 받아오는 Board 데이터를 뜯어보면 그 구조가 아래와 같아요.
{
"postId": 17,
"author": {
"authorId": 1,
"nickname": "김스르으응ㄹ기",
"profileImage": null,
"isAuthor": true
},
"title": "게시문 5번쨰",
"body": "게시물 5번쨰",
"createdTime": null,
"likeCount": 0,
"comments": [
{
"id": 24,
"author": {
"authorId": 1,
"nickname": "김스르으응ㄹ기",
"profileImage": null,
"isAuthor": true
},
"body": "댓글 2번쨰",
"isDeleted": false,
"createdTime": "2023-01-17T17:40:41.041311",
"likeCount": 0,
"replies": [
{
"id": 25,
"author": {
"authorId": 1,
"nickname": "김스르으응ㄹ기",
"profileImage": null,
"isAuthor": true
},
"body": "대댓글 1번쨰",
"createdTime": "2023-01-17T17:41:04.001386",
"likeCount": 0,
"deleted": false
}
]
}
],
"imageUrls": []
}
...
보다시피 게시글 데이터의 구조가 이미 BoardData
내에 Comments
의 배열 데이터가 포함되어있는 형태이기 때문에 이 두 데이터를 Recoil State로 관리하면 중복이 생겼어요. 그래서 이 중복을 없애고자 API 통신이 성공하면 refetch가 일어나도록 React-Query을 사용해 로직을 개선했어요.
React-query의 Query
객체는 자기 자신을 가진 QueryCache
객체와 함께 상태가 변경되었을 때 호출할 수 있는 Observer
옵션을 갖고 있어요. 당시 작업 기록에 따르면 캐시 이슈를 아래와 같이 기록해두었더라구요.
캐시가 남아있어, 쿼리 키에 따른 데이터가 바뀌면 지난 데이터를 렌더링하는 문제가 발생했다. 그래서 refetch 실행과 함께 RecoilTransactionObservation이란 snapshot tracker를 등록해 새로 렌더링했다.
지금은 Mutate
를 사용하지만 당시엔 React-Query 사용에 익숙치 않아 Observer에 transactionObservation_UNSTABLE
라는 옵션을 주고 data 변경 시 RecoilState에 대해 setBoardData가 다시 일어나게 만들었어요. 따라서, QueryProvider로 App을 감싸뒀다면 변경된 상태값이 전부 적용되도록 했어요.
// useGetPostDetail.js
...
type QueryResult = {
board: IBoard;
status: string;
};
export function useGetPostDetail(postId: number): QueryResult {
const [board, setBoardData] = useRecoilState<IBoard>(boardState);
const { status, refetch } = useQuery(["postData", postId], async () => await getPostDetail(postId), {
onSuccess: (data) => {
setBoardData(data);
},
enabled: !!postId,
});
useEffect(() => {
refetch();
}, [postId]);
// useTransactionObservation_UNSTABLE hook :
// observe changes to the Recoil state. This function takes a callback that is called
// whenever a transaction is committed. In the callback, we check if the current value of
// boardState is different from the previous value, and if so, we update the board state
// using the setBoard function.
const transactionObservation_UNSTABLE = useRecoilTransactionObserver_UNSTABLE(({ snapshot }) => {
const currentBoard = snapshot.getLoadable(boardState).valueOrThrow();
if (currentBoard !== board) {
setBoardData(currentBoard);
}
});
return { board, status };
}
// postPage.tsx
...
export function PostPage() {
const { id } = useParams<{ id: string }>();
const { board, status } = useGetPostDetail(id); // QueryProvider가 state를 감지하면 board 데이터가 변경돼요.
if (status === "loading" || !board) {
return <div>Loading...</div>;
}
return (
<>
<Header />
{isPC && board && (
<Section>
<Column>
<Board {...board} />
<CommentsList {...board.comments} />
</Column>
</Section>
)}
...
</>
);
}
그땐 이렇게 작성해서, 표면상으로 state가 적절히 관리되고 있는 것처럼 보였어요. useMutate와의 차이도 시각적으로 두드러지지 않아 transactionObserver를 그대로 두었는데 이후에 useMutate
를 postReply api에 적용해 썼어요.
// api/postReply.ts
async function postReply(props: ReplyProps) {
const token = localStorage.getItem("token");
return await client
.post(`community/comment/${props.id}`, JSON.stringify(props.body), {
headers: {
"Content-Type": `application/json`,
JWT: token,
},
})
.then((response) => {
if (response.status === 200) {
return response.data;
}
});
}
export const usePostReply = () => {
const queryClient = useQueryClient();
return useMutation(postReply, {
onSuccess: () => {
return queryClient.invalidateQueries({ queryKey: ["postData"] });
},
});
};
이렇게 postReply에 mutate를 적용하며, onSuccess에 바로 query validation으로 캐시 데이터들을 즉각적으로 수정해줄 수 있었어요!
저는 이 프로젝트를 통해서 처음으로 React-Query를 다루어 보았어요. useQuery의 콜백 동작에 async/await 키워드를 사용하며 비동기 동작을 더 자세히 살펴보아야 했고 query의 개념을 이해하느라 바쁜 나머지 mutation
의 역할을 이해하지 못했었어요. 이후 프로젝트들에선 점차 개선해, 먹팟에선 mutation으로 깔끔한 캐시 동기화를 할 수 있었어요. 짧게 남겨놓은 평가는 아래와 같이 남아있어요.
useQuery 동작의 option이 매우 다양해서 라이브러리를 계속 들여다봐야했음.
시간을 써도써도 잘 모르겠다 ㅠ- ㅜ
쿼리키와 스테이트간 연관 관계가 어떻게 되는걸까?
그럼에도, 이 프로젝트를 통해 새로운 기술 도입에 겁을 내지 않게 되었어요. 이전까진, 지금 배우는 것도 벅차 새로운 기술을 쓰게 되면 오히려 나쁜 결과를 초래하지 않을까 겁이 났었거든요. 그렇지만 오히려 새로운 걸 쓰며 계속해서 왜 생태계에 해당 기술이 나타났는지 알아보기도 하고, 변하는 와중에도 변하지 않는 핵심을 찾아 나갈 수 있었어요.
또한 기존의 860줄 코드에서 React-Query를 도입하며 524줄의 코드로 약 60% 가량으로 줄였으며, client 로직과 server 로직 분리를 고민한 경험이었어요.
그리고 무엇보다 밤새워 만든 커뮤니티로 다음 학기에 300명이 넘는 학생들이 동아리에 지원해주었다는 소식과 실제로 공지, 과제에 사용하고 있다는 이야기에 실제 사용자들을 만나 너무 기뻤어요.
마지막으로 프로젝트에서 아쉬웠던 점을 이야기하면 어려웠던 점에서 이야기한 것과 비슷한데요,
프로젝트 볼륨에 비해 Test code 작성이나 StoryBook을 도입하지 않아서 아쉬웠던 점과 상대경로 사용 등을 개선할 수 있을 것으로 보여요.
이 프로젝트는 후에 회장이었던 친구가 전국 멋사 대학 커뮤니티 프로젝트로 발전시켰어요!
훨씬 더 개선된 코드와 디자인과 기능들로 무장한 업그레이드 버전의 프로젝트에요.
아래 링크로 접속하시면 대학 별 멋사 모집 알림 신청과 커뮤니티를 열람하실 수 있어요. 이번에 멋사 대학의 혜택이 더욱 확대된다 하니 관심있는 분들은 알림 신청을 해두길 추천해드려요.
감사합니다!