반응형
만약, 갑자기 서버에 많은 유저들이 조회를 서버에 걸어온다면 어떻게될까? 서버에 부하가 생겨서 부담이 커지기 때문에 서버가 다운될수도 있다. → 이를 대비하기 위해 성능 최적화 필요.
여기서 가장 많이 트래픽이 모이는 곳이 CRUD중에서 R인 “조회” 에서 많이 일어난다.
현재 코드에는 정적 쿼리에서 동적 쿼리로 바꾸려고 적용했었던 적이 있었다.
동적 쿼리 적용을위해 QueryDSL을 사용했으며, 이제 해당하는 그 코드에 대해서 조회 성능을 개선을 위한 로직을 구현 해보았다.
🚄 문제 조회 성능 최적화
1️⃣ QueryDSL 리팩토링
@Repository
@RequiredArgsConstructor
public class ProblemQueryRepositoryImpl implements ProblemRepositoryCustom {
private final JPAQueryFactory jpaQueryFactory;
@Override
public Page<Problem> searchByCondition(Pageable pageable, ProblemSearchCondition searchCondition) {
QProblem problem = QProblem.problem;
QProblemCategory problemCategory = QProblemCategory.problemCategory;
QCategory category = QCategory.category;
// where 조건을 깔끔 하게 조립
BooleanBuilder builder = new BooleanBuilder();
builder.and(problem.isDeleted.isFalse());
JPAQuery<Problem> query = jpaQueryFactory
.selectDistinct(problem)
.from(problem)
.leftJoin(problemCategory).on(problem.eq(problemCategory.problem))
.leftJoin(problemCategory.category, category)
.where(builder);
// 카테고리 필터링
if (searchCondition.category() != null) {
builder.and(category.code.eq(searchCondition.category())
.or(category.korName.eq(searchCondition.category())));
}
// 난이도 필터링
if (searchCondition.difficulty() != null) {
builder.and(problem.difficulty.eq(Difficulty.valueOf(searchCondition.difficulty())));
}
List<Problem> content = query
.offset(pageable.getOffset()) /* offset + limit를 통한 페이징 최적화 */
.limit(pageable.getPageSize())
.orderBy(problem.createdAt.desc())
.fetch();
Long total = jpaQueryFactory
.select(problem.countDistinct())
.from(problem)
.leftJoin(problemCategory).on(problem.eq(problemCategory.problem)) // <-- leftJoin 적용
.leftJoin(problemCategory.category, category)
.where(builder)
.fetchOne();
return new PageImpl<>(content, pageable, total != null ? total : 0);
}
}
주요 쿼리 전략
- Problem ↔ ProblemCategory ↔ Category 간 다대다(N:N) 관계를 leftJoin을 통해 조인
- 카테고리 필터는 code 또는 korName으로 지원
- 동적 조건을 BooleanBuilder로 유연하게 조립
- distinct, countDistinct()로 중복 문제 제거
- offset + limit으로 페이징 최적화
- offset은 시작위치, limit는 데이터 개수 제한을 의미함.
- Soft-delete 문제(isDeleted = false) 필터링
| 최적화 포인트 | 설명 |
| 카테고리 단방향 설계 대응 | Problem ↔ ProblemCategory ↔ Category 조인을 명시적으로 수행 |
| 중복 방지 | selectDistinct, countDistinct로 문제 중복 제거 |
| 조건별 동적 쿼리 | 조건이 있을 때만 .and(...)로 추가되므로 쿼리 성능 손실 최소화 |
| N+1 방지 | 직접 조인 수행 (엔티티 관계를 통한 간접 접근 아님) |
| 페이징 최적화 | .offset(), .limit() 적용하여 한 번에 필요한 데이터만 조회 |
2️⃣ EAGER 즉시로딩 사용
최근에 AWS S3을 활용해 문제 이미지 기능을 구현하였다.
문제 이미지는 상세 조회시에만 쓰이기 때문에, 단건 조회 이므로 EAGER를 사용하는 경우에는 적합하게 쓰인다.
그래서 현재 Problem 엔티티 내 imageUrl이라는 필드가 추가 되어있고, Lazy로 사용중이다.
Lazy ( 지연 로딩 ) 사용 할 경우,
problem.getImageUrl();
// → 이 시점에 추가 SELECT 쿼리 1번 더 발생
즉, DB에 두 번 접근:
- 1번: Problem 엔티티
- 1번: imageUrl 리스트
Eager( 즉시 로딩 ) 사용 할 경우,
// 처음 SELECT 시에 imageUrl 함께 로딩
select ... from problem
left outer join problem_image_url ...
→ 한 번의 쿼리로 모두 로딩, 쿼리 수 줄어듦 → 이게 바로 성능 최적화
그래서 Problem 엔티티 내 해당하는 필드를 EAGER로 바꿔주었다.
@ElementCollection(fetch = FetchType.EAGER) // 즉시 로딩으로 변경
private List<String> imageUrl = new ArrayList<>();
Eager를 사용할때 상세 조회 에서는 효과적으로 쓰일 수 있지만, 리스트 조회( 목록 조회 ) 같은 경우에는 과도한 로딩이 발생할 수 있기 때문에 적절하지 않을수도 있다.
반응형