본문으로 건너뛰기

"공통 컴포넌트" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

모든 태그 보기

· 약 10분
최지훈

들어가며

글을 읽는 이유

  • FE와 BE 협업 프로젝트로 야놀자 클론 프로젝트를 한 경험이 있습니다.

  • 이때 가장 어려움을 겪었던 점은 공통 컴포넌트를 사용하는 페이지가 늘어나면서 공통 컴포넌트에 점점 많은 조건이 추가 되었고, 이로인해 더 이상 공통 컴포넌트라고 부를 수 없을 만큼 코드 복잡도가 높아졌던 점입니다.

  • 이에 프로젝트 종료 후 유지 보수에 어려움을 겪고있어 해당 글을 통해 컴포넌트를 분리하는 기준과 방법을 알아보고 싶습니다.

요약

  1. 컴포넌트는 웹 앱을 구성 하는 데 있어 가장 작은 단위이다.

  2. 컴포넌트를 나누는 기준

    A. 재사용성 컴포넌트

    • HTML 요소를 고려한 컴포넌트 분리
    • 중복을 고려한 컴포넌트 분리 (props, children 활용하기)

    B. 복잡성 컴포넌트

    • 컴포넌트가 여러 책임을 가질 때 분리
    • 컴포넌트에 비지니스 로직이 있을 때 분리

    C. 리렌더링 컴포넌트

    • 컴포넌트와 관련이 없는 상태가 있을 때 분리

주요 내용

컴포넌트에 대한 전반적인 내용

컴포넌트

  • 웹 앱을 구성 하는 데 있어 가장 작은 단위

컴포넌트를 만들 때 가장 많이 발생하는 실수 다섯 가지

  1. 복잡한 컴포넌트를 만든다.
  2. 하나의 컴포넌트에 여러 책임을 추가한다.
  3. 몇몇 동작하는 부분을 결합하여 컴포넌트를 만든다.
  4. 비지니스 로직을 컴포넌트에 추가한다.

언제 나눠야 할까?

컴포넌트를 만드는 기준, 즉 나누는 기준 중 가장 많이 선택되는 이유는 (A) 재사용성과 (B) 복잡성입니다.

컴포넌트를 나누는 기준

