본문 바로가기

웹 프론트엔드

React CleanCode #1, 합성으로 관심사를 분리하기

 

서언

안녕하세요. React CleanCode 첫 번째 주제로 Composition(합성)을 다룹니다. 최근에 회사에서 많은 코드를 작성하면서 느끼는 것이 있었는데요. 바로 프론트엔드가 다루어야할 관심사가 너무나 많다는 것입니다. 크게는 UI 로직(단순 UI, 애니메이션 로직, 하드코딩적인 요소), 서버 로직(데이터 패칭, 업데이트 로직, 유저 인증인가 로직, 로딩처리, 에러처리), 로그 등 이 있습니다. React를 사용하며 이러한 관심사를 잘 분리하지 않는다면, 스파게티 코드가 된다는 것을 체감했습니다. 그러면 어떻게 관심사의 지옥에서 벗어날 수 있을까요? 즉 관심사를 어떻게 잘 분리해야 할까요? 관심사 분리는 보통 함수(클래스) 분리를 통해 이루어집니다. React에서 함수의 실체는 훅, 컴포넌트, 유틸함수 입니다. 유틸함수는 리액트에 종속된 이야기는 아니고, 리팩터링이나 클린코드 같은 책에서 잘 설명하고 있습니다. 그래서 앞으로 훅과 컴포넌트 분리 방법을 이야기할 것이며, 오늘은 그중에서 합성을 통한 관심사 분리 방법을 다루겠습니다.

합성(Composition)이란?

먼저 합성에 대해서 간단하게 설명하고자 합니다.
React에서 합성이란 컴포넌트 안에 다른 컴포넌트를 담는 방법 입니다. 컴포넌트 자체를 props 로 넘겨주는 방식을 취합니다. 이때 다음과 같이 children을 활용한 방법이 주로 사용됩니다.

function GreenBackground(props) {
  return <div style={{ backgroundColor: 'green' }}>{props.children}</div>;
}

function Forest() {
  return (
    <GreenBackground>
      <Tree />
      <Tree />
      <Tree />
    </GreenBackground>
  );
}

합성의 목적

합성의 목적은 무엇일까요?
우리가 흔히 사용하는 합성 패턴의 목적은 “재사용” 입니다. 공식문서에서도 이러한 예제를 다루고 있습니다.
 
“어떤 컴포넌트들은 어떤 자식 엘리먼트가 들어올 지 미리 예상할 수 없는 경우가 있습니다. 범용적인 ‘박스’ 역할을 하는 Sidebar 혹은 Dialog와 같은 컴포넌트에서 특히 자주 볼 수 있습니다.”
 
다시 말하자면, 내부에 들어갈 엘리먼트의 형태가 정해져 있지 않아, 유연하게 받아서 재사용성을 극대화하려는 목적에서 쓰입니다.
 
이것도 매우 중요하지만, 이것만큼 중요하다고 생각하는 포인트를 다루고 싶습니다. 바로 “관심사의 분리(Separation of concerns)” 목적입니다. 관심사란, 특정 기능이나 역할을 의미하고, 관심사의 분리란, 특정 기능이나 역할에 따라서 분리하는 것을 의미합니다. 관심사를 분리하는 목적은 무엇일까요?
 

  • 명확한 관리포인트
    • A 컴포넌트가 B, C, D, E 관심사를 가지고 있는 상황이면, B 관심사를 수정하게 위해서 A 컴포넌트를 찾아가서, B, C, D, E 를 모두 읽으며 판단해야하는 문제
    • 관심사가 잘 분리되어 있다면, B에 수정사항이 생겼을 때 B를 가진 컴포넌트만 읽으면 됨.
  • 가독성 증대
    • 함수는 하나의 일만 하도록 하라! 고 클린코드에 쓰여져있는데요. 같은 관점입니다. 컴포넌트(함수)가 하나의 역할을 할 때, 가독성이 좋아집니다.
  • 의존관계의 분리로 인한, 에러 가능성 저하
    • 스파게티 처럼 꼬여있지 않고, 명확한 역할을 하는 컴포넌트들로 나누어져 있으면 로직을 파악하기 쉽고, 장기적으로 에러 가능성을 낮춥니다.

