본문 바로가기
프론트엔드 관련/기초

FSD에서 도메인 중심 아키텍처로 전환하며 마주한 현실적인 선택들

by ash9river 2026. 1. 4.

본격적인 글을 들어가기 앞서

원래는 프로젝트 아키텍쳐의 방향성을 검토하고, 더 나은 아키텍쳐를 고안해내었다. 그리고 적용하는데 드는 비용을 따져서 현재 프로젝트에 개선된 아키텍쳐를 적용하지 않고, 다음 프로젝트에서부터 적용할 예정이었다.

 

처음에는 전체 코드베이스를 전부 검토하고 리팩토링하는 비용을 계산하였을 때, 전체 프로젝트를 갈아엎는 선택이 득보다는 실이 많다는 생각이 들었었다. 하지만 마침 개발기간이 연장되었기에 썩은 가지를 도려내기 위한 여정을 출발하였고, 이 글은 그 여정의 시작과 끝, 그리고 그 여정의 도중에 마주친 현실적인 고민들을 담기 위해 노력하였다.

 

사실 아키텍쳐 개선이라는게 현실적으로 무조건 좋은 결과만 driven되는 것은 아니였다. 결과를 예측하고, 개선에 드는 비용을 따져가면서 신중하게 접근해야 했고, 개선을 해나가는 와중에도 이리저리 흔들리는 기준을 붙잡으면서 더 좋은 방향성을 위하여 고민하였다.

 

기존의 프로젝트에는 아키텍쳐가 사실상 존재하지 않았고-관습적으로 프로젝트를 진행하였기 때문이다-현실적인 고민들을 충분히 한 끝에서야 아키텍쳐가 만들어졌다고 해도 과언이 아니었다. 기억이 더 흐릿해지기 전에 최대한 내 intuition을 담아, 더 나은 소프트웨어 아키텍쳐를 만들어나갈 수 있기를.

 

첫 고민의 시작은 import문이었다.

프로젝트의 크기가 충분히 커져서 어느정도 고도화가 되었기에, 미루어두었던 import의 개선을 시도할려 했었다.

예시로 만든 실제 환경와 흡사한 import 구문들

 

import 문의 개선을 위해서, 먼저 이전 프로젝트에서는 어떻게 import를 관리했는지 살펴보았다.

 

이전 프로젝트에서의 import 설정

사실 지난 프로젝트에서는 특별한 고민 없이 shortest를 사용했기에, 기준이 없어서 너무 무분별해보였다. 

{
  "typescript.preferences.importModuleSpecifierEnding": "auto",
  "typescript.preferences.importModuleSpecifier": "shortest",
  "javascript.preferences.importModuleSpecifierEnding": "auto",
  "javascript.preferences.importModuleSpecifier": "shortest"
}

 

// 익명화된 import문
import BaseLayout from '../../layouts/BaseLayout';
import { createDomainQueryOptions } from '@queries/options/domain';
import { useGlobalStore } from '@features/auth/stores';
import { useLocalFormStore } from '../../stores';
import RadioButton from '@components/ui/RadioButton';
import TextInput from '@components/ui/TextInput';
import SelectInput from '@components/ui/SelectInput';

 

그래서 현재 프로젝트에서 절대 경로만을 선택하기로 결정하였다.

{
  "typescript.preferences.importModuleSpecifierEnding": "auto",
  "typescript.preferences.importModuleSpecifier": "non-relative",
  "javascript.preferences.importModuleSpecifierEnding": "auto",
  "javascript.preferences.importModuleSpecifier": "non-relative",
}

 

그런데, 절대 경로로 import를 불러오다보니, 지금에 이르러서 너무 import 구문이 난잡해지는 사태가 발생하였다. 그래서 이를 해결하기 위해 barrel export를 도입하기로 결심하였다.

 

 

Barrel export의 예상 시나리오 구성 및 그에 대한 문제점

Barrel export의 도입을 위해, 다음과 같이 예상 시나리오를 2개 구성하였다.

 

