구름위키
Spring Boot에서 NestJS로, 둘이 무슨 차이가 있길래? 본문
최근 개발에 관련된 글을 보다 흥미로운 글을 하나 발견했다.
https://codex.epril.com/migrating-spring-boot-to-nestjs/
9시간이 사라질 뻔했다 — Spring Boot 백엔드를 NestJS로 옮긴 이야기
메모리 절약과 언어 통일을 위해 Spring Boot에서 NestJS로 마이그레이션한 경험
codex.epril.com
바로 Spring Boot로 만들어진 서버를 NestJS로 마이그레이션 이야기였다.
이 개발자는 블로그를 Spring Boot로 직접 만들어서 운영하고 있었다. 기능은 잘 돌아갔지만 문제는 기능이 아니었다고 한다.
이 서비스가 돌고 있는 서버의 메모리는 1.8GB로 작은 EC2 인스턴스 하나인데 JVM 하나가 500MB 가까이 쓰고 있다는 것을 발견했다고 한다. 종종 메모리를 확인해보면 남은 메모리가 82MB가 나오는 등 굉장히 불안한 상태로 운영되고 있었다고 한다.
Spring Boot가 국내에서 가장 대중적이고 일반적인 웹 백엔드 프레임워크로 사용되고 있다. 생태계도 넓고 Java라는 언어만 알면 어렵지 않게 사용할 수 있다는 점에서 인기를 끌고 있지만 앞서 문제로 제시했던 것처럼 JVM이 굉장히 많은 메모리를 차지하고, 또 콜드 스타트 타임이 길다는 점에서 명확한 단점을 보여준다.
초기에 공부할 때는 별로 문제가 되지 않는다 느꼈지만 성능에 대해 고민하고, 또 서버에 배포하는 일이 잦아지며 이 제한된 자원을 효율적으로 사용하는 방법을 고민해보니 이 문제가 얼마나 크게 와닿는지 체감할 수 있었다.
실제로 시장을 보면 요즘 Spring Boot 외에 NestJS나 FastAPI, Django와 같은 프레임워크로 웹 개발을 하는 기업도 많이 볼 수 있다. 서버 개발자로 나아가는 입장에서, 더 이상 Spring Boot에 묶여 있을 것이 아니라 전반적인 프레임워크에 대해 익숙해지고 경험해볼 필요를 느꼈다.
Spring Boot에서 NestJS로
위 글의 작성자는 프론트엔드를 Next.js와 TypeScript로 작성해두었기 때문에 백엔드도 Node로 옮기겠다는 결정을 했다. 언어가 하나로 통일되는 것은 물론 NestJS로 옮기면 메모리도 줄어든다는 장점을 살릴 수 있기 때문이다.
이 부분에 대해서는 추가로 조사를 해보았다. 메모리 절대량을 따지고 보면 Spring Boot보다 NestJS가 유리하지만 처리량 대비 효율이 두 프레임워크는 다르다고 한다. 부하가 올라가게 되면 JVM의 JIT와 멀티스레드가 CPU 바운드 작업에서 NestJS의 방법보다 앞선다고 한다. 그렇기에 같은 RPS를 처리하는 데 필요한 인스턴스 수로 환산을 하게 되면 격차가 줄어들거나 역전되기도 한다고 한다.
Spring Boot의 편의성에서 오는 차이점
Spring Boot가 DB와 통신하는 과정에는 Hibernate가 있다. 그렇기에 코드 내에서 어떤 객체를 바꾸고 우리가 별도로 save()를 해주지 않아도 @Transactional 어노테이션에 의해 영속성 컨텍스트 내에 저장되고 원본이 달라졌음을 인식해 자동으로 DB에 UPDATE를 날려준다. 즉, Dirty Checking을 해준다는 것이다.
그렇기에 Spring Boot에서 코드를 짤 때는 Hibernate를 믿고 업데이트 정보를 따로 save() 해주지 않아도 되었다. 하지만 NestJS에서 주로 사용하는 TypeORM과 같은 경우에는 업데이트를 하면 별도로 save()를 해주어야 한다.
코드가 그대로 넘어갈 경우, 메모리의 객체는 업데이트되고 사용자의 요청에 응답되지만 실제 DB에는 적용되지 않는다. 즉, 수정과 같은 작업이 일어나도 그 순간은 변경되지만 새로고침하면 원래대로 돌아오는 문제가 발생한다.
그리고 제일 치명적인 것은 "조용히 실패한다"는 것이라고 말하고 있다. 로직상 전혀 문제가 없다. 우리의 목적과는 다르지만 코드 자체는 문제가 없기 때문에 그대로 처리하고 실패가 뜨지 않는다. 이러면 이후 다시 문제를 발견했을 때 원인 추적에 어려움을 겪을 수 있다.
마이그레이션 이전에 검증부터 해보자
글 작성자는 코드를 짜기 전에 이게 어떻게 틀렸다는 걸 알아낼 것인지 부터 정했다. 이를 위해 아래 2가지를 준비했다.
| 1. 골든 파일 - Java가 살아 있을 때만 뽑을 수 있는 것
코드에 보면 순수 함수들이 있다. DB, 네트워크와 같은 것이 전혀 필요 없는 순수한 Java의 함수들. 작성자는 Java 쪽에 덤퍼를 하나 만들어 1,289건의 함수 쌍을 JSON으로 뽑았다. 그리고 TypeScript로 옮긴 함수에 같은 입력을 넣어 바이트 단위로 비교해보았다고 한다.
이런 순수 함수들은 Java 내에서만 존재하고 작동하고 있기 때문에 다른 정답지가 없다. 즉, Java가 살아있는 동안에만 정답을 확인할 수 있는 함수들이다. 그렇기 때문에 마이그레이션 단계에서 Java 애플리케이션을 지우기 전에 해야 하는 작업이었다.
이 과정을 통해 몇 가지 에러를 잡을 수 있었다고 한다.
- Java의 정규식 \s와 JavaScript의 \s가 매칭하는 문자 집합이 다르다는 점
- 문자열 자르기가 이모지를 반쪽만 남긴다는 점
굉장히 사소하고 별거 아닌 에러이지만 모르고 지나갔을 때, 슬러그가 이상하게 생성되기도 하는 문제를 일으킬 수 있다. 언어의 처리 방식에 따른 차이를 정답지(기존 애플리케이션)을 통해 비교할 수 있을 때 비교함으로써 추후 문제가 발생할 것을 미리 대비하고 사전에 잡을 수 있는 오류를 잡을 수 있었다.
| 2. Shadow Diff - 두 백엔드를 나란히 세우기
실제 결과를 비교하기 위해 운영 DB를 복제해 Spring Boot 애플리케이션과 NestJS 애플리케이션에 연결했고, 이 상태에서 같은 요청을 보내며 테스트했다. 단순히 응답만 비교할 것이 아닌 DB를 확실히 분리하고 각 맞물린 DB의 상태도 확인하며 실제 데이터의 CRUD 정상 작동 여부를 확인했다.
작성자는 첫 실행 결과로 얻은 값의 불일치율이 100%였다고 한다. 대부분 서로 유사한 원인에서 나왔으나 그 중 가장 치명적이던 부분은 시간과 관련된 부분이었다고 한다.
실제 DB에 저장된 값은 정상적이었으나 Spring Boot( 기존에 사용 중이던 애플리케이션 )은 9시간 전 값을 보여주고 있었다고 한다. 새로 만든 서비스가 아니라 원래 운영 중이던 서비스에서 잘못된 시간을 응답해주었다고 한다. 원인은 아래와 같았다고 한다.
작성자는 JDBC 접속 URL에 severTimeZone=Asia/Seoul을 붙였는데, 애플리케이션을 돌리는 Docker 컨테이너에서는 타임존 설정이 없어 UTC로 돌고 있었다고 한다. 즉, Spring Boot 서비스는 UTC로 작동 중이고, MySQL 데이터베이스는 KST로 작동 중이라는 것이다. 그래서 MySQL 드라이버가 이 사이에서 MySQL에 저장된 KST를 Spirng Boot로 보낼 때 UTC로 변환하고 있었고, Spring Boot 내에서는 UTC로 계산이 이루어지고 있었다는 것이 드러났다.
즉, 지금 Spring Boot 서비스는 9시간이 밀린 상태로 데이터를 처리하는 구조로 되어 있다는 것이다.
근데 이 처리를 보고 의문이 들었다. Spring Boot에서 UTC로 처리하고 있었으면 분명 일부 기능에는 문제가 있었을 것이다. DB를 타지 않는 작업들은 분명 엉뚱한 시간을 보여주었을 것이고, Spring Boot에서 LocalDateTime.now()와 같은 현재 시간을 가져오는 코드를 썼다면 불일치가 발생했을 것이다. 그런데 어떻게 지금까지 이렇게 사용할 수 있었을까? 이에 대한 이야기가 뒤에 나온다.
우선, 조회 요청에는 UTC를 KST로 변환하는데, 작성 요청에는 변환하지 않고 그대로 사용했다고 한다. 그래서 새로 쓴 글은 그 순간 9시간 전으로 돌아가 표시되었고 이게 이후에 MySQL 드라이버에 의해 KST로 변환되어 저장되어 있던 것. 또한, Spring Boot의 시간 기준이 달랐기 때문에 검색 필터링에서도 오작동하고 있었다고 한다. 지금까지 발견하지 못하고 있던 버그였던 것이다.
근데 NestJS도, Spring Boot도 모두 독립적으로 테스트를 돌렸을 때는 문제가 없었다고 한다. 그 이유는 바로 환경이었다. Spring Boot 테스트 또한 JVM 위에서 돌아가는데, JVM 자체가 UTC로 돌아갔기 때문에 코드 내에서 시간 불일치는 발생하지 않는다. 그렇기에 모든 테스트가 성공하게 된 것이고, NestJS 또한 작동하는 환경 자체의 시간과 DB에서의 시간이 일치했기 때문에 테스트가 성공한 것이다. 즉, 이 문제는 Shadow Diff를 만들어서 검사해야만 알 수 있던 것이다.
문서를 믿지 말 것
작성자는 이 마이그레이션 작업을 위해 문서를 열심히 작성했다고 한다. 하지만 맋아 운영 서버에서 하나씩 측정해보니 오류가 계속해서 발견되었다고 한다.
직접 썼더라도, AI와 함께 썼더라도 알고 있거나 당장 확인한 사실을 문서에 기입했을 때, 이를 "참고"할 수 있지만 이 문석 "100% 맞다"라고 말할 수는 없다. 휴먼 에러가 있을 수도 있으며 AI는 잘못 이해했을 수도 있기 때문이다.
이 말에는 나도 굉장히 동의했다. 특히 AI를 적극적으로 사용하는 지금, 문서는 깨끗하게 정리해주지만 항상 맞다고 믿을 수 없다. 실제로 내가 만들었던 서비스들에 대해 분석이나 정리를 부탁했을 때, AI가 놓치거나 잘못 말하는 경우가 종종 있었다. 더 나아가, 협업을 할 때도 팀원이 작성한 문서에 오류가 발생될 때도 있었으며, 아래 글과 같이 API 명세만 믿고 만들었는데 최신화가 되어 있지 않아서 문제가 되었던 적도 있다.
https://cloud-wiki.tistory.com/32
[협업] API 요청 시 Figma는 백엔드 개발자에게 있어 필수? 참고?
일반적으로 하나의 서비스를 개발하기 위해 프로젝트가 진행되면 아래 포지션의 사람들이 함께 협업을 진행하게 된다.기획자, PM, 디자이너, Front-End 개발자, 그리고 Back-End 개발자. 규모에 따라
cloud-wiki.tistory.com
작성자의 결과와 주는 교훈
이후 작성자는 서버를 구동해 목표였던 메모리 비교를 해보았다고 한다.
Docker 이미지의 크기는 947MB에서 723MB로, 컨테이너 메모리는 595MB에서 220MB로, 그리고 제일 중요한 서버의 여유 메모리는 396MB에서 1,146MB로 눈에 띄는 개선을 이루었다고 한다. 여유 메모리가 82MB까지 떨어지던 기존 Spring Boot에서 1.1GB까지 늘어났다는 극적인 개선을 이루었다.
마이그레이션을 겪으며 작성자는 이런 조언을 해주고 있다.
- 구 시스템이 살아 있을 때, 그 옆에 새 시스템을 세워두고 같은 질문을 여러 번 던져볼 것.
- 테스트는 "내가 예상한 것"이 맞는지 확인해주지 "내가 예상하지 못한 것"은 확인해주지 않는다.
구 시스템이 정답지인 상황에, 마이그레이션 과정에서는 반드시 두 시스템을 비교하며 확인하는 작업이 필수라고 한다. 기존 서비스가 사라지게 되면 더 이상 정답을 확인할 방법이 없어지기 때문이다. 또한, 테스트 결과는 믿어도 되지만 그 테스트 시나리오를 작성한 본인은 믿으면 안된다는 것도 배울 수 있었다. 우선, 테스트 시나리오를 정말 꼼꼼하게 작성한 것이 아니면 그 서비스의 신뢰성을 100% 대변할 수 없다고 생각한다. 주요 기능에 대해서는 증명할 수 있어도 세부적인 것까지는 증명할 수 없을 때가 존재한다고 생각한다.
NestJS를 공부할 계획을 세우며 앞으로 기존의 운영중인 홈페이지를 NestJS로 마이그레이션할 수도 있다는 생각을 했다. 그 때 반드시 이 글을 참고해 실수 없이 성공적인 마이그레이션을 이룰 수 있도록 도전해보아야겠다.
'프로그래밍' 카테고리의 다른 글
| [MSA] Kubernetes vs. Spring Cloud + Netflix Eureka (0) | 2026.02.03 |
|---|---|
| [Architecture Design] Facade 패턴 (0) | 2025.12.26 |
| Framework vs. Library (0) | 2025.06.30 |