profile pic in top navigation
아토믹 디자인과 커스텀 훅을 활용해 컴포넌트 구조 리팩토링하기2024.06.17
refactoring
component
atomic design
hooks
post thumbnail

2023년 11월부터 구름톤 유니브 1기 해커톤에서 만난 5명의 팀원과 사이드 프로젝트로 24절기로 쓰는 일기장: 시즈닝(Seasoning) 개발을 시작했어요. 프론트엔드 개발로 참여해 약 3개월 동안의 시간 동안 MVP 기능 구현을 마쳤고 3월 초에 서비스를 런칭했습니다.

3월 한 달 동안 감사하게도 100명이 넘는 사용자가 서비스를 찾아 주었고, 이에 따라 팀에서는 기존의 기능을 수정하거나 새로운 화면을 추가하는 작업이 빈번했어요. 인스타그램 홍보와 외부 홍보 부스를 마련할 기회가 몇 번 있었기 때문에 사용성 피드백에 즉각 대응해서 최대한 빠르게 수정 사항을 적용해야 했습니다.

1. 리팩토링을 결정하기까지

작은 단위의 기능을 여러 화면에 걸쳐 빠르게 추가하는 일이 잦아지자, 기존 프로젝트 구조에서 불편한 점이 다수 느껴졌습니다. 그중 대부분은 이전에 기능을 구현하면서 불편함을 느꼈지만 추후 개선을 다짐하며 넘어갔던 부분들이었어요.

기존 프로젝트 구조

1src
2├── main.jsx /* 엔트리 포인트 */
3├── index.css // 전역 스타일 시트
4├── assets // 이미지, 폰트, ...
5├── ...
6├── components // 컴포넌트 → 사용된 페이지를 기준으로 분류
7│   ├── common
8│   ├── feed
9│   ├── home
10│   │ ├── FortuneModal.jsx
11│   │ ├── YearlyContent.jsx
12│   │ ├── SeasonalContent.jsx
13│   │ └── ...
14│   └── ...
15├── pages // 페이지 컴포넌트
16│   ├── feed
17│   ├── home
18│   │ └── HomePage.jsx
19│   └── ...
20└── utils // 공통 비즈니스 로직 (API Loader, Routing, ...)
21 ├── api
22 ├── hooks
23 ├── router
24 └── ...

시즈닝은 리액트로 개발되었고 자바스크립트로 작성되었습니다. 각 페이지 컴포넌트는 src/pages에서 화면 기준 디렉토리를 적용해 관리하고 있으며 컴포넌트들 또한 src/components에서 사용되는 공통 최상단 페이지를 기준으로 분류했습니다.

컴포넌트나 페이지의 스타일링은 Styled-components로 작성해 내부 로직과 함께 하나의 파일 안에서 보관하도록 했어요. 그 밖에 API 요청이나 라우팅처럼 공통 비즈니스 로직에 관한 부분은 src/utils 내에서 관리하도록 했고요.

기존 구조의 문제점

1. 재사용되지 않는 컴포넌트와 기능 추가에 따르는 리소스 낭비

백엔드 팀에서 업로드된 이미지를 자체적으로 리사이징해 최적화하는 파이프라인을 구축한 후, 프로필 이미지에 대해서만 먼저 테스트 배포를 올리기로 했습니다. 그리고 프론트엔드 팀에서 이에 대응해 최적화된 이미지 로딩 시도가 실패하면 원본 이미지를 대체 로딩해야 하는 기능 추가 작업이 주어졌어요.

이에 따라 프로필 이미지가 사용되는 마이페이지와 친구 피드 페이지 등에서 수정된 프로필 이미지 로직을 일괄적으로 반영해야 했습니다. 하지만 기존의 구조로는 동일한 기능 수정을 컴포넌트가 적용된 페이지 각각에 여러 번 반영해야 했어요.

기존 구조에서도 여러 화면에서 동일하게 사용되는 부분을 컴포넌트로 분리해 개발한 부분이 있었습니다. 위 예시의 절기 게시글 작성 페이지와 절기 게시글 수정 페이지에서는 기존의 게시글 정보 (텍스트 컨텐츠, 첨부 이미지, …) 를 우선 로딩해 초기 데이터로 제공하는지의 여부를 제외하면, 나머지 폼 데이터 처리와 작성 중인 게시글 컨텐츠를 편집하는 로직은 두 페이지가 거의 같은 코드를 공유하고 있었어요.

따라서 공통으로 묶을 수 있는 기능을 @components/write/ContentEditor 컴포넌트로 분리했습니다. 하지만 ContentEditor 컴포넌트가 수행하는 기능 중 어느 부분까지 재사용하도록 분리해야 할지 애매하다고 느꼈어요. 다시 말해 “컴포넌트의 재사용성”에 관한 일관되고 보다 명확한 기준이 필요했습니다.

2. 한 파일에 혼재된 기능과 뷰

시즈닝은 Styled-components를 사용해 스타일링을 한 덕분에 각 컴포넌트에 대해서 스타일 오염에 대한 걱정 없이 관리할 수 있었고 JSX 코드상으로도 DOM을 구성하는 각 부분의 역할이 명확하게 드러나 편리했습니다. 대신 하나의 컴포넌트나 페이지 파일의 코드가 너무 길어진다는 단점이 있었어요.


1// @components/write/ImageSlider.jsx
2// ...
3
4/* 스타일링 */
5const Layout = styled.div`
6 ...
7`;
8
9const ImagesContainer = styled.div`
10 ...
11`;
12
13const DotsContainer = styled.div`
14 ...
15`;
16
17// ...
18
19const ImageSlider = ({
20 editable,
21 images,
22 setImages,
23 ...
24}) => {
25 /* 상태 */
26 const [activeDotIndex, setActiveDotIndex] = useState(0);
27 const imageScrollRef = useRef();
28
29 /* 내부 로직 */
30 const handleDotClick = (index) => { ... };
31 const handleImageChange = (index) => { ... };
32 const handleImageDelete = (index) => { ... };
33 const handleImageScroll = () => { ... };
34
35 /* 컴포넌트 몸체 */
36 return (
37 <Layout>
38 {images.length > 0 && (
39 <ImagesContainer ref={imageScrollRef} onScroll={handleImageScroll}>
40 {images.map((image, index) => (
41 <section key={index}>
42 ...
43 </section>
44 ))}
45 </ImagesContainer>
46 )}
47
48 ...
49 </Layout>
50 );
51};
52
53export default ImageSlider;

더욱 문제를 느꼈던 부분은 어떤 컴포넌트의 스타일과 기능을 두 명의 프론트엔드 개발자가 다른 작업에서 동시에 접근해야 하는 경우였습니다. 기능과 뷰를 부분적으로 나누어 관리할 수 있다면 충돌에 대한 염려를 줄일 수 있는 부분이라고 생각했어요.

그뿐만 아니라 여러 컴포넌트에서 동일한 로직을 공유하는 부분들도 있었습니다.


1// @pages/feed/FeedPage.jsx
2const FeedPage = () => {
3 const { initialFeedData } = useLoaderData();
4 const [feedData, setFeedData] = useState(initialFeedData);
5 const [lastFeedItemId, setLastFeedItemId] = useState(
6 initialFeedData.length > 0 ? initialFeedData.at(-1).article.id : null
7 );
8 // ...
9
10 const fetchFeedData = async () => {
11 // 마지막에 호출한 아이템의 id를 받아 Infinite Scroll 방식의 Pagination 수행
12 if (!lastFeedItemId) return;
13
14 try {
15 const feedResponse = await axios.get(
16 // ...
17 );
18 } catch (error) {
19 // ...
20 }
21 };
22
23 // Intersection Observer API를 활용해 Infinite Scroll 이벤트 처리
24 const { focusElementRef } = useRefFocusEffect(fetchFeedData, []);
25
26 return (
27 <Layout>
28 ...
29
30 <ContentArea>
31 {feedData.map((data, index) => (
32 <FeedItem key={index} data={data} />
33 ))}
34
35 <div ref={focusElementRef} />
36 </ContentArea>
37
38 <TabBar />
39 </Layout>
40 );
41};
42
43export default FeedPage;

