본문 바로가기
Front-End/React

[React] 리엑트에 SOLID 원칙 적용시키기

by SeanK 2022. 10. 31.

안녕하세요, 개발자 Sean입니다. 

 

오늘은 베타 버전을 끝마치고 이제는 뭘 해야 할까... 뒹굴대다가

 

본격적으로 리펙토링을 가지는 시간을 좀 가져볼까 합니다. 

 

그래서 리펙토링을 어떤 원칙을 가지고 접근하면 좋을까 고민하다 아래와 같은 좋은 글이 있어 옮겨봅니다. 

 

Applying SOLID principles in React

 

소프트웨어 산업이 성장하며 개발자들이 수많은 우여곡절을 겪음에 따라 모범사례와 우수한 소프트웨어 설계 원칙이 나타나고 미래에 똑같은 실수를 방지하기 위해 개념화되었습니다. 특히 객체 지향 프로그래밍의 세계에서 이러한 모범사례가 수많이 개발되었고 SOLID는 의심의 여지없이 더욱 영향력을 키우게 되었습니다. 
SOLID는 각각의 글자가 다섯개의 설계 원칙을 의미하는 축약어 입니다:
  • Single responsibility principle (SRP)
  • Open-closed principle (OCP)
  • Liskov substitution principle (LSP)
  • Interface segregation principle (ISP)
  • Dependuncy inversion principle (DIP)

이번 포스팅에서는 각각의 원칙의 중요성에 대해 말하고 리엑트 어플에서 SOLID를 어떻게 적용할 수 있을지 살펴보겠습니다. 

 

시작에 앞서 미리 알려드려야 할 것이 있습니다. SOLID 원칙은 객체 지향 프로그래밍 언어를 염두에 두고 구상되었습니다. 이 원칙은 클래스와 인터페이스에 중점을 두고 있는데 자바스크립트에서는 해당 개념이 없습니다. 자바스크립트에서 클래스라고 보이는 것들은 단순히 프로토타입 시스템을 이용해 시뮬레이션된 클래스에 불과합니다. 인터페이스는 언어 그 자체에 아예 포함되어 있지도 않죠(타입 스크립트에서는 지원을 다소 하지만 말입니다). 더욱이 모던 리엑트를 작성하는 방식은 객체 지향과 더욱 동떨어져 있습니다. 함수적 표현과 가깝죠. 

 

하지만 좋은 소식은 SOLID와 같은 소프트웨어 설계 원칙은 언어에 구애받지 않고 추상적이기 때문에 유연하게 해석하며 곁눈질을 하면 함수적 표현인 리엑트 코드에도 적용할 방법을 찾을 수 있습니다. 

 

그러면 이제 시작해보죠. 

 

Single responsibility principle (SRP)

원래의 의미는 "모든 클래스는 하나의 역할만을 가져야 한다"입니다. 즉 하나만 하라는 것이죠. 우리는 이것을 단순하게 "모든 함수/모듈/컴포넌트는 정확히 하나의 역할을 가진다"로 정의를 바꿔볼 수 있을 겁니다. 하지만 "하나의 역할"의 의미를 이해하기 위해선 컴포넌트를 두 개의 관점에서 바라봐야 합니다.

 

- 내부적: 내부에서 컴포넌트가 무엇을 하는가.

- 외부적: 다른 컴포넌트에서 이 컴포넌트가 어떻게 쓰이는가.

 

우선은 내부적으로 바라보도록 하겠습니다. 내부적으로 컴포넌트가 하나의 역할을 하도록 하기 위해선:

  • 너무 많은 역할을 하는 큰 컴포넌트를 작은 컴포넌트로 나눕니다. 
  • 주요 컴포넌트의 기능과 연관성이 없는 코드는 별도의 유틸리티 함수로 독립시킵니다. 
  • 연결된 기능을 커스텀 훅으로 캡슐화 합니다. 

그렇다면 위 원칙을 어떻게 적용시킬수 있는지 살펴보겠습니다. 활동 중인 유저 리스트를 보여주는 아래 예제를 통해 살펴보도록 하죠. 

