프론트엔드가 점점 복잡해지면서 프론트엔드 코드베이스를 아키텍처 수준에서 구조화하고 관리하는 것의 중요성이 점점 높아지고 있다. 필자도 이 주제에 대해 굉장히 오랫동안 고민해 왔는데, 특히 백엔드와 달리 프론트엔드에는 3-계층 아키텍처나 클린 아키텍처와 같은 정형화된 아키텍처가 없었기 때문이다.

 

물론 프론트엔드와 백엔드는 소프트웨어의 본질적인 내용을 공유하기 때문에, 여러 아키텍처의 핵심 논리를 프론트엔드에도 충분히 적용할 수 있다. 하지만 프론트엔드의 특수성 때문에 백엔드 아키텍처를 그대로 적용하기는 어려울 뿐더러, 비효율적이다. 따라서 핵심 논리를 기반으로 프론트엔드만의, 그리고 해당 프로젝트의 복잡성을 고려한 아키텍처를 구축하는 것이 가장 현실적이고 효율적인 방법일 것이다.

 

이번 포스팅에서는 필자가 프로젝트에 적용하고 있는 프론트엔드 아키텍처를 소개하고, 이를 구축하면서 고민했던 내용들을 정리해보려고 한다. 앞서 언급했듯이, 프론트엔드 분야에는 전형적인 아키텍처가 잘 확립되어 있지 않으며, 관련 자료도 부족해 직접 설계를 해야 했다. 이 과정에서 프론트엔드 아키텍처와 관련된 인터넷 자료를 거의 모두 읽었고, '클린 아키텍처'와 도메인 주도 설계 등 아키텍처 관련 도서도 최대한 많이 읽으며 프론트엔드에 어떻게 적용할지 고민했다.

 

아키텍처는 결국 의사결정의 종합이기 때문에 ‘단순히 아키텍처는 이렇다!’라고 설명하기는 어렵다. 따라서 글이 좀 길어질 수 있을 것 같다. 하지만 나와 같은 고민을 하고 있다면 분명 도움이 될 것이다.

 

 

프론트엔드에도 아키텍처가 필요한 이유

프론트엔드건 백엔드건 아키텍처가 필요한 이유는 ‘복잡성을 관리’ 하기 위해서이다. 만약 아키텍처가 없이 코드베이스가 하나의 진흙탕처럼 꼬여있다면, 우리는 개발을 할 때 코드베이스의 모든 내용을 알아야 만 한다. 우리의 인지 능력에는 한계가 있기 때문에 이는 개발을 어렵게 만들고 유지보수 비용을 증가시킨다. 하지만 특정 원칙에 따라 코드베이스를 분리하여 계층을 나누고 이들 간의 의존성 방향을 관리하면, 우리가 알아야 할 코드의 범위를 인지 능력 내로 제한할 수 있어 복잡성을 효과적으로 관리할 수 있다.

아키텍처가 없으면 코드베이스는 하나의 진흙탕과 같아진다.

이러한 아키텍처는 코드 변경 및 확장 시 특히 유용하다. 코드 변경은 크게 두 단계로 나뉘는데, 첫째는 변경할 코드 위치를 찾는 것이고, 둘째는 변경할 로직을 작성하는 것이다. 잘 설계된 아키텍처는 관심사가 명확히 분리되고 응집되어 있어, 변경할 위치를 쉽게 찾을 수 있게 도와준다. 또한, 변경에 대한 영향력을 캡슐화하여 로직 작성도 수월하게 만들어준다.

 

따라서, 효과적인 아키텍처를 구축하면 변경에 유연하게 대응할 수 있고 빠른 개발 수요를 충족할 수 있다. 반대로, 어느 순간부터 개발 속도가 점점 느려진다면 아키텍처에 문제가 있을 가능성이 크다. 이런 경우에는 코드베이스를 효과적으로 재구조화하는 것이 필요하다.

 

 

아키텍처를 소개합니다

아래 그림은 필자가 프로젝트에 적용하고 있는 아키텍처이다. 애플리케이션이 점점 복잡해짐에 따라 아키텍처도 진화해야 한다. 필지가 진행하는 프로젝트는 몇 개월 동안 복잡한 기능들이 추가되면서 점점 복잡해졌고, 그때마다 아키텍처도 복잡성을 효과적으로 관리할 수 있도록 진화해 왔다. 이러한 변화를 거치면서 자연스럽게 아래와 같은 아키텍처가 구축되었고, 내 경험상 복잡한 프론트엔드에서 효과적으로 관리할 수 있는 견고한 아키텍처라고 생각하여 소개해보고자 한다.

 

