메모리 한계 극복과 섀도우 검증 기반의 백엔드 Spring Boot에서 NestJS로 이관하기

궁금하면 500원·2026년 8월 17일

1.8GB 메모리 한계 개선한 Spring Boot에서 NestJS로의 이관하기

블로그 백엔드는 본래 Java 25, Spring Boot, JPA, Flyway 조합으로 운용되고 있었습니다.
서비스 실행 자체에는 문제가 없었으나, 시스템이 동작하는 AWS EC2 인스턴스의 메모리가 1.8GB에 불과하다는 점이 구조적인 장애 요소였습니다.

단일 JVM 프로세스가 약 600MB의 메모리를 점유하면서 free -m 명령어로 확인한 가용 메모리가 82MB까지 하락하는 현상이 자주 발생했습니다.

프론트엔드가 이미 Next.js와 TypeScript 기반으로 구성되어 있었기에, 백엔드 역시 Node.js 환경으로 이관하여 언어 생태계를 통일하고 메모리 사용량을 절감하기로 결정했습니다.

이관 작업을 위한 사전 조사 결과, 전환 대상 코드의 규모는 다음과 같았습니다.

  • Java 프로덕션 코드: 약 50,000줄
  • 컨트롤러: 30개 엔드포인트 139개
  • JPA 엔티티: 30개
  • Flyway 마이그레이션 Script: 14개

개인이 운영하는 서비스 측면에서 단순 포팅을 넘어서는 정밀한 검증 전략이 필수적인 상황이었습니다.


영속성 컨텍스트의 Dirty Checking 가장 위험했던 지점

마이그레이션 설계 단계에서 가장 유의해야 했던 부분은 Spring Data JPA의 변경 감지 로직이었습니다.

@Transactional
public PostDetailResponse publishPost(Long id) {
    Post post = postRepository.findById(id).orElseThrow(...);
    post.publish(); // 상태 변경만 수행
    return PostDetailResponse.from(post);
} // save() 호출 없이 메서드 종료 시점에 자동 UPDATE 쿼리 실행

명시적인 save() 호출 없이도 트랜잭션 종료 시점에 Hibernate가 객체의 변경 사항을 감지하여 DB에 UPDATE 쿼리를 전송하는 구문이 코드베이스 내에 총 30곳 존재했습니다.

NestJS/TypeORM 환경으로 전환할 때 이러한 로직을 누락하면 런타임 에러가 발생하지 않고 HTTP 200 OK 응답이 반환됩니다.

화면상으로는 정상 처리된 것으로 보이지만 실제 DB 반영은 실패하는 '정적 오류' 현상이 발생하게 됩니다.


무결성 검증 체계 구축

단순 단위 테스트만으로는 이러한 잠재적 오류를 완벽히 포착하기 어렵다고 판단하여, 마이그레이션 전 두 가지 검증 전략을 수립했습니다.

1. 골든 파일 기반 입출력 검증

슬러그 생성, 마크다운 파싱, 읽기 시간 계산 등 상태를 가지지 않는 순수 함수들의 검증을 위해 기존 Java 애플리케이션에서 입출력 데이터 쌍 3,289건을 추출하여 JSON 형식의 '골든 파일'을 생성했습니다.

이후 TypeScript로 재작성된 동일 로직에 해당 데이터를 주입하여 바이트 단위로 출력을 비교했습니다.

이 과정을 통해 다음과 같은 언어 간 세부 동작 차이를 사전에 파악하여 수정할 수 있었습니다.

  • Java와 JavaScript 간 정규표현식 \s 특수문자 매칭 범위 차이
  • 서로그레이트 페어 이모지 문자열 처리 시 인덱스 단절 문제

2. 섀도우 Diff 환경 구축

서버 단에서 실제 DB 상태 변화까지 동시 검증하기 위해 두 백엔드를 나란히 실행하는 섀도우 환경을 구축했습니다.

               동일한 HTTP 요청 전송
                     ↓               ↓
         Spring Boot (:8080,8084,8085)     NestJS (:8081,8082,8083)
                     ↓               ↓
            MySQL (codex_a)     MySQL (codex_b)
            (운영 DB 복제본 A)   (운영 DB 복제본 B)

운영 DB를 복제 및 익명화한 독립된 두 개의 DB 스키마를 구성한 뒤, 동일한 요청을 두 백엔드에 동시 전송하여 응답 데이터와 최종 DB 데이터 상태를 종합 비교했습니다.

읽기 요청 70건, 쓰기 요청 26건으로 구성된 검증 코퍼스를 통해 응답값뿐만 아니라 트랜잭션 종료 후 DB의 상태까지 대조함으로써 Dirty Checking 누락 여부를 완벽히 판별할 수 있었습니다.


타임존 변환 버그의 발견과 추적

섀도우 Diff 초기 실행 시 58건의 테스트 전체에서 데이터 불일치가 발생했습니다.
대부분의 원인은 단일 문제에서 기인한 것이었으며, 가장 핵심적인 원인은 9시간의 타임존 오차였습니다.

DB 저장 데이터:        2026-08-14 08:59:25
기존 Spring 응답:      2026-08-13 23:59:25 (UTC 기준 변환 / -9시간)
신규 NestJS 응답:      2026-08-13 08:59:25 (DB 원본 값 그대로 출력)

원인 분석 결과, 기존 환경의 JDBC URL에는 serverTimezone=Asia/Seoul 옵션이 지정되어 있었으나 Docker 컨테이너 자체 타임존 설정이 누락되어 JVM은 UTC 기준으로 동작하고 있었습니다.
이 과정에서 MySQL 드라이버가 시간을 서울 기준에서 UTC로 이중 변환하고 있었던 것입니다.