그러면 합성을 통해서 어떻게 관심사를 분리할 수 있는지 구체적으로 설명드려보겠습니다. 먼저 스파게티 코드를 소개하고, 5번의 Step으로 관심사를 분리해보며 합성의 힘을 느껴보도록 합시다.

Step 0 : 스파게티 코드

스파게티 코드를 보여드리겠습니다.
아래 코드는, 회원의 좋아요 리스트를 보여주는 페이지를 구현한 컴포넌트 입니다. 코드를 모두 이해하실 필요는 없습니다. 주석을 중심으로, 이 페이지가 다루는 관심사에는 어떤 것들이 있는지 읽어주시면 됩니다.

export default function LikeListPage() {
  // 1. 유저 정보를 가지고 오는 Query
  const meQuery = useQuery(["me"], () => getMe());
  // 2. 유저 정보가 있으면, 좋아요 리스트를 가지고 오는 Query
  const likeListQuery = useQuery(["likeList"], () => getLikeList(), {
    enable: meQuery.data != null,
  });

  const router = useRouter();

  // 3. 토스트 로직
  const [message, setMessage] = useState("");
  const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const [isOpenToast, setIsOpenToast] = useState(false);
  const showToast = (message: string) => {
    setIsOpenToast(true);
    setMessage(message);

    if (toastTimer.current) {
      clearTimeout(toastTimer.current);
    }

    const timer = setTimeout(() => {
      setIsOpenToast(false);
      setMessage("");
    }, 3000);
    toastTimer.current = timer;
  };

  // 4. Mount 시에 amplitude 를 활용한 page log 를 찍는다.
  useEffect(() => {
    amplitude.getInstance().logEvent("viewed-page", {
      pageName: "Home",
      pageTitle: "My Awesome Website",
      url: window.location.href,
    });
  }, []);

  // 5. Mount 시에 scroll을 최상단까지 올린다.
  useEffect(() => {
    window.scrollTo({ top: 0 });
  }, []);

  // 6. 유저 정보가 없을 경우, Login 시키기
  useEffect(() => {
    if (meQuery == null) {
      router.push("/login");
    }
  }, []);

  // 7. Query 로딩을 처리한다.
  if (meQuery.isLoading || likeListQuery.isLoading) {
    return <Loading />;
  }

  // 8. Query 에러를 처리한다.
  if (meQuery.isError || likeListQuery.isError) {
    return <ErrorPage />;
  }

  // 6. 유저 정보가 없을 경우, Guard
  if (meQuery == null) {
    return null;
  }

  return (
    <div>
      <button onClick={() => showToast("show toast")}>Show Toast!</button>
      {isOpenToast && (
        <Toast message={message} onClick={() => setIsOpenToast(false)} />
      )}
    </div>
  );
}

프론트엔드가 가질 수 있는 관심사의 대부분 서술해본 코드입니다. 운영 중인 서비스라면 위의 관심사 중 많은 것들을 가지고 있을 것입니다.
코드가 어떻게 느껴지시나요? 크게 이상하다고 느끼지 않으실 수도 있습니다. 페이지 컴포넌트에서 가져야 할 로직을 잘 가지고 있어 문제가 없다고 느끼실 수도 있습니다. 하지만 위 코드는 관심사 분리 측면에서 봤을 때, 잘못 작성된 코드입니다.
 
관심사 분리의 적절성은 “함수가 단일 관심사(책임) 가지고 있는가” 를 기준으로 생각하면 됩니다. LikeListPage라는 함수가 하나의 책임을 가지고 있나요?
 
관심사를 숫자로 카운팅을 해보았는데요. 8개의 관심사가 존재합니다. 정리해보자면

  1. 회원/비회원 판별
  2. 좋아요 데이터 받아오기
  3. 토스트
  4. 로깅
  5. 마운트 시 스크롤
  6. 로딩 처리
  7. 에러 처리

 
좋아요 리스트를 보여주는 LikeListPage 가 7개의 관심사를 가지고 있습니다.
5단계에 거쳐서 관심사를 분리하여, 각 컴포넌트가 최대한 하나의 책임을 가지도록 만들어볼 것입니다. 먼저 해보고 오셔도 좋습니다.

Step 1 : Loading, Error 관심사 분리

첫 번째로, 간단하게 로딩과 에러 로직을 분리하려고 합니다.