너무나 당연하게도 이 아키텍처가 유일한 정답은 아니다. 프로젝트의 특성에 따라 가장 적합한 아키텍처는 다를 수 있다. 항상 더 나은 아키텍처를 고민하고 있기 때문에, 더 좋은 생각이 있다면 언제든지 환영하고 있다.

아키텍처 다이어그램

마치 계층 아키텍처처럼 수평 분할 다이어그램으로 표현했지만, 사실 이 그림에는 몇 가지 모순이 있다(이는 아래에서 설명하겠다). 백엔드처럼 아키텍처를 하나의 그림으로 명확히 그릴 수 있으면 좋겠지만, 프론트엔드는 여러 이유로 인해 이것이 어렵다. 그래도 큰 개요상 틀린 그림은 아니다. 각 계층은 다음과 같은 관심사를 가진다.

 

  • page layer: 페이지 수준에서 어떤 컴포넌트를 화면에 보여줄지를 결정한다.
  • component layer: 하나의 역할을 담당하는 작은 컴포넌트로 구성되며, 크게 비즈니스 로직을 훅으로부터 가져와 처리하는 도메인 컴포넌트와 화면에 보이는 스타일링을 담당하는 뷰 컴포넌트로 나뉜다.
  • business layer: 가장 중요한 계층으로, 비즈니스 로직이 응집되어 있는 곳이다. 크게 컴포넌트에서 사용하는 비즈니스 훅과 함수, 클래스 등 비훅 형태의 비즈니스 로직을 처리하는 서비스로 나뉜다
  • store layer: 애플리케이션의 데이터를 관리하는 계층으로, 서버에서 받은 데이터를 캐시 하는 쿼리와 클라이언트 측 데이터를 저장하는 스토어로 구성된다.
  • utility layer: 비즈니스 로직은 아니지만 애플리케이션 동작을 위해 필요한 로직들을 관리하는 곳으로, 모든 계층에서 접근 가능하다.

 

도메인 주도 설계와 수직 분할

나는 앞서 아키텍처를 나누는 이유가 변경이 발생할 때 코드베이스 전체가 아닌 특정 계층만 알게 하여 복잡성 관리에 도움을 준다고 설명했다. 그러나 새로운 유스케이스를 개발할 때를 생각해 보자. 정말로 특정 계층만 알게 될까? 실제로는 많은 개발이 컴포넌트 계층부터 비즈니스 계층, 그리고 스토어 계층까지 이어지며 모든 계층에 걸쳐 수정이 필요할 수 있다. 즉, 아키텍처로 분리하더라도 여전히 코드베이스 전체가 변경의 영향을 받는 상황인 것이다.

 

위와 같은 상황에서 변경 유효범위를 제한하기 위해서는 기능에 따른 수평 분할 외에도 도메인(서비스) 단위의 수직 분할을 적용해야 한다. 아래와 같이 코드베이스에 수직 분할을 적용하면, 모든 계층에 걸친 변경 사항이 있더라도 변경의 영향을 해당 수직 분할이 적용된 영역으로 제한할 수 있다.

유명한 클린 아키텍처의 수직 분할 예시

따라서 필자는 위 다이어그램처럼 코드베이스를 계층화할 뿐 아니라 비즈니스와 관련된 계층에는 내부에 수직 분할하여 코드베이스를 구분하고 있다. 수직 분할의 경계를 어떻게 설정하느냐가 또 중요한 문제인데, 필자는 먼저 애플리케이션 당 3~5개의 큰 도메인으로 나누고, 각 도메인 내에서 데이터 모델을 기준으로 세분화하고 있다. 이러한 접근 방식은 데이터 모델을 중심으로 훅에 응집된 비즈니스 로직을 작성하는 프론트엔드 관행과 잘 맞아떨어진다.

아키텍처를 반영한 폴더 구조

