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에 있으니, 실제로 참조하는 방향은 아래에서 위다. 규칙을 어긴 것으로 보기 쉬운 자리인데 아니다. 이것을 의존성 역전이라고 한다. 층 순서를 지키려고 화살표를 일부러 뒤집는 것이다.
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). 세로로 도메인을 가르고 가로로 성격을 가르는 셈이다.
위에서 아래로만 가져다 쓸 수 있다. 오른쪽은 entities/product 한 슬라이스를 열어 본 모습이다.
이 구조가 층을 나눌 때 보는 것은 얼마나 조립됐는가 하나다. entities는 날것의 부품이고, features는 그 부품으로 만든 동작이고, widgets는 그 동작을 모은 화면 덩어리다. 버튼보다 카드가 위고 카드보다 화면이 위라는 순서는 누가 봐도 같으니 UI는 이 하나로 정렬된다.
그런데 API 호출, DTO 변환, 응답 스키마, 캐시 정책은 이 순서로 줄을 세울 수가 없다. 조회 API가 목록 컴포넌트보다 더 조립된 것인지 덜 조립된 것인지는 물어봐도 답이 나오지 않는다. 쓸 기준이 없으니 다른 잣대를 끌어와야 했고, 그때 끌어온 것이 재사용 빈도였다.
재사용 여부는 지금 답할 수 있는 질문이 아니다. 두 달 뒤에 다른 화면이 생기면 한 곳에서만 쓰던 것이 두 곳이 된다. 코드를 놓을 자리를 미래 예측으로 정하는 셈이다. 예측이 틀리면 폴더를 옮겨야 하고, 옮기면 import 경로가 전부 바뀐다. 그래서 아무도 안 옮긴다. 틀린 자리에 그대로 둔다.
도메인 볼륨이 작으면 이걸 안 고쳐도 된다. 대메뉴 하나가 화면 서너 개면 entities가 부풀어도 폴더를 열면 다 보인다. 우리 쪽은 대메뉴 하나가 화면 스무 개를 넘겼다.
4. 폴더 구조가 기준에서 벗어난 두 가지 방식
한 명은 컴포넌트를 거의 다 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, 메시징 같은 바깥과의 통신을 담당하고, 얻어온 정보를 도메인 모양으로 바꿔 전달한다.
의존은 여기서도 한 방향이다. 위에서 아래로만 간다.
6. 두 기준을 겹쳐 놓으면 어떻게 되나
두 기준은 서로를 대체하지 않는다. FSD는 조립 정도로 나누고 DDD는 역할로 나눈다. 묻는 게 다르니 둘을 겹쳐 놓을 수 있다.
우리는 도메인을 먼저 자르고 그 안에서 계층을 나눴다. 대메뉴 하나가 곧 도메인 하나다.
| DDD 계층 | 무엇이 들어가나 | FSD로 치면 |
|---|---|---|
| Presentation | 화면, 컴포넌트, 훅 | pages · widgets · features · entities의 ui |
| Application | 유스케이스, DTO, 스키마, 생성된 API 클라이언트 | 어디에도 정확히 대응하지 않는다 |
| Domain | 타입, 규칙, 판정 함수 | entities의 model 일부 |
| Infrastructure | Repository 구현, 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. 기준을 하나 더 두면서 치른 것
특히 두 번째가 신경 쓰인다. 얇은 유스케이스가 늘면 “이거 그냥 컴포넌트에서 부르면 안 되나” 라는 말이 나오고, 그 말이 통과되기 시작하면 계층이 다시 흐려진다. 지금은 예외를 두지 않는 쪽으로 버티고 있다.
확인하지 못한 것
- 경계를 도구로 강제하지 못했다. 지금은 리뷰로 막고 있다. ESLint의 import 제한 규칙으로 계층 간 방향을 강제하는 것이 다음 할 일이다. 사람이 눈으로 지키는 규칙은 일정이 밀리면 지켜지지 않는다는 것을 이미 한 번 겪었다.
- 도메인 계층의 테스트가 얇다. 규칙을 Domain으로 모은 이유의 절반은 테스트하기 쉬워서인데, 실제 커버리지는 아직 그만큼 올리지 못했다.
- 얇은 유스케이스를 몇 개까지 용인할지 정하지 않았다. 수를 세어 본 적이 없어서 문제인지 아닌지도 아직 모른다.
- 회의 시간이 얼마나 줄었는지 재보지 못했다. 어디에 둘지를 두고 리뷰가 멎는 일은 없어졌는데, 그게 몇 분인지는 세지 않았다.
7. 그래서 코드 위치는 이제 무엇으로 정하나
무슨 일을 하는 코드냐로 정한다. 화면을 그리면 Presentation, 시나리오를 진행하면 Application, 규칙이면 Domain, 바깥과 통신하면 Infrastructure 다. 이건 지금 답할 수 있는 질문이고 두 달 뒤에도 답이 같다.




답글 남기기