이현우 프로필

객체의 책임과 경계, 3-Layer Architecture

현재 프론트엔드 개발자로 일하고 있지만, 이번 사이드 프로젝트에서는 NestJS를 사용해 백엔드 개발에 참여하게 되었다.

내가 맡은 첫 기능은 회원가입이었다. 회원가입에 필요한 auth.controller, signup.dto, auth.service를 만들었고, Service에 PrismaService를 주입해 사용자를 저장하려 했다.

당시 작성한 코드는 아래 코드와 같았다.

@Injectable() export class AuthService { private readonly prisma: PrismaService; constructor(prisma: PrismaService) { this.prisma = prisma; } async signUp(dto: SignupDto) { // 이메일 중복 체크 // 전화번호 중복 체크 return this.prisma.user.create({ data: { userName: dto.userName, email: EmailVo.of(dto.email), password: await Password.of(dto.password), address: Address.of({ address1: dto.address1, address2: dto.address2, zipCode: dto.zipCode, }), mobile: MobileVo.of(dto.mobile), }, }); return { message: '회원가입에 성공했습니다.', } } }

PR에서 다음과 같은 취지의 리뷰를 받았다.

제가 백엔드에서 가장 중요하게 생각하는 점은 각 객체의 책임과 경계인데요. 백엔드 구조를 크게 나누면 다음과 같습니다.

  • Presentation(Controller) Layer: 외부 요청을 검증하고 Service를 호출합니다.
  • Application(Service) Layer: 비즈니스 로직을 처리합니다.
  • Persistence(Repository) Layer: 데이터의 저장·조회·수정·삭제를 담당합니다.

현재는 this.prisma.user.create를 Service Layer에서 직접 호출하고 있습니다. 이 경우 Service가 비즈니스 로직뿐만 아니라 데이터 저장 방식까지 결정하게 되어 책임이 커질 수 있습니다.

Service가 Prisma에 직접 의존하면 User 테이블이나 저장 방식이 변경될 때 여러 Service를 함께 수정해야 할 가능성이 있습니다.

본격적으로 DDD와 객체지향을 학습하기 전에 이 글을 읽어보시기를 추천드립니다.

이 PR을 계기로 DDD객체지향을 공부하기 전에, 객체의 책임과 경계를 나누는 가장 기본적인 구조인 3-Layer Architecture부터 정리해 보기로 했다.

3-Layer Architecture란?

3-Layer Architecture는 애플리케이션을 역할에 따라 세 계층으로 나누는 구조다.

클라이언트 ↓ HTTP 요청 Controller Layer OR Presentation Layer ↓ 기능 실행 요청 ServiceLayer OR Application Layer ↓ 데이터 조회, 저장 요청 Persistence Layer OR Repository Layer Database

NestJS 프로젝트에 대입하면 보통 다음과 같이 나눌 수 있다.

계층NestJS에서 주로 사용하는 객체주요 책임
Presentation LayerController, Request DTO요청 수신, 입력값 검증, 응답 반환
Application LayerService비즈니스 규칙 실행, 작업 순서 조정
Persistence LayerRepository데이터 조회, 저장, 수정, 삭제

프론트엔드는 백엔드의 3계층에 포함되는 것이 아니라, HTTP를 통해 Presentation Layer와 통신하는 외부 클라이언트다.

1. Presentation Layer: 요청을 받고 응답한다

Presentation Layer는 외부 클라이언트와 백엔드가 만나는 입구다. NestJS에서는 주로 Controller와 Request DTO가 이 역할을 담당한다.

회원가입에서는 다음과 같은 일을 처리한다.