위 폴더 구조를 보면 이해가 더 쉬운데, 비즈니스 계층에 훅이 있고 그 아래에 큰 도메인(numerical-guidance 등)으로 나눈 후, 데이터 모델로 세분화한다. 흥미로운 점은 기능별 수평 분할된 계층은 business, hooks와 같은 기술적인 이름을 사용하는 반면, 도메인 기반의 수직 분할은 numerical-guidance, 예측 지표 등 도메인 용어를 사용한다는 것이다. 이때 도메인 용어를 기획자의 언어와 통일하면 기능에 대해 이야기할 때 더 편리하다.

 

은 탄환과 콘웨이 법칙

한 가지 먼저 말하고 싶은 것은, 필자가 진행하는 프로젝트는 금융 도메인으로, 프론트엔드에 복잡한 비즈니스 로직이 매우 많은 애플리케이션이라는 점이다. 따라서 복잡성을 관리하기 위해 코드베이스를 더 세분화하여 관리할 필요가 있었다. 그러나 대부분의 애플리케이션이나 아직 복잡하지 않은 개발 초기 단계에서는 이러한 세분화가 오히려 복잡성을 증가시킬 수 있다. 아키텍처에도 은 탄환은 없다. 그때 상황과 환경에 맞는 아키텍처를 구축하고 계속 진화시켜나가야 한다.

 

또한, 애플리케이션의 내재된 복잡성 외에도 팀의 구조가 아키텍처에 영향을 미치기도 한다. 이를 콘웨이 법칙이라고 부르는데, 팀을 나누고 생산성을 극대화하기 위해서는 코드베이스를 독립적으로 분리해야만 가능하기 때문이다. 따라서 팀의 구성과 협업 방식에 고려해서 아키텍처를 구성해야 할 필요도 있다.

 

필자는 실제로 콘웨이 법칙을 경험한 적이 있는데, 프로젝트를 진행할 때 처음에 3명의 개발자가 있을 때는 3개의 도메인으로 분할되었지만, 한 명의 개발자가 추가되어 4명이 되자 도메인도 4개로 재구성되었다. 이렇듯 개발자가 충분한 독립적인 생산성을 내기 위해서는 아키텍처 상에서 분리가 이뤄져야 할 필요가 있다.

 

 

비즈니스 계층: 애플리케이션의 심장

프론트엔드 아키텍처의 핵심은 비즈니스 로직과 컴포넌트(view)를 분리하는 것이다. 대부분의 프론트엔드 복잡성 문제는 비즈니스 로직과 컴포넌트가 강하게 결합되어 발생한다. 이 둘이 결합되어 있으면 수정 시 비즈니스 로직과 뷰 로직을 모두 고려해야 하기 때문에 복잡성이 높아지고 유지보수 비용이 증가한다. 또한, 비즈니스 로직을 뷰 변경 사항으로부터 보호하기 어렵고, 컴포넌트는 비즈니스 로직으로 인해 서로 강하게 결합되어 재사용하기도 어려워진다. 이 둘은 명확히 다른 관심사고 다른 이유로 변경된다. 따라서 이를 명확하게 분리하자.

 

과거에 커스텀 훅이 없던 시절에는 container/presentational 구조로 개발하며, Container 컴포넌트에 비즈니스 로직을 작성해야만 했다. 그러나 커스텀 훅이 등장한 이후로는 더 이상 그렇게 할 필요가 없다. 비즈니스 로직을 훅에 응집시켜 비즈니스 로직과 컴포넌트를 분리하자. 이렇게 하면 비즈니스 로직은 재사용하기 쉽고, 컴포넌트를 더욱 독립적으로 관리할 수 있다.

 

이러한 비즈니스 로직을 응집시킨 커스텀 훅들을 모아놓은 것이 바로 비즈니스 계층이다. 비즈니스 계층이 애플리케이션의 심장인 이유는 모든 비즈니스 로직이 이 계층에 응집되어 있기 때문이다. 이 계층에서 애플리케이션의 모든 상태를 조작하며, 가치를 만들어 낸다.

 

훅(hooks)

나는 이러한 비즈니스 로직을 다루는 훅을 비즈니스 훅이라고 부르며, 뷰 로직을 다루는 훅과 엄격하게 구분한다. 비즈니스 훅은 비즈니스 계층에 모아두고, 뷰 로직을 다루는 훅은 유틸리티 계층에 위치시켜 아키텍처 수준에서도 이들을 분리하고 있다.

