27일 차 TIL

2026. 3. 8. 15:15·내배캠

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번
  2. 각 카테고리 조회 1번씩 ( 총 n번 )
  3. 가게-카테고리 생성 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번
  2. 입력받은 카테고리에 해당하는 카테고리 목록 조회 1번 ( 총 1번 )
  3. 가게-카테고리 생성 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
'내배캠' 카테고리의 다른 글
  • 29일차 TIL - RestDocs 사용법
  • 28일 차 TIL
  • [내일배움캠프] 25일 차 TIL - CodeRabbit 코드 리뷰
  • [내일배움캠프] 24일 차 TIL 클린 아키텍처와 DDD
MvA
MvA
백엔드 개발자 김재현입니다. 주로 공부하면서 느낀점을 기록합니다.
  • MvA
    Man vs Ai
    MvA
  • 전체
    오늘
    어제
    • 분류 전체보기 (94)
      • Java (6)
      • Python (8)
        • 딥러닝 (1)
        • 머신러닝 (7)
      • JavaScript (2)
      • 내배캠 (60)
      • 개인 프로젝트 (11)
      • 책 후기 (5)
      • 기타 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    머신러닝
    TiL
    내일배움캠프
    배포
    딥러닝
    Riot API
    아키텍처
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
MvA
27일 차 TIL
상단으로

티스토리툴바