친구 피드 페이지(/feed)나, 알림 페이지(/notification)등 일부 페이지에서는 API 구조가 페이지네이션 구조로 설계되어 있습니다. 따라서 프론트엔드 팀에서는 초기 데이터를 fetch한 후, 화면의 최하단 부분이 스크롤 되었는지의 여부를 Intersection Observer API를 이용해 감지하고 추가로 필요한 데이터를 다시 fetch하도록 Infinite Scroll 로직을 구현했어요. 그리고 이를 Infinite Scroll을 적용한 페이지들에서 활용하기 쉽게 하기 위해 커스텀 훅으로 분리했고요.

하지만 지금까지의 프로젝트에서는 기능의 재사용성을 많이 고려하지는 못했어요. 위의 예시처럼 눈에 띄게 분리할 수 있고 여러 곳에서 사용하는 로직이 아니라면 대부분은 뷰와 얽혀 있는 형태로 컴포넌트 내에 존재했기 때문이었습니다. 하지만 컴포넌트가 의존하는 뷰와 로직을 분리할 수 있다면 공유되는 로직에 관한 부분들 또한 효율적으로 재사용하도록 개선할 수 있겠다고 생각했어요.

3. 혼란스러운 스타일링 규칙들


1// @components/.../SomeComponent.jsx
2// ...
3
4const Layout = styled.div`
5 ...
6`;
7
8const TitleContainer = styled.div`
9 ...
10`;
11
12const OptionBox = styled.div`
13 ...
14`;
15
16const IconWrapper = styled.div`
17 ...
18`;
19
20// Layout? Container? Box?

컴포넌트와 페이지 스타일링에서 일관적인 네이밍 컨벤션이 필요하다는 것을 예전부터 느껴왔습니다. 필요에 의해 다른 네이밍을 활용하는 것은 좋다고 생각하지만, 300여개의 커밋이 쌓인 단계까지 와서는 일관적인 기준이 꼭 필요하다는 것을 체감했습니다.

두 명의 프론트엔드 개발자가 CSS 속성을 작성하며 암묵적인 기준에 따라 작업했지만 문서화된 기준이 있었다면 헷갈리지 않고 참고하며 코드를 작성했을 것이라는 점이 아쉬웠어요. 전체적으로 리팩토링을 수행하며 이를 명확하게 정리할 필요가 있다고 생각했습니다.


현재 프로젝트 구조에서 새로운 기능을 추가하는 데 쓰는 시간적 리소스보다 리팩토링에 드는 리소스가 적게 든다는 생각이 들었고, 주저 없이 프론트엔드 팀 내에서 리팩토링을 제안했습니다. 하지만 모든 부분을 한 번 만에 개선하기는 어렵기에 어떤 것들을 이번 리팩토링에서 먼저 적용할지 꽤 고민했어요.

  1. 재사용할 수 있는 컴포넌트 구조
  2. 재사용할 수 있는 비즈니스 로직의 분리

결과적으로, 이번 리팩토링에서는 기반이 되는 컴포넌트와 비즈니스 로직의 재사용 가능한 구조를 정리하는 것을 핵심으로 고려하기로 했습니다. 앞으로 개선하거나 추가할 사항들에 영향을 끼치는 중요한 부분이라고 생각했기 때문입니다.

또한 서비스를 구성하는 컴포넌트와 비즈니스 로직을 리팩토링하려면 프로젝트의 세세한 부분까지 수정할 필요가 있으므로, 그 과정에서 일관된 컨벤션을 확립하고 이를 적용해 나가는 것 또한 부차적으로 신경 쓸 수 있겠다고 생각했습니다.

2. 아토믹 디자인에 기반한 재사용 가능한 컴포넌트 구조

아토믹 디자인은 컴포넌트의 결합을 atommoleculeorganismtemplatepage 단위로 나누며, 작은 단위의 결합을 통해 큰 단위를 만드는 계층적 모델을 표현하는 방식입니다. “아토믹” 디자인이라는 이름은 이를 제안한 Brad Frost가 컴포넌트가 결합하는 방식을 유기체의 화학적 결합 모델을 끌어와 설명하는 것에서 이름을 붙였다고 해요.

아토믹 디자인은 여러 계층을 거치며 컴포넌트가 효율적인 형태로 결합되는 멘탈 모델을 제공하지만 계층이 나뉘어 있다고 해서 반드시 선형적으로 수행되어야 하는 프로세스는 아닙니다. 또한 저자가 웹 애플리케이션을 예시로 들고는 있지만 모바일 앱 등의 디자인 시스템을 구성할 때도 얼마든지 활용할 수 있는 방법이고요.

이 포스트에서는 아토믹 디자인을 너무 자세히 설명하는 대신, 원문을 직접 참고해 보시는 것을 추천드려요. 그리고 저는 이를 사용하기로 결정한 이유와 도입한 결과에 집중해 설명하려고 합니다.

효용성 검토

아토믹 디자인을 프로젝트 개발 초기부터 염두에 두고 있었으나 몇 가지 고민을 해결하지 못한 채 도입을 보류해 왔습니다.

  1. 디자인 시스템이 확립되어 있지 않다면 효율적이지 못한 채 구조만 복잡하게 만들 수 있음
  2. Molecule과 organism을 나누는 판단 기준이 명확하지 않을 수 있음

시즈닝 개발 초기에는 여러 화면에서 공용되는 컴포넌트 요소가 많지 않았고, 재사용을 통해 얻는 이점보다 아토믹 디자인의 계층 구조에 무리해서 맞추려다 발생하는 리소스 낭비가 더 크지 않을까 하는 염려가 있었어요. 게다가 아토믹 디자인을 프로젝트에 도입한 사례들을 보면 많은 경우에 molecule과 organism의 애매한 판단 기준을 고민하는 경우가 많았고 한창 개발이 빠르게 이루어져야 하는 시점에서 이런 고민을 더 얹을 필요는 없다고 생각했습니다.

하지만 지금 시점에서는 많은 고민이 해소되었고 이전보다 긍정적으로 아토믹 디자인의 도입을 바라보게 되었습니다.

  1. 재사용성에 기반해 적은 코드의 수정으로 여러 화면에 적용 가능
  2. Atom부터 단계적으로 적용할 수 있는 테스트 코드
  3. 서비스의 규모가 크지 않으면서도 다양한 기능 확장이 예상됨

개발 과정에서 디자인 팀과 지속적으로 디자인 시스템 수정을 거듭하며 현재는 재사용할 수 있는 부분들이 늘어나는 방향으로 많은 수정이 이루어졌습니다. 대부분의 기능을 구현한 시점에서 어떤 부분이 재사용되는지도 비교적 명확하게 드러나기도 했고요. 이에 따라 atom은 물론, molecule과 organism 등 단위를 나누는 데 필요한 논의가 체감할 수 있을 만큼 줄어들었다고 느꼈습니다.

또한 프로젝트를 개발하며 가장 아쉬웠던 점이 Storybook, Jest 등을 이용한 단위 테스트를 적용하지 않은 것이었는데, 아토믹 디자인을 활용한 사례에서 atom부터 단위 테스트를 적용해 차근차근 테스트 커버리지를 높인 사례를 보고 테스트 환경으로의 구축에서 징검다리 역할을 하기 좋은 구조라는 생각이 들었습니다.

컴포넌트 구조를 리팩토링하는 것은 단순히 지금까지의 코드를 수정하는 것을 넘어 프론트엔드 팀이 앞으로 마주할 기능 수정에 대해 준비하는 기회라고 느껴졌습니다. 현재 시즈닝의 기능과 앞으로 추가될 기능을 두고 판단했을 때, 아토믹 디자인에 기반한 구조가 적절한 수단이라고 판단을 내렸고 결론적으로 채택하게 되었어요.

Atom

  • 컴포넌트를 구성하는 가장 작은 단위
  • 혼자서는 어떠한 기능을 수행할 수 없는 단위
  • 레이아웃에 관련된 요소는 제외

