SOLID 원칙: 프론트엔드 개발자를 위한 가이드

Development

SOLID는 객체지향 프로그래밍의 5가지 기본 원칙으로, 로버트 마틴(Robert C. Martin)이 제안한 설계 원칙입니다. 백엔드 개발에서 주로 언급되지만, 프론트엔드 개발에서도 유지보수성과 확장성이 높은 코드를 작성하는 데 유용합니다.

SOLID 원칙을 왜 알아야 할까?

프론트엔드 애플리케이션은 갈수록 복잡해지고 있습니다. 단순한 UI 렌더링을 넘어 상태 관리, 비즈니스 로직, API 통신 등 다양한 책임을 담당하게 되었습니다. SOLID 원칙을 이해하고 적용하면 다음과 같은 이점이 있습니다:

  • 유지보수성 향상: 코드 변경이 다른 부분에 미치는 영향을 최소화
  • 테스트 용이성: 각 모듈이 독립적이어서 단위 테스트 작성이 쉬움
  • 재사용성 증가: 컴포넌트와 로직을 다른 프로젝트에서도 활용 가능
  • 협업 효율성: 명확한 책임 분리로 팀원 간 코드 이해도 향상

SOLID 원칙의 5가지 구성

SOLID는 다음 5가지 원칙의 앞글자를 따온 약어입니다:

  • S: Single Responsibility Principle (단일 책임 원칙)
  • O: Open/Closed Principle (개방-폐쇄 원칙)
  • L: Liskov Substitution Principle (리스코프 치환 원칙)
  • I: Interface Segregation Principle (인터페이스 분리 원칙)
  • D: Dependency Inversion Principle (의존성 역전 원칙)

이제 각 원칙을 React와 TypeScript 예제와 함께 살펴보겠습니다.


1. Single Responsibility Principle (단일 책임 원칙)

하나의 클래스(컴포넌트)는 하나의 책임만 가져야 한다.

개념

단일 책임 원칙은 하나의 모듈은 하나의 액터(변경을 요구하는 주체)에 대해서만 책임을 져야 한다는 원칙입니다. 쉽게 말해, 컴포넌트나 함수를 변경해야 하는 이유는 단 하나여야 합니다.

프론트엔드에서 자주 위반되는 경우:

  • 데이터 fetching과 UI 렌더링을 한 컴포넌트에서 처리
  • 비즈니스 로직과 상태 관리를 혼재
  • 여러 도메인의 로직을 하나의 훅에 모두 포함

이 원칙을 지키면:

  • 코드 변경 시 영향 범위가 명확해집니다
  • 단위 테스트 작성이 쉬워집니다
  • 코드 재사용성이 높아집니다
  • 여러 개발자가 동시에 작업할 때 충돌이 줄어듭니다

위반했을 때의 문제:

  • API 스펙이 변경되면 UI 컴포넌트까지 수정해야 함
  • 테스트 시 모든 의존성을 모킹해야 함
  • 코드 이해가 어렵고 예측 불가능한 사이드 이펙트 발생

❌ Bad Case: 너무 많은 책임을 가진 컴포넌트

function UserProfile() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);
 
  // 데이터 fetching
  useEffect(() => {
    setLoading(true);
    fetch('/api/user')
      .then((res) => res.json())
      .then((data) => setUser(data))
      .finally(() => setLoading(false));
  }, []);
 
  // 데이터 포맷팅
  const formatDate = (date: string) => {
    return new Date(date).toLocaleDateString('ko-KR');
  };
 
  // 비즈니스 로직
  const isVipUser = () => {
    return user?.points > 10000;
  };
 
  // UI 렌더링
  return (
    <div>
      {loading ? (
        <p>Loading...</p>
      ) : (
        <>
          <h1>{user?.name}</h1>
          <p>가입일: {formatDate(user?.joinedAt)}</p>
          {isVipUser() && <span>VIP</span>}
        </>
      )}
    </div>
  );
}

이 컴포넌트는 데이터 fetching, 포맷팅, 비즈니스 로직, UI 렌더링 모두를 담당하고 있어 변경 이유가 너무 많습니다.

✅ Good Case: 책임을 분리한 구조

// 데이터 fetching을 담당하는 커스텀 훅
function useUser() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);
 
  useEffect(() => {
    setLoading(true);
    fetch('/api/user')
      .then((res) => res.json())
      .then((data) => setUser(data))
      .finally(() => setLoading(false));
  }, []);
 
  return { user, loading };
}
 
