
- DB 단계 최적화: 불필요 인덱스/과도한 CASCADE 정리 → 쓰기 성능 및 무결성 개선.
- 쿼리 구조 개선: N+1 제거(폴더별 미읽은 수 집계), 다건 UPDATE를 CASE 단일 쿼리로 통합.
- 동시성 활용: 순차 처리 →
Promise.all기반 병렬 처리로 대량 작업 가속.- 결과: 응답 시간 50%+ 단축, DB 부하 70%↓, 코드 403줄 순감.
운영 환경에서 API 응답이 튀고, 피크 타임에 DB가 뜨거워지는 문제는 대부분 “쿼리 구조·인덱스·동시성” 3축에서 원인이 드러납니다. 본 리팩터링은 이 세 축을 단계적으로 다뤄 측정 가능한 성과를 만들었습니다. (응답시간/부하/코드 줄 수)
운영 중 적체가 보이는 프로젝트의 공통점은 인덱스 과다와 과격한 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 생략 → 애플리케이션 레벨로 제어
}
기존엔 폴더 수만큼 COUNT가 반복되는 N+1 쿼리였고, 이를 IN + GROUP BY 단일 쿼리로 교체했습니다.
// 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%↓).
아이템 정렬처럼 “행마다 다른 값”을 갱신해야 한다면, 반복 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%↓).
목록 이동/복사 등 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) 이동 시, 쿼리 수와 경로가 단축되며 처리 시간 체감이 발생.
복잡한 재귀를 반복/분할로 치환하고, 알림 등 부가 책임을 분리했습니다.
정렬은 머지 정렬 커스텀 코드 → 내장 Array.prototype.sort로 단순화해 가독성과 성능을 동시에 얻었습니다.
// Before: 커스텀 병합 정렬 (예시)
function mergeSortByOrder(listA, listB) { /* ...길고 복잡... */ }
// After: 내장 sort로 충분 (예시)
const sorted = [...links, ...folders].sort((a, b) => a.order - b.order);
운영 로그성 데이터엔 Soft Delete가 유용합니다. 또한 URL 등 고유키는 Unique 제약을 스키마에 명확히 두어 중복을 차단합니다.
// Soft Delete (예시)
@Entity('maintenance')
class Maintenance {
@DeleteDateColumn() deletedAt: Date;
}
// Unique 제약 (예시)
@Entity('og_metadata')
@Unique(['url'])
class OgMeta {
@Column() url: string;
}
Promise.all/배치 크기 제한). 이번 리팩터링은 DB 설계 → 쿼리 전략 → 동시성 → 도메인 로직을 한 흐름으로 정돈해, 측정 가능한 성과를 냈다는 점이 핵심입니다. 같은 문제를 겪는 팀이라면 위 체크리스트부터 적용해 보세요. 성능은 결국 구조에서 나옵니다. (요약 근거)
참고: 본 글의 기술 근거는 사용자가 제공한 리팩터링 분석 파일을 기반으로 하며, 예시 코드는 운영 코드와 무관하게 이해를 돕기 위해 별도로 구성되었습니다.
링크 드라퍼는 단순한 저장 툴이 아닙니다.
정리하고, 수정하고, 다시 꺼내보게 만드는 링크 관리 도구를 지향하고 있습니다.
• 🔗 빠르고 간편한 링크 저장
• 🧠 저장한 링크를 폴더별로 정리
• 🌐 폴더를 친구에게 공유 가능
• ⚡ 크롬 익스텐션 원클릭 저장
👉 링크 드라퍼 사용하러 가기
👉 크롬 웹스토어에서 설치하기
서비스 업데이트
기능 꿀팁
카카오톡 채널을 통해 빠르게 받아보세요!
👉 카카오톡 채널 추가하기