const ActiveUsersList = () => {
  const [users, setUsers] = useState([])
  
  useEffect(() => {
    const loadUsers = async () => {  
      const response = await fetch('/some-api')
      const data = await response.json()
      setUsers(data)
    }

    loadUsers()
  }, [])
  
  const weekAgo = new Date();
  weekAgo.setDate(weekAgo.getDate() - 7);

  return (
    <ul>
      {users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo).map(user => 
        <li key={user.id}>
          <img src={user.avatarUrl} />
          <p>{user.fullName}</p>
          <small>{user.role}</small>
        </li>
      )}
    </ul>    
  )
}

위 컴포넌트는 상대적으로 짧긴해도, 이미 꽤 여러 역할을 하고 있습니다. 데이터를 가져오고 필터링하고 각각의 컴포넌트를 렌더링하고 있습니다. 이를 어떻게 잘게 나눌지 한번 보죠. 

 

우선은 useState나 useEffect 훅을 사용했다면 이는 커스텀 훅을 이용할 좋은 기회입니다. 

const useUsers = () => {
  const [users, setUsers] = useState([])
  
  useEffect(() => {
    const loadUsers = async () => {  
      const response = await fetch('/some-api')
      const data = await response.json()
      setUsers(data)
    }

    loadUsers()
  }, [])
  
  return { users }
}


const ActiveUsersList = () => {
  const { users } = useUsers()
  
  const weekAgo = new Date()
  weekAgo.setDate(weekAgo.getDate() - 7)

  return (
    <ul>
      {users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo).map(user => 
        <li key={user.id}>
          <img src={user.avatarUrl} />
          <p>{user.fullName}</p>
          <small>{user.role}</small>
        </li>
      )}
    </ul>    
  )
}

이제 useUsers 훅은 단 하나의 일에만 관여합니다. 사용자 데이터를 API를 통해 가져오는 것이죠. 이는 코드를 짧게 만들 뿐 아니라 목적을 이해하기 위해 해독이 필요한 구조적 훅을 이름에서 바로 목적을 알 수 있는 도메인 훅으로 변경함으로써 메인 컴포넌트를 더욱 읽기 쉽도록 만들었습니다. 

 

그다음 컴포넌트를 렌더링 하는 JSX를 한 번 살펴보죠. 배열을 루핑 하는 매핑이 있다면 개발자는 JSX가 발생시키는 배열 아이템의 복잡성에 주의를 기울여야 합니다. 만약에 코드가 한 줄짜리의 이벤트 핸들러가 붙지 않은 코드라면 그냥 한 줄로 두어도 상관없습니다. 하지만 더 복잡한 마크업이 붙는다면 별도의 컴포넌트로 떼어내는 것이 좋은 방법입니다. 

const UserItem = ({ user }) => {
  return (
    <li>
      <img src={user.avatarUrl} />
      <p>{user.fullName}</p>
      <small>{user.role}</small>
    </li>
  )
}


const ActiveUsersList = () => {
  const { users } = useUsers()
  
  const weekAgo = new Date()
  weekAgo.setDate(weekAgo.getDate() - 7)

  return (
    <ul>
      {users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo).map(user => 
        <UserItem key={user.id} user={user} />
      )}
    </ul>    
  )
}

위의 변화로 메인 컴포넌트를 더욱 작게 만들었고 렌더링 로직을 별도의 컴포넌트에 두어 가독성을 높였습니다. 

 

마지막으로, 유저 데이터중 비활성화된 유저들은 필터링하는 로직이 남아 있습니다. 이 로직은 상대적으로 고립되어 있으며 어플리케이션의 다른 부분에서 재사용될 가능성이 있으니 유틸리티 함수로 분리합니다:

const getOnlyActive = (users) => {
  const weekAgo = new Date()
  weekAgo.setDate(weekAgo.getDate() - 7)
  
  return users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo)
}

const ActiveUsersList = () => {
  const { users } = useUsers()

  return (
    <ul>
      {getOnlyActive(users).map(user => 
        <UserItem key={user.id} user={user} />
      )}
    </ul>    
  )
}

이 시점부터 메인 컴포넌트는 이미 충분히 짧아졌고 가독성이 좋아졌으므로 여기서 이만 쪼개기를 그만둬도 괜찮을 것 같습니다. 하지만 조금만 더 자세히 보면, 아직도 뭔가 하나 이상의 역할을 하고 있다는 것을 알 수 있습니다. 현재 위 컴포넌트는 데이터를 가져오고 난 뒤에 해당 데이터를 필터링하고 있습니다. 그래서 이상적으로 추가적인 작업 없이 그냥 데이터를 가져와서 렌더링 하는 방식으로 바꾸려고 합니다. 따라서 마지막 작업으로 이 로직을 새로운 커스텀 훅 안에 캡슐화합니다. 

