React 아키텍처를 고민해보다

이은서·2025년 1월 11일
2

지금 회사에 입사한지 거의 1년이 다 되어 가고 있습니다. 처음 입사했을 때부터 코드 구조와 정책에서 개선하고 싶은 점들이 여럿 있었는데요, 최근에서야 개선이 필요하다는 점을 적극적으로 이야기하며 아키텍처 개편을 추진하게 되었습니다. 현재 하나씩 수정해 나가고 있으며, 이 과정에서 고민했던 내용들을 공유하고자 글을 작성하려고 합니다.

기존 코드의 문제점

처음 회사에 왔을 때 전체적인 구조

1. Service의 역할 불필요

Service, Repository, Page로 구분된 구조 자체는 적절한 정책과 역할 분배가 이루어진다면 나쁘지 않은 전략일 수 있습니다. 하지만 정책 상 Service는 다른 페이지에서 공통으로 쓸 수 있었습니다. 때문에 Service에는 어떠한 비즈니스 로직이 들어가기가 힘든 구조가 되었습니다.비즈니스 로직을 포함하지 못하고 Repository를 단순히 감싸는 형태로 사용되었습니다. 또한, 서버 상태를 직접 관리하는 것이 아니라 React Query 같은 툴에 의존했기 때문에 Service가 오히려 불필요한 보일러플레이트를 증가시키는 원인이 되었습니다. 결국, 주요 비즈니스 로직과 데이터 가공은 Page 컴포넌트에서 처리하였고 생산성은 저하되었습니다.

Example:

// src/repositories/userRepository.ts

export async function getUser(userId: string) {
  const response = await fetch(`/api/users/${userId}`);
  return response.json();
}
// src/services/userService.ts

/**
 * 원래 Service 계층에서는
 * - 도메인 규칙이나 비즈니스 로직
 * - 데이터 가공/처리
 * 등을 담당하는 것이 일반적입니다.
 *
 * 그러나 여기에선 별도의 로직 없이
 * Repository 함수를 그대로 호출만 하고 있습니다.
 */
export async function fetchUserData(userId: string) {
  return await userRepository.getUser(userId);
}

2. Entity 혹은 Model의 부재

Entity 또는 Model을 정의하지 않고, 서버에서 내려오는 DTO를 그대로 프론트엔드에서 사용했습니다. 이렇게 되면 서버의 DTO 스펙이 변경되면, 이를 사용하는 프론트엔드의 모든 코드를 수정해야 했습니다. 뿐만 아니라 데이터 변환이 산재되었습니다. 예를 들어, 서버에서 시간을 문자열(string)로 내려주면, 프론트에서 이를 Date 객체나 Day.js 객체로 변환해야 하는 상황이 빈번했습니다. 이러한 변환 로직이 분산되어 관리와 유지보수가 어려워졌습니다.

3. Page의 과중한 책임

팀의 정책 중에는 컴포넌트에서 API 호출을 금지하고 Page 컴포넌트에서만 호출하도록 하는 정책이었습니다. 이렇게 되면 하위 컴포넌트는 Props로 데이터를 받아 처리해야 했는데, 이는 Container-Presenter 패턴과 유사했지만, 일반적인 패턴과 달리 Container를 하나의 Page로 한정 지어 안티패턴이 되었습니다. 때문에 Page 컴포넌트가 비대화되며, 비즈니스 로직이 Page에 몰리는 구조가 되었고 결국 SRP를 해치게 되어버렸습니다.테스트 코드를 단순화하기 위한 정책이었지만, 오히려 Page에 쌓인 로직으로 인해 테스트 복잡도가 증가하였습니다.

4. 분리 자체가 명확하지 않음

전체적으로 컴포넌트, 훅 등이 도메인 및 역할 별로 나누어져 있다기 보다는 페이지 별로 나누어져 있었습니다.
예를 들어, 같은 User 데이터를 쓰더라도 /a/b, /x/y 라는 페이지가 있을 때, components/a/b/, components/x/y/와 같은 구조로 컴포넌트를 관리했습니다. 이럴 경우 User, Product 등 특정 도메인과 연관된 로직·컴포넌트를 한곳에서 파악하기 어렵고 도메인 단위로 관리/확장하기가 힘듭니다. 또한 페이지가 늘어날수록 폴더 구조가 점점 늘어나며, 파일도 분산됩니다. "어떤 기능을 수정/추가하려면 어느 페이지 폴더를 가야 하는지" 찾기부터가 복잡해질 수 있습니다.

