최근 진행중인 프로젝트의 컴포넌트를 수정과 리팩토링하는 어려움이 있는 컴포넌트를 많이 경험했습니다.
대부분의 컴포넌트에서 아래와 같은 공통점이 발견할 수 있었습니다.
- API, 컨텍스트, 기능 로직 등의 코드가 분리되지 않고 컴포넌트에 몰려있는 점
- 프레젠테이셔널 컴포넌트와 컨테이너 컴포넌트의 구분이 안되어있는 점
이러한 문제들은 내가 작성한 코드에서도 확인할 수 있었다.....
관심사별 컴포넌트의 로직을 분리하기전에 SOLID 원칙의 SRP원칙에 대해 알아봅시다.
SOLID의 S를 담당하는 SRP원칙
React 3회차: SOLID한 컴포넌트 만들기
1. SOLID 원칙 지난 시간에 공부했던 ‘비즈니스 로직’, ‘캡슐화’, ‘모듈성, ‘추상화’ 등을 이번에 배우는 SOLID라는 개념으로 더 확실하게 정리해보자. SOLID 원칙을 프론트엔드에 적용하면
58cjdcns99.tistory.com
위의 글에서 간단하게 정리한 경험이 있지만, 조금 더 React 친화적으로 정리해봅시다.
정의
SRP 원칙은 Single Response Principle의 약자로서, 하나의 객체가 하나의 목적을 갖게끔 설계하는 소프트웨어 설계 원칙 중 하나이다. 객체 지향 프로그래밍에서 객체가 하나의 책임만 갖도록 설계하는 원칙입니다.
예시
철수는 캠핑에서 요리에 사용할 칼을 고르던 중 맥가이버 칼(스위스 아미 나이프)가 너무 멋있어서 덜컥 구매하게 되었다. 그런데 X무에서 구매한 나머지 몇번 사용하고 칼이 고장나 버렸다.
이러한 경우 칼을 수리해야 하지만, 여러 도구가 동시에 있기에 분리해서 수리해야하는 번거로움이 있다. 즉 수리하기 어렵다는 것이다.
하지만 같이 캠핑을 간 영희는 요리용 칼을 샀다. 이 칼은 고장나도 칼 날만 갈아내면 되기에 수리 용이성이 매우 좋다.
그냥 고치면 되는거 아닌가?
이처럼 하나의 클래스에 여러 기능(책임)을 넣는냐, 각 기능별로 분리해 클래스를 분리시키느냐의 설계는 프로그램 유지 보수와 밀접한 관련이 있습니다.
하나의 객체에 여러 기능이 높은 응집도를 갖을 수록 서로 다른 목적의 로직 코드끼리 끈끈?하게 결합되어 있을 확률이 높기 때문에 시스템의 유지 보수에 어려움을 겪을 수 있습니다. 그로인해 하나의 로직 코드에 수정 사항이 발생하면 다른 로직 코드의 수정이 불가피할 수 있으며, 이에 따라 전체 로직의 테스트를 진행해야할 경우가 있습니다.
정리하자면 여러 책임 코드가 포함되어 있는 클래스는 하나의 책임에 변경 사항이 발생한 경우 다른 책임의 변경으로 연쇄 작용/나비 효과가 일어날 수 있습니다.
SRP 원칙을 위반하지 않는 React 컴포넌트로 수정하자
아래의 코드는 React 개발자라면 한번쯤 봤을법한 안티패턴 입니다.
"use client";
import { useParams } from "next/navigation";
import { Suspense, useContext, useEffect, useState } from "react";
import Header from "@/components/Layout/header/Header";
import DefaultBody from "@/components/Layout/Body/defaultBody";
import BottomNav from "@/components/Layout/BottomNav/BottomNav";
import { DepartmentReviewComponent } from "@/components/Review/DepartmentReview";
import { useDepartmentRatingInfoContext } from "@/api/review/useDepartmentRatingInfoContext";
import { ReviewComment } from "@/components/Review/ReviewComment";
import { useLectureReviewsContext } from "@/api/review/useLectureReviewsContext";
import { ReviewBtn } from "@/components/Review/ReviewBtn";
import { ReviewContext } from '@/context/WriteReviewContext';
const DepartmentReview = () => {
const { departmentCode } = useParams();
const [lectureInfo, setLectureInfo] = useState<lectureInfoType | null>(null);
const [emotion, setEmotion] = useState<emotionType | null>(null);
const [reviewList, setReviewList] = useState<reviewType[]>([]);
const [refetch, setRefetch] = useState<boolean>(false);
const { data, setData } = useContext(ReviewContext);
const getDepartMentRateInfo = async () => {
try {
const response = await useDepartmentRatingInfoContext({
departmentId: `${departmentCode}`,
});
const data = response.data;
setLectureInfo({
_id: data._id,
lectureNm: data.lectureNm,
professor: data.professor,
starRating: data.starRating,
participants: data.participants,
grade: data.grade,
semesters: data.semesters,
});
setEmotion(data.emotion);
} catch (error) {
console.error("과목 �� 후기 조회 실��", error);
alert("과목 �� 후기 조회 실��");
} finally {
return;
}
};
const getReviewList = async () => {
try {
const response = await useLectureReviewsContext({
lectureId: `${departmentCode}`,
});
const data = response.data;
setReviewList(data.contents);
} catch (error) {
console.error("과목 �� 후기 조회 실��", error);
alert("과목 �� 후기 조회 실��");
} finally {
return;
}
};
useEffect(() => {
getDepartMentRateInfo();
getReviewList();
}, []);
useEffect(() => {
if (lectureInfo) {
setData({
...data,
grade: {
label: `${lectureInfo.grade}학년`,
value: `${lectureInfo.grade}`,
},
});
}
}, [lectureInfo]);
useEffect(() => {
getReviewList();
if (refetch) {
setRefetch(false);
}
}, [refetch]);
return (
<Suspense>
<Header>
<Header.BackButton />
<Header.Title>과목 별 후기</Header.Title>
</Header>
<DefaultBody hasHeader={1}>
{lectureInfo && emotion && (
<DepartmentReviewComponent
subjectName={lectureInfo.lectureNm}
professor={lectureInfo.professor}
grade={lectureInfo.grade}
semesters={lectureInfo.semesters}
starRating={lectureInfo.starRating}
score={emotion}
/>
)}
{reviewList.length > 0 &&
reviewList.map(
({ _id, content, starRating, likeCount, liked, semester }, idx) => {
return (
<ReviewComment
key={`${_id}_${idx}`}
starRating={starRating}
content={content}
likes={likeCount}
isLiked={liked}
semester={semester}
_id={_id}
refetch={setRefetch}
/>
);
}
)}
<ReviewBtn />
</DefaultBody>
<BottomNav activeIndex={3} />
</Suspense>
);
};
export default DepartmentReview;
위의 컴포넌트는 아래와 같이 여러 책임을 갖고 있기에 SRP에 위배되는 컴포넌트입니다.
- 데이터 패칭 → getReviewList, getDepartmentRateInfo 함수의 비동기 패칭
- 에러 핸들링 → getReviewList, getDepartmentRateInfo 함수의 try...catch
- 로딩 상태 관리 → 패칭 데이터 조건부 렌더링
- 레이아웃과 프레젠테이션 → 프레젠테이션 영역과 로직컬한 영역의 분리가 모호함
관심사를 분리해 정리하기
위의 코드에서는 네 가지 관심사(책임)가 존재합니다. 이러한 관심사를 분리해 보도록 하겠습니다.
"use client";
import { useParams } from "next/navigation";
import { Suspense, useContext, useEffect, useState } from "react";
import Header from "@/components/Layout/header/Header";
import DefaultBody from "@/components/Layout/Body/defaultBody";
import BottomNav from "@/components/Layout/BottomNav/BottomNav";
import DepartmentReviewComponent from "@/components/Review/DepartmentReview";
import { useDepartmentRatingInfoContext } from "@/api/review/useDepartmentRatingInfoContext";
import { ReviewComment } from "@/components/Review/ReviewComment";
import { useLectureReviewsContext } from "@/api/review/useLectureReviewsContext";
import { ReviewBtn } from "@/components/Review/ReviewBtn";
import { ReviewContext } from "@/context/WriteReviewContext";
import React from "react";
const useReviewData = (departmentCode: string) => {
const [lectureInfo, setLectureInfo] = useState<lectureInfoType | null>(null);
const [lectureDataLoading, setLectureDataLoading] = useState<boolean>(true);
const [lectureDataError, setLectureDataError] = useState<Error | null>(null);
useEffect(() => {
fetchLectureInfo();
}, [departmentCode]);
const fetchLectureInfo = async () => {
setLectureDataLoading(true);
try {
const response = await useDepartmentRatingInfoContext({
departmentId: `${departmentCode}`,
});
const data = response.data;
setLectureInfo({
_id: data._id,
lectureNm: data.lectureNm,
professor: data.professor,
starRating: data.starRating,
participants: data.participants,
grade: data.grade,
semesters: data.semesters,
emotion: data.emotion,
});
} catch (error) {
setLectureDataError(error as Error);
} finally {
setLectureDataLoading(false);
}
};
return {
lectureInfo,
lectureDataLoading,
lectureDataError,
lectureDataRefetch: fetchLectureInfo,
};
};
const useReviewList = (departmentCode: string) => {
const [reviewList, setReviewList] = useState<reviewType[]>([]);
const [reviewListLoading, setReviewListLoading] = useState<boolean>(true);
const [reviewListError, setReviewListError] = useState<Error | null>(null);
const [reviewListRefetch, setReviewListRefetch] = useState<boolean>(false);
useEffect(() => {
fetchReviewList();
setReviewListRefetch(false);
}, [departmentCode, reviewListRefetch]);
const fetchReviewList = async () => {
setReviewListLoading(true);
try {
const response = await useLectureReviewsContext({
lectureId: `${departmentCode}`,
});
const data = response.data;
setReviewList(data.contents);
} catch (error) {
setReviewListError(error as Error);
} finally {
setReviewListLoading(false);
}
};
return {
reviewList,
reviewListLoading,
reviewListError,
reviewListRefetch: setReviewListRefetch,
};
};
const DepartmentReview = () => {
const { departmentCode } = useParams();
const { data, setData } = useContext(ReviewContext);
const {
lectureInfo,
lectureDataLoading,
lectureDataError,
lectureDataRefetch,
} = useReviewData(`${departmentCode}`);
const {
reviewList,
reviewListLoading,
reviewListError,
reviewListRefetch
} = useReviewList(`${departmentCode}`);
if (lectureDataLoading || reviewListLoading) return <>로딩중...</>;
if (lectureDataError || reviewListError) return <>에러 발생. 잠시뒤 다시 시도해 주세요.</>;
return (
<Suspense>
<Header>
<Header.BackButton />
<Header.Title>과목 별 후기</Header.Title>
</Header>
<DefaultBody hasHeader={1}>
{lectureInfo && (
<DepartmentReviewComponent
subjectName={lectureInfo.lectureNm}
professor={lectureInfo.professor}
grade={lectureInfo.grade}
semesters={lectureInfo.semesters}
starRating={lectureInfo.starRating}
score={lectureInfo.emotion}
/>
)}
{reviewList.length > 0 &&
reviewList.map(
({ _id, content, starRating, likeCount, liked, semester }, idx) => {
return (
<ReviewComment
key={`${_id}_${idx}`}
starRating={starRating}
content={content}
likes={likeCount}
isLiked={liked}
semester={semester}
_id={_id}
refetch={reviewListRefetch}
/>
);
}
)}
<ReviewBtn />
</DefaultBody>
<BottomNav activeIndex={3} />
</Suspense>
);
};
export default DepartmentReview;
핵심 요약!
- 데이터와 UI를 분리하기 : 데이터는 훅을 사용하고, UI는 프레젠테이션 컴포넌트를 사용합시다.
- 집중화된 컴포넌트 만들기 : 각 컴포넌트는 한 가지 업무(책임)만 원활하게 수행할 수 있어야 합니다.
- 단순한 기능들을 이용해 복잡한 기능을 구현하기 위해 합성과 재사용을 활용합니다.
- 재사용할 수 있는 로직은 커스텀 훅으로 추출하여 선언합니다.
- 계층적인 설계 생각하기 : 데이터, 비즈니스, 로직, 프레젠테이션 로직을 나누어 고민합니다.
결론
각 컴포넌트가 하나의 명확한 기능(책임)을 갖도록 설계 및 개발 하면 전체 애플리케이션의 유지보수성과 테스트에 이점을 얻을 수 있다. 또한 변화에 대응하는 유연성이 증가한다.
'FE 이모저모 공부' 카테고리의 다른 글
JS의 호이스팅 발생 이유 (0) | 2024.07.26 |
---|---|
JS의 이벤트 루프 Event Loop (0) | 2024.07.24 |
동기와 비동기 (Synchronous & Asynchronous) (1) | 2024.07.23 |
HTML의 iframe이 무엇이고 왜 iframe의 접근을 금지하지? (0) | 2024.07.17 |
TanstackQuery의 캐싱 (0) | 2024.05.15 |