// 기존 코드
// Query 로딩을 처리한다.
if (meQuery.isLoading || likeListQuery.isLoading) {
  return <Loading />;
}

// Query 에러를 처리한다.
if (meQuery.isError || likeListQuery.isError) {
  return <ErrorPage />;
}

리액트에서 보통 Loading은 Suspense를 통해서, Error는 ErrorBoundary를 통해서 분리합니다. 이게 왜 합성이냐구요? 이들도 컴포넌트가 컴포넌트를 리턴하는 패턴이기 때문입니다.
가령 에러바운더리는, 에러가 존재할 경우 ErrorPage Fallback을 return 하고, 정상 케이스일 경우, children을 return 합니다.
서스펜스는, 로딩 상태일 경우, Loading Fallback 을 return 하고, 그렇지 않을 경우 children을 return 합니다.
 
아래는 리팩터링한 코드입니다.

// Loading, Error 관심사 분리
export default function LikeListPage() {
  return (
    <ErrorBoundary fallback={<ErrorPage />}>
      <Suspense fallback={<Loading />}>
        <LikeList />
      </Suspense>
    </ErrorBoundary>
  );
}

LikeList 컴포넌트에서 로딩 처리, 에러 처리를 관심사로 가지지 않고, 상단 Suspense, ErrorBoundary에서 관리하고 있음을 알 수 있습니다. LikeList 는 성공 케이스에만 관심을 가지면 됩니다.
LikeList 에서 불필요한 if 문을 없애서, 한결 컴포넌트 흐름을 파악하기 쉬워졌습니다.

Step2 : 비회원 관심사 분리

다음은 회원 인가(Authentication)에 대한 관심사를 분리하려고 합니다. LikeList 컴포넌트는 애초에 회원인 경우에만 보여지면 되는 것이고, 회원인 상황에만 관심을 가지면 됩니다.

// 기존 코드
function LikeList() {
  // 유저 정보를 가지고 오는 Query
  const meQuery = useQuery(["me"], () => getMe(), { suspense: true });
  // 유저 정보가 있으면, 좋아요 리스트를 가지고 오는 Query
  const likeListQuery = useQuery(["likeList"], () => getLikeList(), {
    enable: meQuery.data != null,
    suspense: true,
  });

  // 유저 정보가 없을 경우, Login 시키기
  useEffect(() => {
    if (meQuery == null) {
      router.push("/login");
    }
  }, []);

  // 유저 정보가 없을 경우, Guard
  if (meQuery == null) {
    return null;
  }

  ...
}

하지만 위 컴포넌트에서는 회원이 아닌 경우 로그인 페이지로 라우팅 하거나, 회원인 경우에만 좋아요 리스트를 패칭해오는 등, 관심사가 얽혀있는 모습을 볼 수 있습니다. 이를 합성을 사용해 분리해봅시다.
 
회원이 아닌 경우, Guard 하여 LikeList를 렌더링 하지 못 하게 만드는 구현을 써보겠습니다.

function UserGuard({ children }: { children: ReactNode }) {
  const router = useRouter();

  // 유저 정보를 가지고 오는 Query
  const meQuery = useQuery(["me"], () => getMe(), { suspense: true });

  // 유저 정보가 없을 경우, Login 시키기
  useEffect(() => {
    if (meQuery == null) {
      router.push("/login");
    }
  }, []);

  // 유저 정보가 없을 경우, Guard
  if (meQuery == null) {
    return null;
  }

  return <>{children}</>;
}

UserGuard 컴포넌트에서 유저 정보를 받아오고, 비회원인 경우 렌더링 하지 않고 라우팅 시키는 방식으로 관심사를 분리했습니다.
이렇게 되면 LikeList는 이미 회원인 경우에만 렌더링 되는 것이니, LikeList는 “회원의 좋아요 리스트”만 관심사로 가지면 됩니다.
 
아래처럼 상위에서 감싸주기만 하면 됩니다.

export default function LikeListPage() {
  return (
    <UserGuard>
      <LikeList />
    </UserGuard>
  );
}

Step3 : 토스트 관심사 분리

다음으로 토스트 로직인데요. LikeList에서는 토스트 로직을 가지고 와서 사용하기만 하면 될뿐, 구체적인 구현체를 알 필요는 없습니다. 기존 코드는 토스트 관심사 때문에, 코드 흐름 파악에 어려움이 생깁니다.

