프로필

데브고래밥

@devgoraebap

스택오버플로우의 단골손님이였던 Claude를 채찍질하는 개발자

Album Art

0:00 0:00
방문자 정보

요즘 관심있는

패키지 설계의 컨버전스 thumbnail image
208
0
#architecture

패키지 설계의 컨버전스

이전 글은 패키지를 구성하는 방법들의 장단점에 대해 언급했지만, 그래서 어떻게 정답에 가까운 모범 사례를 유지할 수 있는지에 대한 내용은 없었다. 이 글에서 다루려고 한다. 

솔직히 말하면 필자는 많은 인력이 참여하는 대규모 프로젝트를 해본 적이 없다. 아마도 대규모 프로젝트는 더 체계적인 아키텍처와 명확한 레이어 분리가 필요하며, 처음부터 구조를 제대로 잡고 시작하는 것이 중요할 것이다.

하지만 완벽하지 않은 기획과 설계, 명확하지 않은 요구사항 속에서도 일단은 만들어야 하는 상황에 있거나, 혼자 개발하면서 프로젝트 구조에 대해 고민하고 있다면 이 글이 도움이 될 수 있다고 생각한다.


시작하기

예제의 소스코드 진입점

해당 글에서 다루는 패키지 표현은 typescript 개발 환경을 예로 보여주지만, 소스코드 진입점 외에는 거의 비슷할테니 각 소스코드 환경에 맞게 생각하면 된다.

project root/
└── src // 예제에서 다루는 소스코드 진입점

FSD 철학 이용하기

필자는 FSD(Feature sliced design)의 철학을 좋아한다. 1년 전, FSD를 접하고 익숙해졌을 무렵에 코드를 바라보는 방법 자체에 변화가 생겼다. 관심이 있다면 확인해봐도 좋을 내용들이다.(믿기지 않지만 공식문서에서 한국어 번역을 지원하기 시작했다 😭)

공식문서에는 프론트엔드 애플리케이션 구조를 위한 아키텍처 방법론이라고 슬로건으로 말하고 있지만, 패키지 구성에 대한 많은 노하우를 제공한다. 모든 개념을 백엔드 프로젝트에 억지로 녹일 필요는 없다. 다만, 도움이 되는 몇가지 기술을 활용할 예정이다.

따라서 앞으로의 내용에서 용어를 헷갈리지 않게 FSD의 기술을 일부 빌려 용어를 정리하고 가려고 한다.

소스코드 진입점의 패키지들을 레이어(Layer)라고 표현한다. 

레이어는 비슷한 관심사를 가진 여러 패키지들을 그룹화한다. 각 레이어는 계층 구조를 가지며, 상위에 위치할수록 고수준 레이어, 하위에 위치할수록 저수준 레이어가 된다.

참조 규칙은 명확하다. 고수준 레이어의 패키지는 저수준 레이어의 패키지를 참조할 수 있지만, 저수준 레이어의 패키지는 고수준 레이어의 패키지를 참조할 수 없다. 이러한 단방향 의존성을 통해 레이어 간 참조 방향을 명확하게 유지할 수 있다.

예를 들어, 다음과 같은 레이어 구조를 생각해볼 수 있다.

project root/
└── src/
    ├── app        // Level 6 (최고수준)
    ├── common     // Level 5
    ├── features   // Level 4
    ├── infra      // Level 3
    ├── domain     // Level 2
    └── shared     // Level 1 (최저수준)

이 구조에서 app 레이어는 하위의 모든 레이어를 참조할 수 있고, shared 레이어는 상위 레이어를 참조할 수 없다. 각 레이어는 자신보다 아래에 위치한 레이어만 참조 가능하다.

레이어 내부 패키지 간 참조 규칙

같은 레이어 내 패키지들의 상호 참조를 허용할지 금지할지는 레이어의 특성에 따라 결정할 수 있다.

고수준 레이어일수록 패키지 간 참조를 금지하는 것이 바람직하다. 고수준 레이어는 여러 기능이 집중되어 있어 패키지 간 참조를 허용하면 결합도가 높아지기 쉽다. 반면 저수준 레이어는 재사용 가능한 공통 기능을 제공하는 특성상 패키지 간 협력이 자연스럽게 발생한다.

예를 들어:

app/           // 패키지 간 참조 금지 ❌
├── admin/     // admin은 api, web을 참조할 수 없음
├── api/       
└── web/

