본문으로 건너뛰기

컴포넌트를 좀 더 직관적으로 만들어 보자(feat. 별점 기능 구현)

· 약 15분

직관적인 컴포넌트라는 것은 도대체 무엇을 의미하는 것일까요? 별점 기능을 구현한 2가지 컴포넌트를 비교해 보며 어느 컴포넌트가 더 직관적인지 생각해봅시다.

시작

프로젝트를 진행하던 중 다음과 같은 별점을 매기는 UI를 제작해야했습니다.

UI를 어느정도 조작해보면 알겠지만, 생각보다 복잡해 보입니다. 별점을 매긴 후에도 호버만 했을 때 별의 색깔이 변하는가 하면, 별점 UI를 벗어났다 나와도 별점은 또 유지됩니다.

저는 이를 아래와 같이 구현하였습니다.

starRate.tsx
import { useState } from 'react';
import { ReactComponent as Star } from './star.svg';

interface Props {
setActive: () => void;
}

export default function App({ onClick }: Props) {
const [hoveredStarIndex, setHoveredStarIndex] = useState(0);
const [clickedStarIndex, setClickedStarIndex] = useState(0);
const fillStarOfIndex = (num: number, event?: string): string => {
if (event === 'enter' && hoveredStarIndex >= num) {
return '#ff7f23';
}
if (event === 'leave' && clickedStarIndex >= num) {
return '#ff7f23';
}
return '#eeeeee';
};
return (
<div>
{[1, 2, 3, 4, 5].map((num) => (
<button // onClick이벤트를 위해 감싸주었습니다.
key={num}
onMouseEnter={() => setHoveredStarIndex(num)}
onMouseLeave={() => setHoveredStarIndex(0)}
onClick={() => {
setClickedStarIndex(num);
onClick?.();
}}
>
<Star key={num} fill={fillStarOfIndex(num, hoveredStarIndex === 0 ? 'leave' : 'enter')} />
</button>
))}
</div>
);
}

생각보다 간결하지 않나요? 고작 2개의 상태로 이 기능을 구현했습니다! 그리고 PR을 올렸는데 다음과 같은 리뷰를 받았습니다!

상태 및 함수를 직관적으로(한 번에 알아볼 수 있게), 그리고 일반적인 이름으로 변경하는 것이 좋겠네요.

직관적인 코드?

사실 위의 코드는 제 나름대로 최대한 직관적이게 보이려고 고민해 네이밍 한 경우였습니다.

네, 저는 이 때까지 직관적이라는 말이 잘 와닿지 않았습니다. 그래서 제 나름대로의 기준을 정해 "이정도면 직관적이지 않나?" 생각하며 네이밍을 했습니다. 이 이상 어떻게 직관적으로 바꾸면 좋을 지 고민하던 찰나에 추가적인 답변을 얻을 수 있었습니다.

현재 코드의 구조 자체를 좀 더 직관적으로 만들어 보면 어떨까요?

  • 컴포넌트의 흐름을 파악하고 이 흐름을 가져가보도록 해보세요.
  • 만약 분기가 나누어질 수 있는 컴포넌트라면 분기를 나누는 것도 좋은 방법입니다.

그렇습니다. 코드가 좀더 직관적으로 구조가 바뀐다면 상태와 함수들도 더 자연스럽게 직관적으로 네이밍 할 수 있다는 것이었습니다.

별점 기능을 직관적으로 바라보자

우선 이 별점 기능의 흐름을 파악해 보도록합시다. 별과 별점 UI 전체를 각각 Star, StarRateContainer라고 하겠습니다. 마우스 포인터는 짧게 포인터로 축약하겠습니다.

  • StarRateContainer에 포인터가 들어온 경우
    • Star를 클릭하면 별점이 매겨진다.
    • Star에 포인터가 들어오면 해당 Star를 포함한 왼쪽의 Star들이 모두 색칠된다.
  • StarRateContainer밖으로 포인터가 나간 경우
    • 매겨진 별점만큼 왼쪽부터 Star가 색칠된다.

어? 여기서 느낌이 오셨나요? 저는 이렇게 나누어 보고 2가지 부분을 캐치할 수 있었습니다.

  1. 포인터가 들어온 경우와 나간 경우로 분기가 나누어졌습니다!
  2. 별점이라는 상태는 두 경우 모두 사용하는 상태입니다!

그렇다면 이제 이 코드는 분기를 나누어야 했다는 것을 알 수 있습니다.

그리고 우연스럽게도 분기를 나누지 않았기 때문에 별점이라는 상태는 하나였습니다.

노트

사실 제가 처음 짠 코드를 빠르게 보면 어디를 봐도 별점같이 보이는 상태는 없습니다. 하지만 clickedStarIndex라는 상태가 이 역할을 하고 있었답니다! 여기까지만 봐도 이미 위 코드는 직관적이지 못한 코드라고 할 수 있겠네요.

