Back to all posts

NestJS 아키텍처: Controller와 Service의 역할 분담

NestJS 아키텍처: Controller와 Service의 역할 분담을 공부하며 이해한 흐름을 정리한 글입니다.

2026년 05월 26일6

1. 개요 (Overview)

NestJS는 강력한 모듈식 아키텍처와 객체지향 디자인 패턴(특히 의존성 주입(Dependency Injection)관심사 분리(Separation of Concerns))을 기반으로 설계되었습니다.
흔히 저지르는 실수 중 하나는 컨트롤러(Controller) 내부에 비즈니스 로직이나 데이터 변경(mutation) 코드를 직접 작성하는 것입니다.

예를 들어, 현재 작성된 코드에서는 다음과 같이 컨트롤러 내부에서 인메모리 배열(posts)을 직접 다루고 상태를 변경하고 있습니다.

// posts.controller.ts (AS-IS)
@Controller('posts')
export class PostsController {
  // ...
  @Post()
  postPosts(...) {
    const post = { ... };
    posts = [...posts, post]; // 비즈니스 로직 및 상태 변경이 컨트롤러에 노출됨
    return post;
  }
}

NestJS 아키텍처 관점에서는 이러한 방식의 개발을 안티 패턴으로 규정하며, 컨트롤러와 서비스의 역할을 명확히 분리할 것을 권장합니다.


2. Controller와 Service의 역할 정의

2.1. Controller (컨트롤러)의 역할

컨트롤러는 **HTTP 요청을 수신하고 응답을 반환하는 관문(Gateway)**입니다. HTTP 계층과의 상호작용만 담당해야 합니다.

  • HTTP 메소드 및 경로 라우팅: @Get(), @Post(), @Put(), @Delete() 등 라우팅 정의
  • 요청 데이터 바인딩 및 검증: @Body(), @Query(), @Param()을 통한 값 바인딩 및 DTO(Data Transfer Object)와 Pipe를 이용한 데이터 검증(Validation)
  • HTTP 응답 구성: 적절한 HTTP 상태 코드 반환, 쿠키 및 헤더 설정
  • 컨트롤러는 "얇게(Thin)" 유지해야 합니다. 실제 연산이나 비즈니스 규칙은 알 필요가 없고, 단순한 흐름 제어기 역할만 수행합니다.

2.2. Service (서비스 / Provider)의 역할

서비스는 비즈니스 로직(Business Logic)을 처리하고 데이터를 제어하는 핵심 엔진입니다. @Injectable() 데코레이터를 사용하여 NestJS IoC(Inversion of Control) 컨테이너가 의존성을 관리할 수 있도록 합니다.

  • 비즈니스 로직 수행: 비즈니스 규칙 처리, 데이터 가공, 트랜잭션 관리
  • 데이터 소스(DB/인메모리) 상호작용: Repository나 DB 클라이언트를 통해 데이터를 CRUD 수행
  • 외부 API와의 연동: 외부 시스템과의 통신
  • HTTP 계층 독립성: 서비스는 HTTP 요청(Request), 응답(Response) 객체에 직접 접근하지 않아야 하며, 순수 TypeScript 로직으로 유지되어 독립적인 테스트가 가능해야 합니다.

2.3. 의존성 주입(Dependency Injection)과 생성자(Constructor) 주입 방식

NestJS에서는 컨트롤러가 서비스에 직접 의존하지 않고, 외부(NestJS IoC 컨테이너)에서 서비스 인스턴스를 주입받도록 설계되어 있습니다. 이를 **의존성 주입(DI)**이라고 하며, 그 핵심이 되는 코드가 바로 아래의 생성자 코드입니다.

constructor(private readonly appService: PostsService) { }

이 한 줄의 코드는 두 가지 중요한 개념이 결합되어 있습니다.

① TypeScript의 생성자 매개변수 속성 (Parameter Properties)

TypeScript에서는 생성자 매개변수 앞에 접근 제한자(private, public, protected)나 readonly를 붙이면, 클래스 멤버 변수 선언과 생성자 내에서의 할당을 동시에 수행해 줍니다.

즉, 아래의 길고 번거로운 코드를 TypeScript가 한 줄로 축약해 준 것입니다.

  • 동일한 의미의 일반적인 TypeScript 코드:
    export class PostsController {
      // 1. 멤버 변수 선언
      private readonly appService: PostsService;
    
      // 2. 생성자 매개변수로 주입받아 할당
      constructor(appService: PostsService) {
        this.appService = appService;
      }
    }
    

② NestJS IoC 컨테이너에 의한 자동 주입 (Auto-wiring)

NestJS 애플리케이션이 시작될 때, IoC(제어의 역전) 컨테이너는 @Controller@Injectable 데코레이터가 붙은 클래스들의 메타데이터를 분석합니다.

  1. PostsController가 생성될 때 생성자 매개변수의 타입(PostsService)을 확인합니다.
  2. 컨테이너에 등록된 PostsService 인스턴스를 찾아 PostsController를 생성할 때 주입(Injection)해 줍니다.
  3. 이를 통해 우리는 클래스 내부에서 new PostsService()와 같이 인스턴스를 직접 생성(결합도 증가)하지 않고도, 언제나 준비된 appService 인스턴스를 즉시 사용할 수 있습니다.

3. 역할 분리가 필요한 이유 (장점)

① 재사용성 (Reusability)