features/      // 패키지 간 참조 금지 ❌
├── auth/      // auth는 payment, notification을 참조할 수 없음
├── payment/
└── notification/

domain/        // 패키지 간 참조 허용 ✓
├── user/      // user는 order, product를 참조 가능
├── order/     // 단, 양방향 참조는 최소화
└── product/

shared/        // 패키지 간 참조 허용 ✓
├── utils/     // utils는 types, constants를 자유롭게 참조 가능
├── types/
└── constants/

app, features 같은 고수준 레이어는 각 패키지가 독립적인 기능을 담당하므로 상호 참조를 금지한다. domain, shared 같은 저수준 레이어는 패키지 간 협력이 필요하므로 참조를 허용하되, domain의 경우 양방향 참조는 최소화해야 한다.


위에서 소개한 레이어 구조는 처음부터 모두 갖춰야 하는 것이 아니다. 프로젝트 초기에는 최소한의 레이어로 시작하여, 코드베이스가 성장하고 복잡도가 증가함에 따라 필요한 레이어를 추가해 나간다. 점진적으로 발전한 최종 레이어 구조의 예시는 다음과 같다.

왜 패키지들을 레이어로 묶어야 할까?

이제 몇 가지 예제를 통해 구체적으로 살펴보자.

project root/
└── src/
    ├── domain a/
    │   ├── a.controller.ts
    │   ├── a.service.ts
    │   ├── a.repository.ts
    │   └── a.entity.ts
    ├── domain b/
    ├── domain c/
    ├── main.module.ts
    └── main.ts

간혹 이런 패키지 구성을 접하곤 하는데, 위 구조에 대해 한 가지 짚고 넘어가려 한다. domain A, B, C는 최상위 패키지들이지만, 아직 '레이어'라고 부르기에는 애매한 구조다. 현재는 모두 도메인 관련 패키지들이라는 공통점이 있어 문제가 없어 보이지만, 다음 예제를 살펴보자.

project root/
└── src/
    ├── domain a/
    │   ├── a.controller.ts
    │   ├── a.service.ts
    │   ├── a.repository.ts
    │   └── a.entity.ts
    ├── domain b/
    ├── domain c/
    ├── common/
    ├── database/
    └── utils/

결국 요구사항을 만족하기 위해 프로젝트를 진행하다 보면, 다른 외부 기술이 필요해지고 반복적인 기능을 추출하게 된다. 따라서 위와 같이 새로운 패키지들이 추가된 모습이다.

문제가 있다고 하면 아까와는 다르게 패키지들의 관심사에 일관성이 사라졌다는 것이다. domain A, B, C는 비즈니스 도메인을 기준으로 나눈 Feature-Based 패키지인 반면, common, database, utils는 기술적 역할을 기준으로 나눈 Type-Based 패키지다. 같은 레벨에 위치한 패키지가 서로 다른 분류 기준을 따르고 있는 것이다.

이렇게 되면 패키지 구조를 보는 것만으로는 프로젝트의 구성 원칙을 파악하기 어렵다. 새로운 기능을 추가할 때 어디에 두어야 할지, 공통 기능과 도메인 로직을 어떻게 구분할지에 대한 명확한 가이드가 없어진다.

따라서 우리는 자연스럽게 관련 있는 패키지들을 하나의 레이어로 그룹화하게 된다. 여기서는 도메인 관심사에 해당하는 패키지들을 app이라는 레이어로 묶었다. 구조는 다음과 같다. (레이어명이 마음에 들지 않더라도, 이는 네이밍 선호의 문제이므로 양해 부탁드린다)

project root/
└── src/
    ├── app/
    │   ├── domain a/
    │   │   ├── a.controller.ts
    │   │   ├── a.service.ts
    │   │   ├── a.repository.ts
    │   │   └── a.entity.ts
    │   ├── domain b/
    │   └── domain c/
    ├── common/
    ├── database/
    └── utils/

이제 비즈니스 도메인(app)과 기술적 요소(common, database, utils)가 구조적으로 분리되었다. 패키지의 역할과 위치가 명확해지면서 프로젝트 구성 원칙을 쉽게 파악할 수 있게 되었다.

비즈니스 외적 요소를 묶는 레이어의 필요성

만약, 위 구조에서 프로젝트가 커지면 어떻게 될까?