1. export * from "./pages/EntityPage"
2. export { default as EntityPage } from "./pages/EntityPage"

 

1번 시나리오의 문제점

vscode상에서 원본 경로보다 barrel export 경로의 우선순위가 낮게 측정되어, barrel export로 만든 엔트리파일인 index.ts로 자동완성이 안되고, 원본 경로만이 자동완성에 추론이 되고 있었다.

 

2번 시나리오의 문제점

default export를 단순히 barrel export를 통해서 named export로 바꾸는데, 이는 기존 default export를 만들었던 설계의 의미가 오히려 흐려지는 방향이어서 좋지 않았다.

 

default export에 대한 근본적인 의문이 생겼다

그래서 기존 default export로 계속 작성하던 코드가 과연 맞는 방향인가? 라는 생각이 들어서 default export의 효용성을 확인해보고 named export와 무슨 차이가 있는지 확인해보았다.

 

내가 컴포넌트에 default export를 사용했던 이유

컴포넌트에 대해서 전부 default export를 사용했던 결정에는 나름 합리적인 이유가 있었다.

파일 하나당 컴포넌트 하나라는 전제를 명시적으로 알릴 수 있었고, export default로 import된 파일을 보면 별도의 설명 없이도 이 파일은 컴포넌트라 인식할 수 있었다.

 

FSD 폴더 구조를 도입했던 당시에는 기능 단위로 컴포넌트들이 묶여 있었기 때문에, 이 default export로 되어있는 컴포넌트들이 전혀 불편하지 않았다.

오히려 이 export가 직관적이고 합리적이라 느껴졌다.

 

하지만 이 합리적이라 생각한 구조가 barrel export를 설계할 때, 기존 설계의 의미가 흐려졌기에, 과연 관습적으로 사용하던 export default를 계속 사용하는 것이 구조적으로 타당한가라는 근본적인 의문이 들었다.

default export 자체가 잘못된 선택이라는건 아니였다

barrel export를 통해서 import를 하나의 엔트리 포인트로 정리할려고 시도하니, default export가 barrel export를 사용하는 구조에 적합한지에 의문이 들은 것이다.

 

default export와 named export를 비교해보았다.

default export는 파일 단위로 명확한 의미를 가지며, 기존 컨벤션상 해당 파일은 컴포넌트다라는 것을 명시적으로 할 수 있다는 장점을 가진다.

하지만 barrel export를 이용하면 엔트리 파일에서 default export를 감싸 다시 named export로 전환되기 때문에 default export의미가 많이 흐려졌다.

import { lazy } from 'react';
import { LoaderFunctionArgs } from 'react-router';

const FeaturePageLazy = lazy(() => import('./pages/FeaturePage'));

const FeaturePageLoader = async (args: LoaderFunctionArgs) => {
  const { featurePageLoader } = await import(
    './pages/FeaturePageLoader'
  );
  return featurePageLoader(args);
};

export { FeaturePageLazy, FeaturePageLoader };

 

그래서 named export가 더 타당하다고 느꼈다

그래서 엔트리 파일에서 barrel export를 위한 확장에는 default export에 비하여 named export가 유리했을뿐더러, named export는 Symbol Refactor, Tree-Shaking에서도 default export보다 이점을 보이기에 모든 export는 named export인 것이 더 유리하다는 생각을 하였다.

이는 코드 스타일 통일 뿐만 아니라 엔트리 포인트의 구조를 명확히 정하고, 이 엔트리 포인트에서 public을 관리하기 위한 전략적인 선택이었다.

 

named export를 전제로한 엔트리 포인트 시나리오 재구성

기본적인 모든 컴포넌트의 export를 named export로 전제하고, Product라는 예시 도메인의 시나리오를 구성해보자

product/index.ts

 
export * from './hooks';
export * from './components';
export * from './services';

product/hooks/index.ts

 
export * from './useFoo';
export * from './useBar';

product/components/index.ts

 
export * from './ProductCard';
export * from './ProductList';
 