Button, Image, Text 등 위의 기준에 부합하는 작은 컴포넌트들을 atom으로 분류했어요. 처음부터 atom을 생각나는 대로 떠올리며 분류하는 대신 여러 화면에 걸쳐 순차적으로 아토믹 디자인을 적용해 가면서 위의 기준을 충족하는 공통 컴포넌트를 atom으로 분리했습니다.

초기에는 Row, Column 등 자주 사용하는 레이아웃 스타일도 atom으로 분리했으나, 레이아웃에 관련된 요소는 디자인 시스템의 일부라고 판단하기 어렵다고 생각해 제외했습니다. 아래에서 설명할 스타일 주입 과정에서도 코드가 너무 길어져 재사용에 대한 이점이 크지 않다고 판단했고요.

추가: 스타일을 외부에서 props로 주입하기

최대한 재사용되는 컴포넌트를 작성하기 위해서 해당 컴포넌트가 적용되는 모든 상황을 고려해 기본 인터페이스를 구체화하려고 고민했습니다. 그리고 그중에서도 특히 atom 요소의 스타일을 정하는 props에 대해서 고민을 충분히 하고 싶었습니다. 상위 컴포넌트들에서 포함하기 위해서 리팩토링 초기 이후에는 형태가 변경되지 않아야 한다고 생각했기 때문입니다.

재사용되는 컴포넌트를 설계하는 여러 사례를 참고하며, 컴포넌트에 필요한 기본 인터페이스는 어느 정도 정해 놓고 컴포넌트를 사용하는 시점에 필요한 스타일을 추가로 주입하도록 하는 패턴을 봤는데요, 이는 납득할 수 있는 좋은 방법이라고 느꼈으나 어디까지 주입하게 할 지에 대해서는 시즈닝의 상황에 집중해 조금 더 고민할 필요가 있다고 생각했습니다. 따라서 각 atom 컴포넌트가 사용될 수 있는 여러 상황을 충분히 고려하고, 자주 변경되는 속성만을 기본 props로 정의했습니다.


1// atoms/Text/index.jsx
2export default function Text({
3 notoserif,
4 size = 1,
5 weight = 400,
6 color = `#333`,
7 children,
8 ...props
9}) {
10 return (
11 <S.Layout
12 notoserif={notoserif}
13 size={size}
14 weight={weight}
15 color={color}
16 {...props}
17 >
18 {children}
19 </S.Layout>
20 );
21}
1// molecules/MyFeedItem/index.jsx
2import Text from '@components/atoms/Text';
3// ...
4
5export default function MyFeedItem({ articleId, term, year, image, preview }) {
6 return (
7 <S.Layout to={`/article/${articleId}`}>
8 <S.InfoContainer>
9 <section>
10 <Text notoserif size="1.625" weight="700">
11 {TermsToChinese[term]}
12 </Text>
13 <Text
14 notoserif
15 size="0.75"
16 color="#bfbfbf"
17 style={{ paddingBottom: '0.25rem' }}
18 >
19 {TermsToKorean[term]}
20 </Text>
21 </section>
22
23 <Text
24 notoserif
25 size="0.75"
26 color="#bfbfbf"
27 style={{ paddingBottom: '0.25rem' }}
28 >
29 {year}년
30 </Text>
31 </S.InfoContainer>
32
33 <FeedItem image={image} preview={preview} />
34 </S.Layout>
35 );
36}

예시로 Text 컴포넌트는 주로 font-size, font-weight, 그리고 color 속성을 매번 다르게 가집니다. 아주 가끔은 line-height 속성이나 font-family 속성이 다르게 정해져야 하는 경우도 있었고요. 따라서 Text atom의 기본 속성은 위처럼 정해 놓고, 이를 제외한 속성에서 변경이 필요한 경우는 style = {{ ... }}처럼 style props를 주입하는 방식이 효과적이라고 느꼈습니다.

추가: Styled-components 파일 분리하기

기존에 Styled-components를 사용해 각 컴포넌트에 독립적인 스타일링을 적용하는 방식은 분명 편리한 점이 있었습니다. 문제는 각 컴포넌트 파일의 길이가 너무 길어지고 뷰(스타일)와 로직을 구분하기 어려웠다는 부분이었어요. 그리고 이를 해결할 만한 좋은 방법을 정리한 포스트를 찾았습니다.


1// SomeComponent/style.js
2import styled from 'styled-components';
3
4export const Layout = styled.div` ... `;
5export const Header = styled.header` ... `;
6// ...
1// SomeComponent/index.jsx
2import * as S from './style.js';
3import OtherComponent from '...';
4
5export default function SomeComponent() {
6 // ...
7
8 return (
9 <S.Layout to={`/article/${articleId}`}>
10 <S.Header>
11 <S.ProfileContainer> ... </S.ProfileContainer>
12 </S.Header>
13 <OtherComponent />
14 <S.Footer> ... </S.Footer>
15 </S.Layout>
16 );
17}
  • style.js: 컴포넌트의 스타일 요소
  • index.jsx: 컴포넌트의 몸체

위 방법처럼 스타일 요소를 별개의 파일로 분리한 후 import해 사용함으로써 대체로 길이가 긴 스타일 요소들을 떼어내 한 컴포넌트의 코드 길이가 지나치게 길어지는 것을 방지할 수 있었습니다. JSX 상에서 다른 컴포넌트와 S prefix가 붙은 스타일 요소를 구분하기 쉬운 것은 또 하나의 장점이었고요.

아토믹 디자인을 적용하며 컴포넌트별로 디렉토리를 만들었기 때문에, 그 안에서 스타일 요소(style.js)를 분리하는 방식은 컴포넌트 파일(index.jsx)에서 불러오기에도 직관적이라고 느껴졌습니다. 또한 이후 커스텀 훅을 이용해 로직을 재사용하도록 한 단계에 앞서 컴포넌트 파일의 관심사 영역을 분리한 것이 도움이 됐어요.

Molecule

  • 고유한 특성을 가지기 시작하는 단위
  • (고유한 실행 컨텍스트 내에서) 하나의 기능을 수행하는 컴포넌트

ArticleHeader, NavigationHeader, TabBar 등 고유한 특성을 가지는 단위 컴포넌트들을 molecule로 분류했습니다. 또한 molecule을 구성하는 단계에서 자연스럽게 atom을 활용해 조립하듯이 컴포넌트를 구현했어요.

앞선 아토믹 디자인 도입 사례들을 통해 molecule은 SRP (Single Responsibility Principle: 단일 책임 원칙) 에 의해 “하나의 역할”을 수행하도록 해야 하며 이것이 atom과 molecule을 구분하는 이상적인 기준이라는 내용을 접할 수 있었는데요, 저는 이러한 역할이 “고유한 실행 컨텍스트” 내에서 존재해야 설득력이 있다고 생각했습니다.

예시로 Button의 경우 클릭 이벤트 발생 시 전달한 핸들러 함수가 실행되는 순간 고유의 기능을 수행한다고 본다면 atom과 molecule 중 어떤 것으로 분류할지 헷갈릴 수 있지만, 클릭 이벤트 자체는 특정 컨텍스트와 결부해 수행된다고는 보기 어려웠어요. 따라서 이러한 애매한 상황들을 구분하기에 쉬워졌습니다.

추가: Semantic Tags

컴포넌트들을 아토믹 디자인을 통해 다시 정리해 나가면서 역할이 비교적 뚜렷한 부분들은 HTML Semantic Elements (이하 semantic tag) 를 이용했어요. 예시로 -Carousel이나 -List 등의 컴포넌트는 ul로, -ListItemli로 명시해 리스트 성격을 갖고 있음을 나타냈습니다. NavigationHeaderArticleHeader 등은 header 요소로 명시했고요.

Semantic tag는 SEO (: 검색 엔진 최적화) 를 위해 활용할 수 있는 방법으로 가장 잘 알려졌지만, 모바일 기기의 브라우저에서 읽기 도구 등이 웹 페이지를 더 잘 표시할 수 있도록 돕는 좋은 방법이기도 합니다. (참고: Building websites for Safari Reader Mode and other reading apps) 이번 서비스에서는 기획상 SEO가 요구되는 부분은 없었으나, 웹 접근성을 향상하는 동시에 화면을 구성하는 요소들의 성격을 명확하게 표현할 수 있는 좋은 방법이라고 생각해서 컴포넌트 작성 시 적극적으로 고려했습니다.