project root/
└── src/
    ├── app/
    ├── common/
    ├── database/
    ├── utils/
    ├── cache/
    ├── logger/
    ├── auth/
    └── ... (계속 늘어남)

문제는 외부 라이브러리나 특정 기능들을 포함하는 레이어들이 계속 추가된다는 것이다. 레이어가 많아질수록 참조관계를 파악하는게 매우 어려워진다.

이 문제를 해결하는 방법은 핵심 비즈니스 로직과 무관한 기능들을 위한 shared 같은 공통 레이어를 통해 그룹화 하는 것이다. (레이어명이 마음에 들지 않더라도, 이는 네이밍 선호의 문제이므로 양해 부탁드린다)

project root/
└── src/
    ├── app/
    ├── common/
    └── shared/
        ├── database/
        ├── utils/
        ├── config/
        ├── logger/
        └── .../

shared 레이어 내부의 패키지들은 서로 참조하는 것을 허용한다. 대신 참조가 뒤엉키지 않도록 내부적인 룰을 만들어가야 한다. 예를 들어 shared/config와 같은 패키지가 있다면, shared 내의 다른 패키지들이 이 config를 참조하는 경우가 생길 수 있다. 그렇기 때문에 참조를 허용하되, 반대로 config 같은 기반 패키지는 다른 패키지에 의존하지 않도록 하는 등의 규칙이 필요하다. 즉, 참조 방향을 단방향으로 유지하는 것이다.

그리고 shared 레이어 내부 패키지들은 도메인 로직을 포함하면 안 된다는 룰을 일괄 적용할 수 있다. 이 규칙 하나만으로도 "여기는 비즈니스와 무관한 기술적 영역"이라는 명확한 경계가 생긴다. 구조를 보는 것만으로도 "비즈니스 도메인"과 "그 외의 기능들"이 구분되는 것이다.

TMI로, FSD에서는 이런 개념을 Slice와 Segment로 구분하여 설명한다. Slice는 비즈니스 도메인 단위의 수평적 분할(예: user, post, order)을 의미하고, Segment는 기술적 역할에 따른 수직적 분할(예: ui, api, model)을 의미한다. 그리고 FSD는 "상위 레이어는 하위 레이어만 참조할 수 있고, 같은 레이어의 Slice끼리는 참조할 수 없다"는 엄격한 규칙을 가지고 있다. 우리가 다루는 shared는 FSD의 최하위 레이어에 해당하며, Slice 없이 Segment들로만 구성된다. 이는 shared가 비즈니스 로직을 포함하지 않고 기술적 코드만 담기 때문이다.

프레임워크 실행 컨텍스트 레이어의 배치 전략

**NestJS와 같이 Guard, Interceptor 등을 Controller 레벨에서 참조할 수 있는 프레임워크에 해당하는 내용이다. 사용 환경이 다르다면 참고용으로만 읽어도 충분하다.

현재 구조를 보면 appshared 를 제외한 common 레이어가 있는것을 알 수 있다. 이 레이어는 프레임워크가 제공하는 실행 컨텍스트 기능 패키지들을 관리하는 레이어이다. 주로 요청의 특정 시점을 제어할 수 있는 middleware, interceptor, guard, pipe 등이 있다.

project root/
└── src/
    ├── app/
    │   ├── domain a/
    │   ├── domain b/
    │   └── domain c/
    ├── common/
    │   ├── guards/
    │   │   └── auth.guard.ts
    │   ├── interceptors/
    │   └── middlewares/
    │       └── whitelist.middleware.ts
    └── shared/
        ├── database/
        ├── utils/
        └── cache/

문제는 이렇게 구성하면 대부분 특정 비즈니스 로직을 포함하는 기능이 생긴다는 것이다. auth.guardwhitelist.middleware든 결국 특정 도메인과 연결되는 경우가 많다.

컨트롤러에서 가드나 인터셉터, 파이프등을 필요로 하므로 Controller를 포함하고 있는 app 레이어에서 common레이어를 참조하는 건 자연스럽다. 하지만 app 레이어 내부 패키지들에 ORM 엔티티나 기능들이 응집되어 있다면, common 레이어에서 역으로 이를 참조해서 가져다 쓸 상황이 발생한다.

project root/
└── src/
    ├── app/
    │   └── user/
    │       ├── user.controller.ts // auth.guard.ts 를 참조
    │       ├── user.entity.ts
    │       └── ...
    └── common/
        └── guards/
            └── auth.guard.ts // user.entity.ts를 참조