  • POST /users 요청을 받는다.
  • 이메일, 비밀번호, 이름, 전화번호의 형식을 검사한다.
  • 검증된 데이터를 Service에 전달한다.
  • Service가 반환한 결과를 HTTP 응답으로 전달한다.
export class CreateUserDto { @IsEmail() email: string; @IsString() @MinLength(8) password: string; @IsString() @IsNotEmpty() name: string; @IsString() @IsNotEmpty() mobile: string; ... }
@Controller('users') export class UsersController { constructor(private readonly usersService: UsersService) {} @Post() create(@Body() dto: CreateUserDto) { return this.usersService.create(dto); } }

NestJS에서는 ValidationPipeclass-validator를 사용해 DTO의 유효성을 검사할 수 있다.

이 계층에서 확인하는 것은 주로 요청의 형식이다. 이메일 형식이 올바른지, 필수값이 비어 있지 않은지, 비밀번호 길이가 조건에 맞는지 등을 검사한다.

반면 데이터 조회와 정책 판단이 필요한 검증은 Application Layer에서 처리하는 것이 자연스럽다.

2. Application Layer: 회원가입이라는 일을 수행한다

Application Layer는 애플리케이션이 제공하는 기능을 실행한다. NestJS에서는 일반적으로 Service가 이 역할을 담당한다.

회원가입 Service는 다음 작업의 순서를 조정할 수 있다.

  1. 이메일과 전화번호를 저장할 형식으로 정규화한다.
  2. 이미 가입된 이메일인지 확인한다.
  3. 비밀번호를 해시한다.
  4. 저장할 사용자 데이터를 만든다.
  5. Repository에 사용자 저장을 요청한다.
@Injectable() export class UsersService { constructor( private readonly usersRepository: UsersRepository, private readonly passwordHasher: PasswordHasher, ) {} async create(dto: CreateUserDto) { const email = dto.email.trim().toLowerCase(); const mobile = dto.mobile.replaceAll('-', ''); const exists = await this.usersRepository.existsByEmail(email); if (exists) { throw new ConflictException('이미 가입된 이메일입니다.'); } const hashedPassword = await this.passwordHasher.hash(dto.password); const user = await this.usersRepository.create({ email, mobile, name: dto.name, password: hashedPassword, }); return { id: user.id, email: user.email, name: user.name, mobile: user.mobile, }; } }

수정된 Service에는 this.prisma.user.create()가 없다. Service는 Prisma의 모델명이나 쿼리 작성법을 알지 않고, UsersRepository에 사용자를 저장해 달라고 요청한다.

여기서 중요한 점은 Service가 데이터 저장과 완전히 무관해진 것이 아니라는 것이다. 회원가입을 완료하려면 사용자를 저장해야 하므로 Service가 Repository의 create()를 호출하는 것은 자연스럽다.

다만 Service는 저장해야 한다는 사실만 알고, 어떤 ORM으로 어떤 쿼리를 실행할지는 Persistence Layer에 맡긴다.

이메일과 전화번호의 정규화 위치에는 하나의 절대적인 정답이 있는 것은 아니다. 현재처럼 Service에서 처리할 수도 있고, 도메인 규칙이 복잡해지면 Email, Mobile과 같은 Value Object로 옮길 수도 있다. 이번 글에서는 회원가입 기능의 규칙으로 보고 Service에서 처리했다.

3. Persistence Layer: Prisma를 이용해 데이터를 저장한다

Persistence Layer는 데이터베이스에 접근해 데이터를 조회하고 저장하는 계층이다. 보통 Repository가 이 역할을 담당한다.

export interface CreateUserData { email: string; password: string; name: string; mobile: string; }
@Injectable() export class UsersRepository { constructor(private readonly prisma: PrismaService) {} async existsByEmail(email: string) { const user = await this.prisma.user.findUnique({ where: { email }, select: { id: true }, }); return user !== null; } create(data: CreateUserData) { return this.prisma.user.create({ data, }); } }

이제 Prisma에 직접 의존하는 객체는 UsersRepository다.

Prisma의 API가 변경되거나 저장 쿼리에 수정이 필요하다면 Repository를 중심으로 확인할 수 있다. 반대로 회원가입 정책이 변경되면 Service를 중심으로 수정하면 된다.

즉, 변경의 이유가 다른 코드를 서로 다른 객체로 분리한 것이다.

기존 코드에서는 어떤 책임이 섞여 있었을까?

기존 UsersService는 한 메서드 안에서 다음 작업을 모두 처리하고 있었다.

  • 이메일과 전화번호 정규화
  • 비밀번호 해시
  • 회원가입 정책 처리
  • Prisma에 전달할 data 객체 구성
  • Prisma 모델을 이용한 데이터베이스 저장

코드의 길이만 보면 아직 복잡하지 않다. 하지만 문제는 코드의 줄 수보다 이 객체가 변경되는 이유가 여러 개라는 점이다.

예를 들어 다음과 같은 변경이 발생할 수 있다.

