느린 API, 어디서 막혔나

LinkDropper·2025년 10월 24일

Link Dropper

목록 보기
13/17
post-thumbnail
  • DB 단계 최적화: 불필요 인덱스/과도한 CASCADE 정리 → 쓰기 성능 및 무결성 개선.
  • 쿼리 구조 개선: N+1 제거(폴더별 미읽은 수 집계), 다건 UPDATE를 CASE 단일 쿼리로 통합.
  • 동시성 활용: 순차 처리 → Promise.all 기반 병렬 처리로 대량 작업 가속.
  • 결과: 응답 시간 50%+ 단축, DB 부하 70%↓, 코드 403줄 순감.

왜 이 글을 읽어야 할까?

운영 환경에서 API 응답이 튀고, 피크 타임에 DB가 뜨거워지는 문제는 대부분 “쿼리 구조·인덱스·동시성” 3축에서 원인이 드러납니다. 본 리팩터링은 이 세 축을 단계적으로 다뤄 측정 가능한 성과를 만들었습니다. (응답시간/부하/코드 줄 수)


1) 스키마·인덱스 정비: 쓰기 성능과 무결성을 동시에

운영 중 적체가 보이는 프로젝트의 공통점은 인덱스 과다과격한 CASCADE입니다.
이번 작업에서는 인덱스를 최소화하고, 외래키의 삭제 전이를 보수적으로 조정해 INSERT/UPDATE 성능을 회복하고 무결성을 보강했습니다.

예시 코드 (단순화된 엔티티)

// Before: 인덱스/연쇄 옵션이 과함 (예시)
@Entity('resource')
@Index(['ownerId', 'isArchived'])
export class Resource {
  @Column({ default: false }) isArchived: boolean;
  @ManyToOne(() => Folder, { onDelete: 'CASCADE' }) folder: Folder | null;
}

// After: 필요한 관계만, 보수적 전이 (예시)
@Entity('resource')
export class Resource {
  @Column({ default: false }) isArchived: boolean;
  @ManyToOne(() => Folder) folder: Folder | null; // onDelete 생략 → 애플리케이션 레벨로 제어
}

2) N+1 제거: “폴더별 미읽은 개수”를 한 방에

기존엔 폴더 수만큼 COUNT가 반복되는 N+1 쿼리였고, 이를 IN + GROUP BY 단일 쿼리로 교체했습니다.

예시 코드 (집계 API)

// Before: 폴더마다 개별 COUNT (예시)
const withUnread = await Promise.all(
  folders.map(async (f) => ({ ...f, unread: await countUnread(f.id) }))
);

// After: 한 번에 묶어서 조회 (예시)
const ids = folders.map(f => f.id);
const rows = await db.query(/* sql */ `
  SELECT folder_id, COUNT(*) AS unread
  FROM link
  WHERE is_trashed = false
    AND last_use_at IS NULL
    AND folder_id = ANY($1)
  GROUP BY folder_id
`, [ids]);

const unreadMap = new Map(rows.map(r => [Number(r.folder_id), Number(r.unread)]));
const withUnreadFast = folders.map(f => ({ ...f, unread: unreadMap.get(f.id) ?? 0 }));

폴더 10개 기준 11쿼리 → 2쿼리로 축소(82%↓).


3) 대량 UPDATE 최적화: CASE 문으로 원자적 일괄 반영

아이템 정렬처럼 “행마다 다른 값”을 갱신해야 한다면, 반복 UPDATE 대신 CASE-based 단일 UPDATE로 전환합니다. 트랜잭션 비용이 줄고, 원자성이 보장됩니다.

예시 코드 (정렬 순서 일괄 반영)

// Before: 항목마다 UPDATE (예시)
for (const it of items) {
  await db.update('sort', { sort_order: it.order }, { item_id: it.id });
}

// After: CASE 한 번으로 (예시)
const ids = items.map(x => x.id);
const cases = items.map(x => `WHEN item_id = ${x.id} THEN ${x.order}`).join(' ');
await db.query(`
  UPDATE sort
  SET sort_order = CASE ${cases} ELSE sort_order END
  WHERE item_id = ANY($1)
`, [ids]);

실측 예시: 5개 항목 정렬 시 10쿼리 → 1쿼리(90%↓).


4) 순차 → 병렬: 대량 이동·복사 작업 가속