features/
  [domain]/
    index.ts    루트 배럴
    hooks/
      index.ts
    pages/
      somethingPage.tsx
      index.ts
    services/
      index.ts  하위 배럴

 

고안한 설계의 방향성과 실제 동작이 맞지 않는 현상이 나타났을때

설계의 방향성 자체는 모든 폴더에 index.ts를 두고, 루트 폴더의 index.ts에서 그 파일들을 가져오는 방식을 고안하였다.

이때 public은 루트의 index.ts에서 이루어지고, 파일이 속한 하위 배럴에서만 수정이 이루어지는 구조로 설계하였다.

 

언뜻 보기엔 이상적인 barrel export라고 생각하였다. 그러나, 해당 구조에서는 vscode의 실 동작상 import가 제대로 이루어지지 않았다. 

 

// 내가 원한 동작 (index.ts는 생략)
import { somethingPage } from '@/features/[domain]'
// 실제 동작
import { somethingPage } from '@/features/[domain]/pages/somethingPage'

 

따라서 import에 관한 vscode 세팅 자체의 변경을 고민하였다.

기존에는 barrel export로의 전환을 염두에 두었기 때문에, import는 무조건 절대경로로만 작성되게끔 설정하였다. 이를 아래와 같이 새롭게 설정함으로써, 각 도메인 폴더에서는 상대경로, 그리고 외부에서는 절대경로, @로 무조건 엔트리파일에만 접근하는 방식을 고안했다.

 

 "typescript.preferences.importModuleSpecifier": "shortest"

 

그러나 위와 같이 세팅을 변경하였음에도, vscode의 현 동작을 확인해 보았더니, 자동완성이 의도된 경로가 아닌 원본 경로인 @/features/[domain]/pages/somethingPage를 우선경로로 추론하고 있었다.

 

따라서 상태를 정확히 파악하고 해결방안을 모색하고자, 해당 프로젝트의 tsconfig 파일에 정의된 path alias를 확인해보았다.

tsconfig의 path alias는 다음과 같았다

    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }

 

tsconfig의 path alias는 기본 설정과 동일하였다.

 