  • 회원가입 정책이 변경된다.
  • 이메일 정규화 규칙이 변경된다.
  • User 테이블의 컬럼 구성이 변경된다.
  • Prisma 쿼리 작성 방식이 변경된다.
  • Prisma 대신 다른 데이터 접근 기술을 사용한다.

이 변화들이 모두 UsersService의 수정으로 이어진다면 Service의 책임과 영향 범위가 점점 커진다. 다른 Service에서도 this.prisma.user.create()this.prisma.user.update()를 직접 사용하고 있다면, 영속성 구조가 변경될 때 여러 Service를 찾아다니며 수정해야 할 수도 있다.

Repository를 두면 데이터 접근 방식의 변경을 Persistence Layer 안으로 모을 수 있다.

다만 User 테이블이 변경되면 Repository만 수정하면 된다고 단정할 수는 없다. 컬럼 변경이 비즈니스 개념의 변경까지 의미한다면 DTO와 Service도 함께 변경될 수 있다. 계층 분리의 목적은 모든 변경을 한 파일에서 끝내는 것이 아니라, 변경의 성격에 따라 확인할 범위를 명확하게 만드는 것에 가깝다.

회원가입 요청의 전체 흐름

Repository를 분리한 뒤 회원가입 요청은 다음 순서로 흐른다.

회원가입 폼 제출 UsersController - 요청 수신 - CreateUserDto 형식 검증 UsersService - 이메일·전화번호 정규화 - 이메일 중복 확인 - 비밀번호 해시 - 회원가입 작업 순서 조정 UsersRepository - Prisma 쿼리 실행 - 사용자 데이터 저장 Database

응답은 반대 방향으로 돌아온다.

Database → UsersRepository → UsersService → UsersController → 프론트엔드

이 구조에서 각 계층은 바로 아래 계층에 필요한 작업을 요청하지만, 그 계층의 세부 구현까지 직접 담당하지 않는다.

계층을 나누면 의존성이 사라지는 걸까?

계층을 나눈다고 의존성이 없어지는 것은 아니다.

  • Controller는 Service에 의존한다.
  • Service는 Repository에 의존한다.
  • Repository는 Prisma에 의존한다.

중요한 것은 의존성의 방향과 범위다.

Controller는 Prisma가 무엇인지 몰라도 되고, Repository는 HTTP 요청 객체를 알 필요가 없다. Service 역시 Prisma의 user.create() 사용법을 몰라도 회원가입 기능을 실행할 수 있다.

각 객체가 자신에게 필요한 책임만 알고 있기 때문에, 한 부분의 세부 구현이 다른 계층으로 퍼지는 것을 줄일 수 있다.

Service에서 Prisma를 직접 사용하면 무조건 잘못일까?

Service에서 Prisma를 직접 호출한다고 해서 무조건 잘못된 코드는 아니다.

규모가 작은 프로토타입이나 단순한 CRUD 기능에서는 별도의 Repository가 오히려 파일과 코드만 늘릴 수도 있다. Repository의 메서드가 Prisma 메서드를 이름만 바꿔 그대로 호출하는 수준이라면 얻는 이점이 크지 않을 수도 있다.

하지만 다음과 같은 상황이라면 Repository 분리를 고려할 이유가 충분하다.

  • 여러 Service에서 같은 모델의 Prisma 쿼리를 반복해서 사용한다.
  • 데이터 조회와 저장 규칙이 복잡해지고 있다.
  • Prisma의 타입과 쿼리 구조가 Service 전반으로 퍼지고 있다.
  • 비즈니스 로직을 데이터베이스 없이 단위 테스트하고 싶다.
  • 프로젝트에서 계층별 책임과 구조를 명확하게 유지하기로 합의했다.

이번 사이드 프로젝트의 PR은 단순히 파일 하나를 더 만들라는 의미가 아니었다. 프로젝트가 커졌을 때 변경 범위를 관리할 수 있도록, 지금부터 객체의 책임과 경계를 의식해 보자는 리뷰에 가까웠다.