보통 아래와 같이 구성하진 않지만, 추상적으로 표현하였다.

따라서 참조 방향이 app → shared, shared → app으로 레이어간의 양방향 참조가 되는 것이다.

물론 이벤트나 의존성 역전으로 기능 참조 방향을 조정할 수 있지만, 도메인 모델이나 ORM 엔티티 같은 타입 참조는 막기 어렵다. 이후 다룰 도메인 레이어 분리로 근본적으로 해결할 수 있으나, 현재 구조에서는 임시 방편이 필요하다.

해결 방법은 간단하다. 특정 도메인에 종속된 실행 컨텍스트는 해당 도메인 패키지 내부에 두는 것이다. 예를 들어 user 도메인에만 사용되는 Guard나 Middleware는 common이 아닌 app/user 안에 배치한다. common 레이어에는 순수하게 어떤 도메인에도 종속되지 않은 공통 기능만 유지한다.

고수준 레이어의 패키지 간 양방향 참조 문제

이번 글에서 가장 중요한 내용이다. app 레이어 내부의 패키지들은 서로 참조할 수밖에 없다. 대부분의 요구사항은 여러 도메인 간 협력을 필요로 하기 때문이다.

클래스 vs 패키지 레벨의 참조

클래스 레벨에서는 양방향 참조가 잘 발생하지 않는다. 예를 들어 UserService가 PostService를 참조하면, 일반적으로 PostService는 UserService를 참조하지 않는다.

✅ 단방향 참조 (문제 없음)
user/ → post/
UserService가 PostService 호출

하지만 패키지 레벨에서는 다르다. 엔티티, DTO, 타입 등의 파일들은 도메인 경계를 넘나들며, 자연스럽게 양방향 참조를 만든다.

사례 1: ORM 엔티티 관계

// post/post.entity.ts
import { User } from '../user/user.entity';

@Entity()
class Post {
  @ManyToOne(() => User, user => user.posts)
  author: User;  // Post는 User를 참조
  
  title: string;
  content: string;
}

// user/user.entity.ts
import { Post } from '../post/post.entity';

@Entity()
class User {
  name: string;
  email: string;
  
  @OneToMany(() => Post, post => post.author)
  posts: Post[];  // User도 Post를 참조
}

ORM 관계 매핑으로 user ↔ post 양방향 참조가 발생한다. 설계 관점에서는 자연스럽지만, 레이어 관점에서는 고수준 레이어인 app 내부에서 패키지 간 양방향 참조가 생긴다는 점이 문제다.

사례 2: 서비스 레이어

// order/order.service.ts
class OrderService {
  createOrder(userId: string) {
    this.userService.validateUser(userId);  // user 패키지 참조
  }
}

// user/user.service.ts
class UserService {
  deleteUser(userId: string) {
    const orders = this.orderRepository.findByUserId(userId);  // order 패키지 참조
  }
}

각 서비스는 단방향으로 참조하지만, 패키지 레벨에서는 user ↔ order 양방향 참조가 된다.

왜 문제인가?

패키지 간 양방향 참조가 무조건 나쁜 것은 아니다. 핵심은 레이어의 수준이다.

  • 저수준 레이어 (shared): 더 이상 참조할 하위 레이어가 없어 내부 패키지 간 참조가 자연스럽다
  • 고수준 레이어 (app): 많은 비즈니스 로직을 포함하는데도 내부 패키지 간 양방향 참조가 발생하면 복잡도가 급격히 증가한다

app 레이어에서 이런 문제가 발생하는 것 자체가 설계 개선이 필요하다는 신호다.

도메인 레이어 분리를 통한 점진적 개선

app 레이어 내부 패키지 간 참조가 생기는 근본적인 이유는 요구사항이 도메인 간 협력을 요구하기 때문이다. 응집도를 위해 해당 도메인의 모든 기능을 하나의 패키지에 담으면 피할 수 없는 문제다.

그렇다면 어떻게 해결할 수 있을까? 핵심은 비즈니스 로직 역시 계층적으로 분리될 수 있다는 점이다. 실제로 app 레이어 내부에서 패키지 간 참조를 일으키는 것은 대부분 Controller를 제외한 나머지—엔티티, DTO, 서비스 등—이다.