그러나 barrel export를 이용할려면 ./index.ts를 강제해야 하는데, path alias에서 @/*를 통해 와일드 카드 매칭을 하면 엔트리 포인트인 ./index.ts로의 진입이 강제적으로 이루어지기 어려워진다.

 

따라서 기존의 @/*를 폐기해야만 한다는 결론에 다다르게 되었는데, 이는 선택의 갈림길에 이르렀다.

 

선택의 갈림길, 나의 결정은 더 현실적인 방향이었다.

현재 프로젝트는 모든 import에 @/* 와일드 카드 매칭을 활용하고 있으며, 그 규모가 전체 26k LOC 프로젝트 중 2.1k에 근접하였다.   

 

이에 대해 리팩토링을 진행한다면 상당히 많은 시간과 비용이 들 것이라 예측이 되었다. 리팩토링 과정에서 발생할 수 있는 예측할 수 없는 버그 및 리팩토링 그 자체에 소요되는 시간 등을 고려했을 때, 리팩토링의 비용이 이득보다 클 가능성이 높다고 판단하였다.

 

그래서 이 프로젝트에서 점진적 리팩토링을 하거나 아니면 다음 프로젝트에서 하거나. 두 가지 방향성 중 하나를 선택할려고 하였다. 

 

그러기 위해서 프로젝트가 대규모에 도달한다는 가설을 설정하고, 그 대규모 프로젝트는 어떻게 구성되어야 할지 문제의 근본적인 원인을 탐색하였다.

 

과거의 의사결정을 떠올리면서 문제의 근본적인 원인을 탐색해보자.

현재 프로젝트를 되돌아보는 작업은 폴더 구조를 점검하는 것에서부터 시작하였다.

 

현 프로젝트의 기본 구성 기조는 FSD(Feature-Sliced Design)이었다. 이는 이전에 진행했던 프로젝트인, 식용곤충 사육관리 시스템에서 사용하던 폴더 구조를 그대로 가져왔기 때문이었다.

이전 프로젝트는 약 15k LOC 규모로, FSD를 적용하기에 적당한 수준이었다.

 

그러나 현 프로젝트는 FSD의 기조만 따온 상태로, 대부분 페이지 기반으로 구성되어 있었다.

관리 시스템 특성상, 대분류/중분류/소분류로 폴더가 나뉘다 보니, 폴더 구조가 자연스럽게 페이지별로 나뉘게 된 것이었다. 각 페이지마다 도메인이 크게 겹치지 않는 형태가 되었기에 FSD의 기조를 따르고 있었어도, 자연스럽게 실질적 폴더 구성은 페이지 기반으로 변형되었다.

 

이러한 폴더 구성의 문제는 도메인이 겹치는 상황에서 드러났다.

 

다른 페이지에서도 중복하여 사용할 공통된 로직에 대하여, 추상화한 파일을 어떤 페이지 폴더에 위치시켜야 하는가? -위치 선정의 혼란을 겪게 된 것이다.

 

그 결과, 공통 로직의 위치를 파악하지 못할 뿐더러,그 파일의 정의마저 불분명하여, 결과적으로 공통 로직을 중복 작성하는 등의 문제가 발생하였다.

 

결국 이 구성은 확장성 및 유지보수성 측면에서 한계에 다다른 것이다.

 

이러한 고찰은 곧이어 프로젝트의 폴더 구조를 바꿔야 한다는 판단으로 이르렀다.

 

도메인 중심으로 프로젝트의 폴더를 구성하고, 이 도메인 폴더에서 엔트리 파일을 따로 만들어서, 엔트리 파일을 통해서만 접근하도록 강제할려 하였다.

 

tsconfig의 path alias부터 시작하였다. 

새로운 도메인 중심 폴더 구조를 설계하기 위해, 먼저 tsconfig의 path alias를 재설계하는 것부터 시작해 보자.

이 과정에서 가장 중요한 목표는 사용자가 엔트리 포인트를 통해서만 접근할 수 있도록 강제하는 것이었고, 이를 위해 기존의 와일드카드 매칭 @/*의 폐기가 최우선 순위였다.

 

 

기존 프로젝트에서 와일드 카드 매칭을 제거하고 다듬어본 처음의 구성은 다음과 같았다.

{
    "baseUrl": "./src",
    "paths": {
      "@assets/*": ["assets/*"],
      "@components/*": ["components/*"],
      "@features/*": ["features/*"],
      "@hooks/*": ["hooks/*"],
      "@layouts/*": ["layouts/*"],
      "@libs/*": ["libs/*"],
      "@queries/*": ["queries/*"],
      "@services/*": ["services/*"],
      "@stories/*": ["stories/*"],
      "@lib-types/*": ["types/*"],
      "@utils/*": ["utils/*"]
}

 

이 설정은 기존 구조에서 단순히 와일드 카드 매칭만 제거한 초안에 가까웠다.

 

tsconfig의 path alias를 검토하면서 보니, /features와 /stories를 제외한 대부분의 폴더는 공통 로직으로 볼 수 있었다. 그래서 이들을 하나로 모아 /shared 폴더로 응집하는 편이 더 적절하다고 판단했다. /features는 /pages로 변경하고, /stories는 단순히 Storybook 전용 폴더이므로 변경하지 않았다.

 

따라서 /src의 구성을 다음과 같이 설계하였다.

/pages
/domains
/shared
/stories

 

 

세부 규칙

  • /pages에서는 엔트리 파일인 page만 import한다.
  • /domains의 각 도메인 폴더단의 루트 index.ts를 엔트리 포인트로 고정시킨다.

 

이렇게 tsconfig에 path alias를 설정하면서 eslint의 rule로 엔트리 포인트만 import하게끔 강제시키면, 코드베이스가 대규모로 성장하더라도 구조적 일관성과 확장성을 유지할 수 있는 아키텍처를 만들 수 있다고 판단했다.

 

아키텍쳐를 세부적으로 설계했음에도, 리팩토링은 현실적으로 무리가 있다 판단하였다.

전체적인 아키텍쳐를 갈아엎을려면 대규모 리팩토링을 진행하면서 대부분의 파일을 수정해야 하였다. 현실적으로 개발 일정과 리스크를 고려했을 때, 당시 상황에서는 대규모 리팩토링을 진행하기 어렵다고 판단했고 따라서 리팩토링은 보류하기로 결정했다.

 

그러나 이후 회사에서 프론트엔드 프로젝트 전체를 백엔드 개발자에게 인수인계하라는 지시가 내려왔고, 인수인계가 완료될 때까지 신규 기능 개발이 모두 중단되었다.
그로 인해 일정에 여유가 생겼고, 그 기간을 아키텍쳐 리팩토링에 집중할 수 있는 기회라 판단하여 전체 코드 베이스에 단계적으로 적용하기 시작했다.

 

아키텍쳐를 보호하기 위해 내가 사용한 방법들

아키텍쳐의 규칙은 문서로만 정의되어있을 때 쉽게 파괴될 수 있다. 특히 프로젝트가 대규모로 성장하고, 작업하고 있는 사람이 여럿일 수록 저마다의 스타일이 달라, 쉽게 설계가 무너질 수 있다.

 

그래서 엔트리 포인트인 index.ts를 통해서만 import하도록 도구로 강제할 필요가 있었다.

internal / public의 분리

먼저, 도메인 폴더를 다음과 같이 정의하였다.

 

domains/

    [domain]/

        internal/ 

        index.ts

 

  • interanl/ : 도메인 내부 구현(외부에서 참조 금지)
  • index.ts : 해당 도메인의 유일한 엔트리 포인트(Public API)

내부인 internal 폴더에서는 상대경로로 접근하고 외부에서 도메인을 접근시 반드시 Public API인 index.ts만을 통해서 접근하게 만든다.

 

아래는 실제 Public을 익명화시킨 index.ts이다.

import {
  entityMutationOptions,
  entityQueryOptions,
} from './internal/api';
import { entityEndpoint } from './internal/api/entityEndpoint';
import { entityLabel } from './internal/model/labels/entityLabel';
import { canEditEntity } from './internal/policy/entityPolicy';
import { entityFormFields } from './internal/presentation/form/entityFormFields';
import { entitySelectOptions } from './internal/presentation/selectOptions/entitySelectOptions';
import { entityTableColumns } from './internal/presentation/table/entityTableColumns';

export type { Entity } from './internal/model/types/entity.types';

export const entity = {
  mutations: entityMutationOptions,
  queries: entityQueryOptions,
  label: entityLabel,
  endpoint: entityEndpoint,
  form: entityFormFields,
  selectOptions: {
    base: entitySelectOptions,
  },
  table: entityTableColumns,
  policy: {
    canEdit: canEditEntity,
  },
} as const;

 

 

외부에서는 항상 Public API인 entity를 참조하여, 도메인 내부를 리팩토링시에 외부 코드가 거의 영향을 받지 않는다.

 

 

tsconfig의 path alias로 엔트리 포인트 고정

tsconfig에서 path alias를 엔트리 포인트 기준으로 설계하였다.

 

    "baseUrl": "src",
    "paths": {
      "@domains/*": ["domains/*/index.ts"]
    }

 

 

