직전에 글에서 SOLID 원칙을 리액트 컴포넌트에 적용하는 것에 대한 글을 언젠가 작성한다고 했는데, 시간이 될 때 바로 작성하는게 좋을 듯 해서 미루지 않고 바로 돌아왔습니다.🤩
SOLID 원칙은 객체지향 설계에서 지켜줘야 하는 소프트웨어 개발 원칙을 말한다.
실무에서 SOLID 원칙을 깊게 생각해보면서 적용해보려고 하지 않았던 것 같다.
아래에서 설명하는 코드는 '여기 + @'를 기반으로 설명한다.
관련 영상: https://www.youtube.com/watch?v=t_h_A6RkM7A
단일 책임 원칙이란 '객체는 오직 하나의 책임을 가져야 한다'는 의미이다.
쉽게 말하면 하나의 책임만을 가지는 컴포넌트가 되도록 설계를 해야 한다는 것이다.
다행히 우리는 컴포넌트를 쪼개는 것을 익숙해 하고 있고 이를 당연하게 생각하는 개발자들도 많다. 컴포넌트를 쪼개는 행위 자체가 하나의 책임만을 가질 수 있도록 나누는 역할을 도와준다.
물론, 단순히 컴포넌트를 나누는 것만이 원칙을 지키는 것이 아니라 동일한 책임을 가진 컴포넌트로 분리해야 한다. 코드를 통해 살펴보자
// BAD ❌
export function EditUserProfileBAD() {
// ...
// input 핸들링 함수
const handleInputChange = (e) => { ... };
// 이미지 핸들링 함수
const handleImageChange = (e) => { ... };
// form submit 함수
const handleSubmit = async (e) => { ... };
return (
<div>
<h1>Edit User Profile</h1>
<form onSubmit={handleSubmit} >
<div>
<label>
Profile Picture:
</label>
{formData.image && (
<div>
<img
src={URL.createObjectURL(formData.image)}
alt="Profile Preview"
/>
</div>
)}
<input
type="file"
accept="image/*"
name="image"
onChange={handleImageChange}
/>
</div>
<div>
<label>Name:</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleInputChange}
/>
<div>{errors.name}</div>
</div>
<div className="flex flex-col mb-4">
<label>Email:</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleInputChange}
/>
<div>{errors.email}</div>
</div>
<div>
<label>Password:</label>
<input
type="password"
name="password"
value={formData.password}
onChange={handleInputChange}
/>
<div>{errors.password}</div>
</div>
<button type="submit">Update Profile</button>
</form>
</div>
);
}
// GOOD ✅
export function EditUserProfileGOOD() {
// ...
// form submit 함수
const handleSubmit = async (e) => { ... };
return (
<div>
<h1>Edit User Profile</h1>
<form onSubmit={handleSubmit} >
<ProfilePictureUploader />
<InputField
labelText="Name"
fieldRegister={register("name")}
error={errors.name?.message}
/>
<InputField
labelText="Email"
fieldRegister={register("email")}
error={errors.email?.message}
/>
<InputField
labelText="Password"
fieldRegister={register("password")}
error={errors.password?.message}
/>
<button type="submit">Update Profile</button>
</form>
</div>
);
}
input과 image 업로드하는 것을 하나의 컴포넌트 내에서 모두 담당하고 있어서 단일 책임을 가지고 있지 않았지만, 이를 컴포넌트로 각각 분리하여 책임을 명확히 나눌 수 있었다.
개방 폐쇄 원칙이란 "확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다"라는 의미이다.
쉽게 풀어서 얘기하면 요구사항이 변경되면 기존 코드의 수정 없이 확장되어야 하고, 기존에 사용되고 있는 곳들에도 영향이 없어야 한다는 뜻으로 볼 수 있다.
OCP를 해치기 가장 쉬운 예시는 바로 if/else문으로 처리한 예시로 볼 수 있다. if/else문의 분기로 처리한 코드이 경우 조건이 변경되면 조건문을 수정할 수 밖에 없고 이는 변경이 이루어진다는 얘기이다.
코드를 통해 살펴 보자.
// BAD ❌
const ShoppingPage = () => {
// ...
return (
<>
{userAccount.map((userAccountInfo) => {
const { userType } = userAccountInfo;
if (userType === 'USER_TYPE_A') return <UserATypeBanner />
else if (userType === 'USER_TYPE_B') return <UserBTypeBanner />
else if (userType === 'USER_TYPE_C') return <UserCTypeBanner />
return null;
})
</>
)
}
유저의 등급에 따라 title이 변경되거나 나타나는 툴팁의 내용이 변경되거나 등 여러가지가 달라질 수 있다. 유저의 등급이 추가되거나 수정되면 기존 코드를 변경할 수 밖에 없다.
// GOOD ✅
const BANNER_INFO = {
USER_TYPE_A: {
//...
},
USER_TYPE_B: {
//..
},
USER_TYPE_C: {
}
}
const ShoppingPage = () => {
// ...
return (
<>
{userAccount.map((userAccountInfo) => {
const { userType } = userAccountInfo;
return (
<UserBanner
bannerType={BANNER_INFO[userType].bannerType}
title={BANNER_INFO[userType].title}
/>
)
})
</>
)
}
이렇게 변경하면 유저의 타입이 추가/삭제되어도 기존코드에는 영향이 없어진다.
이것 말고도 OCP를 지키는 좋은 방법은 컴파운드 컴포넌트 패턴을 활용하는 방법도 있다.
필요한 곳에서 컴포넌트를 조합해서 사용하게 된다면 요구사항이 변경이 되더라도 변경에는 닫혀있는 컴포넌트를 구현할 수 있다.
리스코프 치환 법칙은 "하위 타입 객체는 상위 타입 객체에서 가능한 행위를 수행할 수 있어야 한다"를 의미 한다. 쉽게 풀어서 말하면 자식은 부모가 할 수 있는 것들은 당연하게 할 수 있어야 한다는 뜻이다.
그러나 리액트에서는 상속 보다는 합성을 권장하고 있다.
참고: https://reactjs-kr.firebaseapp.com/docs/composition-vs-inheritance.html
그렇다면 리액트에서 생각해볼 수 있는게 무엇이 있을지 조금 더 풀어서 생각해보자.
하위에서 사용되는 메소드(하위 타입 객체)는 상위(라이브러리 등)에서 제공하는 메소드(상위 타입 객체)를 모두 수행할 수 있어야 한다.
import { someMethod } from 'react-libraryA';
const useCustomHookFromLibraryA = (...) => {
// ...
const handleInput = () => {
// libraryA의 someMethod handleInput을 제공
};
return {
handleInput: someMethod
}
}
react-libraryA를 사용해서 커스텀훅을 만들었는데 어느 순간 어떠한 이유로 인해 deprecated되어 사용을 못하게 되었다.
import { someMethod } from 'react-libraryB';
const useCustomHookFromLibraryB = (...) => {
// ...
const handleInput = () => {
// libraryB의 someMethod로 쿵짝쿵짝해서 같은 기능을 제공
};
return {
handleInput
}
}
그런 순간에도 우리는 라이브러리의 변화에 대응하였지만 사용하는 쪽에서는 똑같은 기능을 제공할 수 있도록 해야 한다.
인터페이스 분리 원칙이란 "객체는 자신이 사용하는 메소드에만 의존해야 한다"는 원칙이다.
리액트에서 생각해보면 불필요한 props를 넘기지 않는 것으로 보면 된다.
위에서 살펴본 SRP 원칙을 지키면 어느 정도 해소가 되는 원칙이다. 단일 책임을 가지는 컴포넌트는 불필요한 props를 받을 필요가 없어지기 때문이다. 코드를 통해 살펴보도록 하자.
// BAD ❌
export function UserProfileBAD() {
const user = {
name: "John Doe",
email: "john.doe@example.com",
};
const project = {
name: "Landing Page",
};
return (
<div>
<h2 className="font-bold">User Dashboard</h2>
<Notification user={user} project={project} />
</div>
);
}
const Notification = ({ project, user }: NotificationProps) => {
if (project) {
// Display a project notification
return (
<div role="alert">
<span className="">
<IoMdNotifications />
</span>
<p className="font-bold">Project Export Finished</p>
<p className="text-sm">{project?.name}</p>
</div>
);
} else if (user) {
// Display a user notification
return (
<div role="alert">
<span className="">
<IoMdNotifications />
</span>
<p className="font-bold">Project Export Finished</p>
<p className="text-sm">{user?.email}</p>
</div>
);
} else {
return null;
}
};
결국에는 Notification
컴포넌트에 불필요한 props를 받아서 분기 처리를 하고 있는 것이다.
// GOOD ✅
export function UserProfileGOOD() {
const user = {
name: "John Doe",
email: "john.doe@example.com",
};
const project = {
name: "Landing Page",
};
return (
<div>
<h2 className="font-bold">User Dashboard</h2>
<UserNotification user={user} />
{/* <ProjectNotification project={project} /> */}
</div>
);
}
각각의 Notification컴포넌트로 분리하여 필요한 props만 넘기면 되는 것이다.
어찌보면 SRP원칙을 지키도록 했다면 위와 같은 상황이 벌어지지 않았을 것이다. 같은 책무를 가지지 않는 컴포넌트를 하나의 컴포넌트로 만들게 되면서 벌어진 일이기 때문이다.
또 다른 간단한 예시를 살펴보면,,
// BAD ❌
<UserProfile userInfo={userInfo} userId={userInfo.userId} userType={userInfo.userType} />
왜 잘못 되었을까?
userInfo={userInfo}
를 넘기게 되면서 나머지 props들이 불필요한 props로 변하게 되었다.
userInfo를 넘기는데 굳이 위에서 userTitle
과 userType
을 구조 분해 할당해서 넘길 필요가 있었을까?
그러므로 userInfo를 넘기는 이유가 무엇인지 판단해서 컴포넌트 내부에서 분해해서 사용할지 상위에서 결정해서 내려줄지 정해서 props를 정하는 것이 올바른 방법이다.
의존성 역전 원칙이란 "의존 관계를 맺을 때, 변하기 쉬운 것(구체적인 것)보다 변하기 어려운 것(추상적인 것)에 의존해야 한다"는 의미이다.
여기서 중요한 것은 바로 추상화된 것에 의존해야 한다는 것이다. 구체적인 구현은 쉽게 변하기 때문에 추상화된 것에 의존해서 코드의 확장성, 유연성을 향상시킬 수 있는 것이다.
// BAD ❌
const FeedbackForm = () => {
// ...
const handleSubmit = async (e) => {
try {
e.preventDefault();
// Perform registration logic here, e.g., send data to the server.
console.log("Registration data:", formData);
const data = {
id: uuidv4(),
fullName: formData.name,
feedback: prepareFeedbackDataColumn(formData.feedback),
};
await axios.post("https://playside.io/feedback/submit", data);
} catch (err) {
if (err && err instanceof Error)
console.log("Error: ", err.message);
else if (err instanceof AxiosError) handleAxiosError(err);
}
};
return (
<div>
// ...
<form onSubmit={handleSubmit}>
// ...
<button type="submit">
Send Feedback 😄
</button>
</form>
</div>
</div>
);
};
handleSubmit
는 구체적인 것에 의존하고 있다. 물론 API가 변경이 쉬운 것은 아니지만 구체적인 것에 의존하게 되는 것보다 추상화된 것에 의존해야 유지보수성, 유연성이 향상되는 것이다.
// GOOD ✅
export class FeedbackService {
constructor(private feedbackEndpoints) {}
async submitFeedback(feedbackData: {
feedback: string;
name: string;
}) {
try {
const data = {
id: uuidv4(),
fullName: feedbackData.name,
feedback: prepareFeedbackDataColumn(feedbackData.feedback),
};
await axios.post(this.feedbackEndpoints.SUBMIT, data);
} catch (err) {
if (err && err instanceof Error)
console.log("Error: ", err.message);
else if (err instanceof AxiosError) handleAxiosError(err);
}
}
}
export function FeedbackGOOD() {
const feedbackService = new FeedbackService();
return <FeedbackForm feedbackService={feedbackService} />;
}
const FeedbackForm = (feedbackService: FeedbackService) => {
// ...
const handleSubmit = async (e) => {
e.preventDefault();
// 상위에서 의존성을 주입받는 형태로 변경하여 (추상화된 것에 의존 한다.)
await feedbackService.submitFeedback(formData);
};
return (
<div>
// ...
<form onSubmit={handleSubmit}>
// ...
<button type="submit">
Send Feedback 😄
</button>
</form>
</div>
</div>
);
};
외부에서 feedbackService
를 주입받아서 handleSubmit 함수가 추상화된 메서드에 의존하게 되었다.
함께 보면 좋은 글
최근에 제로초님이 SOLID 강의를 시작했던데 아직 보진 않았지만 이것도 보면 좋을 것 같다.