이 문제는 점진적으로 해결할 수 있다. 처음부터 완벽한 구조를 만들 필요는 없다. 프로젝트가 성장하면서 복잡도가 실제로 문제가 될 때, 그때 단계적으로 개선해도 충분하다.

1단계: domain 레이어 분리 - 재사용 가능한 핵심 로직 추출

먼저 app 레이어에서 재사용 가능한 핵심 요소들을 새로운 domain 레이어로 분리한다. ORM 엔티티 또는 도메인 모델, 도메인과 상호작용이 필요한 리파지토리 그리고 다른 도메인에 의존하지 않는 독립적인 기능들이 여기에 해당한다.

project root/
└── src/
    ├── app/
    │   ├── user/
    │   ├── post/
    │   └── ...
    ├── common/
    ├── domain/ // 새로 추가한 레이어
    │   ├── user/
    │   │   ├── user.entity.ts
    │   │   ├── user.repository.ts
    │   │   └── user-core.service.ts
    │   ├── post/
    │   ├── series/
    │   ├── comment/
    │   └── ...
    └── shared/

해당 domain 레이어는 내부 패키지들끼리 참조를 허용하되, 엔티티 관계나 타입 간의 사용을 허용하는 것이지 비즈니스 로직 수준의 기능 참조는 있을 수 없다. 예를 들어 user/user.entitypost/post.entity를 타입으로 참조하는 건 괜찮지만, user/user.core.servicepost/post.core.service를 호출하는 것은 안 된다. 각 도메인 패키지의 기능들은 독립적이어야 하며, 이들을 조합하는 것은 상위 app 레이어의 역할이다.

참고: 여기서 말하는 domain 레이어는 클린 아키텍처나 DDD의 "순수한 도메인 레이어"와는 다르다. Repository 구현체가 있든, ORM 엔티티를 직접 사용하든 상관없다. 중요한 것은 app 레이어에서 재사용 가능한 핵심 로직을 분리한다는 실용적인 목적이다.

2. app 패키지의 독립성 확보

불필요한 분리가 아니냐고 생각하는 사람도 있을 것이다.

하지만 이렇게 분리하면 명확한 장점이 생긴다. 이제 app 레이어는 내부 패키지 간 참조를 금지하는 규칙을 적용할 수 있다. 참조가 필요한 로직들이 모두 domain레이어로 이동했기 때문에, app 레이어 내부 패키지들은 Controller와 도메인 기능들을 조율하는 Service 등으로 구성된다. 필요한 비즈니스 로직은 domain레이어에서 가져다 쓴다. 이를 통해 고수준 레이어의 내부 패키지 간 참조를 제거할 수 있다.

또다른 장점은 app 레이어가 API 리소스 계층을 명확하게 표현할 수 있다는 것이다. 

프로젝트가 API를 제공하든 SSR을 하든, 사용자 인터페이스와 연결되는 영역이 필요하다. 하지만 기존 app 레이어는 도메인 중심으로 구성되어 있어 여러 문제가 발생한다. Controller만 존재하는 패키지가 생기거나 반대로 Controller가 없는 도메인 패키지도 존재할 수 있으며, 관리자 페이지, 공개 API, 내부 API 등을 구분해야 할 때도 API 리소스 계층을 명확히 표현하기 어렵다.

Controller를 포함하는 패키지는 비즈니스 도메인이 아닌 API 엔드포인트나 페이지 단위로 구성되는 것이 더 자연스럽다. 패키지 구조를 보는 것만으로 "이 애플리케이션이 어떤 API를 제공하는가"가 한눈에 드러나게 되는 것이다. 다음과 같이 모습이 달라질 수 있다.

project root/
└── src/
    ├── app/
    │   ├── users/
    │   ├── posts/
    │   ├── comments/
    │   ├── auth/
    │   ├── admin/
    │   │   ├── users/
    │   │   ├── posts/
    │   │   └── dashboard/
    │   ├── home/
    │   ├── feed/
    │   └── webhooks/
    │       └── payment/
    ├── common/
    ├── domain/
    │   ├── user/
    │   ├── post/
    │   ├── comment/
    │   └── series/
    └── shared/

app레이어는 API 엔드포인트, 관리자 기능, SSR 페이지, 웹훅 등 인터페이스 중심으로 구성되고, domain레이어는 비즈니스 도메인 분류 중심으로 구성되는 것이다.

