직관적인 컴포넌트라는 것은 도대체 무엇을 의미하는 것일까요? 별점 기능을 구현한 2가지 컴포넌트를 비교해 보며 어느 컴포넌트가 더 직관적인지 생각해봅시다.
시작
프로젝트를 진행하던 중 다음과 같은 별점을 매기는 UI를 제작해야했습니다.
UI를 어느정도 조작해보면 알겠지만, 생각보다 복잡해 보입니다. 별점을 매긴 후에도 호버만 했을 때 별의 색깔이 변하는가 하면, 별점 UI를 벗어났다 나와도 별점은 또 유지됩니다.
저는 이를 아래와 같이 구현하였습니다.
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가지 부분을 캐치할 수 있었습니다.
- 포인터가 들어온 경우와 나간 경우로 분기가 나누어졌습니다!
- 별점이라는 상태는 두 경우 모두 사용하는 상태입니다!
그렇다면 이제 이 코드는 분기를 나누어야 했다는 것을 알 수 있습니다.
그리고 우연스럽게도 분기를 나누지 않았기 때문에 별점이라는 상태는 하나였습니다.
사실 제가 처음 짠 코드를 빠르게 보면 어디를 봐도 별점같이 보이는 상태는 없습니다.
하지만 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가지의 상태가 관리되지 않을까요?
Star를 클릭시 해당Star의 순번을 상태에 저장한다.- 이 상태는 별점이 되겠네요.
Star에 포인터가 들어오면 해당Star의 순번을 상태에 저장한다.- 초기 값은 별점이 되겠네요.
- 이 순번보다 작거나 같은
Star는 색칠되어야 하겠군요.
위에서 언급했다시피 1번의 상태는 부모 컴포넌트에서 같이 관리할 수 있습니다.
별점이라는 상태는 두 경우 모두 사용하는 상태입니다!
그러니 Star를 클릭 했을 때 "나 클릭됐어!"만 알려주면 되겠네요.
다음과 같이 2번의 상태만 포함시켜 StarRateContainer에 포인터가 들어온 경우의 컴포넌트를 만들었습니다.
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를 색칠하는 게 전부입니다.
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 컴포넌트의 코드입니다!
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} />);
이번 기회로 직관적인 컴포넌트와 직관적이지 않은 컴포넌트의 차이를 느끼고 직관적인 코드를 지향하는 개발자로 거듭나 봅시다!
정리
- 코드는 직관적일수록 좋습니다.
- 직관적인 네이밍을 위해서는 직관적인 코드를 작성할 필요가 있습니다.
- 상태가 적다고, 코드의 길이가 짧다고 좋은 코드가 아닙니다.
- 오히려 짧지만 직관성이 떨어지는 코드는 한눈에 파악하기 힘듭니다.
- 이는 코드를 읽는 사람으로 하여금 더욱 많은 시간과 집중을 요하게 합니다.
- 어떠한 기능을 구현한다면, 우선 흐름을 파악하세요.
- 흐름에서 분기가 있는지 찾아내고 나누어 주세요.
- 분기가 있을 떄 공통적으로 이용 가능한 상태를 파악하세요.
- 이러한 분기는 컴포넌트의 재활용성도 높여줍니다.