스토리북 작성을 통해 얻게 되는 리팩토링 효과 | 카카오엔터테인먼트 FE 기술블로그

컴포넌트 주도 개발을 할 때 보통 결과물을 실시간으로 확인해가며 컴포넌트를 구현합니다. 결과물을 보기 위해 앱을 띄워놓고 만족하는 조건을 재현해서 확인해볼 수도 있지만 조건을 재현하는 과정은 꽤 번거로운 일입니다. 예를 들어 결제가 완료된 뒤에 보여야 할 모달을 구현했다고 한다면 결과물을 확인하기 위해 변수를 조작하거나 심지어는 직접 결제해보아야 할 수도 있습니다.
스토리북은 UI 컴포넌트를 독립적인 환경에서 그려볼 수 있는 툴 중 하나입니다. 스토리북으로 ‘스토리’라고 불리는 컴포넌트 미리보기를 작성해두면 결과물을 즉시 확인할 수 있어서 유용합니다. 최초 개발자뿐만 아니라 코드리뷰나 수정을 하는 다른 개발자나 디자이너도 쉽게 확인할 수 있어 도움이 됩니다. 참고로 스토리북 이외에도 Bit, Pattern Lab, Framer 등 많은 툴에서 UI 컴포넌트의 미리보기를 작성하는 기능을 지원하고 있습니다.
스토리 작성은 결과물을 쉽고 빠르게 확인할 수 있다는 장점 이외에 또 다른 장점이 있습니다. 바로 좀 더 나은 컴포넌트 설계를 고민하게 된다는 점입니다. 코드를 작성할 때는 준수한 설계하에 구현했다고 생각했지만, 나중에 수정이나 재사용을 하려고 하니 설계의 문제점을 발견하여 많은 부분을 수정했던 경험이 있으실 겁니다. 그런데 컴포넌트 구현과 스토리 작성을 병행하면 스토리로 옮기는 게 예상대로 되지 않을 때 저절로 설계의 결함을 발견하게 됩니다. 그래서 스토리 작성을 위해 고민을 하다 보면 자연스럽게 설계에 대한 고민과 리팩토링으로 이어집니다.
스토리 작성은 다음의 특징이 있습니다.
  • 독립적인 환경에서 그립니다.
  • 컴포넌트를 재사용하여 다시 그립니다.
환경이나 다른 컴포넌트와 의존성이 낮아야 독립적인 환경에서 그리는 것이 가능하고, 재사용이 쉬워야 다른 곳에서도 그릴 수 있기 때문에 스토리 작성은 리팩토링 효과를 부수적으로 얻게 됩니다.
저는 이번에 프로젝트를 진행하며 마치 명세서를 작성하듯이 새로 구현하거나 수정한 모든 컴포넌트를 스토리북으로 옮기는 작업을 병행해 보았습니다. 이에 따라 결과물을 쉽게 확인할 수 있게 되었을 뿐만 아니라 스토리북으로 옮기는 과정에서 강제로 마주하게 된 리팩토링 효과로 인해 코드의 구조가 간결해지고 사용성이 높아져서 매우 만족스러웠습니다.
FE개발팀의 팀원이 최근 테스트에 관한 경험을 공유했는데, 마찬가지로 이런 부수적인 리팩토링 효과를 마주했다고 합니다. 스토리북에 잘 그릴 수 있는 컴포넌트, 테스트가 가능하고 쉬운 컴포넌트, 설계가 잘 된 컴포넌트 간에는 양의 상관관계가 있음을 짐작할 수 있습니다.
제가 컴포넌트를 스토리북으로 옮기는 과정에서 자연스럽게 리팩토링을 하게 된 경험을 소개해 보려고 합니다. 본문에서는 리액트를 예시로 들고 있습니다. 예시를 분류하면 좋을 것 같아서 예시의 문제점을 병명에 비유하여 소개해보겠습니다. 제가 구현한 컴포넌트에 발생했던 문제점들은 다음과 같았습니다.
  • 과로: 하나의 컴포넌트에서 너무 많은 일을 하는 경우
  • 발작 증세: 간혹 그려져서는 안되는 결과물로 그려지는 경우
  • 피터팬 증후군: 독립해서 그려보니 의도와 다르게 그려지는 경우

과로: 하나의 컴포넌트에서 너무 많은 일을 하는 경우

