[10분 테코톡] 리액트 컴포넌트 설계와 SOLID

흑우·2023년 11월 28일

10분 테코톡 - 5기

목록 보기
3/16

SOLID란?

  • 소프트웨어 설계 5원칙
  • 소프트웨워 설계를 이해하기 쉽게 해주는 5가지 원칙

SRP (Single Response Principle) - 단일 책임 원칙

  • 단일한 동작만 가지도록 분리? => X
  • 사용자나 이해 관계자 등의 변경을 요청하는 사람들을 중심으로 책임을 분리한다.
  • 즉, SRP 원칙은 비즈니스 관점에서 책임을 분리하는 원칙으로 볼 수 있습니다.
  • 분리된 모듈은 한 가지 책임에 관한 변경사항이 생겼을 때만 코드를 수정하게 되는 구조가 좋은 구조
  • 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 => <UserItem key={user.id} user={user} />)}
      	</ul>
    )
}
  • ActiveUsersList 컴포넌트는 활성화된 User들을 렌더링하는 기능을 수행합니다.
  • 뿐만아니라, 서버에서 User 데이터를 가져오는 기능, User 데이터 정보 중 활성화된 필터링하는 기능을 하나의 컴포넌트에서 수행하고 있습니다.
  • SRP 원칙을 준수하여 리팩토링 해보죠!
// Users 데이터를 패칭하는 기능을 커스텀 훅으로 분리
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 getOnlyActive = (users) = {
	const weekAgo = new Date();
  	weekAgo.setDate(weekAgo.getDate() - 7);

	return users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo)
}

// 렌더링하는 부분만 컴포넌트에 남기기
const ActiveUserList = () => {
	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}
}

// 컴포넌트에서는 반환된 데이터로 UI만 그리기
const ActiveUserList = () => {
	const { activeUsers } = useActiveUsers();
  	return (
    	<ul>
      		{activeUsers.map(user => <UserItem key={user.id} user={user}>)}
      	</ul>
    )
}

OCP(Open Closed Principle) 개방 폐쇄 원칙

  • 소프트웨어 구성요소는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다.
  • 쉽게 말하면 기존의 코드는 유지하지만 새로운 기능을 확장할 수 있어야 한다.
  • OCP가 위반된 코드
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>
    )
}
  • Header 컴포넌트에서 또 다른 기능을 추가하고 싶다면 Header 컴포넌트를 직접 수정해야 합니다.
  • 컴포넌트 합성을 통해서 해결할 수 있습니다!
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 컴포넌트를 반복적으로 선언해줘야 하는 거 아닌가요..?
  • Card 컴포넌트를 예시로 들어보겠습니다.
const CardItem = ({ imageUrl, tagNumber, name }: Props) => {
...

return (
	<div>
    	<img src={imageUrl} />
    	<div>
          <span>{tagNumber}</span>
          <span>{name}</span>
        </div>  
  	</div>
)
}
  • Card 컴포넌트에 다른 요구 사항이 생긴다면? (설명 추가, 이미지 rounded)
    • props를 추가하는 방법 - 요구 사항이 많아질 수록 컴포넌트가 복잡해진다.
    • CardItem의 책임을 분리하는 방법
      • CardThumbnail
        • 카드 이미지를 원하는 형태로 보여줄 수 있다.
      • CardBody
        • 카드 정보에 해당하는 내용을 묶는 컨테이너
        • 여러 텍스트를 어떻게 정렬할 지 정할 수 있다.
      • CardText
        • 카드에 들어가는 텍스트, 스타일링이 가능하다.
        • 텍스트 줄 제한을 몇 줄로 할 지 정할 수 있다.
const CardThumbnail = ({ url, size, rounded, className }: Props) => ...

const CardBody = ({ className, align, children }: Props) => ...

const CardText = ({className, lineCnt, children}: Props) => ...

const RoundCardItem = (...) => {
	return (
    	<div>
        	<CardThumbnail url={imageUrl} rounded />
        	<CardBody align="center">
              <CardText>{tagNumber}</CardText>
            </CardBody>
        <div>
    )
}
  • 기존의 생성한 컴포넌트를 수정하지 않고 카드의 구성 요소를 잘게 나누고 이를 조합, 합성하면서 RoundCardItem, SquareCardItem 등 다양한 카드 컴포넌트를 만들 수 있습니다.

