XionWCFM의 로고 이미지

프론트엔드 관심사 분리에 대해 - Toss Frontend Accelerator

retrospect
XionWCFM의 로고 이미지
유길종(XionWCFM)
2024.07.25. 20:00
과제를 받은 뒤 멘토링 슬랙에 들어가보니 멘토링이 시작된 것이 체감되었어요. 주어졌던 과제를 끝낸 뒤 PR을 정리하면서 첫주차 주제인 관심사 분리에 대해 주어진 질문들을 고민해봤습니다.
아래에서 다루는 모든 내용은 제 개인적인 의견임을 밝힙니다.

관심사란 무엇일까?

제 생각에 관심사라는 것은 결국 무언가를 어떤식으로 분류하여 볼것인가? 에 대한 내용이라고 생각을 해요
이 과정에서 분류 기준을 세밀하게 나누어 볼 것이냐 , 뭉툭하게 볼 것이냐에 따라서 똑같은 코드라도 관심사가 여러개라고 판단할 수도 있고, 한개라고 판단할 수도 있기 때문에 관심사라는 주제로 코드를 볼 때 사람마다 의견이 달라질 수 밖에 없는 것 같기도 합니다.

관심사라는 개념을 정의하는 기준은 무엇일까?

앞서 저는 관심사는 "결국 무언가를 어떤식으로 분류하여 볼것인가?" 에 대한 내용이라고 정의했어요
그렇다면 관심사라는 개념을 정의하는 기준은 무엇일까? 라고 했을 때 저는 실용적인 관점에서 접근을 하고싶다는 생각을 했습니다.
관심사를 정의하는 이유는 결국 사람이 이해하기 쉬운 형태로 추상화를 진행하기 위해서라고 생각했어요 그렇다면 이 관점에서 관심사 정의란 추상화를 위한 도구일 것입니다.
그렇다면 추상화는 언제 필요할까요? 저는 추상화는 복잡함을 가리고 줄여주는 일을 수행하기 때문에 문제의 복잡도를 줄이기 위해서 사용한다고 생각해요
그렇기 때문에 관심사를 정의하는 기준은 "문제의 복잡도"라고 생각합니다.

이 관점에서의 사례를 생각해보자면 저는 radix-primitives와 같은 Headless Library의 예를 들고 싶어요
그 효용에 있어 갑론을박이 있는 개념이긴 하지만 Dan Abramov의 Presentational and Container Components의 멘탈모델을 참고하여 보면 컴포넌트의 계층을 다음과 같이 생각할 수 있습니다.
- 비순수 로직과 상태를 가진 Container 컴포넌트
- 디자인과 UI를 담당하는 Presentational 컴포넌트
이렇게 관심사 기준으로 컴포넌트를 분리할 때 로직 담당과 디자인 담당 두 계층으로 간단히 나누어 생각할 수가 있겠죠 이 추상화는 일반적인 사례에서 매우 잘 작동합니다.
하지만 테이블, Drawer, Dialog 등 관리가 복잡한 UI 들도 단순히 저 두단계의 추상화만으로 생각하면 어떨까요? 이런 경우에는 디자인 컴포넌트라는 추상화 내부에서도 다시 관심사를 나누어야 편한 경우가 생길수 있을거에요
저는 바로 그 관점에서 디자인 컴포넌트에서 특히 복잡한 부분이 되기 쉬웠던 동작 부분과 웹 접근성 부분을 분리해낸 Radix와 같은 헤드리스 라이브러리들이 등장했다고 생각합니다.

이렇듯이 서로 같은 "디자인"이라는 카테고리의 문제를 풀기위한 추상화를 생각할 때에도 문제가 얼마나 복잡한지에 따라 간결한 추상화만 적용하는 편이 유용한 경우도, 조금 더 세부적으로 관심사를 나누어야 유용한 경우도 있기 때문에 관심사를 정의하고 추상화를 할 때에는 "현재 상황이 나에게 복잡한 상황인가?" 를 기준으로 보면 좋은 것 같습니다.

높은 응집도, 낮은 결합도라는 가치가 의미하는 것은 무엇일까?

