티스토리 뷰
강의 목록 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건만 만들도록.
'회고 > 우당탕 개발자 성장기' 카테고리의 다른 글
| MongoDB 쿼리에서 대시보드로: AI와 함께 만든 시청 데이터 관측 도구 (0) | 2026.05.22 |
|---|---|
| pub/sub 로직 개선하기 (3) | 2025.06.25 |
| api 속도 개선 해보기 (0) | 2025.03.16 |
| 테스트 코드를 너무 믿었다 (0) | 2024.07.19 |
- Total
- Today
- Yesterday
- 알고리즘 공부
- 취업준비
- 알고리즘공부
- 개발자 취준
- 백엔드 개발자
- 코딩테스트
- 자바공부
- 기술 면접 준비
- 백준
- 취준
- 취업 준비
- 주니어 개발자 취업 준비
- 제로베이스 백준 장학금
- 프로그래머스
- 코테 준비
- 프로그래머스 자바
- 코테공부
- 백엔드 개발자 취업 준비
- 백엔드 개발자 기술 면접 준비
- 코딩테스트공부
- 자바
- java
- 코테준비
- 개발자 취업 준비
- 개발자 면접 준비
- 프로그래머스 카카오
- 알고리즘
- 제로베이스 백엔드 스쿨
- 코딩테스트 준비
- 코딩테스트 공부
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
