NestJS와 GraphQL : 도입 배경과 기본 개념 #2

Hyeongyeom
tesser-team
Published in
13 min readMar 15, 2023

--

안녕하세요. 테서의 서비스 개발 팀에서 백엔드 직무를 맡고 있는 최현겸입니다.

이번 포스팅에서는 앞선 NestJS와GraphQL : 도입 배경과 기본 개념 #1 포스팅에 이어 NestJSGraphQL의 주요 개념에 대해 간단한 예시와 함께 살펴보고자 합니다. 1편 포스팅을 읽고 다음 글을 읽는 것을 추천드립니다.

프로젝트 구조(Project Struture)

NestJS에서는 기본적으로 계층형 구조 기법을 사용합니다. “관심사 분리 구조”로 칭하기도 하며, 작업을 나누고 각 작업마다 역량을 집중하는 방식입니다. 일반적으로 3계층 구조를 많이 사용하고 Presentation Tier, Application Tier, Data Tier로 나누어집니다.

  • Presentation Tier는 사용자 인터페이스 혹은 외부와의 통신을 담당하며, NestJS에서는 컨트롤러로 구현됩니다.
  • Application Tier는 주로 비즈니스 로직을 담당하며, NestJS에서는 서비스로 구현됩니다.
  • Data Tier는 데이터베이스에 데이터를 읽고 쓰는 역할을 담당하며, NestJS에서는 리포지토리로 구현됩니다.

NestJS는 3계층 구조를 프로바이더(Provider)로 구분하여 응집도는 높이고 결합도는 낮추는 소프트웨어 설계를 지향합니다. 터미널에 $ nest new project-name 명령어만 치면 간단하게 아래와 같은 구조로 생성됩니다.

src/
├── app.controller.spec.ts
├── app.controller.ts
├── app.module.ts
├── app.service.ts
└── main.ts

또한 cli를 통해 각 독립적인 기능을 하는 모듈의 구조를 간단하게 만들 수 있습니다. 이 외에도 각 모듈별로 필요한 enums, custom type, transactions 등의 파일이 각 모듈 별로 구성됩니다.

라이프 사이클 (Life Cycle)

라이프 사이클이란 Request부터 Response를 반환하기까지 일련의 과정을 의미합니다.

Express는 Request부터 Response까지의 라이프 사이클에 관여하는 처리 흐름을 미들웨어(Middleware)로 통일합니다. 이로 인해 개발자가 코드를 작성할 때, 미들웨어 함수의 순서나 각 미들웨어가 어떤 역할을 하는지 일일이 파악해야 하는 등 가독성과 유지보수성에 어려움이 있습니다.

Nest.js는 Express에 비해 더 구조적이고 명확한 라이프 사이클을 갖고 있습니다. Nest.js에서는 개발자가 미들웨어의 순서를 직접 조정해줘야 하는 번거로움을 자동으로 적용하는 방식으로 해결합니다.

다음은 자동화된 Nest.js의 라이프 사이클을 도식화한 그림입니다.

출처: Yariv Gilad’s slides

Request가 들어오면 다음과 같은 순서로 흘러갑니다.

  1. Guards (global, controller, root 순서)
  2. Interceptors (global, controller, route 순서)
  3. Pipes (global, controller, route, route parameter pipe 순서)
  4. Controller
  5. Service
  6. Interceptors (route, controller, global 순서)
  7. Filter (예외 처리)
  8. Response를 반환

이 라이프 사이클은 개발자가 코드의 순서를 조정하지 않아도 자동으로 적용되며, 미들웨어의 순서를 직접 조정해줘야 하는 문제를 해결해줍니다.

또한, Nest.js에서는 데코레이터를 사용하여 각 라이프 사이클에 필요한 기능을 적용할 수 있습니다. 예를 들어, @UseGuards(), @UseInterceptors(), @UsePipes(), @Controller(), @Service()와 같은 데코레이터를 사용하여 Guards, Interceptors, Pipes, Controller, Service 등을 적용할 수 있습니다.

