출처: https://cekrem.github.io/posts/single-responsibility-principle-in-react/
우리는 지금까지 의존성 역전 원칙, 인터페이스 분리 원칙, 리스코프 치환 원칙, 그리고 개방-폐쇄 원칙에 대해 다뤄보았습니다. 이제 SOLID의 기초가 되는 단일 책임 원칙(SRP)을 살펴볼 차례입니다.
다시 한번, 고전적인 저서 'Clean Architecture'를 통해 좋은 소프트웨어 아키텍처의 중요성을 일깨워주신 Uncle Bob에게 감사드립니다! 이 시리즈는 그의 책에서 주된 영감을 얻었습니다.
단일 책임 원칙이란 하나의 클래스는 오직 하나의 이유로만 변경되어야 한다는 원칙입니다.
다음 코드는 흔히 발견되는 안티패턴 입니다.
// 이렇게 작성하지 마세요
const UserProfile = () => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetchUser();
}, []);
const fetchUser = async () => {
try {
const response = await fetch("/api/user");
const data = await response.json();
setUser(data);
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
}
};
const handleUpdateProfile = async (data: Partial<User>) => {
try {
await fetch("/api/user", {
method: "PUT",
body: JSON.stringify(data),
});
fetchUser(); // Refresh data
} catch (e) {
setError(e as Error);
}
};
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!user) return <div>No user found</div>;
return (
<div>
<h1>{user.name}</h1>
<form onSubmit={/* form logic */}>{/* Complex form fields */}</form>
<UserStats userId={user.id} />
<UserPosts userId={user.id} />
</div>
);
};
이 컴포넌트는 다음과 같은 여러 책임을 가지고 있어 단일 책임 원칙에 위배됩니다.
이를 각각의 역할에 집중화 된 컴포넌트들로 분리해보겠습니다.
// 데이터 페칭 훅
const useUser = (userId: string) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetchUser();
}, [userId]);
const fetchUser = async () => {
try {
const response = await fetch(`/api/user/${userId}`);
const data = await response.json();
setUser(data);
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
}
};
return { user, loading, error, refetch: fetchUser };
};
// 프레젠테이션 컴포넌트
const UserProfileView = ({
user,
onUpdate,
}: {
user: User;
onUpdate: (data: Partial<User>) => void;
}) => (
<div>
<h1>{user.name}</h1>
<UserProfileForm user={user} onSubmit={onUpdate} />
<UserStats userId={user.id} />
<UserPosts userId={user.id} />
</div>
);
// 컨테이너 컴포넌트
const UserProfileContainer = ({ userId }: { userId: string }) => {
const { user, loading, error, refetch } = useUser(userId);
const handleUpdate = async (data: Partial<User>) => {
try {
await fetch(`/api/user/${userId}`, {
method: "PUT",
body: JSON.stringify(data),
});
refetch();
} catch (e) {
// 에러 핸들링
}
};
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
if (!user) return <NotFound message="User not found" />;
return <UserProfileView user={user} onUpdate={handleUpdate} />;
};
각 컴포넌트가 단 하나의 명확한 책임을 갖도록 설계하면, 전체 애플리케이션이 더욱 유지보수하기 쉽고, 테스트하기 쉬우며, 유연해집니다.
Uncle Bob이 클린 아키텍처에서 강조했듯이, '변경해야 하는 이유가 하나여야 한다'는 것입니다. 다음의 미묘한 차이를 이해하는 것이 중요합니다.
프로 팁: 컴포넌트가 하는 일을 설명할 때 '그리고'라는 단어를 사용하게 된다면, 이는 아마도 단일 책임 원칙을 위반하고 있을 수 있습니다. 분리하세요! 하지만 동시에 각 부분이 왜 변경될 수 있는지, 그리고 누가 그러한 변경을 요청할 수 있는지도 함께 고려해봐야 합니다.
소프트웨어 아키텍처에 대한 포괄적인 가이드를 찾고 계신다면, 이 글은 그런 글이 아닙니다. 최근 제가 작성한 소프트웨어 아키텍처에 관한 게시물들의 목적은 제가 이전에 너무 쉽게 무시했거나 적용하기를 게을리했던 몇 가지 원칙들을 실용적인 방식으로 탐구해보는 것이었습니다. 저는 이러한 개념들을 완벽히 마스터했다고 주장하는 것도 아니고, 이러한 원칙들을 모든 상황에 엄격하게 적용해야 한다고 제안하는 것도 아닙니다. 심지어 제가 든 간단한 예시들이 이러한 원칙들을 구현하거나 설명하는 최선의 방법이라고 주장하는 것도 아닙니다. 오히려, 저는 고전적인 소프트웨어 공학 원칙들과 현대적인 개발 방식을 연결하려는 제 시도를 기록하고 있는 것입니다. 사실, 저는 아직 최종적으로 얼마나 '클린 아키텍처'에 가깝게 갈 것인지 혹은 얼마나 실용적으로 접근할 것인지를 결정하지 못했습니다. 하지만 지금은 이러한 학습과 탐구 과정을 (대체로) 즐기고 있습니다. 레딧에서 저나 Uncle Bob, 혹은 다른 누군가를 비난하기 전에 이 점을 염두에 둬주시면 감사하겠습니다 😅
각각의 컴포넌트를 따로 파일로 분리할 필요 없이 하나의 파일 내에서 SRP에 따라 여러 컴포넌트를 정의해도 괜찮은건가요?