const useActiveUsers = () => {
  const { users } = useUsers()

  const activeUsers = useMemo(() => {
    return getOnlyActive(users)
  }, [users])

  return { activeUsers }
}

const ActiveUsersList = () => {
  const { activeUsers } = useActiveUsers()

  return (
    <ul>
      {activeUsers.map(user => 
        <UserItem key={user.id} user={user} />
      )}
    </ul>    
  )
}

이 부분에서 useActiveUsers라는 데이터를 가져오고 필터링하는 커스텀 훅을 만들었습니다(그리고 필터링된 데이터를 메모라이즈 했습니다). 따라서 메인 컴포넌트는 아주 단순한 역할만을 하게 되었습니다 - 훅으로 부터 받아온 정보를 렌더링 하는 것이죠.

 

"단 하나의 역할"이라는 해석에 따라, 여전히 컴포넌트가 데이터를 가져오고 렌더링한다는 점에서 아직은 "단 하나의 역할"만을 하고 있지 않다고 말할 수 있습니다. 그렇다면 더 깊숙이 들어가 훅을 호출하는 컴포넌트를 만들고 그 컴포넌트에서 다른 컴포넌트로 프롭을 전하는 방식으로 해결할 수는 있지만 실제 애플리케이션에서 이런 방식을 통해 이득을 얻을 수 있는 경우는 거의 없습니다. 그러니 "데이터를 받아와서 렌더링 한다"는 정의를 "하나의 역할"로 받아들여 주도록 하죠.

 

이제 외부적 관점입니다. 컴포넌트는 절대 독립적이지 않습니다. 오히려 다른 컴포넌트에게 기능을 전달하던 혹은 다른 컴포넌트가 전달한 기능을 사용하는 등 더 큰 시스템의 한 부분으로서 상호작용하죠. 따라서, SRP의 외부적 관점은 하나의 컴포넌트가 얼마나 많이 사용될 수 있는가를 중점적으로 봅니다. 

 

더 자세히 이해하기 위해, 아래의 예제를 살펴보죠. 텔레그램이나 페이스북 메세지와 같은 메시지 어플에서 하나의 메시지를 표시하는 컴포넌트가 있다고 가정해 봅시다. 아래와 같이 간단히 표시할 수 있습니다. 

const Message = ({ text }) => {
  return (
    <div>
      <p>{text}</p>
    </div>
  )
}

만약에 텍스트와 이미지를 같이 보내고 싶다면 컴포넌트는 약간 복잡해 집니다. 

const Message = ({ text, imageUrl }) => {
  return (
    <div>
      {imageUrl && <img src={imageUrl} />}
      {text && <p>{text}</p>}
    </div>
  )
}

더 나아가, 녹음 메세지를 전달할 수도 있을 겁니다. 이는 컴포넌트를 더욱 복잡하게 하겠죠.

const Message = ({ text, imageUrl, audioUrl }) => {
  if (audioUrl) {
    return (
      <div>
        <audio controls>
          <source src={audioUrl} />
        </audio>
      </div>
    )
  }

  return (
    <div>
      {imageUrl && <img src={imageUrl} />}
      {text && <p>{text}</p>}
    </div>
  )
}

시간이 지나면서 이 기능은 더 많은 기능들, 예를 들어 비디오와 스티커 등을 지원할 것이며 컴포넌트는 비대해지고 복잡해질 것을 충분히 예상해 볼 수 있습니다. 여기서 다시 한번 어떤 일들이 일어났는지 복기해봅시다. 

 

처음 컴포넌트는 SRP의 원칙에 부합했으며 정확하게 하나의 역할을 하였습니다. 메세지를 렌더링 하는 것이죠. 하지만, 애플리케이션이 진화하면서, 더욱더 많은 기능을 점차 추가하기 시작했습니다. 렌더링 로직에 조그마한 조건문을 달았고 점차 공격적으로 렌터 트리를 교체하며 "단 하나의 역할"이란 원래의 정의가 너무 거대하고 일반화되었습니다. 하나의 목적을 가진 컴포넌트에서 시작해 결국 다양한 역할을 하는 컴포넌트로 끝이 나 버렸습니다. 

 