두 개념 모두 지향하는 가치는 "유지보수성" 이라고 요약할 수 있을 것 같습니다.
결합도를 낮추면 모듈의 변경이 있을 때에도 해당 모듈의 변경으로 인해 변경되어야하는 부분들이 적어질 것이고 응집도를 높인다면 변경사항이 있을 때 어디를 변경해야할지 명확하게 알 수 있으며 편리하게 수정할 수 있을 거라고 생각해요
이런 관점에서 보면 결국 두 개념 모두 개발자가 빠르게 변경을 해낼 수 있게 한다. 는 측면에서 소프트웨어를 소프트웨어답게 만들어주는 것이 최대 가치라고 생각을 합니다.
특히 이러한 가치는 소스코드의 양이 늘어나고 관리해야하는 코드가 많아질수록 빛을 발하는 것 같은데요
작은 변경사항 하나에도 앱 전반에 예상할 수 없는 사이드 이펙트가 생길 수 있는 환경은 개발자 입장에서 너무 두려운 환경이라고 생각이 듭니다.

변경되기 쉬운 관심사와 변경되기 어려운 관심사는 어떻게 결합해야할까?

이 문제에 대해서는 정말 실버불렛이 없는 것 같아요.
특히 제가 종종 잘 설계했다고 생각한 구조에서도 여러 이해관계로 인해 모듈의 입출력 구조를 바꿔야하는 "파괴적 변경" 이 필요해졌던 것 같습니다.
사실 이런 경우는 개발자의 컨트롤 영역 밖에 있는 일이라고 생각하기 때문에 개발자 입장에서는 그저 안바뀌기를 기도하거나 바꿔야할 부분을 최소화하는 것 말고는 답이 없는 것 같습니다
저같은 경우엔 이럴 때에는 자주 변경되는 관심사와 잘 변경되지 않는 관심사 사이에 "완충지대" 를 두는 방법이 효과가 있었던 것 같습니다.

예를 들면 API의 Response 스펙 변경이 잦은 상황이라면 내 애플리케이션의 모든 부분이 API 응답형태에 직접 의존하는게 아니라 중간에 매핑을 수행하는 계층을 추가시키는 형태로 관리하는 방법이 있을 것 같아요
그러나 이렇게 중간에 계층을 하나 추가하는 것도 복잡도를 증가시키는 원인이 되기 때문에 항상 좋은 해결책이 되지는 않는 것 같아요
그래도 API로 한정해서 생각을 한다면 최대한 늦게 매퍼를 추가할 수 있기 때문에 성급한 추상화 대신 일단 직접 의존하다가 변경이 생기면 매퍼를 추가하는 방법이 좋은 것 같습니다.
예를 들면 이런식으로요
import { queryOptions } from "@tanstack/react-query";
 
type ExampleType = {
  id: number;
  title: string;
  createAt: string;
};
 
const exampleFetch = async () => {
  const response = await fetch("https://jsonplaceholder.typicode.com/todos/1");
  const json = await response.json();
  return json as ExampleType;
};
 
const exampleQueryOptions = () =>
  queryOptions({
    queryKey: ["example"],
    queryFn: exampleFetch,
  });
 
이런 코드가 있었다고 가정을 해봅시다. 일단은 API 응답을 그대로 사용하고 있습니다. 이 때 갑자기 API 양식이 이렇게 달라졌다는 상황을 가정해볼까요?
{
    "ID":"1",
    "title":"example",
    "create_at":"2024-07-25T23:29:07.053Z"
}
조금 극단적인 예시이긴 하지만 이런형태로 API가 변경되었다고 가정해봅시다. 이런 경우가 왔을때 위 상황에서 매퍼를 추가하는 것은 매우 쉽습니다.
import { queryOptions } from "@tanstack/react-query";
 
type ExampleType = {
  id: number;
  title: string;
  createAt: string;
};
 
type ExternalExampleType = {
  ID: string;
  title: string;
  created_at: string;
};
 
const exampleMapper = (data: ExternalExampleType): ExampleType => {
  return {
    id: parseInt(data.ID),
    title: data.title,
    createAt: data.created_at,
  };
};
 