Organism

  • 좀 더 복잡한 인터페이스 조각
  • Molecule을 내부에 반복적으로 가지는 컴포넌트
    • Molecule을 Item으로 가지는 List, Grid
    • Carousel

복잡한 기존 페이지들을 리팩토링하기에 앞서 꼭 결정하고자 했던 것 중 하나는 molecule과 organism을 나누는 기준이었습니다. 그리고 고민 끝에 내부에 molecule을 반복적으로 포함하고 있는 경우에 한해서 organism으로 표현하기로 결정했습니다. 시즈닝을 구성하는 각 기존 페이지 대부분은 molecule의 조합으로도 구성할 수 있었고, 굳이 재사용되는 부분이 아니라면 organism까지 나누지 않고 molecule만으로 컴포넌트의 기능을 모두 구현하는 게 효율적이라고 생각했기 때문이에요.

예시로 위의 TabBar는 각 페이지로 전환하는 4개의 TabBarItem을 갖고 있지만, 각 TabBarItem을 molecule로 만들어 TabBar에 포함되도록 하는 대신 스타일 요소로 취급하도록 했습니다. TabBarItem이 단순히 Link 역할만 수행할뿐더러 다른 컴포넌트에서 재사용될 가능성이 작다고 생각했기 때문이에요.

MyFeedItemFriendsFeedItem은 절기 등의 상태 정보를 나타내는 상단 부분만 다르고 이미지를 포함한 콘텐츠 영역은 동일하게 공유하는데요, 따라서 해당 부분을 molecule인 FeedItem으로 분리하고 이를 포함한 두 컴포넌트에서 활용했습니다. 다만 내부에 FeedItem을 포함하고 있다는 이유만으로 MyFeedItemFriendsFeedItem을 organism으로 분류하기보다는 세 컴포넌트 모두를 molecule로 분류했습니다. FeedItem을 내부에 가지고 있지만 반복적으로 가지고 있지 않기 때문에 List와 같은 복잡한(!) 컴포넌트와는 구분되어야 한다고 생각했어요.

앞선 상황과 다르게 마이페이지와 계정 설정 페이지 등에서 사용되는 MenuListMenuListItem을 내부에 반복적으로 포함하고 있는 형태인데요, MenuListItem은 클릭 시 다른 페이지로 넘어가거나 특정 액션을 수행하는 등 기능을 type props에 따라 다르게 수행하도록 할 필요가 있었고 재사용성을 고려해 molecule로 구현했습니다. 이에 따라 내부에 molecule을 포함하고 있는 컴포넌트인 MenuList는 organism으로 구현해 아토믹 디자인 구조상에서 좀 더 복잡한 상위 인터페이스라는 점을 명확히 구분하고자 했어요.

이렇게 molecule과 organism을 구분하게 되면서 더 복잡한 인터페이스를 판단하는 기준을 “반복적인 내부 컴포넌트를 가지고 있는 경우”로만 한정할 수 있어 판단이 좀 더 쉬워졌습니다.

추가: 컴포넌트 네이밍

컴포넌트의 이름을 정하는 경우 “사용되는 용도”와 “컴포넌트의 형태”를 결합한 이름으로 고려했으며, 최대한 혼동하는 경우를 줄이고자 보편적으로 사용되는 용어 위주로 정리했습니다. 어떤 컴포넌트에 대해 약속된 이름이 있다면 그 이름을 사용하는 게 컴포넌트의 기능을 명확히 나타내는 방법이라고 생각했기 때문이에요.

예를 들면 위의 화면에서 이미지를 가로로 스와이프할 수 있도록 하고 현재 표시되는 이미지의 순서를 인디케이터로 나타내는 컴포넌트는 기존에 ImageSlider라는 이름으로 불렀지만, 아토믹 디자인을 적용하며 Carousel이라는 이름을 사용하도록 변경했어요. 이 부분에서는 Apple Human Interface Guideline (HIG)에 제시된 기본 컴포넌트들의 이름을 다수 참고했습니다.

Template

  • 레이아웃 배치만을 위한 단위
    • 하나의 page는 중첩 라우팅을 기준으로 최소 하나의 template에 1대1 대응
  • 상태 관리 등 비즈니스 로직은 일괄 제외

페이지의 내용을 그리는 데 필요한 레이아웃과 스타일을 배치하고 조립하기 위해 template 내에서 앞서 구현한 atom과 molecule, organism을 호출했어요. Page에 앞서 template을 분리해 컴포넌트를 배치하는 단계는 주입되는 데이터에 대해 다양하게 존재할 수 있는 케이스를 고려하고 하위 계층의 컴포넌트를 다시 수정할 수 있는 기회를 돕습니다.

Template과 page는 특정 페이지에 해당하는 내용만을 표현하기 위해 사용되는 단위이므로 1대 1로 대응된다는 특성이 있습니다. 단, HomePage에서 /home?category=year처럼 Search Parameter에 따라 다르게 렌더링되는 영역이 존재하는 경우에는 template을 중첩해 표현했어요.

추가: HoC 패턴으로 공통 바깥 레이아웃을 분리

친구 목록 페이지나 계정 설정 페이지처럼 하나의 뎁스를 추가해서 진입하는 경우 위처럼 Navigation Layout을 사용하는데요, 1대 1로 대응되는 template의 특성을 유지하면서도 겹치는 코드를 줄일 필요가 있었습니다.

하지만 동일한 원리로 모달의 공통 배경 영역을 분리하는 레이아웃을 활용하는 경우에는 공통 바깥 레이아웃 영역을 template으로 분리하도록 하면 molecule에 해당하는 컴포넌트에서 상위 template을 포함하는 코드가 되고, 이는 자연스럽지 않다고 생각했어요.


1// hoc/withNavigation/index.jsx
2import * as S from './style';
3
4import NavigationHeader from '@components/molecules/NavigationHeader';
5
6export default function withNavigation(
7 navigationTitle = 'Empty Title',
8 WrappedContent,
9 CustomBackButton
10) {
11 return (props) => {
12 return (
13 <S.Layout>
14 <NavigationHeader
15 title={navigationTitle}
16 CustomBackButton={CustomBackButton}
17 />
18
19 <WrappedContent {...props} />
20 </S.Layout>
21 );
22 };
23}
1// templates/NoticeTemplate/index.jsx
2// ...
3import withNavigation from '@components/hoc/withNavigation';
4
5function NoticeTemplate({ noticeData }) {
6 return (
7 <S.Layout>
8 { /* ... */ }
9 </S.Layout>
10 );
11}
12
13export default withNavigation('공지사항', NoticeTemplate); // 함께 감싸서 export

따라서 이런 경우 HoC(Higher-order Component) 패턴을 사용해 hoc/withNavigation으로 공통 레이아웃 영역을 분리한 후, template 내에서 함께 export해서 사용했습니다. 모달의 경우도 레이아웃의 바깥 영역을 공통으로 사용했기 때문에 동일한 패턴으로 hoc/withModalBackground로 모달을 감싸는 배경 레이아웃과 클릭 이벤트를 처리하는 함수 등을 분리해 활용했어요.

Page

  • 최종적으로 표현되는 UI를 완성하는 단위
  • 컨텐츠, 컨텍스트, 비즈니스 로직을 Template에 주입

하나의 페이지에 대응되는 인터페이스 조각들을 template에서 지정했다면, page에서는 해당 페이지에 필요한 데이터, 상태 등을 관리하고 template에 주입하도록 했습니다. 전달 과정에서는 React의 Context API를 사용해 페이지별로 전역 context를 활용하도록 했고요. (Context에 대해서는 아래에서 한 번 더 말씀드리려고 합니다.)

아토믹 디자인 적용 후 개선된 프로젝트 구조


1src
2├── main.jsx
3├── index.css
4├── assets
5├── ...
6├── components // 컴포넌트: 아토믹 디자인에 기반해 atom, molecule, organism, template을 분류
7│   ├── atoms
8│   ├── molecules
9│   ├── organisms
10│   ├── templates
11│   └── hoc
12├── pages // 페이지 컴포넌트: 아토믹 디자인 기반 page를 분류
13│   ├── feed
14│   ├── home
15│   └── ...
16└── utils
17 ├── api
18 ├── hooks
19 ├── router
20 └── ...

