면접에서 SOLID 원칙을 못 대답한 뒤에 다시 정리한 글
오래전에 알고 있었던 개념이지만, 막상 면접에서 질문을 받으니 입이 안 떨어졌다.
"알고 있다"와 "설명할 수 있다"는 완전히 다른 레벨이라는 걸 체감한 뒤, 다시 정리한 기록.
SOLID란
SOLID는 객체지향 설계에서 지켜야 할 5가지 원칙의 앞글자를 따서 만든 이름이다.
- S — Single Responsibility Principle (단일 책임 원칙)
- O — Open-Closed Principle (개방-폐쇄 원칙)
- L — Liskov Substitution Principle (리스코프 치환 원칙)
- I — Interface Segregation Principle (인터페이스 분리 원칙)
- D — Dependency Inversion Principle (의존 역전 원칙)
이 원칙들의 공통 목표는 하나다. 변경에 유연하고 확장하기 쉬운 코드 구조를 만드는 것. 새로운 요구사항이 들어왔을 때 영향 범위가 작고, 기존 코드를 건드리지 않고도 기능을 추가할 수 있는 설계를 지향한다.
SOLID는 특정 언어나 프레임워크에 종속되지 않는다. Java에서 나온 개념이지만 TypeScript, Python, Go 어디서든 적용할 수 있다. 다만 프론트엔드에서는 클래스보다 함수와 컴포넌트를 더 많이 쓰기 때문에, 원칙의 "정신"을 컴포넌트 설계에 맞게 해석하는 것이 중요하다.
5가지 원칙은 서로 독립된 개별 개념이 아니라, 서로 연결되어 있다. SRP를 지키다 보면 자연스럽게 ISP를 지키게 되고, DIP를 적용하면 OCP가 따라온다. 이 점을 염두에 두고 읽으면 이해가 훨씬 쉬워진다.
SRP — 단일 책임 원칙
하나의 모듈(클래스, 함수, 컴포넌트)은 하나의 책임만 가져야 한다.
"책임"이란 "변경의 이유"로 바꿔 읽으면 더 명확하다. 하나의 모듈을 수정해야 하는 이유가 두 가지 이상이라면, 그 모듈은 책임이 과하다.
위반 사례
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setUser);
}, [userId]);
if (!user) return <Skeleton />;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<p>{user.createdAt.toLocaleDateString('ko-KR')}</p>
</div>
);
}
이 컴포넌트는 데이터 페칭, 로딩 상태 처리, 날짜 포맷팅, UI 렌더링을 전부 하고 있다. API 응답 구조가 바뀌어도, 날짜 표시 형식이 바뀌어도, UI 디자인이 바뀌어도 이 컴포넌트를 수정해야 한다.
개선
// 데이터 페칭 책임
function useUser(userId: string) {
return useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId) });
}
// 포맷팅 책임
function formatDate(date: Date): string {
return date.toLocaleDateString('ko-KR');
}
// UI 렌더링 책임
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading } = useUser(userId);
if (isLoading) return <Skeleton />;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<p>{formatDate(user.createdAt)}</p>
</div>
);
}
API가 바뀌면 useUser만, 날짜 형식이 바뀌면 formatDate만, UI가 바뀌면 UserProfile만 수정하면 된다.
OCP — 개방-폐쇄 원칙
확장에는 열려 있고, 수정에는 닫혀 있어야 한다.
새로운 기능을 추가할 때 기존 코드를 수정하지 않고 확장할 수 있는 구조를 만들라는 뜻이다.
위반 사례
function NotificationBanner({ type }: { type: string }) {
if (type === 'success') return <div className="bg-green-500">성공!</div>;
if (type === 'error') return <div className="bg-red-500">에러 발생</div>;
if (type === 'warning') return <div className="bg-yellow-500">주의</div>;
// info 타입이 추가되면? 이 함수를 직접 수정해야 한다.
return null;
}
알림 타입이 추가될 때마다 이 컴포넌트 내부를 수정해야 한다. 타입이 10개가 되면 if문이 10개가 된다.
개선
const notificationStyles: Record<string, { className: string; message: string }> = {
success: { className: 'bg-green-500', message: '성공!' },
error: { className: 'bg-red-500', message: '에러 발생' },
warning: { className: 'bg-yellow-500', message: '주의' },
};
function NotificationBanner({ type }: { type: string }) {
const config = notificationStyles[type];
if (!config) return null;
return <div className={config.className}>{config.message}</div>;
}
// info 타입 추가 시: notificationStyles에 한 줄만 추가하면 됨
// NotificationBanner 컴포넌트는 수정하지 않는다
설정 객체(또는 map)로 분리하면, 새로운 타입 추가는 데이터 확장이지 코드 수정이 아니다.
LSP — 리스코프 치환 원칙
자식 타입은 부모 타입을 대체할 수 있어야 한다.
부모 타입을 기대하는 자리에 자식 타입을 넣어도 프로그램이 의도대로 동작해야 한다는 뜻이다. TypeScript에서는 인터페이스나 타입을 구현할 때 이 원칙이 적용된다.
위반 사례
interface InputProps {
value: string;
onChange: (value: string) => void;
}
// 일반 텍스트 입력 — InputProps 계약대로 동작
function TextInput({ value, onChange }: InputProps) {
return <input value={value} onChange={(e) => onChange(e.target.value)} />;
}
// 읽기 전용 입력 — onChange를 무시해버린다
function ReadOnlyInput({ value, onChange }: InputProps) {
// onChange를 받아놓고 아무것도 하지 않는다
return <input value={value} readOnly />;
}
ReadOnlyInput은 InputProps를 구현했지만 onChange를 무시한다. InputProps를 기대하고 onChange를 호출하는 부모 컴포넌트에서 예기치 않은 동작이 발생한다. 값이 바뀌지 않는데 에러도 안 나니까, 디버깅하기 더 어렵다.
개선
interface ReadableInputProps {
value: string;
}
interface EditableInputProps extends ReadableInputProps {
onChange: (value: string) => void;
}
function TextInput({ value, onChange }: EditableInputProps) {
return <input value={value} onChange={(e) => onChange(e.target.value)} />;
}
function ReadOnlyInput({ value }: ReadableInputProps) {
return <input value={value} readOnly />;
}
읽기 전용과 편집 가능한 입력의 인터페이스를 분리하면, 각 컴포넌트가 자기 계약을 온전히 이행한다. 이렇게 하면 ISP(인터페이스 분리 원칙)도 자연스럽게 지켜진다.
ISP — 인터페이스 분리 원칙
클라이언트가 사용하지 않는 인터페이스에 의존하지 않아야 한다.
하나의 거대한 인터페이스보다, 용도에 맞게 분리된 작은 인터페이스 여러 개가 낫다.
위반 사례
interface UserData {
id: string;
name: string;
email: string;
avatar: string;
address: string;
phoneNumber: string;
creditCard: string;
orderHistory: Order[];
}
// 헤더에서는 name과 avatar만 필요한데, UserData 전체를 요구한다
function Header({ user }: { user: UserData }) {
return (
<header>
<img src={user.avatar} />
<span>{user.name}</span>
</header>
);
}
Header는 name과 avatar만 필요하지만, UserData 전체를 prop으로 받고 있다. creditCard나 orderHistory가 바뀔 때 Header의 타입 정의도 영향을 받고, 테스트할 때 불필요한 mock 데이터를 만들어야 한다.
개선
interface UserProfile {
name: string;
avatar: string;
}
interface UserContact {
email: string;
phoneNumber: string;
address: string;
}
interface UserBilling {
creditCard: string;
orderHistory: Order[];
}
function Header({ user }: { user: UserProfile }) {
return (
<header>
<img src={user.avatar} />
<span>{user.name}</span>
</header>
);
}
Header는 UserProfile만 알면 된다. 결제 정보가 바뀌어도 Header는 영향을 받지 않는다.
DIP — 의존 역전 원칙
구체적인 구현에 의존하지 말고, 추상(인터페이스)에 의존하라.
상위 모듈이 하위 모듈의 구현 세부사항을 직접 알면 안 된다. 둘 다 추상에 의존해야 한다.
위반 사례
// 컴포넌트가 localStorage를 직접 참조한다
function useTheme() {
const [theme, setTheme] = useState(() => localStorage.getItem('theme') ?? 'light');
const toggle = () => {
const next = theme === 'light' ? 'dark' : 'light';
localStorage.setItem('theme', next);
setTheme(next);
};
return { theme, toggle };
}
이 훅은 localStorage에 직접 의존한다. 테스트할 때 localStorage를 모킹해야 하고, 나중에 쿠키나 DB로 저장소를 바꾸려면 훅 내부를 수정해야 한다.
개선
// 저장소 추상화
interface StorageAdapter {
get(key: string): string | null;
set(key: string, value: string): void;
}
const localStorageAdapter: StorageAdapter = {
get: (key) => localStorage.getItem(key),
set: (key, value) => localStorage.setItem(key, value),
};
const cookieAdapter: StorageAdapter = {
get: (key) => { /* cookie에서 읽기 */ },
set: (key, value) => { document.cookie = `${key}=${value}; path=/`; },
};
function useTheme(storage: StorageAdapter = localStorageAdapter) {
const [theme, setTheme] = useState(() => storage.get('theme') ?? 'light');
const toggle = () => {
const next = theme === 'light' ? 'dark' : 'light';
storage.set('theme', next);
setTheme(next);
};
return { theme, toggle };
}
useTheme은 StorageAdapter 인터페이스에만 의존한다. localStorage든 쿠키든 테스트용 인메모리든, 인터페이스만 맞으면 갈아 끼울 수 있다. 테스트에서 모킹도 간단해진다.
원칙들은 어떻게 연결되는가
5가지 원칙은 독립적이지 않다. 실제로 적용하다 보면 하나를 지키면 다른 것도 자연스럽게 따라오는 경우가 많다.
- SRP를 지키면 ISP가 따라온다. 컴포넌트의 책임을 하나로 좁히면, 그 컴포넌트가 필요로 하는 props(인터페이스)도 자연스럽게 작아진다.
- DIP를 적용하면 OCP가 가능해진다. 구현이 아닌 추상에 의존하면, 새로운 구현을 추가할 때 기존 코드를 수정하지 않아도 된다.
- LSP는 OCP의 전제 조건이다. 자식 타입이 부모 타입을 안전하게 대체할 수 없으면, 확장을 위해 기존 코드를 수정해야 하는 상황이 생긴다.
마무리
SOLID는 "반드시 5개를 다 지켜야 하는 체크리스트"가 아니다. 각 원칙은 특정 문제를 해결하기 위한 지침이고, 코드에 해당 문제가 없으면 억지로 적용할 필요도 없다.
하지만 "이 컴포넌트가 너무 많은 일을 하고 있지 않나?", "이 인터페이스가 너무 뚱뚱하지 않나?", "이 모듈이 특정 구현에 너무 묶여 있지 않나?"라는 질문을 습관적으로 던질 수 있다면, 코드의 유연성과 유지보수성은 자연스럽게 올라간다.
면접에서 못 대답한 경험이 결국 이 글을 쓰게 만들었다. 다음에는 "예시 하나만 들어주세요"라는 꼬리 질문에도 막힘 없이 답할 수 있을 것 같다.