이러한 문제를 해결할 수 있는 방법으로는 일반적인 Message 컴포넌트를 없애고 더욱 세분화된 단 하나의 컴포넌트를 만드는 것입니다. 

const TextMessage = ({ text }) => {
  return (
    <div>
      <p>{text}</p>
    </div>
  )
}

const ImageMessage = ({ text, imageUrl }) => {
  return (
    <div>
      <img src={imageUrl} />
      {text && <p>{text}</p>}
    </div>
  )
}

const AudioMessage = ({ audioUrl }) => {
  return (
    <div>
      <audio controls>
        <source src={audioUrl} />
      </audio>
    </div>
  )
}

이 컴포넌트 안의 로직은 서로 매우 다릅니다. 따라서 각자 진화해 가는 것이 자연스럽습니다. 

 

이러한 문제는 보통 어플리케이션이 커지면서 점진적으로 증가한다고 보는 것이 타당합니다. 개발자는 필요한 모든 기능을 해주는 기존의 컴포넌트/함수를 재사용하길 원하며 이에 따라 별도의 프롭/인자를 넘겨주고 내부 로직을 변경하게 되죠. 그리고 누군가는 똑같은 상황에 빠지게 되고 별도의 컴포넌트와 로직을 분리하기보다는 다른 인자를 추가하고 다른 if를 넣어 눈덩이가 더욱더 커지는 악순환에 빠집니다. 

 

이런 악순환을 끊기 위해서는 기능 구현을 위해 기존의 컴포넌트를 수정할 때 컴포넌트의 재사용성을 극대화 하기 위함인지 아니면 이것이 타당하기 때문인지 아니면 단순히 게으르기 때문인지 고려하시길 바랍니다. 비대한 컴포넌트의 문제점을 이해하고 그것의 단 하나의 역할이 무엇인지 어떻게 정의할 것인지를 주의하셔야 합니다. 

 

실용적인 측면에서, 기존의 목적에서 컴포넌트가 비대해 졌고 쪼개는 작업이 필요함을 알려주는 좋은 표식이 컴포넌트의 행동을 바꾸는 대량의 if 구문입니다. 이는 단순 자바스크립트의 함수에도 마찬가지로 적용됩니다. 만약 다른 결과를 내는 함수를 만들기 위해 내부에 여러 인자들을 지속적으로 추가하면 결국 너무 많은 역할을 하는 함수를 만들게 됩니다. 다른 표식은 다량의 옵션 프롭을 가진 컴포넌트입니다. 만약 다른 콘텍스트에서 별도의 하위 속성 집합을 가진 컴포넌트를 이용한다면, 높은 확률로 하나의 컴포넌트로 가장한 다량의 컴포넌트를 다뤄야 할지도 모릅니다. 

 

요약하자면, "하나의 역할" 원칙은 컴포넌트를 작고 하나의 목적을 가지도록 유지하는 것입니다. 이런 컴포넌트는 이해하기 쉽고 테스트와 변형이 쉽습니다. 또한 의도치 않은 코드 반복을 줄일 수 있습니다.  

 

Open-closed principles (OCP)

OCP란 "소프트웨어는 확장성에는 열려있어야 하며, 수정에는 폐쇄적이어야 한다"라는 의미입니다. 리엑트 컴포넌트와 함수는 소프트웨어의 일부분이기 때문에 위 의미를 그대로 적용할 수 있습니다. 

 

open-closed 원칙은 원래의 소스 코드의 변경없이도 컴포넌트가 확장 가능하도록 구조를 짜는 방식을 지향합니다. 실제로 적용을 해보기 위해 아래 시나리오를 살펴보도록 하죠. 다른 페이지에서 Header 컴포넌트를 공유하는 애플리케이션을 작업 중이라고 해보겠습니다. 그리고 어떤 페이지에 유저가 위치하고 있느냐에 따라, Header는 약간의 다른 UI를 가질 것입니다:

const Header = () => {
  const { pathname } = useRouter()
  
  return (
    <header>
      <Logo />
      <Actions>
        {pathname === '/dashboard' && <Link to="/events/new">Create event</Link>}
        {pathname === '/' && <Link to="/dashboard">Go to dashboard</Link>}
      </Actions>
    </header>
  )
}

