티스토리 뷰

728x90

강의 목록 API 하나를 "추측 대신 측정"으로 파고들어
캐싱 → 구조 개선 → 메모리까지 잡은 기록.
Stack: Node.js · TypeScript · TypeORM(MySQL) · Redis · Vitest

 

 

한눈에 보기

지표 Before After 변화
응답 시간(대형 케이스) 9.5 s 0.27 s 🟢 −97%
외부 인기도 API 7,000 ms 20 ms (캐시 히트) 🟢 −99.7%
요청당 객체 메모리 3,560 KB 53 KB 🟢 −98.5%
조립 객체 수 1,652개 20개 🟢 −98.8%

응답 20건을 주는 API가 매번 1,652건을 다 만들고 있었다. → 20건만 만들도록 바꾼 이야기.

 


개선 여정 (요약)

 


1. 측정부터 — 구간별 타이밍

const mark = (label) => { marks[label] = now() - last; last = now(); };
// 각 await 뒤에 mark('단계')  →  로그로 한 줄 출력

 

상위 몇 구간이 대부분을 차지했다. 가장 무거워 보이는 곳부터 더 쪼개 봤다.


2. 삽질 ① — "인덱스 없나?" → ❌

엔티티에 product_id 인덱스 선언이 안 보여 풀스캔을 의심했다. 그런데 마이그레이션엔 이미:

UNIQUE KEY product_course_unique (product_id, course_id)  -- prefix로 product_id 커버

 

 

EXPLAIN ANALYZE로 확인:

Index lookup on product_course (product_id=...)  actual time=..4.64  rows=1939
의심 실제
인덱스 없는 풀스캔? ❌ 인덱스 정상, SQL ~12ms
느린 진짜 이유 1,939행을 객체로 만드는 비용 (전송+ORM 하이드레이션)

💡 느린 게 SQL인지 행→객체 변환인지는 전혀 다른 문제. EXPLAIN 한 줄로 갈린다.

 


3. 반전 — 진범은 외부 인기도 API

상품에 연결된 전체 강의(1,652건) 의 인기도를 외부 API에서 가져오는데, 이 호출 하나가 응답 건수에 비례해 폭증했다 👇

처리 건수: 1,652

인기도 API      ████████████████████████████████████  7,238 ms  🔴 83%
상품강의맵         ███  569 ms
강의 상세 조회      ██  462 ms
카테고리 조회        ▏  150 ms
점수/부착          ▏   90 ms
────────────────────────────────────────────────────
총합            ~8.7 s

 

응답 건수에 비례(건수 ↑ → 시간 ↑). 다른 모든 구간을 합쳐도 외부 API 하나의 1/8. 나머지 최적화는 일단 곁가지였다.

💡 가장 큰 레버부터. 7초짜리 외부 호출 앞에선 수십 ms 최적화는 의미가 작다.


4. 개선 ① — 외부 호출 Redis 캐싱

외부 API는 못 고치니 결과를 캐싱. 기존 캐시 서비스(같은 데이터 모양)를 재사용.

const cached = await popularityCache.get(key);
const courses = cached ?? (await popularityClient.fetch(...)).data;
if (!cached) popularityCache.set(key, courses).catch(...);   // fire-and-forget
호출 외부 API                   전체                       
1회차 (캐시 미스) ~7,000 ms ~9.5 s
2회차 (캐시 히트) 37 ms 0.58 s

 

⚠️ 첫 요청은 여전히 느림(캐싱의 숙명) · 무효화·TTL은 기존 로직 재사용.


5. 개선 ② — 페이지네이션 앞당기기 ⭐

캐시 히트 0.58초의 남은 절반은 "1,652건 전부 조립 후 20건만 slice" 구조였다.

 

 

핵심 통찰: 정렬에 필요한 값은 id · popularity · 입장시각 셋뿐 → 무거운 조립 없이 정렬 가능.

 

// Phase A — 전체를 가볍게 (정렬·total)
const tuples = ids.filter(visible).map(id => ({ id, popularity, enteredAt }));
tuples.sort(compare);
const pageIds = tuples.slice(offset, offset + limit).map(t => t.id);

// Phase B — 페이지 20건만 무겁게
const [courses, categories] = await Promise.all([fetchFull(pageIds), fetchCategories(pageIds)]);

 

구간 Before After
전체 1,652건 처리 349 ms 58 ms
전체(캐시 히트) 0.58 s 0.27 s

❓ 두 번 조회 = 중복? → 아니다. A는 1,652건×2컬럼(정렬용), B는 20건×풀컬럼(조립용). 페이지는 A 정렬 후에야 정해져 합칠 수 없다. 겹치는 건 20행 재조회뿐.

 


6. 메모리 — GC 노이즈를 피해 측정

heapUsed 델타는 GC 탓에 −81 ~ +28MB로 요동 → 결정적 지표(조립 배열의 JSON 바이트)로 측정.

const builtKB = JSON.stringify(builtArray).length / 1024;
Before  ████████████████████████████████████████  3,560 KB  (1,652개)
After   ▌                                              53 KB  (   20개)
                                                      ≈ 67× ↓

💡 GC 런타임에서 "요청당 메모리"는 heapUsed 델타보다 데이터 볼륨 프록시가 정직하다.

 


7. 테스트 우선 (+ 캐시 함정)

리팩터링 전에 "응답 동일성"(정렬·total·페이지) 테스트를 통과시켜 두고 시작.

⚠️ 캐싱 도입 후 기존 테스트가 흔들림 — 캐시 히트로 미소비된 mockResolvedValueOnce가 누수되어 다음 테스트를 오염.

beforeEach(async () => {
  vi.restoreAllMocks();                 // 누수 spy 제거
  vi.spyOn(client, 'fetch').mockResolvedValue({ data: [...] });
  await cache.flush('popularity:cache:*');
});

 


 

최종 스코어보드

응답시간   9.5s ████████████████████  →  0.27s ▌        (−97%)
외부API    7.0s ███████████████       →  0.02s ▏        (−99.7%)
메모리     3.5MB ██████████████        →  53KB ▏        (−98.5%)

코드를 더 똑똑하게 짠 게 아니라, 꼭 필요한 만큼만 하도록 바꿨다.
1,652건을 다 만들어 20건만 쓰던 걸 — 20건만 만들도록.

 

728x90