이를 통해 코드의 가독성을 향상시키고, 미들웨어의 순서 조정이 필요하지 않아 코드를 더 간결하게 작성할 수 있습니다.

의존성 주입(Depengency Injection)

NestJS는 기본적으로 의존성 주입(DI) 패턴을 지원하며, 의존성 주입을 위해 @Injectable() 데코레이터를 제공합니다.

의존성 주입이란 객체 간의 의존 관계를 느슨하게 연결하는 방법입니다. 의존성 주입은 객체가 다른 객체를 생성하거나 조작하는 것이 아니라, 외부에서 객체에 필요한 의존성을 주입하는 방식입니다.

그렇다면 의존성 주입이 왜 중요할까요?

객체 지향 프로그래밍에서 유지 보수성, 확장성, 유연성을 갖춘 소프트웨어를 개발하기 위한 다섯 가 지 ‘SOLID 법칙’ 에 대해 중요하게 다뤄집니다. (구체적인 내용은 https://ko.wikipedia.org/wiki/SOLID_(객체_지향_설계) 내용을 참고해주세요.)

의존성 주입은(DI)는 SOLID 원칙 중 하나인 ‘의존성 역전 원칙(Dependency Inversion Principle, DIP)’와 관련이 있습니다. 객체가 다른 객체를 생성하거나 조작하는 것이 아니라, 외부에서 객체에 필요한 의존성을 주입하는 방식입니다. 이를 통해 객체 간의 결합도를 줄이고, 유연하고 재사용 가능한 코드를 작성할 수 있습니다.

NestJS에서 의존성 주입을 하는 방법은 크게 두 가지가 있습니다.

💡 1. 클래스 생성자 함수에 의존성을 주입하는 방법
(OwnerService 클래스를 PetService 클래스에 주입하는 예시)

주입하고자 하는 클래스(OwnerService)에 @Injectable() 데코레이터 적어 이 클래스를 DI system에 활용하겠다고 명시합니다.

@Inject() 데코레이터를 사용하여 의존성 주입이 필요한 클래스(PetService)의 생성자 함수에서 의존성 주입을 받을 클래스(OwnerService)를 선언하고, 주입합니다.

💡 2. 모듈에서 의존성 주입하는 방법

@Module() 데코레이터가 있는 pet.module.ts의 providers 속성에 @Injectable() 데코레이터로 선언된 OwnerService 클래스를 등록합니다.

클래스 생성자 함수에 의존성을 주입하는 방법과 다르게 @Inject() 데코레이터를 사용하지 않고 의존성 주입이 필요한 클래스(PetService)의 생성자 함수에서 의존성 주입을 받을 클래스(OwnerService)를 선언하고 주입합니다.

이렇게 함으로써 PetService에서 OwnerService를 주입받으면, PetService는 OwnerService의 구체적인 구현에 의존하지 않고도 Owner와 관련된 작업을 수행할 수 있습니다. 이를 통해, PetService를 OwnerService와 느슨하게 결합하여 코드의 유연성과 확장성을 높일 수 있습니다.

제어의 역전(IOC: Inversion of Control)

위에서 DI는 객체 간의 의존 관계를 자동으로 설정해주는 것이라고 설명했습니다. 그렇다면, 어떻게 데코레이터 하나로 자동으로 의존 관계가 설정되고, 객체가 관리되는 것일까요?

결론부터 먼저 말씀드리면, NestJS framework에서 의존 관계, 객체 생성, 소멸 등을 관리합니다. 더 자세히는 IoC Container가 객체의 라이프사이클을 관리합니다. 객체가 언제 생성되어야 하는지, 언제 소멸되어야 하는지, 객체 간의 의존 관계가 어떻게 설정되어야 하는지 등을 결정합니다.

IOC란 객체의 생성부터 소멸까지 어플리케이션이 제어권을 갖는 것이 아니고, 이르 컨테이너에게 넘겨서 인스턴스를 대신 관리해주는 일을 말합니다.

스프링(Spring)을 사용해봤다면 빈(BEAN)에 대해 들어보신 적이 있을겁니다. 빈이란 스프링 IoC 컨테이너가 관리하는 자바 객체를 의미하는데 NestJS에서는 빈(Bean) 대신에 프로바이더(Provider)라는 용어를 사용합니다.

NestJS에서는 프로바이더(Provider)를 통해 객체를 생성하며, 이때 IoC Container가 프로바이더의 의존성 설정을 컨트롤합니다. 즉, IoC Container는 프로바이더의 생성과 소멸을 관리하며, 필요한 시점에 프로바이더를 생성하고 소멸시킵니다.

객체가 더 이상 사용되지 않을 때, IoC Container는 객체를 제거하기도 합니다. 이를 GC(Garbage Collection)라고 합니다. GC의 역할은 객체가 더 이상 참조되지 않을 때, 자동으로 객체를 제거하는 것입니다.

모듈(Module)

NestJS에서 모듈(Module) 은 애플리케이션의 구성 요소 중 하나로, 관련 있는 컨트롤러(또는 리졸버), 서비스, 프로바이더 등을 논리적으로 그룹화하고 모듈 간에 의존성을 관리하는 역할을 합니다. 즉, 위에 설명한 의존성 주입 및 모듈 관리를 위한 클래스입니다.

다음은 GraphQLModule 을 전역으로 주입하여 사용하기 위해 app.module.ts 파일에 설정한 내용입니다.

  • GraphQLModule: NestJS에서 GraphQL을 사용할 수 있도록 지원하는 모듈입니다. 이 모듈은 forRoot 메소드를 통해 GraphQL 설정을 적용할 수 있습니다.
  • ApolloDriver: Apollo Server를 NestJS에서 사용할 수 있도록 지원하는 드라이버입니다. NestJS에서 GraphQL을 사용할 때 Apollo Server를 사용하려면, @nestjs/graphql 패키지와 함께 이 드라이버를 사용하면 됩니다.
  • imports: 현재 모듈에서 사용할 다른 모듈들의 리스트입니다.
  • controllers: 현재 모듈에서 사용할 컨트롤러들의 리스트입니다.
  • providers: 현재 모듈에서 사용할 프로바이더(서비스, 리포지토리 등)들의 리스트입니다.
  • exports: 현재 모듈에서 내보낼 프로바이더들의 리스트입니다.

리졸버(Resolver)

GraphQL은 RESTful API와 달리, 데이터 요청에 대해 세부적으로 쿼리를 작성하고 그 결과를 받는 것이 특징입니다. 따라서, 기존의 컨트롤러와 뷰의 역할이 분리되어, 데이터 처리를 담당하는 부분을 controller가 아닌 resolver가 담당합니다.

Resolver 클래스는 GraphQL 스키마에서 정의한 쿼리와 뮤테이션을 처리하는 함수들을 정의합니다. 함수의 이름과 인자는 스키마에서 정의한 쿼리와 뮤테이션과 일치해야 합니다.

서비스(Service)

서비스는 Resolver에서 호출되는 비즈니스 로직을 처리합니다. 일반적으로 데이터베이스와 상호작용하거나, 외부 API와 통신하거나, 다른 데이터 소스에서 데이터를 가져와 가공하고 정제하는 작업 등을 수행합니다.

필요한 서비스 클래스를 작성하고, Resolver 클래스에서 서비스 클래스의 메서드를 호출하여 데이터를 처리합니다.

아폴로(Apollo)

NestJS와 GraphQL을 같이 사용하기 위해서는 @nestjs/graphql 패키지를 사용해야합니다. @nestjs/graphql 패키지 만으로 NestJS와 GraphQL을 함께 사용할 수 있지만, 일반적으로 NestJS에서 GraphQL을 사용할 때 Apollo Server를 사용합니다.

GraphQL은 RESTful API와는 다르게 클라이언트가 원하는 데이터를 요청할 수 있도록 서버 측에서 스키마를 정의해야 합니다. 이 스키마는 데이터의 타입, 필드, 쿼리 등을 정의하는데, 이러한 스키마를 작성하고 관리하기 위해서는 별도의 서버가 필요합니다. Apollo Server는 GraphQL API를 빠르고 쉽게 구현할 수 있도록 도와주는 서버 라이브러리입니다.

출처: Apollo official docs

Apollo Server를 사용하면, GraphQL API를 구현하는 데 필요한 기능들을 쉽게 제공받을 수 있습니다. 예를 들어, Apollo Server는 데이터를 조회하는 Resolver 함수를 작성하는 데 필요한 도구들을 제공합니다. 또한, Apollo Server는 캐시를 관리하고, 데이터를 정규화하여 중복되는 데이터를 제거하는 등의 기능을 제공합니다. 이를 통해, GraphQL API를 빠르고 효율적으로 구현할 수 있습니다. Apollo Server 외에도 GraphQL 기능을 지원하는 많은 도구를 링크에서 확인할 수 있습니다.

스키마(Schema)

GraphQL에서 스키마는 데이터 그래프의 형태와 어떤 타입의 쿼리와 뮤테이션(변경)을 지원 여부를 정의하는 것입니다. 스키마는 GraphQL API에서 제공되는 데이터의 형식과 어떤 데이터를 요청할 수 있는지, 어떤 데이터를 변경할 수 있는지를 명시적으로 정의합니다.

스키마 파일은 *.graphql (또는 *.gql) 확장자를 가지며, GraphQL 스키마를 정의하는 SDL(Schema Definition Language) 문법을 사용합니다. 스키마 파일은 autoSchemaFile 옵션에 지정한 경로에 저장됩니다. GraphQLModule을 설정할 때 autoSchemaFile: true 또는 path(파일경로)를 사용하면 스키마 파일을 자동으로 생성할 수도 있습니다.

플러그인(Plugin)

Apollo Plugins는 Apollo 에 추가되는 기능으로, 요청 처리 라이프 사이클의 다양한 단계에서 동작하는 기능을 추가할 수 있습니다. Apollo는 강력한 기능을 제공하지만, 다양한 기능을 처리하려면 서버의 기능을 확장해야 할 때가 많습니다. 이때 Apollo Plugins를 사용하면 간단하게 서버 기능을 확장할 수 있습니다.

Apollo Studio는 Apollo GraphQL에서 제공하는 클라우드 기반의 서비스로, GraphQL 스키마 관리, 모니터링, 버전 관리, 문서화 등의 기능을 제공합니다. Apollo Studio Plugin은 Apollo Studio와 Apollo Server를 연결하여, Apollo Studio에서 제공하는 다양한 기능을 Apollo Server에서 활용할 수 있게 해줍니다.

특히, Apollo Studio Plugin인 중 ApolloServerPluginLandingPageLocalDefault 는 Apollo Server의 기본 랜딩 페이지를 사용하며, 로컬 환경에서 스키마를 확인하고 쉽게 쿼리를 테스트할 수 있게 만들어줍니다. ApolloDriverConfig 객체에서 plugins 속성에 ApolloServerPluginLandingPageLocalDefault를 추가하면 됩니다. 아래는 예시 코드입니다.

위 처럼 설정하면 로컬 개발 환경에서 아래 사진과 같이 간편하게 스키마 확인 및 쿼리 테스트를 할 수 있습니다.

마치며

앞선 포스팅에서는 NestJS와 GraphQL의 도입 배경 및 현황에 대해 알아보았습니다. 그리고 이번 포스팅에서는 NestJS와 GraphQL의 기본 개념에 대해 살펴보았습니다.

저희 개발 팀은 NestJS의 의존성 주입 기능과 GraphQL의 유연성을 통해 코드 재사용성을 높이고, Apollo를 이용하여 문서화 및 API 테스트를 진행하고 있습니다. 또한 Typescript와 앞선 개발 도구를 함께 사용하여 코드 안정성과 가독성에서 원활한 개발환경을 구축했습니다.

앞으로 NestJS와 GraphQL을 활용하는 개발자들이 더 많아져서 커뮤니티가 더욱 활성화 되길 바라며, 다음에는 이번에 다루지 못한 더 많은 개념들을 자세하고 쉽게 포스팅 할 예정입니다. 끝까지 읽어주셔서 감사합니다.

--

--