A. 재사용성 컴포넌트

  1. HTML 요소를 고려한 컴포넌트 분리

    function ListComponent(...) {
    return (
    <ul>
    <li>
    <h3>...</h3>
    <p>...</p>
    </li>
    <li>
    <h3>...</h3>
    <p>...</p>
    </li>
    </ul>
    );
    }

    위 코드의 컴포넌트를 분리한다고 했을때, li 태그로 묶은 ItemComponent 보다, 리스트 요소 말고 다른 곳에서도 사용할 수 있는 SomethingComponent를 하나의 컴포넌트로 묶는 것이 좋다.

    function ItemComponent(...) {
    return (
    <li>
    <h3>...</h3>
    <p>...</p>
    </li>
    );
    }

    // 보다

    function SomethingComponent(...) {
    return (
    <>
    <h3>...</h3>
    <p>...</p>
    </>
    );
    }
  2. 중복을 고려한 컴포넌트 분리 (props, children 활용하기) 이렇게 둘 이상의 컴포넌트에서 사용할 재사용 가능한 컴포넌트를 만들 때 가장 큰 특징 중 하나는 조건문 입니다.

    완벽하게 같은 걸 사용하면 문제가 안 되지만 서로 다른 부분이 있다면 조건문이 들어가게 됩니다. 우리가 현실에서 마주하는 재사용 컴포넌트는 아래와 같이 점점 거대해지곤 합니다.

    function Page1() {
    return (
    <ul>
    <li>
    <Card ... />
    </li>
    </ul>
    );
    }

    function Page2() {
    return (
    <ul>
    <li>
    <Card ... />
    </li>
    </ul>
    );
    }

    function Card(props) {
    const [a, setA] = useState(props.a ? props.foo : props.bar);
    const condition1 = props.a && !props.b;

    return (
    <section>
    <h3>...</h3>
    <p>가격...</p>
    <div>
    <button>{a ? 'fooValue' : 'barBalue'}</button>
    </div>
    {props.showSummary && <p>요약...</p>}
    {condition1 && <div>...</div>}
    </section>
    );
    }

    Page1 컴포넌트와 Page2 컴포넌트는 Card 컴포넌트에 의존적입니다.

    만약 Page1과 Page2 뿐만 아니라 더 많은 컴포넌트가 Card 컴포넌트를 사용할수록 문제는 더 심각해집니다.

    이런 문제는 왜 발생했을까요? 그리고 어떤 문제를 일으킬까요?

    가장 먼저 컴포넌트가 반환하는 요소의 중복을 추출해서 재사용해야 한다는 접근 방법이 문제의 발단일 수 있습니다.

    추출한 컴포넌트 내부에 사용하는 방법에 따라 조건문이 추가된다는 건, 사용하는 컴포넌트들이 서로 다른 수정의 이유를 갖는 다는 걸 의미합니다. 즉, 중복 제거와 재사용의 대상이 아닙니다. 따라서 처음에 조건문이 들어갈 때부터 산불의 작은 불씨가 시작된 것이었습니다.

    이 문제들을 해결하는 방법 중 하나는 재사용하려는 컴포넌트에는 정말 공통적인 것들만 남겨두고 사용하는 컴포넌트의 고유한 것은 속성(props)으로 전달하는 것입니다.

    function Page1() {
    return (
    <ul>
    <li>
    <Card
    summary={<p>요약...</p>}
    />
    </li>
    </ul>
    );
    }

    function Page2() {
    return (
    <ul>
    <li>
    <Card ... />
    </li>
    </ul>
    );
    }

    function Card(props) {
    return (
    <section>
    <h3>...</h3>
    <p>가격...</p>
    {props.summary}
    </section>
    );
    }

    이렇게 상태나 조건문 등의 결합이 사라진 것만으로도 Page1만의 특징인 summay는 Page1이 관리하고 Card는 이에 대해 신경 쓸 필요가 없습니다.

    특히나 이 방법은 props drilling을 피하거나 컴포넌트의 제어를 역전하는 등 좋은 점을 더 많이 갖고 있고 공식문서에서도 소개하는 만큼 반드시 숙지하고 있을 필요가 있습니다.

B. 복잡성 컴포넌트

  1. 컴포넌트가 여러 책임을 가질 때 분리

    function Page(props) {
    // 선택한 탭을 변경하면 보여주는 내용을 변경합니다.
    // 페이징을 다룹니다.
    // 단어를 검색을 합니다.
    // 검색 조건 토글을 다룹니다.
    // 등등
    }

    이렇게 되면 기능 간에 결합이 강하게 발생해서 수정이 쉽지않습니다.

    그렇기 때문에 컴포넌트를 책임에 맞게 나눠서 단순화 해야 합니다.

    Page 컴포넌트가 탭, 검색, 페이징 그리고 이 정보들을 취합해 컨텐츠를 보여주는 등 모든 책임을 갖지 않도록 해야 합니다.

  2. 컴포넌트에 비지니스 로직이 있을 때 분리

    일반적으로 유저 인터페이스(UI)와 비지니스 로직은 변경의 속도, 즉 빈도가 다릅니다. 이때 컴포넌트에 비지니스 로직이 포함되어있다면 빈번한 UI 변경에 따라 자주 영향을 받을 수 있습니다.

    따라서 UI와 비지니스 로직을 적절하게 분리하는 건 소프트웨어를 오랫동안 유지보수 하는 데 있어서 아주 중요합니다.