도메인 폴더에서 가져오려 하면 반드시 index.ts와 매칭이 된다.

즉, 물리적으로 엔트리 포인트를 우선순위로 만들었다.

 

eslint를 통해 경계 위반을 차단

tsconfig만으로는 개발자가 직접 내부 폴더를 import하는 것을 막지 못한다.

 

// 만약 개발자가 직접 import를 한다면 정상작동됨
import { Something } from '@domains/something/internal/model/something'

 

 

이를 막기 위해 ESLint에 다음 규칙을 추가한다.

 

        'no-restricted-imports': [
          'error',
          {
            patterns: [
              '@domains/*/internal/**',
              'domains/*/internal/**',
              '@/domains/*/internal/**'
            ],
          },
        ],

 

이제 내부 구현을 직접 참조할려하면 eslint가 즉시 에러를 발생시킨다.

 

코딩컨벤션은 취향이 아니라 아키텍쳐의 의도이다

코딩컨벤션을 코드 레벨에서 강제한 결과, 다음의 효과를 얻을 수 있었다.

 

  • 도메인 내부 구현을 마음대로 가져올 수 없다.
  • 외부에서는 항상 Public API만 바라본다.
  • 도메인 리팩토링 시, 외부 코드가 거의 영향을 받지 않는다

 