// 날짜 포맷팅 유틸리티
function formatDate(date: string) {
  return new Date(date).toLocaleDateString('ko-KR');
}
 
// 비즈니스 로직
function isVipUser(points: number) {
  return points > 10000;
}
 
// UI 렌더링만 담당
function UserProfile() {
  const { user, loading } = useUser();
 
  if (loading) return <LoadingSpinner />;
  if (!user) return null;
 
  return (
    <div>
      <h1>{user.name}</h1>
      <p>가입일: {formatDate(user.joinedAt)}</p>
      {isVipUser(user.points) && <VipBadge />}
    </div>
  );
}

각 함수와 컴포넌트가 단일 책임을 가지게 되어 테스트와 유지보수가 용이해집니다.


2. Open/Closed Principle (개방-폐쇄 원칙)

확장에는 열려있고, 수정에는 닫혀있어야 한다.

개념

개방-폐쇄 원칙은 소프트웨어 엔티티는 확장에는 열려 있어야 하지만, 변경에는 닫혀 있어야 한다는 원칙입니다. 즉, 새로운 기능을 추가할 때 기존 코드를 수정하지 않고도 확장할 수 있어야 합니다.

프론트엔드에서의 적용:

  • 컴포넌트에 새로운 variant나 옵션 추가
  • 폼 검증 규칙 추가
  • 새로운 데이터 소스나 API 엔드포인트 추가
  • 플러그인이나 미들웨어 시스템 설계

이 원칙을 지키면:

  • 기존 코드의 버그 발생 위험 없이 기능 확장 가능
  • 레거시 코드를 건드리지 않고 새 기능 추가
  • A/B 테스트나 기능 플래그 구현이 용이
  • 여러 팀이 독립적으로 기능 개발 가능

위반했을 때의 문제:

  • 새 기능 추가 시마다 기존 코드 수정으로 인한 리그레션 위험
  • if-else 분기가 끝없이 늘어나 복잡도 증가
  • 기존 동작을 보장하기 위한 회귀 테스트 부담 증가

❌ Bad Case: 새 기능 추가 시 기존 코드 수정 필요

function Button({ type, onClick, children }) {
  const getButtonStyle = () => {
    if (type === 'primary') {
      return 'bg-blue-500 text-white';
    } else if (type === 'secondary') {
      return 'bg-gray-500 text-white';
    } else if (type === 'danger') {
      return 'bg-red-500 text-white';
    }
    // 새로운 타입을 추가할 때마다 이 함수를 수정해야 함
  };
 
  return (
    <button className={getButtonStyle()} onClick={onClick}>
      {children}
    </button>
  );
}

✅ Good Case: 확장 가능한 구조

// 버튼 스타일을 외부에서 정의
const buttonStyles = {
  primary: 'bg-blue-500 text-white hover:bg-blue-600',
  secondary: 'bg-gray-500 text-white hover:bg-gray-600',
  danger: 'bg-red-500 text-white hover:bg-red-600',
  success: 'bg-green-500 text-white hover:bg-green-600', // 쉽게 추가 가능
} as const;
 
type ButtonVariant = keyof typeof buttonStyles;
 
interface ButtonProps {
  variant?: ButtonVariant;
  className?: string;
  onClick?: () => void;
  children: React.ReactNode;
}
 
function Button({ variant = 'primary', className = '', onClick, children }: ButtonProps) {
  const baseStyle = 'px-4 py-2 rounded font-medium transition-colors';
  const variantStyle = buttonStyles[variant];
 
  return (
    <button className={`${baseStyle} ${variantStyle} ${className}`} onClick={onClick}>
      {children}
    </button>
  );
}
 
// 사용 시 커스텀 스타일도 추가 가능
<Button variant="primary" className="shadow-lg">
  클릭하세요
</Button>;

이제 새로운 스타일을 추가할 때 buttonStyles 객체만 수정하면 되고, 컴포넌트 로직은 변경하지 않아도 됩니다.


3. Liskov Substitution Principle (리스코프 치환 원칙)

상위 타입의 객체를 하위 타입의 객체로 치환해도 프로그램이 정상 동작해야 한다.

개념