C. 리렌더링 컴포넌트

  1. 컴포넌트와 관련이 없는 상태가 있을 때 분리

    하나의 컴포넌트 안에서 서로 영향을 주지 않는 상태가 여럿 있으면 불필요한 렌더링이 발생하는 문제입니다.

    function Page1() {
    const [카드 호버 상태, set카드 호버 상태] = useState(false);
    const [탭 호버 상태, set탭 호버 상태] = useState('none');

    return (
    ...
    <ul></ul>
    ...
    <ul>카드</ul>
    ...
    );
    }

    이 코드에서 탭과 카드는 서로 영향을 주지 않습니다. 하지만 탭에 호버를 하면 카드들이 렌더링되고 카드에 호버를 하면 탭이 렌더링 됩니다.

    function Page1() {
    return (
    ...
    <Tab>
    ...
    <ul>
    ...
    <li><Card><li>
    ...
    </ul>
    ...
    );
    }

    function Tab() {
    const [탭 호버 상태, set탭 호버 상태] = useState('none');

    return (
    <ul></ul>
    );
    }

    function Card() {
    const [카드 호버 상태, set카드 호버 상태] = useState(false);

    return (
    <section>...</section>
    );
    }

· 약 16분
최지훈

들어가며

💡 프론트엔드에서 의존성 살펴보기 (이문기)를 읽는 이유

  1. 야놀자 클론 코딩 프로젝트를 진행하면서 숙소 리스트를 공통 컴포넌트로 묶는 과정에서 어려움을 겪었습니다.
  2. 각 페이지마다 필요한 조건을 하나씩 추가하다보니 더 이상 공통 컴포넌트의 역할을 할 수 없게 되었습니다.
  3. 이에 어떤 부분을 공통적으로 관리하고 묶어야하는지 알기 위해 의존성에 대해 공부해보게 되었습니다.

💡 요약

  • 공통 부분만 잘 묶어도 의존성 문제는 눈에띄게 사라진다.
  • 공통 부분이 될 수 있는 점은 변수(상태), 함수(매개변수와 출력값의 타입), 컴포넌트, 타입이 있으며, 그중에서 타입이 가장 큰 틀을 짜는 순간에서 가장 중요한 부분이다.
  • 여러 곳에서 함께 사용하는 컴포넌트는 시간이 갈수록 전달받는 속성과 조건문이 추가되면서 수정하기엔 몸집이 너무 커지는 현상이 생깁니다. 이런 형태의 의존성 문제를 해결하는 방법 중 한 가지는 의존성 역전입니다.

주요 내용

A. 의존성이란?

의존성(dependency)은 단어 그대로 의존 관계를 설명하는 용어 입니다.

‘A 컴포넌트는 B 컴포넌트에 의존한다.’는 'A 컴포넌트가 동작하기 위해 B 컴포넌트가 필요하다.'라는 뜻과 같습니다.

function ComponentA() {
return (
...
<ComponentB>...</ComponentB>
...
);
}

개발 시 고려해야하는 의존성은 변수, 함수, 컴포넌트, 타입 의존성이 있습니다.

A-a. 변수 의존성

