Chrome DevTools로 잡는 JavaScript 메모리 누수

개요 JavaScript 메모리 누수는 웹 애플리케이션의 성능을 저하시키는 주요 원인 중 하나입니다. 특히 SPA(Single Page Application)에서 페이지 전환 시 메모리 사용량이 지속적으로 증가하거나, DOM 요소를 제거했음에도 불구하고 참조가 유지되는 문제가 빈번히 발생합니다. 이 가이드에서는 Chrome DevTools를 활용해 메모리 누수를 진단하고 해결하는 체계적인 방법을 단계별로 설명합니다. 실제 사례를 바탕으로 한 코드 예제와 검증 방법을 포함해, 개발자들이 즉시 적용할 수 있는 실용적인 솔루션을 제공합니다. 문제 상황: 메모리 누수의 대표적 증상 1. 페이지 전환 시 메모리 사용량 지속 증가 SPA에서 라우터가 페이지 전환을 처리할 때, 이전 페이지의 리소스가 제대로 해제되지 않으면 메모리 사용량이 선형적으로 증가합니다. 예를 들어 React 애플리케이션에서 useEffect 정리 함수를 누락하거나, Vue에서 beforeUnmount 생명주기에서 이벤트 리스너를 제거하지 않는 경우 발생합니다. ...

2026년 4월 18일 · 4 min · 837 words · Chanyeol

사내 VM IP 관리 시스템 만들기 - React 대시보드 + Docker Compose 배포 (4편)

React 대시보드 대시보드는 탭 2개로 구성된다. VM 목록 — 전체 VM 현황, 상태별 필터, ONLINE/OFFLINE/UNKNOWN 배지 이벤트 로그 — IP 충돌, IP 변경, 오프라인 이벤트 타임라인 WebSocket이 아닌 30초 폴링으로 구현했다. VM 상태가 초 단위로 바뀌지 않고, 운영 대시보드 특성상 약간의 지연은 허용된다. 심플하게 가는 게 낫다고 판단했다. 30초 폴링 훅 // usePolling.js import { useEffect, useRef } from 'react'; export function usePolling(callback, intervalMs = 30_000) { const callbackRef = useRef(callback); useEffect(() => { callbackRef.current = callback; }); useEffect(() => { callbackRef.current(); // 마운트 시 즉시 1회 실행 const id = setInterval(() => callbackRef.current(), intervalMs); return () => clearInterval(id); }, [intervalMs]); } // App.jsx const [vms, setVms] = useState([]); const [loading, setLoading] = useState(true); usePolling(async () => { const data = await fetch('/api/vms').then(r => r.json()); setVms(data); setLoading(false); }, 30_000); callbackRef를 쓰는 이유는 setInterval 클로저가 최초 등록 시점의 callback을 계속 참조하는 문제를 피하기 위해서다. useRef로 항상 최신 콜백을 가리키도록 한다. ...

2026년 4월 15일 · 5 min · 958 words · Chanyeol

사내 VM IP 관리 시스템 만들기 - 기획 배경 + 전체 아키텍처 + DB 스키마 (1편)

왜 만들게 됐나 VM이 늘어나면서 IP 관리가 슬슬 문제가 됐다. 스프레드시트에 IP 할당 현황을 수동으로 관리하는데, 담당자마다 업데이트 타이밍이 달라서 실제 상태와 항상 일치하지 않음 VM을 삭제하면서 IP 해제를 빠뜨리면, 나중에 같은 IP를 다른 VM에 할당해서 충돌이 발생 VM이 죽어도 스프레드시트에 살아있는 것처럼 남아있어서 “이 서버 살아있어요?” 같은 질문이 자주 옴 특정 VM의 IP 변동 이력을 알고 싶어도 추적 방법이 없음 그래서 만들었다. VM에 경량 에이전트를 설치해서 주기적으로 heartbeat를 전송 서버에서 heartbeat를 분석해서 IP 충돌, IP 변경, 오프라인 등 이상 감지 이상 발생 시 Slack 알림 웹 UI에서 전체 VM 현황과 이벤트 로그 조회 기술 스택 영역 기술 선택 이유 에이전트 Go 1.22 단일 바이너리, 런타임 불필요, 크기 소형 백엔드 Spring Boot 3 + JPA 팀 표준 스택 DB PostgreSQL 16 이력 테이블 + 집계 쿼리 캐시/dedup Redis 7 Slack 알림 중복 방지 프론트엔드 React 18 + Vite 30초 폴링 대시보드 인프라 Docker Compose 4개 서비스 일괄 관리 에이전트는 처음에 Node.js로 만들었다가 Go로 전환했다. 이유는 2편에서 다룬다. ...

2026년 4월 12일 · 4 min · 759 words · Chanyeol

사내 인사정보 관리 시스템 만들기 - 배치 변동 감지 + React AG Grid UI (2편)