리스코프 치환 원칙은 하위 타입은 상위 타입을 대체할 수 있어야 한다는 원칙입니다. 프론트엔드에서는 같은 인터페이스(props)를 구현한 컴포넌트는 서로 교체 가능해야 하며, 부모의 기대를 모두 충족해야 합니다.

TypeScript를 사용하면 타입 시스템이 이를 어느 정도 보장해주지만, 런타임 동작까지 보장하는 것은 아닙니다. 예를 들어 onChange 콜백을 특정 조건에서만 호출한다면 타입은 맞지만 계약은 위반한 것입니다.

이 원칙을 지키면:

  • 컴포넌트를 자유롭게 교체하거나 확장 가능
  • 다형성을 통한 유연한 설계 가능
  • 예측 가능하고 안정적인 동작 보장
  • 디자인 시스템 구축 시 일관성 유지

위반했을 때의 문제:

  • 컴포넌트 교체 시 예상치 못한 버그 발생
  • props는 같은데 동작이 달라 혼란 야기
  • 사용자가 컴포넌트 내부 구현을 알아야만 사용 가능
  • 테스트 시 각 구현체마다 다른 시나리오 필요

❌ Bad Case: 부모의 계약을 위반하는 자식

interface InputProps {
  value: string;
  onChange: (value: string) => void;
  placeholder?: string;
}
 
function TextInput({ value, onChange, placeholder }: InputProps) {
  return (
    <input
      type="text"
      value={value}
      onChange={(e) => onChange(e.target.value)}
      placeholder={placeholder}
    />
  );
}
 
// 문제: onChange의 계약을 위반
function NumberInput({ value, onChange, placeholder }: InputProps) {
  return (
    <input
      type="number"
      value={value}
      onChange={(e) => {
        // 숫자만 허용하는 검증을 여기서 하면서 onChange를 호출하지 않을 수 있음
        if (isNaN(Number(e.target.value))) {
          return; // onChange가 호출되지 않아 부모의 기대를 어김
        }
        onChange(e.target.value);
      }}
      placeholder={placeholder}
    />
  );
}

✅ Good Case: 계약을 준수하는 구조

interface BaseInputProps {
  value: string;
  onChange: (value: string) => void;
  placeholder?: string;
}
 
function TextInput({ value, onChange, placeholder }: BaseInputProps) {
  return (
    <input
      type="text"
      value={value}
      onChange={(e) => onChange(e.target.value)}
      placeholder={placeholder}
    />
  );
}
 
interface NumberInputProps extends BaseInputProps {
  onInvalidInput?: (value: string) => void;
}
 
function NumberInput({ value, onChange, placeholder, onInvalidInput }: NumberInputProps) {
  const handleChange = (newValue: string) => {
    // 항상 onChange를 호출하여 계약을 지킴
    onChange(newValue);
 
    // 추가적인 검증 피드백은 별도 콜백으로 처리
    if (isNaN(Number(newValue)) && onInvalidInput) {
      onInvalidInput(newValue);
    }
  };
 
  return (
    <input
      type="number"
      value={value}
      onChange={(e) => handleChange(e.target.value)}
      placeholder={placeholder}
    />
  );
}

이제 TextInputNumberInput 모두 BaseInputProps를 준수하며, 어느 것을 사용하더라도 onChange가 항상 호출됩니다.


4. Interface Segregation Principle (인터페이스 분리 원칙)

클라이언트는 자신이 사용하지 않는 인터페이스에 의존하지 않아야 한다.

개념

인터페이스 분리 원칙은 클라이언트는 자신이 사용하지 않는 메서드(props)에 의존하면 안 된다는 원칙입니다. 범용 인터페이스 하나보다 구체적인 인터페이스 여러 개가 낫습니다.

프론트엔드에서는 "무거운 props 객체"를 그대로 전달하는 대신, 각 컴포넌트가 실제로 필요한 데이터만 받도록 설계해야 합니다. 이는 특히 성능 최적화(React.memo)와 관련이 깊습니다.

이 원칙을 지키면:

  • 컴포넌트의 의존성이 명확해져 이해하기 쉬움
  • 불필요한 리렌더링 방지 (React.memo 최적화)
  • props가 작아져 타입 체크가 빠르고 정확해짐
  • 컴포넌트 재사용성이 높아짐

