[초월번역] React에 적용하는 SOLID 원칙

Ethan Yu·2023년 9월 12일
1

초월번역

목록 보기
2/2
post-thumbnail

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

👉원본보기(Mastering React JS SOLID Principles by Kristiyan Velkov)


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 원칙을 준수하면 전문적이고, 독립적이고, 가독성있고, 심지어는 쉽게 수정할 수 있는 컴포넌트를 만들 수 있습니다!

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

🙊 나쁜 사례

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;

UserProfileProfilePicture이라는 두 컴포넌트로 분리하였습니다. 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를 준수하면 적응성이 좋고(기능을 추가하는 등의 변화에도 쉽게 대처할 수 있고), 독립적이면 쉽게 유지 보수할 수 있는 좋은 코드를 짤 수 있습니다.

🙊 나쁜 사례

// 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 컴포넌트를 추가로 생성하는 것이죠. 이때 IconButtonButton 컴포넌트를 확장한 컴포넌트입니다. icon prop을 추가로 받아 문구에 아이콘을 추가해줍니다.

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

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

Liskov Substitution Principle

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

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

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

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

🙊 나쁜 사례

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

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

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

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

Interface Segregation Principle

“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 인터페이스는 addUserdisplayUser 메서드를 제공하고 있습니다. 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 컴포넌트는 nameemail 속성을 전달받아 완벽한 화면을 그리고 있습니다. App 컴포넌트에서 UserProfile 컴포넌트에 꼭 맞는 DisplayUser 인터페이스의 속성들을 넘겨주고 있네요.

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

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

Dependency Inversion Principle

“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 원칙을 준수하는 개발자들은 더 잘 설계된, 유지 보수가 쉬운, 확장 가능한 코드를 만들 수 있습니다.

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


출처
위키피디아

profile
🧐 사용자와 개발자를 모두 배려하고 싶은 개발자. 백엔드부터 임베디드까지 다양하게 개발하다가 지금은 🎨 프런트엔드에 자리잡았어요.

0개의 댓글

관련 채용 정보