const HomePage = () => (
  <>
    <Header />
    <OtherHomeStuff />
  </>
)

const DashboardPage = () => (
  <>
    <Header />
    <OtherDashboardStuff />
  </>
)

위 코드에서는 어떤 페이지에 유저가 위치하느냐에 따라 다른 페이지로 이동할 수 있는 링크를 렌더링 하고 있습니다. 하지만 위 방식은 매우 좋지 않은 코딩 방식임을 쉽게 알 수 있습니다. 왜냐하면 페이지를 더 추가하게 되면 어떤 일이 벌어질지 자명하기 때문입니다. 새로운 페이지가 만들어질 때마다, Header 컴포넌트로 돌아가 어떤 액션 링크를 렌더링 해야 할지 추가적으로 수정해야 할 것입니다. 이러한 접근법은 Header 컴포넌트를 취약하게 만들고 사용되는 콘텍스트에 종속되게 만들기 때문에 open-closed 원칙에 위배됩니다. 

 

이 문제를 해결하기 위해, component composition을 사용할 수 있습니다. Header 컴포넌트는 그 안에 무엇을 렌더링 할 것인가를 고민하지 않고 대신에 그 책임을 Header 컴포넌트를 이용하는 컴포넌트에게 떠넘기는 겁니다. children prop을 이용해서 말이죠. 

const Header = ({ children }) => (
  <header>
    <Logo />
    <Actions>
      {children}
    </Actions>
  </header>
)

const HomePage = () => (
  <>
    <Header>
      <Link to="/dashboard">Go to dashboard</Link>
    </Header>
    <OtherHomeStuff />
  </>
)


const DashboardPage = () => (
  <>
    <Header>
      <Link to="/events/new">Create event</Link>
    </Header>
    <OtherDashboardStuff />
  </>
)

이러한 접근 방식으로, Header 내부에 있는 변수 로직을 완전히 없애고 컴포넌트 수정없이 composition을 사용해 원하는 것을 아무거나 집어넣을 수 있습니다. 이를 활용하는 좋은 방안으로는 끼워 넣을 수 있는 컴포넌트에 placeholder를 표시하는 것입니다. 그리고 하나의 컴포넌트당 하나의 placeholder만을 사용해야 하는 것도 아닙니다. 만약 여러 개의 확장 지점이 필요하다면 (혹은 children prop이 다른 용도로 사용되고 있는 경우라면) 다수의 다른 Props를 사용할 수 있습니다. 만약에 Header로부터 해당 컴포넌트를 사용하는 컴포넌트에 어떤 콘텍스트를 넘겨줘야 한다면, render props pattern을 사용할 수 있습니다. 보시다시피, composition은 아주 유용하죠. 

 

이처럼 open-closed 원칙을 통해, 컴포넌트간의 커플링을 줄이고 더욱 확장 가능하고 재사용 가능하도록 코드를 만들 수 있습니다. 

 

Liskov substitution principle (LSP)

LSP는 객체를 설계할 때 "하위 객체가 상취 객체를 대체할 수 있도록"하길 권장합니다. 원래의 정의로는 하위/상위 관계가 클래스의 상속으로 이루어지지만, 꼭 그렇게 되라는 법은 없습니다. 넓은 의미로, 상속이란 단순히 본래의 객체 위에 비슷한 역할을 하는 다른 객체를 얹는 것이고 이는 리엑트에서도 자주 이용하는 방법입니다. 

 

하위/상위 관계의 간단한 예로는 styled-components 라이브러리(혹은 비슷한 문법을 이용하는 다른 CSS-in-JS 라이브러리)가 있습니다. 

import styled from 'styled-components'

const Button = (props) => { /* ... */ }

const StyledButton = styled(Button)`
  border: 1px solid black;
  border-radius: 5px;
`

const App = () => {
  return <StyledButton onClick={handleClick} />
}

위 코드에서 Button 컴포넌트를 이용해 StyledButton을 만들어 냈습니다. 이 새로운 StyledButton 컴포넌트는 몇개의 CSS 클래스를 추가하긴 했지만 원래의 Button 컴포넌트의 기능을 모두 가지고 있습니다. 따라서 이 경우, Button과 StyledButton을 상위 하위 컴포넌트로 생각해 볼 수 있습니다. 

 

