Heeyaa

Project

Subing

Side Project

구독 서비스를 한 곳에서 관리하고, 중복 구독 감지 및 비용 절감 대안을 추천해주는 플랫폼

기간

2025.10 ~

스택

Java, Spring Boot, Spring Data JPA, PostgreSQL, Spring AI, WebSocket, Caffeine, Docker, Railway

트러블슈팅 #1 — 구독 최적화 엔진

설계 배경

  • 단순 구독 목록 조회를 넘어 "어떻게 하면 덜 쓸 수 있는지" 알려주는 것이 핵심 목표
  • 중복 구독 감지 → 저렴한 대안 탐색 → 포트폴리오 최적화의 3단계 파이프라인으로 설계
SubscriptionOptimizationService
    │
    ├─ 1. detectDuplicateServices()
    │      └─ 카테고리별 그룹핑 → 같은 카테고리 2개 이상 구독 감지
    │
    ├─ 2. findCheaperAlternatives()
    │      ├─ Stage 1: 같은 서비스 다운그레이드 (전환 비용 0)
    │      └─ Stage 2: 다른 서비스 대안 탐색 (전환 비용 모델 적용)
    │
    └─ 3. selectPortfolioOptimizedAlternatives()
           └─ 충돌 제거 + 최대 변경 수 제한 (기본 3개)

핵심 설계 결정

전환 비용 모델

  • 단순 가격 비교 시 비현실적 추천 발생 (시청 기록 유실, 콘텐츠 차이, 가족 공유 재설정 등 무형 비용 미반영)
  • 마찰 비용을 수치화해 순절약(netSavings)이 0 이하인 대안 자동 제외
Stage 1: YouTube Premium Family (₩14,900) → Individual (₩10,900)  전환비용 ₩0, 순절약 ₩4,000
Stage 2: Netflix Premium (₩17,000) → Wavve Standard (₩10,900)     전환비용 ₩2,000, 순절약 ₩4,100

크로스 서비스 전환   +₩2,000
연간 결제 해지       +₩2,000
카테고리 변경        +₩1,000
→ netSavings = 총절약 - 전환비용 ≤ 0 이면 추천 제외

N+1 쿼리 방지

  • 구독마다 카테고리 플랜을 개별 조회 시 N+1 발생
구독 N개 × findByCategory() → DB 쿼리 N번

   쿼리 1: 사용자 구독 목록 (JOIN FETCH로 Service 포함)
   쿼리 2: 관련 카테고리 전체 플랜 IN 쿼리 1번
   이후 in-memory Map으로 조회
  • DB 쿼리를 구독 수에 관계없이 2회로 고정, 이후는 메모리에서 처리

포트폴리오 최적화

  • 같은 구독에 대한 충돌 추천 발생 시 최적 대안 1개 선별
충돌 예시:
  추천 A: Netflix Premium → Netflix Standard (다운그레이드)
  추천 B: Netflix Premium → Wavve (서비스 전환)   ← 둘 다 실행 불가

서로 다른 구독이 동일 대안을 가리킬 경우:
  구독 X → Wavve Standard 추천
  구독 Y → Wavve Standard 추천   ← 순절약이 큰 쪽만 남김
  • 우선순위: ① 순절약 → ② 다운그레이드 우선(마찰 낮음) → ③ 신뢰도 점수
  • 최종 결과를 maxChangesPerRun(기본 3개)으로 제한 (한번에 너무 많은 변경 제안 시 사용자 부담)

신뢰도 점수 (0~100)

  • 초기 버전에서 maxChangesPerRun 정책값에 따라 점수가 함께 변동 → 관리자가 최대 추천 수를 바꿀 때마다 대안 신뢰도가 바뀌는 것은 직관에 맞지 않아 제거
  • 대안 자체의 품질(절약 금액, 전환 비용, 결제 주기)만 반영
시나리오점수산출 근거
같은 서비스 다운그레이드, ₩5,000 절약9065 +20(동일서비스) +5(전환비용 0)
다른 서비스(월간), ₩8,000 절약7065 +5(서비스전환)
다른 서비스(연간), 순절약 ₩1,0004565 +5 −10(미미한절약) −15(연간해지)

관리자 정책 오버라이드

  • 알고리즘 파라미터 11개를 재배포 없이 실시간 조정 가능 (전환 비용, 연간 결제 페널티, 최대 추천 수, 탐색 타임아웃 등)
application.yml (기본값)
    → DB 오버라이드 병합
    → volatile CachedPolicySnapshot (TTL 30초)
    → 최적화 알고리즘에서 사용
  • volatile + Double-checked locking으로 스레드 안전 보장, 매 요청 DB 조회 없이 캐시 제공
  • 정책 변경마다 감사 로그(누가/언제/무엇을) 기록 → 개별 키 롤백 가능

타임아웃 보호

  • 구독이 많은 사용자의 탐색이 오래 걸릴 경우를 대비해 100ms 타임아웃 적용
  • 초과 시 그때까지 찾은 결과만 즉시 반환 (부분 결과 응답)

결과

  • 전환 비용 모델로 순절약 0 이하 대안 자동 필터링
  • DB 쿼리 2회 고정으로 N+1 문제 원천 차단
  • 재배포 없이 파라미터 실시간 조정 가능

트러블슈팅 #2 — 서버 비용 절감을 위한 메모리 최적화

배경 및 문제 상황

  • Railway 2GB 서버로 운영 중 과금 부담으로 1GB로 다운그레이드
  • 다운그레이드 후 GPT 추천 호출 시마다 OOM으로 컨테이너 강제 종료 반복
  • JVM 메모리 설정 없음 → 평소 RSS 1.3~1.5GB, GPT 스트리밍 호출 시 추가 급증