const exampleFetch = async () => {
  const response = await fetch("https://jsonplaceholder.typicode.com/todos/1");
  const json = await response.json();
  return exampleMappter(json);
};
 
const exampleQueryOptions = () =>
  queryOptions({
    queryKey: ["example"],
    queryFn: exampleFetch,
  });
 
 
mapper라는 완충지대를 두는 것을 통해 변경되지 않았던 기존의 코드들은 여전히 ExampleType에 의존할 수 있어졌습니다.
덕분에 API 스펙의 변경이라는 커다란 변경사항이 발생했음에도 불구하고 코드의 변경은 해당 영역에서만 필요해졌죠 그러나 알수있듯이 이 해결을 위해 매퍼라는 계층이 추가되었고 코드가 많아졌습니다.
따라서 이런형태로 완충지대를 두는 기법은 미리하기보다는 최대한 미룰수 있을 때까지 미룬 뒤에 필요해지면 그때 하는 것이 가장 좋은 것 같아요

나는 어떤 기준으로 관심사를 분리하는가?

저는 관심사를 분리할 때 비즈니스 맥락의 포함 여부를 기준으로 분리하는 것을 가장 중요하게 생각해요
제가 생각했을 때 비즈니스 맥락은 코드에서 가장 자유분방하게 바뀌며, 가장 많이 바뀌는 요소라고 생각하거든요
그래서 코드의 추상화 계층을 생각할 때엔 주로 이런 형태로 생각을 하는 편입니다.
- 유틸 코드를 통해 비즈니스 정책을 구현하는 코드
- 어디서나 재사용될 수 있는 유틸성 코드
이렇게 비즈니스 맥락 여부로 계층을 정의 한 뒤 컴포넌트의 계층을 정할 때에는 주로 Container Presentational 패턴에 따라 로직과 디자인을 분리하는 것 같아요
디자인 컴포넌트를 순수하게만들면 스토리북, vitest 등의 테스트 라이브러리를 통해 테스트하는 것이 편해지기 때문에 이런 방식을 선호하는 편입니다.

부록

Container Presentational Pattern은 정말 쓸모없어졌나?

Dan Abramov의 Presentational and Container Components에서 Dan Abramov는 Hooks가 Container Presentational 컴포넌트의 작업을 대체할 수 있다라는 의견을 내비쳤습니다.
컴포넌트의 로직 부분을 담당하는 작업을 Container 컴포넌트를 만들어서 위임하는 대신 추상화된 커스텀훅을 통해 달성할 수 있다는 맥락으로 보입니다.
저는 이것이 코드 관점에서는 맞는말이지만 테스트 관점에서는 여전히 Container 컴포넌트의 유용성이 존재한다고 생각하는데요 특히 Storybook과 같은 UI 테스팅 라이브러리를 사용한다면 Presentational 컴포넌트를 구성하는 것이 매우 중요합니다.

예를 들어 커스텀훅을 통해 로직을 Hook으로 분리한 컴포넌트를 Storybook으로 테스팅하고자한다면 어떨까요? 이 경우 해당 컴포넌트의 UI 동작을 테스트하기 위해 해당 커스텀훅이 필요로하는 Context와 의존성을 모두 스토리북 레벨에서 제공해야하는 복잡도가 추가됩니다.
반면 Container 컴포넌트를 사용한다면 그저 Presentational 컴포넌트에 적절한 목데이터를 넣어주기만 하면 끝나는 작업이 되죠 이런 관점에서 여전히 Container Presentational Pattern은 유용한 지점이 존재하는 것 같습니다.

마치며

관심사 분리와 추상화는 언제 이야기해도 신나는(?)주제 인 것 같습니다. 덕분에 분량 조절은 약간 실패한 것 같지만 즐겁게 글을 쓴 것 같습니다.
읽어주시는 분들도 재밌게 읽어주셨기를 바라며 이번 글을 마치도록 하겠습니다. 감사합니다.
XionWCFM을 나타내는 캐릭터 이미지
유길종(XionWCFM)
무언가를 쉽게 설명 해낼 때 쾌감을 느끼는 사람입니다. 현재는 프론트엔드 개발자로 일하고 있습니다.