aster®

FSD의 API 코드를 DDD 계층으로 나눈 이유

2026-09 · 아키텍처

왼쪽에 수평으로 쌓인 다섯 겹의 판, 오른쪽에 같은 부피를 동심원으로 다시 배치한 형태. 가운데 원이 노란색이고 둘 사이를 화살표가 잇는다.

API 호출 코드를 entities 에 둘지 features 에 둘지를 두고 리뷰가 길어진 적이 있다. 여러 곳에서 쓰니 entities 에 두자는 의견과, 한 화면에서만 쓰니 features 면 충분하다는 의견이 나왔다. 그날은 결론을 내리지 못했다.

비슷한 논의가 반복되면서 코드 위치를 정하는 기준을 다시 살펴봤다. 화면을 구성하는 데 쓰던 FSD 구조는 유지하고, API 호출과 데이터 변환에는 DDD 계층을 적용했다.

1. API 코드 배치의 세 가지 선택지

목록 조회처럼 여러 slice 가 같이 쓰는 호출은 쉽다. entities/product/api 에 두면 된다. 어려운 것은 그 반대다. 상세 화면에서 딱 한 번 부르는 호출, 한 사용자 동작을 위해서만 존재하는 호출이 있다.

어디에 두나되는 것치르는 것
entities 에 둔다규칙을 안 어긴다. 나중에 재사용해도 그대로 쓴다한 번 쓰는 코드가 공용 자리를 차지한다. entities 가 계속 부푼다
features 에 둔다쓰는 자리 옆에 있어 찾기 쉽다다른 slice 가 필요해지면 features → features import 가 생긴다. 금지된 방향이다
양쪽에 복사한다규칙도 안 어기고 위치도 자연스럽다스키마가 바뀌면 두 곳을 고쳐야 한다. 한 곳을 빠뜨려도 에러가 안 나서 한참 뒤에 안다

표 1. 한 번만 쓰는 API 호출을 둘 수 있는 세 자리와 각각 치르는 것

세 번째를 고른 적도 있고 첫 번째를 고른 적도 있다. 어느 쪽도 다음번에 같은 판단을 처음부터 다시 해야 한다는 사실은 바꾸지 못했다.

그리고 한 번이라도 두 번째를 고르면 그 한 줄 때문에 규칙을 더는 근거로 쓸 수 없게 된다. 예외가 하나 생긴 규칙은 다음 리뷰에서 아무도 인용하지 않는다. "저기도 그렇게 돼 있잖아요" 한 마디면 끝이니까.

2. 조회 계약·구현·유스케이스 분리

같은 조회가 지금은 세 조각으로 나뉘어 있다. 지금 쓰고 있는 다른 프로젝트의 코드다.

Domain 은 필요한 조회를 인터페이스로만 선언한다. 구현은 모른다.

// src/domain/spot/spot-repository.ts
export interface SpotRepository {
  list(query: ListSpotsQuery): Promise<Page<Spot>>;
  nearby(query: NearbySpotsQuery): Promise<Page<Spot>>;
  findDetail(id: SpotId): Promise<SpotDetail | null>;
}

Infrastructure 가 그 인터페이스를 구현한다. src/infrastructure/tourapi/tourapi-spot-repository.ts 가 실제 API 를 부르고, mock-spot-repository.ts 가 같은 인터페이스를 가짜 데이터로 만족시킨다. 화면은 둘 중 어느 쪽이 꽂혔는지 모른다.

Application 은 유스케이스를 만든다. Repository 를 주입받아 시나리오를 진행하고, 도메인 객체를 화면이 쓸 모양으로 바꿔 내보낸다.

// src/application/spot/list-nearby-spots.ts
export function makeListNearbySpots(repo: SpotRepository) {
  return async function listNearbySpots(
    input: NearbySpotsInput,
  ): Promise<SpotListView> {
    const center = createCoordinate(input.lng, input.lat);
    if (!center) throw new InvalidCoordinateError();

    const result = await repo.nearby({
      locale: input.locale,
      center,
      radiusMeters: clampRadius(input.radiusMeters),
      category: input.category,
      page: {
        page: input.page ?? DEFAULT_PAGE.page,
        size: input.size ?? DEFAULT_PAGE.size,
      },
    });
    // …도메인 규칙으로 걸러내고 정렬한 뒤 뷰 모델로 바꾼다
  };
}