비즈니스 훅을 만들 때 한 가지 중요한 팁은, 원칙적으로 비즈니스 훅은 컴포넌트를 몰라야 한다는 것이다. 이 훅이 어떤 컴포넌트에서 사용될지는 비즈니스 훅의 관심사가 아니다. 비즈니스 훅은 데이터를 가져오고 이를 조작하는 데만 집중해야 한다. 훅과 컴포넌트가 너무 결합하면 훅이 점점 무거워지고 재사용하기 어려워지므로, 이를 의식하면서 개발해야 한다.

 

훅 나누기

개발을 하다 보면 훅을 어느 정도로 세분화해서 관리해야 할지 고민이 들기 마련이다. 예를 들어, 크게는 하나의 도메인 단위로 쪼갤 수도 있고, 작게는 메서드 하나 수준으로까지도 쪼갤 수 있다. 각자 장단점이 있겠지만, 필자는 데이터 모델 단위로 쪼개고 관련 데이터 모델을 조작하는 로직을 하나의 훅에 응집하는 것을 선호한다

// 비즈니스 훅 예시
export const useIndicatorBoardMetadataListViewModel = () => {
  const { data: indicatorBoardMetadataList } = useFetchIndicatorBoardMetadataList();
  const { trigger: deleteIndicatorBoardMetadataTrigger } = useDeleteIndicatorBoardMetadata();
  const { trigger: createIndicatorBoardMetadataTrigger } = useCreateIndicatorBoardMetadata();

  const convertedIndicatorBoardMetadataList = useMemo(() => {
    if (!indicatorBoardMetadataList) return undefined;

    return convertIndcatorBoardMetadataList(indicatorBoardMetadataList);
  }, [indicatorBoardMetadataList]);

  // ...

  const createIndicatorBoardMetadata = async (name?: string) => {
    const body = {
      indicatorBoardMetadataName: createNotDuplicatedName(
        name ?? '메타데이터',
        convertedIndicatorBoardMetadataList?.names ?? [],
      ),
    };

    return await createIndicatorBoardMetadataTrigger(body);;
  };

  const deleteIndicatorBoardMetadata = async (id: string) => {
    deleteIndicatorBoardMetadataTrigger(id, {
      optimisticData: (): IndicatorBoardMetadataResponse[] | undefined => {
        return convertedIndicatorBoardMetadataList?.deleteIndicatorBoardMetadata(metadataId);      
        },
      revalidate: false,
    });
  };

  return {
    indicatorBoardMetadataList: convertedIndicatorBoardMetadataList,
    createIndicatorBoardMetadata,
    deleteIndicatorBoardMetadata,
  };
};

 

서비스

비즈니스 계층에는 훅 외에도 서비스라는 개념이 존재한다. 서비스는 함수나 클래스와 같은 비훅 형태의 비즈니스 로직을 관리한다. 특히 필자가 이 계층에서 가장 많이 사용하는 개념은 '뷰 모델'이다. 뷰 모델은 서버로부터 받은 응답 데이터를 캡슐화하여 API 변경으로부터 컴포넌트를 보호하고, 복잡한 계산 로직을 쉽게 작성할 수 있게 해 준다 (예: 차트를 그리기 위한 변환 로직).

 

개인적으로는 뷰 모델이 많은 도움이 되었다. 특히 API 변경이 빈번한 개발 초기나 클라이언트 측에서 복잡한 계산 로직이 필요한 경우에 추천할 만한 기법이다. 뷰 모델에 대한 더 자세한 설명은 이 글을 실제 코드 및 사용법은 이 글을 참고하길 바란다.

// 뷰모델 예시 코드
 
export class CustomForecastIndicator {
  readonly id: string;
  readonly name: string;
  readonly type: IndicatorType;
  readonly targetIndicator: IndicatorResponse;
  
  // ...
  constructor({
    id,
    name,
    type,
    targetIndicator,
    // ...
  }: CustomForecastIndicatorResponse) {
    this.id = id;
    this.name= customForecastIndicatorName;
    this.type = type;
    this.targetIndicator= targetIndicator;
  }
  
  // API 변경으로 사라진 필드를 계산된 값으로 만들고 접근자 함수를 통해 노출한다.
  get targetIndicatorName() {
	  return this.targetIndicator.name
  }
}

뷰 모델 외에도, 최근 Next.js의 RSC를 위한 fetch 함수나 서버 액션 등, 클라이언트에서 상태를 저장하지 않고 바로 서버에 요청하는 코드는 서비스 계층에서 작성하고 있다.

 

 

