프론트엔드 SOLID — 선언형 UI에 맞춘 실천과 한계

프론트엔드 SOLID — 선언형 UI에 맞춘 실천과 한계

컴포넌트 구조를 설계할 때마다 같은 고민이 반복됐다. 어떻게 하면 더 확장 가능하게 만들 수 있을까? 관심사 분리는 어디까지 해야 할까? 추상화는 언제 하는 게 적절할까? 유지보수성을 높이려면 무엇을 바꿔야 할까?

이런 고민을 하다 보니 자연스럽게 OOP의 SOLID 원칙에 관심을 가지게 됐다. 하지만 막상 프론트엔드에 적용하려니 완전히 들어맞지 않았다.

  • 단일 책임? 컴포넌트는 렌더링·상태·효과가 한 곳에 섞여야 작동한다.
  • 개방·폐쇄? Props 조합이 폭발하면 오히려 복잡도가 올라간다.
  • 의존성 역전? DI 컨테이너는 번들 크기만 키운다.

SOLID는 분명 좋은 원칙인데, 프론트엔드에서는 맥락이 달랐다. 그래서 프론트엔드에 맞는 접근을 다시 정리해보았다.

프론트엔드에서 SOLID는 '객체 설계'가 아니라, 변화 비용을 최소화하기 위한 UI 경계의 기술이다.


1. 왜 이게 문제가 되는가?

프론트엔드 개발에서 실제로 겪는 문제들

문제 1: 컴포넌트가 300줄을 넘어가고 테스트하기 어렵다

function ProductList() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(false);
 
  useEffect(() => {
    setLoading(true);
    Promise.all([
      fetch("/api/products").then((r) => r.json()),
      fetch("/api/inventory").then((r) => r.json()),
    ]).then(([products, inventory]) => {
      const available = products.filter((p) => inventory[p.id]?.stock > 0);
      setProducts(available);
      setLoading(false);
    });
  }, []);
 
  return loading ? <Spinner /> : <List items={products} />;
}

왜 문제인가?

  • 데이터 fetching, 변환, 렌더링이 모두 섞여 있어서 테스트하려면 실제 API 호출 필요
  • 재사용 불가능
  • 변경 범위 예측 불가

문제 2: 새 필터 추가할 때마다 컴포넌트를 수정해야 한다

function UserList({ users, filterType }) {
  let filtered = users;
  if (filterType === "active") {
    filtered = users.filter((u) => u.isActive);
  } else if (filterType === "admin") {
    filtered = users.filter((u) => u.role === "admin");
  } else if (filterType === "premium") {
    filtered = users.filter((u) => u.isPremium);
  }
  // 새 필터 타입 추가할 때마다 여기를 수정...
  return <List items={filtered} />;
}

왜 문제인가?

  • 필터 조건이 추가될 때마다 UserList 컴포넌트를 열어서 if문 추가
  • 확장성 없음 (조합 필터는? "active이면서 premium"은?)
  • 테스트 케이스가 filterType 개수만큼 늘어남

문제 3: div로 만든 버튼이 키보드로 작동하지 않는다

function FakeButton({ onClick, children }) {
  return (
    <div onClick={onClick} className="button">
      {children}
    </div>
  );
}

왜 문제인가?

  • Enter/Space 키 동작 안 함
  • Tab으로 포커스 안 됨
  • 스크린 리더가 버튼으로 인식 못함
  • 진짜 버튼으로 교체하면 스타일 깨짐

문제 4: 거대한 User 객체 때문에 불필요한 리렌더가 발생한다

function UserCard({ user }: { user: User }) {
  // name, avatar만 쓰는데 User 전체(20개 필드)를 받음
  return (
    <div>
      <img src={user.avatar} />
      <h3>{user.name}</h3>
    </div>
  );
}
 
// 사용
<UserCard user={user} />;
// user.email이 바뀌어도 UserCard가 리렌더됨

왜 문제인가?

  • 필요 없는 데이터 변경에도 리렌더링
  • UserCard를 다른 곳에 쓰려면 User 객체 전체를 만들어야 함
  • 테스트 시 거대한 mock User 객체 필요

