27일 차 TIL
가게-카테고리에서, 수정 후 저장하는 로직에서 발생하는 n+1 조회 문제를 해결,
카테고리 목록이 제대로 조회되도록 수정,
가게 상세조회 시, 삭제 칼럼은 조회에서 제외하도록 수정했다.
1. 가게-카테고리 n+1 조회 문제 해결 과정
가게와 카테고리의 중간 테이블의 서비스 구현체인
StoreCategoryServiceImpl의 수정 메서드 updateStoreCategories를 수정했다.
이 메서드의 용도)
- 기존 가게의 카테고리 목록을 사장님이 입력한 카테고리 목록으로 변경한다.
이때 발생하는 n+1 조회 문제를 findById()에서 findAllById로 바꾸어 해결했다.
- 기존 코드
@Override
@Transactional
public void updateStoreCategories(Store store, List<UUID> categoryIds) {
// 1. DB에서 기존 해당 가게의 데이터를 지운다.
storeCategoryRepository.deleteByStore(store);
if (categoryIds == null || categoryIds.isEmpty()) {
return;
}
// 2. 새로운 카테고리 ID 목록으로 다시 생성
for (UUID categoryId : categoryIds) {
Category category = categoryRepository.findById(categoryId)
.orElseThrow(() ->
new IllegalArgumentException("존재하지 않는 카테고리 ID입니다: " + categoryId));
createStoreCategory(store, category);
}
}
입력받은 카테고리마다 조회 + 저장을 하기 때문에 n번 조회 + n번 생성이 발생한다.
메서드의 총호출을 정리하면 2n+1의 쿼리를 호출한다.
- 삭제쿼리 1번
- 각 카테고리 조회 1번씩 ( 총 n번 )
- 가게-카테고리 생성 n번
- 수정된 코드
@Override
@Transactional
public void updateStoreCategories(Store store, List<UUID> categoryIds) {
// 1. 기존 연결 삭제
storeCategoryRepository.deleteByStore(store);
if (categoryIds == null || categoryIds.isEmpty()) {
return;
}
// 2. 한 번의 쿼리로 모든 카테고리 조회 (SELECT ... FROM category WHERE id IN (...))
List<Category> categories = categoryRepository.findAllById(categoryIds);
// 3. 입력받은 ID 개수와 조회된 개수가 다른지 체크 (예외 처리)
if (categories.size() != categoryIds.size()) {
throw new IllegalArgumentException("일부 카테고리 ID가 유효하지 않습니다.");
}
// 4. 생성 및 저장
for (Category category : categories) {
createStoreCategory(store, category);
}
}
}
입력받은 카테고리들에 해당하는 카테고리목록을 한 번에 조회하고 n번 생성한다.
메서드의 총호출을 정리하면 n+2의 쿼리를 호출한다.
- 삭제쿼리 1번
- 입력받은 카테고리에 해당하는 카테고리 목록 조회 1번 ( 총 1번 )
- 가게-카테고리 생성 n번
이때 n번생성을 1번으로 줄이고 싶다면?
saveAll()을 사용하고, yml에 batch 설정을 추가해주어야 한다.
- 배치설정을 추가한 yml
spring:
jpa:
properties:
hibernate:
jdbc.batch_size: 20 # 한 번에 보낼 쿼리 개수
order_inserts: true # Insert 쿼리 순서 최적화
- saveAll() 적용 예시
// 4. 생성 및 리스트에 담기
List<StoreCategory> newConnections = categories.stream()
.map(category -> new StoreCategory(store, category))
.toList();
// 5. 한 번에 저장 (Batch Insert 설정이 되어있다면 쿼리 1번으로 묶임)
storeCategoryRepository.saveAll(newConnections);
만약 yml에서 배치 설정을 하지 않는다면 동일하게 생성 쿼리가 n번 호출된다.
2. 카테고리 목록 조회 에러 해결
단순하게 서비스 interface에서는 로직을 비워놨는데
서비스 Impl에서는 로직을 작성해 둔 줄 알고 사용했었다.
- 수정 후
@Override
public List<StoreCategory> findAllByStore(Store store) {
return storeCategoryJpaRepository.findAllByStore(store);
}
@Override
public Optional<StoreCategory> findFirstByStore(Store store) {
return storeCategoryJpaRepository.findFirstByStore(store);
}
@Override
public void deleteByStore(Store store) {
storeCategoryJpaRepository.deleteByStore(store);
}
원래는
return List.of();
return Optional.empty();
그리고 deleteByStore는 반환값이 void라서 아예 비워놨었다.
3. 가게 상세 조회시 삭제 칼럼도 조회되는 문제
가게의 목록은 QueryDSL로 따로 메서드를 만들어서 사용했기 때문에
deleted_by 컬럼에 값이 존재하는 행은 목록조회 시 제외되었다.
하지만 상세조회는 JPA 쿼리 메서드를 그대로 사용했기에
deleted_by에 의해서 제외되지 않았다.
따라서 메서드에 filter를 적용해서 deleted_by가 null인 값만 가져오도록 변경했다.
- 수정 후
@Repository
@RequiredArgsConstructor
public class StoreRepositoryImpl implements StoreRepository {
private final StoreJpaRepository storeJpaRepository;
// 저장, 수정, 삭제 메서드 생략
@Override
public Optional<Store> findById(UUID id) {
return storeJpaRepository.findById(id)
.filter(store -> store.getDeletedAt() == null);
}
@Override
public Page<Store> searchStores(StoreSearchCondition condition, Pageable pageable) {
// 이제 storeJpaRepository가 StoreRepositoryCustom을 상속받았으므로
// 직접 호출이 가능합니다. 실제 로직은 StoreRepositoryCustomImpl에서 실행됩니다.
return storeJpaRepository.searchStores(condition, pageable);
}
}
filter(조건)은 조건이 true인 것만 가져온다.
4. 가게명 조회시 무분별한 dto 생성
기존 코드
@Transactional(readOnly = true)
@Override
public StoreResponse getStore(UUID storeId) {
Store store = storeRepository.findById(storeId)
.orElseThrow(() -> new IllegalArgumentException("해당 가게를 찾을 수 없습니다. ID: " + storeId));
List<String> categoryNames = storeCategoryRepository.findAllByStore(store).stream()
.map(sc -> sc.getCategory().getCategoryName())
.toList();
return StoreResponse.from(store, store.getUser().getNickname(), categoryNames);
}
해결법
가게와 가게_카테고리를 양방향 매핑한다.
그 후 store.getCategoryList().getCategoryName() 처럼 가져온다.
5. QueryDSL 카운트 쿼리 최적화
카운트 쿼리를 PageImpl에 파라미터로 넘겨줄 때,
실행 후 결과데이터를 넘겨주는 방법과
쿼리 자체를 넘겨주는 방법이 있다.
쿼리 자체를 넘겨주고 필요할 떄 실행하게 함으로써
첫 페이지나 마지막 페이지에 남은 데이터의 개수를 예상할 수 있다면
카운트쿼리를 실행하지 않고 바로 가져올 수 있다.
- 기존의 코드
@RequiredArgsConstructor
public class StoreJpaRepositoryImpl implements StoreQuerydslRepository {
private final JPAQueryFactory queryFactory;
private final QStore store = QStore.store;
@Override
public Page<Store> searchStores(StoreSearchCondition condition, Pageable pageable) {
List<Store> content = queryFactory
.selectFrom(store)
.where(
nameContains(condition.name()),
store.deletedAt.isNull()
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
// 카운트 쿼리도 별도로 작성 가능
Long total = queryFactory
.select(store.count())
.from(store)
.where(
nameContains(condition.name()),
store.deletedAt.isNull()
)
.fetchOne();
return new PageImpl<>(content, pageable, total != null ? total : 0L);
}
private BooleanExpression nameContains(String name) {
return name != null ? store.name.contains(name) : null;
}
}
- 카운트 쿼리를 필요할 때만 실행하여 최적화한 코드
@RequiredArgsConstructor
public class StoreJpaRepositoryImpl implements StoreQuerydslRepository {
// JpaQueryFactory, QStore 생략
// searchStores() 생략
// 필요 시 카운트 쿼리도 실행
JPAQuery<Long> countQuery = queryFactory
.select(store.count())
.from(store)
.where(
nameContains(condition.name()),
store.deletedAt.isNull()
);
// fetchOne(); 대신 정의만 함.
// 필요할 때 카운트쿼리도 실행
return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}
// 이름포함 util 메서드 생략
}
1. 가져올 데이터가 size보다 작다면, 안 가져오고 자동으로 첫 페이지의 요소의 개수만큼만 가져온다.
ex) 페이지당 10개를 가져오는데, 전체 데이터 개수가 3개라면? => 자동으로 3개만 가져온다.
2. 마지막 페이지라면? 마지막 페이지의 남은 갯수만큼만 가져온다.
3. 기존에는 무조건 카운트 쿼리가 실행됐지만,
수정 후에는 Lazy 방식으로 필요할 때만 가져온다.
이 경우는 사용자가 첫 페이지만 볼 일이 많다면 DB의 부하를 감소시켜 준다.
6. QueryDSL fetchJoin() 사용 시 문제점
fetchJoin은 기본적으로 객체 그래프를 탐색하는 방식이다.
따라서 해당 엔티티에서 가져올 객체를 필드로써 가지고 있어야 한다.
fetchJoin은 양방향관계이거나
다대 1 관계처럼 가져올 객체를 외래키로써 필드로 가지고 있거나
1대 1 관계에서 외래키의 주인인 경우에 사용할 수 있다.
하지만 다대 1의 단방향 관계에서
1의 해당하는 쪽에서는 fetchJoin을 사용할 수 없다. ( 가져올 객체에 대한 정보가 없기 때문 )
이 경우에는 "DTO 조회" 방식을 통해 엔티티를 거치지 않고 가져올 수 있다.
그래서 5번의 코드를 다시 DTO 직접 조회방법으로 바꾸려고 한다.
'내배캠' 카테고리의 다른 글
| 29일차 TIL - RestDocs 사용법 (0) | 2026.03.10 |
|---|---|
| 28일 차 TIL (0) | 2026.03.09 |
| [내일배움캠프] 25일 차 TIL - CodeRabbit 코드 리뷰 (0) | 2026.03.06 |
| [내일배움캠프] 24일 차 TIL 클린 아키텍처와 DDD (0) | 2026.03.05 |
| 토스 사용 시, 결제 시나리오 (0) | 2026.03.04 |