페이지 및 컴포넌트 계층: 또 하나의 계층 구조

프론트엔드 아키텍처에서 가장 모호한 부분은 페이지 계층과 컴포넌트 계층으로 이루어진 UI 계층이다. 이는 위에서 나눈 계층 외에도 컴포넌트 간에만 작동하는 또 다른 계층이 존재하기 때문이다. React와 같은 대부분의 UI 라이브러리는 컴포넌트에 계층 구조를 가진다. 이러한 계층 구조를 통해 컴포넌트의 관심사를 잘 구분해야 복잡성을 효과적으로 관리할 수 있다.

 

그렇다면 컴포넌트의 관심사는 무엇이 있을까? 필자는 웹의 세 가지 요소인 HTML, CSS, 그리고 JavaScript를 참고하는 접근법을 떠올렸다. 단순하게 HTML은 구조를 잡고, CSS는 화면에 보이는 스타일을 표현하며, JavaScript는 비즈니스 로직을 담당한다고 할 수 있다. 따라서 컴포넌트를 세 가지 주요 관심사로 나누어, 이들 간에 명확한 역할 분담을 통해 3 계층 구조로 컴포넌트를 구조화했다.

컴포넌트 계층 구조

가장 상위의 컨테이너 컴포넌트는 페이지 수준에서 어떤 컴포넌트를 화면에 보여줄지를 결정하고 전체적인 구조를 잡는 역할을 한다. 그 아래의 도메인 컴포넌트는 비즈니스 훅으로부터 비즈니스 로직을 가져와 뷰 컴포넌트에 전달하여 애플리케이션의 목적을 달성한다. 마지막으로 뷰 컴포넌트는 화면에 보이는 요소들을 처리하는 역할을 한다.

 

횡단 관심사와 선언형 프로그래밍

사실 컴포넌트의 관심사는 앞서 말한 것 외에도 매우 많다. 그럼에도 불구하고 3-계층으로 컴포넌트를 나눈 이유는 계층이 3개를 넘어가면 인간의 인지 능력 한계로 인해 오히려 복잡성이 증가한다고 생각하기 때문이다.. 그래서 대부분의 아키텍처가 3 레이어를 넘지 않는다고 생각한다.

 

계층화되지 않은 관심사는 주로 횡단 관심사에 해당하며(예: 로깅, 로딩, 에러 처리 등), 이러한 관심사는 이를 담당하는 컴포넌트를 만들어 이에 응집시켜 최대한 선언적으로 관리하려고 하고 있다.

 

도메인 컴포넌트

비즈니스 로직을 애플리케이션의 심장이라고 표현했지만, 백엔드와 다르게 프론트엔드에서는 컴포넌트 계층도 비즈니스 로직만큼 중요하다. 왜냐하면 컴포넌트와 비즈니스 로직이 함께 존재할 때만 프론트엔드의 목적을 달성할 수 있기 때문이다.

 

비즈니스 로직과 컴포넌트가 결합되는 곳이 바로 도메인 컴포넌트다. 따라서 도메인 컴포넌트가 얼마나 독립적으로 잘 관리되고 있는지는 아키텍처의 건강 상태를 알 수 있는 중요한 지표이다. 또한, 도메인 컴포넌트는 프론트엔드에서 가장 효과적으로 테스트를 수행할 수 있어, 테스트 단위로 적절합니다. 테스트를 작성하려면 컴포넌트를 독립적으로 개발해야 하므로, 결과적으로 설계가 더욱 좋아지는 긍정적인 효과도 있다.

 

도메인 컴포넌트는 서버로부터 이어지는 비즈니스 로직과 컴포넌트 계층의 접점이기 때문에 변화가 자주 발생한다. 따라서 과하다 싶더라도, 도메인 컴포넌트 내에 하나의 로직만 존재하도록 최대한 세분화하는 것이 경험 상 좋다.

// 도메인 컴포넌트: 자신의 책임(강의를 추가하는 것)만 집중하여 수행한다.
export default function AddLectureButton() {
  const { addLecture } = useLectures();
 
  const handleLectureAdd = () => {
    addLecture({
      title: 'Lecture Title',
      description: 'Lecture Description',
    });
  };
 
  return <Button onClick={handleLectureAdd}>Add Lecture</Button>;
}
 
