특정 유저에게만 발생한 메모리 누수 문제를 해결하며 겪은 삽질기. 간단해 보였던 포트폴리오 업데이트 기능에 숨어있던 데이터 중복 생성 버그와 그 해결 과정을 공유한다. 나와 같은 실수를 반복하지 않기를 바란다.
🕵️♂️ 사건의 시작
프로덕션 환경에서 특정 유저에게만 JavaScript heap out of memory 오류가 발생하며 서버가 죽는다는 보고를 받았다. 처음에는 단순한 리소스 문제인가 싶었지만, 유독 한 명의 유저에게서만 문제가 발생한다는 점이 이상했다. 로컬 환경에서 해당 유저의 데이터로 테스트를 진행하니 동일한 문제가 발생했다. 서버는 4GB에 육박하는 메모리를 사용하다가 18분 만에 장렬히 전사했다.
🔍 원인 분석, 데이터의 역습
문제의 원인은 뜻밖에 데이터베이스에 있었다. 특정 유저의 language_skill과 certification 테이블에 비정상적으로 많은 중복 데이터가 쌓여 있었던 것이다. 일반 유저는 테이블당 2~4개의 데이터를 가지고 있었지만, 문제의 유저는 무려 256개의 레코드를 가지고 있었다.
왜 이런 일이 벌어졌을까? 코드를 파헤쳐 보니 원인은 프론트엔드와 백엔드의 환장할 콜라보에 있었다.
1. 프론트엔드의 순진한 요청
포트폴리오 생성 플로우는 여러 페이지에 걸쳐 진행된다.
profile.tsx → education.tsx → career.tsx → experience.tsx
문제는 각 페이지를 이동할 때마다 onUpdate 함수가 호출되면서 전체 사용자 데이터를 updateUser API로 전송했다는 점이다. 예를 들어, education 페이지에서는 languageSkills 데이터를 따로 가지고 있지 않으니, languageSkills는 빈 배열 []로 보내졌다.
2. 백엔드의 과잉 친절
백엔드의 updateArrayFields 함수는 더 가관이었다. 새로운 배열 데이터에 id가 없으면, 기존 데이터를 모두 삭제하고 새로운 레코드를 생성하도록 구현되어 있었다.
프론트엔드에서 languageSkills: []를 보내면, 백엔드는 해당 유저의 모든 language_skill을 지웠다. 그리고 유저가 다시 language_skill을 입력하고 다른 페이지로 이동하면, id가 없는 새로운 데이터로 인식하여 새 레코드를 생성했다. 이 과정이 반복되면서 데이터는 기하급수적으로 늘어났다.
결국, 포트폴리오 페이지를 여러 번 왔다 갔다 하며 정보를 수정했던 성실한 유저에게서만 문제가 발생했던 것이다. 평범한 유저들은 한 번에 정보를 입력하고 저장했기에 이 문제를 겪지 않았다.
🛠️ 해결 과정
1. 긴급 조치: 일단 살리고 보자
가장 먼저 불부터 꺼야 했다.
- 중복 데이터 삭제: 쌓여있던 중복 데이터를 SQL로 정리했다.
- 프론트엔드 안전장치: 데이터 전송 전에 중복을 제거하고, 배열 길이를 제한하는 로직을 추가했다.
- 서버 메모리 설정: 임시방편으로 PM2의
max-old-space-size를 늘려 서버가 버틸 시간을 벌었다.
2. 근본적인 해결: 제대로 고치자
임시방편만으로는 부족했다. 근본적인 해결을 위해 구조를 뜯어고쳤다.
- 섹션별 업데이트 API 구현:
updateUser라는 거대한 API 대신,/users/:id/profile,/users/:id/educations처럼 각 섹션별로 업데이트할 수 있는 API를 새로 만들었다. - 백엔드 로직 개선:
undefined로 들어온 필드는 업데이트에서 제외하여 기존 데이터를 보존하도록 수정했다. - 프론트엔드 리팩토링: 페이지 이동 시 불필요한 전체 데이터를 보내지 않고, 변경된 섹션의 데이터만 보내도록 수정했다.
✨ 교훈
이번 경험을 통해 몇 가지 중요한 교훈을 얻었다.
- API는 작고 명확하게: 거대한 만능 API는 얘기치 못한 사이드 이펙트를 낳는다. 기능에 맞게 API를 잘게 쪼개는 것이 중요하다.
- 사용자 패턴을 예측하라: 개발자의 시선이 아닌, 실제 사용자의 다양한 사용 패턴을 고려해야 한다. 가장 성실한 유저가 가장 큰 고통을 받는 시스템은 분명 문제가 있다.
- 방어 코드는 필수: 프론트엔드와 백엔드 양쪽에서 데이터의 정합성을 검증하고 예상치 못한 입력에 대비하는 방어 코드는 아무리 강조해도 지나치지 않다.
단순해 보이는 기능 뒤에 숨어있는 복잡성을 항상 경계해야 한다는 것을 다시 한번 깨닫게 된 값진 경험이었다.