하루 1~2시간의 시간을 들여 꾸준히 아토믹 디자인을 적용해 나간 지 한 달 반여 만에 PR을 올릴 수 있었습니다. 어쩌면 코드를 작성하는 시간보다도 현재 프로젝트에 적용하기 위한 규칙들을 생각하는 데 쓴 시간이 더 많은 것 같기도 합니다... 결과적으로 아토믹 디자인을 적용하기 전에 비해 컴포넌트의 디렉토리 구조가 훨씬 보기 쉬워졌습니다. 물론 보기 쉬운 것보다 더 와닿은 장점들이 많이 있었어요.

마침 아토믹 디자인 적용 작업이 완료되고 나서 바로 기능 추가 요청이 있었습니다. 매 절기 기록장이 열릴 때마다 전송했던 절기 알림 서비스를 뉴스레터에서 카카오 친구 채널톡으로 변경하기로 하고, 서비스 내에 카카오 채널 친구를 유도하는 팝업과 페이지를 추가하기로 했습니다.

이에 따른 추가 작업은 정말 오래 걸리지 않았습니다. 네비게이션 타이틀을 사용하는 페이지였기 때문에 hoc/withNavigation으로 감싸 주었고, 설정 메뉴 리스트를 제공하기 위해 organisms/MenuList에 필요한 데이터를 주입했어요. 새로 추가해야 하는 페이지와 기능이 간단한 이유도 있었겠지만, 적어도 이전에 비해 이미 디자인 시스템 안에 있는 컴포넌트를 활용하는 작업이 놀라울 정도로 직관적이라는 사실을 체감했습니다.

3. 커스텀 훅을 이용한 재사용 가능한 비즈니스 로직의 분리

React 16.8 버전과 함께 Hooks가 등장하며 React에서 컴포넌트를 작성하는 패러다임이 크게 바뀐 적이 있었습니다. 기존에는 클래스형 컴포넌트만을 통해서 컴포넌트의 생명 주기를 다룰 수 있었기 때문에 함수형 컴포넌트의 용도는 단순한 UI를 표현하는 정도로 제한되었습니다.

하지만 Hooks의 등장으로 함수형 컴포넌트를 이용해 생명 주기를 포함한 상태 관리 로직을 쉽게 구현할 수 있게 됐습니다. 가장 중요한 포인트는 컴포넌트 내의 기능을 생명 주기가 아닌, 비슷한 기능을 수행하는 로직을 기준으로 추상화할 수 있게 되었으므로 로직을 분리하는 것이 훨씬 자연스러워졌다는 점입니다.

관심사의 분리

왜 로직을 분리하는 것이 좋을까요? 이는 컴퓨터 과학의 소프트웨어 설계 원칙 중 하나인 관심사의 분리 (SoC: Separation of Concerns) 에 기반합니다.

위의 회원 가입 폼을 예시로 들어 보면 유저가 입력하는 정보는 닉네임, 이메일, 비밀번호가 있으며 React에서는 각 값에 대응하는 input 필드의 이벤트를 핸들링하는 함수가 필요합니다. 만약 비밀번호가 지켜야 하는 규칙이 존재한다면 이를 검증하는 로직도 필요할 거고요. 마지막에 이러한 데이터들을 모아 FormData를 전송하는 함수도 있어야 할 것입니다. 예시에서는 아주 기본적인 회원 정보만을 포함했지만, 만약 동일한 페이지에서 프로필 사진을 설정하는 기능이 추가된다면 해당 코드는 기존 회원 가입 폼에 대한 코드와 함께 존재할 것입니다. 이렇듯 더 많은 기능이 존재할수록 코드의 특정 부분만을 수정하는 작업은 복잡해질 것임이 틀림없습니다.

만약 페이지 컴포넌트 내에서 비슷한 내용을 처리하는 로직이 서로 구분되어 있다면 어떨까요? 회원 가입 폼의 비밀번호 필드에 대한 규칙을 수정한다고 해도, 프로필 사진을 관리하는 부분은 영향을 받지 않을 것입니다. 또한 프로필 사진을 관리하는 동일한 코드가 다른 페이지에서도 사용되는 예시를 생각해 본다면 이를 재사용하기 위해서 분리하는 것이 좋다는 것은 더 받아들이기 쉬운 사실이기도 하고요.

16.8 버전 이전 React에서 주로 사용했던 클래스형 컴포넌트는 로직을 재사용하기에는 적절치 않은 구조였습니다. 컴포넌트 사이에서 공유하는 로직을 사용하기 위해서는 컴포넌트를 입력받아 다른 컴포넌트를 반환하는 HoC 패턴 등을 강제적으로 사용해야 했습니다.


1// withFormHandler.js
2function withFormHandler(WrappedComponent) {
3 return class extends Component {
4 state = {
5 username: '',
6 email: ''
7 };
8
9 handleInputChange = (event) => { ... };
10 handleSubmit = (event) => { ... };
11
12 render() {
13 return (
14 <WrappedComponent
15 {...this.props}
16 formData={this.state}
17 handleInputChange={this.handleInputChange}
18 handleSubmit={this.handleSubmit}
19 />
20 );
21 }
22 };
23}
1// SignUpForm.js
2import withFormHandler from '...';
3
4class SignUpForm extends React.Component {
5 render() {
6 const { formData, handleInputChange, handleSubmit } = this.props;
7
8 return (
9 <form onSubmit={handleSubmit}>
10 <input
11 type="text"
12 name="username"
13 value={formData.username}
14 onChange={handleInputChange}
15 placeholder="이름을 입력하세요."
16 />
17 <button type="submit" />
18 { /* ... */ }
19 </form>
20 );
21 }
22}
23
24// 반드시 다른 컴포넌트를 감싸는 형태로 사용되어야 함
25export default withFormHandler(SignUpForm);

하지만 이러한 방법들은 컴포넌트 단위로 로직을 분리하는 방식이었으므로 반드시 다른 컴포넌트를 감싸는 형태로 사용되었습니다. 따라서 어떤 클래스형 컴포넌트가 여러 공통 로직을 동시에 사용하고자 한다면 그만큼 여러 Wrapper 컴포넌트가 쌓이는 구조, 즉 Wrapper Hell를 자연스레 유발하게 됩니다. 이는 보기에도 좋지 않지만 props를 다루는 방식에서 더욱 복잡했습니다.

Custom Hooks


1// useForm.js
2import { useState } from 'react';
3
4function useForm(initialValues) {
5 const [values, setValues] = useState(initialValues);
6
7 const handleChange = (event) => { ... };
8 const handleSubmit = (event) => { ... };
9
10 return { values, handleChange, handleSubmit };
11}
12
13// SignUpForm.js
14import useForm from '...';
15
16function SignUpForm() {
17 const { values, handleChange, handleSubmit } = useForm({ username: '', email: '' });
18
19 return (
20 <form onSubmit={handleSubmit}>
21 <input
22 type="text"
23 name="username"
24 value={values.username}
25 onChange={handleChange}
26 placeholder="이름을 입력하세요."
27 />
28 <button type="submit" />
29 { /* ... */ }
30 </form>
31 );
32}
33
34export default SignUpForm;

다시 커스텀 훅 얘기로 돌아오겠습니다. Hooks의 등장과 함께 컴포넌트 내에서도 로직을 분리할 수 있는 방법이 생기며 사용자 정의 훅(커스텀 훅)을 활용하는 방식이 빠르게 확산됐습니다. 각 훅은 독립적인 실행 컨텍스트를 가지므로 여러 곳에서 활용해도 서로 영향을 주지 않음이 보장되며, 따라서 커스텀 훅 안에 커스텀 훅을 포함하는 것도 가능했습니다. 추가적인 React 컴포넌트 계층을 구성하지 않고도 로직의 관심사 분리를 더 잘 수행할 수 있는 방법이 생긴 것입니다.

