React UI 컴포넌트와 도메인 컴포넌트 분리 전략: 지속 가능한 프론트엔드 설계
📌 읽기 전에
📚 이 포스팅은 React 환경을 기준으로 설명하지만, 컴포넌트 기반 프레임워크(Vue, Svelte 등) 전반에 적용 가능한 설계 철학을 담고 있습니다.
🖐️ 서론
프론트엔드 프로젝트가 커지다 보면 반드시 마주하는 문제가 있습니다. 바로 "어디까지가 공통 컴포넌트인가?", "어떤 컴포넌트 설계가 유지 보수에 유리한가?"에 대한 혼란입니다. 처음에는 단순히 버튼이나 입력창 정도를 공통화합니다. 하지만 시간이 흐를수록 특정 API, 특정 데이터에 의존하거나 복잡한 비즈니스 로직이 섞인 '비대해진 컴포넌트' 들이 혼재합니다.
신입 시절 사수님께 UI와 도메인(Domain/Business)으로 명확히 나누었을 때 얻을 수 있는 유연함을 많이 피드백 받았습니다. FE 개발자로서 지키고 있는 제 설계 철학의 기준이 되었고, 지금의 회사에도 적용하기 위해 노력하고 있습니다. 오늘은 FE 컴포넌트 설계 철학에 대한 원칙과 적용 사례를 공유해 보려 합니다.
TL;DR
- UI 컴포넌트: 오직 인터페이스를 통한 요소의 렌더링에만 집중 (재사용성 극대화)
- 도메인 컴포넌트: 비즈니스 로직과 데이터 처리 (응집도 향상)
- 핵심 전략: 의존성은 항상 도메인에서 UI로 흐르는 '단방향' 설계 (예측 가능한 아키텍처)
✅ 왜 나누어야 하는가?
하나의 컴포넌트가 '어떻게 보일지'와 '어떤 데이터를 다룰지'를 모두 책임지게 되면 다음과 같은 부작용이 발생합니다.
- 재사용성 하락: 디자인은 같은데 데이터 구조가 달라 사용하지 못하는 상황이 발생합니다.
- 테스트의 어려움: 단순 UI를 테스트하고 싶은데 Mock 데이터나 API 호출 가로채기(MSW 등) 설정이 복잡해집니다.
- 의존성 전파: 특정 도메인의 변경이 다른 도메인 UI에 영향을 주게 됩니다.
🏗️ 설계의 핵심: 역할 분담
1. UI 컴포넌트
UI 컴포넌트는 오직 "데이터를 어떻게 보여줄 것인가"에만 집중합니다.
- 특징: 프로젝트 내 어떤 도메인에서도 사용할 수 있어야 합니다. (ex) Button, Modal, List)
- 데이터: 도메인 모델을 직접 건드리지 않고, 정의된 인터페이스를 통해서만 접근합니다.
// UI 컴포넌트 예시: 도메인 색이 전혀 없는 경우
type CardProps = {
title: string;
description: string;
thumbnailUrl: string;
onClick?: () => void;
};
const Card = ({ title, description, thumbnailUrl, onClick }: CardProps) => (
<div className="card-layout" onClick={onClick}>
<img src={thumbnailUrl} alt={title} />
<h3>{title}</h3>
<p>{description}</p>
</div>
);2. 도메인 컴포넌트
도메인 컴포넌트는 "우리 서비스의 비즈니스 로직"을 알고 있습니다.
-
특징: 특정 서비스 페이지나 섹션에 종속적입니다. (ex) RecommendationYoutubeCampaign, CommunityBoardArticle)
-
데이터: 서버 API 응답 형태나 비즈니스 객체를 직접 다룹니다.
-
조합: 내부적으로 여러 UI 컴포넌트를 조합하여 실제 기능을 완성합니다.
// 도메인 컴포넌트 예시: [추천형 + 유튜브] 광고 캠페인 모델을 다룸
const RecommendationYoutubeCampaign = ({ campaignId }: { campaignId: string }) => {
const { data: recommendationCampaign, isLoading } = useCampaignQuery({
campaignType: CampaignType.Recommendation,
campaignId
}); // 도메인 의존적 hook
if (isLoading) return <SkeletonCampaign />;
return (
<>
<CampaignMeta {...기타_생략}/>
<CampaignDetailCard
title={recommendationCampaign.Youtuber.channelName}
description={recommendationCampaign.campaignDescription}
thumbnailUrl={recommendationCampaign.campaign.representativeImage.src}
{...기타_생략}
/>
<CampaignApplicationAction {...기타_생략}/>
{...기타_생략}
</>
);
};- 이 예시에서 CampaignDetailCard는 도메인 컴포넌트로 봐야 할지, UI 컴포넌트로 봐야 할지 애매할 수 있습니다.
- 저는 이런 경우 특정 도메인에 종속된 UI 컴포넌트라고 부릅니다. (ex: 캠페인에 종속된 UI 컴포넌트)
- UI 컴포넌트는 반드시 '완전히 범용적'일 필요는 없습니다. 특정 도메인에 종속되더라도, 비즈니스 판단 없이 표현만 담당한다면 UI 컴포넌트로 간주할 수 있습니다.
- 예를 들어 캠페인 컴포넌트는 아래와 같이 구성할 수 있습니다.
type CampaignCompProps = CardCompProps & {
title: string,
description: string,
thumbnailUrl: string,
...기타_생략
}
const CampaignDetailCard = ({title, description, thumbnailUrl}: CampaignCompProps) => {
return <section className={cn({...기타_생략},className)}>
<Text.title size={SIZE.Lg}>{title}</Text.title>
<Text.title size={SIZE.Lg}>{description}</Text.title>
// ... 생략
</section>
}🏗️ 설계 전략: 의존성의 방향
💡 핵심 원칙: 의존성의 방향은 항상 위에서 아래로 향합니다. 하위(UI) 레이어는 상위(Domain) 레이어의 존재를 몰라야 하며, 데이터는 위에서 아래로만 흐릅니다.
상위 컴포넌트는 하위 컴포넌트를 조립(Composition) 하는 역할을 수행합니다.
구조도에서 볼 수 있듯, 오직 위에서 아래로 흐르는 단방향 의존성입니다. 이는 리액트의 기본 철학을 잘 준수하는 방식입니다. 도메인 컴포넌트가 데이터 fetching(Tanstack Query 등)과 비즈니스 로직을 감싸고, 그 결과물을 순수 UI 컴포넌트에게 Props로 전달하는 형태가 이상적입니다. 이런 구조를 갖춰놓으면 도메인 로직의 변경이 공통 UI를 훼손시키는 경우를 예방할 수 있습니다.
💡 도메인 - UI 분리의 실무적 이점
1. 데이터에 대한 변경과 UI 변경을 구분해서 관리할 수 있습니다.
- 실제로 저희 회사에서는 추천형, 입찰형, 모집형이라는 세 개의 캠페인 유형이 있습니다. 각 캠페인 유형은 다시 한번 유튜브, 인스타그램, 블로그로 나눠집니다.
- Campaign의 종류가 추가되거나 삭제될 경우: 현재 자사 서비스에서는 모집형 - 유튜브 캠페인은 서비스되지 않습니다. 서비스가 확장되면서 이 케이스가 추가되어야 하는 경우 도메인별 구분이 잘 되어있고 인터페이스가 잘 수립되어 있다면 기존 캠페인 UI와 일관성 있게 관리가 가능합니다. (ex: RecruitmentYoutubeCampaign을 추가, 다른 도메인과 비슷한 구성)
- 특정 도메인의 변경 (ex: 모집형 - 인스타그램 캠페인에 변경이 있을 경우)에 유연합니다. -> RecruitmentInstagramCampaign 컴포넌트를 수정합니다.
2. 공통된 UI는 UI 컴포넌트를 구성해서 응집도를 높일 수 있습니다.
- 캠페인 화면의 UI가 변경될 경우 (Campaign) 컴포넌트를 수정해 일관된 UI를 유지하며 빠르게 변경할 수 있습니다.
3. API 의존성과 UI 구성을 분리해서 생각할 수 있습니다.
- 만약 전역 상태를 사용하고 있다면, 전역 상태에 대한 설정은 도메인 컴포넌트에서 처리하고 UI 컴포넌트는 전달받은 props를 가지고 화면을 그리는, 책임의 분리를 가질 수 있습니다.
- 만약 Tanstack Query를 전역적으로 활용하고 있다면, 해당하는 도메인에 대한 query는 도메인 컴포넌트에서 처리하고 UI 컴포넌트는 여전히 자신의 인터페이스를 가지고 렌더링을 담당할 수 있습니다.
4. 그 자체로 도메인을 설명해 주는 문서가 될 수 있습니다.
- 개발자들 사이에서 코드의 흐름을 그대로 적어두는 주석은 나쁜 패턴으로 인식되곤 합니다. 코드 자체로 설명이 되는 게 관리 포인트를 줄이는 최선의 방법이라는 해석입니다.
- 조건문이 덕지덕지 붙어있는 코드가 아닌, 작업자의 고민이 담겨있는 도메인 구분은 그 자체로 도메인 구성과 관계를 설명해 줄 수 있습니다. (ex: 어떤 캠페인이 있고, 캠페인의 UI는 어떻게 생겼으며, Button 등 디자인 시스템 레벨은 어떻게 구성되어 있는지 등)
🛠️ 리팩토링 적용기: 혼돈에서 질서로
자사 도메인에서는 여러 유형의 광고 캠페인이 존재합니다. 추천형 캠페인, 모집형 캠페인 즉 광고 유형에 따라서 분기됩니다. 다시 한번 더 유튜버 / 인스타그래머 / 블로거 등 플랫폼 별 분류가 있습니다. 여기에 이어서 우리 팀의 매니저님이 할당되는 대행형 캠페인 / 광고주가 직접 운영하는 구독형 캠페인으로 다시 한번 나눠집니다. 즉 어떤 카테고리를 상위로 해석할 것인지, 무슨 기준으로 도메인을 다룰 것인지 해석의 여지가 많습니다. 제가 처음 합류했을 때 FE 팀에서는 이 기준이 많이 부족했습니다. Next.js 철학을 따른다 + 응집도를 높인다 라는 해석 아래 app router 페이지 컴포넌트에 모든 로직을 정의하고 있었고, 각 페이지별로 컴포넌트 구성을 따로 관리, 모든 페이지는 조건문(if-else)의 남용으로 렌더링을 하고 있었습니다.
그래서 저는 위에서 보여드린 예시처럼, '도메인의 기준을 설정하고 도메인 관련 비즈니스 로직 (Tanstack Query를 포함)은 모두 도메인 컴포넌트에서 / 그 외 공통 UI는 별개의 UI 컴포넌트로 나누자.'는 철학을 전파했습니다. '회사 차원에서 개발, 특히 프론트엔드 영역은 생명주기가 짧고 빠르게 만들고 지워지는 코드다.'는 기준이 오래 지속되어왔고, 문화처럼 자리 잡혀있어 설득에 1년여의 시간이 걸렸습니다.
설득의 논지는 크게 아래의 네 가지였습니다.
- 회사 입장에서 페이지 단위 개발을 유지할 시 장기적인 손실이 발생할 것
- 도메인 / UI를 나누는 것은 관심사 분리의 기본 원칙이고, 일반론적인 방식임
- 생명주기가 짧으면 짧을수록 더 관심사 분리가 이뤄져야 함. 그래야 후속 대응이 쉬움
- 도메인 / UI 구분이 없는 상태에서 온보딩 문서 혹은 프로세스가 완벽하지 않다면, 신규 입사자는 적응하기 힘들 것 (제가 그랬던 것처럼)
입사 이후 1년 정도의 시간이 흘러서 모든 팀원들이 불편함을 호소했고, 저는 제 철학을 조금씩 설득하기 시작했습니다.
AS-IS: 무분별한 인라인 분기와 페이지 비대화
const Page = () => {
const {campaignId}: {campaignId: string} = useParams();
const searchParams = useSearchParams();
const applicationId = searchParams.get('applicationId') as string;
const recommendationId = searchParams.get('recommendationId') as string;
// 예시 단순화
const {data}: useQuery({queryKey: ['campaigns',campaignId]});
return <>
{!!applicationId && <div>모집형 캠페인에대한 컴포넌트들 여기에 나열</div>}
{!!recommendationId && <div>추천형 캠페인에대한 컴포넌트들 여기에 나열</div>}
</>
}
export default Page;// campaign/[campaignId]/page.ts
{!!applicationId && <div>
{platformType === 'Instagram' && <div></div>...}
{platformType === 'Blog' && ...}
{platformType === 'Instagram' && <div></div>...}
{platformType === 'Youtube' && <span></span>...}
{platformType === 'Instagram' && <div></div>...}
</div>}
{!!applicationId && <div>모집형 캠페인에대한 컴포넌트들 여기에 나열</div>}각 플랫폼 분기 역시 분기 없이 순전히 해당 페이지를 그리기 위해 무의미하게 조건 처리되어 있습니다. 이는 명령형 패러다임의 장점이 필요한 상황이 아닙니다. 관심사 분리가 이루어지지 않은 / 유지 보수하기에 난해한 코드입니다.
TO-BE: 도메인에 따른 선언적 분리와 책임 격리
그래서 저는 이 구분 기준을 나누고자 아래와 같은 코드를 제시합니다. (위의 도메인 분류하는 설명의 예시와 유사합니다.)
// components/recruitmentCampaigns/RecruitmentCampaign.ts
type RecruitmentCampaignDetailProps = Pick<CampaignBase, 'campaignId' | 'platformType'>;
const RecruitmentCampaignDetail = ({campaignId, platformType}: RecruitmentCampaignDetailProps) => {
const {data: recruitmentCampaign} = useRecruitmentCampaign({platformType});
// 모집형 캠페인만 렌더링, 여전히 프론트 인터페이스가 없기에 리팩토링 필요합니다.
if (!recruitmentCampaign || recruitmentCampaign.type !== 'RECRUITMENT') {
return null;
}
return (
<>
{recruitmentCampaign.platform === 'INSTAGRAM' && <RecruitmentInstagramCampaign campaign={recruitmentCampaign} />}
{recruitmentCampaign.platform === 'BLOG' && <RecruitmentBlogCampaign campaign={recruitmentCampaign} />}
{recruitmentCampaign.platform === 'YOUTUBE' && <RecruitmentYoutubeCampaign campaign={recruitmentCampaign} />}
</>
)
}이 컴포넌트의 책임은 "모집형 캠페인을 어떤 하위 컴포넌트로 위임할지 결정하는 것"까지입니다.
각 캠페인은 다시 한번, CampaignDetail 컴포넌트를 호출할 것이며, CampaignDetail 컴포넌트는 FE에서 UI를 그리기 위해 캠페인 데이터를 제외한 다른 데이터에 의존하지 않은 props + UI만 렌더링 하는 책임을 가진 컴포넌트가 될 것입니다.
type RecruitmentInstagramCampaign = RecruitmentCampaign & InstagramCampaign & {...기타_생략}
export type RecruitmentInstagramCampaignProps = {
campaign: RecruitmentInstagramCampaign
};
const RecruitmentInstagramCampaign = ({campaign}: {campaign: RecruitmentInstagramCampaign}) => {
// 모집형 + 인스타그램 캠페인에 해당하는 비즈니스 로직
return (
<>
<CampaignHeader title={campaign.RecruitmentCampaign.title} {...기타_생략} />
<CampaignSelectionCard title={campaign.RecruitmentCampaign.title} {...기타_생략} />
</>
)
}공통의 UI (캠페인별로 상이하지 않은 형태)는 CampaignSelectionCard 등 props를 정의한 컴포넌트로, 그 외 도메인을 처리하는 컴포넌트는 데이터(전역 상태를 사용한다면 전역 스토어) 의존성을 가지게끔 설계합니다.
추천형 캠페인 혹은 그 외 기타 도메인 역시 동일한 기준으로 설계가 가능합니다. 새로운 캠페인 유형이 추가되어도 같은 방식으로 확장할 수 있습니다.
type RecommendationInstagramCampaign = Recommendation & InstagramCampaign & {...기타_생략}
type RecommendationInstagramCampaignProps = {
campaign: RecommendationInstagramCampaign
};
const RecommendationInstagramCampaign = ({campaign}: {campaign: RecommendationInstagramCampaign}) => {
// 추천형 + 인스타그램 캠페인에 해당하는 비즈니스 로직
return (
<>
<CampaignHeader title={campaign.Recommendation.title} {...기타_생략} />
<CampaignSelectionCard title={campaign.Recommendation.title} {...기타_생략} />
</>
)
}✅ 리팩토링 결과 및 회고
- 백엔드 데이터에 대한 의존성을 줄일 수 있었습니다. (전역 상태를 사용한다면 컴포넌트 레벨에서의 데이터 의존성이 0에 수렴합니다)
- 복잡한 조건 분기문을 나열하지 않아서 가독성이 향상되었습니다.
- SRP, DIP (단, DI 컨테이너를 활용한 완전한 제어 역전은 FE 레벨에서는 불가) 등 객체 지향의 설계 이념을 충족합니다. 비록 OOP를 지향하지 않더라도 이는 역할별 구분, 관심사 분리가 이루어졌다는 의미가 될 수 있습니다.
- 단위 테스트가 가능해졌습니다. (AS-IS 코드는 로직이 섞여 있어 단위 테스트에 부적합한 형태였습니다.)
- 무리하게 모든 것을 공통화할 필요는 없습니다. 해당 도메인 내에서만 쓰이는 컴포넌트는 그 도메인에서 처리하는 것이 더 안전한 선택입니다.
- 도메인 컴포넌트가 깊어질 경우 Props Drilling이 발생하곤 합니다. 그렇다고 Context를 주입하는 순간, 순수성을 잃고 종속성을 가지게 됩니다. 이럴 때는 컴포넌트 합성 방식을 활용할 수 있었습니다.
const AdvertiserProfile = () => {
return (
<UserProfile.Wrapper>
<UserProfile.Date>{...기타_생략}</UserProfile.Date>
<UserProfile.Title>{...기타_생략}</UserProfile.Title>
</UserProfile.Wrapper>
)
}🧭 끝으로
지난 포스팅에서 비동기를 값으로 다루는 '함수형 리팩토링'을 고민한 것은 데이터를 어떻게 잘 다룰 것인지에 대한 고민이었습니다. 이번에는 그 데이터가 최종적으로 안착할 '그릇'에 대해 고민해 보았습니다.
결국 좋은 설계란 "변경의 이유가 같은 것끼리 묶고, 다른 것끼리 나누는 것"이라는 기본 원칙으로 귀결됩니다. 쉽게 표현해서 응집도를 높이고 결합도를 낮추자는 목표입니다. UI와 도메인을 나누는 기준을 팀 내에서 합의하고 코드를 작성한다면, 기획이 변경되어 디자인이 바뀌거나 데이터 구조가 바뀌어도 서로에게 미치는 영향을 최소화할 수 있습니다.
좋은 코드는 유지 보수를 고려하지 않고 평가할 수 없습니다. 제가 회사에 합류했던 시점에 마주한 코드는 PoC를 지난지 얼마 되지 않은 코드였습니다. 도메인 설계 능력이 부족한 팀에서, 빠르게 제품을 출시하고자 하는 목표를 세웠다면 분리보다는 통째로 작업 하는 게 유리했을 수 있습니다. 따라서 하나의 설계 기준이 모든 상황에 적용될 수 있는 '정답'처럼 인식하지는 않으셨으면 좋겠습니다.
작년쯤부터 유행하고있는 FSD 아키텍처나 혹은 좀 더 이전으로 돌아가서 아토믹 디자인 등 컴포넌트 레이어를 구분하는 아키텍처들도 모두 어떻게 응집도를 높이고 결합도를 낮추는지에 대한 고민이라고 생각합니다. 자신만의 설계 원칙을 가지고 아키텍처를 바라보면 좀 더 다양한 시각을 얻으실 수 있는 것 같습니다.
잘못된 정보가 있을 경우 피드백 부탁드립니다. 어떤 의견이라도 언제나 환영합니다. 여러분은 프로젝트의 컴포넌트 경계를 나눌 때 어떤 기준을 가장 중요하게 생각하시나요? 각자의 실무 환경에서는 어떤 방식으로 관심사를 분리하고 계시는지 댓글로 자유롭게 공유해 주세요. 이 글이 프론트엔드 설계 고민에 작은 보탬이 되었기를 바랍니다.