들어가며
이전 글에서 로직 계층을 내려서 단일 관심사를 챙기는 이야기를 했었죠. 여러 주제가 섞인 컴포넌트를 분리하고, 각 컴포넌트가 자기 로직만 가지도록 만드는 거였어요.
이번엔 반대 방향입니다. 분기를 상위로 올려서 단일 관심사를 챙기는 이야기를 해볼게요.
컴포넌트는 "단일 책임이 아니라 단일 관심사"로 생각해야 합니다.
여기서 단일 관심사란 곧 높은 응집도를 의미합니다. 하나의 컴포넌트가 하나의 맥락에 필요한 것들만 모아서 가지고 있는 상태죠.
근데 컴포넌트 안에서 강하게 결합된 로직들을 어떻게 분리할 수 있을까요?
트리 구조의 컴포넌트
상위에서 복잡도를 해소하기
React 컴포넌트는 단방향 트리 구조입니다. 데이터와 제어 흐름이 위에서 아래로만 흐르죠.
<Parent> // 여기서 결정하면
<Child /> // 여기는 단순해진다
</Parent>
이게 무슨 뜻이냐면, 상위에서 복잡도를 해소할수록 하위가 단순해진다는 겁니다.
TypeScript의 Tagged Union과 비슷한 개념이에요:
// 하나의 타입에서 모든 상태 관리
type Post = {
canEdit: boolean;
editableFields?: { ... }; // canEdit이 true일 때만 유효
readOnlyFields?: { ... }; // canEdit이 false일 때만 유효
}
// Tagged Union - 분기가 타입 수준에서 미리 됨
type Post =
| { type: 'editable'; editableFields: { ... } }
| { type: 'readonly'; readOnlyFields: { ... } }
Tagged Union의 핵심은 분기를 타입 정의 시점에 미리 해두는 것입니다. 그러면 각 케이스를 다룰 때 "이 상황에서 저 필드가 유효한가?"를 고민할 필요가 없어지거든요.
React 컴포넌트도 똑같습니다. TypeScript에서 Tagged Union으로 타입을 분기하듯, React에서는 컴포넌트 분기로 같은 효과를 냅니다. 상위에서 조건을 판단하고 적절한 컴포넌트를 렌더링하면, 하위 컴포넌트는 자기 케이스만 생각하면 되죠.
분기를 상위로 밀어내기
Before: 한 컴포넌트에서 분기 처리
function PostDialog({ postId, onClose }) {
// 아토믹한 훅들을 조합
const { data: post } = useSuspenseQuery(postQueries.detail(postId));
const { canEdit } = usePostEditPermission({ status: post.status });
const form = useForm({ defaultValues: { title: post.title } });
const { mutate } = useSavePost({ postId, onSuccess: onClose });
// 분기 처리
if (canEdit) {
return (
<>
<DialogHeader>포스트 편집</DialogHeader>
<PostForm form={form} />
<Button onClick={() => mutate(form.getValues())}>저장</Button>
</>
);
}
return (
<>
<DialogHeader>포스트 상세</DialogHeader>
<PostContent post={post} />
<Button onClick={onClose}>닫기</Button>
</>
);
}
문제점:
- 응집도 낮음:
form,mutate가 읽기 모드에서 불필요하게 존재 - 확장 어려움: 각 모드별 기능 추가 시 if 분기 내부가 복잡해짐
After: 분기를 상위로
상위에서 미리 조건을 분기해 하위에선 해당 관심사에만 집중할 수 있도록 처리합니다.
useSuspenseQuery를 사용해 isLoading 상태를 신경쓰지 않도록 하는 것과 유사하죠.
// 상위에서 분기 먼저 처리
function PostPermissionBranch({ postId, onClose }) {
const { data: post } = useSuspenseQuery(postQueries.detail(postId));
const { canEdit } = usePostEditPermission({ status: post.status });
return canEdit
? <Editable post={post} onClose={onClose} />
: <ReadOnly post={post} onClose={onClose} />;
}
// Editable은 편집만 생각
function Editable({ post, onClose }) {
const form = useForm({ defaultValues: { title: post.title } });
const { mutate } = useSavePost({ postId: post.id, onSuccess: onClose });
return (
<>
<DialogHeader>포스트 편집</DialogHeader>
<PostForm form={form} />
<Button onClick={() => mutate(form.getValues())}>저장</Button>
</>
);
}
// ReadOnly는 읽기만 생각
function ReadOnly({ post, onClose }) {
return (
<>
<DialogHeader>포스트 상세</DialogHeader>
<PostContent post={post} />
<Button onClick={onClose}>닫기</Button>
</>
);
}
개선 사항:
- 응집도 높음: 각 컴포넌트가 자기 케이스에 필요한 것만 가짐
- 확장 쉬움: 편집 모드 기능 추가?
Editable만 수정하면 됨
적용 사례
사례 1: 권한에 따라 UI와 Action이 완전히 다른 경우 → 분리
function EditPostModal({ isOpen, close, postId }) {
return (
<Dialog open={isOpen} onOpenChange={close}>
<DialogContent>
<Suspense fallback={<LoadingState />}>
<PostPermissionBranch postId={postId} onClose={close} />
</Suspense>
</DialogContent>
</Dialog>
);
}
function PostPermissionBranch({ postId, onClose }) {
const { data: post } = useSuspenseQuery(postQueries.detail(postId));
const { canEdit } = usePostEditPermission({ status: post.status });
return canEdit ? (
<PostDialogContent.Editable post={post} onClose={onClose} />
) : (
<PostDialogContent.ReadOnly post={post} onClose={onClose} />
);
}
사례 2: 권한에 따라 버튼만 다른 경우 → 유지
function EditDocumentModalContent({ documentId, close }) {
const { data: document } = useSuspenseQuery(documentQueries.detail(documentId));
const { canEdit, canPublish } = useDocumentPermission({ status: document.status });
// 모든 상태가 공통으로 사용됨
const { uploadFile } = useFileUpload(document.id);
const editor = useCreateBlockNote({ uploadFile });
const form = useForm({
defaultValues: { title: document.title, content: document.content }
});
const { saveMutation, publishMutation } = useDocumentMutation({ ... });
return (
<>
<DialogHeader>문서 편집</DialogHeader>
<DocumentForm form={form} editor={editor} editable={canEdit} />
<DialogFooter>
<Button onClick={close}>취소</Button>
{canEdit && <Button onClick={handleSave}>저장</Button>}
{canPublish && <Button onClick={handlePublish}>발행</Button>}
</DialogFooter>
</>
);
}
여기서는 굳이 분리하지 않았어요. 왜냐하면:
- 모든 권한 케이스에서 같은 훅을 호출
- 분기가 버튼 visible/disabled만 바꿈
- 분리하면 오히려 같은 데이터를 여러 곳에서 나눠 관리해야 함
분리 여부 판단 기준
무조건 분리: 분기별로 다른 훅을 호출해야 할 때
// ❌ 이런 코드가 보이면 무조건 분리
function PostDialog({ postId, canEdit }) {
const { data: post } = useSuspenseQuery(postQueries.detail(postId));
// 편집 모드에서만 필요한 훅들
const form = useForm({ ... }); // canEdit일 때만 필요
const { mutate } = useSavePost({ ... }); // canEdit일 때만 필요
if (canEdit) {
return <EditUI form={form} mutate={mutate} />;
}
return <ReadOnlyUI post={post} />;
}
훅은 조건부 호출이 불가능합니다. 그래서 분기 중 한쪽에서만 쓰는 훅이 있으면, 다른 쪽에서는 불필요한 훅이 실행됩니다. 이는 비효율적이며 인지 강도를 올립니다.
UI가 동일할 경우 한방에 처리하고 싶은 생각이 들지만 참아야 합니다..
분리 신호
- 조건별로 UI 구조가 크게 다르다
- 조건별로 필요한 훅이 다르다 (→ 무조건 분리)
- 한 케이스에서만 쓰는 상태가 다른 케이스에서 불필요하게 존재한다
유지 신호
- 모든 조건에서 같은 훅을 호출한다
- 조건이 버튼 visible/disabled 정도만 바꾼다
- 분리하면 같은 데이터를 여러 곳에서 나눠 관리해야 한다
마치며
분기를 상위로 밀어내면 자연스럽게:
- 각 컴포넌트의 응집도가 높아져 단일 관심사로 관리하기 용이하고
- 코드 읽기가 쉬워지고
- 확장하기도 편해집니다
각 파일을 열면 딱 그 상황에 대한 코드만 있으니까요.
'<frontend> > clean?code' 카테고리의 다른 글
| 단일 관심사 컴포넌트 (6) | 2025.12.13 |
|---|---|
| 훅 추상화 강도 (3) | 2025.08.31 |