기술 포스트

컴포넌트 설계: 에이전시 납품용 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개를 열어야 해요. 그냥 직접 스타일 박는 게 빨라요."

그때 깨달았습니다. 좋은 코드는 절대적인 기준이 아닙니다. 그 코드를 누가, 어떤 맥락에서 유지보수하느냐에 따라 "좋은 코드"의 정의가 바뀝니다.

납품용은 한눈에 읽히는 코드가 좋은 코드입니다. 서비스용은 한 곳만 고치면 전부 바뀌는 코드가 좋은 코드입니다.

맥락을 무시한 "베스트 프랙티스"는 없습니다.