// 컨테이너 컴포넌트: 화면에 어떤 컴포넌트를 그리고, 레이아웃을 만들지에 대한 관심사만 가진다.
export default function LectureContainer() {
 
  return (
    <div>
      <AddLectureButton />
      <DeleteLectureButton />
      <LectureList />
    </div>
  );
}

백엔드와 달리 프론트엔드는 컴포넌트의 계층도 고려하며 아키텍처를 설계해야 하는 것이 가장 큰 특징이다. 특히 앞서 나눈 비즈니스 계층화와 컴포넌트 계층화는 서로 직교하는 느낌이 든다. 도메인 컴포넌트를 제외하고는 수직 분할을 적용하기 어렵기 때문이다.

 

또한, 여기서는 뷰 컴포넌트를 하나의 컴포넌트로 소개했지만, 디자인 시스템에 따라 뷰 컴포넌트 내부에도 계층이 있을 수 있다(아토믹 디자인 시스템처럼). 이런 요소들까지 고려하면 프론트엔드는 더욱 복잡해진다. 여기서는 일단 비즈니스 로직 관점에서 코드베이스를 바라보도록 하자.

 

MVI 패턴?

프론트엔드 아키텍처를 이야기할 때, 흔히 MVVM, MVI와 같은 MV* 구분법을 사용하는 경우도 많습니다. 이러한 구분법 내에서는 내 아키텍처는 테오 님이 언급하셨던 MVI(Model-View-Intent) 패턴과 가장 유사하다고 생각한다. 물론 완전한 MVI라면 Redux와 같이 이벤트 디스패치를 사용해야겠지만, 도메인 컴포넌트에서도 비즈니스 로직(Intent)만을 가지고, 훅에서 데이터 변경 로직을 응집하고 있다는 점에서 MVI의 핵심 철학을 공유하고 있다고 생각하기 때문이다.

 

물론 MVI라는 네이밍은 실제로 아직 웹 쪽에서는 잘 안 쓰이고 모바일 진영에서 자주 쓰는 용어인 듯하다.

 

 

스토어 계층: 과유불급

스토어 계층은 애플리케이션의 데이터를 관리하는 역할을 하며, 서버 측 데이터를 다루는 쿼리와 클라이언트 측 데이터를 다루는 스토어로 나뉜다. 예전에는 서버 측 데이터와 클라이언트 측 데이터를 구분하지 않고 사용했지만, 둘은 명확하게 다른 관심사를 가지므로 분리하여 관리하는 것이 바람직하다.

 

데이터를 관리하는 일은 생각보다 복잡하다. 특히 서버 측 데이터를 다루는 경우 경쟁 상태나 캐시 관리 등 다양한 문제를 해결해야 한다. 이 때문에 React-Query나 SWR 같은 라이브러리를 사용하는 것이 이제는 자연스러운 선택이 된 듯하다.

 

이러한 라이브러리를 사용함으로써 아키텍처적으로 가장 큰 이점은 데이터를 전역 데이터처럼 다룰 수 있다는 점이다. 데이터를 전역 상태처럼 관리할 수 있어야 컴포넌트와 비즈니스 로직을 완전히 분리할 수 있다. 이를 위해서는 어디선가 더러운 작업을 처리해줘야 하는데, 라이브러리는 이러한 작업을 훌륭하게 처리해 주기 때문에 우리는 세부적인 구현에 신경 쓰지 않아도 된다.

 

스토어와 비즈니스 계층 결합 위험성

전통적인 3-계층 아키텍처와 마찬가지로 비즈니스 계층과 스토어 계층은 강하게 결합될 위험이 항상 존재한다. 데이터가 스토어 계층에도 존재하기 때문에, 스토어 계층에서 데이터를 수정하고 싶은 유혹이 계속 생기기 때문이다. 하지만 두 계층이 결합하면 비즈니스 로직이 응집되지 않고 분산되어, 변경의 유효 범위가 넓어지고 수정이 어려워진다.

 

스토어 계층의 역할은 데이터를 조작하는 것이 아니라, 데이터를 관리하는 것임을 명심해야 한다. 그 이상은 스토어 계층에 존재할 필요가 없다. 이러한 이유로 recoil이나 jotai 같은 atom 개념의 작은 상태 관리 도구들이 인기를 끌고 있다고 생각한다. 비즈니스 계층이 존재하는 이상, 스토어 계층은 그 이상 커져서는 안 된다.

 