코드를 직관적으로 개선

본격적으로 컴포넌트를 직관적으로 만들어봅시다. 우선 분기를 나누어보죠.

isMouseEnter ? <EnterStarRateContainer rating={rating} /> : <LeaveStarRateContainer rating={rating} />;

이어서 살펴보겠습니다. 각각의 Star는 순번이라는 값이 필요해 보입니다. 가장 왼쪽의 Star가 1이라면, 옆의 Star는 2 ... 그렇게 가장 오른쪽의 Star는 5라는 값이 필요할 것 같네요.

아래와 같이 순번을 줄 수 있을 것 같습니다.

[1, 2, 3, 4, 5].map((num) => <Star key={num} />);

이제 StarRateCotainer에 포인터가 들어온 경우를 살펴봅시다. 위의 흐름대로라면 아래와 같이 2가지의 상태가 관리되지 않을까요?

  1. Star를 클릭시 해당 Star의 순번을 상태에 저장한다.
    • 이 상태는 별점이 되겠네요.
  2. Star에 포인터가 들어오면 해당 Star의 순번을 상태에 저장한다.
    • 초기 값은 별점이 되겠네요.
    • 이 순번보다 작거나 같은 Star는 색칠되어야 하겠군요.

위에서 언급했다시피 1번의 상태는 부모 컴포넌트에서 같이 관리할 수 있습니다.

별점이라는 상태는 두 경우 모두 사용하는 상태입니다!

그러니 Star를 클릭 했을 때 "나 클릭됐어!"만 알려주면 되겠네요.

다음과 같이 2번의 상태만 포함시켜 StarRateContainer에 포인터가 들어온 경우의 컴포넌트를 만들었습니다.

EnterStarRateContainer.tsx
import { ReactComponent as Star } from 'assets/svg/star.svg';
import { useState, useContext } from 'react';
import StarRateContext from './StarRateContext';

export default function EnterStarRateContainer({ rating }: { rating: number }) {
const { handleMouseLeave, handleClick } = useContext(StarRateContext);
const [starNum, setStarNum] = useState(rating);

return (
<div
onMouseLeave={() => {
handleMouseLeave();
setStarNum(0);
}}
>
{[1, 2, 3, 4, 5].map((num) => (
<button key={num} onMouseEnter={() => setStarNum(num)} onClick={() => handleClick(num)}>
<Star key={num} fill={starNum >= num ? '#ff7f23' : '#eee'} />
</button>
))}
</div>
);
}

위 코드를 읽으면서 이런 코드가 정말 직관적인 코드라는 것을 느꼈습니다! 마지막에 코드를 기술하겠지만, StarRatingContext에서 꺼내 쓰고 있는 handleMouseLeave, handleClick는 정말 말 그대로의 역할을 수행하는 함수입니다. 그래서 굳이 안의 함수가 어떤 구조인지 알지 않아도 코드를 이해하기 충분했습니다. 각 속성 이벤트에 들어간 함수들도 정말 깔끔합니다! 대표적으로 하나의 이벤트만 살펴보겠습니다. onMouseLeave에서는 말 그대로 마우스가 떠났다고 조작하는 handleMouseLeave와 별 순번 상태 초기화를 위한 setStarNum(0)이 들어가 있습니다.

StarRateContainer밖으로 마우스가 나갔을 경우의 컴포넌트도 마저 살펴봅시다.

이 경우에는 매겨진 별점 만큼 Star를 색칠하는 게 전부입니다.

LeaveStarRateContainer.tsx
import { ReactComponent as Star } from 'assets/svg/star.svg';
import StarRateContext from './StarRateContext';
import { useContext } from 'react';

export default function LeaveStarRateContainer({ rating }: { rating: number }) {
const { handleMouseEnter } = useContext(StarRateContext);
return (
<div onMouseEnter={handleMouseEnter}>
{[1, 2, 3, 4, 5].map((num) => (
<button key={num}>
<Star key={num} fill={rating >= num ? '#ff7f23' : '#eee'} />
</button>
))}
</div>
);
}

이제 분기를 판단해줄 부모 컴포넌트인 StarRating만 확인하면 되겠네요. 우선, 분기를 판단하는 기준은 "마우스가 들어왔느냐?" 였습니다. 따라서 다음과 같은 상태가 있으면 적절하겠네요.

const [isMouseEnter, setIsMouseEnter] = useState(false);

그리고 별점도 부모에서 관리해주기로 했었습니다! 별점은 밑의 코드와 같이 props로 넘겨주면 되겠네요.

const [rating, setRating] = useState(0);
<EnterStarRateContainer rating={rating} />

