백엔드 프레임워크를 하나 고를 때 저는 보통 "이 프레임워크가 강제하는 구조가 있는가"를 먼저 봅니다. Express처럼 얇은 라우팅 라이브러리는 자유도가 높은 대신, 프로젝트가 커질수록 계층과 의존 관계를 팀이 스스로 지켜야 합니다. NestJS는 이 구조를 프레임워크 차원에서 밀어붙이는 쪽이고, 저 역시 실제로 써보면서 이 강제성 덕분에 덜 헤맨 경험이 몇 번 있습니다.
이번 글에서는 NestJS의 ORM 연동이나 웹소켓 같은 구체적인 기능이 아니라, 모듈·컨트롤러·프로바이더·DI라는 뼈대 개념만 정리하는 글입니다. Spring Boot를 먼저 경험한 입장에서 어떤 지점이 닮았고 어떤 지점이 다른지도 함께 짚어봅니다.
1. NestJS, 어떤 프레임워크인가
NestJS는 Node.js 위에서 TypeScript 사용을 전제로 만들어진 서버(백엔드) 프레임워크입니다. 내부적으로는 Express(또는 Fastify)를 HTTP 계층으로 두고 있지만, 그 위에 애플리케이션을 모듈과 의존성 주입(DI)으로 조립하는 구조를 얹었다는 점이 Express를 직접 쓰는 것과 가장 큰 차이입니다.
그래서 NestJS는 "라우터만 있는 라이브러리"라기보다, 계층과 경계를 프레임워크가 어느 정도 강제하는 쪽에 가깝습니다. 라우팅 함수 하나만 있으면 되는 작은 스크립트에는 오히려 과할 수 있지만, 여러 명이 함께 작업하는 프로젝트에서는 이 강제성이 오히려 도움이 됩니다.
이 감각을 한 번 잡아두면, 이후에 컨트롤러나 서비스가 늘어나도 어디에 무엇을 둘지 크게 헤매지 않습니다.
2. Spring Boot와 비교해서 보는 NestJS
제가 대학시절 처음으로 접한 웹 프로젝트는 Express.js 였습니다. 이후 해당 프로젝트를 다시 개발하며 NestJS를 접했지만, 당시에는 SW 아키텍쳐에 대한 지식이 거의 없었습니다. 팀원과 함께 만들었지만 그 누구도 NestJS를 이해하고 있지 않으니 당연히 구조도 엉망이고, 겨우 원하는 동작만 하는 수준에 그칠 뿐이었습니다.
그 이후 멀티캠퍼스에서 Spring Boot를 접하며 체계적인 백엔드 앱의 구조를 배웠고, 다른 프로젝트를 진행하며 NestJS를 접했습니다. 예전과는 전혀 다른 지식베이스를 가진 상태에서 느낀 것은, NestJS는 Spring과 구조적으로 닮은 부분이 많다는 것입니다. 데코레이터(NestJS)와 어노테이션(Spring)으로 클래스의 역할을 표시하는 방식, 생성자 주입 기반의 DI/IoC 컨테이너, 컨트롤러·서비스로 나뉘는 계층 구조가 대표적입니다.
다만 이 둘이 같은 제품이거나 같은 생태계라는 뜻은 아닙니다. Nest는 "Spring이 가진 그 구조적인 축을 Node/TypeScript 생태계로 가져온 형태"에 가깝다고 이해하는 편이 정확합니다. 런타임 자체가 JVM과 Node로 다르고, 기본으로 딸려오는 스택이나 관례, 생태계 규모도 차이가 큽니다. 이 글에서는 두 프레임워크의 API를 1:1로 대응시키는 표까지는 다루지 않습니다.
이렇게 비교해두는 이유는 단순합니다. Spring Boot 경험이 있는 사람이 NestJS를 접했을 때 완전히 새로운 개념부터 배우는 게 아니라, 이미 아는 개념을 다른 문법으로 다시 만나는 감각으로 접근할 수 있기 때문입니다. 실제로 저 역시 그런 경로로 NestJS에 빠르게 적응할 수 있었습니다.
3. NestJS를 선택하는 이유
제가 NestJS를 선택한 이유는 트렌드를 따라간 것이 아니라, 몇 가지 구체적인 맥락에서 나온 판단입니다.
3.1. 주력 스택의 일관성
가장 먼저, 주력 스택이 TypeScript이기 때문입니다. 프론트엔드에서 Nuxt/Vue 등을 TypeScript로 작성한다면, 백엔드도 같은 언어와 타입 시스템으로 맞추고 싶다는 생각이 자연스럽게 따라옵니다. 타입이 앞단과 뒷단 사이에서 어긋나는 문제를 줄일 수 있다는 점이 큽니다.
예를 들어보겠습니다. 서버-클라이언트가 분리된 분리형 아키텍처(Decoupled Architecture)에서는 API 서버와 그 응답을 기반으로 동작하는 클라이언트 간의 드리프트를 방지하기 위해서는 데이터의 무결성과 Full-stack Type Safety이 중요합니다. 이를 위해 OpenAPI(Swagger), Protobuf(Go언어)와 같이 API 데이터 구조를 명세하거나 CI 파이프 라인을 통해 api 명세 목록을 기반으로 codegen을 실행하는 등 백엔드와 프론트엔드가 데이터 타입을 동일하게 유지하고자 노력합니다.
하지만 서버와 클라이언트가 같은 주력 스택을 쓴다면 문제가 줄어듭니다. 같은 언어이기 때문에, 하나의 파일에 타입을 정의해 이를 공유하면 되기 때문입니다. 당연히 통신 과정에서 드리프트도 줄어들고, zod와 같은 라이브러리를 사용하면 무결성을 유지하기도 쉬워집니다.
3.2. 예측 가능성
Express만으로 프로젝트가 커지면 생성자·의존성·경계가 점점 흐려지기 쉽습니다. 라우터를 어디에 두든, 서비스 로직을 어떻게 인스턴스화하든, 의존성을 어떤 방식으로 전달하든 전부 개발자의 선택입니다. 프로젝트 초기에는 개발 속도 측면에서 이점을 취할 수 있겠지만, 코드베이스가 커지고 참여 인원이 커지면 이야기가 달라집니다. 어떤 서비스가 어디서 생성되고 어디로 주입되는지, 어떤 모듈이 어떤 모듈을 참조할 수 있는지 같은 규칙이 컨벤션 문서에만 남게 됩니다.
NestJS는 모듈과 DI로 이 부분을 프레임워크 차원에서 예측 가능하게 만들어 줍니다. @Module에 등록되지 않은 provider는 애초에 주입될 수 없고, exports로 명시하지 않은 provider는 다른 모듈에서 접근할 수 없습니다. 이러한 위반 사항은 코드 리뷰에서 사람이 걸러내기 이전에, 앱의 부트스트랩 시점에 에러를 발생시키기 때문에, 사람이 기억하고 참조해야 할 규칙의 범위가 줄어듭니다.
3.3. 개발 용이성
마지막으로, 프론트엔드와 TypeScript를 공유하는 풀스택 또는 초기 모놀리스 설계와 잘 맞습니다. 특히 소규모 팀에서는 이 이점이 체감상 훨씬 크게 다가옵니다. DTO를 하나 정의하면 class-validator로 검증 규칙까지 클래스 내에 선언할 수 있고, 이를 @nestjs/swagger를 사용하면 OpenAPI 스펙의 문서까지 추출할 수 있습니다. 이는 3.1에서 언급하는 타입 공유와도 쉽게 이어집니다.
또한 Nest CLI의 스캐폴딩(nest generate module/controller/service)은 3.2에서 짚지 않은, 디렉토리 구조나 파일 명명 등의 "강제하지 않는 관례" 영역을 사실상 표준화해줍니다. 강제하지는 않지만, 팀원이 별도 문서 없이도 코드나 파일의 일관성을 유지하기 쉽다는 뜻입니다.
Express, Fastify, gRPC, GraphQL, WebSocket 등 다양한 통신 방식을 어댑터 패턴으로 갈아 끼울 수 있다는 점도 있습니다. 처음에는 REST 기반 모놀리스로 시작하더라도, 이후 특정 도메인만 마이크로서비스로 분리하거나 gRPC로 내부 통신을 바꾸는 등의 전환이 애플리케이션 구조(모듈, DI)를 유지한 채로 가능합니다. 초기 설계 단계에서 "나중에 구조를 크게 갈아엎어야 할 위험"을 줄여준다는 점에서, 확장을 고려한 첫 스택으로서의 개발 용이성이라고 볼 수 있습니다.
4. NestJS의 핵심 개념
NestJS를 처음 볼 때 가장 먼저 이해해야 하는 것은 아래 네 가지입니다. 네 개념은 서로 독립적이지 않고, 결국 하나의 흐름 — 모듈이 경계를 나누고, 그 경계 안에서 DI가 컨트롤러와 프로바이더를 엮어준다 — 으로 이어집니다.
4.1. Module — 기능 단위의 경계
Module은 단순히 "코드를 묶어두는 폴더" 개념이 아니라, NestJS 앱이 부트스트랩될 때 실제로 참조하는 의존성 그래프의 단위입니다. imports로 어떤 모듈을 가져다 쓸지, controllers/providers로 이 모듈이 소유한 컴포넌트가 무엇인지, exports로 이 중 무엇을 외부에 공개할지를 선언합니다.
앞서 3.2에서 짚었듯이, 같은 모듈 안에서 주입하려면 providers에, 다른 모듈에서 쓰려면 exports에 명시해야 합니다. 즉 “이 기능이 어디까지 남에게 열려 있는지”가 컨벤션이 아니라 코드로 강제됩니다.
AppModule은 이런 기능 모듈들을 imports로 모아 조립하는 최상위 모듈 역할을 합니다. 기능이 늘어날수록 이 구조는 "어디에 무엇이 있는지"를 찾기 쉽게 해주고, 반대로 모듈을 잘못 나누면 순환 참조(A가 B를 imports하고 B도 A를 imports하는 상황) 같은 문제가 부트스트랩 시점에 바로 드러납니다.
forwardRef()로 우회할 수 있지만, 이 글에서는 존재만 짚고 넘어갑니다.4.2. Controller — HTTP 요청의 진입점
Controller는 HTTP 요청이 들어오는 진입점입니다. 라우팅과 요청/응답 처리를 담당하고, 실제 도메인 로직은 컨트롤러가 아니라 서비스에 위임하는 것이 기본 원칙입니다.
컨트롤러는 usersService.findOne을 호출할 뿐, 조회 로직 자체를 들고 있지 않습니다. 이렇게 나눠두면 같은 서비스 로직을 REST 컨트롤러뿐 아니라 3.3에서 언급한 다른 어댑터(gRPC 핸들러 등)에서도 재사용할 수 있습니다.
4.3. Provider(Service) — 로직 단위
Provider는 @Injectable()로 표시되는 로직 단위입니다. 컨트롤러나 다른 서비스에 주입되어 실제 비즈니스 로직을 수행합니다. 코드는 현재 개발 중인 프로젝트 Cosider에서 발췌했습니다.
@Injectable()이 붙었다고 해서 자동으로 주입 대상이 되는 것은 아닙니다. 4.1에서 본 것처럼 해당 모듈의 providers 배열에 등록되어 있어야, 실제로 컨테이너가 인스턴스를 만들고 주입해줍니다.
4.4. DI(의존성 주입) — 컨트롤러와 프로바이더를 엮는 방식
NestJS는 생성자 주입을 기본으로 합니다. 4.2의 UsersController가 생성자 파라미터로 UsersService를 받는 것, 그리고 바로 위 4.3의 AuthService가 JwtService를 생성자로 받는 것 모두 같은 원리입니다. 생성자 파라미터로 타입만 선언하면, 개발자가 직접 new UsersService()나 new JwtService()를 호출할 필요가 없습니다. Nest가 부트스트랩 시점에 모듈 선언을 훑어 IoC 컨테이너를 구성하고, 어떤 클래스가 어떤 클래스를 필요로 하는지 생성자 타입을 보고 그래프를 만든 뒤 인스턴스를 대신 만들어 주입합니다.
기본 스코프는 싱글턴입니다. UsersService는 앱이 뜰 때 한 번 생성되어, 이를 필요로 하는 모든 컨트롤러·서비스가 같은 인스턴스를 공유합니다. 요청마다 새 인스턴스가 필요한 경우에는 request-scoped provider를 별도로 선언할 수 있지만, 기본값은 싱글턴이라는 점만 기억해두면 충분합니다.
이 구조가 실무에서 체감되는 지점은 테스트입니다. UsersController를 테스트할 때 실제 UsersService 대신 mock 객체를 주입하면 되므로, 컨트롤러와 서비스 구현이 강하게 결합되지 않습니다. 생성자로 무엇을 받는지만 보면 이 클래스가 무엇에 의존하는지 드러난다는 점도, 3.2에서 말한 "예측 가능성"이 DI 레벨에서 구체화된 형태입니다.
이 외에 Pipe, Guard, Interceptor처럼 요청 전후로 끼어드는 계층도 있지만, 이 글에서는 이름과 역할 정도만 언급합니다. 각각의 구체적인 사용법은 실제로 ORM이나 인증 로직을 붙이는 다음 글에서 다룰 예정입니다.
5. 결론
NestJS는 Node/TypeScript 생태계에서 모듈과 DI로 구조를 잡아주는 프레임워크입니다. 모듈이 경계를 정하고, 컨트롤러와 프로바이더가 그 경계 안에서 역할을 나누고, DI가 이 둘을 엮어준다는 흐름만 기억해두면, 이후 어떤 기능이 추가돼도 어디에 무엇을 둘지 헤매지 않습니다. Spring Boot와 닮은 구석이 많아 그 경험이 있다면 진입 장벽이 낮고, TypeScript를 주력으로 쓰는 프로젝트의 백엔드로 자연스럽게 이어붙일 수 있습니다. 그래서 저도 새 프로젝트의 백엔드를 정할 때 이 프레임워크를 자연스럽게 먼저 떠올리게 됩니다.
다음 글에서 ORM, UUID 기반 식별자, 웹소켓 같은 구체적인 기능으로 넘어가기 전에, 지금은 "NestJS는 모듈과 DI로 경계를 나누는 Node/TS 서버 프레임워크"라는 지도 하나만 머릿속에 잡아두면 충분합니다.