추가로, StyledButton은 Button과 똑같은 props를 가지기 때문에 그것의 기반이 되는 컴포넌트의 동일한 인터페이스를 따릅니다. 이러한 이유로, 애플리케이션 내부에서 추가적인 변경 없이 StyledButton을 Button으로 교체할 수 있습니다. 이것이 Liskov substitution 원칙의 이점이죠.

 

아래는 다른 컴포넌트를 기반으로 하는 컴포넌트의 한 예입니다:

type Props = InputHTMLAttributes<HTMLInputElement>

const Input = (props: Props) => { /* ... */ }

const CharCountInput = (props: Props) => {
  return (
    <div>
      <Input {...props} />
      <span>Char count: {props.value.length}</span>
    </div>
  )
}

위 코드에서는 input의 글자 갯수를 알려주는 기능을 가진 컴포넌트를 만들기 위해 기본적인 Input 컴포넌트를 기반으로 사용했습니다. 로직을 추가했지만 여전히 Input 컴포넌트의 원래의 기능을 가지고 있지요. 컴포넌트의 인터페이스도 변함이 없습니다(둘 다 같은 props를 넘겨받고 있습니다.) 따라서 LSP 원칙을 따랐음을 발견할 수 있습니다. 

 

Liskov substitution 원칙은 특히 아이콘이나 입력창 등 공통의 특성을 공유하는 컴포넌트의 콘텍스트에 유용합니다. 왜냐하면 하나의 아이콘 컴포넌트는 다른 아이콘과 대체 가능해야 하며, DatePickerInput과 AutocompleteInput 컴포넌트는 더 일반화된 Input 컴포넌트를 대체할 수 있어야 하기 때문입니다. 하지만 한 가지 알고 넘어가야 할 점은 이 원칙은 항상 지켜질 수 없으며 꼭 지켜져야 할 필요는 없다는 것입니다. 종종 하위 컴포넌트를 만들 때 상위 컴포넌트가 가지고 있지 않은 기능을 추가할 수 있으며 이때는 상위 컴포넌트의 인터페이스를 깰 수 있습니다. 이는 지극히 당연한 결과이기 때문에 LSP를 모든 부분에 적용하려고 노력하지 않아도 괜찮습니다. 

 

LSP를 적용하는 것이 타당한 부분에 있어서는 원칙을 불필요하게 어길 필요는 없습니다. 흔히 발생할 수 있는 두가지 예를 한번 살펴봐 보겠습니다. 

 

첫 번째 예는 props를 별다른 이유 없이 쪼개는 경우입니다. 

type Props = { value: string; onChange: () => void }

const CustomInput = ({ value, onChange }: Props) => {
  // ...some additional logic

  return <input value={value} onChange={onChange} />
}

위 코드에서 CustomInput는 Input 컴포넌트가 바라는 props가 아닌 자신만의 Props를 재정의 했습니다. 그 결과, Input 컴포넌트가 가질 수 있었던 많은 특성의 집합을 잃어버리게 되었고 결국 인터페이스를 깨뜨리게 되었습니다. 이를 해결하기 위해, 원래의 input이 바라는 원래의 props를 그대로 spread 오퍼레이터를 이용해 아래로 내려주어야 합니다. 

type Props = InputHTMLAttributes<HTMLInputElement>

const CustomInput = (props: Props) => {
  // ...some additional logic

  return <input {...props} />
}

또 다른 LSP 원칙을 위배하는 예는 어떤 특성에 별칭을 부여하는 것입니다. 이는 프로퍼티가 로컬 변수와 변수명이 충돌할 때 사용하곤 합니다.

type Props = HTMLAttributes<HTMLInputElement> & {
  onUpdate: (value: string) => void
}

const CustomInput = ({ onUpdate, ...props }: Props) => {
  const onChange = (event) => {
    /// ... some logic
    onUpdate(event.target.value)
  }

  return <input {...props} onChange={onChange} />
}

이를 해결할 수 있는 방법으로는 로컬 변수 이름을 정할 때 네이밍 컨벤션을 잘 따르는 것입니다. 예를 들어, onSomething 프로퍼티에 맞는 각 지역 함수로 handleSomething 함수를 사용하는 방법이 있습니다. 

type Props = HTMLAttributes<HTMLInputElement>