목록 이동/복사 등 I/O가 많은 작업은 타입별 분리 + Promise.all로 병렬화하여 전체 처리 시간을 줄였습니다.

예시 코드 (이동 작업)

// Before: 순차 처리 (예시)
for (const it of items) await moveOne(it);

// After: 타입별 병렬 처리 (예시)
const linkItems = items.filter(i => i.type === 'link');
const folderItems = items.filter(i => i.type === 'folder');

await Promise.all([
  Promise.all(linkItems.map(moveOneLink)),
  Promise.all(folderItems.map(moveOneFolder)),
]);

10개 항목(링크5/폴더5) 이동 시, 쿼리 수와 경로가 단축되며 처리 시간 체감이 발생.


5) 응용: Publication/Item 서비스 단순화

복잡한 재귀를 반복/분할로 치환하고, 알림 등 부가 책임을 분리했습니다.
정렬은 머지 정렬 커스텀 코드 → 내장 Array.prototype.sort로 단순화해 가독성과 성능을 동시에 얻었습니다.

예시 코드 (정렬 단순화)

// Before: 커스텀 병합 정렬 (예시)
function mergeSortByOrder(listA, listB) { /* ...길고 복잡... */ }

// After: 내장 sort로 충분 (예시)
const sorted = [...links, ...folders].sort((a, b) => a.order - b.order);

6) 유지·데이터 모델 개선: Soft Delete/Unique 명시

운영 로그성 데이터엔 Soft Delete가 유용합니다. 또한 URL 등 고유키는 Unique 제약을 스키마에 명확히 두어 중복을 차단합니다.

예시 코드

// Soft Delete (예시)
@Entity('maintenance')
class Maintenance {
  @DeleteDateColumn() deletedAt: Date;
}

// Unique 제약 (예시)
@Entity('og_metadata')
@Unique(['url'])
class OgMeta {
  @Column() url: string;
}

성과 (운영 지표 기준)

  • 응답 시간: 50%+ 단축
  • DB 부하: 70% 감소
  • 코드베이스: 403줄 순감(677 삭제, 274 추가)
  • 확장성/안정성: 대량 처리 시 일관된 성능 유지, 트랜잭션/원자성 보장
    근거: 프로젝트 정리 문서의 결론/지표 요약.

체크리스트: 우리 팀에 바로 적용하려면

  1. 스키마: 과도한 인덱스/연쇄 옵션 제거, 유니크/널 제약을 모델 의도로 맞춤.
  2. 쿼리: N+1 의심 구간을 IN+GROUP BY로 통합, 다건 UPDATE는 CASE 단일화.
  3. 동시성: CPU/IO 바운드별로 병렬화 설계(Promise.all/배치 크기 제한).
  4. 코드 품질: 커스텀 정렬·재귀 로직을 내장 함수/반복으로 단순화.
  5. 삭제 정책: 로그·이력엔 Soft Delete, 하드 삭제는 최소화.

마무리

이번 리팩터링은 DB 설계 → 쿼리 전략 → 동시성 → 도메인 로직을 한 흐름으로 정돈해, 측정 가능한 성과를 냈다는 점이 핵심입니다. 같은 문제를 겪는 팀이라면 위 체크리스트부터 적용해 보세요. 성능은 결국 구조에서 나옵니다. (요약 근거)

참고: 본 글의 기술 근거는 사용자가 제공한 리팩터링 분석 파일을 기반으로 하며, 예시 코드는 운영 코드와 무관하게 이해를 돕기 위해 별도로 구성되었습니다.


🧪 링크 드라퍼, 정식 출시!

링크 드라퍼는 단순한 저장 툴이 아닙니다.
정리하고, 수정하고, 다시 꺼내보게 만드는 링크 관리 도구를 지향하고 있습니다.

• 🔗 빠르고 간편한 링크 저장
• 🧠 저장한 링크를 폴더별로 정리
• 🌐 폴더를 친구에게 공유 가능
• ⚡ 크롬 익스텐션 원클릭 저장

👉 링크 드라퍼 사용하러 가기
👉 크롬 웹스토어에서 설치하기


💬 카카오톡 채널 추가하고 소식 받기

서비스 업데이트
기능 꿀팁
카카오톡 채널을 통해 빠르게 받아보세요!
👉 카카오톡 채널 추가하기

profile
“기록하는 습관을 도구로 만들다 — 두 개발자의 링크 드라퍼 구축기”

0개의 댓글