app레이어와 domain 레이어 정도만 분류되어도 이미 상당히 깔끔한 구조가 된다. app 레이어 하위의 패키지 간에는 참조가 없고, 필요한 로직은 모두 domain레이어에서 가져다 쓰기 때문에 각 API 엔드포인트나 페이지는 독립적으로 동작한다. 패키지 간 양방향 참조 문제도 대부분 해결된다.

그럼에도 완벽하다고 할 수는 없는데, app레이어에서 사용되는 유즈케이스 서비스들이 재사용이 필요한 경우가 생긴다. 예를 들어 "게시글 작성" 기능이 일반 사용자 API(/posts)와 관리자 API(/admin/posts)에서 모두 필요하다면, 같은 로직을 두 곳에서 중복으로 작성하게 된다. 이런 유즈케이스 로직을 domain레이어에 넣기에는 너무 상위 레벨의 비즈니스 흐름이고, app레이어에 두자니 재사용이 어렵다.

이번에도 app레이어와 domain 레이어 사이의 레이어를 하나 더 만들어서 해당 문제를 해결할 수 있다. 점진적인 개선의 연속이다.

재사용 가능한 유즈케이스의 점진적 분리

클린 아키텍처에서 유즈케이스는 UI/Delivery 메커니즘과 독립적이어야 하며, 순수한 비즈니스 로직에만 집중해야 한다고 강조한다.

하지만 필자가 웹 개발자로서 진행했던 대부분의 프로젝트에서는 기능들이 결국 사용자 인터페이스와 밀접하게 연결되어 있었다. 사용자가 버튼을 클릭하면 → 서버에 요청이 가고 → 데이터를 처리하고 → 화면에 결과를 보여준다. 이 흐름에서 "유즈케이스"를 처음부터 분리하는 게 모든 프로젝트에 필요한 것일까? 적어도 중소규모 프로젝트의 초기 개발 단계에서는 꼭 그렇지 않다고 느꼈다.

app 레이어는 현재 프레젠테이션 레이어와 애플리케이션 레이어가 공존하는 형태라고 볼 수 있다. Controller도 있고, 비즈니스 로직을 조율하는 Service도 함께 있다.

하지만 domain 레이어를 따로 분리하는 것조차 불필요하다고 생각하는 독자들이 있을 텐데(필자가 그러했음), 여기에 애플리케이션 레이어까지 처음부터 분리하고 들어가면 결국 controllers, services, repositories를 나누는 Type-Based와 다를 게 뭔가 싶을 것이다.

필자의 의견은 고수준 레이어일수록 하위의 패키지 간 참조가 필요한 상황은 레이어를 분리할 근거가 될 수 있다고 생각한다. 따라서 처음엔 요청과 밀접하게 유즈케이스, 애플리케이션 서비스를 app에 두어도 크게 문제가 없다. 원래 더 단순한 Feature-Based로 기능들을 구성했을 테니 말이다.

다만, 해당 서비스 자체가 재사용될 상황이 온다면 이때 점진적으로 새로운 레이어를 두고 기능을 옮기면 된다. 이때 레이어 하위엔 동일한 도메인 패키지 이름을 사용하기보단 기능의 의도를 나타내는 게 좋다. FSD에서 이와 비슷한 레이어가 있기에 필자는 똑같이 features라는 레이어를 만들고 재사용되는 기능을 두었다.

이쯤되면 레이어가 계속 늘어나지 않을까 걱정할 수 있지만, 실제로는 그렇지 않다. 대부분의 프로젝트는 최상위 레이어 아래로 재사용 가능한 공통 기능 레이어, 핵심 비즈니스 로직 레이어, 인프라 및 기술 지원 레이어 정도로 수렴한다.

project root/
└── src/
    ├── app/
    │   ├── users/
    │   ├── posts/
    │   └── admin/
    ├── features/
    │   ├── create-post/
    │   │   ├── create-post.service.ts
    │   │   ├── create-post.dto.ts
    │   │   └── index.ts
    │   ├── delete-user/
    │   │   ├── delete-user.service.ts
    │   │   ├── delete-user.dto.ts
    │   │   └── index.ts
    │   └── send-notification/
    │       ├── send-notification.service.ts
    │       ├── send-notification.dto.ts
    │       └── index.ts
    ├── domain/
    │   ├── user/
    │   ├── post/
    │   └── comment/
    ├── common/
    └── shared/