과로는 스토리북을 연동하려고 할 때 최초에 마주쳤던 문제였습니다. 앱에서 잘 동작하던 컴포넌트를 단순히 스토리북으로 옮겼을 뿐인데 에러 메시지가 뜨면서 컴포넌트를 그릴 수 없었습니다. 원인은 하나의 컴포넌트가 렌더링뿐만 아니라 데이터 불러오기, 링크 연결 등의 라우팅 처리, 상태 관리, 로깅 데이터 전송 등 사이드 이펙트를 일으키는 다양한 임무를 함께 수행하고 있었기 때문입니다. 스토리북은 앱과는 다른 독립적인 환경이기 때문에 앱에서 사용한 기능을 스토리북에서도 이해하고 처리하기 위한 모킹 작업이 필요합니다. 스토리북 세팅 초기에는 이러한 부분이 누락되어 있으므로 컴포넌트가 앱에서 수행했던 기능을 모두 소화하지 못하고 오류로 인식하기 마련입니다.
컴포넌트의 많은 역할 중에 ‘렌더링’은 유일하게 플러그인 설치나 모킹 등 별도의 작업을 하지 않아도 스토리북에서 잘 동작하는 기능입니다. 가장 처음에 했던 작업은 ‘Presentational’ 요소 혹은 ‘View’에 해당하는 요소가 무엇인지 구분하고 이를 독립적인 컴포넌트로 분리하는 작업이었습니다. 물론 이렇게 분리된 컴포넌트를 나중에 다시 찾기 쉽도록 디렉터리 체계 (hierarchy)를 구축하는 과정도 함께 요구되었습니다. 아토믹 디자인을 참고하여 컴포넌트를 분리하고 배치하였습니다.
다음과 같이 ‘Presentational’ 요소를 분리하여 과중한 업무를 덜어내었습니다.
/* BEFORE */ const Human = () => { // data fetching const [face, isLoading] = useFetch('face'); // routing const router = useRouter(); // state managing const health = useRecoilValue(healthAtom); // ... many side effects return ( <> {'...'} {'...'} </> ); } /* AFTER */ const Human = () => { // data fetching const [face, isLoading] = useFetch('face'); // routing const router = useRouter(); // state managing const health = useRecoilValue(healthAtom); // ... many side effects return ( <> {'...'} <Eyes eyes={eyes} /> {'...'} </> ); };
주의할 점은 과하게 작은 범위로 분리하지 않는 것입니다. 재사용이 가능하거나 시맨틱으로 구분할 수 있는 이해하기 쉬운 단위이면 충분합니다.
이 외에도 자주 마주하게 되는 경우 하나를 조금 더 살펴보겠습니다.
컴포넌트에서 데이터를 불러오는 경우 이를 스토리북에서 재현하기 위해서는 데이터를 불러오는 부분의 모킹이 필요합니다. 모킹을 추가하는 것은 피곤한 일입니다. 그래서 스토리를 작성하다 보면 이 컴포넌트에서 꼭 데이터를 불러와야 하는지 재검토해보는 계기가 생깁니다. 물론 react-query 와 같은 data fetching 라이브러리를 사용하면 캐싱 기능 덕분에 데이터 로딩을 처리하는 부분이 여러 군데에 있어도 부담이 줄어듭니다. 하지만 상위 컴포넌트에서 이미 데이터를 불러왔다면 중복으로 처리할 필요 없이 상태 관리 라이브러리를 이용해서 필요한 데이터를 가져올 수 있습니다.
lazy loading이 구현된 경우처럼 컴포넌트에서 바로 데이터를 불러와야 하는 경우가 있습니다. 이 경우에도 데이터를 불러오는 부분만 분리하는 방법이 있습니다. Container/Presentational Pattern으로 ‘Presentational’ 컴포넌트를 분리하면 모킹을 할 필요가 없어 스토리 작성이 쉬워집니다.
/* BEFORE */ const Face = () => { const [face, isLoading] = useFetch('face'); return isLoading ? ( <> {'...'} {face} {'...'} </> }; /* AFTER */ const FaceLoader = () => { <Suspense fallback={<Loading />}>const FaceFetcher = () => { const [face] = useFetch('face'); return <Face face={face} />;const Face = ({ face }) => { return ( <> {'...'} {face} {'...'} </> ); };
Container/Presentational Pattern에서 Container를 커스텀 훅으로 대체하곤 하는데, 위의 예시에서는 커스텀 훅 마저도 별도의 파일로 분리한 모습입니다.
위와 같이 ‘fetcher’를 분리하면 “Face” 컴포넌트는 props를 ‘fetcher’로부터 전달받게 됩니다. 즉 데이터를 불러오는 의존성이 내부에 캡슐화되어 있다가 외부에서 주입할 수 있는 형태로 변경되었습니다. Face 컴포넌트는 이제 데이터를 불러오는 것에 대한 관심은 사라졌고, 오로지 주어진 값으로부터 적절히 그리는 역할만 잘 수행하면 됩니다. 이 상태에서 Face 컴포넌트를 스토리북으로 옮기면 별도의 노력 없이도 controls 플러그인 에서 props를 변경하면서 결과물이 바뀌는 것을 바로 확인할 수 있습니다.
위의 개선을 통해 스토리북에 옮기는 것은 쉬워졌습니다. 하지만 이렇게 데이터를 훅으로 불러오는 부분까지 분리하는 작업은 당장 필요 없을 수도 있습니다. 따라서 중요도는 다소 떨어지는 개선이지만, 결과적으로는 데이터 로드와 렌더링 간의 직교성이 높아진 부분이므로 다음에 데이터 로드의 조건이 복잡해지거나 중복되는 데이터 로드가 발생하는 경우 손쉽게 수정할 수 있습니다.

발작 증세: 간혹 그려져서는 안 되는 결과물로 그려지는 경우

제품이 간혹 기대와 벗어나게 렌더링되어 UX 경험을 낮추는 경우입니다. 이는 버그로 간주하고 있고, 개발자가 주의를 기울이면 이런 경우가 발생하지 않도록 예방할 수 있습니다.
하지만 이것도 컴포넌트의 설계와 연관된 경우가 있었습니다. 컴포넌트의 변화, 즉 variation이 많도록 설계가 되면 발작 증세가 쉽게 발현될 수 있습니다. 컴포넌트의 렌더링되는 경우의 수가 많으면 유연성이 높아집니다. 따라서 재사용을 할 수 있는 범위가 늘어난다는 장점이 있습니다. 하지만 재사용을 잘못하면 결함으로 여겨질 정도로 잘못된 결과물을 그리게 될 수도 있습니다. 즉 재사용을 할 수 있는 범위는 넓어지지만 제대로 사용하기 위해 큰 노력이 들기 때문에 오히려 재사용이 어려워지는 아이러니한 결과가 발생합니다. 비유하자면 완성된 가구 중에 선택할 수 있게 하는 게 아니라 가구를 조립하는데 필요한 각종 재료를 쥐여주는 것과 같습니다.
챕터의 발행일을 보여주는 간단한 컴포넌트를 예시로 들어보겠습니다.
notion imagenotion image
스토리북의 Controls 플러그인으로 발행일을 변경해가며 결과물의 변화를 확인해 볼 수 있습니다. 그런데 publishedDate가 문자열 타입을 입력으로 받다 보니 다음과 같은 결과물도 렌더링 될 수 있습니다.
최초의 기획 의도는 ‘YYYY.MM.DD’의 포맷으로 출력되기를 원했지만 개발자가 주의를 기울이지 않으면 기획 의도와 다른 형태로 출력될 여지가 있습니다.
하지만 publishedDate의 타입이 Date라면 렌더링되는 경우의 수는 훨씬 줄어듭니다.
notion imagenotion image
Headwind example
Date 타입을 입력받은 후 컴포넌트 내부에서 지정된 ‘YYYY.MM.DD’ 포맷으로 변환하도록 하면 더 이상 다른 포맷으로 출력될 수 있는 여지가 없습니다.
또 다른 발작 증세의 예시를 들어보겠습니다.
const Face = ({ eyesY }: { eyesY: number }) => {...}
const Face = ({ eyesY }: { eyesY: 'top' | 'middle' | 'bottom' }) => {...}
얼굴을 두 가지 방식으로 그려보았습니다.
하지만 전자의 경우는 <Face eyesY={9999} /> 으로, 입보다 아래에 눈이 달리는 기형적인 결과물을 그릴 수도 있습니다. 스토리북으로 옮겨보면 이러한 예외 케이스들을 상상의 나래를 펼쳐서 처리하는 것이 아니라 직접 확인해볼 수 있기 때문에 조금 더 쉽게 문제를 발견할 수 있습니다.
그렇다고 해서 컴포넌트의 변화를 제한하는 것이 무조건 좋은 것은 아닙니다. 앞서 언급했듯이 유연성이 높아질수록 재사용을 할 수 있는 범위가 늘어납니다. 추천하는 방법은 유연성이 높은 컴포넌트를 하나 설계해두고, 이를 바탕으로 목적과 용도에 맞고 유연성이 낮은 컴포넌트를 조립해 나가는 것입니다.
컴포넌트의 유연성을 확장하는 패턴
여기에서 유연성을 확장하는 패턴을 잠깐 소개하고 넘어가겠습니다.
대표적인 예시는 요소의 스타일을 외부에서 주입하는 방법입니다. classes라는 하나의 props에 tailwind의 클래스로 스타일을 주입하는 예시를 들어보겠습니다. 스타일 충돌을 처리하기 위해 tailwind merge를 사용하였습니다.
const Face = ({ classes }) => { return ( <> <Eyes className={classes.eyes} /> <Nose className={twMerge('mx-auto', classes.nose)} /> <Mouse className={twMerge('bg-red', classes.mouth)} /> </> ); };
또 다른 방법은 Compound Pattern 으로 작성하여 조합하는 방법입니다.
const Face = ({ children }) => { return <>{children}</>; }; const Eyes = () => <>{'...'}</>; const Nose = () => <>{'...'}</>; Face.Eyes = Eyes; Face.Nose = Nose;
위의 방법으로 먼저 유연한 컴포넌트를 설계한 뒤, 다음과 같이 다시 용도에 맞게 변화를 줄여 작성할 수 있습니다.
const AngryFace = () => ( <Face> <Face.Eyes className="scale-x-50" /> <Face.Nose className="bg-red" /> {'...'} </Face> );

피터팬 증후군: 독립해서 그려보니 의도와 다르게 그려지는 경우

컴포넌트가 다른 컴포넌트에 의존성을 가진 경우입니다. 특정 컴포넌트의 아래에 위치할 때는 문제가 없었으나 독립시켜서 해당 컴포넌트만 스토리북에 그려보았더니 예상과 다르게 그려지는 경우가 있습니다.
<div className="relative h-50 w-100"> <Thumbnail /> </div> // Thumbnail.tsx const Thumbnail = () => ( <div className="absolute right-0 h-50 w-50"> <img /> </div> );
앞선 그림처럼 컨테이너의 오른쪽에 위치하는 Thumbnail 컴포넌트는 부모 컴포넌트와 떨어져서 독립적으로 그려지는 순간 눈앞에서 사라집니다.
실제로 사라진 것은 아니고 화면의 맨 오른쪽으로 붙어버렸네요 😂. 상위 요소 중에 position: relative; 속성이 있는 요소를 기준으로 배치가 되기 때문에 의도하지 않은 위치에 배치가 될 수 있습니다. 이런 경우는 position: relative; 속성이 있는 부모 요소까지 함께 컴포넌트에 포함하면 환경과 관계없이 항상 예상된 결과물을 그릴 수 있습니다.
피터팬 증후군의 또 다른 예시입니다.
const ChapterTitle = () => { <div className="pt-34"> <InnerContent /> </div>; };
앱에서는 ChapterTitle 컴포넌트가 매우 자연스럽게 그려집니다. 컴포넌트 상단에 매겨진 여백이 적합한 문맥에 배치되어 있기 때문입니다.
스토리북에 그려보았더니 위에 지정된 여백이 있어 독립된 컴포넌트로 보기에 조금 어색합니다.
실제로 이 컴포넌트를 다른 곳에서 재사용하려고 할 때 단순히 여백 값이 달라 재사용을 못하는 경우를 마주하기도 합니다.
const ChapterTitle = () => <InnerContent />; <div className="pt-34"> <ChapterTitle /> </div>;
따라서 앞선 코드처럼 layout (여백)에 대한 책임을 분리하면 다른 layout에도 충분히 재사용을 할 수 있게 됩니다.

예시 3. 부모나 전역 환경에 항상 존재할 것이라고 가정하고 참조하는 경우

const Component = () => { const { openModal } = useModal(); // 전역에 선언된 모달을 띄워준다. <button onClick={() => openModal()} />; };
앱 상에서는 useModal을 실행하면 전역 환경에 선언된 모달을 띄워주는 기능을 잘 수행합니다. 하지만 독립된 스토리북 환경에서 컴포넌트를 실행하면 useModal을 처리하지 못하고 에러를 반환합니다. 환경에 의존성을 갖고 있는 경우입니다. 전역 환경에 useModal을 처리하는 기능이 다 구현되어 있다고 가정하고 구현했기 때문에, 그렇지 않은 환경에서 문제가 생깁니다.
const Component = ({ onOpenModal }) => <button onClick={() => onOpenModal()} />;
앞선 코드와 같이 환경에 의존성이 있는 부분을 분리하면 스토리북으로 옮기는게 가능해집니다. 스토리북의 ‘Actions’ 플러그인에서는 이러한 핸들러의 트리거 여부를 한눈에 확인할 수 있습니다.
이렇게 수정하고 나면 외부에서 의존성을 추가할 수 있으므로 실제로 코드를 재사용하고 목적에 맞게 수정하는 작업이 편해집니다.
이상으로 제가 스토리북을 작성해가는 과정에서 자연스럽게 얻을 수 있었던 리팩토링 효과들을 과로한 컴포넌트, 발작 증세를 일으키는 컴포넌트, 피터팬 증후군을 앓는 컴포넌트를 예시로 들면서 살펴보았습니다. 컴포넌트 설계 과정에서 어디에서 컴포넌트를 나누어야 하고 책임을 어디까지 두어야 하는지 막막할 때가 종종 있습니다. 그럴 때 스토리북을 작성하는 작업을 병행하거나 스토리에 올라갔을 때 결과물이 어떻게 그려질지 예상해보면 의외로 해답을 쉽게 구할 수 있을지도 모릅니다.