등등... 사실 이외에도 다른 문제점들이 있지만 구조적인 문제중에 큰 것들만 가져왔습니다.
최소한의 문제점들을 고쳐보려고 Page 컴포넌트에서 로직을 처리하기 보다는 Page를 위한 View Model 형태로 Custom Hook을 만들어 Custom hook에서 비즈니스 로직을 처리하게 구조를 수정해보기도 했지만 비즈니스 로직을 분리하는 정도만 의미가 있었고 사실상 큰 개선이 되지는 않았었습니다.

고민 내용

1. React에 의존적으로 짜지 않아야하나?

아키텍처를 고민하면서 "React에 의존적으로 짜지 않아야하나?" 하는 생각을 했었습니다. View 영역을 분리해놓고 React에 종속되지 않는 방식으로 작성한다면, 이후에 다른 프레임워크나 라이브러리로 전환할 때 View 관련 요소만 변경하면 되므로 높은 유연성을 확보할 수 있습니다. 그러나 React의 편리한 기능 및 라이브러리를 (예: Jotai, React-query, Custom Hook 등)을 활용하지 않고 로직을 작성하면 코드의 보일러 플레이트 작성이 커지고 복잡성이 증가할 것이라고 보았습니다.

결론적으로, React에 의존적인 설계를 선택했습니다. 이는 React와 React 라이브러리에서 제공하는 다양한 기능들을 적극 활용하여 생산성을 극대화하는 방향이었습니다. 현재 프론트엔드 트렌드와 React의 높은 시장 점유율을 고려했을 때, React를 대체할 일이 당분간 없을 가능성이 높다는 점도 고려했습니다.

2. 기능 및 컴포넌트를 Domain 별로 묶을까?

프로젝트가 커질수록 폴더 구조를 어떻게 구성할 것인가도 중요한 고민이었습니다. 저는 역할별(Role-based)이 아닌 도메인별(Domain-based)로 구성하는 방식이 나을까 하는 고민을 했습니다.

예를 들어, User라는 도메인과 관련된 모든 코드(Controller, Service, Repository)를 하나의 user 폴더에 배치하는 방식입니다. 이렇게 되면 아래와 같은 장점을 가지고 있습니다.

  • 하나의 도메인 내에서 수정할 코드들이 함께 모여 있어, 수정 시 관련된 코드를 빠르게 파악하고 변경할 수 있습니다.
  • 폴더 간 이동을 줄이고, 도메인별로 업무를 나눔으로써 개발자가 특정 도메인에만 집중할 수 있습니다.

저는 도메인 중심 폴더 구조를 택했고, 특정 도메인에 속하기 애매하거나 공통적으로 쓸 수 있는 여러 도메인에서 사용할 가능성이 큰 apis와 페이지(pages)는 도메인 폴더와 분리하였습니다.

3. 회사 상황 고려: 리팩토링 vs 신규 기능 개발

회사 환경에서는 다른 일들을 중단시키고 전체 프로젝트를 리팩토링하기란 사실상 불가능합니다. 새로운 기능 개발은 지속적으로 이루어져야 하며, 서비스의 성장과 운영을 동시에 고려해야 하기 때문입니다. 이러한 제약 속에서 다음과 같은 접근법을 채택했습니다.

  1. 장기적 아키텍처 설계: 전체적인 목표 아키텍처를 먼저 정의하고, 이를 바탕으로 점진적으로 리팩토링을 진행했습니다.
  2. 스프린트 기반 개선: 한 스프린트에서는 새로운 기능을 개발하고, 다음 스프린트에서는 코드 퀄리티를 개선하는 작업 방식으로 진행하기로 했습니다.
  3. 협업과 우선순위 조율: 기획자, 디자이너, 팀 리더와의 협의를 통해 우선순위를 설정하고, 리팩토링 작업이 서비스 발전을 저해하지 않도록 조율했습니다.

현재 리펙토링 방향은...

