지난 게시글에서는 SRP, OCP, LSP까지 알아보았다. 이번에는 남은 두 가지 원칙인 ISP와 DIP에 대해서 서술하겠다.
인터페이스 분리 원칙은 "클라이언트는 자신이 사용하지 않는 메서드에 의존하면 안 된다"는 원칙이다. 즉, 하나의 방대한 인터페이스나 props를 제공하는 대신, 특정 기능에 필요한 최소한의 인터페이스만 노출해야 한다는 개념이다.
간단하게는 프롭스나 인터페이스를 제공받을때, 필요한 메서드나 객체만 가져오면 되는 것이다.
리액트에서는 ISP에 따라 컴포넌트가 필요하지 않은 props나 메서드까지 받지 않도록 설계한다. 이를 통해 컴포넌트가 작아지고 모듈화되며, 가독성과 재사용성이 높아진다.
예를 들어, 사용자 프로필을 렌더링하는 컴포넌트가 있다면, 사진 표시, 이름 표시, 소개 표시 등으로 컴포넌트를 나누어 각각 필요한 데이터만 받게 하면 코드가 더 명료해진다.
사실은 우리가 알게 모르게 자주 사용하고 있는 원칙중 하나인데, 잘못된 예시와 좋은 예시를 살펴보자.
// book 객체 전체를 넘겨줘서 불필요한 데이터까지 의존 - 틀린 예
interface Book { id: number; title: string; author: string; image: string; /* ... */ }
const BookDetails = ({ book }: { book: Book }) => {
return (
<div>
<img src={book.image} alt={book.title} />
<h2>{book.title}</h2>
<p>{book.author}</p>
</div>
);
};
위와 같은 예시는 book 객체 전체를 props로 전달하여 컴포넌트가 불필요한 데이터까지 받게 된다.
// 필요한 props만 분리하여 전달
const BookDetails = ({ image, title, author }: { image: string; title: string; author: string }) => {
return (
<div>
<img src={image} alt={title} />
<h2>{title}</h2>
<p>{author}</p>
</div>
);
};
개선된 예제에서는 필요한 image
, title
, author
만 props로 전달하여 ISP 원칙을 준수한다.
이처럼 ISP를 적용하면 컴포넌트에 필요한 데이터만 전달되므로 컴포넌트 인터페이스가 간결해진다. 결과적으로 컴포넌트의 재사용 범위가 넓어지고 코드 의존성이 줄어든다.
컴포넌트가 너무 많은 props를 받는다면, 컴포넌트를 다시 설계하여 역할별로 나누거나 props를 그룹화해서 필요한 데이터만 전달해야 한다.
지나친 인터페이스 분리는 관리해야할 컴포넌트가 많아질 수 있으므로, 필요에 따라 적절히 분리하는 것이 중요하다.
의존 역전 원칙은 "고수준 모듈이 저수준 모듈에 의존해서는 안 되고, 대신 둘 다 추상화에 의존해야 한다"는 원칙이다. 리액트에서는 컴포넌트가 구체적인 구현(API 호출, HTTP 클라이언트 등)에 직접 의존하지 않고, props나 Context 등 추상적인 인터페이스를 통해 필요한 로직을 주입받도록 설계해야 한다.
쉽게 얘기하면, 컴포넌트 안의 로직을 적절히 분리하라는 것이다. 명확한 목적성(비즈니스 로직, UI/UX 로직)을 두고 관심사에 따라 기능과 코드를 알맞게 나눠주면 된다.
이 원칙을 따르면 컴포넌트 간 결합도가 낮아져 코드의 유연성과 테스트 용이성이 향상된다. 예를 들어, 폼 컴포넌트를 작성할 때 데이터 저장 로직을 컴포넌트 내부에 두지 않고, 외부에서 함수 형태로 주입하면 같은 폼 컴포넌트를 다양한 상황에서 재사용할 수 있다.
이렇게 의존성을 주입하는 방식은 단위 테스트 시 모킹하기도 쉬워져 테스트 코드 작성이 수월해진다.
예시를 살펴보자.
// 컴포넌트가 API 호출 로직에 직접 의존
const CreateBookForm = () => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
// axios를 직접 호출 (저수준 모듈)
await axios.post("/api/books", formData);
};
return (
<form onSubmit={handleSubmit}>
<input name="title" />
<button type="submit">Create</button>
</form>
);
};
처음 코드를 작성하거나, 빠르게 구현이 필요한 경우 위의 예제와 마찬가지로 UI와 비즈니스 로직이 한 컴포넌트안에서 정의가 되있을 경우가 많다.
이와 같은 상황은, CreateBookForm이 axios와 같이 저수준 모듈에 의존하여 렌더링하는 형태이기 때문에 이는 DIP의 원칙을 위배하고 있다고 볼 수 있다. 즉, 추상화를 하지 않고, 로직이 한 컴포넌트에 뭉쳐있는 것이다.
// 의존성을 분리하여 onSubmit 함수를 props로 주입
interface BookFormProps {
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
}
const BookForm = ({ onSubmit }: BookFormProps) => (
<form onSubmit={onSubmit}>
<input name="title" />
<button type="submit">Submit</button>
</form>
);
const CreateBookPage = () => {
const handleCreate = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
await axios.post("/api/books", formData);
};
return <BookForm onSubmit={handleCreate} />;
};
const EditBookPage = () => {
const handleEdit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
};
return <BookForm onSubmit={handleEdit} />;
};
이렇게 컴포넌트를 분리하게 되면, BookForm이 UI 렌더링만 담당하고, 실제 제출 로직은 CreateBookPage나 EditBookPage에서 주입받는다. 이로써 폼 컴포넌트가 구체적인 API 구현에 의존하지 않고 추상 인터페이스(onSubmit 함수)에 의존하도록 설계되어 DIP를 준수한다.
컴포넌트는 가능한 한 낮은 수준의 구현(API 호출, HTTP 클라이언트)에 직접 의존하지 말고, 함수나 인터페이스를 상위 컴포넌트에서 props나 Context를 통해 전달받자.
DIP를 준수할 경우 컴포넌트 테스트가 쉬워지게 되는데, 단위 테스트에서 해당 props만 모킹하면 되므로 테스트 작성 비용이 줄어든다.
그러나 ISP 처럼 지나치게 많은 추상 레이어를 도입하면 오히려 복잡도가 증가할 수 있다.
SOLID 원칙을 리액트 개발에 적용하면 컴포넌트 간 역할과 의존성을 명확히 하여 코드의 유지보수성과 확장성을 높일 수 있다. 물론 SOLID 원칙은 만능 해결책이 아니며, 과도한 추상화나 분리가 오히려 복잡도를 높일 수도 있다. 각 원칙이 추구하는 바를 이해하고 프로젝트 상황에 맞춰 적절히 응용하면, 안정적이고 확장 가능한 리액트 코드를 작성하는 데 큰 도움이 될 것이다.
SOLID 원칙은 기본적으로 객체지향 설계 원리이지만, 위의 예시와 같이 함수형 컴포넌트와 훅을 주로 사용하는 리액트에서도 적용이 된다. 기능이 많아지고, 코드가 복잡해질수록 SOLID 원칙을 적절히 활용하여 구조적으로 견고한 애플리케이션을 구현해보자.
https://www.codinn.dev/articles/applying-solid-principles-in-react