features 레이어는 app 레이어 하위의 여러 패키지에서 재사용되는 유즈케이스를 담는다. 예를 들어 "게시글 작성" 기능이 일반 사용자 API와 관리자 API에서 모두 필요하다면, features/create-post로 분리하여 두 곳에서 가져다 쓰는 것이다.

도메인 레이어에서 외부 관심사 분리

현재 domain 레이어의 패키지들은 해당 도메인에 필요한 핵심 기능들의 집합이다. 대부분의 백엔드 개발에 ORM을 사용하는 것처럼 필자 역시 ORM을 사용하며, 블로그 프로젝트는 Active Record 패턴을 사용하는 도메인 엔티티들로 구성했다. 해당 패턴을 쓰지 않더라도 Data Mapper 패턴을 활용하는 데이터 액세스 계층을 사용할 것이다. 여기서는 Repository라는 이름으로 사용 중이다. 이런 패턴들은 PoEAA(Patterns of Enterprise Application Architecture) 같은 책에서 자세히 다루고 있다.

뭐가 되었든 간에, 현재 domain 레이어는 아키텍처 분류로 따지면 Domain + Infrastructure의 집합이다. ORM 엔티티도 있고, 데이터베이스 접근을 위한 Repository도 함께 들어 있다. 순수한 도메인 로직만 있는 게 아니라, 인프라스트럭처 관심사도 섞여 있는 것이다.

하지만 누군가는 Infra 계층을 분리하여 domain 레이어를 더 순수하게 만들고 싶을 수도 있다. Infra를 분리하면 테스트하기 용이해질 뿐만 아니라, 도메인 로직과 기술적 구현 세부사항을 명확히 구분할 수 있다. 데이터베이스를 MySQL에서 PostgreSQL로 바꾸거나, ORM 라이브러리를 교체하더라도 도메인 레이어는 영향을 받지 않는다. 또한 도메인 패키지를 보는 것만으로 "이 도메인이 무엇을 하는가"에 집중할 수 있고, 기술적 세부사항은 Infra 레이어에서 확인하면 된다.

이 또한 점진적인 개선이 가능한 부분이다. 새로운 infra 레이어를 만들 텐데, app 레이어와 features 레이어는 해당 레이어를 바로 참조할 수 있다. 하지만 domain 레이어는 앞서 이야기했듯 외부 의존이 없는 shared를 제외하면 도메인 관련 레이어 중 가장 하위 레이어여야 한다.

따라서 OOP의 기술을 활용하여 인터페이스를 통한 의존성 역전(Dependency Inversion)으로 이를 해결할 수 있다.

project root/
└── src/
    ├── app/
    │   └── admin/
    │       └── users/
    │           ├── users.controller.ts
    │           └── users-application.service.ts // user.core.service 참조
    ├── domain/
    │   └── user/
    │       ├── user.dto.ts
    │       ├── user.repository.ts // 인터페이스
    │       ├── user.entity.ts
    │       ├── user.vo.ts
    │       ├── user-core.service.ts // user.repository 인터페이스 사용
    │       └── ...
    └── infra/
        └── repositories/
            └── user.repository.impl.ts // 구현체, domain 레이어의 인터페이스와 필요한 dto 참조

domain에는 Repository 인터페이스만 두고, infra에는 그 구현체를 둔다. 의존성 주입을 통해 런타임에 구현체가 주입되므로, domaininfra를 알 필요가 없다. 결과적으로 의존성 방향은 appdomaininfra가 된다.

Query Service를 통한 조회 최적화

또한 유즈케이스와 별개로, UI에 표현해야 하는 복잡도가 있는 조회 기능들은 대부분 Controller, Service를 무의미하게 지나치고 Repository에서 요청의 필요한 파라미터를 활용하여 조건을 처리하는 경우가 있다.

이 접근법의 장점은 명확하다. Infrastructure 레벨이기 때문에 도메인 레이어의 제약에서 자유롭다. 여러 엔티티를 조인해서 필요한 데이터를 한 번에 가져올 수 있으며, 데이터베이스 조인과 애플리케이션 조인 중 어느 것이 더 효율적인지 비교하여 선택할 수 있다. 무엇보다 CRUD만 제공하는 도메인 서비스에서 단순히 Repository를 호출하기만 하는 **싱크홀 안티패턴(Sinkhole Anti-Pattern)**을 피할 수 있다.

