컴포넌트 설계: 에이전시 납품용 vs 자체 서비스용, 구조가 다르다
Article
"좋은 코드"는 상황에 따라 다르다
프론트엔드 커뮤니티에서 자주 듣는 말이 있습니다.
"컴포넌트는 재사용 가능하게 만들어야 한다." "공통 컴포넌트를 잘 추출해야 한다." "DRY 원칙을 지켜라."
맞는 말입니다. 자체 서비스를 운영할 때는.
그런데 에이전시 납품 프로젝트에서 같은 원칙을 적용하면? 오히려 유지보수가 어려워집니다. 저는 이걸 실제로 겪었습니다.
두 가지 맥락, 두 가지 기준
에이전시 납품용 프로젝트
- 만들고 인수인계합니다
- 유지보수는 다른 팀이 합니다
- 요구사항은 고정되어 있습니다 (스펙 기반)
- 프로젝트 간 코드를 공유하지 않습니다
자체 서비스용 프로젝트
- 만든 팀이 계속 운영합니다
- 요구사항이 계속 바뀝니다
- 비슷한 UI가 여러 곳에 반복됩니다
- 디자인 시스템이 존재하거나 필요합니다
이 차이를 무시하고 "좋은 코드"를 논하면 핵심을 놓칩니다.
같은 카드 컴포넌트, 다른 설계
에이전시 납품용: 독립성 우선
// components/ProductCard.tsx — 납품용
interface ProductCardProps {
title: string;
price: number;
imageUrl: string;
description: string;
}
export function ProductCard({ title, price, imageUrl, description }: ProductCardProps) {
return (
<div className="border rounded-lg p-4">
<img
src={imageUrl}
alt={title}
className="w-full h-48 object-cover rounded"
/>
<h3 className="mt-2 text-lg font-bold">{title}</h3>
<p className="text-gray-600 text-sm mt-1">{description}</p>
<p className="text-blue-600 font-bold mt-2">
{price.toLocaleString()}원
</p>
</div>
);
}
설계 원칙:
- 원시 타입(primitive) props만 받습니다. 복잡한 객체나 Context 의존 없음.
- 스타일이 컴포넌트 안에 포함되어 있습니다. 외부 디자인 시스템 의존 없음.
- 단일 파일로 완결됩니다. import가 최소화됨.
- 인수인계받는 사람이 이 파일 하나만 보면 전부 이해할 수 있습니다.
자체 서비스용: 확장성 우선
// components/ui/Card.tsx — 자체 서비스용
import { cn } from '@/lib/utils';
import { type VariantProps, cva } from 'class-variance-authority';
const cardVariants = cva('rounded-lg border', {
variants: {
size: {
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
},
variant: {
default: 'bg-white border-gray-200',
outlined: 'bg-transparent border-gray-300',
elevated: 'bg-white shadow-md border-transparent',
},
},
defaultVariants: {
size: 'md',
variant: 'default',
},
});
interface CardProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof cardVariants> {}
export function Card({ className, size, variant, ...props }: CardProps) {
return (
<div className={cn(cardVariants({ size, variant }), className)} {...props} />
);
}
// components/ProductCard.tsx — Card를 활용
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Price } from '@/components/ui/Price';
import { ProductImage } from '@/components/product/ProductImage';
interface ProductCardProps {
product: Product;
variant?: 'grid' | 'list';
onQuickView?: (id: string) => void;
}
export function ProductCard({ product, variant = 'grid', onQuickView }: ProductCardProps) {
return (
<Card variant="elevated" size="md">
<ProductImage src={product.imageUrl} alt={product.title} />
<div className="mt-2">
{product.isNew && <Badge>NEW</Badge>}
<h3 className="text-lg font-bold">{product.title}</h3>
<Price amount={product.price} currency="KRW" />
{onQuickView && (
<button onClick={() => onQuickView(product.id)}>
빠른 보기
</button>
)}
</div>
</Card>
);
}
설계 원칙:
- 공통 UI 컴포넌트(
Card,Badge,Price)를 추출합니다. 여러 곳에서 재사용. - variants로 다양한 상황에 대응합니다. 디자인 변경 시 한 곳만 수정.
- Product 객체를 통째로 받습니다. 내부 구조를 알고 있으므로 유연하게 활용.
- 콜백 props(
onQuickView)로 동작을 외부에서 주입합니다.
차이가 나는 구체적인 지점들
1. Import 깊이
// 납품용: 외부 의존성 최소화
import { ProductCard } from './ProductCard'; // 끝
// 서비스용: 디자인 시스템 레이어 활용
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { useProduct } from '@/hooks/useProduct';
import { formatPrice } from '@/lib/format';
납품용은 import 체인이 깊어지면 인수인계 비용이 기하급수적으로 올라갑니다. 파일 3개 이상 따라가야 하면 위험 신호입니다.
2. 상태 관리
// 납품용: 컴포넌트 로컬 상태로 충분
function SearchForm() {
const [query, setQuery] = useState('');
// ...
}
// 서비스용: 전역 상태 or Context 활용
function SearchForm() {
const { query, setQuery, filters } = useSearchContext();
// ...
}
납품용은 전역 상태를 쓰면 이해하기 어려워집니다. 서비스용은 여러 컴포넌트가 같은 상태를 공유해야 하므로 전역 상태가 합리적입니다.
3. 스타일 관리
// 납품용: Tailwind 직접 적용, 한 파일에서 완결
<div className="flex items-center gap-4 p-4 border rounded-lg">
// 서비스용: 디자인 토큰 + variants
<Card variant="outlined" size="lg" className="flex items-center gap-4">
4. 에러 처리
// 납품용: 간단하게
if (!data) return <div>데이터가 없습니다</div>;
// 서비스용: 에러 바운더리 + 재시도
<ErrorBoundary fallback={<ErrorCard onRetry={refetch} />}>
<Suspense fallback={<CardSkeleton />}>
<ProductCard />
</Suspense>
</ErrorBoundary>
판단 기준 정리
| 기준 | 납품용 | 서비스용 |
|---|---|---|
| 핵심 가치 | 독립성, 이해 용이성 | 재사용성, 일관성 |
| 컴포넌트 크기 | 큰 편 (자기 완결적) | 작은 편 (조합 가능) |
| Props | 원시 타입 위주 | 객체, 콜백 함수 활용 |
| 스타일 | 인라인/직접 적용 | 디자인 시스템 토큰 |
| 상태 관리 | 로컬 상태 | Context/전역 상태 |
| Import 깊이 | 얕게 (1~2단계) | 깊어도 OK (잘 문서화되었다면) |
| 추상화 수준 | 낮게 (명시적) | 높게 (DRY) |
실수했던 경험
에이전시 프로젝트에서 "제대로 해보자"라는 생각으로 디자인 시스템을 구축한 적이 있습니다. Button, Input, Card, Modal 전부 variants까지 만들었습니다.
결과? 납품 후 유지보수 팀에서 전부 걷어냈습니다.
그쪽 개발자에게 물어보니 이렇게 말하더군요.
"이거 버튼 하나 수정하려면 파일 4개를 열어야 해요. 그냥 직접 스타일 박는 게 빨라요."
그때 깨달았습니다. 좋은 코드는 절대적인 기준이 아닙니다. 그 코드를 누가, 어떤 맥락에서 유지보수하느냐에 따라 "좋은 코드"의 정의가 바뀝니다.
납품용은 한눈에 읽히는 코드가 좋은 코드입니다. 서비스용은 한 곳만 고치면 전부 바뀌는 코드가 좋은 코드입니다.
맥락을 무시한 "베스트 프랙티스"는 없습니다.