React Documentation의 번역본을 인용하면, 커스텀 훅은 “복잡한 로직을 단순한 인터페이스 속에 숨길 수 있도록 하거나 복잡하게 뒤엉킨 컴포넌트를 풀어내도록” 하는 과정을 돕습니다. 앞선 리팩토링에서 컴포넌트의 뷰와 기능을 나누고 뷰를 재사용하는 과정에 아토믹 디자인을 활용했다면, 로직을 재사용하는 과정에 커스텀 훅을 적극적으로 활용해 보면 좋겠다고 판단한 이유입니다.

아토믹 디자인에서의 Page-level Context 관리하기

시즈닝에서는 페이지를 그리는 데 필요한 데이터를 fetch하고 나서 페이지가 렌더링되도록 하기 위해 React-router의 API Loader를 활용했어요. 이전에는 page 안에서 loaderData를 전달받고 컴포넌트에 전달하기까지의 모든 흐름이 이루어졌지만 아토믹 디자인을 적용하고 나서는 컴포넌트를 배치하고 데이터를 주입하는 역할을 template에 위임했습니다. 그리고 page는 필요한 상태와 로직을 context로 묶어 페이지 전역으로 뿌리는 역할만을 수행하면 되는 상황이었습니다.

하지만 page가 데이터와 비즈니스 로직을 관리하고 또 제공하는 두 가지 역할을 맡게 되면서 page 내 코드가 너무 길어질 수 있다는 문제를 발견했습니다. Context를 provide하는 부분은 문맥상 page 안에 위치하는 게 적절했지만, context에 포함될 비즈니스 로직까지 전부 page에 위치시킬 필요는 없다고 느꼈어요.


1// pages/NotificationPage.jsx
2import { useLoaderData } from 'react-router-dom';
3import {
4 NotificationContext,
5 createNotificationContext,
6} from '@contexts/NotificationContext';
7
8import NotificationTemplate from '@components/templates/NotificationTemplate';
9
10export default function NotificationPage() {
11 const loaderData = useLoaderData();
12 const notificationContextValue = createNotificationContext(loaderData);
13
14 return (
15 <NotificationContext.Provider value={notificationContextValue}>
16 <NotificationTemplate />
17 </NotificationContext.Provider>
18 );
19};
1// contexts/NotificationContext.js
2// ...
3import { useState, useEffect, createContext, useContext } from 'react';
4
5export const NotificationContext = createContext();
6
7export function useNotificationContext() {
8 return useContext(NotificationContext);
9}
10
11export function createNotificationContext(loaderData) {
12 const { initialNotificationData } = loaderData;
13
14 const [notifications, setNotifications] = useState(initialNotificationData);
15 const [friendRequests, setFriendRequests] = useState([]);
16 const [otherNotifications, setOtherNotifications] = useState([]);
17 // ...
18
19 const fetchNotificationData = async () => { ... };
20
21 useEffect(() => {
22 // ...
23 }, [notifications]);
24
25 const { observerRef } = useIntersectionFocus(fetchNotificationData, []);
26
27 return { friendRequests, notifications: otherNotifications, observerRef };
28}

따라서 페이지마다 전역으로 제공된 상태와 비즈니스 로직을 관리하는 부분을 커스텀 훅으로 분리했어요. 또한 context의 provider와 consumer를 위한 인터페이스를 create*PageContext(), use*PageContext()로 제공해 page와 template에서 사용하도록 했습니다. 그 결과, context 영역을 page에서 분리함으로써 page가 전역 context의 provider 역할만을 수행하도록 할 수 있었습니다.


1// pages/NoticePage.jsx
2import { useLoaderData } from 'react-router-dom';
3
4import NoticeTemplate from '@components/templates/NoticeTemplate';
5
6export default function NoticePage() {
7 const { noticeData } = useLoaderData();
8
9 return <NoticeTemplate noticeData={noticeData} />;
10}

모든 페이지가 전역 context로 데이터를 전달하도록 통일하지는 않았습니다. 예시로 NoticePage 같은 경우 API loader로 전달된 데이터 이외에 상태 관리나 로직이 필요하지 않은 구조였기 때문에 template으로 내려 주는 props로 fetch한 공지사항 데이터를 전달했습니다. 만약 이후에 기능 추가가 필요하다면 그때 context를 분리하고 기능 추가 요소를 구현해 주는 방식으로 확장이 가능할 것으로 생각했기 때문입니다.

폼 데이터를 핸들링하는 훅 분리하기

동시에 여러 상태를 포함하는 페이지일수록 많은 비즈니스 로직에 의존하며 context 파일의 코드가 길어지는 경향이 있었어요. 프로필 수정 페이지나 게시글 작성 및 수정 페이지처럼 폼 데이터를 활용하는 페이지가 가장 대표적인 예시였습니다.

해당 페이지들의 로직을 살펴보면 useState()로 입력 필드의 값을 상태로 관리하거나 useEffect()로 전체 폼의 유효성을 검사하는 등 기본 훅을 활용하고 있었기 때문에, 커스텀 훅으로 분리하기에 적절했습니다. 특히 게시글 작성 페이지와 수정 페이지의 경우는 초깃값을 전달하는 부분을 제외하면 같은 로직만으로 구성되었다고 봐도 무방했어요.


1// hooks/useProfileForm.jsx
2// ...
3import { useEffect, useState } from 'react';
4import { useNavigate } from 'react-router-dom';
5
6export default function useProfileForm(initialUserData) {
7 const [userData, setUserData] = useState(initialUserData);
8 const [warningType, setWarningType] = useState(`NO_WARNING`);
9 const [warningText, setWarningText] = useState('');
10 const [isValidForm, setIsValidForm] = useState({
11 validId: true,
12 validNickname: true,
13 validForm: true,
14 });
15 // ...
16
17 const handleChangeId = (event) => {
18 // ...
19
20 /* 필드의 유효성 검사 */
21 const regex = /^(?=[a-z0-9._]{5,20}$)(?!.*[.]{2})[^.].*[^.]$/;
22 if (regex.test(newId)) {
23 setIsValidForm({ ...isValidForm, validId: true });
24 } else {
25 setIsValidForm({ ...isValidForm, validId: false });
26 }
27 };
28
29 const handleChangeName = (event) => { ... };
30
31 useEffect(() => {
32 if (isValidForm.validId && isValidForm.validNickname) {
33 setIsValidForm({ ...isValidForm, validForm: true });
34 } else if (!isValidForm.validId) {
35 // ...
36 } else {
37 // ...
38 }
39 }, [userData]);
40
41 const handleClickSubmit = async () => {
42 // ...
43
44 try {
45 const formData = new FormData();
46
47 if (isImageChanged) {
48 const base64Data = images[0].imageData.split(',')[1];
49 const imageBlob = base64ToBlob(base64Data, images[0].imageType);
50 formData.append(
51 `image`,
52 imageBlob,
53 `profile.${images[0].imageExtension}`
54 );
55 } else {
56 // ...
57 }
58
59 const formJson = JSON.stringify({
60 image_modified: isImageChanged,
61 accountId: userData.accountId,
62 nickname: userData.nickname,
63 });
64
65 const formBlob = new Blob([formJson], { type: 'application/json' });
66 formData.append('request', formBlob);
67
68 // ...
69 } catch (error) {
70 console.error('Error:', error);
71 }
72 };
73
74 return {
75 handleImageForm: {
76 image: images[0],
77 imageInputRef,
78 // ...
79 },
80 handleProfileForm: {
81 userData,
82 isValidForm,
83 handleChangeId,
84 handleChangeName,
85 handleClickSubmit,
86 // ...
87 },
88 };
89}
1// contexts/EditProfileContext.js
2// ...
3import useProfileForm from '@utils/hooks/useProfileForm';
4
5export function createEditProfileContext(loaderData) {
6 const prevUserData = loaderData.userData;
7
8 const { handleImageForm, handleProfileForm } = useProfileForm(prevUserData);
9 const { image, imageInputRef, handleImageReplace, handleImageChange } =
10 handleImageForm;
11 const {
12 userData,
13 isValidForm,
14 handleChangeId,
15 handleChangeName,
16 handleClickSubmit,
17 // ...
18 } = handleProfileForm;
19
20 return {
21 image,
22 userData,
23 // ...
24 };
25}

