React와 SOLID 원칙에 관한 짧은 글을 읽고 우리말로 옮겨보았습니다. 글의 전개를 역자의 입맛에 맞춰 재구성하였습니다(🤬초월번역주의).
📙 예상 독자 : SOLID 원칙이 무엇인지 알고 싶은 모든 React 개발자
👉원본보기(Mastering React JS SOLID Principles by Kristiyan Velkov)
SOLID 원칙은 코드가 느슨하게 결합되어 유지 보수와 확장이 쉽도록 만들어주는 다섯 가지 설계 원칙입니다. 다섯 가지 SOLID 원칙은 아래와 같습니다.
🙃 역주
SOLID 원칙 각각을 우리말로 옮기면 그 의미가 잘 살아나지 않아 이 글에서는 원문 표기 그대로 사용하도록 하겠습니다.
많은 글들이 SOLID 원칙을 설명하고 있으나 React 프로젝트 위에서 이 다섯 꼭지를 적용해보는 글은 많지 않습니다. 이 글에서는 React 컴포넌트 설계 과정에서 SOLID 원칙이 어떻게 적용될 수 있는지 사례를 들어 설명해보도록 하겠습니다.
“A module should be responsible to one, and only one, actor.” — Wikipedia.
Single-responsiblity 원칙을 준수하면 컴포넌트가 명확한 단일 기능을 담당하도록 코드를 생성할 수 있습니다.
컴포넌트는 하나의 구체적인 기능을 담당하여야 하며, 관련 없는 작업은 수행하지 않아야 합니다. SR 원칙을 준수하면 전문적이고, 독립적이고, 가독성있고, 심지어는 쉽게 수정할 수 있는 컴포넌트를 만들 수 있습니다!
실제 사례를 보도록 합시다.
const UserProfile = ({ user }) => {
return (
<div>
<h1>User Profile</h1>
<p>Name: {user.name}</p>
<p>Email: {user.email}</p>
<img src={user.profilePictureUrl} alt="Profile" />
</div>
);
};
export default UserProfile;
위 사레의 UserProfile
컴포넌트는 사용자의 프로필 정보뿐 아니라 프로필 이미지까지 몽땅 보여주고 있습니다. 단일 작업만 담당하는 것이 아니기에 이는 SRP를 위반한 컴포넌트라고 볼 수 있겠습니다.
🙃 역주
단일 작업이라는 기준은 개인이 정하기 나름이에요. 누군가는 이름 렌더링과 이메일 렌더링 작업도 별개의 작업이라고 구분할 수 있어요.
만약 사용자 프로필 정보 혹은 프로필 이미지를 렌더링하는 방식에 변화가 생긴다면 이 단일 컴포넌트를 수정해야 합니다. 하나의 기능을 수정하려다 큰 기능까지 수정하게 되는 꼴입니다. 단일 컴포넌트가 여러 기능을 수행한다면 서비스가 복잡해질수록 컴포넌트를 유지보수하기도 어려워지고, 컴포넌트를 해석하기도 어렵다는 점 항상 명심하세요.
이 나쁜(?) 컴포넌트를 리팩토링해보겠습니다.
const UserProfile = ({ user }) => {
return (
<div>
<h1>User Profile</h1>
<p>Name: {user.name}</p>
<p>Email: {user.email}</p>
</div>
);
};
const ProfilePicture = ({ user }) => {
return (
<div>
<h1>Profile Picture</h1>
<img src={user.profilePictureUrl} alt="Profile" />
</div>
);
};
const App = () => {
const user = {
name: "John Doe",
email: "johndoe@example.com",
profilePictureUrl: "https://example.com/profile.jpg",
};
return (
<div>
<UserProfile user={user} />
<ProfilePicture user={user} />
</div>
);
};
export default App;
UserProfile
과 ProfilePicture
이라는 두 컴포넌트로 분리하였습니다. UserProfile
컴포넌트는 사용자 프로필 정보(이름과 이메일)를 렌더링하는 단일 기능을 담당하고, ProfilePicture
은 사용자 이미지를 렌더링하는 단일 기능을 담당합니다. 각 컴포넌트가 독립적인 하나의 기능을 담당하게 됐네요.
SRP 원칙을 준수하면 컴포넌트들을 쉽게 수정하고 관리할 수 있습니다. 예를 들어볼까요? 사용자 프로필 이미지를 수정하고 싶은 경우 ProfilePicture
컴포넌트만 수정하면 됩니다. UserProfile
은 건들지도 않는 거죠. 이처럼 컴포넌트의 관심사를 분리하는 것은 코드의 구성, 유지보수성, 재사용성을 모두 개선할 수 있습니다.
“software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.” — Wikipedia.
Open-Closed 원칙은 확장은 쉽지만 수정은 어려운 컴포넌트의 구조를 강조합니다. 여기서 확장은 새로운 기능과 동작을 추가하는 경우를 의미하며, 이 때 기존 코드는 수정되지 않아야 함을 뜻합니다. OCP를 준수하면 적응성이 좋고(기능을 추가하는 등의 변화에도 쉽게 대처할 수 있고), 독립적이면 쉽게 유지 보수할 수 있는 좋은 코드를 짤 수 있습니다.
// Button.js
import React from 'react';
const Button = ({ onClick, children, icon }) => {
if (icon) {
return (
<button onClick={onClick}>
<span className="icon">{icon}</span>
{children}
</button>
);
} else {
return (
<button onClick={onClick}>{children}</button>
);
}
};
export default Button;
기존 Button
컴포넌트에 icon
이 추가되어 개발자가 위와 같이 코드를 변경하였습니다. icon
이 props으로 전달된 경우 아이콘이 달린 버튼을 그리고, icon
을 넘기지 않은 경우 아무것도 없는 버튼을 렌더링합니다. 평범해 보이는 이 사례는 왜 좋지 않을까요?
이 코드는 OCP를 위반한 코드입니다. 기존에 존재하는 Button
컴포넌트를 확장하는 대신 icon
을 붙여 컴포넌트를 수정하였기 때문입니다. 수정하였기 때문에 컴포넌트를 다루기가 어려워졌고 관리하기 쉽지 않아졌습니다.
나중에 더 다양한 종류의 버튼이 생긴다면 또다시 Button
컴포넌트 자체를 수정해야 하겠네요. 이를 테면 icon
이 버튼 우측에 달려있는 버튼이 있다고 생각해보죠. 또다시 props와 분기문을 사용해야 할까요? 이는 기존 코드를 수정하는 일은 없어야 한다는 OCP에 대한 명백한 위반입니다.
// Button.js
import React from 'react';
const Button = ({ onClick, children }) => (
<button onClick={onClick}>{children}</button>
);
export default Button;
// IconButton.js
import React from 'react';
import Button from './Button';
const IconButton = ({ onClick, children, icon }) => (
<Button onClick={onClick}>
<span className="icon">{icon}</span>
{children}
</Button>
);
export default IconButton;
이렇게 수정해보는 건 어떨까요? Button
컴포넌트는 기본 버튼을 렌더링하는 데 사용하고, IconButton
컴포넌트를 추가로 생성하는 것이죠. 이때 IconButton
은 Button
컴포넌트를 확장한 컴포넌트입니다. icon
prop을 추가로 받아 문구에 아이콘을 추가해줍니다.
이제 OCP를 준수할 수 있게 되었습니다. Button
컴포넌트의 코드를 수정하지 않고 기능을 안전하게 확장한 컴포넌트를 추가로 생성하였습니다. 기존 버튼에 영향을 주지 않고 새로운 버튼 유형을 추가한 것입니다.
OCP를 준수함으로써 코드가 더 독립적이게 되었습니다. 관리하기도 더 쉬워졌고, 앞으로의 변경에도 더 대응하기 쉬워졌습니다. 기존 코드를 수정하지 않았으니까요.
“Subtype objects should be substitutable for supertype objects” — Wikipedia.
Liskov Substitution 원칙은 상위의 클래스가 언제든지 하위의 클래스를 대체할 수 있어야 한다는 것을 의미합니다. 즉, 하위 클래스를 사용한 자리에는 언제나 상위 클래스를 사용할 수 있어야 합니다.
리액트의 상위, 하위 클래스는 무엇을 의미할 수 있을까요? Button
컴포넌트를 하나 그려봅시다. 이를 확장한 PrimaryButton
과 SecondaryButton
은 Button
컴포넌트의 하위 컴포넌트라고 할 수 있습니다(리턴 형태는 동일하나 버튼의 모양이 달라질 수 있겠네요).
LSP에 따르면 Button
이 들어갈 수 있는 자리라면 그 하위 컴포넌트인 PrimaryButton
과 SecondaryButton
이 들어갈 수 있어야 합니다. 아무런 문제도 일으키면 안되구요.
class Button extends React.Component {
render() {
return (
<button>{this.props.text}</button>
);
}
}
class PrimaryButton extends Button {
render() {
return (
<button style={{ backgroundColor: 'blue', color: 'white' }}>{this.props.text}</button>
);
}
}
class SecondaryButton extends Button {
render() {
return (
<a href="#" style={{ backgroundColor: 'gray', color: 'black' }}>{this.props.text}</a>
);
}
}
// Usage of the components
function App() {
return (
<div>
<Button text="Regular Button" />
<PrimaryButton text="Primary Button" />
<SecondaryButton text="Secondary Button" />
</div>
);
}
클래스 컴포넌트를 예시로 들어보겠습니다. 여기서 SecondaryButton
은 LSP를 위반하였습니다. button 인스턴스를 렌더링하는 대신 a 인스턴스를 렌더링하였기 때문입니다. 하위 컴포넌트가 상위 컴포넌트를 대체할 수 없게 되었습니다. 버튼이 아니니까요.
App
컴포넌트에서 동작하는 SecondaryButton
컴포넌트는 Button
이나 PrimaryButton
와 유사하게 동작한다고 예상하기 어렵습니다. 하위 컴포넌트가 상위 컴포넌트에 상응하는 것을 제공하지 않기 때문입니다. 이는 명백한 LSP의 위반입니다.
하위 클래스는 상위 클래스의 기능을 보장할 수 있어야 합니다!
class Button extends React.Component {
render() {
return (
<button>{this.props.text}</button>
);
}
}
class PrimaryButton extends Button {
render() {
return (
<button style={{ backgroundColor: 'blue', color: 'white' }}>{this.props.text}</button>
);
}
}
class SecondaryButton extends Button {
render() {
return (
<button style={{ backgroundColor: 'gray', color: 'black' }}>{this.props.text}</button>
);
}
}
// Usage of the components
function App() {
return (
<div>
<Button text="Regular Button" />
<PrimaryButton text="Primary Button" />
<SecondaryButton text="Secondary Button" />
</div>
);
}
위의 좋은 예시에서 PrimaryButton
과 SecondaryButton
은 완벽하게 Button
컴포넌트를 상속하고 있습니다. render()
메서드도 상속했고, 오버라이딩을 통해 그들만의 렌더링 규칙을 제공합니다.
PrimaryButton
과 SecondaryButton
가 Button
의 하위 클래스이기 때문에 우리는 자유롭게 Button
대신 PrimaryButton
혹은 SecondaryButton
을 사용할 수 있습니다. 문제를 일으키지 않고 완벽하게 상위 클래스를 대체하였기 때문에 LSP를 준수한 사례라고 할 수 있겠습니다.
클래스 컴포넌트를 사용하였지만 LSP 규칙은 함수형 컴포넌트나 훅에서도 동일합니다: 파생된 컴포넌트는 항상 상위 컴포넌트를 대체할 수 있어야 합니다.
“No code should be forced to depend on methods it does not use.” — Wikipedia.
Interface Segregation 원칙은 클라이언트의 요구 사항에 딱 맞는 인터페이스가 제공되어야 하며, 이때 인터페이스는 너무 요구 사항 이외의 불필요한 기능을 제공을 제공하지 않을 것을 강조합니다. 실사례를 통해 확인해 봅시다.
// Interface for user management
interface UserManagement {
addUser: (user: User) => void;
displayUser: (userId: number) => void;
}
// UserProfile component implementing UserManagement interface
const UserProfile: React.FC<UserManagement> = ({ addUser, displayUser }) => {
// ...
return (
// ...
);
};
// Usage of the component
const App: React.FC = () => {
const userManager: UserManagement = {
addUser: (user) => {
// Add user logic
},
displayUser: (userId) => {
// Display user logic
},
};
return (
<div>
<UserProfile {...userManager} />
</div>
);
};
UserManagement
인터페이스는 addUser
과 displayUser
메서드를 제공하고 있습니다. UserProfile
컴포넌트는 이 인터페이스를 사용하고 있네요.
앗, UserProfile
컴포넌트를 다시 확인해볼까요. 메서드는 두 개 전달받았지만 실제로는 displayUser
메서드만 사용하고 있습니다. 사용자 프로필을 화면에 렌더링하는 데에는 displayUser
메서드만 필요합니다.
이는 ISP를 위반한 것입니다. UserProfile
컴포넌트는 정작 본인이 사용하지도 않는 addUser
를 강제적으로 전달받아야 하네요. addUser
메서드에 불필요한 의존성이 생겨버린 것입니다. 불필요한 것들이 생겨버리면서 코드는 더 복잡해지고, 사용하지 않아야 할 메서드(여기서는 addUser
)가 호출되는 일이 있을 수도 있겠네요.
// Interface for displaying user information
interface DisplayUser {
name: string;
email: string;
}
// UserProfile component implementing DisplayUser interface
const UserProfile: React.FC<DisplayUser> = ({ name, email }) => {
return (
<div>
<h2>User Profile</h2>
<p>Name: {name}</p>
<p>Email: {email}</p>
</div>
);
};
// Usage of the component
const App: React.FC = () => {
const user = {
name: 'John Doe',
email: 'johndoe@example.com',
};
return (
<div>
<UserProfile {...user} />
</div>
);
};
DisplayUser
인터페이스는 사용자 정보를 화면에 렌더링하는 데 꼭 필요한 정보(속성)만을 명시하고 있습니다. UserProfile
컴포넌트는 name
과 email
속성을 전달받아 완벽한 화면을 그리고 있습니다. App
컴포넌트에서 UserProfile
컴포넌트에 꼭 맞는 DisplayUser
인터페이스의 속성들을 넘겨주고 있네요.
속성들을 인터페이스로 분리함으로써, UserProfile
컴포넌트는 DisplayUser
인터페이스에만 의존하고 있습니다. 사용자 프로필을 렌더링하는 데 꼭 필요한 정보만을 전달받고 있는 것이죠. 이렇게 함으로써 코드는 불필요한 의존성 없이 재사용될 수 있는 독립적인 구조를 가질 수 있게 되었습니다.
ISP를 통해 인터페이스를 간결하고 관련성있게 유지하고, 유연하게 관리할 수 있는 코드를 챙겨갈 수 있습니다.
“One entity should depend upon abstractions, not concretions” — Wikipedia.
Dependency Inversion 원칙은 더 고차적인 컴포넌트가 하위의 컴포넌트에 의존하지 않아야 함을 강조합니다. 이렇게 함으로써 컴포넌트 간 결합도를 낮출 수 있고, 코드를 더 용이하게 관리할 수 있습니다. 이번에도 사례를 통해 그 의미를 확인해 봅시다.
// High-level component
const App = () => {
const [data, setData] = useState([]);
useEffect(() => {
// Violation: App depends directly on a specific low-level implementation
fetchDataFromDatabase().then((result) => {
setData(result);
});
}, []);
const fetchDataFromDatabase = () => {
// Simulated fetching of data from a specific database
return Promise.resolve(['item1', 'item2', 'item3']);
};
return (
<div>
<h1>Data:</h1>
<ul>
{data.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
);
};
export default App;
App
컴포넌트는 데이터베이스에서 데이터를 가져오기 위해 본인이 가지고 있는 fetchDataFromDatabases
에 직접적으로 의존하고 있습니다.
이는 DIP를 위반한 것입니다. 상위의 컴포넌트(App
)이 하위의 구성요소(fetchDataFromDatabases
)에 단단하게 결합되어 있기 때문입니다. 단단하게 결합되어 있다는 것은 무엇을 의미할까요? 하위의 구성 요소를 다른 것으로 변경하고 싶다면 상위 컴포넌트를 수정해야 합니다. App
컴포넌트를 직접 건들여야 하는 것이죠.
DIP를 준수하려면 상위 컴포넌트는 하위 컴포넌트에 직접적으로 의존하지 않고, 하위 컴포넌트를 추상화한 것이나 인터페이스화한 것에 의존해야 합니다. 그렇게 함으로써 상위 컴포넌트는 하위 컴포넌트와 느슨하게 결합될 수 있고, 관리가 용이해집니다. 하위 컴포넌트를 다른 것으로 쉽게 교체할 수도 있고, 하위 컴포넌트를 수정하고 싶으면 상위 컴포넌트의 수정 없이 하위 컴포넌트 코드만 들여다 보면 됩니다.
// Abstraction: Interface or contract
const DataService = () => {
return {
fetchData: () => {}
};
};
// High-level component
const App = ({ dataService }) => {
const [data, setData] = useState([]);
useEffect(() => {
dataService.fetchData().then((result) => {
setData(result);
});
}, [dataService]);
return (
<div>
<h1>Data:</h1>
<ul>
{data.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
);
};
// Dependency: Low-level component
const DatabaseService = () => {
const fetchData = () => {
// Simulated fetching of data from a database
return Promise.resolve(['item1', 'item2', 'item3']);
};
return {
fetchData
};
};
// Dependency Injection: Providing the implementation
const AppContainer = () => {
const dataService = DataService(); // Creating the abstraction
const databaseService = DatabaseService(); // Creating the low-level dependency
// Injecting the dependency
return <App dataService={dataService} />;
};
export default AppContainer;
DataService
라는 추상화된 컴포넌트를 생성하였습니다. App
컴포넌트는 구체적인 것에 의존하지 않고 DataService
에 의존하고 있습니다. 즉, DataService.fetchData()
를 호출함으로써 내부가 어떻게 구현되어 있든 넘어오는 데이터를 전달받아 화면을 렌더링합니다.
AppContainer
가 구현을 담당합니다. 추상적인 DataService
를 선언하고 이를 구체적으로 구현한 DataBaseService
를 생성합니다. 그리고 이 구체적인 기능 요소를 App
에 넘겨줍니다(주입합니다). App
컴포넌트 입장에서는 DataService
형태이면 넘겨받을 수 있으므로 어떤 구현체라도 DataService
형태라면 받아 처리할 수 있습니다.
APP
컴포넌트는 구체적인 DataBaseService
에 의존하지 않고 추상적인 DataService
에 의존하는 것이 핵심입니다. 다른 내부 동작을 가지고 있는 구현체로 쉽게 교체할 수 있고, 테스트도 용이해집니다.
다섯 가지 SOLID 원칙은 서비스의 개발 및 설계 지침으로서 함의점을 가집니다. SOLID 원칙을 준수하는 개발자들은 더 잘 설계된, 유지 보수가 쉬운, 확장 가능한 코드를 만들 수 있습니다.
이 포스팅이 의미있는 인사이트를 전달했길 바라며, 여러분이 만들고 있는 서비스를 다시 돌아보는 계기가 되었기를 진심으로 바랍니다. 😊
출처
위키피디아