의존성 역전에 대해서 들어보신적 있을겁니다.
어떤 모듈이 가지고 있는 의존성을 격리하여 외부로 부터 주입받는 형태를 보통 의존성 역전이라고 합니다.
왜 이런 설계가 필요할까요? 우선 좋은 설계에 대해 짚고 넘어가보겠습니다.
우리 모두가 공감할 수 있는 좋은 설계는 "기능이 동작하면서 내일 쉽게 변경할 수 있는 코드"라고 생각합니다.
확장은 쉬우면서 수정에는 닫혀있는 객체 지향에서의 개방 폐쇄 원칙도 이를 뒷받침하는 원칙중 하나입니다.
좋은 설계, 기능이 동작하면서 내일 쉽게 변경할 수 있는 코드
최악의 상황을 생각해보겠습니다. 이번 주에 새로운 기능을 개발해야 하는데 이 기능과 연결된 모듈이 \10개가 넘고 심지어 보이지 않는 숨은 의존성까지 존재합니다. 끔찍하죠?
우리가 바라는 상황은 수정해야 하는 기능에 대한 모듈의 범위가 좁고 한 곳에 모여있는 상황입니다. 이를 응집도가 높고 모듈간 결합도가 낮다라고 표현합니다. 오늘 다룰 의존성 역전은 모듈 간의 결합도를 줄이고 유연성과 유지보수성을 높이기 위한 전략입니다.
이해를 위해 예제를 살펴도록 하겠습니다.
class EmailSender {
sendEmail(message: string): void {
console.log(`Sending email with message: ${message}`);
}
}
class NotificationService {
private emailSender: EmailSender;
constructor() {
this.emailSender = new EmailSender();
}
notify(message: string): void {
this.emailSender.sendEmail(message);
}
}
// 사용 예시
const notificationService = new NotificationService();
notificationService.notify('Hello World');
위 코드에서의 문제점이 뭘까요? NotificationService가 emailSender를 직접 참조하면서 두 모듈이 결합되어 있습니다.
만약 이메일이 아닌 다른 notify수단이 추가되어야 한다면 NotificationService를 수정해야 합니다. 하나의 기능을 개발하는데 여러 모듈을 수정해야 함으로 결합도는 높고 응집도는 낮은 상황입니다. 이를 아래와 같이 개선할 수 있습니다
interface IMessageSender {
send(message: string): void;
}
class EmailSender implements IMessageSender {
send(message: string): void {
console.log(`Sending email with message: ${message}`);
}
}
class NotificationService {
private messageSender: IMessageSender;
constructor(messageSender: IMessageSender) {
this.messageSender = messageSender;
}
notify(message: string): void {
this.messageSender.send(message);
}
}
const emailSender = new EmailSender();
const notificationService = new NotificationService(emailSender);
notificationService.notify('Hello World');
무엇이 개선됐을까요?
NotificationService는 이제 어떤 인스턴스가 올지 알 수 없습니다. NotificationService와 EamilSender와의 직접적 결합이 분리됐습니다. 이제는 IMessageSender의 인터페이스를 따르는 인스턴스라면 전부 수용할 수 있도록 구현이 훨씬 유연해졌죠! 이제는 아래와 같이 다양한 Sender가 추가되는 기획이 발생하더라도 NotificationService를 수정하지 않아도 됩니다.
class SmsSender implements IMessageSender {
send(message: string): void {
console.log(`Sending SMS with message: ${message}`);
}
}
const smsSender = new SmsSender();
const smsNotificationService = new NotificationService(smsSender);
smsNotificationService.notify('Hello via SMS');
class PushNotificationSender implements IMessageSender {
send(message: string): void {
console.log(`Sending push notification with message: ${message}`);
}
}
const pushNotificationSender = new PushNotificationSender();
const pushNotificationService = new NotificationService(pushNotificationSender);
pushNotificationService.notify('Hello via Push Notification');
이를 정리해보면 모듈간의 직접 결합을 피하기 위해 추상화된 인터페이스를 의존했고
개선 이전에 의존하고 있던 모듈을 외부에서 주입받도록 의존성의 위치가 바뀌었습니다.
이것이 바로 의존성 역전입니다. 그러면 어떤 의존성을 느슨한 결합으로 수정해야할까요?
의존 관계를 맺을 때, 변화하기 쉬운 것 보다 변화하기 어려운 것에 의존해야 한다.
이를 위해 구체적인 구현(고수준 모듈)보다 추상적 인터페이스에 의존해야 한다.
추상적 인터페이스는 구체적 방법과 상황을 의존해선 안 된다. 이는 인터페이스의 구현체에서 정의되어야 한다.
라고 의존성 역전 원칙은 설명합니다.
그럼 리액트에서 이 의존성 역전을 어떻게 적용시킬 수 있을까요?
간단한 예시로 알아보도록 하겠습니다
import api from "~/common/api";
const LoginForm = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const handleSubmit = async (evt) => {
evt.preventDefault();
await api.login(email, password);
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit">Log in</button>
</form>
);
};
로그인을 요청하는 폼 컴포넌트입니다.
이 컴포넌트에서 onSubmit 시점에서 직접 login요청을 하면서 api 스팩과 결합된 상황입니다. 이렇게 되면 form 제출과 api호출이 결합되어 외부에서 이 사이에 유효성 검증, 알림 등의 다른 처리를 할 수 없고 api스팩과의 의존성이 생겼습니다.
보다 유연한 구조는 LoginForm과 login 요청을 분리하고 LoginForm은 UI만 담당하게 되는 것입니다.
type Props = {
onSubmit: (email: string, password: string) => Promise<void>;
};
const LoginForm = ({ onSubmit }: Props) => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const handleSubmit = async (evt) => {
evt.preventDefault();
await onSubmit(email, password);
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit">Log in</button>
</form>
);
};
위 구조에서는 loginApi를 의존하던 LoginForm이 onSubmit prop을 통해 모듈의 의존성이 상위 모듈로 이동했습니다.
따라서 LoginForm과 login api호출간의 결합이 사라졌고 login api 스팩 변경에서 자유로워진 형태입니다.
또 login요청에 대한 제어를 상위 모듈에 위임했으니 제어의 역전으로 볼 수도 있습니다.
이 밖에 Render props 를 이용해 스타일링 방식을 외부로 위임하는 방식도 의존성 역전의 사례로 볼 수 있습니다. 스타일링 변경에 대한 책임을 상위 모듈로 위임했기 때문입니다. 또 다른 사례로 axios 모듈을 사용할 때 apiClient로 래핑해서 사용한다면 이 역시 의존성의 역전입니다. axios와의 강한 결합 대신에 추상화된 apiClient를 통해 요청하기 때문이죠 네트워크 요청이 필요한 각 모듈에서는 axios의 인터페이스에서 자유로워집니다.
다음은 React 공식 홈페이지에 있는 Render props 패턴을 통한 유연성을 확보 예제입니다.
// The <Mouse> component encapsulates the behavior we need...
class Mouse extends React.Component {
constructor(props) {
super(props);
this.handleMouseMove = this.handleMouseMove.bind(this);
this.state = { x: 0, y: 0 };
}
handleMouseMove(event) {
this.setState({
x: event.clientX,
y: event.clientY
});
}
render() {
return (
<div style={{ height: '100vh' }} onMouseMove={this.handleMouseMove}>
{/* ...but how do we render something other than a <p>? */}
<p>The current mouse position is ({this.state.x}, {this.state.y})</p>
</div>
);
}
}
class MouseTracker extends React.Component {
render() {
return (
<>
<h1>Move the mouse around!</h1>
<Mouse />
</>
);
}
}
부연 설명드리자면 Mouse는 마우스의 위치를 트래킹하며 뷰포트에서의 위치를 상태로 관리하는 컴포넌트입니다.
이제 이 컴포넌트를 이용해서 마우스의 위치를 트래킹하고 싶은 순간에 Mouse컴포넌트를 활용하고 싶습니다.
예를 들어 마우스 커서를 따라다니는 고양이 이미지를 만들고 싶습니다.
Cat이라는 컴포넌트를 선언해서 마우스의 위치를 props으로 전달받으면 될 것 같네요!
class Cat extends React.Component {
render() {
const mouse = this.props.mouse;
return (
<img src="/cat.jpg" style={{ position: 'absolute', left: mouse.x, top: mouse.y }} />
);
}
}
class MouseWithCat extends React.Component {
constructor(props) {
super(props);
this.handleMouseMove = this.handleMouseMove.bind(this);
this.state = { x: 0, y: 0 };
}
handleMouseMove(event) {
this.setState({
x: event.clientX,
y: event.clientY
});
}
render() {
return (
<div style={{ height: '100vh' }} onMouseMove={this.handleMouseMove}>
{/*
We could just swap out the <p> for a <Cat> here ... but then
we would need to create a separate <MouseWithSomethingElse>
component every time we need to use it, so <MouseWithCat>
isn't really reusable yet.
*/}
<Cat mouse={this.state} />
</div>
);
}
}
class MouseTracker extends React.Component {
render() {
return (
<div>
<h1>Move the mouse around!</h1>
<MouseWithCat />
</div>
);
}
}
위 예제에서 아쉬운 점은 MouseWithCat과 Cat이 결합됐다는 점입니다.
우리는 Mouse 컴포넌트를 계속 활용하고 싶은데 이렇게 특정 모듈과 강하게 결합되면 재사용성이 떨어지겠죠...
그럼 매번 MouseWithSomthing을 만들어야할까요? 특정 모듈과의 의존성을 떼어내고 싶다는 생각이 강하게 듭니다.
아래는 render props 패턴을 적용해 특정 모듈간의 결합도를 낮추고 인터페이스(render props)에 의존하는 즉 포스팅의 주제인 의존성 역전을 통해 유연성을 확보했습니다.
class Cat extends React.Component {
render() {
const mouse = this.props.mouse;
return (
<img src="/cat.jpg" style={{ position: 'absolute', left: mouse.x, top: mouse.y }} />
);
}
}
class Mouse extends React.Component {
constructor(props) {
super(props);
this.handleMouseMove = this.handleMouseMove.bind(this);
this.state = { x: 0, y: 0 };
}
handleMouseMove(event) {
this.setState({
x: event.clientX,
y: event.clientY
});
}
render() {
return (
<div style={{ height: '100vh' }} onMouseMove={this.handleMouseMove}>
{/*
Instead of providing a static representation of what <Mouse> renders,
use the `render` prop to dynamically determine what to render.
*/}
{this.props.render(this.state)}
</div>
);
}
}
class MouseTracker extends React.Component {
render() {
return (
<div>
<h1>Move the mouse around!</h1>
<Mouse render={mouse => (
<Cat mouse={mouse} />
)}/>
</div>
);
}
}
이제 Mouse와 특정 모듈간의 결합이 사라졌기 때문에 무엇을 렌더링하든 Mouse 컴포넌트를 수정할 일이 없어졌습니다. 확장에 열려있고 수정에 닫혀있는 구조! 저희가 딱 원하던 모습이네요!
결론
모듈이 가지고 있는 의존성 그리고 어떤 의존성을 분리해야 했을 때 변경에 유연한 구조가 되는지에 대해 알아봤습니다.
SOLID원칙은 객체 지향에서 출발한 내용이지만 유연한 설계를 목표로 하고 있기 때문에 함수형 패러다임에서도 일맥상통하는 부분이 있었습니다. 하지만 그대로 적용하기에는 어려운 부분이 있고 그 목적성에 무게를 두고 변형시켜 적용하는 유연함이 필요할 것 같습니다.
참고
'react' 카테고리의 다른 글
[설계] 격리된 컴포넌트간의 통신 설계 (feat. Eventbus) (0) | 2024.06.28 |
---|---|
[Nextjs] next13에서 react-query의 필요성 (0) | 2024.06.27 |
[설계] Compound Pattern으로 변경에 유연한 컴포넌트 설계하기 (0) | 2024.06.24 |
실무에서 가장 많이 실수하는 useEffect 사례 (0) | 2024.06.23 |
[설계] 대규모 React앱의 Multi Tab 통신 (0) | 2024.06.20 |