변수의 의존성과 관련된 사례에는 무엇이 있고 어떻게 개선할 수 있는지 방법을 알아보겠습니다.

  • 사례 1: 변수 의존성이 넓은 경우 (변수에 접근할 수 있는 범위가 넓은 경우)

    // 바닐라 JS 버전

    let discount = 0.1;
    let price = 10000;

    $discountInput.addEventListener('change', (event) => {
    // 할인율 변경 이벤트
    discount = Number(event.target.value) / 100;
    const discounted = price * (1 - discount);

    const $result = document.querySelector('#result');

    $result.textContent = `${discounted}`;
    });

    $priceInput.addEventListener('change', (event) => {
    // 가격 변경 이벤트
    price = Number(event.target.value);
    const discounted = price * (1 - discount);

    const $result = document.querySelector('#result');

    $result.textContent = `${discounted}`;
    });

    let으로 선언된 값이 어떻게 사용되는지 알아보기 위해서 살펴야 하는 반경이 넓습니다.

    두 이벤트 리스너를 확인해야하고, price가 숫자일 수도 있고 문자열일 수도 있다면 사용하는 곳에서 모든 가능성에 대비해야 합니다.

    • 해결

      변수에 접근할 수 있는 범위, 즉 스코프를 제한하여 일정 부분 해소할 수 있습니다.

      (() => {
      // 즉시실행 함수를 통해 변수에 접근할 수 있는 범위를 제한합니다.
      let discount = 0.1;
      let price = 10000;

      // 할인율과 가격을 변경하는 함수를 만듭니다.
      // 이렇게 함으로써 discount와 price에 정해진 처리를 통해 값이 할당되는 걸 보장할 수 있습니다.
      const setDiscount = (value) => {
      discount = Number(value) / 100;
      };

      const setPrice = (value) => {
      price = Number(value);
      };

      $discountInput.addEventListener('change', (event) => {
      // 할인율 변경 이벤트
      setDiscount(event.target.value);
      const discounted = price * (1 - discount);

      const $result = document.querySelector('#result');

      $result.textContent = `${discounted}`;
      });

      $priceInput.addEventListener('change', (event) => {
      // 가격 변경 이벤트
      setPrice(event.target.value);
      const discounted = price * (1 - discount);

      const $result = document.querySelector('#result');

      $result.textContent = `${discounted}`;
      });
      })();

      위와 같이 즉시 실행 함수로 discount와 price의 사용 범위를 감싸면 즉시 실행 함수의 외부에서 discount와 price에 접근하는 걸 제한할 수 있고 수정이 발생할 때 살펴봐야 하는 범위도 제한할 수 있습니다.

      또한 setDiscount 그리고 setPrice 처럼 변수에 값을 할당하는 방법을 제한함으로써 값을 사용하는 곳에서 예상 가능한 값을 사용할 수 있게 됩니다.

    // 리액트 버전

    function Page() {
    const [price, setPrice] = useState(10000);
    const [discount, setDiscount] = useState(0.1);

    return (
    <>
    <input
    onChange={(event) => {
    setPrice(Number(event.target.value));
    }}
    />
    <input
    onChange={(event) => {
    setDiscount(Number(event.target.value) / 100);
    }}
    />

    <p>할인된 가격: {price * (1 - discount)}</p>
    </>
    );
    }
    • 해결

      Page 컴포넌트에는 더 많은 변수와 컴포넌트들이 생길 수 있고 이 변수와 컴포넌트들이 price와 discount를 조작하거나 가져가 사용할 수 있기 때문에 PriceAndDiscount 컴포넌트로 분리합니다.

      또한 price와 discount가 예상 가능한 형태로 수정되는 걸 보장하기 위해 각 변수를 위한 훅을 만들어 줍니다.

      const usePrice = (initialValue) => {
      const [price, setPriceAction] = useState(initialValue);
      const setPrice = (value) => {
      // setPrice처럼 price에 값을 할당 할 때 Number로 형변환 하는 걸 보장합니다.
      setPriceAction(Number(value));
      };

      return [price, setPrice];
      };

      // useDiscount도 usePrice와 같은 방법으로 작성합니다.
      const useDiscount = (...) => {...};

      function PriceAndDiscount() {
      const [price, setPrice] = usePrice(10000);
      const [discount, setDiscount] = useDiscount(0.1);

      return (
      <>
      <input
      onChange={(event) => {
      setPrice(event.target.value);
      }}
      />
      <input
      onChange={(event) => {
      setDiscount(event.target.value);
      }}
      />

      <p>할인된 가격: {price * (1 - discount)}</p>
      </>
      );
      }

      function Page() {
      return (
      <>
      <PriceAndDiscount />
      ...
      </>
      );
      }

      지금까지의 과정을 요약하면 캡슐화라고 할 수 있습니다.

A-b. 함수, 컴포넌트 의존성

여러 곳에서 함께 사용하는 컴포넌트는 시간이 갈수록 전달받는 속성과 조건문이 추가되면서 수정하기엔 몸집이 너무 커지는 현상이 생깁니다.

이런 형태의 의존성 문제를 해결하는 방법 중 한 가지는 의존성 역전입니다.

예를들어 다양한 곳에서 동일한 유틸 함수 getNumber에 의존할 때, getNumber가 바뀌면 사용하는 곳 모두 변경 가능성에 노출되기 때문에 문제가 생길 수 있습니다.