이에 따라 편집 중인 프로필 정보나 게시글 정보를 상태로 관리하고 폼 데이터를 전송하는 로직을 useProfileForm(), useArticleForm()과 같이 커스텀 훅으로 분리했습니다. 각 커스텀 훅 안에서는 기본 훅을 활용해 입력 상태를 보관하고 패턴을 비교해 검증하는 등의 함수를 옮겨 작성했어요.

또한 이렇게 분리한 커스텀 훅 사이에서도 공통된 관심사를 가지는 영역이 존재했습니다. 예시로 프로필 수정 페이지와 게시글 작성 및 수정 페이지에서는 공통으로 이미지 형식의 파일 입력 기능을 제공합니다. 프로필 수정 페이지는 단일 이미지 첨부, 게시글 작성 페이지에서는 최대 2개의 이미지를 첨부한다는 차이는 있었지만 이미지 업로드 및 교체 등 제공하는 기능이 거의 동일했기에 커스텀 훅으로 분리해 볼 수 있는 부분이라고 생각했습니다.


1// hooks/useImageForm.jsx
2// ...
3import { useState, useRef } from 'react';
4
5export default function useImageForm(initialImages, MAX_IMAGES) {
6 const [images, setImages] = useState([...initialImages]);
7 const [replacingImageIndex, setReplacingImageIndex] = useState(null);
8 const imageInputRef = useRef(null);
9
10 const handleImageUpload = () => {
11 if (images.length < MAX_IMAGES) {
12 imageInputRef.current.click();
13 }
14 };
15
16 const handleImageReplace = (replacingImageIndex) => {
17 imageInputRef.current.click();
18 setReplacingImageIndex(replacingImageIndex);
19 };
20
21 const handleImageDelete = (deleteImageIndex) => {
22 setImages((images) =>
23 images.filter((_, index) => index !== deleteImageIndex)
24 );
25 };
26
27 const handleImageChange = (event) => {
28 const file = event.target.files && event.target.files[0];
29 const { fileName, fileExtension, fileType } = getFileInfo(file);
30
31 // ...
32 event.target.value = null;
33 };
34
35 return {
36 images,
37 setImages,
38 imageInputRef,
39 handleImageUpload,
40 handleImageReplace,
41 handleImageDelete,
42 handleImageChange,
43 };
44}
1// hooks/useProfileForm.jsx
2// ...
3import useImageForm from '@utils/hooks/useImageForm';
4
5export default function useProfileForm(initialUserData) {
6 const { images, imageInputRef, handleImageReplace, handleImageChange } =
7 useImageForm(
8 [
9 {
10 imageName: 'prev-profile-image',
11 imageExtension: undefined,
12 imageType: undefined,
13 imageData: initialUserData.image,
14 },
15 ],
16 1
17 );
18
19 // ...
20}

결과적으로 이미지 입력을 처리하는 로직을 useImageForm() 훅으로 한 번 더 분리해, 앞서 작성한 useProfileForm()useArticleForm() 훅에서 이를 활용하도록 했습니다. 현재 입력된 이미지 배열을 가리키는 상태와 이미지 교체 및 삭제 등을 수행할 수 있는 핸들러 함수들을 선언해 인터페이스로 제공했어요.

관심사가 비슷한 코드를 커스텀 훅으로 분리하며 느낀 또 하나의 장점은 실행 컨텍스트 내에서 서로를 의존하는 로직을 함께 두면서, 외부에는 꼭 필요한 인터페이스만을 제공하도록 숨길 수 있었다는 점입니다. 덕분에 꼭 전달해야 할 데이터만을 전달함으로써 props drilling 문제를 부분적으로 개선했습니다.

4. 리팩토링을 마치며

커스텀 훅 적용 후 개선된 프로젝트 구조


1src
2├── main.jsx /* 엔트리 포인트 */
3├── index.css // 전역 스타일 시트
4├── assets // 이미지, 폰트, ...
5│   ├── fonts
6│   ├── svg
7│   └── ...
8├── ...
9├── components // 컴포넌트: 아토믹 디자인 기반으로 분류
10│   ├── atoms
11│   ├── molecules
12│   ├── organisms
13│   ├── templates
14│   └── hoc
15├── pages // 페이지 컴포넌트: 아토믹 디자인의 page를 라우팅 기반으로 분류
16│   ├── feed
17│   ├── home
18│   └── ...
19├── contexts // 페이지 컨텍스트: 페이지 단위로 분류
20└── utils // 공통 비즈니스 로직 (API, Routing, ...)
21 ├── api
22 ├── hooks // 커스텀 훅: 재사용성을 고려해 분류
23 │ ├── useArticleForm
24 │ ├── useImageForm
25 │ ├── useIntersectionFocus
26 │ └── ...
27 ├── router
28 └── ...

아토믹 디자인에 이어 다시 한 달 반 정도가 지난 끝에 비즈니스 로직의 재사용성을 고려한 리팩토링 작업이 끝났습니다.

  1. 재사용할 수 있는 컴포넌트 구조 → 아토믹 디자인 적용
  2. 재사용할 수 있는 비즈니스 로직의 분리 → 커스텀 훅 활용

리팩토링을 진행하는 과정이 거의 모든 컴포넌트의 코드를 한 번씩은 수정하는 큰 단위의 작업이었던 만큼 방향을 잃을 뻔한 적이 많았지만 맨 처음 생각했던 리팩토링 목표에 집중하려 했습니다. 또 구조에 대한 고민이 필요한 순간마다 코드를 작성하기에 앞서 충분히 시간을 두어 고민하고 결정했어요. 덕분에 아토믹 디자인 패턴과 커스텀 훅 각각을 적용하는 데에 더 익숙해질 수 있던 계기가 되었다고 느꼈습니다.

  • 컴포넌트 28개, 코드 8,466줄 → 컴포넌트 37개, 코드 7,358줄

프로젝트의 디렉토리 구조는 크게 변하지 않았지만, 안에 담긴 코드는 이전보다 비교해 훨씬 간결해졌습니다. 프로젝트의 assets 디렉토리를 제외한 src 디렉토리 내 코드 라인 수를 측정 (.jsx, .js, .css 기준) 해 본 결과, 기능이 몇 차례 추가된 후의 브랜치를 비교했음에도 컴포넌트의 개수와 코드 총수가 훨씬 줄어들었기에 여러 군데 중복된 코드가 삭제되고 재사용되는 코드로 대체되었다는 것을 체감했어요.

아토믹 디자인에 대한 시선

아토믹 디자인을 적용하면서 많은 고민이 필요했던 부분은 아토믹 디자인이 하나의 개념적 모델이기 때문에 현실의 코드에 적용하기 위해서 몇 가지 규칙을 세워야만 했다는 점입니다. 어떤 요소들을 atom으로 구분할지, 레이아웃에도 아토믹 디자인 구조를 적용할지, 그리고 molecule과 organism을 어떤 기준으로 나눌지 등에 합리적이고 적용하기 쉬운 규칙을 만들고자 시간을 충분히 두어 고민했습니다.

예시로 molecule과 organism을 구분하는 판단 기준의 경우에 적용 사례마다 각 개발 주체에서 자체적으로 해석해 다르게 적용한 경우가 많았습니다. 저 또한 이를 참고해 지금까지의 프로젝트를 개발하며 느낀 문제점들을 근거로 자체적인 규칙을 세웠어요.

많은 적용 사례에서 아토믹 디자인은 Silver Bullet (: 모든 상황을 한 번에 해결하는 완벽한 해결책) 이 아님을 강조했는데요, 프로젝트의 상황을 고려해서 적절하게 사용하는 것이 방법론에 매몰되지 않고 활용할 수 있는 방법이라는 것을 직접 적용해 보며 느꼈습니다.

재사용성을 고려하며 느낀 점

어떤 화면을 구성하는 컴포넌트들에 아토믹 디자인을 적용하는 작업은 그 컴포넌트 하나만을 두고 이루어지는 작업이라기보다는 전체 컴포넌트를 매번 참고하는 작업에 가까웠습니다. 컴포넌트의 재사용되는 부분에 대해 전체 화면을 고려해야 했기 때문입니다.