배치 수집 + 변동 감지 전체 흐름 API에서 전체 인원 목록 가져오기 │ DB 현재 상태와 비교 │ ├─ DB에 없는 usId → JOIN (입사) ├─ API에 없는 usId → LEAVE (퇴사) └─ 값이 다른 필드 → DEPT_CHANGE / ROLE_CHANGE │ ▼ 변동 건 → user_history 적재 현재 상태 → users 테이블 upsert 배치 구현 async function runBatch() { console.log(`[배치] 시작: ${new Date().toLocaleString('ko-KR')}`); // 1. API에서 전체 인원 가져오기 const apiUsers = await fetchAllUsers(); console.log(`[배치] API 응답: ${apiUsers.length}명`); // 2. DB 현재 상태 가져오기 const dbUsers = await prisma.user.findMany(); const dbMap = new Map(dbUsers.map(u => [u.usId, u])); const apiMap = new Map(apiUsers.map(u => [u.usId, u])); const historyRecords = []; // 3. 입사 감지 (API에 있고 DB에 없음) for (const [usId, apiUser] of apiMap) { if (!dbMap.has(usId)) { historyRecords.push({ usId, changeType: 'JOIN', fieldName: null, oldValue: null, newValue: apiUser.usName, }); } } // 4. 퇴사 감지 (DB에 있고 API에 없음) for (const [usId, dbUser] of dbMap) { if (!apiMap.has(usId)) { historyRecords.push({ usId, changeType: 'LEAVE', fieldName: null, oldValue: dbUser.usName, newValue: null, }); } } // 5. 변동 감지 (둘 다 있는데 값이 다름) const TRACKED_FIELDS = ['deptName', 'usRollName', 'usPosName']; for (const [usId, apiUser] of apiMap) { const dbUser = dbMap.get(usId); if (!dbUser) continue; for (const field of TRACKED_FIELDS) { const oldVal = dbUser[field] ?? null; const newVal = apiUser[field] ?? null; if (oldVal !== newVal) { historyRecords.push({ usId, changeType: field === 'deptName' ? 'DEPT_CHANGE' : 'ROLE_CHANGE', fieldName: field, oldValue: oldVal, newValue: newVal, }); } } } // 6. 이력 적재 if (historyRecords.length > 0) { await prisma.userHistory.createMany({ data: historyRecords }); } // 7. 현재 상태 upsert for (const user of apiUsers) { await prisma.user.upsert({ where: { usId: user.usId }, update: { ...user, updatedAt: new Date() }, create: user, }); } // 8. 퇴사자 DB에서 제거 (선택) const leaveIds = [...dbMap.keys()].filter(id => !apiMap.has(id)); if (leaveIds.length > 0) { await prisma.user.deleteMany({ where: { usId: { in: leaveIds } } }); } console.log( `[배치] 완료 — 입사 ${historyRecords.filter(h => h.changeType === 'JOIN').length}, ` + `퇴사 ${historyRecords.filter(h => h.changeType === 'LEAVE').length}, ` + `변동 ${historyRecords.filter(h => !['JOIN','LEAVE'].includes(h.changeType)).length}` ); } 배치 스케줄 // 매일 오전 1시 실행 (TZ=Asia/Seoul 필수) cron.schedule('0 1 * * *', () => { runBatch().catch(err => console.error('[배치] 오류:', err)); }); 최초 배포 시에는 배치가 돌 때까지 DB가 비어있다. 수동으로 한 번 실행해줘야 한다. ...

2026년 4월 10일 · 6 min · 1076 words · Chanyeol

사내 인사정보 관리 시스템 만들기 - 아키텍처 + Prisma 스키마 설계 (1편)

왜 만들게 됐나 사내 그룹웨어에는 인사 정보가 있지만 조회 UI가 불편하고, 부서 이동이나 입퇴사 같은 변동 이력을 추적하는 기능이 없었다. 직접 관리하고 싶은 데이터가 생겼을 때 그때그때 SQL을 뽑아 쓸 수 있는 환경도 필요했다. 그래서 만들었다. 그룹웨어 API에서 인사 데이터를 매일 자동 수집 이전 상태와 비교해서 입사/퇴사/부서이동/직책변경 자동 감지 React 웹 UI에서 조회·수정·삭제, CSV 다운로드, INSERT SQL 생성 기술 스택 선택 영역 기술 선택 이유 백엔드 Node.js 20 + Express 기존 Slack 봇과 동일 스택 ORM Prisma 타입 안전한 쿼리, 마이그레이션 자동화 DB PostgreSQL 16 JSON 파일로는 한계, 이력 테이블 필요 스케줄러 node-cron 매일 1시 배치 실행 프론트엔드 React 18 + Vite 빠른 개발 환경 그리드 AG Grid Community 대용량 데이터 필터/정렬/페이지네이션 서버 상태 TanStack Query API 캐싱, 로딩/에러 상태 관리 인프라 Docker Compose 3개 서비스(DB, 서버, 클라이언트) 일괄 관리 기존 Slack 봇은 DB 없이 JSON 파일로 상태를 관리했는데, 인사 데이터는 수백 명 규모에 이력까지 쌓아야 하니 PostgreSQL이 필요했다. ...

2026년 4월 9일 · 4 min · 760 words · Chanyeol

노트북으로 홈서버 구축하기 - 직접 만든 컨테이너 대시보드 (Spring Boot + React + SSE) (12편)

왜 직접 만들었나 Portainer가 있는데 굳이 만든 이유는 단순하다. 그냥 만들어보고 싶었다. Portainer는 기능이 너무 많다. 나는 컨테이너 목록 확인, 시작/중지/재시작, 로그 보기 딱 세 가지만 필요했다. 이 정도면 직접 만들 수 있겠다 싶어서 Spring Boot 백엔드 + React 프론트엔드로 구성했다. 완성 화면 컨테이너 목록을 카드 UI로 표시 (상태별 색상 배지) Start / Stop / Restart 원클릭 제어 Logs 버튼으로 실시간 로그 스트리밍 (SSE) 5초 주기 자동 갱신 ...

2026년 4월 3일 · 5 min · 978 words · Chanyeol
1