[Week 2-1] SOLID한 컴포넌트 만들기
3-1) 내 손에 익은 공구상자
일을 하거나 취미 활동을 할 때 직접 사용해 보면서 익숙하게 사용하는 것들이 있다. 유일한 도구라 사용하는 것 일수도 있고, 쓰다 보니 만족하여 계속 사용하는 것 일수도 있다.
여기서 중요한 점은 내 공구상자가 남들보다 크고 비싼 공구들이 잔뜩 들어있다고 무조건 좋은 것은 아니다.
달랑 볼펜 하나로 훌륭한 그림을 그릴 수 있듯이 그 도구를 얼마나 이해하고 잘 활용할 수 있는지에 달려있다.
개발자도 마찬가지이다 라이브러리/프레임워크 수십개를 안다는 것과 제대로 사용할 줄 안다는 것은 큰 차이이기 때문
이러한 이유로 오늘 챌린지에선 Context API와 Class를 예시로 들었다.
https://ko.legacy.reactjs.org/docs/context.html
Context의 사용법과 컴파운드 컴포넌트 패턴(CCP)에 적용한 예시는 지난 글에서 다루어 보았다.
Context API는 그저 ‘어떠한 맥락(context) 내에서 상태를 공유할 수 있는 기능’
을 제공하는 API일 뿐입니다. 그걸 어떻게 쓸지는 개발자의 재량에 달려있죠.
위 게시글 예시처럼 단순 전역 상태 관리로 사용하는 것이 아닌 필요한 부분에서 적절하게 사용한다면 컴포넌트 내에서의 응집도는 높이고 외부 컴포넌트 혹은 외부 변수와는 연관성이 적어지는 낮은 결합도의 컴포넌트를 만들 수 있다.
Class는 배우고 예제를 작성할 때만 해도 '그냥 함수나 커스텀 훅으로 쓰지 굳이?'라는 생각에 좋아하지 않았다.
그러나 이번 기회에 알아봤을 때 내장된 state를 사용한 상태 관리, Legacy 코드 호환성, this를 활용한 속성과 메서드에 쉬운 접근 등 함수 컴포넌트와 다른 장단점이 있음을 알게 되었다.
Class란 ‘상태와 액션을 결합하고 캡슐화 하는 문법적인 도구’입니다.
프론트 엔드 특성상 새로운 기술이 나오거나 사장된 기술들이 새로 주목을 받는 등 변화무쌍하기 때문에 지속적인 관심과 공부가 필요할 것 같다.
https://www.youtube.com/watch?v=fR8tsJ2r7Eg
3-2) SOLID 라는 이름의 통찰에 관하여
이번 챌린지에서 계속 이야기하고있는 좋은 아키텍처, 클린 코드 등을 위해 필요한 것이 SOLID 원칙이다.
객체지향 프로그래밍(OOP)에 필요한 것 아닌가?
프론트 엔드에도 필요한가?
현존 소프트웨어 원칙 중 가장 영향력이 크며 JavaScript로 프론트 개발을 할 때도 도움이 된다. 심지어 React 공식 문서에는 SOLID의 S인 SRP에 대해 언급도 하고 있다.
https://react.dev/learn/thinking-in-react
개발 초기에는 아키텍처 관련 제약들이 오히려 방해가 된다고 여길 가능성이 높다. 수많은 시스템에서 좋은 아키텍처가 결여된 이유는 바로 이 때문이다. 다시 말해 이러한 팀은 아키텍처 없이 시작하는데, 팀 규모가 작은 데다가 상위 구조로 인한 장애물이 없기를 바라기 때문이다.
- 클린 아키텍처
SRP(Single Responsibility Principle, 단일 책임 원칙)
SRP는 찾아보면 다른 원칙들 중에서 가장 중요하게 생각하고 나머지 원칙들은 SRP를 만족하기 위해 함께 가야 할 원칙이라고 말하는 경우가 많다.
“SRP의 최종 버전은 다음과 같다. 하나의 모듈은 하나의, 오직 하나의 액터에 대해서만 책임져야 한다.”
- 클린 아키텍처
여기에서 책임이란 '동작'을 의미하는 것이 아니다.
하나의 함수/컴포넌트가 수행할 수 있는 책임이 여러 개라면 함수 혹은 컴포넌트끼리 강한 결합이 발생하기 때문에 낮은 결합도 높은 응집도에 상반되는 결과가 발생하게 된다.
OCP(Open-Closed Principle, 개방-폐쇄 원칙)
확장에는 열려있고 변경에는 닫혀있어야 한다는 원칙이다.
요구사항이 추가되거나 변경될 때 기존 코드를 수정하는 것보다 새로운 코드(컴포넌트)를 추가하는 원칙인데 프론트 엔드에서 발생할 수 있는 예시 코드가 있다.
sections.map((section) => {
if(section.type === "BANNER"){
return section.items.map((item) => <Banner item={item} />);
} else if(type === "RECENTLY_VIEWED"){
return section.items.map((item) => <PosterView item={item} />);
}
}
만약 위 코드에서 새로운 type이 추가된다면 else if를 추가해야 할 것이다.
sections.map((section) =>
<Section section={section}>
{section.items.map((item) =>
<Item section={section} item={item} />
}
</Section>
하지만 OCP원칙을 준수한다면 확장에 열려있는 코드가 완성된다. 이제 type이 추가되거나 삭제되어도 해당 컴포넌트는 수정을 하지 않아도 된다.
LSP(Liskov Substitution Principle, 리스코프 치환 법칙)
상속으로 이어진 관계에서 예상 못할 행동을 하지 말라.
실무에서 SOLID 원칙 중 에러/오류가 가장 많이 발생하는 부분이라고 설명한다.
정의된 상위 컴포넌트와 출력을 담당하는 하위 컴포넌트가 예상과 다르다면 잘못 사용하게 될 경우가 발생한다.
1. 대체 가능성: 하위 클래스의 인스턴스는 프로그램의 정확성을 해치지 않으면서 상위 클래스의 인스턴스를 대체할 수 있어야 합니다.
2. 계약에 의한 설계: 하위 클래스는 상위 클래스가 정의한 계약(ex. 메서드 사양)을 준수해야 합니다.
3. 행동 호환성: 하위 클래스는 상위 클래스의 행동(ex. 메서드가 반환하는 값의 범위, 예외 처리)을 보존해야 합니다.
ISP(Interface Segregation Principle, 인터페이스 분리 법칙)
클라이언트는 사용하지 않는 메서드에 대해 의존적이지 않아야 합니다.
SRP 원칙을 지키기 위해 선행되어야 할 원칙인 것 같다. 단일책임에 맞게 컴포넌트가 잘 나누어져 있다면 불필요한 데이터를 props로 받을 필요가 없다.
return (
<TicketInfo
waitfreePeriod={ticketInfo.waitfreePeriod}
waitfreeChargedDate={ticketInfo.waitfreeChargedDate}
rentalTicketCount={ticketInfo.rentalTicketCount}
ownTicketCount={ticketInfo.ownTicketCount}
commentCount={commentInfo.commentCount}
commentError={commentApiError /* 코멘트 에러 발생 시 재시도 버튼 추가해야함 */}
keywordInfo={keywordInfo}
/>
)
주렁주렁
DIP(Dependency Inversion Principle, 의존성 역전 원칙)
추상(abstraction)은 구체(detail)에 의존하지 않아야 하며, 구체는 추상에 의존적이어야 합니다.
고수준의 모듈은 저수준의 모듈에 의존적이면 안되고, 둘 다 추상에 의존적이어야 합니다.
의존성 역전이란 의존성이 제어흐름과 반대 반향으로 역전되는 것을 의미한다.
필요한 데이터를 내부에서 구체적으로 직접 정의하지 말고 외부에서 추상의 형태로 받아와야 한다는 말이다.
class UserService {
async localLogin(email: string, password: string) {
const user = await mysql.query(`SELECT * FROM user WHERE email = ?`, [
email,
]);
if (jwt.compare(password, user.password)) {
throw new Error("login fail");
}
return jwt.create(user.uuid);
}
}
위 코드의 경우 mysql DB를 사용하면서 SQL문을 직접 작성해 놓고 사용하고 있으며 인증방식 중 JWT를 고정으로 사용하게 하드코딩 되어있다.
이럴 경우 다른 DB를 사용하거나 다른 인증방식을 사용한다면 재사용할 수 없는 코드가 되는 것이다.
그렇기 때문에 필요한 정보들은 외부에서 받아와서 실행만 하도록 수정할 필요가 있다.
결국 SRP 원칙을 다른 방식으로 설명하는 것과 크게 다르지 않다.
https://fe-developers.kakaoent.com/2023/230330-frontend-solid/
3-3) [실전 스킬 3] 컴포넌트 SOLID 시공법
SOLID 원칙에 속하지는 않지만 모든 원칙들을 관통하는 랜더링 IoC에 대해 알아보았다.
IoC(Inversion of Control, 제어의 역전)
리액트로 개발을 하다 보면 컴포넌트가 기본 기능대로만 동작하기보다는, 원하는 방식으로 확장되어 동작하길 바랄 때가 종종 있습니다. 이럴 때 우리는 IoC(Inversion of Control) 즉, 제어 역전 패턴을 통해 컴포넌트를 사용하는 개발자에게 컴포넌트의 제어권을 넘겨줌으로써, 개발자가 원하는 대로 컴포넌트를 컨트롤하도록 할 수 있습니다.
오늘 챌린지를 통해 IoC 패턴들 중 몇 가지를 새로 알게 되었다.
컴파운드 컴포넌트 패턴(CCP)
Children Props
Render Props
Children Props
CCP는 며칠 전에 찾아보았으므로 Children Props부터 보자면 화면에 보이는 구현을 컴포넌트 내부에 정의하지 않고 제어권을 컴포넌트 외부로 넘겨 사용처에서 합성하도록 하는 패턴이다.
import React from 'react';
import Card from './Card';
import VisibleTrigger from './VisibleTrigger';
const App = () => {
const isNewUser = true;
return (
<div>
<VisibleTrigger onVisible={() => {
console.log("카드가 화면에 나타났습니다.", { ... });
// 로깅 또는 유저 정보 수집 로직
}}>
<Card title="카드 제목 1" content="이것은 카드 내용입니다." isNewUser={isNewUser} />
</VisibleTrigger>
{/* 다른 카드들... */}
</div>
);
};
export default App;
위 코드에서 VisibleTrigger 컴포넌트는 자식 Card 컴포넌트를 렌더링 하면서 prop를 통해 onVisible 콜백을 전달받았다.
여기서 IoC가 이루어지는 부분은 onVisible는 VisibleTrigger 컴포넌트 내부에서 정의되는 것이 아닌 외부에서 제공된다는 점이다.
Render Props
Render Props는 컴포넌트의 children prop 자리에 함수를 전달하고, 컴포넌트 내부에서는 이 함수의 인자로 값을 전달하는 패턴입니다. 모든 상황에서 필요하진 않겠지만 로직을 컴포넌트 내부로 캡슐화하고, 그 결과물만 렌더링 로직에 넘겨주고 싶을 때는 좋은 선택지가 될 수 있습니다.
/** RenderPropsList.tsx */
interface IRenderPropsList<T> {
renderItem?: (data: T) => ReactNode; // rendering 함수
dataSource: Array<T>;
}
const RenderPropsList = function <T>({ dataSource, renderItem }: IRenderPropsList<T>) {
return (
<div>
<span>List Count : {totalCount}</span>
<ul>
{dataSource.map((data, index) => {
if (renderItem) {
return <li key={index}>{renderItem(data)}</li>;
}
return <li key={index}>{String(data)}</li>;
})}
</ul>
</div>
);
};
/** App.tsx */
const App: React.FC = function () {
const [data, setData] = useState([
{ id: 1, name: "flower", score: 91 },
{ id: 2, name: "geoji", score: 100 },
{ id: 3, name: "novell", score: 73 },
{ id: 4, name: "star", score: 84 },
]);
return (
<RenderPropsList
dataSource={data}
renderItem={({ name, score }) => {
return (
<div>
<span>{`Name : ${name} , Score : ${score}`}</span>
</div>
);
}}
/>
);
};
1) RenderPropsList 컴포넌트는 dataSource와 renderItem 총 두 가지의 props를 받는다.
2) 이렇게 사용하게 되면 RenderPropsList 컴포넌트는 데이터를 어떻게 렌더링 할지 내부에서 결정할 필요가 없게 된다.
3) 대신에 외부에서 제공되는 renderItem 함수를 사용하여 데이터를 렌더링 하게 된다.
4) 앞으로 요구사항이 변하게 된다면 renderItem 함수만 확인하여 렌더링 방식을 제어할 수 있다.
https://fe-developers.kakaoent.com/2022/221110-ioc-pattern/
https://fe-developers.kakaoent.com/2022/220731-composition-component/
📝 오늘의 3줄 요약
1. 만 가지 라이브러리 보다 한 가지 라이브러리를 만 번 연습하자.
2. SOLID 원칙 중 SRP를 꼭 기억하자.
3. 프론트 엔드에도 디자인 패턴이 중요하다.