🚀 도전 목표
퀴즈 서비스 삭제 기능 성능 최적화
💡 핵심 과정 및 결과
YouQuiz? 서비스에서 하나의 클래스(Class)는 다수의 퀴즈(Quiz)를 포함하고 있다.
- 각 퀴즈는 여러 개의 선택지(Choice)를 보유
- 계층 구조: Class(1) → Quiz(N) → Choice(M)
많은 퀴즈가 생성되는 만큼 많은 퀴즈도 삭제될 것이다.
- 클래스 삭제 시 연관된 모든 퀴즈와 선택지도 함께 삭제 필요
- 클래스 당 평균 10개 이상의 퀴즈와 각 퀴즈당 4개 이상의 선택지 예상
- 향후 서비스 확장 시 데이터 증가로 인한 성능 저하 우려
따라서 클래스 삭제 기능의 성능을 파악하고, 개선사항을 트러블 슈팅해보았다.
퀴즈 서비스의 경우 한 게임 당 문제의 개수는 10개가 넘어가는 경우가 많아질 것이라 보았다.
삭제하는 방법은 다음과 같이 두 개로 나뉘어진다.
순차적 삭제 vs 벌크 삭제
그렇기에, 순차적 삭제의 성능과 벌크 삭제의 성능 분석이 필요했다.
벌크 삭제란?
데이터베이스에서 여러 레코드를 한 번의 쿼리로 삭제하는 방식이다. 기존의 순차적 삭제 방식과 달리, 서브쿼리를 활용하여 연관된 데이터를 한 번에 처리할 수 있다.
기능과 벌크 삭제의 적절성
벌크 삭제의 주요 단점으로는 대량의 로그 생성을 인한 디스크 I/O가 증가 될 수 있으며 트랜잭션 롤백 시 복구 비용이 증가한다는 문제가 있다. 그리고 여러 행을 잠그기 때문에 락(Lock) 경합 가능성이 증가한다.
하지만 우리 프로젝트의 삭제 단위는 클래스 별로 이루어지기 때문에 락 경합 가능성은 덜하다고 판단이 되었다. 물론 디스크 I/O와 트랜잭션 롤백 문제는 여전히 문제가 될 수 있다.
그럼에도 불구하고 추후에 서술할 성능 분석에 따른 결과가 이러한 단점을 상쇄하고도 벌크 삭제를 선택할 수 있었다.
순차적 삭제 코드
async deleteWithRelations(classEntity: Class): Promise<void> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
if (classEntity.quizzes) {
for (const quiz of classEntity.quizzes) {
if (quiz.choices && quiz.choices.length > 0) {
for (const choice of quiz.choices) {
await queryRunner.manager.delete(Choice, {
id: choice.id,
});
}
}
}
}
if (classEntity.quizzes && classEntity.quizzes.length > 0) {
for (const quiz of classEntity.quizzes) {
await queryRunner.manager.delete(Quiz, {
id: quiz.id,
});
}
}
await queryRunner.manager.delete(Class, {
id: classEntity.id,
});
await queryRunner.commitTransaction();
} catch (error) {
console.error('Failed to delete class with relations:', error);
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
벌크 삭제 코드
async deleteWithRelations(id: number): Promise<void> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await queryRunner.query(
`DELETE FROM choice WHERE quiz_id IN (SELECT id FROM quiz WHERE class_id = ?)`,
[id],
);
await queryRunner.query(`DELETE FROM quiz WHERE class_id = ?`, [id]);
await queryRunner.query(`DELETE FROM class WHERE id = ?`, [id]);
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw new InternalServerErrorException('Failed to delete');
}
}
성능 분석을 위한 테스트 진행
성능 분석을 위한 계획
성능 측정 코드 구현
const startTime = performance.now();
// 삭제 로직 실행
const endTime = performance.now();
console.log(`삭제 실행 시간: ${endTime - startTime}ms`);
테스트 절차
- 각 시나리오별 테스트 데이터 생성
- 순차적 삭제와 벌크 삭제 각각 실행
- 실행 시간 기록 및 비교
검증 방법
- 실행 전후 데이터베이스 상태 확인
테스트 결과
소규모 데이터 (2 퀴즈, 6 선택지)
- 벌크 삭제: 30.99ms
- 순차적 삭제: 28.85ms
- 성능 차이 미미
이때만 해도 둘의 차이가 크지않다는 것을 알 수 있다.
하지만 벌크 삭제의 성능은 삭제할 데이터의 개수가 증가할수록 빛을 발한다.
중규모 데이터 (10 퀴즈, 20 선택지)
- 벌크 삭제: 37.02ms
- 순차적 삭제: 59.91ms
- 벌크 삭제가 1.62배 더 빠름
대규모 데이터 (15 퀴즈, 46 선택지)
- 벌크 삭제: 34.40ms
- 순차적 삭제: 77.14ms
- 벌크 삭제가 2.24배 더 빠름
데이터의 규모가 커질수록 성능의 차이가 커지는 것을 알 수 있다. 이러한 큰 차이는 이전에 말했던 디스크 I/O와 트랜잭션 시 롤백 문제를 상쇄하고도 남을 장점이라고 판단되었기에 벌크 삭제를 선택하게 되었다.
성능 개선 내역
- 성능 최적화
- 대규모 데이터(15퀴즈, 46선택지) 기준 2.24배 성능 향상
- 데이터 증가에 따른 선형적 성능 저하 방지
- 실행 시간 예측 가능성 확보
- 코드 품질 개선
- 중첩 루프 제거로 복잡도 감소 (O(N*M) → O(1))
- 트랜잭션 처리 시간 단축
- 유지보수성 향상
- 리소스 관리
- 데이터베이스 커넥션 사용 시간 감소
- 메모리 사용량 최적화
- 시스템 리소스 효율적 활용
이런 성능 향상은 N+1 문제 해결과 데이터베이스 round trip 최소화에 기인한다.
각 테이블별 단일 쿼리 처리로 전체 쿼리 실행 횟수를 크게 줄였다.
🔎 개선 사항
위에 나온 테스트는 로컬 환경에서 이루어진 단적인 경험이기에 서비스 운영 간의 실제로 이뤄지는 성능을 기록해야한다.
실제 서비스 단계에서 발생하는 문제점을 파악하고자 인프라에 추가적인 설정이 필요하다.
인덱스 전략 최적화
- quiz_id, class_id에 대한 복합 인덱스 검토
- 삭제 성능 추가 향상 기대
성능 모니터링 도입
- 실제 서비스 환경에서의 삭제 성능 추적
- 데이터 규모별 성능 지표 수집
장애 대응 체계
- 트랜잭션 실패 시 복구 전략 수립
- 롤백 상황 모니터링
'WEB > 트러블슈팅' 카테고리의 다른 글
[트러블 슈팅] 복합키 인덱스 최적화 (0) | 2025.01.28 |
---|---|
[트러블 슈팅] RTR 도입기 (0) | 2025.01.27 |
[트러블 슈팅] MySQL 시간대(Timezone) 설정 이슈 (0) | 2025.01.07 |