티스토리 뷰

GIT 주소: https://bitbucket.org/basic-setup/node-clean-architecture/src/main/

클린 아키텍처에 대한 고민

일반적으로 전통적인 (3 layered Architecture) controller → service → repository 로 구성하여 프로젝트를 진행하여 왔다.

하지만 점점 프로젝트를 진행하면서 Service Layer의 책임은 커지고, 코드 재사용을 목적으로 service → service 간의 의존성도 생기게 되었고 이 과정에서 의존 관계가 복잡해지고, 최악의 경우 순환 참조가 발생하는 상황도 마주하게 되었다.

또 다른 문제는 트랜잭션 경계의 모호함이다.

책임이 분리된 여러 service를 조합해 호출하다 보면, 각각의 service가 독립적으로 트랜잭션을 관리하거나 상위 계층인 Controller에서 하나의 트랜잭션으로 묶어야 하는 경우도 존재하게 된다.

의도하지 않게 중첩 트랜잭션 구조가 만들어 지기도 한다.

이러한 문제를 완화하기 위해 AOP나 component 분리와 같은 기법을 적용해 보지만, 요구사항이 계속 변경되고 비즈니스 규칙이 복잡해질수록 구조는 점점 이해하기 어려워 지는 것을 느낀다.

이러한 고민의 연장선에서 클린 아키텍처에 대한 관심을 시작되었다.

도메인 주도 설계와 layered architecture의 재정의

도메인 주도 설계의 관점에서 기능 중심으로 도메인을 재구성하고, 기존의 Layered Architecture를 보다 세분화하여 각 계층의 책임을 분리하도록 설계하였다.

  1. 하나의 도메인은 하나의 책임만 갖고록 한다.
    • 도메인은 특정 비즈니스 문제를 해결하는 데 집중하며, 서로 다른 책임을 혼합하지 않는다.
  2. 의존성은 항상 상위 Layer에서 하위 Layer로만 흐른다.
    • 하위 Layer는 상위 Layer의 존재를 알지 못하며, 의존성 역전이 필요한 경우 명시적으로 인터페이스를 통해 해결한다.
  3. 동일한 Layer간의 직접적인 의존성 주입(DI)은 허용하지 않는다.
    • 같은 계층 내에서늬 결합을 제한하여, 계층 간 역할과 책임이 흐려지는 것을 방지한다.
    • 예외적으로 Query Service는 역할이 분리된 Service Layer의 하위 성격으로 의존을 허용한다.
  4. Layer간 경계를 명확히 정의하고, 정해진 방식으로만 상호 작용한다.
    • 각 Layer는 자신의 책임 범위 내에서만 동작하며, 경계를 넘는 접근은 불가능하도록 한다.

계층별(Layer) 구성 및 책임 정의

Controller Layer

  • 외부 인터페이스(API, Router)를 통해 클라이언트 요청을 수신한다.
  • request에 대한 값 검증을 처리한다.
  • 인증 및 인가처리를 담당한다.

ApplicationService Layer

  • 여러 도메인/서비스를 조합하는 오케스트레이션을 수행한다.
  • 비즈니스 규칙을 정의하지 않고 호출하는 역할을 한다.
  • 트랙잭션의 경계를 정의한다.
  • 도메인 서비스 호출 순서와 흐름을 제어한다.

Service Layer

  • 하나의 책임을 갖는 비즈니스 로직을 구현한다.
  • 다른 service에 직접 의존하지 않으며, 필요한 데이터는 Query Service를 통해 수행한다.
  • 트랜잭션의 처리는 하지 않는다.

Query Service Layer

  • Query Service는 service Layer 하위에 위치한다
  • Query Service는 단순한 조회 Helper가 아니라 도메인 전반에서 일관된 조회 정책을 유지하기 위한 조회 전용 Application Support Layer의 성격을 가진다.
  • Query Service 는 다른 service의 DI가 가능하다.
  • 예를 들어 사용자 정보를 조회하는 쿼리가 여러 Service에서 필요한 경우 각 Service가 개별적으로 Repository를 호출하는 대신 Query Service를 통해 조회 로직을 일관되게 재사용하도록 한다.