위반했을 때의 문제:

  • 사용하지 않는 props가 변경되어도 리렌더링 발생
  • 컴포넌트가 너무 많은 데이터에 의존하여 결합도 증가
  • props 변경 시 영향 범위를 파악하기 어려움
  • 테스트 시 사용하지 않는 데이터까지 모킹해야 함

❌ Bad Case: 비대한 props 인터페이스

interface UserCardProps {
  id: number;
  name: string;
  email: string;
  phone: string;
  address: string;
  age: number;
  avatar: string;
  bio: string;
  role: string;
  createdAt: string;
  updatedAt: string;
  // 모든 사용자 정보를 다 받음
}
 
// 이름만 필요한데 모든 props를 받아야 함
function UserName(props: UserCardProps) {
  return <h2>{props.name}</h2>;
}
 
// 연락처만 필요한데 모든 props를 받아야 함
function UserContact(props: UserCardProps) {
  return (
    <div>
      <p>{props.email}</p>
      <p>{props.phone}</p>
    </div>
  );
}

✅ Good Case: 필요한 props만 받는 작은 인터페이스

// 각 컴포넌트에 필요한 props만 정의
interface UserNameProps {
  name: string;
}
 
interface UserContactProps {
  email: string;
  phone: string;
}
 
interface UserAvatarProps {
  avatar: string;
  name: string; // alt text용
}
 
function UserName({ name }: UserNameProps) {
  return <h2>{name}</h2>;
}
 
function UserContact({ email, phone }: UserContactProps) {
  return (
    <div>
      <p>{email}</p>
      <p>{phone}</p>
    </div>
  );
}
 
function UserAvatar({ avatar, name }: UserAvatarProps) {
  return <img src={avatar} alt={`${name}의 프로필`} />;
}
 
// 전체 데이터를 가진 상위 컴포넌트에서 필요한 것만 전달
interface User {
  id: number;
  name: string;
  email: string;
  phone: string;
  avatar: string;
  // ... 기타 필드
}
 
function UserCard({ user }: { user: User }) {
  return (
    <div>
      <UserAvatar avatar={user.avatar} name={user.name} />
      <UserName name={user.name} />
      <UserContact email={user.email} phone={user.phone} />
    </div>
  );
}

각 컴포넌트가 자신에게 필요한 props만 받아 의존성이 명확해지고 재사용성이 높아집니다.


5. Dependency Inversion Principle (의존성 역전 원칙)

구체적인 것이 아닌 추상적인 것에 의존해야 한다.

개념

의존성 역전 원칙은 고수준 모듈은 저수준 모듈에 의존하면 안 되며, 둘 다 추상화에 의존해야 한다는 원칙입니다. 구체적인 구현(axios, fetch, localStorage)이 아닌 인터페이스나 추상화에 의존함으로써 구현체를 쉽게 교체할 수 있게 합니다.

프론트엔드에서는:

  • API 클라이언트를 직접 호출하지 않고 Repository 패턴 사용
  • 브라우저 API(localStorage 등)를 직접 사용하지 않고 추상화
  • Context API나 의존성 주입을 통해 구현체 교체 가능하게 설계

이 원칙을 지키면:

  • 테스트 시 Mock 객체로 쉽게 대체 가능
  • API 변경이나 라이브러리 교체가 용이
  • 다양한 환경(개발/스테이징/프로덕션)에 대응 가능
  • 비즈니스 로직이 인프라 변경에 영향받지 않음

위반했을 때의 문제:

  • 단위 테스트 작성이 매우 어려움 (실제 API 호출 필요)
  • 라이브러리 교체 시 모든 사용처를 수정해야 함
  • 로컬 개발 환경 구축이 복잡해짐
  • 비즈니스 로직과 인프라가 강하게 결합됨

❌ Bad Case: 구체적인 구현에 직접 의존