LSP(Liskov Substituion Principle) - 리스코프 치환 원칙

  • 하위 타입 객체는 상위 타입 객체에서 가능한 행위를 수행할 수 있어야 한다.
  • 펭귄은 LSP 적용이 언된 경우입니다.
  • 리스 코프 치환 원칙은 올바른 상속을 위해, 자식 객체의 확장이 부모 객체의 방향을 온전히 따르도록 권고하는 원칙입니다.
  • 하지만 리액트에서는 상속 대신 합성을 사용하여 컴포넌트 간에 코드를 재사용하는 것이 좋다고 공식 문서에 명시되어 있기 때문에 LSP를 적용하는 일은 극히 드물것으로 생각됩니다.

ISP(Interface Segregation Principle) - 인터페이스 분리 원칙

  • 클라이언트(사용자)가 실제로 사용되는 Interface를 만들어야한다는 의미로, 인터페이스를 사용에 맞게끔 각기 분리해야한다는 설계 원칙
  • ISP가 적용되지 못한 코드
interface Video {
	title: string;
  	duration: number;
  	coverUrl: string;
}

interface Props {
	video: Video;
}

const Thumbnail = ({ video }: Props) => {
	return <img src={video.coverUrl} />;
}
  • Video 객체의 coverUrl만 사용하고 있음에도 Video 객체의 전체를 가지고 오고 있습니다.
  • 구현에는 이상이 없지만 확장에는 용이하지 못합니다.
  • ISP가 적용되지 않았을 때 발생하는 문제 코드
type LiveStream = {
	name: string;
  	previewUrl: string;
}

type Props = {
	items: Array<Video | LiveStream>
}
  
const VideoList = ({ items }) => {
	return (
    	<ul>
        	{items.map(item => {
        		if('coverUrl' in item) {
                	return <Thumbnail video={item}>
                } else {
                	// video 객체를 전달함으로 LiveStream 썸네일을 사용할 수 있다.
                }
        	})}
        </ul>
    )
}
  • ISP가 적용된 코드
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) {
                	return <Thumbnail video={item.coverUrl}>
                } else {
                	
                   return <Thumbnail video={item.previewUrl}>
                }
        	})}
        </ul>
    )
}
  • 비디오 썸네일과 라이브 썸네일을 둘 다 보여줄 수 있도록 되었습니다!

DIP(Dependency Inversion Principle) - 의존 역전 원칙

  • 고수준 모듈이 저수준 모듈의 구현에 의존해서는 안됩니다.
  • 전기를 사용할 때는 플러그를 끼우면 됩니다. 배선을 직접 연결하는 것이 아니라요!
  • 컴포넌트를 설계할 때 렌더링을 하는 것에만 신경을 쓰면되고, 다른 로직에 대해서는 신경쓰지말자!
  • DIP가 위반된 코드
const ActiveUsersList = () => {
	const [users, setUsers] = useState([])
    
    useEffect(() => {
    	const loadUsers = async () => {
        	const response = await fetch('/some-api')
            const data = await response.json()
            setUsers(data)
        }
        loadUsers()
    },[])
  
  	...
}
  • ActiveUsersList 컴포넌트를 UI를 렌더링하는 것에만 관심사가 있고 데이터 패칭에는 관심사가 없습니다.
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 }
}
  • useUsers라는 커스텀 훅을 통해서 DIP를 적용

마무리

SOLID 원칙을 기반으로 리액트 컴포넌트를 설계하는 방법에 대해서 다뤄봤는데요. 굉장히 실용적인 내용이었습니다. 저는 SOLID 원칙의 정확한 의미를 모르고 있었지만 제가 기존에 작성하던 코드가 해당 원칙을 고려하여 작성한 코드와 유사한 것이 신기하네요. 의문이 드는 부분이 하나 있다면 Header 컴포넌트를 설계하는 부분에서 저렇게 Header 컴포넌트를 설계를 한다면 페이지마다 Header 컴포넌트를 선언해줘야 하는 데 맞는 방식일까요?

Reference

profile
흑우 모르는 흑우 없제~

0개의 댓글