
프론트엔드 개발을 처음 배울 때 컴포넌트는 보통 “화면을 여러 조각으로 나누어 만드는 방법”이라고 설명한다.
function CouponCard() {
return (
<div>
<h2>커피 데이트</h2>
<button>사용하기</button>
</div>
);
}
CouponCard는 쿠폰 하나를 보여주는 화면 조각이다.
컴포넌트의 기본 개념을 이해하기에는 충분한 설명이다.
하지만 실제 서비스를 개발하기 시작하면 화면을 어디에서 자를 것인지만으로는 좋은 컴포넌트를 만들기 어렵다.
쿠폰 카드 하나에도 여러 판단이 필요하다.
실제 서비스에서 컴포넌트는 화면을 나누는 조각이 아니다. 하나의 UI가 맡을 책임과 상태의 소유권, 외부와의 계약, 재사용 가능한 변경 범위를 결정하는 경계다.
쿠폰 상세 화면을 눈에 보이는 영역에 따라 나눌 수 있다.
function CouponPage() {
return (
<>
<CouponHeader />
<CouponInformation />
<CouponActions />
<CouponFooter />
</>
);
}
이 구조가 잘못된 것은 아니다.
각 영역이 독립적인 책임을 가진다면 이해하기 좋은 구성일 수 있다.
하지만 화면에 구분선이 있다는 이유만으로 컴포넌트를 나누면 이름만 다른 작은 파일이 늘어날 수 있다.
function CouponTitle({
title,
}: {
title: string;
}) {
return <h2>{title}</h2>;
}
CouponTitle이 단 한 곳에서 제목 한 줄만 출력하고 별도의 규칙도 없다면, 이 분리가 제공하는 이점은 크지 않을 수 있다.
반대로 화면에서는 작아 보여도 독립된 책임이 있다면 컴포넌트로 분리할 이유가 생긴다.
function RedeemCouponButton({
couponId,
disabled,
onRedeemed,
}: RedeemCouponButtonProps) {
const [isSubmitting, setIsSubmitting] =
useState(false);
// 쿠폰 사용 요청과 결과 처리
}
이 버튼은 단순한 모양 이상의 책임을 가진다.
중복 클릭을 막고, 요청 중인 상태를 보여주며, 쿠폰 사용 결과를 부모에게 전달할 수 있다.
컴포넌트 경계는 픽셀이나 HTML 태그의 개수보다 다음 질문으로 판단하는 편이 유용하다.
이 UI는 다른 부분과 구분되는 하나의 책임과 변경 이유를 가지는가?
쿠폰 목록 컴포넌트가 모든 작업을 처리할 수 있다.
function CouponList() {
const [coupons, setCoupons] =
useState<Coupon[]>([]);
const [query, setQuery] =
useState("");
const [selectedId, setSelectedId] =
useState<string | null>(null);
useEffect(() => {
fetch("/api/coupons")
.then((response) =>
response.json()
)
.then(setCoupons);
}, []);
// 검색, 정렬, 선택, 사용 요청,
// 오류 표시와 전체 화면 렌더링
}
처음에는 한 파일에서 전체 흐름을 볼 수 있어 편리하다.
기능이 늘어나면 이 컴포넌트는 서로 다른 이유로 계속 변경된다.
책임에 따라 경계를 나눌 수 있다.
function CouponPage() {
const couponQuery = useCoupons();
return (
<CouponListSection
query={couponQuery}
/>
);
}
function CouponListSection({
query,
}: CouponListSectionProps) {
if (query.isLoading) {
return <CouponListSkeleton />;
}
if (query.isError) {
return <CouponListError />;
}
return (
<CouponList
coupons={query.data}
/>
);
}
function CouponList({
coupons,
}: {
coupons: Coupon[];
}) {
return coupons.map((coupon) => (
<CouponCard
key={coupon.id}
coupon={coupon}
/>
));
}
각 컴포넌트는 다른 질문에 답한다.
CouponPage는 화면에 필요한 서버 데이터를 준비한다.CouponListSection은 로딩·실패·성공 상태를 표현한다.CouponList는 목록을 렌더링한다.CouponCard는 쿠폰 하나의 정보와 행동을 보여준다.파일 수를 늘리는 것이 목적은 아니다.
서로 다른 변경 이유가 한 컴포넌트에 계속 쌓일 때 책임의 경계를 만드는 것이 목적이다.
컴포넌트는 props를 통해 값을 받을 수 있다.
<CouponCard
title="커피 데이트"
remainingUses={3}
/>
문법적으로는 부모가 자식에게 문자열과 숫자를 전달한 것이다.
서비스 설계 관점에서 props는 컴포넌트를 사용하기 위해 외부가 알아야 하는 계약이다.
계약이 지나치게 넓으면 사용하는 쪽이 내부 사정을 많이 알아야 한다.
<CouponCard
title={coupon.title}
recipientName={
coupon.recipient.name
}
remainingUses={
coupon.remainingUses
}
expiresAt={coupon.expiresAt}
status={coupon.status}
isExpired={
coupon.expiresAt <= now
}
canRedeem={
coupon.status === "active" &&
coupon.remainingUses > 0
}
buttonLabel="사용하기"
buttonColor="green"
/>
부모가 만료와 사용 가능 여부를 계산하고 버튼의 세부 표현까지 결정한다.
쿠폰 카드의 규칙이 여러 화면으로 퍼질 수 있다.
컴포넌트가 맡을 판단을 계약 안으로 옮길 수 있다.
<CouponCard
coupon={coupon}
currentUserId={currentUser.id}
onRedeem={handleRedeem}
/>
이제 부모는 쿠폰 카드가 수행하는 구체적인 렌더링 판단을 모두 알 필요가 없다.
그렇다고 모든 데이터를 하나의 거대한 객체로 전달하는 것이 항상 좋은 것은 아니다.
<UserProfileCard
applicationState={applicationState}
/>
이 컴포넌트는 필요한 값이 무엇인지 드러내지 않으며 애플리케이션 전체 구조에 의존할 수 있다.
좋은 컴포넌트 계약은 다음 두 가지 사이에서 균형을 찾는다.
쿠폰 카드 안에서 선택 상태를 관리할 수 있다.
function CouponCard({
coupon,
}: CouponCardProps) {
const [isSelected, setIsSelected] =
useState(false);
// ...
}
카드 하나만 자신의 선택 여부를 사용한다면 자연스러운 위치다.
하지만 한 번에 하나의 쿠폰만 선택할 수 있다면 각 카드가 독립적으로 상태를 가지면 안 된다.
쿠폰 A: 선택됨
쿠폰 B: 선택됨
쿠폰 C: 선택됨
여러 카드가 동시에 선택되는 상태가 만들어질 수 있다.
목록이 선택 규칙을 책임지도록 상태를 올릴 수 있다.
function CouponList({
coupons,
}: CouponListProps) {
const [
selectedCouponId,
setSelectedCouponId,
] = useState<string | null>(null);
return coupons.map((coupon) => (
<CouponCard
key={coupon.id}
coupon={coupon}
selected={
coupon.id === selectedCouponId
}
onSelect={() =>
setSelectedCouponId(coupon.id)
}
/>
));
}
이제 목록이 “하나의 쿠폰만 선택할 수 있다”는 규칙을 관리한다.
상태를 어디에 둘지는 값을 표시하는 위치만으로 결정하지 않는다.
그 상태의 일관성을 누가 책임져야 하는가?
이 질문에 따라 가장 가까운 적절한 소유자를 선택해야 한다.
상태를 너무 아래에 두면 여러 컴포넌트가 동기화되지 않는다.
반대로 모든 상태를 전역 저장소에 두면 작은 화면 변경도 애플리케이션 전체의 관심사가 된다.
쿠폰 목록은 서버에서 가져온 데이터다.
const couponQuery = useQuery({
queryKey: ["coupons"],
queryFn: fetchCoupons,
});
검색 입력창의 열림 여부는 현재 화면에서만 필요한 상태일 수 있다.
const [
isSearchOpen,
setIsSearchOpen,
] = useState(false);
두 값 모두 화면에 영향을 주지만 성격은 다르다.
| 상태 | 원본 | 주요 책임 |
|---|---|---|
| 쿠폰 목록 | 서버·데이터베이스 | 조회, 캐시, 갱신, 오래된 데이터 처리 |
| 검색창 열림 여부 | 현재 UI | 사용자 상호작용 |
| 선택된 쿠폰 ID | 화면 또는 URL | 선택 흐름과 복원 |
| 쿠폰 사용 횟수 | 서버·데이터베이스 | 영구 상태와 동시성 |
| 편집 중인 제목 | 화면의 초안 | 저장 전 임시 변경 |
서버에서 받은 쿠폰을 컴포넌트 상태에 그대로 복사하면 두 개의 원본이 생길 수 있다.
const { data: coupon } =
useCoupon(couponId);
const [localCoupon, setLocalCoupon] =
useState(coupon);
서버 데이터가 갱신되어도 localCoupon이 자동으로 같아진다는 보장은 없다.
단순히 표시하려는 목적이라면 서버 상태를 직접 사용하는 편이 자연스럽다.
const { data: coupon } =
useCoupon(couponId);
return coupon
? <CouponCard coupon={coupon} />
: null;
별도의 상태가 필요하다면 다른 의미를 분명하게 표현해야 한다.
const [couponDraft, setCouponDraft] =
useState(() =>
createCouponDraft(coupon)
);
couponDraft는 서버 상태의 우연한 복사본이 아니라 사용자가 수정 중인 초안이다.
컴포넌트의 상태를 설계할 때는 값의 위치뿐 아니라 원본과 사본의 관계도 결정해야 한다.
자식 컴포넌트가 부모의 상태 변경 함수를 직접 알 수 있다.
<CouponCard
coupon={coupon}
setSelectedCouponId={
setSelectedCouponId
}
/>
이 계약은 자식에게 부모 상태의 저장 방식과 변경 방법을 노출한다.
자식은 사용자의 행동만 전달할 수 있다.
<CouponCard
coupon={coupon}
onSelect={handleCouponSelect}
/>
onSelect는 “부모의 특정 상태를 변경하라”는 구현 지시가 아니다.
“사용자가 이 쿠폰을 선택했다”는 UI 이벤트를 전달한다.
부모는 그 의미에 맞게 반응한다.
function handleCouponSelect(
couponId: string
) {
setSelectedCouponId(couponId);
closeCouponPreview();
trackCouponSelection(couponId);
}
자식은 선택 이후 부모가 무엇을 하는지 알 필요가 없다.
이벤트 콜백의 이름도 기술적 동작보다 의미를 표현하는 편이 좋다.
onClick={setTrue}
이 이름은 값이 변경된다는 사실만 보여준다.
onOpenRedeemDialog={handleOpen}
이 이름은 사용자가 어떤 상호작용을 요청했는지 보여준다.
컴포넌트 이벤트는 부모와 자식을 느슨하게 만드는 동시에 두 영역 사이의 의미 있는 계약이 된다.
재사용 가능한 쿠폰 카드를 만들기 위해 여러 옵션을 추가할 수 있다.
<CouponCard
compact
editable
selectable
showRecipient
hideActions
adminMode
/>
옵션이 여섯 개라면 이론적으로 64개의 조합이 만들어질 수 있다.
그중에는 의미가 모순되는 조합도 있을 수 있다.
<CouponCard
editable
hideActions
/>
수정 가능하지만 행동 버튼은 숨겨진 카드가 서비스에서 실제로 필요한 형태인지 불분명하다.
역할이 분명한 변형으로 제한할 수 있다.
<CouponCard
variant="selectable"
coupon={coupon}
/>
또는 공통 시각 요소를 조합하되 행동은 별도 컴포넌트가 맡게 할 수 있다.
<CouponCardLayout
header={<CouponTitle coupon={coupon} />}
footer={
<RedeemCouponAction
coupon={coupon}
/>
}
/>
이 구조에서 레이아웃은 배치를 책임지고, 행동 컴포넌트는 쿠폰 사용 흐름을 책임진다.
재사용은 옵션을 계속 추가해 하나의 컴포넌트가 모든 경우를 처리하게 만드는 일이 아니다.
재사용 가능한 부분과 상황에 따라 달라지는 부분의 경계를 찾는 일이다.
관리자 쿠폰 카드와 사용자 쿠폰 카드가 비슷하게 보일 수 있다.
<AdminCouponCard coupon={coupon} />
<UserCouponCard coupon={coupon} />
둘을 하나로 합치면 코드 중복을 줄일 수 있을 것처럼 보인다.
<CouponCard
coupon={coupon}
isAdmin={isAdmin}
canDelete={canDelete}
canRedeem={canRedeem}
showOwner={showOwner}
/>
하지만 두 화면의 책임은 다를 수 있다.
시각적으로 공통인 작은 요소만 재사용할 수 있다.
function CouponSummary({
coupon,
}: CouponSummaryProps) {
return (
<>
<CouponTitle
title={coupon.title}
/>
<CouponExpiry
expiresAt={coupon.expiresAt}
/>
</>
);
}
관리자와 사용자 화면은 CouponSummary를 함께 사용하면서 각자의 행동을 별도로 관리할 수 있다.
재사용 여부는 현재 모양이 같은지가 아니라 같은 책임과 같은 이유로 함께 변경되는지에 따라 결정해야 한다.
성급한 공통 컴포넌트는 중복 코드를 줄이는 대신 서로 다른 기능을 하나의 변경 경로로 묶을 수 있다.
성공한 데이터만 가정한 컴포넌트는 간단하다.
function CouponList({
coupons,
}: CouponListProps) {
return coupons.map((coupon) => (
<CouponCard
key={coupon.id}
coupon={coupon}
/>
));
}
하지만 실제 화면에는 성공 목록 외의 상태도 존재한다.
이 상태를 명시적으로 표현할 수 있다.
function CouponListSection({
state,
}: CouponListSectionProps) {
switch (state.status) {
case "loading":
return <CouponListSkeleton />;
case "error":
return (
<CouponListError
onRetry={state.retry}
/>
);
case "success":
return state.coupons.length > 0
? (
<CouponList
coupons={state.coupons}
/>
)
: <EmptyCouponList />;
}
}
이 컴포넌트는 목록의 요청 상태에 따라 어떤 UI가 존재할 수 있는지 정의한다.
실패 화면과 빈 화면은 성공 화면에 임시로 붙이는 예외가 아니다.
사용자가 실제로 마주치는 서비스 상태다.
클릭 가능한 카드를 div로 만들 수 있다.
<div
onClick={onSelect}
>
{coupon.title}
</div>
마우스로는 동작하지만 키보드 사용자는 선택하기 어려울 수 있다.
상호작용의 의미에 맞는 요소를 사용할 수 있다.
<button
type="button"
onClick={onSelect}
aria-pressed={selected}
>
{coupon.title}
</button>
이 컴포넌트는 클릭뿐 아니라 키보드 조작과 선택 상태 전달에도 적합한 기본 동작을 가진다.
버튼의 포커스 처리, 레이블, 비활성 상태 같은 규칙을 재사용 컴포넌트 안에서 일관되게 관리하면 사용하는 화면마다 같은 문제를 다시 해결할 필요가 줄어든다.
컴포넌트의 책임은 화면을 그리는 데서 끝나지 않는다.
사용자가 그 UI를 어떤 방식으로 이해하고 조작할 수 있는지도 포함한다.
쿠폰 목록을 배열 인덱스로 렌더링할 수 있다.
coupons.map((coupon, index) => (
<CouponCard
key={index}
coupon={coupon}
/>
));
목록의 순서가 바뀌거나 항목이 삭제되면 같은 인덱스가 다른 쿠폰을 가리킬 수 있다.
컴포넌트 내부 상태가 잘못된 쿠폰에 연결될 수 있다.
coupons.map((coupon) => (
<CouponCard
key={coupon.id}
coupon={coupon}
/>
));
쿠폰 ID를 사용하면 화면 순서가 달라져도 컴포넌트의 정체성이 실제 쿠폰과 연결된다.
key는 렌더링 경고를 없애기 위한 값에 그치지 않는다.
목록이 변할 때 어떤 이전 컴포넌트가 어떤 다음 데이터에 대응하는지 판단하는 기준이다.
쿠폰 선택과 사용 흐름을 다음과 같이 구성할 수 있다.
flowchart TD
A[CouponPage: 서버 데이터와 사용 요청]
-->|coupons| B[CouponList: 목록과 선택 상태]
B -->|coupon, selected| C[CouponCard: 쿠폰 표시]
C -->|onSelect| B
C -->|onRedeem| A
A -->|갱신된 서버 상태| B
데이터는 책임을 가진 소유자에서 표시 컴포넌트로 내려간다.
사용자 행동은 의미 있는 이벤트로 위쪽에 전달된다.
이 흐름은 모든 상태를 반드시 최상위 컴포넌트에 두라는 뜻이 아니다.
각 상태와 행동을 책임질 수 있는 가장 가까운 경계를 선택해야 한다는 뜻이다.
컴포넌트를 설계하는 일은 결국 UI 안에서 소유권과 데이터 흐름을 설계하는 일이다.
<CouponTitle />
<CouponDescription />
<CouponDate />
독립된 책임이나 재사용 가능성이 없다면 파일과 이동 경로만 늘어날 수 있다.
변경 이유와 의미 있는 계약이 생길 때 경계를 만든다.
function CouponPage() {
// 데이터 조회
// 검색
// 선택
// 사용 요청
// 알림
// 전체 화면 렌더링
}
기능마다 다른 변경 이유가 한곳에 쌓인다.
서버 데이터 준비, 요청 상태 표현, 목록 렌더링과 개별 행동을 책임에 따라 나눈다.
<CouponCard
setSelectedId={setSelectedId}
/>
자식이 부모의 상태 구조를 알아야 한다.
<CouponCard
onSelect={handleSelect}
/>
자식은 사용자 행동을 전달하고 부모가 상태 변경 방식을 결정한다.
<CouponCard
admin
compact
editable
selectable
hideActions
/>
조합이 늘어나면서 존재해서는 안 되는 형태까지 허용될 수 있다.
역할이 명확한 변형, 조합 또는 별도 컴포넌트를 선택한다.
const [coupon, setCoupon] =
useState(query.data);
서버 원본과 로컬 사본이 서로 달라질 수 있다.
편집 초안처럼 별도의 의미가 있을 때만 복사하고 이름과 동기화 규칙을 명확히 한다.
return (
<CouponList
coupons={query.data}
/>
);
로딩, 오류와 빈 목록이 우연한 예외 처리로 남는다.
요청이 가질 수 있는 상태를 명시하고 각 상태의 UI를 설계한다.
key로 사용한다<CouponCard
key={index}
coupon={coupon}
/>
정렬과 삭제 후 컴포넌트 정체성이 다른 데이터에 연결될 수 있다.
목록 항목의 안정적인 ID를 사용한다.
컴포넌트는 화면을 여러 조각으로 나누는 방법이라고 배울 수 있다.
function CouponCard() {
return <article>쿠폰</article>;
}
입문 단계에서는 독립된 UI 조각을 만드는 개념으로 충분하다.
하지만 실제 서비스의 컴포넌트는 더 많은 설계 판단을 포함한다.
컴포넌트의 기본 흐름은 다음과 같이 볼 수 있다.
flowchart LR
A[외부 데이터와 상태]
--> B[컴포넌트 계약]
--> C[UI 표현]
--> D[사용자 행동]
--> E[의미 있는 이벤트]
--> F[상태 또는 서비스 변경]
--> A
컴포넌트는 데이터를 받아 화면을 만들고, 사용자 행동을 의미 있는 이벤트로 전달한다.
이 과정에서 상태와 규칙의 책임이 어느 경계에 있는지를 분명하게 해야 한다.
컴포넌트를 설계한다는 것은 결국 다음 질문에 답하는 일이다.
이 UI가 책임져야 하는 상태와 행동은 무엇이며, 외부에는 어떤 계약만 공개해야 이후의 변경을 안전하게 수용할 수 있는가?
컴포넌트는 화면을 나누는 조각이 아니다.
UI의 책임과 상태의 소유권을 정하고, 사용자 행동과 데이터 흐름을 연결하며, 변경과 재사용의 범위를 설계하는 경계다.