폴더 구조 리팩토링 before / after, 그리고 구조 개선이 이끌어낸 효과

개선 전후를 비교하면서, 실제로 리팩토링이 어떤 문제를 해결했는지 정리해보자.

Before : 도메인이 여기저기 흩어져 있던 구조

src/
 ├─features/
 │   ├─[domain]/  // 사실상 페이지 기반, 겹치는 도메인이 존재
 │   │   ├─pages
 │   │   ├─hooks
 │   │   ├─components
 │   │   └─services
 ├─queries/
 │   ├─keys/
 │   │   └─[domain]
 │   ├─options/
 │   │   └─[domain]
 │   └─types/
 │       └─[domain]
 ├─services/
 │   └─endpoints/
 │       └─[domain]
 ├─types/
 └─utils/

 

한 도메인에 관련된 코드가 features/queries/services에 흩어져 있었다. 특히, queries에는 도메인별로 keys/options/types가 각각 생성되면서, 도메인이 더 생길수록 구조가 점점 복잡해졌다.

 

결과적으로 특정 도메인을 알기 위해서는 여러 가지의 폴더를 왔다갔다 해야하고, 도메인 공통 로직의 위치를 놓쳐서, 같은 로직을 중복 작성하는 문제가 반복적으로 등장하였다.

 

After : 도메인 중심 구조(Public API + 캡슐화)

src/
 ├─domains/
 │   ├─[domain]/
 │   │   ├─internal/
 │   │   │   ├─api
 │   │   │   ├─model
 │   │   │   ├─policy
 │   │   │   └─presentation
 │   │   └─index.ts  // Public API (엔트리 포인트)
 ├─shared/
 ├─stories/
 ├─routes/
 └─types/

 

각 도메인에 필요한 요소를 한 폴더에 응집시켰다.

internal 하위의 폴더는 ESLint를 통해 외부 접근을 차단하여 강제적인 캡슐화를 이끌어내었다.

외부에서는 항상 index.ts라는 Public API(엔트리 포인트)를 통해 도메인에 접근하도록 설계되었다.

 

폴더 구조 개선이 실제로 만들어낸 효과

폴더 구조를 개선하면서 다음과 같은 효과가 발생하였다.

 

1. 도메인별 코드가 한곳에 모여 탐색 비용이 크게 감소하였다.

2. 내부 구현 변경이 외부 코드에 거의 영향을 주지 않게 되었다.

3. 새로운 기능이 추가될 때, 도메인을 사용할 경우, 폴더 구조와 의존성 규칙이 자동으로 가이드 역할을 하였다.

4. 규칙이 문서가 아니라 도구로 강제되기에, 시간이 지나도 구조가 썩지 않는다.

 

그러나 도메인을 철저히 관리하여도, 현실적으로 썩은 도메인은 반드시 발생한다.

 

가장 큰 이유는 다음과 같다.

백엔드 관점에서 잘못 설계된 API는 구조적으로 프론트가 수정할 수 없다.

개선 요청을 하더라도 받아들여지지 않으면, 해당 도메인은 영구적으로 기술 부채가 된다.

 

예를 들어, 확장성을 위해 API를 열어두었다는 이유로, 서로 다른 역할을 가진 API가 하나의 엔드포인트에 묶여있는 경우가 있었다.

 

장비 이력 관리 API와 사육 박스 이력 API가 동일한 엔드포인트에 묶여 있었는데, 프론트엔드 입장에서는 한 API 응답을 억지로 재사용하기 위해 타입 분기, 조건 분기, 매퍼를 덧붙이는 코드를 계속 추가할 수밖에 없었다.

 