// 구체적인 API 클라이언트에 직접 의존
function UserList() {
  const [users, setUsers] = useState([]);
 
  useEffect(() => {
    // axios에 직접 의존
    axios
      .get('https://api.example.com/users')
      .then((response) => setUsers(response.data))
      .catch((error) => console.error(error));
  }, []);
 
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

이 경우 axios를 fetch로 바꾸거나, API URL을 변경하려면 컴포넌트를 직접 수정해야 합니다.

✅ Good Case: 추상화에 의존

// 1. 추상화된 인터페이스 정의
interface UserRepository {
  getUsers(): Promise<User[]>;
  getUserById(id: number): Promise<User>;
}
 
// 2. 구체적인 구현
class ApiUserRepository implements UserRepository {
  constructor(private baseUrl: string) {}
 
  async getUsers(): Promise<User[]> {
    const response = await fetch(`${this.baseUrl}/users`);
    return response.json();
  }
 
  async getUserById(id: number): Promise<User> {
    const response = await fetch(`${this.baseUrl}/users/${id}`);
    return response.json();
  }
}
 
// 3. 테스트용 Mock 구현
class MockUserRepository implements UserRepository {
  async getUsers(): Promise<User[]> {
    return [{ id: 1, name: '테스트 유저', email: 'test@test.com' }];
  }
 
  async getUserById(id: number): Promise<User> {
    return { id, name: '테스트 유저', email: 'test@test.com' };
  }
}
 
// 4. Context로 의존성 주입
const UserRepositoryContext = createContext<UserRepository | null>(null);
 
function useUserRepository() {
  const repository = useContext(UserRepositoryContext);
  if (!repository) {
    throw new Error('UserRepository not provided');
  }
  return repository;
}
 
// 5. 컴포넌트는 추상화에만 의존
function UserList() {
  const repository = useUserRepository();
  const [users, setUsers] = useState<User[]>([]);
 
  useEffect(() => {
    repository.getUsers().then(setUsers);
  }, [repository]);
 
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}
 
// 6. 앱 최상단에서 구현체 주입
function App() {
  const userRepository = new ApiUserRepository('https://api.example.com');
 
  return (
    <UserRepositoryContext.Provider value={userRepository}>
      <UserList />
    </UserRepositoryContext.Provider>
  );
}

이제 컴포넌트는 UserRepository 인터페이스에만 의존하므로, 구현체를 쉽게 교체할 수 있습니다. 테스트할 때는 MockUserRepository를 주입하면 됩니다.


실무에서의 SOLID 적용 팁

1. 처음부터 완벽하게 하려 하지 마세요

SOLID 원칙을 모든 곳에 적용하려고 하면 오히려 과도한 추상화로 코드가 복잡해질 수 있습니다. 먼저 동작하는 코드를 작성하고, 리팩토링 과정에서 점진적으로 개선하세요.

2. 컴포넌트 크기를 주의하세요

컴포넌트가 200줄을 넘어간다면 SRP 위반을 의심해보세요. 여러 책임을 가지고 있을 가능성이 높습니다.

3. 커스텀 훅을 활용하세요

React의 커스텀 훅은 로직을 분리하고 재사용하는 훌륭한 방법입니다. 데이터 fetching, 폼 관리, 애니메이션 등을 커스텀 훅으로 추출하세요.

4. 타입스크립트를 적극 활용하세요

인터페이스와 타입을 통해 추상화를 명확하게 표현할 수 있습니다. LSP와 ISP를 지키는 데 큰 도움이 됩니다.

5. 테스트 가능성을 기준으로 판단하세요

컴포넌트나 함수를 테스트하기 어렵다면, SOLID 원칙을 위반하고 있을 가능성이 높습니다.


SOLID 원칙을 잘 적용한 라이브러리 예시

실제 오픈소스 라이브러리들이 SOLID 원칙을 어떻게 적용했는지 살펴보면 더 구체적인 인사이트를 얻을 수 있습니다.

1. TanStack Query (React Query) - 의존성 역전 원칙

React Query는 데이터 fetching 로직을 추상화하여 컴포넌트가 구체적인 API 구현에 의존하지 않도록 설계되었습니다.

// 컴포넌트는 useQuery라는 추상화에만 의존
function UserProfile({ userId }: { userId: number }) {
  const { data, isLoading } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId), // 구체적인 fetching 로직은 분리
  });
 
  if (isLoading) return <div>Loading...</div>;
  return <div>{data?.name}</div>;
}
 
// API 클라이언트를 변경해도 컴포넌트는 영향받지 않음
async function fetchUser(id: number) {
  // axios, fetch, graphql 등 어떤 방식이든 가능
  return fetch(`/api/users/${id}`).then((r) => r.json());
}

쿼리 함수를 교체하거나 캐싱 전략을 변경해도 컴포넌트 코드는 수정할 필요가 없습니다.