호출이 한 번이든 스무 번이든 조회 계약은 Domain 에 있고 구현은 Infrastructure 에 있다. 그래서 1절의 세 선택지가 없어졌다.

인터페이스는 Domain 에 있고 그걸 구현하는 코드는 Infrastructure 에 있으니, 실제로 참조하는 방향은 아래에서 위다. 규칙을 어긴 것으로 보기 쉬운 자리인데 아니다. 이것을 의존성 역전이라고 한다. 층 순서를 지키려고 화살표를 일부러 뒤집는 것이다.

인터페이스를 도메인에 두면 화살표가 뒤집힌다An architecture diagram generated by Archify.Presentation · 화면 · 컴포넌트 · Architecture componentPresentation화면 · 컴포넌트Application · 유스케이스 · Architecture componentApplication유스케이스Domain · SpotRepository 인터페이스 · Architecture componentDomainSpotRepository 인터페이스TourApiSpotRepository · Infrastructure · 실제 API · Architecture componentTourApiSpotRepositoryInfrastructure · 실제 APIMockSpotRepository · Infrastructure · 테스트용 · Architecture componentMockSpotRepositoryInfrastructure · 테스트용호출인터페이스만 안다구현구현
인터페이스를 Domain 에 두면 Infrastructure 쪽 화살표가 위로 올라간다.

3. FSD를 적용하며 코드 위치가 모호했던 이유

먼저 용어를 맞추고 가자. FSD(Feature-Sliced Design)는 프론트엔드 폴더 구조를 여섯 층으로 나눈다.

src/
├── app/       ← 앱 진입점, 프로바이더, 전역 스타일
├── pages/     ← 라우트 단위 화면
├── widgets/   ← 화면 한 덩어리 (헤더, 사이드바, 목록 섹션)
├── features/  ← 사용자가 하는 동작 하나 (장바구니 담기, 필터 적용)
├── entities/  ← 업무 개념 하나 (사용자, 상품, 주문)
└── shared/    ← 도메인을 모르는 공용 코드 (버튼, fetch 래퍼)

규칙은 하나다. 위층은 아래층을 가져다 쓸 수 있고 그 반대는 안 된다. features 가 entities 를 부르는 것은 되고, entities 가 features 를 부르는 것은 금지이며 같은 층끼리도 안 된다.

층 안은 다시 둘로 쪼갠다. slice 는 도메인 단위 폴더다(entities/user, entities/product). segment 는 그 안의 성격별 칸이다(ui, model, api, lib). 세로로 도메인을 가르고 가로로 성격을 가르는 셈이다.

FSD 여섯 층과 슬라이스 안의 세그먼트app앱 진입점 · 프로바이더pages라우트 단위 화면widgets화면 한 덩어리features사용자 동작 하나entities업무 개념 하나shared도메인을 모르는 공용 코드entities/product한 슬라이스를 열어 본 모습ui컴포넌트model타입 · 상태api호출lib유틸
위에서 아래로만 가져다 쓸 수 있다. 오른쪽은 entities/product 한 슬라이스를 열어 본 모습이다.

이 구조가 층을 나눌 때 보는 것은 얼마나 조립됐는가 하나다. entities 는 날것의 부품이고, features 는 그 부품으로 만든 동작이고, widgets 는 그 동작을 모은 화면 덩어리다. 버튼보다 카드가 위고 카드보다 화면이 위라는 순서는 누가 봐도 같으니 UI 는 이 하나로 정렬된다.

그런데 API 호출, DTO 변환, 응답 스키마, 캐시 정책은 이 순서로 줄을 세울 수가 없다. 조회 API 가 목록 컴포넌트보다 더 조립된 것인지 덜 조립된 것인지는 물어봐도 답이 나오지 않는다. 쓸 기준이 없으니 다른 잣대를 끌어와야 했고, 그때 끌어온 것이 재사용 빈도였다.