이러한 경우, 도메인 자체가 잘못 설계되어 있기 때문에 아키텍처를 아무리 발전시켜도 코드 품질이 근본적으로 좋아질 수 없다.

그렇지만 이런 썩은 도메인도 격리하고, 매퍼를 두고 외부 의존성을 고정함으로써 최소한 관리 가능한 형태로 만들 수 있다.

 

아키텍쳐를 바꾸어도 남는 썩은 도메인, 매퍼를 통해 관리 가능하게

아키텍쳐를 아무리 개선하더라도, 모든 도메인을 순수하게 만들고 유지할 수는 없다.

백엔드 응답 형식에 종속되기에 프론트엔드는 도메인 모델을 올바르게 정의하고 싶어도 구조적으로 썩은 도메인 모델이 발생한다.

 

이런 도메인을 바로잡기 위해 API 자체를 수정하는 것이 정석이지만, 현실에서는 API 수정 요청이 받아들여지는 일이 흔치 않다.

그래서 프론트엔드에서 취할 수 있는 최선의 선택은 썩은 도메인을 없애는 것이 아니라, 격리하고 통제 가능한 상태로 만드는 것이다.

 

이를 위해 mapper 레이어를 별도로 만든다. API 응답을 그대로 전파하지 않고, 도메인 모델을 별도로 정의한다. 그리고 매퍼에서만 썩은 응답을 깨끗한 모델로 변환한다. 나머지에서는 이 깨끗한 모델만을 사용한다.

 

모든 도메인은 순수하고 깨끗하다는 비현실적 상상은 버리고, 최대한 관리할 수 있게 만드는 것이 현실적인 해결책이었다.

 

이러한 구조 덕분에 썩은 도메인은 관리 가능한 도메인-이 또한 기술부채이지만-으로 전환시킬 수 있다.

 

대규모 리팩토링을 통한 아키텍쳐 개선을 이끌어내면서 얻은 구조적 사고 습관

대규모 리팩토링의 출발점은 사소한 부분이었다.

그저 습관적으로 작성해 온 코드, 특히 import 구조에 대한 작은 의문에서 시작되었다.

 

작은 의문은 곧 전체 코드베이스로 확장되었고, 하나의 export 규칙에서 프로젝트 아키텍처 전반을 다시 바라보게 만들었다.

 

처음에는 현실적인 이유로 리팩토링을 포기할 생각이었다. 그러나 일정에 여유가 생기면서, 지금 아니면 절대 못 한다는 판단 아래 실행으로 옮겼다.

 

물론 이 리팩토링은 완벽하지 않았다.

여전히 썩은 도메인이 존재했고, 타협도 하였다.

그래도 결과는 분명하였다.

  • 구조가 정렬되었다.
  • 규칙이 강제되었다.
  • 기술 부채는 최소한 통제 가능한 상태가 되었다.

 

아키텍쳐 개선은 단순히 신기술을 도입하고 트렌드를 따라가며 여러 화려한 코드를 만들어내는 것이 아니라 비용과 결과를 냉정하게 비교한 끝에 실행되어야 하고, 언제나 프로젝트에 통제불가능한 썩은 도메인이 존재할 수 있기 때문에 그 개선이 완벽할 수는 없다. 하지만 이러한 썩은 도메인도 격리하여 관리가능한 상태로 만들 수 있다.

 

후기

실제로 대규모 리팩토링을 진행하면서 흔들리는 기준을 붙잡으려 최대한 노력하였고, 프로젝트의 일관성을 최대한 지키기 위해 노력하였다.

 

약 2주동안 진행한 개선 끝에 프로젝트에 남은 것은 견고한 기준이었고, 나에게는 작은 만족감과 설계부터 실행까지 완수했다는-큰 자부심이 남았다.

 

더 이상 글을 작성하면 사족이기에 나라는 개발자가 더 성장할 수 있기를 바라면서 이글을 마친다.