function getNumber(str) {
return str.replace(/\D/g, '');
}
$inputPhoneNumber.addEventListener('change', (event) => {
// 전화번호를 입력할 때 숫자가 아닌 값을 제거합니다.
const phoneNumber = event.target.value;

event.target.value = getNumber(phoneNumber);
});

$inputPrice.addEventListener('change', (event) => {
// 가격을 입력할 때 숫자가 아닌 값을 제거합니다.
const price = event.target.value;

event.target.value = getNumber(price);
});

이러한 의존성의 방향을 반대로 바꿀 수 있다면 유틸 함수의 수정으로 인해 사용하는 곳의 코드가 바뀌지 않아도 됩니다. 의존성의 방향을 바꾸는 의존성 역전은 아래와 같이 사용합니다.

function getNumber(str) {
return str.replace(/\D/g, '');
}

/**
* 값과 파서를 입력받아 값을 전화번호 형식에 맞게 파싱합니다.
* @param {string} value
* @param {(value: string): string} parser
* @returns {string}
*/
function parsePhoneNumber(value: string, parser: (value: string) => string) {
return parser(value);
}

$inputPhoneNumber.addEventListener('change', (event) => {
const phoneNumber = event.target.value;

event.target.value = parsePhoneNumber(phoneNumber, getNumber);
});

이 코드가 이전과 달라진 점은 parsePhoneNumber 함수 입니다. 이 함수는 value라는 문자열과 문자열을 전달받아 문자열을 반환하는 parser 함수를 매개변수로 사용하고 있습니다.

만약 parsing하는 방식이 변경된다면, 아래와 같이 변경할 수 있습니다.

$inputPhoneNumber.addEventListener('change', (event) => {
const phoneNumber = event.target.value;

event.target.value = parsePhoneNumber(
phoneNumber,
(str) => str.replace(/[^0-9-]/g, ''),
);
});

위 방법을 통해 전화번호 입력 이벤트 리스너는 parsePhoneNumber의 존재로 인해 getNumber에 직접적으로 의존하지 않습니다.

오히려 ‘getNumber’가 parsePhoneNumber의 ‘두 번째 인자는 문자열을 전달받아 문자열을 반환하는 함수이어야 합니다.’라는 규칙에 의존합니다. 이 규칙을 지키지 않는다면 getNumber는 parsePhoneNumber에 의해 사용될 수 없습니다.

물론 문맥이 비슷한 함수나 컴포넌트처럼 묶어야하지만, 이처럼 매개변수 및 반환 타입이 동일한 조건도 의존성으로 볼 수 있습니다.

A-c. 타입 의존성

가장 많이 경험하는 사례 중 API 요청에 대한 응답값을 타입으로 관리하는 경우를 예로 들어보겠습니다.

export type PostResponse = {
id: number;
title: string;
content: string;
likes: number;
createdAt: Date;
updatedAt: Date;
userId: number;
nickname: string;
comments: {
id: number;
content: string;
createdAt: Date;
updatedAt: Date;
userId: number;
nickname: string;
}[];
};

export const fetchPost = (postId: number) => {
return fetch(`/api/posts/${postId}`)
.then((res) => res.json())
.then((data: PostResponse) => {
return data;
});
};

import { fetchPost } from '../api/fetchPost';
import type { PostResponse } from '../api/fetchPost';
import { PostDetail, Comments } from './components';

export function PostDetailPage() {
const [loading, setLoading] = useState(true);
const [post, setPost] = useState<PostResponse | null>(null);

useEffect(() => {
fetchPost(postId)
.then((data) => {
setPost(data);
})
.catch((error) => {
console.error(error);
// error 처리
})
.finally(() => {
setLoading(false);
});
}, []);

if (loading) {
return <p>loading...</p>;
}

return (
<div>
<PostDetail post={post} />
<Comments comments={post?.comments} />
</div>
);
}

import { type PostResponse } from './PostResponse';

export function PostDetail({ post }: { post: PostResponse | null }) {
// ...
}

export function Comments({ comments }: { comments?: PostResponse['comments'] }) {
// ...
}