처음에는 Feature-Sliced Design(FSD)을 본따 아키텍처를 개편하려 했습니다. 하지만 이를 적용하려면 기존 구조를 거의 전면적으로 수정해야 했고, 리팩토링에 상당한 시간이 소요될 것으로 예상되었습니다.

또한, FSD는 명확한 계층 구조를 정의하는 것이 중요한데, 어떤 요소를 entities에 넣을지, widgets에 넣을지, features에 넣을지에 대한 기준이 명확하지 않다면 오히려 기술 부채를 양산할 수 있다는 우려가 있었습니다. 이러한 계층 구분을 고민하는 것 또한 개발 비용이라고 판단하여, 아키텍처를 전면적으로 변경하기보다 현재 가장 큰 문제들을 하나씩 해결하는 방식으로 접근하기로 결정했습니다.

가장 큰 문제라고 생각한건 위에서 언급한 3, 4번이었습니다. 때문에 다음과 같은 리펙토링을 진행하고 있습니다.

1. Page에서 Container로 역할 분리

기능별로 명확한 책임을 가지는 여러 Container 컴포넌트를 만들어 Page에서 역할을 분리했습니다. Page는 여러 도메인을 융합하는 역할만 담당하고, 실제 데이터 처리나 UI 상태 관리는 Container에서 처리하도록 변경했습니다.

2. 도메인 기반 폴더 구조 적용

폴더 구조를 기능별(feature-based)가 아닌 도메인별(domain-based)로 정리하여 “함께 수정되는 파일을 같은 디렉터리에 두기”를 적용했습니다.

이렇게 리팩토링을 진행하면서 FSD를 무리하게 도입하기보다, 현실적인 문제를 하나씩 해결하는 것이 더 적절한 접근일 수 있다는 점을 다시 한번 깨달았습니다.
아키텍처 개편은 단순한 트렌드 적용이 아니라, 현재 코드베이스에서 가장 큰 문제를 해결하는 방향으로 이루어져야 한다는 점을 강조하고 싶습니다

추후 개선 방향

Service / Repository를 제거하고 각 도메인별로 api 폴더를 두어 단순 api를 호출하는 함수를 두고 api를 tanstack/query를 통해 관리하는 custom hook을 만들어 두려고 합니다. 간략하게 생각해놓은 구조는 아래와 같습니다.

src
└── domains
    └── user
        ├── api
        │   ├── userApi.ts        // 단순 fetch/axios 호출만 담당
        │   └── userApi.dto.ts	  // DTO를 정의하고 이를 프론트 모델로 변환하는 함수 구현
        ├── query
        │   ├── useUserQuery.ts // react-query 훅 구현
        │   └── factory.ts  // Query Factory 구현
        ├── model
        │   └── user.ts      // user 모델
        └── ... (기타 파일)
// src/domains/model/user.ts
export interface User {
  id: string;
  name: string;
  createdAt: Date;
}
// src/domains/user/api/userApi.dto.ts
export interface UserResponse {
  id: string;
  first_name: string;
  last_name: string;
  created_at: string;
}

export function transformUserDTO(dto: UserResponse) {
  return {
    id: dto.user_id,
    name: dto.first_name + dto.last_name,
    createdAt: new Date(dto.created_at) 
  };
}
// src/domains/user/api/userApi.ts
export async function getUser(userId: string): UserResponse {
  const response = await fetch(`/api/users/${userId}`);
  return response.json();
}
// src/domains/user/query/factory.ts
export const userQueries = {
  detail: (userId: string) => queryOptions({
    queryKey: ['user', userId],
    queryFn: () => getUser(userId),
    // ...기타 옵션
  }),
  ...
}
// src/domains/user/query/useUserQuery.ts
export function useUserQuery(userId: string) {
  return useQuery({
    ...userQueries.detail(userId),
    select: (data) => transformUserDTO(data)
  })
}

아직 이 아키텍처가 완벽하지 않을 수 있지만 애초에 아키텍처는 완성된 형태라기보다, 팀의 성장과 비즈니스 변화에 맞춰 진화하는 개념입니다. 저희도 많은 피드백과 코드리뷰를 통해 실제 부딪혀보면서 점진적으로 개선하면서 프로젝트와 팀에 맞게 최적화하려고 합니다.

profile
프론트엔드 개발자

0개의 댓글