// 기존 코드
function LikeList() {
  // 토스트 로직
  const [message, setMessage] = useState("");
  const toastTimer = useRef<NodeJS.Timeout>();
  const [isOpenToast, setIsOpenToast] = useState(false);
  const showToast = (message: string) => {
    setIsOpenToast(true);
    setMessage(message);

    if (toastTimer.current) {
      clearTimeout(toastTimer.current);
    }

    const timer = setTimeout(() => {
      setIsOpenToast(false);
      setMessage("");
    }, 3000);
    toastTimer.current = timer;
  };

  return (
    <div>
      <button onClick={() => showToast("show toast")}>Show Toast!</button>
      {isOpenToast && (
        <Toast message={message} onClick={() => setIsOpenToast(false)} />
      )}
    </div>
  );
}

그래서 Toast 로직도 합성을 통해서 외부로 분리하려고 합니다.
ToastProvider를 만들었습니다. Provider도 합성 입니다. Provider는 로직을 하위 Context로 내려주면서 children을 return 하는 함수입니다.

// 토스트 관심사 분리
const ToastContext = createContext({ showToast(message: string) {} });
const ToastProvider = ({ children }: { children: ReactNode }) => {
  const [message, setMessage] = useState("");
  const [isOpenToast, setIsOpenToast] = useState(false);
  const toastTimer = useRef<NodeJS.Timeout>();

  const showToast = (message: string) => {
    setIsOpenToast(true);
    setMessage(message);

    if (toastTimer.current) {
      clearTimeout(toastTimer.current);
    }

    const timer = setTimeout(() => {
      setIsOpenToast(false);
      setMessage("");
    }, 3000);
    toastTimer.current = timer;
  };

  return (
    <ToastContext.Provider value={{ showToast }}>
      {children}
      {isOpenToast && <Toast message={message} />}
    </ToastContext.Provider>
  );
};

Context API 를 통한 Toast 설계는 아래 글에서 구체적으로 살펴보실 수 있습니다.
https://happysisyphe.tistory.com/51
 
Toast 로직을 ToastProvider에 응집시켜준 후, 세부 구현을 숨기고, showToast라는 trigger 함수만 내려주어 사용할 수 있습니다.

export default function LikeListPage() {
  return (
    <ToastProvider>
      <LikeList />
    </ToastProvider>
  );
}

function LikeList() {
  // 유저 정보가 있으면, 좋아요 리스트를 가지고 오는 Query
  const likeListQuery = useQuery(["likeList"], () => getLikeList(), {
    suspense: true,
  });

  const { showToast } = useContext(ToastContext);

  return (
    <div>
      <button onClick={() => showToast("show toast")}>Show Toast!</button>
    </div>
  );
}

Step4 : 로깅 관심사 분리

다음은 로깅 관심사를 분리하려고 합니다. 기존 코드를 먼저 살펴봅시다.

// 기존 코드
function LikeList() {
  // Mount 시에 amplitude 를 활용한 page log 를 찍는다.
  useEffect(() => {
    amplitude.getInstance().logEvent("viewed-page", {
      pageName: "Home",
      pageTitle: "My Awesome Website",
      url: window.location.href,
    });
  }, []);

  return (
    <div>
      <button onClick={() => showToast("show toast")}>Show Toast!</button>
      {isOpenToast && (
        <Toast message={message} onClick={() => setIsOpenToast(false)} />
      )}
    </div>
  );
}

이 코드는 아래 두가지 원칙을 지키면 더 나은 코드가 될 수 있습니다.

  1. 재사용이 가능해야 한다.
  2. amplitude 라는 세부 구현은 드러내지 않는다. 로깅 툴에 변경사항이 발생했을 때, 함수의 구현부만 수정하면 되도록 만든다.

이번에도 합성을 통해서 분리해보도록 합니다. LoggingPage 컴포넌트를 정의합니다. Effect 로직을 가지고, children을 그대로 뱉어줌으로써, 세부 구현을 숨기고, 관심사만 정확히 분리해냅니다. props 를 적절히 뚫어서 재사용이 가능한 형태로 만듭니다.