Repository Layer

  • entity 저장/ 조회를 수행한다.

DTO, Mapper 위치

dto와 mapper는 Layer라기 보다는 경계간 모델로 정의된다.

Layer별 dto와 mapper를 분리할 수도 있지만 그것은 너무 과한 복잡도와 중복 코드가 발생하기 때문에 공통적으로 dto를 사용하고 repository Layer만 mapper를 통해 entity로 변경하도록 하였다.

예시

해당 프로젝트는 node + nest를 활용하여 도메인 주도 설계와 세분화된 Layered Architecture를 적용한 예시이다.

각 도메인은 독립적인 책임을 가지며, presentation → application → service → repository 방향으로만 의존성이 흐르도록 구성하였습니다.

전체 구조

domain
 └─ user
     ├─ application
     │   └─ user.application.ts
     ├─ dto
     │   └─ user.dto.ts
     ├─ infrastructure
     │   └─ user.repository.ts
     ├─ presentation
     │   └─ user.controller.ts
     ├─ service
     │   ├─ query
     │   │   └─ user-query.service.ts
     │   └─ user.service.ts
     └─ user.module.ts

 └─ user-role
     ├─ infrastructure
     │   └─ user-role.repository.ts
     ├─ service
     │   └─ user-role.service.ts
     └─ user-role.module.ts

user.controller.ts

  • restful API를 통해 기능 요청 및 dto에 대한 validation을 처리합니다.
  • 비즈니스 로직은 포함하지 않고 ApplicationService Layer로 위임한다.
@Controller(`/users`)
export class UsersController {
  constructor(
    private readonly userApplicationService: UserApplicationService,
  ) {}

  @Post()
  @HttpCode(HttpStatus.CREATED)
  async create(
    @Body() createUserRequestDto: CreateUserRequestDto,
  ): Promise<UserResponseDto> {
    return await this.userApplicationService.create(createUserRequestDto);
  }

  @Put(':id')
  async update(
    @Param('id', ParseBigIntPipe) id: bigint,
    @Body() updateUserRequestDto: UpdateUserRequestDto,
  ) {
    return await this.userApplicationService.update(id, updateUserRequestDto);
  }
}

user.dto.ts

  • CreateUserRequestDto 에는 user의 가입과 user의 멀티 role 정보를 담고 있습니다.
  • DTO는 계층 간 데이터 전달을 위한 공통 모델로 사용된다.
export class CreateUserDto {
  @IsEmail()
  @IsNotEmpty()
  email: string;

  @IsNotEmpty()
  @MinLength(6, { message: '비밀번호를 최소 6자리 이상이어야 합니다.' })
  password: string;

  @IsString()
  name: string;

  @IsBoolean()
  @IsOptional()
  isActive?: boolean;
}

export class CreateUserRequestDto {
  @Type(() => CreateUserDto)
  @IsNotEmpty()
  @ValidateNested()
  user: CreateUserDto;

  @IsNotEmpty()
  @IsEnum(RoleType, {
    each: true,
    message: 'roles에는 유효한 RoleType 값만 포함되어야 합니다.',
  })
  roles: RoleType[];
}

user.application.ts

  • application service layer에서는 트랜잭션과 여러 서비스의 오케스트레이션 기능을 수행합니다.
  • 여러 Service의 호출 순서를 정의하고, 하나의 트랜잭션으로 묶는다.
  • create
    1. user의 정보를 저장합니다.
    2. 그리고 user.id로 사용자가 가지는 여러 role을 저장합니다.
    3. 각 책임은 Service Layer에 위임하고, application에서 조합하고 transaction을 처리합니다.
  • update
    • 업데이트시에는 user의 정보를 수정합니다.
    • 권한에 대한 업데이트 필요시 userRoleService.update 를 추가하여 기능을 확장해 나갑니다.
@Injectable()
export class UserApplicationService {
  constructor(
    private readonly userService: UserService,
    private readonly userRoleService: UserRoleService,
  ) {}

