HooneyLog
© 2026 Seunghoon Shin. All rights reserved.
모든 게시글
Backend
2026. 3. 30.•
1

NestJS vs Spring Boot: 스레드 모델과 I/O로 알아보는 아키텍처의 차이

Seunghoon Shin
작성자 Seunghoon Shin풀스택 개발자

NestJS vs Spring Boot: 스레드 모델과 I/O로 알아보는 아키텍처의 차이

1. 문제의 배경

백엔드 개발을 시작하거나 새로운 프로젝트 아키텍처를 구상할 때 가장 많이 마주하는 고민 중 하나는 "어떤 프레임워크를 선택할 것인가?" 입니다. 국내 엔터프라이즈 시장을 꽉 잡고 있는 Spring Boot(Java/Kotlin) 와, 프론트엔드 생태계의 강력한 지원을 받으며 빠르게 성장한 NestJS(Node.js/TypeScript) 는 언제나 비교의 대상이 됩니다.

"Spring은 무겁고, Node.js는 가볍다" 혹은 "Spring은 대규모에 적합하고, Node.js는 I/O 작업에 좋다"라는 식의 단편적인 지식은 실무에서 올바른 기술 스택을 선택하는 데 한계가 있습니다. 이러한 프레임워크들의 특징은 단순히 언어의 차이에서 오는 것이 아니라, 그 기반이 되는 런타임의 스레드 처리 방식과 I/O 모델에서 비롯됩니다.

이번 글에서는 주니어 개발자가 반드시 알아야 할 스레드(Thread)와 I/O(Input/Output)의 기초 개념부터, 이를 바탕으로 한 두 프레임워크의 내부 동작 원리까지 상세히 파헤쳐 보겠습니다.

2. 핵심 개념 이해: 스레드(Thread)와 I/O란?

두 프레임워크의 아키텍처를 이해하려면 먼저 운영체제의 핵심 개념을 짚고 넘어가야 합니다.

스레드 (Thread) 란 무엇인가?

스레드(Thread)는 프로세스(실행 중인 프로그램) 내에서 실제로 작업을 수행하는 가장 작은 실행 단위입니다. 식당에 비유하자면 프로세스는 '식당 건물 자체'이고, 스레드는 손님의 주문을 받고 서빙을 하는 '종업원'입니다.

  • 싱글 스레드(Single Thread): 종업원이 1명뿐인 식당입니다. 한 번에 한 가지 일만 순차적으로 처리합니다.
  • 멀티 스레드(Multi Thread): 종업원이 여러 명인 식당입니다. 여러 손님의 주문을 동시에 처리할 수 있어 효율적이지만, 종업원을 고용하고 관리(Context Switching)하는 비용이 발생합니다.

I/O (Input / Output) 와 Blocking vs Non-Blocking

I/O 작업은 데이터베이스 쿼리, 파일 읽기/쓰기, 외부 API 호출 등 CPU 연산이 아닌 외부 자원과의 통신을 의미합니다. 웹 서버의 작업 중 90% 이상이 이 I/O 작업에 해당합니다.

  • Blocking (블로킹): 스레드가 I/O 작업을 요청한 후, 결과가 반환될 때까지 아무것도 하지 않고 대기(Block) 상태에 머무는 방식입니다. 종업원이 주방에 요리를 주문하고, 요리가 나올 때까지 주방 앞에서 멍하니 기다리는 것과 같습니다.
  • Non-Blocking (논블로킹): 스레드가 I/O 작업을 요청한 후, 기다리지 않고 바로 다른 작업을 수행하러 가는 방식입니다. 요리가 완성되면 알림(Event/Callback)을 받고 다시 처리합니다. 진동벨을 주고 다른 테이블의 주문을 받는 종업원과 같습니다.

3. 핵심 아키텍처: Spring Boot vs NestJS

이제 위 개념을 바탕으로 두 프레임워크가 웹 요청을 어떻게 처리하는지 살펴보겠습니다.

Spring Boot: 멀티 스레드 기반 (Thread per Request 모델)

기본적인 Spring Boot(Tomcat 등 Servlet Container 기반)는 멀티 스레드와 동기/블로킹(Sync/Blocking) 모델을 사용합니다.

클라이언트의 요청이 들어오면, 미리 만들어둔 스레드 풀(Thread Pool)에서 스레드를 하나 꺼내어 해당 요청에 1:1로 할당합니다 (Thread per Request). 이 스레드는 요청 처리부터 DB 조회(I/O), 응답까지 전 과정을 책임집니다. DB 조회를 요청하면 해당 스레드는 데이터가 올 때까지 블로킹(대기) 됩니다.

  • 장점: 코드가 직관적이고 순차적이라 디버깅이 쉽습니다. 각 요청이 독립적인 스레드에서 돌아가므로 하나의 요청에서 에러가 나도 다른 요청에 영향을 주지 않습니다. CPU 연산이 많이 필요한 작업에 유리합니다.
  • 단점: 동시에 수만 개의 요청이 몰리면 스레드가 고갈되거나, 너무 많은 스레드가 생성되어 컨텍스트 스위칭(Context Switching) 비용과 메모리 사용량이 기하급수적으로 증가합니다. (이른바 C10K 문제)