function LoggingPage({
  pageName,
  pageTitle,
  children,
}: {
  pageName: string;
  pageTitle: string;
  children: ReactNode;
}) {
  // Mount 시에 amplitude 를 활용한 page log 를 찍는다.
  useEffect(() => {
    amplitude.getInstance().logEvent("viewed-page", {
      pageName,
      pageTitle,
      url: window.location.href,
    });
  }, [pageName, pageTitle]);

  return <>{children}</>;
}

아래 형태로 사용만 해주면, 로깅을 깔끔하게 찍을 수 있습니다.

function LikeList() {
	// ...

  return (
    // 합성을 띄워줌으로써 관심사 분리
    <LoggingPage pageName="Home" pageTitle="My Awesome Website">
      // ...
    </LoggingPage>
  );
}

Step 5: scrollToTop 관심사 분리

웹 어플리케이션을 구현하다보면, 스크롤을 제어해야할 때가 많습니다. 기존 코드에서는 LikeList 내부에 useEffect 로 스크롤을 올려주고 있습니다.

// 기존 코드
function LikeList() {
  // Mount 시에 scroll을 최상단까지 올린다.
  useEffect(() => {
    window.scrollTo({ top: 0 });
  }, []);

  // ...

  return (
    <LoggingPage pageName="Home" pageTitle="My Awesome Website">
      <div>
        <button onClick={() => showToast("show toast")}>Show Toast!</button>
        {isOpenToast && (
          <Toast message={message} onClick={() => setIsOpenToast(false)} />
        )}
      </div>
    </LoggingPage>
  );
}

Effect 로직이 하나만 있으면 상관이 없겠지만, 컴포넌트에서는 다양한 Effect가 들어올 수 있습니다. 그런 경우, 각 Effect가 어떤 역할을 하는지 한번에 파악하기 어렵고, 이는 컴포넌트 흐름을 파악하는데 어려움을 만듭니다. 관심사의 분리와 더불어, 재사용의 효과도 얻을 수 있습니다.
 
합성을 사용해서 이를 구현해보겠습니다.

function ScrollToTop({ children }: { children: ReactNode }) {
  // Mount 시에 scroll을 최상단까지 올린다.
  useEffect(() => {
    window.scrollTo({ top: 0 });
  }, []);

  return <>{children}</>;
}

로깅 예시와 동일하게 Effect를 수행하며, children을 그대로 return 하는 합성 컴포넌트입니다. 관심사를 명확히 분리해줍니다.
 
아래처럼 사용할 수 있습니다.

function LikeList() {
	// ...

  return (
    <LoggingPage pageName="Home" pageTitle="My Awesome Website">
      <ScrollToTop>
        <div>
          <button onClick={() => showToast("show toast")}>Show Toast!</button>
        </div>
      </ScrollToTop>
    </LoggingPage>
  );
}

 
LoggingPage, ScrollToTop 의 구현은 훅이나, (합성이 아닌) 일반 컴포넌트 형태로도 관심사를 분리할 수 있습니다.
 

  • 훅으로 구현 예시
function useScrollToTop() {
  // Mount 시에 scroll을 최상단까지 올린다.
  useEffect(() => {
    window.scrollTo({ top: 0 });
  }, []);
}

function LikeList() {
  useScrollToTop();

  return (
    // ...
  );
}

 

  • 일반 컴포넌트로 구현 예시
function ScrollToTop({ children }: { children: ReactNode }) {
  // Mount 시에 scroll을 최상단까지 올린다.
  useEffect(() => {
    window.scrollTo({ top: 0 });
  }, []);

  return null;
}

function LikeList() {
  // ...
  return (
    <LoggingPage pageName="Home" pageTitle="My Awesome Website">
      <ScrollToTop />
      <div>
        <button onClick={() => showToast("show toast")}>Show Toast!</button>
      </div>
    </LoggingPage>
  );
}

합성 컴포넌트, 훅, 일반 컴포넌트 3가지 방법은 재사용 / 관심사를 분리하는 목적으로 모두 쓰입니다. 상황에 따라서 필요에 따라서 유연하게 사용하면 좋습니다.

최종 리팩터링 코드 

