---
title: "[초월번역] React에 적용하는 SOLID 원칙"
description: "[초월번역주의] React와 SOLID 원칙에 관한 짧은 글을 읽고 우리말로 옮겨보았습니다."
date: 2023-09-12
tags: [react, javascript]
sections: [technical]
draft: false
---

React와 SOLID 원칙에 관한 짧은 글을 읽고 우리말로 옮겨보았습니다. 글의 전개를 역자의 입맛에 맞춰 재구성하였습니다(🤬초월번역주의).
***📙 예상 독자*** : _SOLID 원칙이 무엇인지 알고 싶은 모든 React 개발자_

[👉원본보기(Mastering React JS SOLID Principles by Kristiyan Velkov)](https://blog.stackademic.com/react-js-mastering-react-js-solid-principles-dfb48d03e565)

---

***SOLID 원칙***은 코드가 느슨하게 결합되어 유지 보수와 확장이 쉽도록 만들어주는 다섯 가지 설계 원칙입니다. 다섯 가지 SOLID 원칙은 아래와 같습니다.

1. [S] - **Single-responsibility principle**
2. [O] - **Open-Closed principle**
3. [L] - **Liskov substitution principle**
4. [I] - **Interface segregation principle**
5. [D] - **Dependency inversion principle**

> 🙃 ***역주***
SOLID 원칙 각각을 우리말로 옮기면 그 의미가 잘 살아나지 않아 이 글에서는 ***원문 표기 그대로 사용***하도록 하겠습니다. 

많은 글들이 SOLID 원칙을 설명하고 있으나 React 프로젝트 위에서 이 다섯 꼭지를 적용해보는 글은 많지 않습니다. 이 글에서는 React 컴포넌트 설계 과정에서 SOLID 원칙이 어떻게 적용될 수 있는지 사례를 들어 설명해보도록 하겠습니다.

## Single-responsiblity Principle

> “A module should be responsible to one, and only one, actor.” — Wikipedia.

Single-responsiblity 원칙을 준수하면 컴포넌트가 명확한 단일 기능을 담당하도록 코드를 생성할 수 있습니다. 

컴포넌트는 하나의 구체적인 기능을 담당하여야 하며, 관련 없는 작업은 수행하지 않아야 합니다. SR 원칙을 준수하면 전문적이고, 독립적이고, 가독성있고, 심지어는 쉽게 수정할 수 있는 컴포넌트를 만들 수 있습니다!

실제 사례를 보도록 합시다.

### 🙊 나쁜 사례

```javascript
const UserProfile = ({ user }) => {
 return (
 
 User Profile 
 Name: {user.name} 
 Email: {user.email} 
 
 
 );
};
export default UserProfile;
```

위 사레의 `UserProfile` 컴포넌트는 사용자의 프로필 정보뿐 아니라 프로필 이미지까지 몽땅 보여주고 있습니다. 단일 작업만 담당하는 것이 아니기에 이는 SRP를 위반한 컴포넌트라고 볼 수 있겠습니다.

> 🙃 ***역주***
단일 작업이라는 기준은 개인이 정하기 나름이에요. 누군가는 이름 렌더링과 이메일 렌더링 작업도 별개의 작업이라고 구분할 수 있어요.

만약 사용자 프로필 정보 혹은 프로필 이미지를 렌더링하는 방식에 변화가 생긴다면 이 단일 컴포넌트를 수정해야 합니다. 하나의 기능을 수정하려다 큰 기능까지 수정하게 되는 꼴입니다. 단일 컴포넌트가 여러 기능을 수행한다면 서비스가 복잡해질수록 컴포넌트를 유지보수하기도 어려워지고, 컴포넌트를 해석하기도 어렵다는 점 항상 명심하세요.

이 나쁜(?) 컴포넌트를 리팩토링해보겠습니다.

### 🙉 좋은 사례

```javascript
const UserProfile = ({ user }) => {
 return (
 
 User Profile 
 Name: {user.name} 
 Email: {user.email} 
 
 );
};

const ProfilePicture = ({ user }) => {
 return (
 
 Profile Picture 
 
 
 );
};

const App = () => {
 const user = {
 name: "John Doe",
 email: "johndoe@example.com",
 profilePictureUrl: "https://example.com/profile.jpg",
 };

 return (
 
 
 
 
 );
};

export default App;
```

`UserProfile`과 `ProfilePicture`이라는 두 컴포넌트로 분리하였습니다. `UserProfile` 컴포넌트는 사용자 프로필 정보(이름과 이메일)를 렌더링하는 단일 기능을 담당하고, `ProfilePicture`은 사용자 이미지를 렌더링하는 단일 기능을 담당합니다. 각 컴포넌트가 독립적인 하나의 기능을 담당하게 됐네요.

SRP 원칙을 준수하면 컴포넌트들을 쉽게 수정하고 관리할 수 있습니다. 예를 들어볼까요? 사용자 프로필 이미지를 수정하고 싶은 경우 `ProfilePicture` 컴포넌트만 수정하면 됩니다. `UserProfile`은 건들지도 않는 거죠. 이처럼 컴포넌트의 관심사를 분리하는 것은 코드의 구성, 유지보수성, 재사용성을 모두 개선할 수 있습니다.


## Open-Closed Principle

> “software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.” — Wikipedia.

Open-Closed 원칙은 확장은 쉽지만 수정은 어려운 컴포넌트의 구조를 강조합니다. 여기서 확장은 새로운 기능과 동작을 추가하는 경우를 의미하며, 이 때 기존 코드는 수정되지 않아야 함을 뜻합니다. OCP를 준수하면 적응성이 좋고(기능을 추가하는 등의 변화에도 쉽게 대처할 수 있고), 독립적이면 쉽게 유지 보수할 수 있는 좋은 코드를 짤 수 있습니다.

### 🙊 나쁜 사례

```javascript
// Button.js
import React from 'react';

const Button = ({ onClick, children, icon }) => {
 if (icon) {
 return (
 
 {icon} 
 {children}
 
 );
 } else {
 return (
 {children} 
 );
 }
};

export default Button;
```

기존 `Button` 컴포넌트에 `icon` 이 추가되어 개발자가 위와 같이 코드를 변경하였습니다. `icon`이 props으로 전달된 경우 아이콘이 달린 버튼을 그리고, `icon`을 넘기지 않은 경우 아무것도 없는 버튼을 렌더링합니다. 평범해 보이는 이 사례는 왜 좋지 않을까요? 

이 코드는 OCP를 위반한 코드입니다. 기존에 존재하는 `Button` 컴포넌트를 확장하는 대신 `icon`을 붙여 컴포넌트를 수정하였기 때문입니다. 수정하였기 때문에 컴포넌트를 다루기가 어려워졌고 관리하기 쉽지 않아졌습니다. 

나중에 더 다양한 종류의 버튼이 생긴다면 또다시 `Button` 컴포넌트 자체를 수정해야 하겠네요. 이를 테면 `icon`이 버튼 우측에 달려있는 버튼이 있다고 생각해보죠. 또다시 props와 분기문을 사용해야 할까요? 이는 기존 코드를 수정하는 일은 없어야 한다는 OCP에 대한 명백한 위반입니다. 

### 🙉 좋은 사례

```javascript
// Button.js
import React from 'react';

const Button = ({ onClick, children }) => (
 {children} 
);

export default Button;
```
```javascript
// IconButton.js
import React from 'react';
import Button from './Button';

const IconButton = ({ onClick, children, icon }) => (
 
 {icon} 
 {children}
 
);

export default IconButton;
```

이렇게 수정해보는 건 어떨까요? `Button` 컴포넌트는 기본 버튼을 렌더링하는 데 사용하고, `IconButton` 컴포넌트를 추가로 생성하는 것이죠. 이때 `IconButton`은 `Button` 컴포넌트를 확장한 컴포넌트입니다. `icon` prop을 추가로 받아 문구에 아이콘을 추가해줍니다. 

이제 OCP를 준수할 수 있게 되었습니다. `Button` 컴포넌트의 코드를 수정하지 않고 기능을 안전하게 확장한 컴포넌트를 추가로 생성하였습니다. 기존 버튼에 영향을 주지 않고 새로운 버튼 유형을 추가한 것입니다.

OCP를 준수함으로써 코드가 더 독립적이게 되었습니다. 관리하기도 더 쉬워졌고, 앞으로의 변경에도 더 대응하기 쉬워졌습니다. 기존 코드를 수정하지 않았으니까요.


## Liskov Substitution Principle

> “Subtype objects should be substitutable for supertype objects” — Wikipedia.

Liskov Substitution 원칙은 상위의 클래스가 언제든지 하위의 클래스를 대체할 수 있어야 한다는 것을 의미합니다. 즉, 하위 클래스를 사용한 자리에는 언제나 상위 클래스를 사용할 수 있어야 합니다.

리액트의 상위, 하위 클래스는 무엇을 의미할 수 있을까요? `Button` 컴포넌트를 하나 그려봅시다. 이를 확장한 `PrimaryButton`과 `SecondaryButton`은 `Button` 컴포넌트의 하위 컴포넌트라고 할 수 있습니다(리턴 형태는 동일하나 버튼의 모양이 달라질 수 있겠네요).

LSP에 따르면 `Button`이 들어갈 수 있는 자리라면 그 하위 컴포넌트인 `PrimaryButton`과 `SecondaryButton`이 들어갈 수 있어야 합니다. 아무런 문제도 일으키면 안되구요.

### 🙊 나쁜 사례

```javascript
class Button extends React.Component {
 render() {
 return (
 {this.props.text} 
 );
 }
}

class PrimaryButton extends Button {
 render() {
 return (
 {this.props.text} 
 );
 }
}

class SecondaryButton extends Button {
 render() {
 return (
 {this.props.text} 
 );
 }
}

// Usage of the components
function App() {
 return (
 
 
 
 
 
 );
}
```

클래스 컴포넌트를 예시로 들어보겠습니다. 여기서 `SecondaryButton`은 LSP를 위반하였습니다. button 인스턴스를 렌더링하는 대신 a 인스턴스를 렌더링하였기 때문입니다. 하위 컴포넌트가 상위 컴포넌트를 대체할 수 없게 되었습니다. 버튼이 아니니까요.
 
`App` 컴포넌트에서 동작하는 `SecondaryButton` 컴포넌트는 `Button`이나 `PrimaryButton`와 유사하게 동작한다고 예상하기 어렵습니다. 하위 컴포넌트가 상위 컴포넌트에 상응하는 것을 제공하지 않기 때문입니다. 이는 명백한 LSP의 위반입니다.

하위 클래스는 상위 클래스의 기능을 보장할 수 있어야 합니다!


### 🙉 좋은 사례

```javascript
class Button extends React.Component {
 render() {
 return (
 {this.props.text} 
 );
 }
}

class PrimaryButton extends Button {
 render() {
 return (
 {this.props.text} 
 );
 }
}

class SecondaryButton extends Button {
 render() {
 return (
 {this.props.text} 
 );
 }
}

// Usage of the components
function App() {
 return (
 
 
 
 
 
 );
}
```

위의 좋은 예시에서 `PrimaryButton`과 `SecondaryButton`은 완벽하게 `Button` 컴포넌트를 상속하고 있습니다. `render()` 메서드도 상속했고, 오버라이딩을 통해 그들만의 렌더링 규칙을 제공합니다.

`PrimaryButton`과 `SecondaryButton`가 `Button`의 하위 클래스이기 때문에 우리는 자유롭게 `Button`대신 `PrimaryButton` 혹은 `SecondaryButton`을 사용할 수 있습니다. 문제를 일으키지 않고 완벽하게 상위 클래스를 대체하였기 때문에 LSP를 준수한 사례라고 할 수 있겠습니다. 

클래스 컴포넌트를 사용하였지만 LSP 규칙은 함수형 컴포넌트나 훅에서도 동일합니다: 파생된 컴포넌트는 항상 상위 컴포넌트를 대체할 수 있어야 합니다.

## Interface Segregation Principle

> “No code should be forced to depend on methods it does not use.” — Wikipedia.

Interface Segregation 원칙은 클라이언트의 요구 사항에 딱 맞는 인터페이스가 제공되어야 하며, 이때 인터페이스는 너무 요구 사항 이외의 불필요한 기능을 제공을 제공하지 않을 것을 강조합니다. 실사례를 통해 확인해 봅시다.

### 🙊 나쁜 사례

```javascript
// Interface for user management
interface UserManagement {
 addUser: (user: User) => void;
 displayUser: (userId: number) => void;
}

// UserProfile component implementing UserManagement interface
const UserProfile: React.FC = ({ 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 (
 
 
 
 );
};
```

`UserManagement` 인터페이스는 `addUser`과 `displayUser` 메서드를 제공하고 있습니다. `UserProfile` 컴포넌트는 이 인터페이스를 사용하고 있네요.

앗, `UserProfile` 컴포넌트를 다시 확인해볼까요. 메서드는 두 개 전달받았지만 실제로는 `displayUser` 메서드만 사용하고 있습니다. 사용자 프로필을 화면에 렌더링하는 데에는 `displayUser` 메서드만 필요합니다.

이는 ISP를 위반한 것입니다. `UserProfile` 컴포넌트는 정작 본인이 사용하지도 않는 `addUser`를 강제적으로 전달받아야 하네요. `addUser` 메서드에 불필요한 의존성이 생겨버린 것입니다. 불필요한 것들이 생겨버리면서 코드는 더 복잡해지고, 사용하지 않아야 할 메서드(여기서는 `addUser`)가 호출되는 일이 있을 수도 있겠네요. 

### 🙉 좋은 사례

```javascript
// Interface for displaying user information
interface DisplayUser {
 name: string;
 email: string;
}

// UserProfile component implementing DisplayUser interface
const UserProfile: React.FC = ({ name, email }) => {
 return (
 
 User Profile 
 Name: {name} 
 Email: {email} 
 
 );
};

// Usage of the component
const App: React.FC = () => {
 const user = {
 name: 'John Doe',
 email: 'johndoe@example.com',
 };

 return (
 
 
 
 );
};
```

`DisplayUser` 인터페이스는 사용자 정보를 화면에 렌더링하는 데 꼭 필요한 정보(속성)만을 명시하고 있습니다. `UserProfile` 컴포넌트는 `name`과 `email` 속성을 전달받아 완벽한 화면을 그리고 있습니다. `App` 컴포넌트에서 `UserProfile` 컴포넌트에 꼭 맞는 `DisplayUser` 인터페이스의 속성들을 넘겨주고 있네요.

속성들을 인터페이스로 분리함으로써, `UserProfile` 컴포넌트는 `DisplayUser` 인터페이스에만 의존하고 있습니다. 사용자 프로필을 렌더링하는 데 꼭 필요한 정보만을 전달받고 있는 것이죠. 이렇게 함으로써 코드는 불필요한 의존성 없이 재사용될 수 있는 독립적인 구조를 가질 수 있게 되었습니다.

ISP를 통해 인터페이스를 간결하고 관련성있게 유지하고, 유연하게 관리할 수 있는 코드를 챙겨갈 수 있습니다.


## Dependency Inversion Principle

> “One entity should depend upon abstractions, not concretions” — Wikipedia.

Dependency Inversion 원칙은 더 고차적인 컴포넌트가 하위의 컴포넌트에 의존하지 않아야 함을 강조합니다. 이렇게 함으로써 컴포넌트 간 결합도를 낮출 수 있고, 코드를 더 용이하게 관리할 수 있습니다. 이번에도 사례를 통해 그 의미를 확인해 봅시다.

### 🙊 나쁜 사례

```javascript
// 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 (
 
 Data: 
 
 {data.map((item, index) => (
 {item} 
 ))}
 
 
 );
};

export default App;
```
`App` 컴포넌트는 데이터베이스에서 데이터를 가져오기 위해 본인이 가지고 있는 `fetchDataFromDatabases`에 직접적으로 의존하고 있습니다. 

이는 DIP를 위반한 것입니다. 상위의 컴포넌트(`App`)이 하위의 구성요소(`fetchDataFromDatabases`)에 단단하게 결합되어 있기 때문입니다. 단단하게 결합되어 있다는 것은 무엇을 의미할까요? 하위의 구성 요소를 다른 것으로 변경하고 싶다면 상위 컴포넌트를 수정해야 합니다. `App` 컴포넌트를 직접 건들여야 하는 것이죠.

DIP를 준수하려면 상위 컴포넌트는 하위 컴포넌트에 직접적으로 의존하지 않고, 하위 컴포넌트를 추상화한 것이나 인터페이스화한 것에 의존해야 합니다. 그렇게 함으로써 상위 컴포넌트는 하위 컴포넌트와 느슨하게 결합될 수 있고, 관리가 용이해집니다. 하위 컴포넌트를 다른 것으로 쉽게 교체할 수도 있고, 하위 컴포넌트를 수정하고 싶으면 상위 컴포넌트의 수정 없이 하위 컴포넌트 코드만 들여다 보면 됩니다. 

### 🙉 좋은 사례

```javascript
// 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 (
 
 Data: 
 
 {data.map((item, index) => (
 {item} 
 ))}
 
 
 );
};

// 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;
};

export default AppContainer;
```

`DataService`라는 추상화된 컴포넌트를 생성하였습니다. `App` 컴포넌트는 구체적인 것에 의존하지 않고 `DataService`에 의존하고 있습니다. 즉, `DataService.fetchData()`를 호출함으로써 내부가 어떻게 구현되어 있든 넘어오는 데이터를 전달받아 화면을 렌더링합니다.

`AppContainer`가 구현을 담당합니다. 추상적인 `DataService`를 선언하고 이를 구체적으로 구현한 `DataBaseService`를 생성합니다. 그리고 이 구체적인 기능 요소를 `App`에 넘겨줍니다(주입합니다). `App` 컴포넌트 입장에서는 `DataService` 형태이면 넘겨받을 수 있으므로 어떤 구현체라도 `DataService` 형태라면 받아 처리할 수 있습니다.

`APP` 컴포넌트는 구체적인 `DataBaseService`에 의존하지 않고 추상적인 `DataService`에 의존하는 것이 핵심입니다. 다른 내부 동작을 가지고 있는 구현체로 쉽게 교체할 수 있고, 테스트도 용이해집니다.

## 마무리 

다섯 가지 SOLID 원칙은 서비스의 개발 및 설계 지침으로서 함의점을 가집니다. SOLID 원칙을 준수하는 개발자들은 더 잘 설계된, 유지 보수가 쉬운, 확장 가능한 코드를 만들 수 있습니다.

이 포스팅이 의미있는 인사이트를 전달했길 바라며, 여러분이 만들고 있는 서비스를 다시 돌아보는 계기가 되었기를 진심으로 바랍니다. 😊


---

출처
[위키피디아](https://www.wikipedia.org/)