재사용 여부는 지금 답할 수 있는 질문이 아니다. 두 달 뒤에 다른 화면이 생기면 한 곳에서만 쓰던 것이 두 곳이 된다. 코드를 놓을 자리를 미래 예측으로 정하는 셈이다. 예측이 틀리면 폴더를 옮겨야 하고, 옮기면 import 경로가 전부 바뀐다. 그래서 아무도 안 옮긴다. 틀린 자리에 그대로 둔다.

도메인 볼륨이 작으면 이걸 안 고쳐도 된다. 대메뉴 하나가 화면 서너 개면 entities 가 부풀어도 폴더를 열면 다 보인다. 우리 쪽은 대메뉴 하나가 화면 스무 개를 넘겼다.

4. entities와 features에 흩어진 코드

한 명은 컴포넌트를 거의 다 entities 아래 만들었다. atomic design 을 오래 해온 사람이라 작은 조각을 먼저 만들어 두고 위에서 조립하는 방식에 익숙했다. features 는 거의 비어 있었고, 화면은 widgets 가 entities 를 직접 조립해서 만들었다. widgets → entities 는 허용된 방향이라 리뷰에서도 굳이 짚지 않고 넘어가게 된다.

일정이 밀리면서 결과가 나왔다. 급하게 넣어야 하는 화면이 생기면 이미 entities 에 있는 컴포넌트를 조금 키우는 게 제일 빠르다. 그렇게 entities/product/ui/ProductCard 안에 장바구니 버튼이 들어갔고, 장바구니 버튼은 담기 API 를 불렀다. 업무 개념 하나를 그리던 컴포넌트가 사용자 동작을 품게 된 것이다.

이때도 규칙은 깨지지 않았다. import 방향이 여전히 아래를 향하니까. 깨진 것은 층의 뜻이다. entities 안에 features 급 코드가 들어앉으면 다음 사람은 그 폴더를 보고 "여기가 원래 이런 것도 넣는 자리구나" 라고 배운다. 규칙 대신 선례가 기준이 된다.

다른 한 명은 반대로 갔다. 컴포넌트를 features 에 만들었고 이유도 타당했다. 이건 이 slice 에서만 쓸 거니까 굳이 아래로 내릴 필요가 없다는 것이었다. 그래서 폴더를 훑어보면 성격이 같은 컴포넌트가 두 층에 흩어져 있었다. 어느 쪽도 규칙을 어기지 않았는데 결과는 일관되지 않았다.

5. DDD 계층별 역할

DDD 의 계층은 그 코드가 무슨 일을 하는지로 나뉜다.

Presentation    ← 화면을 그린다. 입력을 받아 아래로 넘긴다
Application     ← 시나리오를 진행한다. 도메인과 인프라를 잇는다
Domain          ← 규칙과 상태 전이를 정의한다. 바깥을 모른다
Infrastructure  ← 바깥과 통신한다. 가져온 것을 도메인 모양으로 바꾼다

하나씩 짚어 보자.

Presentation 은 UI 와 컴포넌트를 묶은 자리다. 백엔드로 치면 Controller 다. 사용자 입력을 받아 아래 계층에 넘기고 결과를 그린다. 여기에 판단이 들어가면 안 된다.

Application 은 프론트 안에 두는 BFF 다. BFF(Backend For Frontend)는 백엔드 응답을 화면이 쓰기 좋은 모양으로 갈아 끼우는 자리를 가리킨다. 백엔드 스펙에서 클라이언트를 생성하고, DTO 를 변환하고, 스키마를 정의한다. 백엔드가 넘겨준 것을 프론트가 쓸 수 있게 옮기는 작업이 전부 여기 모인다. 유스케이스, 그러니까 Service 의 역할이다.

Domain 은 값과 규칙이다. 상태가 어떻게 바뀌는지, 무엇이 유효하고 무엇이 아닌지를 여기서 정한다. 이 계층은 HTTP 도 모르고 React 도 모른다.