const CustomInput = ({ onChange, ...props }: Props) => {
  const handleChange = (event) => {
    /// ... some logic
    onChange(event)
  }

  return <input {...props} onChange={handleChange} />
}

Interface segregation principle (ISP)

ISP에 따르면 "클라이언트는 사용하지 않는 interface에 종속적"이어서는 안 됩니다. 리엑트 애플리케이션의 관점에서는 이를 "컴포넌트는 사용하지 않는 props에 종속적"이어서는 안 된다라고 대체해 해석해 볼 수 있겠습니다. 

 

여기서 ISP의 개념을 약간은 변형시켰지만 그리 큰 변형은 결코 아닙니다. props와 인터페이스 모두 객체(component)와 외부 세계(그것이 사용되는 콘텍스트) 간의 약속이라는 점에서 이 둘은 평행 선상에서 유사함을 알 수 있기 때문입니다. 결국 기준을 엄격하게 적용하기보단 일반적 원칙을 이용해 문제를 해결하는 것이 핵심이니까요. 

 

ISP가 해결하고자 하는 바를 더욱 잘 이해하기 위해, 타입 스크립트를 예제로 이용해 보도록 하겠습니다. 비디오 리스트를 렌더링 하는 애플리케이션이 있다고 가정해 보겠습니다.

type Video = {
  title: string
  duration: number
  coverUrl: string
}

type Props = {
  items: Array<Video>
}

const VideoList = ({ items }) => {
  return (
    <ul>
      {items.map(item => 
        <Thumbnail 
          key={item.title} 
          video={item} 
        />
      )}
    </ul>
  )
}

 각 아이템에 사용하는 Thumbnail 컴포넌트는 아래와 같습니다. 

type Props = {
  video: Video
}

const Thumbnail = ({ video }: Props) => {
  return <img src={video.coverUrl} />
}

Thumbnail 컴포넌트는 작고 단순합니다. 하지만 단 하나의 프로퍼티만을 사용해 전체 video 객체를 프롭으로 전달한다는 문제점을 가지고 있습니다. 

 

이점이 왜 문제가 되냐면, 비디오에 이어서, 라이브 스트리밍도 썸네일을 표시하겠다고 해보겠습니다. 그리고 이 두 리소스는 같은 리스트에 섞여있는 상태입니다. 

 

라이브 스트리밍의 타입 객체는 아래와 같습니다.

type LiveStream = {
  name: string
  previewUrl: string
}

그리고 아래가 업데이트된 VideoList 컴포넌트입니다. 

type Props = {
  items: Array<Video | LiveStream>
}

const VideoList = ({ items }) => {
  return (
    <ul>
      {items.map(item => {
        if ('coverUrl' in item) {
          // it's a video
          return <Thumbnail video={item} />
        } else {
          // it's a live stream, but what can we do with it?
        }
      })}
    </ul>
  )
}

여기서 알 수 있듯이, 한 가지 문제가 생기게 됩니다. video와 live stream 객체는 쉽게 구별이 가능하지만 Thumbnail 컴포넌트에는 객체를 전달할 수 없습니다. 왜냐하면 Video와 LiveStream은 양립이 불가능하기 때문입니다. 우선 타입이 달라 타입 스크립트가 에러를 띄울 것입니다. 그리고 썸네일 URL을 각자 다른 특성 아래에 두고 있습니다. 비디오는 coveUrl에 그리고 라이브 스트림은 previewUrl에 말이죠. 이것이 바로 필요한 객체 이상의 props에 의존성을 가질 때 발생하는 문제입니다. 이는 코드를 재사용하기 어렵도록 만듭니다. 이를 한번 해결해 보죠. 

 

Thumbnail 컴포넌트를 필요로 하는 props에만 의존하도록 리펙터링을 해보겠습니다. 

type Props = {
  coverUrl: string
}

const Thumbnail = ({ coverUrl }: Props) => {
  return <img src={coverUrl} />
}

수정 결과, 비디오와 라이브 스트림의 썸네일을 모두 렌더링 할 수 있습니다. 

type Props = {
  items: Array<Video | LiveStream>
}

const VideoList = ({ items }) => {
  return (
    <ul>
      {items.map(item => {
        if ('coverUrl' in item) {
          // it's a video
          return <Thumbnail coverUrl={item.coverUrl} />
        } else {
          // it's a live stream
          return <Thumbnail coverUrl={item.previewUrl} />
        }
      })}
    </ul>
  )
}