문제 5: localStorage가 SSR에서 에러를 낸다

function saveUserPreference(key: string, value: string) {
  localStorage.setItem(key, value); // SSR: "localStorage is not defined"
}

왜 문제인가?

  • Next.js SSR에서 localStorage is not defined 에러로 빌드 실패
  • 테스트 환경에서 localStorage mock 설정 필요
  • 환경 분기 (typeof window !== "undefined")를 모든 곳에 추가하면 코드 지저분

2. 문제를 하나씩 해결하기

문제 1 해결: S (단일 책임) — 데이터 로직을 훅으로 분리

단일 책임은 파일을 쪼개는 게 아니라, 변경 이유가 같은 것만 모으는 경계 설정이다.

// Good: 데이터 로직을 훅으로 분리
function useAvailableProducts() {
  const { data: products } = useQuery({
    queryKey: ["products"],
    queryFn: () => fetch("/api/products").then((r) => r.json()),
  });
 
  const { data: inventory } = useQuery({
    queryKey: ["inventory"],
    queryFn: () => fetch("/api/inventory").then((r) => r.json()),
  });
 
  return useMemo(() => {
    if (!products || !inventory) return [];
    return products.filter((p) => inventory[p.id]?.stock > 0);
  }, [products, inventory]);
}
 
function ProductList() {
  const products = useAvailableProducts();
  return products.length === 0 ? <Empty /> : <List items={products} />;
}

해결된 것:

  • 데이터 로직 테스트는 useAvailableProducts 훅만 테스트하면 됨
  • 다른 컴포넌트에서도 useAvailableProducts 재사용 가능
  • 재고 로직 변경 시 훅만 수정, UI는 안전

언제 적용하나:

  • 여러 API 조합, 데이터 변환, 복잡한 상태 관리
  • 로직이 10줄 이상이거나 2곳 이상에서 재사용

언제 과한가:

  • 단순히 useQuery 한 번 감싸는 것
  • 로직이 5줄 이하고 재사용 안 됨

문제 2 해결: O (개방·폐쇄) — 필터 함수를 주입받기

OCP는 확장을 위한 추상화가 아니라, 자주 바뀌는 부분을 밖으로 밀어내고 안 바뀌는 축을 고정하는 전략이다.

// Good: 필터 함수를 주입받아 확장에 열림
function UserList({
  users,
  filterFn = (users) => users
}: {
  users: User[];
  filterFn?: (users: User[]) => User[];
}) {
  return <List items={filterFn(users)} />;
}
 
// 사용: 새 필터 추가 시 UserList 수정 불필요
<UserList users={users} filterFn={(u) => u.filter((x) => x.isActive)} />
<UserList users={users} filterFn={(u) => u.filter((x) => x.role === "admin")} />
<UserList
  users={users}
  filterFn={(u) => u.filter((x) => x.isActive && x.isPremium)}
/>

해결된 것:

  • 새 필터 추가 시 UserList 컴포넌트 수정 불필요
  • 조합 필터도 자유롭게 구성 가능
  • UserList는 "리스트 렌더링"에만 집중

언제 적용하나:

  • 필터·정렬 같은 정책이 자주 바뀜
  • 변형이 4개 이상이거나 조합 가능해야 함
  • 화면 조각은 children으로, 스타일은 토큰으로

언제 과한가:

  • 변형이 2~3개 이하면 그냥 union type (variant: "active" | "admin")으로 충분
  • 모든 분기를 주입으로 바꾸면 Prop 폭발

문제 3 해결: L (리스코프 치환) — Polymorphic으로 시맨틱 유지

대체 가능성은 타입이 아니라, 시맨틱·접근성·키보드 행동이라는 사용성 계약을 지켜야 성립한다.

// Good: Polymorphic 컴포넌트로 시맨틱 유지
type PolymorphicProps<E extends React.ElementType> = {
  as?: E;
  children: React.ReactNode;
} & Omit<React.ComponentPropsWithoutRef<E>, "as" | "children">;
 
