서버 운영 중 발견된 메모리 누수와 성능 이슈 분석 및 해결 과정
1. 문제의 시작 - 지속적인 메모리 증가 감지
6개월 동안 서비스를 운영하면서 지속적으로 메모리 사용량이 증가하는 현상을 감지하였다.
처음에는 서비스의 규모가 커지고 트래픽이 증가함에 따른 정상적인 메모리 사용 증가라고 생각했지만,
매일 모니터링 시스템을 통해 평균 데이터를 관찰하면서,
비정상적으로 메모리가 점진적으로 증가하는 현상이 지속된다는 점이 의심스러웠다.
이를 좀 더 면밀히 분석하기 위해 실제 서버 환경에서 부하 테스트를 진행하기보다는,
로컬 환경에서 인위적으로 부하를 재현하여 성능 병목이 발생하는 지점을 정확히 파악하는 방향으로 접근하였다.
그래서 실제 서버의 부하 테스트 보다는 로컬에서 부하테스트 기반으로 부하나 메모리 누수에 걸리는 로직을 파해치고자 하였다.
2. 부하 테스트를 통한 성능 병목 지점 발견
ngrinder 부하 테스트를 진행하던 중, 특정 서비스에서 오류 발생 빈도가 가장 높은 구간이 존재했다.
바로 결제(Payment) 및 재고 관리(Inventory) 시스템이었다.
2.1. 결제 및 재고 관리 시스템의 주요 기능
해당 시스템의 핵심 로직은 다음과 같다:
1️⃣ 상품 옵션 재고 및 전체 재고 자동 계산
- 상품의 개별 옵션(색상, 사이즈)별 재고를 관리
- 전체 상품의 재고를 옵션들의 합산으로 자동 계산
- 재고 변경 이벤트 발생 시 DB 업데이트
2️⃣ 결제 요청 및 응답 처리
- 사용자가 특정 옵션(색상, 사이즈)의 상품을 결제하면
Kafka를 통해 메시지를 전송하고 비동기 처리 - 결제가 진행되면 해당 옵션의 재고를 즉시 감축하고,
결제 완료 시점에 구매 확정 처리 - 결제 요청에 대한 응답이 10분 내에 오지 않으면 자동 취소 후 재고 복구
3️⃣ Kafka를 활용한 비동기 복구 예약 시스템
- 결제 요청 정보를 PaymentService의 requestTaskMap에 저장
- 동일한 유저/상품/옵션/수량의 요청이 있을 경우 기존 예약 작업을 재사용
- 결제 성공 여부를 Kafka로 전달하고, 응답이 없으면 자동으로 재고를 복구
이러한 구조는 즉시 재고 감축 → 구매 확정 또는 복구라는 흐름을 따르며,
이를 통해 실시간으로 사용자에게 구매 권한을 부여하고 재고를 안정적으로 관리하는 것이 목표였다.
3. 메모리 누수 현상 발견 (부하 테스트 과정)
부하 테스트를 진행하면서 가장 먼저 눈에 띈 것은,
서비스의 부하량과 무관하게 log를 찍어보니 requestTaskMap의 메모리 점유율이 지속적으로 증가한다는 점이었다.
3.1. 부하 테스트 환경 설정
- 1000명의 사용자가 동시에 결제 요청을 생성
- 각 사용자는 10개의 서로 다른 상품 옵션을 선택
- Kafka를 통해 결제 요청 메시지를 전송하고 비동기 응답 대기
- 10분 내 응답이 없을 경우 결제 취소 및 재고 복구 실행
- 이 과정을 1분 단위로 1시간 동안 성공 응답시(결제 성공)에 hashmap 메모리가 자동으로 정리되는지 지속하여 장기적인 메모리 사용 패턴 확인
3.2. 발견한 이상 현상
- 부하 테스트가 진행될수록 메모리 사용량이 지속적으로 증가
- 사용자가 결제를 완료하든, 취소되든 requestTaskMap의 크기가 줄어들지 않음
- 부하 테스트 종료 후에도 서버의 메모리 사용량이 해제되지 않음
- 결과적으로 GC(가비지 컬렉션)가 정상적으로 수행되지 않으며, 누적된 데이터가 계속 유지됨
4. 문제 원인 분석
4.1. 메모리 누수의 원인
- PaymentService에서 결제 요청 정보를 requestTaskMap에 저장하고 이를 관리
- 하지만, 결제 성공 또는 취소 후에도 해당 요청 정보가 삭제되지 않음
- requestTaskMap이 계속해서 새로운 Composite Key를 추가하면서,
기존 데이터가 메모리에 쌓이는 형태로 유지됨 - 일정 시간이 지나면 GC가 해제하지 못하는 메모리 누수(Leak) 현상 발생
4.2. 확인된 문제 코드 예시
public class PaymentService { private final ConcurrentHashMap<String, ConcurrentHashMap<UUID, ScheduledFuture<?>>> scheduledTasks = new ConcurrentHashMap<>(); // 🔴 private final ConcurrentHashMap<UUID, RequestTaskInfo> requestTaskMap = new ConcurrentHashMap<>(); private final ScheduledThreadPoolExecutor scheduler = new ScheduledThreadPoolExecutor(1); public PaymentInventoryResponse scheduleRestoration(String userId, Long productId, Long count, UUID taskId, Size productSize, Color productColor) { String compositeKey = userId + "-" + productId + "-" + count + "-" + productSize.name() + "-" + productColor.name(); ConcurrentHashMap<UUID, ScheduledFuture<?>> tasks = scheduledTasks.getOrDefault(compositeKey, new ConcurrentHashMap<>()); // 🔴 ScheduledFuture<?> scheduledTask = scheduler.schedule(() -> { productService.restoreProductQuantity(productId, productSize, productColor, count); log.info("[상품 수량 복구 실행] productId: {}, count: {}", productId, count); }, 10, TimeUnit.MINUTES); // 🔴 tasks.put(taskId, scheduledTask); scheduledTasks.put(compositeKey, tasks); long delay = scheduledTask.getDelay(TimeUnit.SECONDS); LocalDateTime delayTime = LocalDateTime.now().plusSeconds(delay); return new PaymentInventoryResponse(taskId, delayTime); } public void processPaymentResponse(UUID taskId, boolean success) { RequestTaskInfo info = requestTaskMap.get(taskId); if (info == null) { log.warn("해당 taskId에 대한 요청 정보가 없습니다: {}", taskId); return; } String compositeKey = info.getUserId() + "-" + info.getProductId() + "-" + info.getCount() + "-" + info.getProductSize().name() + "-" + info.getProductColor().name(); if (success) { log.info("[결제 성공] compositeKey: {}", compositeKey); } else { log.info("[결제 실패] 즉시 재고 복구 실행."); productService.restoreProductQuantity(info.getProductId(), info.getProductSize(), info.getProductColor(), info.getCount()); } // 🔴 여기서 scheduledTasks 및 requestTaskMap에서 데이터 제거 안함 (메모리 누수) } } |
🔴 문제점: requestTaskMap에서 데이터가 삭제되지 않음
- 결제가 성공하거나 실패하더라도 requestTaskMap에서 해당 데이터가 제거되지 않음.
- Kafka를 통해 전송 후 응답이 오면 해당 데이터를 삭제해야 하지만, 이 로직이 빠져 있음.
- 따라서 requestTaskMap의 데이터가 무한히 증가하면서 메모리 누수 발생.
5. 해결 방법 (최적화 적용)
✅ 해결 방안
✔ Kafka 응답 수신 후 requestTaskMap에서 제거
✔ WeakReference 또는 TTL(Time-To-Live) 설정하여 일정 시간이 지나면 자동 삭제
✔ 메모리 사용량이 일정 수준을 초과하면 캐시 정리 실행
5.1. 수정된 코드 (requestTaskMap 자동 삭제 적용)
public class PaymentService { private final ConcurrentHashMap<String, ConcurrentHashMap<UUID, ScheduledFuture<?>>> scheduledTasks = new ConcurrentHashMap<>(); private final ConcurrentHashMap<UUID, RequestTaskInfo> requestTaskMap = new ConcurrentHashMap<>(); private final ScheduledThreadPoolExecutor scheduler = new ScheduledThreadPoolExecutor(1); public PaymentInventoryResponse scheduleRestoration(String userId, Long productId, Long count, UUID taskId, Size productSize, Color productColor) { String compositeKey = userId + "-" + productId + "-" + count + "-" + productSize.name() + "-" + productColor.name(); scheduledTasks.computeIfAbsent(compositeKey, k -> new ConcurrentHashMap<>()); // ✅ 이미 예약된 작업이 있다면 새로 등록하지 않음 if (scheduledTasks.get(compositeKey).containsKey(taskId)) { ScheduledFuture<?> existingTask = scheduledTasks.get(compositeKey).get(taskId); long delay = existingTask.getDelay(TimeUnit.SECONDS); LocalDateTime delayTime = LocalDateTime.now().plusSeconds(delay); log.info("이미 예약된 작업이 존재합니다. compositeKey: {}, taskId: {}", compositeKey, taskId); return new PaymentInventoryResponse(taskId, delayTime); } // ✅ 새로운 예약 추가 ScheduledFuture<?> scheduledTask = scheduler.schedule(() -> { productService.restoreProductQuantity(productId, productSize, productColor, count); removeScheduledTask(compositeKey, taskId); log.info("[상품 수량 복구 실행] productId: {}, count: {}", productId, count); }, 10, TimeUnit.MINUTES); scheduledTasks.get(compositeKey).put(taskId, scheduledTask); long delay = scheduledTask.getDelay(TimeUnit.SECONDS); LocalDateTime delayTime = LocalDateTime.now().plusSeconds(delay); return new PaymentInventoryResponse(taskId, delayTime); } public void processPaymentResponse(UUID taskId, boolean success) { RequestTaskInfo info = requestTaskMap.get(taskId); if (info == null) { log.warn("해당 taskId에 대한 요청 정보가 없습니다: {}", taskId); return; } String compositeKey = info.getUserId() + "-" + info.getProductId() + "-" + info.getCount() + "-" + info.getProductSize().name() + "-" + info.getProductColor().name(); if (success) { log.info("[결제 성공] 예약 작업 취소됨. compositeKey: {}", compositeKey); } else { log.info("[결제 실패] 즉시 재고 복구 실행."); productService.restoreProductQuantity(info.getProductId(), info.getProductSize(), info.getProductColor(), info.getCount()); } // ✅ 예약 작업 및 요청 정보 삭제하여 메모리 누수 방지 cancelScheduledTask(compositeKey, taskId); requestTaskMap.remove(taskId); } private void removeScheduledTask(String compositeKey, UUID taskId) { scheduledTasks.computeIfPresent(compositeKey, (key, tasks) -> { tasks.remove(taskId); return tasks.isEmpty() ? null : tasks; }); } private void cancelScheduledTask(String compositeKey, UUID taskId) { ConcurrentHashMap<UUID, ScheduledFuture<?>> tasks = scheduledTasks.get(compositeKey); if (tasks != null) { ScheduledFuture<?> task = tasks.remove(taskId); if (task != null) { task.cancel(false); } } } } |
6. 최적화 결과 및 성능 개선 효과
✅ (1) 메모리 사용량 정상화
- 메모리 사용량이 더 이상 지속적으로 증가하지 않음
- requestTaskMap의 크기가 일정 수준 이상 증가하지 않음
✅ (2) 결제 응답 처리 속도 향상
- Kafka 응답을 받은 후 requestTaskMap에서 제거하여 메모리 릴리스 최적화
- TTL 적용 후 일정 시간 동안 응답이 없는 경우 자동으로 데이터 정리
✅ (3) 시스템 안정성 증가
- 불필요한 객체 유지 문제 해결
- 대량의 결제 요청이 발생해도 메모리 누수가 발생하지 않음
7. 결론 및 향후 개선 방향
1️⃣ 부하 테스트를 통해 메모리 누수를 조기에 발견
2️⃣ Kafka 응답 후 데이터를 삭제하도록 로직 수정
3️⃣ TTL을 적용하여 오래된 데이터 자동 정리
👉 향후 JProfiler 및 VisualVM을 활용한 추가적인 메모리 분석을 진행하여,
다른 서비스에서도 유사한 메모리 누수 문제를 점검할 계획.
추가 매핑 개선 사항
지금까지 구현한 기능(상품 옵션 재고 및 전체 재고 자동 계산, 결제 요청/응답, Kafka를 활용한 비동기 복구 예약)
이 시스템은 상품의 옵션별 재고 관리와 전체 재고 자동 계산, 결제 요청 및 복구 처리를 비동기적으로 처리하는 기능을 제공합니다.
주요 기능으로는 상품 등록/수정/삭제, 상품 옵션(색상, 사이즈) 재고 관리, 결제 시 해당 옵션의 재고 차감 및 전체 재고 업데이트, 결제 미응답 시 예약 복구 작업 자동 실행 등이 있습니다.
요약 및 설명
- PaymentService 내부에서
- 예약 작업은 composite key(“productId-userId-productSize-productColor-count”)를 사용하여 관리됩니다.
- 동일한 유저, 상품, 옵션, 수량의 요청이 있을 경우 기존 예약 작업을 재사용(else 분기)하도록 구현하였습니다.
- 결제 요청 정보를 PaymentService의 requestTaskMap에 저장하고, 결제 응답 시 해당 정보를 조회하여 유효성(동일한 건인지) 검증 후 처리합니다.
- PaymentController에서는
- @authuser나 요청 파라미터를 통해 userId, productId, count, productSize, productColor를 받고,
- 이를 PaymentService로 전달하여 Kafka 메시지 전송 및 예약 작업 등록(PaymentInventoryResponse 반환)을 처리합니다.
- 응답 엔드포인트에서는 requestId와 success만 받아 PaymentService에서 모든 처리를 진행하도록 위임합니다.
이 구조를 사용하면,
- 컨트롤러에서는 복잡한 파싱 로직 없이 요청 정보를 PaymentService에 위임하여 코드 중복 및 메모리 사용을 최소화할 수 있고,
- 동일한 유저/상품/옵션/수량의 결제 요청은 하나의 예약 작업으로 관리되어 성능과 일관성이 향상됩니다.
-
상품 도메인 개선 및 재고 자동 계산
-
Product 엔티티
-
ProductOption: 색상 및 사이즈별 개별 재고 관리
-
ProductSizeOption: 사이즈별 치수 정보 관리
-
ProductColorOption: 색상별 이미지 정보 관리
-
-
전체 재고 자동 계산
-
엔티티 콜백(@PrePersist, @PreUpdate) 또는 서비스 계층 로직을 통해 옵션 재고의 합계로 totalQuantity를 자동 계산
-
-
-
결제 요청 및 응답 처리 (Kafka 기반 비동기 처리)
-
결제 요청 시 PaymentController에서 userId, productId, count, productSize, productColor 등의 정보를 받아 Kafka 메시지(PaymentUpdateMessage)에 담아 전송
-
PaymentService는 요청 정보를 내부 저장소(requestTaskMap)로 관리하고, 동일한 유저/상품/옵션/수량에 대해 중복 예약을 방지하기 위해 composite key로 예약 작업(scheduleRestoration)을 관리
-
결제 응답 시 단순히 taskId와 성공 여부(success)만 받으며, PaymentService에서 저장된 요청 정보를 조회하여 예약 작업 취소 또는 즉시 복구 작업을 실행한 후, 모든 in‑memory 정보가 삭제되도록 처리
-
'Backend > Backend 관련 학습 내용' 카테고리의 다른 글
기존 채팅 시스템에서의 WebSocket(웹소켓) 프로토콜의 STOMP 보안 문제점과 인증 레이어 설계 (0) | 2024.11.29 |
---|---|
[BackEnd] CI/CD Jenkins (0) | 2024.04.15 |
다채널 CCTV 사람 객체 이상행동 감지 및 추적 (1) | 2024.03.23 |
JWT 인증인가 token을 통한 로그인 기능 구현 (2) | 2023.11.19 |
[너의 생각을 보여줘] 공모전 개최 프로젝트 (0) | 2023.07.05 |