스토리북이 뭔데 ㅋㅋ
저같은 경우에는 코드스테이츠 과정을 들으면서 처음 알게된 라이브러리인데요
CDD를 도와주는 라이브러리라고 생각할 수 있습니다.
CDD란 Component Driven Development ChatGPT Driven Development
의 약자로 컴포넌트를 모듈 단위로 개발하여 UI 구축을 하는 개발 방법론입니다.
프로젝트 규모가 커지면 커질수록 컴포넌트를 독립된 환경에서 테스트하기 어려워집니다.
또한 컴포넌트가 해당 프로젝트에서만 멀쩡하게 UI를 표현하고
다른 프로젝트에서는 제대로 쓸 수 없는 경우도 많습니다.
따라서 독립된 환경에서 테스팅할 수 있도록 도와주는 스토리북은
저도 프로젝트가 어느정도 자리잡힌 환경에서는
아.. 스토리북이 있으면 이런 바보같은 일은 안해도 될텐데..
라고 생각하게 되는 경우가 잦아졌었습니다.
저는 라이브러리 / 프레임워크에 있어서는 내가 필요하다 생각하면 그때 사용하자
라는 생각을 가지고 있기 때문에 스토리북같은 경우에도
여러번 환경 설정을 시도해보고 간단하게 실습정도만 해본 뒤
실제 프로젝트에서는 적용해보지 않았었는데요
최근들어서는 테스트코드와 더불어 스토리북도 아.. 있으면 좋겠다.. 는 생각이 들어
도입을 준비해보고 있습니다.
서론이 길었는데 그래서 이번 글은 스토리북을 도입하기 이전에
제 두뇌풀가동과 검색으로 고민한 내용들을 서술해봅니다.
스토리북에 올라가는 컴포넌트들은 순수한게 좋다.
우리는 흔히 이런 코드들을 작성하곤 합니다.
import { useQuery } from '@tanstack/react-query';
import React from 'react';
interface WrongComponentProps {}
const WrongComponent = ({}: WrongComponentProps) => {
const hi = useQuery({
queryKey: ['it is wrong!!!!'],
queryFn: async () => {
return '그치만... 개발자쨩.. 이렇게 해버리면...';
},
});
return (
<div>
<div className=" bg-yellow-50">{hi.data}</div>
</div>
);
};
export default WrongComponent;
간단한 예시를 만들어봤습니다.
이코드는 개발자입장에서 편합니다.
prop을 내려줄 필요도 없고 새로운 파일을 만들고 컴포넌트를 만드느라 드는 고생도 없습니다.
별생각없이 이런식으로 작성해온 코드들이 많을것입니다.
하지만 이 코드는 스토리북에서 테스트하기 매우 까다롭습니다.
왜냐하면 이 컴포넌트는 외부에서 스타일에 대한 내용을 바꿀수도 없으며
리액트쿼리가 실행될 수 있는 환경이 없으면 성립되지 못하는 컴포넌트이기 때문입니다.
https://fe-developers.kakaoent.com/2022/220609-storybookwise-component-refactoring/
이런 경우 카카오 기술블로그를 참고하였을 때에는
Container/Presentational Pattern 으로 데이터 로직과 ui 로직을 분리시켜주는 것을 통해
고생스럽게 리액트쿼리를 모킹하는 대신 ui만 테스트 하는것이 가능하다고 소개를 하는데요
Container / Presentational 패턴은 예전에 프론트엔드에서 유행했던 컴포넌트 디자인 패턴입니다.
redux를 사용할 때에도 많이 사용했던 패턴인데요
프로바이더라는 외부 환경에 의존성을 가진 리덕스 로직들을 컨테이너 컴포넌트가 담당하고
프레젠테이션 컴포넌트에서는 전달받은 데이터를 가지고 ui를 그리는 일에만 집중하는 패턴입니다.
저도 한컴포넌트가 ui를 그리는 일과 데이터를 갖고 오는 일 두가지를 모두 수행하는 경우
로직을 건드리기 어려웠던 경험이 있어 이렇게 분리하는 것을 선호하곤 하는데요
제가 Container/ Presentational 패턴을 사용하면서 느꼈던 문제점 중 하나는
어느순간 정신을 차려보면 PropsDrilling이 과도해지기 쉽다는 것이었습니다.
분명 스토어에 상태를 넣어두고 그 상태를 꺼내쓰는데도 불구하고
원칙없이 사용하다보면 어느순간 전역상태를
ui 컴포넌트 안의 ui 컴포넌트 안의 ui 컴포넌트에게 넘기고있다는 것을 깨닫게 됩니다.
전역상태를 사용함으로인해 얻을 수 있는 주요 장점인
1. props drilling에서 자유로워짐
2. 상태를 그 상태가 필요한 곳에 가까이 배치하는 것이 가능해짐
두가지 장점이 퇴색되는 결과를 낳은 것이지요
이 문제를 해결하기 위해 새로운 원칙이 필요했습니다.
첫번째로 생각할 수 있는 것은
UI 컴포넌트의 depth가 깊어지는 것을 막는다면 드릴링 문제도 해결될것이라는 것이었습니다.
하지만 작은 Atom 단위의 컴포넌트들을 조합하여 하나의 컴포넌트를 만들고
그 컴포넌트들을 조합하여 또 하나의 무언가를 만드는 일이 잦으니
정신차려보면 UI 컴포넌트의 Depth가 한도끝도없이 깊어지기 쉬웠습니다.
그러나 그렇다고 컴포넌트를 기능 단위로 쪼개지 않으면 그건 또 그것대로 문제가 된다고 생각합니다.
이 문제를 어떻게 해결하면 좋을까요?
저도 정확히 답을 내리진 못한상태인데 지금 생각으로는 다음과 같이 생각하고 있습니다.
1. 순수하지않은 코드가 필요한 모든 컴포넌트를 최소 단위의 container/presentation으로 생각한다.
2. 1번과정을 통해 결론지은 presentation 컴포넌트들을 스토리북을 통해 개발한다.
3. container와 presentation 컴포넌트는 최대한 가까이 붙여놓는다
가설을 실천해보자
서버로부터 데이터를 받아와서 화면에 뿌려주어야하는 로직이 필요하다고 가정해보겠습니다.
이를 위해 필요한 것은 컨테이너와 프레젠테이션 컴포넌트입니다.
우선 내용이 빈 컴포넌트들을 모두 생성해주겠습니다.
/components/Container.tsx
/components/Presentation.tsx
/components/Presentation.stories.tsx
/components/MyPatternPage.tsx
컴포넌트들을 조립한 결과물은 MyPatternPage 컴포넌트에서 그려주도록 하겠습니다.
우선은 Container.tsx를 작성해주겠습니다.
/Container.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import Presentation from './Presentation';
interface ContainerProps {}
const Container = ({}: ContainerProps) => {
const hi = useQuery({
queryKey: ['hello world'],
queryFn: async () => {
await new Promise((res, rej) => {
setTimeout(() => {
res('success');
}, 5000);
});
return '데이터는 그냥 string이군요';
},
});
return <Presentation>{hi.data}</Presentation>;
};
export default Container;
이 Container의 관심사는 데이터를 가져오는 일입니다.
5초동안 로딩을 한 뒤 스트링을 반환하죠
데이터를 가져오고나면 그 데이터를 그리는 일은
프레젠테이션 컴포넌트에게 위임시키겠습니다.
이제 프레젠테이션 컴포넌트를 스토리북을 통해 개발해보겠습니다.
/Presentation.tsx
import React from 'react';
interface PresentationProps {
children?: React.ReactNode;
}
const Presentation = ({ children }: PresentationProps) => {
return <div>{children}</div>;
};
export default Presentation;
/Presentation.stories.ts
import type { Meta, StoryObj } from '@storybook/react';
import Presentation from './Presentation';
const meta = {
title: 'Example/Presentation',
component: Presentation,
tags: ['autodocs'],
} satisfies Meta<typeof Presentation>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
children: '안녕하세요',
},
};
스토리북을 통해 외부에서 자유롭게 데이터를 주입하면서 테스트할 수 있네요!
그리고 사용자 경험을 위해 비동기적으로 데이터를 가져와야하는 경우에는
비동기로 인해 로딩 / 에러가 되는 부분을 최소한으로 잡아주어
사용자가 다른 콘텐츠는 볼 수 있도록 해주는 것을 저는 중요하게 생각합니다.
따라서 suspense / errorboundary도 최대한 좁게 설정해주겠습니다.
저는 항상 서스펜스와 바운더리는 같이 쓰기때문에
둘을 합친 커스텀 서스펜스 바운더리를 사용하고 있습니다.
import { ErrorBoundary, Suspense } from '@suspensive/react';
interface SuspenseBoundaryProps {
error: React.ReactElement;
suspense?: React.ReactElement;
children?: React.ReactNode;
}
const SuspenseBoundary = ({
children,
error,
suspense,
}: SuspenseBoundaryProps) => {
return (
<ErrorBoundary fallback={error}>
<Suspense fallback={suspense}>{children}</Suspense>
</ErrorBoundary>
);
};
export default SuspenseBoundary;
테스트할때 못생겼으니까 약간 더 스타일링해줬습니다.
이제 MyPatternPage에서 서스펜스바운더리로 래핑하여 컨테이너를 사용해주겠습니다.
'use client';
import React from 'react';
import Presentation from './Presentation';
import SuspenseBoundary from './SuspenseBoundary';
import Container from './Container';
const LOADING_TEXT = '데이터를 가져오는 중이에요';
const ERROR_TEXT = '에러입니다.';
const MyPatternPage = () => {
return (
<div>
<SuspenseBoundary
suspense={<Presentation>{LOADING_TEXT}</Presentation>}
error={<Presentation>{ERROR_TEXT}</Presentation>}
>
<Container />
</SuspenseBoundary>
</div>
);
};
export default MyPatternPage;
에러처리 / 로딩처리는 SuspenseBoundary가 맡으면서
데이터를 가져오는 일이라는 비순수한 일은 Container가
UI를 그리는 관심사는 Presentation 컴포넌트가 맡도록하여
각 컴포넌트들이 하나의 일만 수행할 수 있어졌습니다.
따라서 로딩 / 에러중에 보여주어야하는 화면역시
그저 presentation 컴포넌트를 그대로 가져와서
에러일때 / 로딩일때 보여주어야할 텍스트만 주입해주는 것으로 개발할 수 있었습니다.
만약 로딩/에러중에 텍스트만 바뀌는것보다
로딩 인디케이터를 보여주거나 다른 이미지를 보여주어야하는게 더 낫다면
얼마든지 그에 대한 처리를 수행해줄 수 있습니다.
마치며
프레젠테이션 컴포넌트의 유연성을 어느정도로 가져가는게 좋을지가
고민이 많이 되는 것 같습니다.
회사의 경우 디자인 시스템이 완전히 정립되지 못한 상태여서
컴포넌트를 최대한 유연하게 만들지 않으면 디자이너분의 시안을 반영하기 어려운 문제가 있는데요
시스템 구축에 들일 리소스는 부족한 상태이기 때문에
어쩔수없이 컴포넌트를 최대한 유연하게 설계하고 있습니다.
그러다보니 디자인시스템이 있으나 없으나와 같은 느낌이 들더라구요
어쩌면 좋을지 고민입니다.
'frontend' 카테고리의 다른 글
npm 라이브러리를 만들고 publish 하기 (1) | 2023.11.28 |
---|---|
next.js , tailwindcss, path alias storybook 세팅하기 (2) | 2023.11.07 |
프론트엔드에서 좋은 폴더 구조는 무엇일까? (1) | 2023.10.25 |
radix와 framer-motion을 통합하고 exit animation을 주는 방법 (1) | 2023.10.03 |
radix 의 기본 focus css 제거하기 (0) | 2023.09.30 |