백엔드 개발을 시작하거나 새로운 프로젝트 아키텍처를 구상할 때 가장 많이 마주하는 고민 중 하나는 "어떤 프레임워크를 선택할 것인가?" 입니다. 국내 엔터프라이즈 시장을 꽉 잡고 있는 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의 한계인가?" 를 질문하며 가장 적합한 도구를 선택하는 시니어 엔지니어로 성장할 수 있을 것입니다.