위 코드에 동일한 fetchPost 함수로 API를 호출하는 컴포넌트가 추가되면, PostResponse 타입이 변하면 6개의 컴포넌트가 영향을 받습니다.

PostDetail, Comments, EditPanel, Editor 컴포넌트의 경우, PostResponse 타입에서 comments만 필요로하지만, PostResponse 타입 전체에 의존하고 있습니다. PostResponse에서 모든 컴포넌트가 공통으로 사용되는 타입을 분리할 필요가 있습니다.

// types.ts
type Post = {
id: number;
title: string;
content: string;
likes: number;
createdAt: Date;
updatedAt: Date;
userId: number;
nickname: string;
};

// api/fetchPost.ts
import type { PostDetail } from '../types';

export type PostResponse = Post & {
comments: {
id: number;
content: string;
createdAt: Date;
updatedAt: Date;
userId: number;
nickname: string;
}[];
};

// components
import type { Post } from '../types';

export function PostDetail({ post }: { post?: Post }) {
// ...
}

이에 쉽게 바뀌지 않고 자주 변경되지 않는 속성을 모은 Post 타입을 따로 분리합니다. 이를통해 의존성의 갯수가 늘어나더라도 PostResponse 타입에 의존하는 것보다 Post 타입에 의존하는 것이 더 높은 안정성을 제공할 수도 있습니다.

따라서 아래 그림 처럼 우린 코드를 볼 때 개별 컴포넌트나 타입들이 Post에 의존한다는 개념이 아니라 프론트엔드 전체 코드가 Post라는 도메인에 기반을 둔 타입에 의존한다는 개념으로 이해할 수 있게 됩니다.

또한 필요하다면 각 컴포넌트에서 독립적으로 타입 의존성을 관리할 수 있습니다.

import type { Post } from '../types';

export function PostDetail({
post,
readonly,
}: {
post?: Omit<Post, 'userId'>;
readonly?: boolean;
}) {
// ...
}

타입의 어떤 부분을 공통적으로 관리하고 어떤 부분을 각 함수, 변수, 컴포넌트 등이 스스로 관리할지 결정하는 건 코드가 처해있는 상황과 코드를 관리하는 구성원들의 논의를 통해 결정해야 합니다.

글을 읽고

💡 문제 원인 분석

  • 공통 컴포넌트로 묶으려고 시도했던 각 페이지에서 요구하는 props와 상태, 함수, 컴포넌트, 타입을 하나씩 확인해 보았습니다.
  • 공통 컴포넌트가 시간이 지날수록 수정하기 어려웠던 이유는 타입 의존성를 고려하지 않았기 때문이었습니다.
  • 장바구니에서 페이지에서의 숙소 리스트와 예약 내역 확인 페이지에서 사용하는 숙소의 정보가 UI 측면에서는 동일했지만, 결제 전과 결제 후의 숙소 데이터 관리 방식이 달라지기 때문에 타입이 서로 달랐습니다.
// 장바구니 페이지에서의 숙소 리스트 타입

export interface RoomOption {
cartProductId: number;
roomOptionId: number;
name: string;
thumbnailImage: string;
capacity: number;
pricePerNight: number;
reservationStartDate: string;
reservationEndDate: string;
stayDuration: number;
transportation?: string;
totalPrice?: number;
}
// 예약 내역 확인 페이지에서의 숙소 리스트 타입

export interface PaymentRoomOption {
paymentProductId: number;
accommodationId: number;
roomOptionId: number;
name: string;
thumbnailImage: string;
capacity: number;
pricePerNight: number;
totalPrice: number;
reservationStartDate: string;
reservationEndDate: string;
stayDuration: number;
numberOfGuest: number;
transportation: string;
}

💡 해결

  • 장바구니 페이지에서 RoomOption 타입을 사용하는 숙소 리스트의 경우 모두 공통 컴포넌트로 쉽게 묶을 수 있었습니다.
  • 예약 내역 확인 페이지에서의 숙소 리스트 컴포넌트의 경우 독립적으로 관리하는 것으로 결정하였습니다.
  • PR 링크