직관적인 컴포넌트라는 것은 도대체 무엇을 의미하는 것일까요? 별점 기능을 구현한 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} />);
이번 기회로 직관적인 컴포넌트와 직관적이지 않은 컴포넌트의 차이를 느끼고 직관적인 코드를 지향하는 개발자로 거듭나 봅시다!
정리
- 코드는 직관적일수록 좋습니다.
- 직관적인 네이밍을 위해서는 직관적인 코드를 작성할 필요가 있습니다.
- 상태가 적다고, 코드의 길이가 짧다고 좋은 코드가 아닙니다.
- 오히려 짧지만 직관성이 떨어지는 코드는 한눈에 파악하기 힘듭니다.
- 이는 코드를 읽는 사람으로 하여금 더욱 많은 시간과 집중을 요하게 합니다.
- 어떠한 기능을 구현한다면, 우선 흐름을 파악하세요.
- 흐름에서 분기가 있는지 찾아내고 나누어 주세요.
- 분기가 있을 떄 공통적으로 이용 가능한 상태를 파악하세요.
- 이러한 분기는 컴포넌트의 재활용성도 높여줍니다.