(참고: Spring 생태계도 이러한 한계를 극복하기 위해 Non-Blocking 기반의 _Spring WebFlux_를 도입했고, 최근 Java 21에서는 가상 스레드(Virtual Thread)를 통해 혁신을 꾀하고 있습니다.)

NestJS (Node.js): 싱글 스레드와 이벤트 루프 (Non-Blocking I/O 모델)

NestJS는 Node.js 런타임 위에서 동작합니다. Node.js는 단 1개의 메인 스레드(Event Loop) 가 모든 요청을 처리하는 비동기 논블로킹(Async/Non-Blocking) 모델입니다.

수많은 요청이 들어와도 메인 스레드는 하나입니다. 대신, I/O 작업(DB 쿼리 등)을 만나면 이를 직접 기다리지 않고 백그라운드(운영체제 커널 또는 Worker Pool)에 던져버립니다. 그리고 메인 스레드는 즉시 다음 요청을 받으러 갑니다. I/O 작업이 끝나면 이벤트 큐(Event Queue) 에 콜백이 등록되고, 메인 스레드는 시간이 날 때 이를 꺼내어 응답을 반환합니다.

  • 장점: 스레드 생성 및 스위칭 오버헤드가 거의 없어 메모리 효율이 극강입니다. I/O 작업이 잦은 애플리케이션(채팅, 스트리밍, 단순 CRUD API)에서 엄청난 동시 접속을 가볍게 처리할 수 있습니다.
  • 단점: 메인 스레드가 단 하나이므로, I/O가 아닌 무거운 CPU 연산(이미지 처리, 복잡한 암호화 알고리즘 등) 을 메인 스레드에서 수행해버리면(Blocking the Event Loop), 그동안 모든 사용자의 요청 처리가 멈춰버리는 치명적인 문제가 발생합니다.

4. 구현 및 트러블슈팅: 구조적 유사성과 차이점

Node.js 진영에는 Express.js라는 절대 강자가 있었으나, 자유도가 너무 높아 대규모 프로젝트에서는 아키텍처가 파편화되는 문제가 있었습니다. NestJS는 이를 해결하기 위해 Spring의 철학과 아키텍처(DI, 데코레이터 등)를 Node.js 생태계에 적극적으로 도입한 프레임워크입니다.

따라서 코드를 보면 두 프레임워크는 놀랍도록 유사합니다.

NestJS (TypeScript)

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(); } }

Spring Boot (Java)

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로 넘어오거나 그 반대의 경우에도 학습 곡선이 상대적으로 낮습니다.

5. 결과 및 Trade-off: 무엇을 선택해야 할까?

기술 스택 선택에 절대적인 정답은 없습니다. 각 조직의 상황과 서비스의 특성에 맞는 Trade-off를 고려해야 합니다.

NestJS (Node.js) 를 선택하면 좋은 경우

  • I/O 집중적인 서비스: 실시간 채팅, 알림 서버, 마이크로서비스 간 API Gateway 등 대규모 동시 연결이 필요한 경우.
  • 프론트엔드/백엔드 언어 통일: 프론트엔드 팀이 React, Vue 등으로 구성되어 있고 TypeScript 숙련도가 높을 때. 팀 간 코드 리뷰와 맥락 공유가 매우 쉬워집니다.
  • 빠른 프로토타이핑과 스타트업 환경: 가볍고 빠르게 개발하여 시장의 반응을 확인해야 할 때.

Spring Boot (Java) 를 선택하면 좋은 경우

  • CPU 집중적인 복잡한 비즈니스 로직: 금융권의 복잡한 정산 시스템, 무거운 데이터 처리, 복잡한 트랜잭션 등 CPU 연산이 많이 요구되는 경우.
  • 방대한 레거시 인프라와 생태계: 수많은 엔터프라이즈 라이브러리, 안정적인 ORM(JPA/Hibernate), 확고하게 검증된 생태계가 필요한 경우.
  • 팀의 자바 숙련도: 이미 회사 내에 경험 많은 자바/스프링 시니어 개발자가 포진해 있는 경우.

6. 마치며

표면적으로 NestJS와 Spring Boot는 데코레이터(어노테이션)와 DI를 사용하는 매우 흡사한 모습을 띠고 있습니다. 하지만 그 이면에는 싱글 스레드 이벤트 루프(Node.js)와 멀티 스레드 풀(Java/Tomcat)이라는 운영체제 레벨의 근본적인 차이가 존재합니다.

스레드와 I/O 블로킹의 원리를 명확히 이해한다면, 무작정 유행하는 프레임워크를 좇는 것이 아니라 "우리의 비즈니스 문제는 CPU의 한계인가, I/O의 한계인가?" 를 질문하며 가장 적합한 도구를 선택하는 시니어 엔지니어로 성장할 수 있을 것입니다.

참고 자료

  • Node.js Event Loop Architecture
  • NestJS Official Documentation
  • Spring Web MVC vs Spring WebFlux
← 이전 글[RxJS] 비동기 데이터의 '수도관' 설계하기: 기초부터 실무 패턴까지