이처럼 ISP는 시스템의 컴포넌트 간 의존성을 최소화여 커플링을 방지하고 재사용성을 높일 수 있습니다. 

Dependency inversion principle (DIP)

의존성 도치 원칙은 "구체화가 아닌 추상성에 의존해야 한다"는 의미입니다. 다르게 말하면, 하나의 컴포넌트는 다른 컴포넌트에 직접적으로 의존적 이어선 안되고 두 컴포넌트는 동일한 추상성에 의존해야 한다는 것입니다. 여기서, 컴포넌트란 애플리케이션의 모든 부분을 의미합니다. 리엑트 컴포넌트일 수도 있고 유틸리티 함수 모듈 혹은 3자 라이브러리가 될 수 도 있습니다. 이 원칙은 추상적으로 이해하기는 어려울 수 있으니 바로 예제를 통해 알아보도록 하겠습니다. 

 

아래는 양식을 보내면 유저의 정보를 API를 통해 보내는 LoginForm 컴포넌트입니다. 

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

위 코드에서 LoginFrom 컴포넌트는 직접적으로 api 모듈을 참조하고 있는데 이는 둘 사이에 강력한 커플링이 생기게 만듭니다. 이러한 방식은 좋지 않은 결과를 낳는데 이유는 하나의 컴포넌트 변화가 다른 컴포넌트에 영향을 미치며 이러한 의존성은 코드를 변경하기 어렵게 만들기 때문입니다. 의존성 도치 원칙은 이러한 커플링을 없애는 것에 초점을 맞춥니다. 어떻게 이를 해결할 수 있을지 한번 보겠습니다. 

 

우선, LoginForm 내부에 직접적인 api 모듈 참조를 없앨 것입니다. 대신에 props를 통한 메서드를 끼워 넣도록 하겠습니다.

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

그 결과 LoginFrom 컴포넌트는 더 이상 api 모듈에 의존적이지 않게 되었습니다. 유저 정보를 API로 송신하는 로직은 완전히 onSubmit 콜백으로 추상화되었습니다. 그리고 세세한 실행을 위한 로직은 부모 컴포넌트가 해결해야 할 문제가 되었습니다. 

 

따라서 양식을 api 모듈에게 전달하는 연결 역할을 맡은 새로운 LoginFrom 버전을 만들어 보겠습니다. 

import api from '~/common/api'

const ConnectedLoginForm = () => {
  const handleSubmit = async (email, password) => {
    await api.login(email, password)
  }

  return (
    <LoginForm onSubmit={handleSubmit} />
  )
}

ConnectedLoginForm 컴포넌트는 api와 LoginForm 사이의 풀과 같은 역할을 합니다. 그리고 그 둘에게 완전히 의존적이죠. 여기서 개발자는 무너뜨릴 의존성 자체가 없기 때문에 걱정 없이 이 둘을 독립된 공간에서 반복하거나 테스트할 수 있습니다. 그리고 LoginFrom과 api가 약속된 공통의 추상성을 가지는 한, 애플리케이션은 언제나 예상된 방향으로 동작할 것입니다. 

 

과거에는 이러한 "멍청한" presentational 컴포넌트에 로직을 부여하는 접근법이 3자 라이브러리에서도 많이 사용되었습니다. 가장 유명한 예가 바로 Redux죠. Redux는 컴포넌트의 connect 상위 컴포넌트(HOC)를 이용해 콜백 props를 dispatch 함수에 바인드 합니다. 훅의 도입으로 이러한 접근법은 다소 일반적이지 않지만 HOC를 이용한 로직 주입은 여전히 리엑트에서 많이 사용됩니다. 

 

요약하자면, 의존성 도치 원칙은 애플리케이션의 다른 컴포넌트 사이의 커플링을 최소화하는데 목적이 있습니다. 눈치채셨듯이 최소화는 개별 컴포넌트의 책임 범위 최소화부터 컴포넌트가 인식 및 종속성의 최소화까지 모든 SOLID 원칙에서 반복되는 주제입니다. 

 

 

 

출처: https://medium.com/dailyjs/applying-solid-principles-in-react-14905d9c5377

 

Applying SOLID principles in React

As the software industry grows and makes mistakes, the best practices and good software design principles emerge and conceptualize to avoid…

medium.com