시즈닝 팀에서 컴포넌트 구조와 UI, 디자인 시스템에 걸쳐 많은 부분에 수정 권한을 가지도록 해 주었기 때문에 이번 리팩토링에서 많은 결정을 빠르게 내릴 수 있었지만, 만약 프로젝트가 어느 정도 구조를 이루기 전 초기부터 아토믹 디자인을 적용하기로 했더라면 어떤 부분들을 재사용할지 디자인 팀과 프론트엔드 팀이 긴밀하게 고민하며 결정할 필요가 있었겠다는 것을 느꼈습니다.

Context API를 통한 전역 상태 관리의 문제점

React에서 전역 상태를 다룰 수 있는 도구인 Context API를 제공하지만 Redux, Recoil 등 상태 관리 라이브러리가 더 널리 사용되는 하나의 큰 이유는 위에서 내려오는 props의 변경에 의해 형제 이하 요소의 Re-rendering이 발생하는 현상을 핸들링하기 까다롭다는 점입니다. Children props를 활용해 전달함으로써 Re-rendering을 방지하는 대표적인 해결법도 있지만 서비스의 기능이 복잡해질수록 고려해야 하는 것들 또한 복잡해질 것입니다.

시즈닝에서 페이지의 전역 상태를 Context API로 제공하도록 한 부분은 필요한 러닝 커브가 거의 없다는 점도 있었지만, 사실 가장 큰 이유는 상태 관리 라이브러리를 사용해야 하는 상황을 실제로 마주해 보고 싶었기 때문이었습니다. 상태 관리 라이브러리를 사용해야 한다면 Context API가 해결해 주지 못하는 문제가 무엇인지를 직접 느껴 보고 그 시점부터 도입을 고려해야 한다고 생각했습니다. 특히 최근에는 많은 곳에서 TanStack Query를 사용해 server state와 client state를 관리한다고 느꼈는데, 어떤 필요성에 의해 이런 도구들을 사용하게 되었는지의 흐름을 이해하는 게 중요하다고 생각했습니다.

이번 리팩토링에서 아토믹 디자인을 적용하며 하위 컴포넌트들을 상위 컴포넌트가 감싸는 형태로 화면을 구성하다 보니 Re-rendering 문제가 보다 와닿았습니다. 또 페이지 단위로 전역 context를 구성했지만 ListListItem의 관계처럼 같은 데이터 알맹이를 공유하며 감싸진 컴포넌트끼리는 자체적인 context를 가지도록 해도 좋겠다고도 생각했어요. Context API로 구현할 수 있었던 부분들이지만, 더 나은 구조를 위해 상태 관리 라이브러리들이 제공하는 기능을 활용하는 것도 충분히 시도해 볼 만한 방향이라는 것을 직접 느꼈습니다.

타입스크립트 도입의 필요성

아토믹 디자인 적용 과정에서 atom 컴포넌트의 스타일을 필요시 외부에서 주입하도록 했는데, 이 과정에서 스타일을 주입받는 요소는 안에 어떤 내용이 있는지를 걸러내지 못한 채 주입받을 수밖에 없다는 단점이 있었습니다. 또한 전역 context를 활용하는 부분에서도 제공하는 로직이 복잡해지며 context의 provider와 consumer 사이에 인터페이스가 정의되면 좋겠다고 생각했습니다.

이 과정에서 타입스크립트의 필요성을 느꼈습니다. 타입스크립트를 사용하면 props나 context를 통해 값이 전달되는 경우에 안전한 형태의 값만 포함되도록 보장할 수 있으며, 에디터에서 제공하는 기능을 통해 편리하게 전달되는 값의 인터페이스를 들여다볼 수 있으니까요. 어떤 블로그 포스트를 지나치다가 읽었던 "가장 안전한 방법은 코드를 작성하는 단계에서 알 수 없는 값이 전달될 가능성 자체를 없애버리는 것" 이라는 표현에 공감했습니다.

이전부터 타입스크립트의 장점을 알고도 러닝 커브 때문에 쉽사리 프로젝트에 적용하지는 못했습니다. 하지만 iOS 개발을 접하고 또 다른 정적 언어인 Swift에서 타입 가드를 통해 엄격하게 값을 처리하는 것을 보며 타입스크립트에서 활용하는 타입 관련 패턴을 좀 더 이해하게 되었어요. 특히 현재 프로젝트에 적용해야 할 필요성이 많이 드러난 만큼 꼭 적용해야 한다고 다시 한번 느꼈습니다.

그 외에도...

일관적으로 사용되는 속성을 최우선 기준으로 삼고 이를 제외한 스타일을 외부에서 주입하도록 했음에도 컴포넌트를 사용하는 부분에서의 스타일 props가 너무 길어지는 단점이 있었습니다. 이 부분을 Tailwind CSS 같은 Utility-first CSS로 대체한다면 효율적으로 스타일을 적용할 수도 있겠다고 느꼈어요.

폼 데이터를 다루는 부분을 커스텀 훅으로 분리하면서, 여러 상태를 보기 쉽게 관리할 필요가 있음을 느꼈습니다. 아직 useReducer()와 같은 기본 훅을 익숙하게 사용하지는 못했기 때문에 어려움을 겪었던 부분이지만 복잡한 상태를 다룰 때 Reducer 함수로 접근하는 방식이 선택이 아닌 필수라고 느껴졌어요. 커스텀 훅을 분리하는 것으로 끝나는 게 아니라 훅 내부에서도 효율적인 데이터 흐름을 만드는 데 더 많이 고민해야 한다고 생각했습니다. 기본 훅을 활용해 상태의 흐름을 다루는 데 익숙해진다면 그때 더 나아가서 react-hook-form 같은 도구를 적용해 봐도 좋겠다 싶었습니다.

아토믹 디자인을 적용한 대부분의 사례를 참고하며 Storybook을 활용해 컴포넌트 개발을 진행하는 것의 직관성과 편리함을 느꼈습니다. 이번 작업에서는 Storybook을 적용하기 좋은 형태를 갖추는 데에서 마쳤지만 어쩌면 사용 경험이 없다는 이유로 초기에 도입하지 못한 게 아쉽다고 생각한 부분이었습니다.

이번 리팩토링에서는 기존 프로젝트의 많은 부분에 관여해 왔기에 코드가 어떤 맥락으로 작성되었는지를 대부분 알고 있었고, 따라서 코드를 수정해 의도하지 않은 에러가 발생할 가능성이 작았습니다. 하지만 잘 모르는 부분을 리팩토링해야 했다면 어떻게 기존 코드를 빠르게 이해할 수 있었을까 싶은 생각이 들었어요. 이와 같은 맥락에서 테스트 코드문서화의 필요성을 체감했습니다. 앞으로 주어질 많은 상황에서는 코드를 처음부터 작성하기보다는 기존에 작성된 코드에 기능을 덧붙이는 일이 훨씬 많을 거라는 생각에, 협업 과정에서 내가 작성한 코드를 다른 누군가가 이해할 수 있도록 돕는 행위가 중요하다고 느꼈습니다.


이번 작업을 진행하면서 좋은 프로젝트의 구조에 대해 여러 사례를 접하고 고민할 수 있는 계기가 됐습니다. 이것저것 적용해 보고 싶은 흥미로운 기술도 많았지만, 본질이 흐려지지 않도록 시작했을 때 목표로 다짐했던 두 가지만을 중점적으로 리팩토링하려고 했어요.

적지 않은 시간이 지나 작업을 마무리한 지금, 적용하고 수정한 것보다는 솔직히 아직 적용하지 못한 리팩토링 밖의 것들에 대한 갈증이 더 선명하게 느껴집니다. 그래도 좋은 구조에 대해 고민하는 시간이 쌓일수록 더 좋은 코드를 작성하는 습관으로 이어진다는 믿음을 가지면 해소되어 갈 수 있는 부분이라고 생각해요.

끝으로, 이 포스트에서 진행한 리팩토링 작업은 크게 두 Issue로 나누어 작업했으며 위 PR 링크를 통해 더 자세히 확인할 수 있습니다. 긴 글 읽어 주셔서 감사드려요. 😙

References