Infrastructure 는 Repository 다. Application 이 "이 조건으로 찾아줘", "이걸 저장해줘" 라고 부르면 그 기능을 실제로 구현하는 곳이 여기다. DB, 외부 API, 메시징 같은 바깥과의 통신을 담당하고, 얻어온 정보를 도메인 모양으로 바꿔 전달한다.

의존은 여기서도 한 방향이다. 위에서 아래로만 간다.

DDD 네 계층과 위에서 아래로만 흐르는 의존

6. FSD의 UI 구조와 DDD 계층 결합

두 기준은 서로를 대체하지 않는다. FSD 는 조립 정도로 나누고 DDD 는 역할로 나눈다. 묻는 게 다르니 둘을 겹쳐 놓을 수 있다.

우리는 도메인을 먼저 자르고 그 안에서 계층을 나눴다. 대메뉴 하나가 곧 도메인 하나다.

DDD 계층무엇이 들어가나FSD 로 치면
Presentation화면, 컴포넌트, 훅pages · widgets · features · entities 의 ui
Application유스케이스, DTO, 스키마, 생성된 API 클라이언트어디에도 정확히 대응하지 않는다
Domain타입, 규칙, 판정 함수entities 의 model 일부
InfrastructureRepository 구현, HTTP 클라이언트, 매퍼shared/api + 각 slice 의 api

표 2. DDD 네 계층에 들어가는 코드와 FSD 층의 대응

도메인 세 개를 세로로 자르고 그 안에서 네 계층으로 나눈 격자

대메뉴 하나가 도메인 하나다. FSD 순서(widgets → features → entities)는 Presentation 안에서만 쓰고, 둘 곳을 못 정하던 코드는 Application 줄에 모인다.

표에서 눈여겨볼 줄은 Application 이다. FSD 에 대응하는 자리가 없다. 둘 곳을 못 정하던 코드가 전부 이 줄에 모여 있었다.

FSD 를 통째로 버리지는 않았다. Presentation 안에서는 widgets → features → entities 순서를 그대로 쓴다. 이 기준은 UI 를 정렬할 때 여전히 잘 맞는다. 다만 UI 가 아닌 것들을 이제 여기에 억지로 끼워 맞추지 않는다.

4절에서 나온 문제도 같은 방식으로 막힌다. 컴포넌트 안에서 담기 API 를 직접 부르려면 Presentation 이 Application 을 건너뛰고 바깥을 불러야 한다. 규칙 문서를 뒤질 필요 없이 import 경로만 봐도 어긋난 게 보인다.

계층 분리로 늘어난 작업

계층을 나누면서 파일 수와 학습 부담은 늘었다.

단점내용
파일 수가 늘어난다조회 하나에 인터페이스·구현·유스케이스·DTO 가 따로 생긴다. 화면 하나 만들 때 여는 파일이 늘었다
얇은 계층이 생긴다도메인 규칙이 거의 없는 조회는 Application 이 Repository 를 그대로 부르고 끝난다. 세 줄짜리 유스케이스 파일이 늘어난다
학습 비용이 앞에 몰린다FSD 만 알던 사람에게 계층이 넷 더 생겼다. 새로 합류한 사람은 첫 두 주 동안 속도가 안 난다
도메인을 잘못 자르면 효과가 없다대메뉴를 잘못 나누면 여러 도메인을 엮는 화면 하나에 코드가 다시 몰린다. 기준을 하나 더 두어도 이건 해결되지 않는다

표 3. 기준을 하나 더 두면서 치른 것

특히 두 번째가 신경 쓰인다. 얇은 유스케이스가 늘면 "이거 그냥 컴포넌트에서 부르면 안 되나" 라는 말이 나오고, 그 말이 통과되기 시작하면 계층이 다시 흐려진다. 지금은 예외를 두지 않고 계층을 유지하고 있다.

추가로 검증할 항목

7. 코드 역할에 따른 배치 기준

무슨 일을 하는 코드냐로 정한다. 화면을 그리면 Presentation, 시나리오를 진행하면 Application, 규칙이면 Domain, 바깥과 통신하면 Infrastructure 다. 사용 빈도가 달라져도 코드의 역할이 같다면 같은 위치에 둔다.