최종 리팩터링된 코드를 첨부합니다. 스파게티가 해체된 것이 느껴지시나요? 한 컴포넌트가 하나의 관심사를 가지고, 그것을 합성하여 로직을 완성하는 모습을 볼 수 있습니다. 이는 유지 보수성이 매우 좋습니다. 회원 로직을 수정하기 위해서는 UserGuard만 찾아가면 됩니다. 토스트 로직은 ToastProvider만 찾아가면 됩니다. 

export default function LikeListPage() {
  return (
    <ToastProvider>
      <ErrorBoundary fallback={<ErrorPage />}>
        <Suspense fallback={<Loading />}>
          <UserGuard>
            <LikeList />
          </UserGuard>
        </Suspense>
      </ErrorBoundary>
    </ToastProvider>
  );
}

function UserGuard({ children }: { children: ReactNode }) {
  const router = useRouter();

  // 유저 정보를 가지고 오는 Query
  const meQuery = useQuery(["me"], () => getMe(), { suspense: true });

  // 유저 정보가 없을 경우, Login 시키기
  useEffect(() => {
    if (meQuery == null) {
      router.push("/login");
    }
  }, []);

  // 유저 정보가 없을 경우, Guard
  if (meQuery == null) {
    return null;
  }

  return <>{children}</>;
}

const ToastContext = createContext({ showToast(message: string) {} });
const ToastProvider = ({ children }: { children: ReactNode }) => {
  const [message, setMessage] = useState("");
  const [isOpenToast, setIsOpenToast] = useState(false);
  const toastTimer = useRef<NodeJS.Timeout>();

  const showToast = (message: string) => {
    setIsOpenToast(true);
    setMessage(message);

    if (toastTimer.current) {
      clearTimeout(toastTimer.current);
    }

    const timer = setTimeout(() => {
      setIsOpenToast(false);
      setMessage("");
    }, 3000);
    toastTimer.current = timer;
  };

  return (
    <ToastContext.Provider value={{ showToast }}>
      {children}
      {isOpenToast && <Toast message={message} />}
    </ToastContext.Provider>
  );
};

function LoggingPage({
  pageName,
  pageTitle,
  children,
}: {
  pageName: string;
  pageTitle: string;
  children: ReactNode;
}) {
  // Mount 시에 amplitude 를 활용한 page log 를 찍는다.
  useEffect(() => {
    amplitude.getInstance().logEvent("viewed-page", {
      pageName,
      pageTitle,
      url: window.location.href,
    });
  }, [pageName, pageTitle]);

  return <>{children}</>;
}

function ScrollToTop({ children }: { children: ReactNode }) {
  // Mount 시에 scroll을 최상단까지 올린다.
  useEffect(() => {
    window.scrollTo({ top: 0 });
  }, []);

  return <>{children}</>;
}

function LikeList() {
  // 유저 정보가 있으면, 좋아요 리스트를 가지고 오는 Query
  const likeListQuery = useQuery(["likeList"], () => getLikeList(), {
    suspense: true,
  });

  const { showToast } = useContext(ToastContext);

  return (
    <LoggingPage pageName="Home" pageTitle="My Awesome Website">
      <ScrollToTop>
        <div>
          <button onClick={() => showToast("show toast")}>Show Toast!</button>
        </div>
      </ScrollToTop>
    </LoggingPage>
  );
}

맺으며

합성을 통해서 관심사를 분리하는 방법을 알아보았습니다. 코드가 복잡해지지 않기 위해서, 안전한 시스템을 가진 소프트웨어를 만들기 위해서, 관심사를 분리해주는 것은 매우 중요합니다. 장기적으로 에러 발생 가능성을 줄이고, 개발 생산성을 높여줍니다. 기본적으로 관심사는 함수나 클래스를 통해서 분리할 수 있습니다. 하지만, 리액트를 하다보면 “훅”, “컴포넌트” 라는 개념이 등장하면서 뭔가 새로운 개념을 사용하고 있다는 생각이 들 수 있습니다. 원론적으로 돌아가면, 훅과 컴포넌트도 함수입니다. 즉, 훅과 컴포넌트를 통해서 관심사를 분리하여 더 나은 코드를 작성할 수 있습니다. 컴포넌트가 복잡해지는 것을 느꼈을 때 해결책에 대해서 고민이 있었던 분, 훅으로 관심사 분리하는 것에만 초점을 두신 분들에게 이 글이 도움이 되셨길 바랍니다.