
백엔드 개발을 시작하거나 새 프로젝트의 아키텍처를 구상할 때 가장 자주 마주하는 고민은 "어떤 프레임워크를 선택할 것인가"입니다. 국내 엔터프라이즈 시장을 꽉 잡고 있는 Spring Boot(Java/Kotlin)와, 프론트엔드 생태계의 강력한 지원을 받으며 빠르게 성장한 NestJS(Node.js/TypeScript)는 늘 비교 대상이 됩니다.
"Spring은 무겁고 Node.js는 가볍다", "Spring은 대규모에 적합하고 Node.js는 I/O 작업에 좋다"는 식의 단편적인 지식만으로는 실무에서 올바른 기술 스택을 고르기 어렵습니다. 두 프레임워크의 특징은 단순히 언어의 차이에서 오는 것이 아니라, 그 기반이 되는 런타임의 스레드 처리 방식과 I/O 모델에서 비롯됩니다.
이번 글에서는 스레드(Thread)와 I/O(Input/Output)의 기초 개념부터, 이를 바탕으로 한 두 프레임워크의 내부 동작 원리까지 정리합니다.
두 프레임워크의 아키텍처를 이해하려면 먼저 운영체제의 핵심 개념부터 짚어야 합니다.
스레드(Thread)는 프로세스(실행 중인 프로그램) 안에서 실제로 작업을 수행하는 가장 작은 실행 단위입니다. 식당에 비유하면 프로세스는 '식당 건물 자체'이고, 스레드는 손님의 주문을 받고 서빙하는 '종업원'입니다.
I/O 작업은 데이터베이스 쿼리, 파일 읽기/쓰기, 외부 API 호출 등 CPU 연산이 아닌 외부 자원과의 통신을 뜻합니다. 웹 서버 작업의 90% 이상이 이 I/O 작업에 해당합니다.
이제 위 개념을 바탕으로 두 프레임워크가 웹 요청을 어떻게 처리하는지 살펴봅니다.
기본적인 Spring Boot(Tomcat 등 Servlet Container 기반)는 멀티 스레드와 동기/블로킹(Sync/Blocking) 모델을 사용합니다.
클라이언트의 요청이 들어오면, 미리 만들어둔 스레드 풀(Thread Pool)에서 스레드를 하나 꺼내 해당 요청에 1:1로 할당합니다(Thread per Request). 이 스레드는 요청 처리부터 DB 조회(I/O), 응답까지 전 과정을 책임집니다. DB 조회를 요청하면 해당 스레드는 데이터가 올 때까지 블로킹(대기)됩니다.
참고로 Spring 생태계도 이러한 한계를 극복하기 위해 Non-Blocking 기반의 Spring WebFlux를 도입했고, 최근 Java 21에서는 가상 스레드(Virtual Thread)를 통해 혁신을 꾀하고 있습니다.
NestJS는 Node.js 런타임 위에서 동작합니다. Node.js는 단 1개의 메인 스레드(Event Loop)가 모든 요청을 처리하는 비동기 논블로킹(Async/Non-Blocking) 모델입니다.
수많은 요청이 들어와도 메인 스레드는 하나입니다. 대신 I/O 작업(DB 쿼리 등)을 만나면 이를 직접 기다리지 않고 백그라운드(운영체제 커널 또는 Worker Pool)로 넘깁니다. 그리고 메인 스레드는 즉시 다음 요청을 받으러 갑니다. I/O 작업이 끝나면 이벤트 큐(Event Queue)에 콜백이 등록되고, 메인 스레드는 시간이 날 때 이를 꺼내 응답을 반환합니다.
Node.js 진영에는 Express.js라는 강자가 있었으나, 자유도가 너무 높아 대규모 프로젝트에서는 아키텍처가 파편화되는 문제가 있었습니다. NestJS는 이를 해결하기 위해 Spring의 철학과 아키텍처(DI, 데코레이터 등)를 Node.js 생태계에 적극적으로 도입한 프레임워크입니다.
그래서 코드를 보면 두 프레임워크는 놀랍도록 비슷합니다.
import { Controller, Get, Injectable } from '@nestjs/common'; @Injectable() // 의존성 주입(DI)을 위한 데코레이터 export class AppService { async getHello(): Promise<string> { // Non-Blocking I/O 호출 (await) const data = await this.database.find(); return 'Hello World!'; } } @Controller('users') // 라우팅 데코레이터 export class AppController { constructor(private readonly appService: AppService) {} @Get() async getHello(): Promise<string> { return this.appService.getHello(); } }
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.stereotype.Service; @Service // 의존성 주입(DI)을 위한 어노테이션 public class AppService { public String getHello() { // Blocking I/O 호출 var data = database.find(); return "Hello World!"; } } @RestController // 라우팅 어노테이션 @RequestMapping("/users") public class AppController { private final AppService appService; public AppController(AppService appService) { this.appService = appService; } @GetMapping public String getHello() { return appService.getHello(); } }
두 프레임워크 모두 객체 지향적인 모듈 아키텍처와 제어의 역전(IoC) 컨테이너를 통한 의존성 주입(DI)을 지원합니다. 덕분에 Spring 개발자가 NestJS로 넘어오거나 그 반대의 경우에도 학습 곡선이 상대적으로 낮습니다.
기술 스택 선택에 절대적인 정답은 없습니다. 각 조직의 상황과 서비스 특성에 맞는 Trade-off를 고려해야 합니다.
표면적으로 NestJS와 Spring Boot는 데코레이터(어노테이션)와 DI를 사용하는 매우 비슷한 모습을 띱니다. 하지만 그 이면에는 싱글 스레드 이벤트 루프(Node.js)와 멀티 스레드 풀(Java/Tomcat)이라는 운영체제 레벨의 근본적인 차이가 있습니다.
스레드와 I/O 블로킹의 원리를 명확히 이해하면, 유행하는 프레임워크를 무작정 좇는 대신 "우리의 비즈니스 문제는 CPU의 한계인가, I/O의 한계인가?"를 질문하며 가장 적합한 도구를 고를 수 있습니다.