  @Transactional()
  async create(
    createUserRequestDto: CreateUserRequestDto,
  ): Promise<UserResponseDto> {
    const user = await this.userService.create(createUserRequestDto.user);
    await this.userRoleService.create(BigInt(user.id), createUserRequestDto.roles);
    return user;
  }

  @Transactional()
  update(id: bigint, updateUserRequestDto: UpdateUserRequestDto) {
    return this.userService.update(id, updateUserRequestDto.user);
  }
}

user.service.ts

  • create는 user의 생성을 update는 user의 수정기능만을 담당합니다.
  • node 환경에서 존재하지 않는 ID로 update시 오류가 발생하기 때문에 존재시 값을 리턴받도록 Query Layer에서 처리합니다.
@Injectable()
export class UserService {
  constructor(
    private readonly userQueryService: UserQueryService,
    private readonly userRepository: UserRepository,
  ) {}

  async create(createUserDto: CreateUserDto): Promise<UserResponseDto> {
    const user = await this.userRepository.create(createUserDto);
    return UserResponseDto.toDto(user);
  }

  async update(id: bigint, updateUserDto: UpdateUserDto) {
    await this.userQueryService.getByIdOrThrow(id);
    const user = await this.userRepository.update(id, updateUserDto);
    return UserResponseDto.toDto(user);
  }
}

user-query.service.ts

  • Query Service는 DB 조회(SELECT)에 대한 공통 로직을 담당합니다.
  • 값이 존재하지 않을 경우 예외를 발생시키는 등의 조회 보조(helper) 역할을 수행합니다.
@Injectable()
export class UserQueryService {
  constructor(private readonly userRepository: UserRepository) {}

  async getByIdOrThrow(id: bigint): Promise<User> {
    const user = await this.userRepository.findById(id);
    if (!user) {
      throw new NotFoundException('사용자 정보가 존재하지 않습니다.');
    }
    return user;
  }
}

user.repository.ts

  • ORM을 이용하여 DB의 조회 및 저장을 담당한다.
@Injectable()
export class UserRepository {
  constructor(
    private readonly bcryptComponent: BcryptComponent,
    private readonly txHost: TransactionHost<TransactionalAdapterPrisma>,
  ) {}

  async create(data: CreateUserDto): Promise<User> {
    return this.txHost.tx.user.create({
      data: {
        ...data,
        password: await this.bcryptComponent.hash(data.password),
      },
    });
  }

  async update(id: bigint, data: UpdateUserDto): Promise<User> {
    return this.txHost.tx.user.update({
      where: { id },
      data: {
        ...data,
        ...(data.password && {
          password: await this.bcryptComponent.hash(data.password),
        }),
      },
    });
  }

  async findById(id: bigint): Promise<User | null> {
    return this.txHost.tx.user.findUnique({
      where: { id: id },
    });
  }
}

결론

클린 아키텍처는 특정한 폴더 구조의 구조를 갖는다기 보다는 의존성의 방향과 책임의 경계를 어떻게 지키것인가의 선택의 문제라고 생각합니다.

개발은 나 혼자 하는 것이 아니기 때문에 팀 구성원들 간의 약속을 지키고 프로젝트를 확장해나갔을 때 문제가 되는 부분들을 해소할 수 있는 방법을 고민하고 결정된 구조입니다.

변경에 강하고, 읽기 쉬우며, 확장 가능한 구조를 가져가는 것이 목표였지만 모든 프로젝트에 이 구조가 정답이 될 수 없다고 생각합니다.

우리 팀과 도메인에 맞는 책임 분리를 끊임없이 고민하고 조정해 나가는 과정이라고 생각 합니다.

이 글은 아키텍처 설계에 대한 비슷한 고민을 하고 계신분들께 하나의 참고 사례가 되기를 바라는 마음으로 작성하였습니다.

또한 해당 구조에 대한 피드백이나 다른 방향에 대한 의견을 공유해주신다면 저 또한 발전된 아키텍처를 구상하는데 도움이 될 것으로 생각됩니다.

댓글
공지사항