✨ 들어가기
프론트엔드 개발을 할때 확정된 기획으로 개발에 들어가게 된다. 하지만, 시간이 지나면서 사용자 피드백을 통한 요구사항이 변하는 건 당연한 과정이기도 하다. 지난 시간동안 여러가지 프로젝트를 해보면서, 요구사항에 빠르게 대응하기 위해서는 컴포넌트 그 자체의 역할을 할 수 있도록 만들고, 비즈니스 로직은 컴포넌트 외부에서 관리하는 것이 필요하다고 깨달았다. 이 부분이 뒷받침되는 설계로 프로젝트를 관리하면 뷰와 로직을 분리할 수 있게 될 거고, 개발 속도도 보다 빠르게 진행할 수 있을 것이라고 판단했다.
그래서 이번에 새롭게 시작한 Few 프로젝트에 Model계층을 기반으로 개발한 내용을 공유하고자 한다.
우선, Few는 매일 아침 8시에 뉴스레터를 이메일로 보내주고 매일 조금씩 읽는 습관 더 나아가 학습의 습관을 제공하는 서비스이다.
여러 개의 아티클을 하나의 학습지로 묶어서 하루에 하나씩 아티클을 읽도록 하고, 이후 간단한 문제풀이를 통해 온전한 지식 습득을 만들어주는 웹/앱이다..!
📌 MVP 기획안
빠른 검증을 위해 MVP는 로그인 없이 워크북별(작가들의 컨텐츠 모음집) 구독시, 입력하는 email로 사용자의 구독정보를 관리하고, 이메일 전송을 통해 사용자가 얼마나 워크북 페이지 & 아티클 페이지 & 문제 풀이 페이지로 유입되는지 검증하는 방향으로 기획이 만들어졌다.
일부 요구사항은 아래와 같았다.
- 메인 화면 X
- 메인 url로 접속시, 노션 페이지로 이동
- 노션 페이지 내부에 각 워크북 링크 임베드확인 가능
- 워크북 선택시 , /workbook/{id} 로 진입
즉, 아래의 사진처럼 워크북을 구독하게 되면, 사용자의 email에 대해 여러 워크북들의 구독 데이터가 쌓이게 된다.
그렇기에 API 스펙 논의를 진행할때도, 도메인 별로 API를 분리하는 방향으로 진행하였다. 위 사진에서는 워크북의 정보를 가져오는 API와 이메일을 통한 사용자의 구독 상태를 입력하고 조회하는 API로 방향을 정했다고 이해하면 된다.
즉, MVP의 워크북 상세 페이지에서 데이터를 보여주기 위해서는 워크북의 정보를 가져오는 API 1개만 호출해서 각 컴포넌트를 렌더링 해주면 되는 것이다. 사실 이 상황에서는 모델이 필요한가..? 라는 생각이 들 수도 있다. 왜냐하면 충분히 모델 없이도 API 스펙에 맞춰 컴포넌트의 Props를 명명하고 설계하면 구현가능하기 때문이다.
하지만, 프론트엔드에서 단일 API만 호출하는 경우도 있겠지만, 서비스 규모가 커지거나 도메인별로 분리된 API 환경에서는 한 페이지에서 보여주는 데이터들이 복수의 API의 응답값의 조합으로 구성 될 가능성이 높아지게 된다는 것은 당연하다.
우리 프로젝트도 위 요구사항을 피할 수는 없었다.
📌 1차 요구 사항
그렇게 MVP의 성공적인 론칭이후, 논의를 거쳐 만들어진 1차 요구사항은 다음과 같다.
- email을 통한 사용자 로그인/회원가입 기능
- 메인 화면 페이지
- 카테고리별 워크북 리스트 카드 (좌우스크롤)
- 카테고리별 아티클 리스트 카드 (무한스크롤)
MVP 기획에서는 존재하지 않았던 메인 화면이 아래와 같이 설계되었다.
위 요구 사항만 보았을 때는, 기존에 있던 워크북 상세 조회 API & 아티클 상세 조회 API를 각 컴포넌트에서 따로 부르면 이또한 별문제는 없는 것으로 판단할 수 있다.
그러나 세부 요구사항은 다음과 같았다.
공통 전제 (메인화면 , 워크북 카드 상세 정보) |
로그인 X ( Default ) / 로그인 O + 구독이전 상태 |
로그인 O + 구독중 | 로그인 O + 학습완료 |
이미지 썸네일 | 워크북 썸네일 | 워크북 썸네일 | 워크북 썸네일 |
좌측 상단 뱃지 | 없음 | 현재 학습중 | 학습 완료 |
카테고리 | 워크북 카테고리 | 현재 학습Day/총 Day | 현재 학습Day/총 Day (파란색 bold 글자) |
워크북 제목 | 제목 | 제목 | 제목 |
워크북에 속한 작가이름 리스트 | 작가이름 리스트 | 작가이름 리스트 | 작가이름 리스트 |
학습 중인 유저수 | 학습 중인 유저수 | 학습 중인 유저수 | 총 학습 완료한 유저수 |
하단 버튼 | 구독하기 (클릭시, 구독 API 호출) |
현재 Day 학습하기 (클릭시, 해당 아티클로 이동) |
공유하기 (클릭시, 해당 url 복사) |
심지어 버튼에 대해서는 각 상태에 따라 처리되는 이벤트가 달라져야 했다.
위 표에 대한 디자인은 아래 사진과 같다.
그렇다면 여기서 생각해볼 수 있는 부분은 API의 설계가 도메인별로 나뉘어져 있다는 사실이다.
해당 기능을 구현하기 위해서는 아래와 같은 절차가 필요하다.
- 해당 페이지에서 로그인 여부에 따라 호출하는 API의 종류가 달라진다.
- 로그인을 하지 않은 유저인 경우, 단일로 워크북 상세정보를 가지고 있는 워크북 리스트 API를 호출하여 데이터를 보여준다.
- 하지만, 로그인을 한 유저라면 워크북 리스트 API와 자신이 구독하고 있는 워크북의 리스트 API를 호출한 2개의 데이터 결과 값을 조합해서 사용자에게 보여준다.
사실 이 부분을 클라이언트에서 처리하지 않고, 서버측에서 토큰 여부에 따라 로그인 한 유저 및 메인페이지 조회라는 조건 안에서 내부적으로 데이터를 조합해서 하나의 API로 데이터를 내려주는 것도 다른 방법이 될 수도 있다는 생각도 했다.
하지만, 이렇게 된다면 화면에 종속된 API가 만들어진다는 단점과 서버측에서 여러 테이블을 오고가야 하는 불편한 과정이 추가될 가능성이 높아진다고 판단했다.
드디어 Model을 설계한 시간이 빛낼때가 온 것이다.
👀 WorkbookCard - Component
사용자의 로그인 여부 및 구독상태와는 관계없이 워크북 카드 컴포넌트 자체만 바라보았을때, UI는 동일할 뿐 그 안에 들어가는 텍스트와 이벤트 핸들러의 동작이 다른 것이다. 따라서 워크북 카드 내 세부 컴포넌트를 우선 Compound Component Pattern으로 구현하였다.
// ✅ 이미지 썸네일
const MainImage = ({
mainImageUrl,
}: Pick<WorkbookClientInfo, "mainImageUrl">) => (
<Image
width={269}
height={172}
src={mainImageUrl}
alt="main-image"
loading="lazy"
className="h-[172px] w-[269px] rounded-t-lg object-cover"
/>
);
// ✅ 이미지 썸네일 좌측 상단 뱃지
const CardBadge = ({ badgeInfo }: Pick<WorkbookClientInfo, "badgeInfo">) => (
<div
className={cn(
"absolute left-[13px] top-[14px] w-fit",
"px-[6.3px] py-[3.4px]",
"rounded-[3.2px] text-[10px]/[15px] font-extrabold",
badgeInfo.className,
)}
>
{badgeInfo.title}
</div>
);
// ✅ 워크북 제목
const Title = ({ title }: Pick<WorkbookClientInfo, "title">) => (
<p className="body3-bold w-auto truncate py-[2px] text-white">{title}</p>
);
// ✅ 작가 이름 리스트
const WriterList = ({ writers }: Pick<WorkbookClientInfo, "writers">) => (
<ul className="sub3-medium flex gap-1 pb-[10px] text-text-gray2">
{writers.map((writer, idx) => (
<li key={`workbook-writer-${idx}`}>{writer}</li>
))}
</ul>
);
// ✅ 유저의 수와 관련된 컴포넌트
const PersonCourseWithFewLogo = ({
personCourse,
}: Pick<WorkbookClientInfo, "personCourse">) => (
<div className="flex justify-between pb-[26px] pt-[10px]">
<span className="sub3-medium text-text-gray3">{personCourse}</span>
<FewLogo width={20} height={20} fill="#264932" />
</div>
);
// ✅ 하단 버튼
const BottomButton = ({
buttonTitle,
handleClickBottomButton,
}: Pick<WorkbookClientInfo, "buttonTitle"> & {
handleClickBottomButton: () => void;
}) => (
<Button
className={cn(
"sub3-semibold bg-white text-black",
"h-fit rounded py-[4.5px]",
"hover:bg-white",
"focus:bg-white",
)}
type="button"
onClick={(e) => {
e.stopPropagation();
handleClickBottomButton();
}}
>
{buttonTitle}
</Button>
);
const WorkbookCardDetail = {
MainImage,
Title,
WriterList,
PersonCourseWithFewLogo,
BottomButton,
CardBadge,
};
export default WorkbookCardDetail;
워크북 카드 컴포넌트는 아래처럼 사용할 수 있게 된다.
export default function WorkbookCard({
id,
badgeInfo,
mainImageUrl,
metaComponent,
title,
writers,
personCourse,
buttonTitle,
cardType,
articleId,
}: Props)
...
<div>
<WorkbookCardDetail.ImageWrapper>
<WorkbookCardDetail.MainImage mainImageUrl={mainImageUrl} /> // ✅ 이미지 썸네일
<WorkbookCardDetail.CardBadge badgeInfo={badgeInfo} /> // ✅ 학습 뱃지
</WorkbookCardDetail.ImageWrapper>
<WorkbookCardDetail.WorkbookDetailInfoWrapper>
{metaComponent} // ✅ 카테고리 또는 학습 일자
<WorkbookCardDetail.Title title={title} /> // ✅ 워크북 제목
<WorkbookCardDetail.WriterList writers={writers} /> // ✅ 작가 이름 리스트
<WorkbookCardDetail.PersonCourseWithFewLogo
personCourse={personCourse} // ✅ 학습유저 수 관련
/>
<WorkbookCardDetail.BottomButton // ✅ 하단 버튼
buttonTitle={buttonTitle}
handleClickBottomButton={handleButtonClick}
/>
</WorkbookCardDetail.WorkbookDetailInfoWrapper>
</div>
그렇다면 다음 액션은 어떻게 될까? 한번 생각해보면 좋을 것 같다.
답은 간단하다. 단순하게 각 세부 컴포넌트에 해당하는 props의 값을 상황에 맞는 데이터로 넣어주면 되는 것이다.
만약, 여기서 Model 계층이 없다면...?
사용자의 로그인 여부 & 구독 여부에 따른 상수를 기준으로 조건문을 만들어서 분기처리를 컴포넌트 내부에서 진행해야 할 것이다.
하지만, 비즈니스 로직을 Model로 위임하고 컴포넌트는 오로지 View로만 사용할 수 있도록 하는 것이 컴포넌트의 유지/보수성을 높여준다는 것을 활용해야 한다.
👩🏻💻 Workbook Card Model
1️⃣ Server와 Client 타입 분리하기
모델의 input은 서버 데이터 타입이지만, output은 WorkbookCard의 Props 데이터 타입이어야 한다.
그래서 우선, 워크북 리스트 조회 API 응답 데이터 타입과 사용자의 워크북 구독리스트 조회 API 응답 데이터 타입을 각각 정의해두어야 한다.
// ✅ 워크북 구독 리스트 별 상세 데이터
export interface WorkbookSubscriptionInfo extends Pick<WorkbookInfo, "id"> {
status: SubscriptionStatus;
totalDay: number;
currentDay: number;
rank: number;
totalSubscriber: number;
articleInfo: string;
}
// ✅ 워크북 리스트 별 상세 데이터
export type WorkbookServerInfo = {
subscriberCount: number;
} & Omit<WorkbookInfo, "articles" | "name">;
워크북 카드의 Props를 클라이언트 타입으로 정의하면 아래와 같다.
export interface WorkbookClientInfo {
id: number;
mainImageUrl: string;
metaComponent: React.ReactElement;
title: string;
writers: string[];
personCourse: string;
buttonTitle: string;
badgeInfo: HTMLAttributes<HTMLDivElement>;
cardType: "LEARN" | "SUBSCRIBE" | "SHARE";
articleId: string | null;
}
그리고 만약 두 개의 API를 호출할 경우 workbook Id를 기준으로 데이터를 우선 병합하여 다시 리스트로 만드는 과정이 필요하다.
여기서부터는 Model 계층을 활용하게 된다.
2️⃣ contructor 로 서버 데이터를 전달 후 Combine한 데이터로 변환
다시 정리하면, 워크북 리스트 조회 API 는 유저상태에 관계없이 필수적으로 호출해야 하지만, 워크북 구독 리스트 API 는 로그인한 유저에 한 해서만 호출하면 된다.
export class WorkbookCardModel {
constructor({
initWorkbookSeverList,
initWorkbookSubscriptionInfoList,
}: {
initWorkbookSeverList: WorkbookServerInfo[];
initWorkbookSubscriptionInfoList?: WorkbookSubscriptionInfo[];
}) {
this.workbookList = initWorkbookSeverList;
if (initWorkbookSubscriptionInfoList)
this.workbookSubscriptionInfoList = initWorkbookSubscriptionInfoList;
this.workbookCombineList = this.getWorkbookServerCombineData();
}
get workbookCombineListData() {
return this.workbookCombineList;
}
getWorkbookServerCombineData(): WorkbookCombineInfo[] {
...
return this.workbookList;
}
private workbookList: WorkbookServerInfo[];
private workbookSubscriptionInfoList: WorkbookSubscriptionInfo[] | undefined;
private workbookCombineList: WorkbookCombineInfo[];
}
type WorkbookCombineInfo = WorkbookServerInfo &
Partial<WorkbookSubscriptionInfo>;
type WorkbookCombineInfoSet = {
[key: number]:
| Omit<WorkbookServerInfo, "id">
| Omit<Partial<WorkbookSubscriptionInfo>, "id">;
};
위 처럼 workbookSubscriptionInfoList 는 optional 상태로 생성자를 만들면 되는 상황이다.
두 개의 데이터를 빠르게 병합하기 위해서는 Array형식이 아닌 워크북의 id를 key로 가지는 set 객체를 만들면 이중 반복문을 사용하지 않고 데이터를 병합할 수 있다.
따라서 getWorkbookServerComnbineData( ) 함수 내부에서 workbookList, workbookSubscriptionInfoList를 set의 형태로 변환시켜준다음 이를 다시 array로 만들어주면 컴포넌트에서 데이터를 주입할때 map을 사용할 수 있게 된다.
transformDataToSet({
data,
}: {
data: WorkbookServerInfo[] | WorkbookSubscriptionInfo[];
}) {
return data.reduce<WorkbookCombineInfoSet>((acc, item) => {
const { id, ...rest } = item;
acc[id] = {
...rest,
};
return acc;
}, {});
}
set의 형태로 데이터를 만들어주는 함수는 위와 같으며
getWorkbookServerCombineData(): WorkbookCombineInfo[] {
if (this.workbookSubscriptionInfoList) {
const workbookCombineSet: WorkbookCombineInfoSet = {};
const workbookSetList = this.transformDataToSet({
data: this.workbookList,
});
const workbookSetSubscriptionInfoList = this.transformDataToSet({
data: this.workbookSubscriptionInfoList,
});
for (const workbookKey in workbookSetList) {
const isCommonKey =
Object.prototype.hasOwnProperty.call(workbookSetList, workbookKey) &&
Object.prototype.hasOwnProperty.call(
workbookSetSubscriptionInfoList,
workbookKey,
);
if (isCommonKey) {
const subscriptionItem = workbookSetSubscriptionInfoList[workbookKey];
const workbookItem = workbookSetList[workbookKey];
workbookCombineSet[workbookKey] = {
...workbookItem,
...subscriptionItem,
};
} else {
const workbookItem = workbookSetList[workbookKey];
workbookCombineSet[workbookKey] = {
...workbookItem,
};
}
}
return Object.entries(workbookCombineSet).map(([key, value]) => ({
id: Number(key),
...value,
})) as WorkbookCombineInfo[];
}
return this.workbookList;
}
위와 같이 workbookSubscriptionInfoList가 있다면 set 변환 이후 데이터를 병합하여 Object를 만드는 과정을 거치고,
workbookSubscriptionInfoList가 없다면 단일 데이터인 workbookList를 반환해주면 된다.
3️⃣ Workbook Card 세부 컴포넌트 Data 만들고 주입하기
합쳐진 ServerCombine 배열을 기반으로 이제 client에 맞는 객체를 만들어 새로운 배열을 만들어주기만 하면 된다.
큰 흐름은 다음과 같다.
export class WorkbookCardModel {
constructor({
initWorkbookSeverList,
initWorkbookSubscriptionInfoList,
}: {
initWorkbookSeverList: WorkbookServerInfo[];
initWorkbookSubscriptionInfoList?: WorkbookSubscriptionInfo[];
}) {
..생략
}
getWorkbookServerCombineData(): WorkbookCombineInfo[] {
...생략
}
workbookCardList({
workbookCombineList,
}: {
workbookCombineList: WorkbookCombineInfo[];
}): WorkbookClientInfo[] { // ✅ WorkbookCard Props 형태로 반환 === 모델의 결과값
return workbookCombineList.map(
({
id,
mainImageUrl,
title,
description,
category,
writers,
subscriberCount,
status,
currentDay,
totalDay,
articleInfo,
}) => {
const cardType = this.getWorkbookCardType({ status, currentDay });
const changeToClientData: WorkbookClientInfo = {
id,
badgeInfo: this.getBadeInfo({ cardType }),
... 클라이언트 타입으로 가공
};
};
return changeToClientData;
},
);
getWorkbookCardType({
status,
currentDay,
}: {
status: WorkbookSubscriptionInfo["status"] | undefined;
currentDay: WorkbookSubscriptionInfo["currentDay"] | undefined;
}): WorkbookClientInfo["cardType"] {
if (status && currentDay) {
if (status === "ACTIVE") return "LEARN";
else return "SHARE";
}
return "SUBSCRIBE";
}
getBadeInfo({
cardType,
}: Pick<WorkbookClientInfo, "cardType">): WorkbookClientInfo["badgeInfo"] {
switch (cardType) {
case "LEARN":
return {
title: "현재 학습중",
className: "text-text-gray1 bg-[#f5f5f5]",
};
case "SHARE":
return {
title: "학습완료",
className: "bg-success text-white text-[10px]",
};
default:
return {};
}
}
}
private workbookList: WorkbookServerInfo[];
private workbookSubscriptionInfoList: WorkbookSubscriptionInfo[] | undefined;
private workbookCombineList: WorkbookCombineInfo[];
}
WorkbookCard props의 배열 형태로 workbookCardList를 사용하면,
export default function WorkbookCardList({
code,
}: Partial<CategoryClientInfo>) {
const isLogin = useIsLogin();
const workbookCardList = useQueries({
queries: [
getWorkbooksWithCategoryQueryOptions({
code: code !== undefined ? code : ENTIRE_CATEGORY,
}),
{
...getSubscriptionWorkbooksQueryOptions(),
enabled: isLogin === true, // ✅ enabled로 query 조건분기
},
],
combine: (result) => {
// 1. 워크북 리스트 2. 워크북 구독 리스트
const [workbookServerList, workbookSubscriptionInfoList] = result;
if (workbookServerList.data) {
const workbookCardModel = new WorkbookCardModel({
initWorkbookSeverList: workbookServerList.data,
initWorkbookSubscriptionInfoList: workbookSubscriptionInfoList.data,
});
// ✅ 모델을 통한 client card List 반환
return workbookCardModel.workbookCardList({
workbookCombineList: workbookCardModel.workbookCombineListData,
});
}
},
});
if (!workbookCardList) return <WorkbookCardListSkeleton />;
// ✨ map을 통한 워크북 카드 렌더링
return (
<section className="mr-[18px] flex gap-[8px] overflow-x-auto">
{workbookCardList.map((data, idx) => (
<WorkbookCard key={`work-book-card-${idx}`} {...data} />
))}
</section>
);
}
위와 같이 Map으로 상태에 맞는 데이터를 뿌려줄 수 있게 된다.
🌱 최종 layer 설계도
설명을 위해서 workbookcard 에 대한 모델만 다루어보았지만,
Few 프로젝트에서 사용하고 있는 layer방식은 아래의 계층 중 우측에 해당하는 구조를 가지고 있다.
좌측처럼 layer를 2개의 계층으로 가진 경우에는 디자인이 변경되지 않더라도, API의 변경사항이 발생하면 component도 함께 수정해야 하는 과정이 생길 것이다.
그러나 우측처럼 layer를 3개의 계층으로 만들게 되면 똑같은 상황에서 component를 직접 수정하는 것이 아니라 model을 수정해서 변경사항을 반영해주면 된다.
이를 통해 model 계층을 component 위에 만들어주게 되면, 컴포넌트는 정말 view의 역할로만 사용하는 결과를 만들어내는 설계 구조라는 것을 도출할 수 있다.
🌈 결론
클라이언트와 서버 계층 사이에 레이어를 두는 설계를 통해 API 스펙에 의존적이지 않고, 클라이언트 환경에서 컴포넌트를 쉽게 관리하는 방법을 사용하는 것( ex. 컴파운드 패턴 기반의 컴포넌트 ) 이 가능해졌다.
나중에 API 스펙이 변경되더라도 클라이언트 환경을 고치는 것이 아닌 그 중간 계층의 레이어를 수정하는 방향으로 작업을 진행하면 되는 것이다.
이를 통해 프론트엔드 개발자에게도 초기 설계는 중요하며, 컴포넌트 안에 비즈니스 로직을 넣는 과정이 늘어날수록 버그가 발생했을 때 추적하기 어려울 것이며 발전하는 요구사항에 대응하는 것도 시간이 오래 걸린다는 것을 코드로 체감할 수 있었다.
중요한 사실은 API의 응답 계층이 컴포넌트에 직접 영향을 주는 설계를 피하고, 서버와 컴포넌트 사이에 반드시 중간 계층(윗글에서는 model ) 을 두는 것이 유연한 컴포넌트로 개발하는 방향성이라는 것이다.
추가로 위 프로젝트에서 개선할 부분은 model을 tsx와 ts 확장자를 혼용해서 쓰고 있다는 부분이 있기에 온전히 class 형태의 ts 파일로 비즈니스 로직에 대한 결괏값을 반환하는 model로 통일성 있게 관리하는 것으로 구조를 잡아나가야 할 것 같다.
'WEB > Insight' 카테고리의 다른 글
FEConf 2024 - Lightning Speaker를 통해 (2) | 2024.09.01 |
---|---|
[ 쏙쏙 들어오는 함수형 코딩 ] 계층형 설계 (0) | 2024.01.04 |
[ 쏙쏙 들어오는 함수형 코딩 ] Function 액션/계산/데이터 (0) | 2023.12.06 |
[ 좋은 코드, 나쁜 코드 ] 가독성 높은 코드를 작성하라 (0) | 2023.11.02 |
CORS (0) | 2023.07.09 |