2. Radix UI / Headless UI - 인터페이스 분리 원칙

Radix UI는 접근성, 상태 관리, 스타일링을 완전히 분리하여 필요한 것만 사용할 수 있도록 설계되었습니다.

import * as Dialog from '@radix-ui/react-dialog';
 
// 각 컴포넌트가 독립적인 props를 가짐
function MyDialog() {
  return (
    <Dialog.Root>
      <Dialog.Trigger asChild>
        <button>Open</button>
      </Dialog.Trigger>
 
      <Dialog.Portal>
        <Dialog.Overlay className="overlay" />
        <Dialog.Content className="content">
          <Dialog.Title>제목</Dialog.Title>
          <Dialog.Description>설명</Dialog.Description>
          <Dialog.Close asChild>
            <button>Close</button>
          </Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

각 하위 컴포넌트는 자신에게 필요한 props만 받으며, 사용하지 않는 기능을 위한 props를 받지 않습니다. 예를 들어 Dialog.Title은 접근성 관련 props만 받고, 스타일링은 완전히 사용자에게 맡깁니다.

3. React Hook Form - 단일 책임 원칙

React Hook Form은 폼 상태 관리, 유효성 검증, 제출 처리를 각각 분리된 훅과 컴포넌트로 제공합니다.

import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
 
function SignupForm() {
  // 폼 상태 관리만 담당
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm({
    resolver: zodResolver(signupSchema), // 유효성 검증은 별도 라이브러리에 위임
  });
 
  // 제출 로직은 별도 함수로 분리
  const onSubmit = async (data) => {
    await signupApi(data);
  };
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}
 
      <button type="submit">가입하기</button>
    </form>
  );
}

폼 관리, 유효성 검증, API 통신이 각각 분리되어 있어 테스트와 유지보수가 쉽습니다.

4. Zustand - 개방-폐쇄 원칙

Zustand는 미들웨어 패턴을 통해 기존 스토어 코드를 수정하지 않고도 기능을 확장할 수 있습니다.

import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
 
// 기본 스토어 정의
const useStore = create(
  // 미들웨어로 기능 확장 (기존 코드는 수정 안 함)
  devtools(
    persist(
      (set) => ({
        count: 0,
        increase: () => set((state) => ({ count: state.count + 1 })),
      }),
      { name: 'counter-storage' }
    )
  )
);

devtoolspersist 같은 미들웨어를 추가/제거해도 핵심 스토어 로직은 변경되지 않습니다.

5. Axios - 개방-폐쇄 원칙

Axios의 인터셉터는 HTTP 요청/응답 처리 로직을 확장하는 좋은 예시입니다.

import axios from 'axios';
 
const apiClient = axios.create({
  baseURL: 'https://api.example.com',
});
 
// 기존 axios 코드는 수정하지 않고 인증 로직 추가
apiClient.interceptors.request.use((config) => {
  const token = getAccessToken();
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});
 
// 에러 처리 로직도 확장
apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status === 401) {
      await refreshToken();
      return apiClient.request(error.config);
    }
    return Promise.reject(error);
  }
);

인터셉터를 통해 인증, 로깅, 에러 처리 등을 추가해도 기존 API 호출 코드는 수정할 필요가 없습니다.


결론

SOLID 원칙은 객체지향 프로그래밍의 고전적인 원칙이지만, 현대 프론트엔드 개발에도 여전히 유효합니다. React 컴포넌트를 설계할 때 이 원칙들을 염두에 두면:

  • 유지보수가 쉬운 코드베이스를 만들 수 있습니다
  • 테스트하기 쉬운 구조를 갖출 수 있습니다
  • 팀원들과 협업하기 좋은 명확한 설계를 할 수 있습니다
  • 변화에 유연한 애플리케이션을 구축할 수 있습니다

하지만 과도한 추상화는 오히려 독이 될 수 있습니다. 프로젝트의 규모와 팀의 상황에 맞게 적절히 적용하는 것이 중요합니다. 처음에는 간단하게 시작하고, 코드가 복잡해지거나 중복이 발생할 때 리팩토링하면서 점진적으로 개선해나가는 것을 추천합니다.

SOLID 원칙을 이해하고 실천하는 개발자는 단순히 동작하는 코드를 넘어, 오래 유지되고 진화할 수 있는 코드를 작성할 수 있습니다.