즉, 기존 서비스가 DB 저장값보다 9시간 이전의 시간을 응답하고 있었고, NestJS는 DB 값을 정직하게 반환하고 있었습니다.
이를 수정 없이 배포했을 경우 기존 게시글 작성 시각 점프, RSS 발행일 오작동 등 심각한 데이터 왜곡이 발생할 수 있는 상황이었습니다.

단위 테스트 234개가 모두 통과했음에도 불구하고, 실제 레거시 시스템과의 비교 검증을 통해서만 추출할 수 있었던 결함이었습니다.
이 외에도 다음과 같은 문제점들을 추가로 포착하여 수정했습니다.

  • CUD 작업 간 읽기/쓰기 타임존 적용 비대칭 문제
  • 검색 API 단에서의 타임존 미적용 현상 레거시 버그 동의성 보장 후 배포 이후 수정으로 분류
  • Express 프레임워크 자동 추가 헤더로 인한 304 응답 규격 이탈 문제
  • 헬스체크 응답 스펙 불일치 및 예외 메시지 문구 오류

보완 작업을 거친 후 재검증을 진행한 결과, 불일치 건수는 96건 중 4건 1ms 단위의 단순 시계 오차 으로 감소하였으며 23곳의 Dirty Checking 로직 역시 정상 작동함을 확인했습니다.


문서화의 한계와 실측의 필요성

이번 마이그레이션 과정을 통해 명세 문서와 실제 인프라 상태 간의 격차를 명확히 확인했습니다. 6,000줄 분량의 마이그레이션 계획서를 작성했으나, 실제 운영 환경을 대조한 결과 다음과 같은 불일치가 존재했습니다.

  • 마이그레이션 스크립트 버전: 문서상 V26 명시 -> 실제 V27 적용 중
  • 외부 메일 발송 서비스: 문서상 Resend 명시 -> 실제 Brevo 사용 중
  • 소셜 인증 토큰: 문서상 암호화 저장 명시 -> 실제 DB 설정 테이블 내 평문 저장 중
  • 타임존 영향도: 문서상 "영향 없음"으로 단정 -> 실제 9시간 오차 발생

추론에 기반한 문서 기록보다 실제 서버의 설정값 및 쿼리 실행 결과를 직접 확인하는 실측 과정이 선행되어야 함을 배울 수 있었습니다.


배포 리허설 및 권한 모듈 교정

본 배포 전 롤백 절차를 검증하기 위해 스테이징 성격의 리허설을 진행했습니다.
이 과정에서 배포 스크립트 상의 두 가지 권한 결함을 발견하여 교정했습니다.

  1. 환경변수 파일 권한 관리: 서버 내 API Key 및 비밀번호가 포함된 .env 파일 권한이 666으로 설정되어 있어 이를 제한 조치했습니다.

  2. Sudo 권한 승격 시 ECR 접근 권한 상실: 파일 권한 수정 후 마이그레이션 스크립트 실행을 위해 sudo를 부여하자, 기존 사용자 계정에 바인딩되어 있던 AWS ECR 접근 권한이 서버 기본 IAM Role로 격하되면서 권한 오류가 발생했습니다.

리허설을 통해 스크립트를 수정하였으며, 최종 전환 소요 시간 18초, 롤백 소요 시간 26초라는 가용성 지표를 확보한 상태에서 안전하게 본 배포를 진행할 수 있었습니다.


최종 결과 및 자원 절감 지표

전환 작업은 18초 만에 완료되었으며, 신규 NestJS 컨테이너는 첫 헬스체크를 정상 통과했습니다. 전환 전후의 주요 인프라 지표 비교는 다음과 같습니다.

구분전환 전 (Spring Boot)전환 후 (NestJS)개선 수치
Docker 이미지 용량947 MB723 MB약 23.6% 감소
컨테이너 점유 메모리595 MB220 MB약 63% 절감
서버 가용 메모리396 MB1,146 MB약 189% 증가
컷오버 전 작성일 응답 데이터: 2026-08-13T23:59:25
컷오버 후 작성일 응답 데이터: 2026-08-13T23:59:25

서버 가용 메모리가 1.1GB 이상 확보되었으며, 데이터의 일관성 또한 완전하게 유지됨을 확인했습니다.


회고 및 느낀 점

이번 마이그레이션 과정을 통해 얻은 가장 큰 깨달음은 '코드를 작성하기 전에 검증 수단부터 확보해야 한다'는 점이었습니다.

단위 테스트가 통과하는 것을 보고 작업이 거의 끝났다고 생각했으나, 진짜 문제들은 그 이후에 드러났습니다.
단위 테스트는 어디까지나 '내가 예상한 시나리오' 내에서의 정상 동작만 확인해 줄 뿐, 타임존 왜곡이나 프레임워크 자동 헤더 같은 '예상치 못한 변수'까지 감지해 주지는 못한다는 사실을 깊이 실감했습니다.

레거시 시스템이 살아 있을 때 그 곁에 신규 시스템을 배치하고 동일한 요청을 보내 대조하는 섀도우 검증 방식만이, 제가 인지하지 못했던 허점들을 잡아내는 유일한 방법이었습니다.

앞으로 시스템을 개편하거나 대규모 이관 작업을 진행할 때는 이번 경험을 이정표로 삼고자 합니다. 명세서나 확신에 의존하기보다 실측 데이터를 우선시하고, 구 시스템을 종료하기 전에 원본 데이터에서 정답지를 먼저 확보하는 검증 절차를 표준화하여 시스템 안정성을 확실하게 담보하겠습니다.

profile
레거시를 이해하면서도 새로운 기술을 현실적으로 적용할 수 있는 백엔드 개발자가 되는 것이 목표입니다.

0개의 댓글