만약 같은 비즈니스 로직(예: 게시글 작성)을 HTTP API가 아닌 GraphQL Resolver, CLI 명령어, WebSockets, 혹은 마이크로서비스 메시지 핸들러에서도 사용해야 한다면 어떻게 될까요?

  • 컨트롤러에 로직이 있으면 이를 복사-붙여넣기 해야 합니다.
  • 서비스에 로직이 격리되어 있으면, 새로운 컨트롤러/리졸버 등에서 해당 서비스를 의존성 주입(DI)받아 그대로 호출하면 됩니다.

② 테스트 용이성 (Testability)

비즈니스 로직에 대해 단위 테스트(Unit Test)를 작성할 때, HTTP 컨텍스트(Express/Fastify request 객체 등)를 매번 모킹(Mocking)해야 한다면 테스트 작성이 매우 번거로워집니다.

  • 서비스를 HTTP 계층과 분리해 두면, 데이터베이스나 외부 의존성만 모킹하여 순수한 비즈니스 로직 단위 테스트를 쉽고 빠르게 작성할 수 있습니다.

③ 단일 책임 원칙 (Single Responsibility Principle, SRP)

  • 컨트롤러는 **"요청을 어떻게 받고 응답을 어떻게 보낼 것인가"**에 대한 책임만 집니다.
  • 서비스는 **"요청된 작업을 어떻게 비즈니스적으로 수행할 것인가"**에 대한 책임만 집니다.
    이로 인해 코드가 깔끔해지고 유지보수가 훨씬 용이해집니다.

4. 리팩토링 예시 (Refactoring Example)

현재 작성된 인메모리 포스트 관리 로직을 아키텍처에 맞게 리팩토링하는 가이드는 다음과 같습니다.

4.1. Service 작성 (To-Be)

비즈니스 로직과 데이터(posts 배열) 관리를 서비스로 이동합니다.

// posts.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';

export interface PostModel {
  id: number;
  author: string;
  title: string;
  content: string;
  likeCount: number;
  commentCount: number;
}

@Injectable()
export class PostsService {
  private posts: PostModel[] = [
    { id: 1, author: '아이브', title: 'Ditto', content: '111', likeCount: 12345, commentCount: 123 },
    { id: 2, author: '르세라핌', title: 'Ditto', content: '111', likeCount: 12345, commentCount: 123 },
    { id: 3, author: 'IU', title: 'Ditto', content: '222xs', likeCount: 12345, commentCount: 123 },
  ];

  getAllPosts() {
    return this.posts;
  }

  getPostById(id: number) {
    const post = this.posts.find((post) => post.id === id);
    if (!post) {
      throw new NotFoundException('Post not found');
    }
    return post;
  }

  createPost(author: string, title: string, content: string) {
    const post: PostModel = {
      id: this.posts[this.posts.length - 1].id + 1,
      author,
      title,
      content,
      likeCount: 0,
      commentCount: 0,
    };

    this.posts = [...this.posts, post];
    return post;
  }

  updatePost(id: number, author?: string, title?: string, content?: string) {
    const post = this.posts.find((post) => post.id === id);
    if (!post) {
      throw new NotFoundException('Post not found');
    }

    if (author) post.author = author;
    if (title) post.title = title;
    if (content) post.content = content;

    this.posts = this.posts.map((prevPost) => (prevPost.id === id ? post : prevPost));
    return post;
  }

  deletePost(id: number) {
    this.posts = this.posts.filter((post) => post.id !== id);
    return id;
  }
}

4.2. Controller 수정 (To-Be)

컨트롤러는 생성자를 통해 PostsService를 주입(DI)받고, 각 엔드포인트 메서드에서 서비스를 호출하여 결과만 반환합니다.

// posts.controller.ts
import { Body, Controller, Param, Get, Post, Put, Delete, ParseIntPipe } from '@nestjs/common';
import { PostsService } from './posts.service';

@Controller('posts')
export class PostsController {
  constructor(private readonly postsService: PostsService) {}

  @Get()
  getPosts() {
    return this.postsService.getAllPosts();
  }

  @Get(':id')
  getPost(@Param('id', ParseIntPipe) id: number) {
    return this.postsService.getPostById(id);
  }

  @Post()
  postPosts(
    @Body('author') author: string,
    @Body('title') title: string,
    @Body('content') content: string,
  ) {
    return this.postsService.createPost(author, title, content);
  }

  @Put(':id')
  putPost(
    @Param('id', ParseIntPipe) id: number,
    @Body('author') author?: string,
    @Body('title') title?: string,
    @Body('content') content?: string,
  ) {
    return this.postsService.updatePost(id, author, title, content);
  }

  @Delete(':id')
  deletePost(@Param('id', ParseIntPipe) id: number) {
    return this.postsService.deletePost(id);
  }
}

[!TIP]
위 리팩토링 예시에서는 라우트 파라미터 :id가 문자열에서 숫자로 변경되는 과정을 처리하기 위해 NestJS 기본 내장 파이프인 ParseIntPipe를 활용하여 컨트롤러 수준에서 형변환 검증을 깔끔하게 처리했습니다.


5. 요약 (Summary)

영역 주요 역할 권장되는 로직 지양해야 하는 로직
Controller 요청 처리 및 라우팅 DTO 검증, 라우팅 설정, 상태 코드/헤더 반환, 단순 위임 데이터베이스 쿼리, 인메모리 데이터 직접 수정, 비즈니스 연산
Service 핵심 비즈니스 로직 데이터 가공, 트랜잭션 처리, DB 조회/수정, 알고리즘 구현 Express Request/Response 직접 참조, HTTP 상태 코드 강제 제어

이 가이드에 따라 posts.controller.tsposts.service.ts로 코드를 리팩토링하면, NestJS 프레임워크가 제공하는 강력한 아키텍처적 이점을 온전히 누릴 수 있습니다.