function Action<E extends React.ElementType = "button">({
  as,
  children,
  ...props
}: PolymorphicProps<E>) {
  const Component = as || "button";
  return <Component {...props}>{children}</Component>;
}
 
// 사용: button일 때는 키보드 동작, a일 때는 링크 동작 모두 유지
<Action>클릭</Action>
<Action as="a" href="/home">홈으로</Action>

해결된 것:

  • **as="button"**일 때: Enter/Space 키 동작, 포커스, ARIA role="button" 자동
  • **as="a"**일 때: href 동작, Enter 키 네비게이션, role="link" 자동
  • 시맨틱 HTML을 유지하면서 스타일 일관성 확보

언제 적용하나:

  • 디자인 시스템 기본 컴포넌트 (Button, Link, Heading ...)
  • 접근성 요구사항이 있는 프로젝트
  • 버튼/링크 교체가 자주 일어남

언제 과한가:

  • 모든 컴포넌트에 Polymorphic 적용
  • 접근성 요구사항 없는 내부 툴

문제 4 해결: I (인터페이스 분리) — 필요한 최소 props만 받기

ISP는 타입 쪼개기가 아니라, UI가 실제로 필요한 만큼만 데이터 의존성을 줄이는 절제의 기술이다.

// Good: 필요한 최소 props만 받음
function UserCard({ name, avatar }: { name: string; avatar: string }) {
  return (
    <div>
      <img src={avatar} />
      <h3>{name}</h3>
    </div>
  );
}
 
// 사용
<UserCard name={user.name} avatar={user.avatar} />;
// user.email이 바뀌어도 UserCard는 리렌더 안 됨

해결된 것:

  • 불필요한 리렌더 방지

  • 테스트 시 간단한 props만 전달 ({ name: "test", avatar: "url" })

  • 재사용성 향상 언제 적용하나:

  • 거대한 객체(User, Product)를 받는 컴포넌트

  • 성능 최적화 필요

  • 훅도 "데이터 로딩"과 "UI 상태"를 분리

언제 과한가:

  • props가 3~4개 이하면 굳이 안 나눔
  • 실제로 재사용되지 않거나 성능 문제 없으면 조기 최적화

문제 5 해결: D (의존성 역전) — Storage 인터페이스로 추상화

프론트의 DIP는 추상화의 철학이 아니라, 브라우저·서버·테스트 환경 차이를 숨기는 실용적 안전장치다.

// Good: 인터페이스로 추상화
interface Storage {
  get(key: string): string | null;
  set(key: string, value: string): void;
}
 
export const browserStorage: Storage = {
  get: (key) => localStorage.getItem(key),
  set: (key, value) => localStorage.setItem(key, value),
};
 
export const memoryStorage: Storage = (() => {
  const store = new Map<string, string>();
  return {
    get: (key) => store.get(key) ?? null,
    set: (key, value) => store.set(key, value),
  };
})();
 
const storage = typeof window !== "undefined" ? browserStorage : memoryStorage;
 
function saveUserPreference(key: string, value: string, storage: Storage) {
  storage.set(key, value); // SSR에서도 안전
}

해결된 것:

  • SSR에서 에러 없음
  • 테스트 시 memoryStorage mock으로 간단히 대체
  • 환경 분기 코드가 한 곳에만 집중

언제 적용하나:

  • localStorage, sessionStorage 같은 플랫폼 API
  • fetch, Logger 같은 환경 의존적 효과
  • SSR/테스트 환경 지원 필요

언제 과한가:

  • 모든 곳에 DI 컨테이너 도입 (번들 크기 증가, 복잡도 상승)
  • Storage, Logger 외에는 환경 분기(typeof window)로 충분

과하게 하지 말 것

  1. 모든 컴포넌트에 DI 적용 → Storage, Logger만
  2. useQuery를 한 번만 감싸는 훅 → 로직 10줄 이상일 때만
  3. 3개 이하 props를 무리하게 쪼개기 → 실제 재사용될 때만

SOLID는 완벽한 설계 이론이 아니라, 변화를 가장 싸고 안전하게 흡수하기 위한 UI 전략이다.