들어가며
SOLID 원칙은 로버트 C. 마틴이 제안한 객체지향 설계의 5가지 기본 원칙입니다. 이 원칙들은 소프트웨어를 더 이해하기 쉽고, 유연하며, 유지보수하기 쉽게 만들어줍니다. 비록 객체지향 프로그래밍을 위해 만들어졌지만, React와 같은 컴포넌트 기반 라이브러리에도 동일하게 적용할 수 있습니다.
오늘은 SOLID의 각 원칙이 React 컴포넌트 설계에서 어떻게 활용되는지, 실제 코드 예시와 함께 살펴보겠습니다. 이 원칙들을 이해하고 적용하면 컴포넌트를 더 유지보수하기 쉽고, 재사용하기 좋으며, 테스트하기 쉽게 만들 수 있습니다.
1. 단일 책임 원칙 (Single Responsibility Principle)
핵심: 컴포넌트는 오직 하나의 책임(기능) 만 가져야 합니다.
단일 책임 원칙은 하나의 컴포넌트가 하나의 역할만 수행해야 한다는 원칙입니다. 예를 들어, 데이터 불러오기, 데이터 표시, 입력 처리 등의 책임은 각각 분리되어야 합니다. 이렇게 하면 코드의 가독성이 높아지고, 특정 기능을 수정할 때 다른 부분에 영향을 주지 않게 됩니다.
❌ 나쁜 예 (여러 책임):
function UserProfile() {
const [user, setUser] = useState(null);
// 1. 책임: 데이터 불러오기
useEffect(() => {
fetch('/api/user/1')
.then(res => res.json())
.then(data => setUser(data));
}, []);
// 2. 책임: 데이터 표시하기
if (!user) return <div>로딩 중...</div>;
return <div>{user.name}</div>;
}
위 코드는 데이터 불러오기와 표시하기라는 두 가지 책임을 동시에 가지고 있습니다. 이런 구조는 테스트가 어렵고, 데이터 불러오기 로직을 재사용하기도 힘듭니다.
✅ 좋은 예 (책임 분리):
// 1. 책임: 데이터 불러오기 (커스텀 훅)
function useUser(userId) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/user/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]);
return user;
}
// 2. 책임: 데이터 표시하기
function UserProfile({ userId }) {
const user = useUser(userId); // 불러오는 책임은 훅에 위임
if (!user) return <div>로딩 중...</div>;
return <div>{user.name}</div>;
}
데이터 불러오기 로직을 커스텀 훅으로 분리함으로써 각 부분이 하나의 책임만 갖게 되었습니다. useUser 훅은 다른 컴포넌트에서도 재사용할 수 있고, UserProfile은 UI 렌더링에만 집중할 수 있습니다.
2. 개방-폐쇄 원칙 (Open-Closed Principle)
핵심: 컴포넌트는 확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 합니다.
개방-폐쇄 원칙은 새로운 기능을 추가할 때 기존 코드를 수정하지 않고 확장할 수 있어야 한다는 원칙입니다. React에서는 composition 패턴이나 children props를 활용하면 이 원칙을 쉽게 적용할 수 있습니다.
❌ 나쁜 예 (수정이 필요함):
// 새로운 알림 타입(예: 'warning')이 추가될 때마다
// 이 컴포넌트 내부를 수정해야 함
function Alert({ type, message }) {
if (type === 'success') {
return <div style={{ color: 'green' }}>{message}</div>;
}
if (type === 'error') {
return <div style={{ color: 'red' }}>{message}</div>;
}
return <div>{message}</div>;
}
새로운 알림 타입을 추가할 때마다 Alert 컴포넌트 내부에 조건문을 추가해야 합니다. 이는 코드를 수정해야 하므로 OCP를 위반합니다.
✅ 좋은 예 (children으로 확장):
// 기본 Alert 컴포넌트는 수정할 필요 없음
function Alert({ children }) {
return <div className="alert-base">{children}</div>;
}
// 새로운 타입을 '확장'해서 만듦
function SuccessAlert({ message }) {
return <Alert><span style={{ color: 'green' }}>{message}</span></Alert>;
}
function ErrorAlert({ message }) {
return <Alert><span style={{ color: 'red' }}>{message}</span></Alert>;
}
// 'warning' 타입을 추가해도 Alert 자체는 수정 X
function WarningAlert({ message }) {
return <Alert><span style={{ color: 'orange' }}>{message}</span></Alert>;
}
기본 Alert 컴포넌트는 변경하지 않고, 새로운 컴포넌트를 만들어 확장합니다. 이렇게 하면 기존 코드의 안정성을 유지하면서 새로운 기능을 추가할 수 있습니다.
3. 리스코프 치환 원칙 (Liskov Substitution Principle)
핵심: 부모(기본) 컴포넌트가 사용되는 곳에 자식(파생) 컴포넌트를 넣어도 문제없이 작동해야 합니다.
리스코프 치환 원칙은 하위 타입이 상위 타입을 대체할 수 있어야 한다는 원칙입니다. React에서는 비슷한 역할을 하는 컴포넌트들이 동일한 props 인터페이스를 가져야 한다는 의미로 해석할 수 있습니다.
❌ 나쁜 예 (인터페이스 비일관성):
function NormalInput({ value, onChange }) {
return <input value={value} onChange={onChange} />;
}
// 'onChange' 대신 'onTextChange'라는 다른 prop 이름을 사용
function FancyInput({ value, onTextChange }) {
return <input value={value} onChange={e=> onTextChange(e.target.value)} />;
}
// 부모 컴포넌트
function Form({ InputComponent }) {
// InputComponent가 NormalInput이면 잘 작동하지만,
// FancyInput이면 'onChange'가 없어서 작동 불능
return
<InputComponent value={...} onChange={...} />;
}
FancyInput이 다른 prop 이름을 사용하므로, NormalInput과 교체할 수 없습니다.
✅ 좋은 예 (인터페이스 일관성):
function NormalInput({ value, onChange }) {
return <input value={value} onChange={onChange} />;
}
// 동일한 props (value, onChange)를 사용
function FancyInput({ value, onChange }) {
return <input className="fancy" value={value} onChange={onChange} />;
}
// 부모 컴포넌트
function Form({ InputComponent }) {
// NormalInput, FancyInput 둘 다 문제없이 '치환' 가능
return
<InputComponent value={...} onChange={...} />;
}
두 컴포넌트가 동일한 인터페이스를 사용하므로 서로 교체 가능합니다. 이렇게 하면 컴포넌트를 유연하게 변경할 수 있고, TypeScript를 사용한다면 타입 안정성도 확보할 수 있습니다.
4. 인터페이스 분리 원칙 (Interface Segregation Principle)
핵심: 컴포넌트가 사용하지 않는 props를 받도록 강요해선 안 됩니다.
인터페이스 분리 원칙은 필요한 것만 의존해야 한다는 원칙입니다. React에서는 컴포넌트가 필요한 최소한의 props만 받아야 한다는 의미입니다. 이렇게 하면 컴포넌트의 의존성이 명확해지고, 불필요한 리렌더링을 방지할 수 있습니다.
❌ 나쁜 예 (거대한 props 객체):
// 이름만 표시하는데 user 객체 전체를 받음
function UserAvatar({ user }) {
// user.email, user.age 등은 여기서 필요 없음
return <div>{user.name}</div>;
}
function App() {
const user = { name: 'Kim', age: 30, email: 'test@test.com' };
return
<UserAvatar user={user} />;
}
UserAvatar는 이름만 표시하지만 user 객체 전체를 받습니다. 이렇게 되면 user의 어떤 속성이든 변경될 때마다 컴포넌트가 리렌더링될 수 있습니다.
✅ 좋은 예 (필요한 prop만 받기):
// 'name' prop만 받도록 분리
function UserAvatar({ name }) {
return <div>{name}</div>;
}
function App() {
const user = { name: 'Kim', age: 30, email: 'test@test.com' };
// 부모가 필요한 'name'만 전달
return
<UserAvatar name={user.name} />;
}
필요한 name만 전달하므로 컴포넌트의 의존성이 명확합니다. 이는 코드의 가독성을 높이고, 메모이제이션을 더 효과적으로 활용할 수 있게 해줍니다.
5. 의존 역전 원칙 (Dependency Inversion Principle)
핵심: 컴포넌트가 구체적인 구현에 직접 의존하지 말고, 추상화에 의존해야 합니다.
의존 역전 원칙은 고수준 모듈이 저수준 모듈에 의존하지 않고, 둘 다 추상화에 의존해야 한다는 원칙입니다. React에서는 컴포넌트가 구체적인 API 서비스나 라이브러리를 직접 import하지 않고, props를 통해 주입받도록 하는 것이 좋습니다.
❌ 나쁜 예 (내부에서 직접 의존):
import { apiService } from './api'; // 'apiService'라는 구체적인 구현에 의존
function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
// apiService.fetchProducts()를 직접 호출
apiService.fetchProducts().then(setProducts);
}, []);
return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}
ProductList가 apiService에 직접 의존하므로, 테스트할 때 실제 API를 호출해야 하고, API 구현이 변경되면 컴포넌트도 수정해야 할 수 있습니다.
✅ 좋은 예 (props로 의존성 주입):
// 'fetchProducts' 함수를 props(추상화)로 받음
function ProductList({ fetchProducts }) {
const [products, setProducts] = useState([]);
useEffect(() => {
fetchProducts().then(setProducts); // 주입받은 함수를 호출
}, [fetchProducts]);
return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}
// App.js (실제 사용 시)
import { apiService } from './api';
<ProductList fetchProducts={apiService.fetchProducts} />
// Test.js (테스트 시)
const mockFetch = () => Promise.resolve([{ id: 1, name: 'Mock' }]);
<ProductList fetchProducts={mockFetch} /> // 목업 함수 주입
의존성을 props로 주입받으면 테스트하기 쉽고, API 구현을 변경해도 컴포넌트는 수정할 필요가 없습니다. 이는 컴포넌트의 결합도를 낮추고 유연성을 높입니다.
마치며
SOLID 원칙은 처음에는 다소 추상적으로 느껴질 수 있지만, React 컴포넌트 설계에 적용하면 실질적인 이점을 가져다줍니다:
- 단일 책임 원칙(SRP): 커스텀 훅과 컴포넌트 분리로 관심사를 명확히 나눕니다
- 개방-폐쇄 원칙(OCP): Composition 패턴으로 기존 코드 수정 없이 확장합니다
- 리스코프 치환 원칙(LSP): 일관된 props 인터페이스로 컴포넌트 간 호환성을 보장합니다
- 인터페이스 분리 원칙(ISP): 필요한 props만 받아 의존성을 최소화합니다
- 의존 역전 원칙(DIP): 의존성 주입으로 테스트 가능하고 유연한 구조를 만듭니다
이러한 원칙들을 모두 한 번에 적용하려고 하지 말고, 프로젝트의 상황에 맞게 점진적으로 도입하는 것이 좋습니다. 완벽한 설계보다는 읽기 쉽고 유지보수하기 좋은 코드를 만드는 것이 더 중요합니다.
여러분의 프로젝트에서 SOLID 원칙을 어떻게 적용하고 계신가요?