원인 분석

원인영향
JVM 메모리 설정 없음Metaspace 무제한, CodeCache 240MB 예약, Direct Memory 무제한
Executors.newCachedThreadPool()동시 N요청 → 스레드 2N개 × 1MB 스택
@Transactional 클래스 레벨GPT 호출 5~15초 동안 DB 커넥션 점유 → 5개 풀 고갈
Reactor 스트림 미취소클라이언트 연결 끊겨도 GPT 스트림 계속 실행 → 메모리 누수
MaxDirectMemorySize 미설정Netty Direct Memory를 Xmx만큼 무제한 사용
enrichPriceInfo() N+1 쿼리추천 3~5건 × 개별 플랜 DB 조회
NotificationScheduler 풀스캔 4회전체 구독 테이블을 매일 자정 4번 반복 로드

해결

1차 — JVM 영역별 제한, @Transactional 스코프 축소, CachedThreadPoolFixedThreadPool, GPT 중복 호출 제거 → OOM 빈도 감소, 하지만 여전히 발생

2차 — Reactor Disposable 추적, FixedThreadPool → 바운디드 ThreadPoolExecutor, N+1 배치 쿼리, 스케줄러/통계 쿼리 최적화 → GPT 추천에서 여전히 OOM

3차 — JVM 메모리를 1GB 서버 스펙에 맞게 재조정, Reactor 이벤트 루프 블로킹 수정 → OOM 완전 해결

근본 원인: 코드 레벨 최적화에 집중했지만, 실제 원인은 1GB 서버에 400MB만 할당한 JVM 설정이었고, 특히 MaxDirectMemorySize=64m이 Netty GPT 스트리밍 OOM의 직접 원인이었다. 서버 스펙의 70~80%를 JVM에 할당하고 나머지는 OS·컨테이너 오버헤드에 남기는 것으로 목표했다.

JVM 메모리 영역별 제한

# Railway 1GB 서버 — Heap(512m) + Meta(128m) + Direct(128m) + Code(48m) ≈ 800MB
ENV JAVA_OPTS="-Xms256m -Xmx512m \
  -XX:MaxMetaspaceSize=128m \
  -XX:MaxDirectMemorySize=128m \
  -XX:+UseSerialGC \
  -Xss256k \
  -XX:ReservedCodeCacheSize=48m \
  -XX:+ExitOnOutOfMemoryError"
플래그기본값최종 설정이유
-Xmx메모리의 25%512m1GB 서버에서 충분한 힙 확보
-XX:MaxDirectMemorySizeXmx와 동일128mNetty 스트리밍 Direct Buffer 확보
-XX:MaxMetaspaceSize무제한128mSpring 클래스 로딩 무제한 증가 방지
-XX:+UseSerialGCG1GCSerialGCGC 오버헤드 ~50MB 절약
-XX:ReservedCodeCacheSize240MB48mJIT 코드 캐시 192MB 절약

MaxDirectMemorySize : Spring AI ChatModel.stream()이 Netty WebClient를 통해 Direct Memory에 버퍼 할당했다. 기본값이 -Xmx와 같아 힙 밖에서 무제한 소비 가능하다. 1차에서 64MB로 보수적으로 잡았다가 GPT 스트리밍 중 Direct Memory 부족해 OOM 재발했다. 1GB 서버에서는 128MB가 안정적이었다.

Reactor 스트림 누수 차단

// 변경 전: 클라이언트 끊겨도 GPT 스트림 계속 실행
streamFlux.subscribe(chunk -> { ... }, error -> { ... }, () -> { ... });
emitter.onTimeout(() -> emitter.complete());  // emitter만 닫고, GPT 스트림은 살아있음

// 변경 후: Disposable 추적으로 OpenAI API 연결까지 정리
AtomicReference<Disposable> disposableRef = new AtomicReference<>();
disposableRef.set(streamFlux.subscribe(...));

emitter.onTimeout(() -> {
    Disposable d = disposableRef.get();
    if (d != null && !d.isDisposed()) d.dispose();  // WebClient HTTP 연결까지 종료
    emitter.complete();
});

CachedThreadPool → 바운디드 ThreadPoolExecutor

// 변경 전: 무제한 스레드
Executors.newCachedThreadPool()

// 1차 수정: 스레드 제한했지만 큐가 LinkedBlockingQueue(Integer.MAX_VALUE) → 큐 무제한
Executors.newFixedThreadPool(2)

// 최종: 스레드 + 큐 모두 제한 + 백프레셔
new ThreadPoolExecutor(
    2, 2, 60, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(4),
    new ThreadPoolExecutor.CallerRunsPolicy()  // 큐 꽉 차면 호출자 스레드에서 실행 → 자연스러운 백프레셔
);

Reactor 이벤트 루프 블로킹 방지

  • GPT 스트리밍 완료 콜백에서 DB 작업(enrichPriceInfo, saveRecommendationResult)을 이벤트 루프 스레드에서 직접 실행 → 이벤트 루프 블로킹 → 버퍼 적체 → 메모리 증가
  • DB 작업을 별도 바운디드 스레드풀로 이관

스케줄러 쿼리 통합 및 통계 집계 쿼리 전환

  • NotificationScheduler: 전체 구독 테이블 4회 풀스캔 → 1회 로드 후 4개 체크로 통합
  • AdminStatisticsService: findAll() 후 메모리 필터 → countByTier(), GROUP BY DB 집계 쿼리로 전환

결과

변경 전: 평소 RSS 1.3~1.5GB (2GB 서버)
변경 후: 평소 RSS ~800MB (1GB 서버에서 안정), OOM 없음

결과

전환 비용 모델 기반 구독 최적화, 비용 절감을 위한 JVM·코드 최적화