위 코드를 적용한 StarRaing 컴포넌트의 코드입니다!

StarRating.tsx
import { useMemo, useState } from 'react';
import StarRateContext from './StarRateContext';
import EnterStarRateContainer from './EnterStarRateContainer';
import LeaveStarRateContainer from './LeaveStarRateContainer';

export default function StarRating({ onClick }: { onClick: () => void }) {
const [rating, setRating] = useState(0);
const [isMouseEnter, setIsMouseEnter] = useState(false);

const value = useMemo(
() => ({
handleMouseEnter: () => setIsMouseEnter(true),
handleMouseLeave: () => setIsMouseEnter(false),
handleClick: (num: number) => {
setRating(num);
onClick?.();
},
}),
[onClick],
);

return (
<StarRateContext.Provider value={value}>
{isMouseEnter ? <EnterStarRateContainer rating={rating} /> : <LeaveStarRateContainer rating={rating} />}
</StarRateContext.Provider>
);
}

이제 누군가 제 코드를 본다면 직관적이라고 느낄 수 있을 것입니다.

이 뿐만이 아니라 컴포넌트 <LeaveStarRateContainer />는 readonly기능을 하는 별점으로도 재활용할 수도 있겠네요!

아래의 코드는 import 구문을 제외한 전체 코드를 한데 묶어놓은 것입니다. 한 번 맨 처음 소개했던 StarRating.tsx와 비교했을 때 정말 직관적인지, 한 눈에 보기 쉬운지 비교해보세요.

// StarRating.tsx
export default function StarRating({ onClick }: { onClick: () => void }) {
const [rating, setRating] = useState(0);
const [isMouseEnter, setIsMouseEnter] = useState(false);

const value = useMemo(
() => ({
handleMouseEnter: () => setIsMouseEnter(true),
handleMouseLeave: () => setIsMouseEnter(false),
handleClick: (num: number) => {
setRating(num);
onClick?.();
},
}),
[onClick],
);

return (
<StarRateContext.Provider value={value}>
{isMouseEnter ? <EnterStarRateContainer rating={rating} /> : <LeaveStarRateContainer rating={rating} />}
</StarRateContext.Provider>
);
}
//EnterStarRateContainer.tsx
export default function EnterStarRateContainer({ rating }: { rating: number }) {
const { handleMouseLeave, handleClick } = useContextCheckNull(StarRateContext);
const [starNum, setStarNum] = useState(rating);

return (
<div
onMouseLeave={() => {
handleMouseLeave();
setStarNum(0);
}}
>
{[1, 2, 3, 4, 5].map((num) => (
<button
key={num}
onMouseEnter={() => setStarNum(num)}
onClick={() => {
handleClick(num);
}}
>
<Star key={num} fill={starNum >= num ? '#ff7f23' : '#eee'} />
</button>
))}
</div>
);
}
//LeaveStarRateContainer.tsx
export default function LeaveStarRateContainer({ rating }: { rating: number }) {
const { handleMouseEnter } = useContextCheckNull(StarRateContext);
return (
<div onMouseEnter={handleMouseEnter}>
{[1, 2, 3, 4, 5].map((num) => (
<button key={num}>
<Star key={num} fill={rating >= num ? '#ff7f23' : '#eee'} />
</button>
))}
</div>
);
}

어떠신가요? 코드가 거의 2배 이상 길지만 많은 사람들은 오히려 이쪽 손을 들어줄 것이라 생각합니다.

좀 더 수정한다고 하면 Star에게 순번을 매기기 위한 배열을 좀 더 직관적으로 전달할 수 있을 것 같네요!

const starNumList = [1, 2, 3, 4, 5];
starNumList.map((num) => <Star key={num} />);

이번 기회로 직관적인 컴포넌트와 직관적이지 않은 컴포넌트의 차이를 느끼고 직관적인 코드를 지향하는 개발자로 거듭나 봅시다!

정리

  • 코드는 직관적일수록 좋습니다.
  • 직관적인 네이밍을 위해서는 직관적인 코드를 작성할 필요가 있습니다.
  • 상태가 적다고, 코드의 길이가 짧다고 좋은 코드가 아닙니다.
    • 오히려 짧지만 직관성이 떨어지는 코드는 한눈에 파악하기 힘듭니다.
    • 이는 코드를 읽는 사람으로 하여금 더욱 많은 시간과 집중을 요하게 합니다.
  • 어떠한 기능을 구현한다면, 우선 흐름을 파악하세요.
    • 흐름에서 분기가 있는지 찾아내고 나누어 주세요.
    • 분기가 있을 떄 공통적으로 이용 가능한 상태를 파악하세요.
    • 이러한 분기는 컴포넌트의 재활용성도 높여줍니다.