전역 상태에 대한 오해

과거 내가 React를 처음 배울 시기에는 전역 상태 관리를 편리함과 성능을 희생하는 트레이드오프 관계로 묘사하곤 했다. 그래서인지 지금도 전역 상태를 남발하게 되면 성능에 뭔가 큰 죄를 짓고 있는 듯한 느낌이 든다. 하지만 실제로 전역 상태를 사용해서 잃는 성능은 거의 미미하다.

 

오히려 전역 상태를 사용하지 않음으로써 얻는 손해가 훨씬 크다. 전역 상태를 사용하지 않으면 해당 데이터 모델을 중심으로 컴포넌트가 강하게 결합되기 때문이다. 이럴 바에는 전역 상태를 사용해 컴포넌트 간 결합을 끊어내고 독립적인 컴포넌트로 관리하는 게 유지보수 차원에서 훨씬 더 낫다. 설사 성능에 문제가 생긴다고 해도, 독립적인 컴포넌트를 구축하는 게 성능을 개선하기도 훨씬 수월하다.

 

전역 상태의 문제는 오히려 다른 곳에 있다. 전역 상태를 잘못 관리하면 언제 어디서 수정되는지 추적하기가 매우 어려워질 수 있다. 따라서 전역 상태를 사용할 때는 이를 캡슐화하고 수정하는 로직을 응집시켜야 한다. 우리 구조에서는 비즈니스 계층이 이 역할을 담당한다.

 

클라이언트 상태는 계산된 값으로 변경하자

클라이언트 상태를 관리할 때 흔히 하는 실수는 원천 데이터로부터 도출할 수 있는 데이터를 클라이언트 상태로 저장하는 것이다. 이는 지식의 중복이기 때문에 동기화 문제로 이어져 결국 버그가 되어 나타난다. 심지어 버그를 찾기도 매우 어렵다!

프론트엔드 개발을 잘하기 위해서는 실제 상태와 계산된 값을 명확하게 구분하는 것이 중요하다. 이 문제는 생각보다 복잡해서, 경험이 많은 개발자들도 종종 실수하는 부분이다. 필자도 물론 자주 실수를 하곤 한다. 만약 캐시를 하고 싶은 거라면 useMemo 훅을 사용하자. 계산된 값은 상태로 저장하지 말고, 직접 컴포넌트에서 계산하거나, 복잡한 계산이라면 서비스의 뷰 모델에서 객체지향적으로 처리하고 있다.

 

마무리하며

이번 포스팅에서는 필자의 그동안의 고민을 아주 콤팩트하게 담아 작성해 봤다. 복잡한 프론트엔드를 위한 아키텍처를 구축하는 것은 필자가 정말 오랫동안 고민해온 주제이며, 아직도 고민 중인 주제이다. 프론트엔드가 아직 역사가 짧아서인지 아키텍처에 대한 자료가 생각보다 적다. 하지만 복잡한 애플리케이션을 만드는 데 효과적인 아키텍처가 없다면 난이도가 훨씬 올라간다. 따라서 이 글을 쓴 이유도 나와 같은 고민을 하는 사람들에게 조금이나마 도움을 주기 위해서이다. 특히 회사를 다니지 않고도 복잡한 프로젝트를 진행해보고 싶은 사람들에게 도움이 될 것이다.

 

프론트엔드 아키텍처는 뭐랄까, 서버 측 아키텍처보다 더 역동적이고 변주가 많다고 해야 할까? 의존하는 것도 많고, 컴포넌트라는 특수성이 있기 때문인 듯하다. 특히 최근에 나온 리액트 서버 컴포넌트(RSC)는 아키텍처 관점에서 주목할 만한데, 서버 컴포넌트 자체가 컴포넌트 계층을 구성할 때 영향을 주기 때문이다. 이로 인해 프론트엔드 패러다임이 또 어떻게 변할지 기대된다.

 

아무튼 정리하면, 프론트엔드도 점점 성숙도가 높아지면서 더 많은 것을 고려하게 된다고 생각하는데, 아키텍처도 그중 하나라고 생각한다. 이쪽 분야로 더 많은 자료가 세상에 나왔으면 하고, 나도 조금이나마 이에 기여하고 싶다는 마음으로 글을 써봤다. 긴 글 읽어주신 분들께 정말 감사드린다.