Project
Subing
구독 서비스를 한 곳에서 관리하고, 중복 구독 감지 및 비용 절감 대안을 추천해주는 플랫폼
기간
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 절약 | 90 | 65 +20(동일서비스) +5(전환비용 0) |
| 다른 서비스(월간), ₩8,000 절약 | 70 | 65 +5(서비스전환) |
| 다른 서비스(연간), 순절약 ₩1,000 | 45 | 65 +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 스코프 축소, CachedThreadPool → FixedThreadPool, 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% | 512m | 1GB 서버에서 충분한 힙 확보 |
-XX:MaxDirectMemorySize | Xmx와 동일 | 128m | Netty 스트리밍 Direct Buffer 확보 |
-XX:MaxMetaspaceSize | 무제한 | 128m | Spring 클래스 로딩 무제한 증가 방지 |
-XX:+UseSerialGC | G1GC | SerialGC | GC 오버헤드 ~50MB 절약 |
-XX:ReservedCodeCacheSize | 240MB | 48m | JIT 코드 캐시 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 BYDB 집계 쿼리로 전환
결과
변경 전: 평소 RSS 1.3~1.5GB (2GB 서버)
변경 후: 평소 RSS ~800MB (1GB 서버에서 안정), OOM 없음
결과
전환 비용 모델 기반 구독 최적화, 비용 절감을 위한 JVM·코드 최적화