필자는 Controller에서 Query Service를 직접 호출하는 방식을 선호한다. 레이어드 아키텍처에서는 싱크홀 안티패턴이 전체의 20% 이하라면 허용 가능하다고 보지만, 조회 기능은 대부분의 API에서 차지하는 비중이 크기 때문에 모든 조회가 의미 없이 Application Service를 통과한다면 이 비율을 쉽게 넘어선다.

물론 "Controller는 단일 Service만 의존해야 한다"는 반대 관점도 타당하다. Facade 패턴처럼 Controller가 하나의 Application Service만 알고, 그 내부에서 Query Service나 Domain Service를 조율하는 방식이다. 이렇게 하면 Controller의 책임이 명확해지고 의존성 관리도 단순해진다. 조회에 실제 비즈니스 로직이 없더라도 구조적 일관성을 위해 Application Service를 거치는 것이 더 낫다는 주장이다.

다만 이 경우 Application Service의 조회 메서드들은 그저 Query Service를 그대로 호출하는 껍데기가 된다. 게다가 조회 기능만 있는 패키지는 Application Service가 필요 없어 Controller에서 Query Service를 직접 호출하는 것이 합리적인 반면, 명령(Command) 기능도 함께 있는 패키지는 Application Service가 필요하다. 결과적으로 어떤 패키지는 Controller → Query Service, 어떤 패키지는 Controller → Application Service → Query Service 구조가 되어 일관성이 깨진다는 문제도 있다.

솔직히 필자도 확고하게 어느 쪽이 정답이라고 말하기 어렵다. 프로젝트마다 여러 방식을 시도해봤지만, 상황에 따라 적합한 방법이 달라서 명확한 결론을 내리지 못했다. 각자의 프로젝트 특성과 팀의 우선순위에 맞춰 선택하는 수밖에 없을 것 같다.

필자의 경우 다음과 같은 구조로 운영하고 있다.

project root/
└── src/
    ├── app/
    │   └── admin/
    │       └── posts/
    │           ├── posts.controller.ts // posts-query.service, post-application.service 참조
    │           └── posts-application.service.ts (command.service라고 봐도 무방) // post.repository 인터페이스 참조
    ├── domain/
    │   └── post/
    │       ├── post.dto.ts
    │       ├── post.repository.ts // 인터페이스
    │       ├── post.entity.ts
    │       └── ...
    └── infra/
        ├── dto/
        │   └── post-query.dto.ts
        ├── queries/
        │   └── post-query.service.ts // 복잡도 있는 조회기능 서비스
        ├── read-models/
        │   └── admin-post-list.model.ts
        └── repositories/
            └── post.repository.impl.ts // 구현체, domain 레이어의 인터페이스와 필요한 dto 참조

TypeScript 환경에서 TypeORM이나 Drizzle ORM의 쿼리 빌더를 활용해 infra/queries에 Query Service를 구성한다. Controller는 조회가 필요할 때 이 Query Service를 직접 참조하고, 명령 작업은 Application Service를 통해 Domain 레이어의 Repository Interface를 사용하는 방식이다. 완벽한 일관성은 아니지만, 실용적인 절충안으로 운영 중이다.

마무리

현재까지 등장한 레이어를 최종적으로 정리하면 다음과 같다.

처음부터 설계를 통해 완벽한 구조를 잡아갈 수도 있지만, 만약 기존에 패키지에 대한 참조를 고려하지 않고 개발했다면 이런 방식으로 점진적인 구조를 만들어갈 수 있다.

하나의 Feature-Base 패키지로 시작해서 필요할 때 domain을 분리하고, 유즈케이스의 재사용이 필요하면 features를 추가하고, 순수성이 필요하면 infra를 분리하면 된다. 기존의 구조에서 큰 비용을 지불하지 않고, 조금씩 구조를 개선해 나갈 수 있다.

관련된 예제 코드는 해당 블로그 소스코드를 통해 확인할 수 있다.

아쉬운 점이 있다면, 마음에 들지 않으면 프로젝트를 마구마구 지워버리는 습관 때문에 이전의 비슷한 여러 상황의 예제들을 보여줄 수 없다는 것이다. 이렇게 글을 작성할 줄 몰랐는데, 앞으로는 참고 자료용으로라도 남겨둬야겠다.