Ch9. Spring Data JPA로 효율적인 목록 조회 API 설계와 구현

김지민·2024년 12월 29일

UMC springboot

목록 보기
8/9

학습 목표

  1. 효율적인 목록 조회 API 설계 방법을 탐구하고, 페이징 처리의 원리를 이해한다.
  2. Spring Data JPA를 활용하여 데이터 양이 많은 환경에서 성능 최적화 방법을 학습한다.

1. 왜 목록 조회 API가 중요할까?

🌟 목록 조회 API의 중요성

  1. 성능 최적화의 핵심:
    • 조회 API는 시스템 부하의 주요 원인 중 하나입니다. 데이터 양이 많을수록 효율적인 처리가 중요합니다.
  2. 사용자 경험 개선:
    • 클라이언트가 한 번에 모든 데이터를 로드할 필요 없이 필요한 만큼의 데이터를 요청할 수 있어야 합니다.
  3. 데이터 전송 비용 감소:
    • 불필요한 데이터를 줄이고 네트워크 전송량을 최소화합니다.

⚠️ 문제: 데이터가 많아질수록 발생하는 성능 문제

  • 예시 상황:
    리뷰 데이터가 10만 건일 때, 클라이언트가 한 번의 요청으로 모든 데이터를 요청한다면:
    1. 네트워크 부하: 전송량이 기하급수적으로 증가.
    2. 메모리 문제: 클라이언트와 서버에서 데이터 처리 용량 초과.
    3. 응답 지연: 사용자 경험 저하.

이를 해결하기 위해 페이징(Paging)과 같은 기법이 필요합니다.


2. 설계 원리와 구현 프로세스

자 이제 차근차근 API를 만들어 봅시다.
보통 API를 만들 때 아래의 순서를 따르면 우왕좌왕 하지 않고 만들 수 있습니다.

🧭 전제 조건: API URL은 설계가 되었다는 전제입니다!
1. API 시그니처를 만든다!

  • 응답과 요청 DTO를 만든다.
  • 컨트롤러에서 어떤 형태를 리턴하는지, 어떤 파라미터가 필요한지, URI는 무엇인지 HTTP Method는 무엇인지만 정해둔다.
  • 컨버터 정의만 해둔다.
  1. API 시그니처를 바탕으로 Swagger에 명세를 해준다.
  2. 데이터베이스와 연결하는 부분을 만든다.
  3. 비즈니스 로직을 만든다.
  4. 컨트롤러를 완성한다.
  5. Validation 처리를 한다.

2.1. API 시그니처 설계

API 시그니처란?

API 시그니처는 API의 기본 설계 정보로, 클라이언트와 서버 간에 어떤 데이터를 주고받을지 정의한 약속입니다. API가 어떤 역할을 하고, 어떤 데이터를 입력받고 출력하는지 명확히 알려주는 가이드라고 볼 수 있습니다.

주요 구성 요소
1. URI: API의 주소 (예: /stores/{storeId}/reviews)
2. HTTP Method: 작업 유형 (예: GET, POST)
3. 요청 데이터:

  • Path Variable: URL 경로에 포함된 데이터 (예: {storeId})
  • Query Parameter: URL 쿼리 스트링 데이터 (예: ?page=1)
  • Body: 요청 본문 데이터 (예: JSON 형식)
  1. 응답 데이터: 서버가 반환하는 데이터의 구조 (예: JSON)
  2. 상태 코드: 요청 결과를 나타내는 코드 (예: 200 OK, 400 Bad Request)

간단한 예: 특정 가게의 리뷰 목록을 조회하는 API

  • URI: /stores/{storeId}/reviews
  • HTTP Method: GET
  • 요청 데이터:
    • Path Variable: storeId (가게 ID)
    • Query Parameter: page (페이지 번호)
  • 응답 데이터:
    {
        "reviewList": [...],
        "totalElements": 100,
        "isLast": false
    }
  • 상태 코드: 200 OK, 401 Unauthorized

해당 글에서 API 시그니처를 만든다는 말은 아래와 같습니다.
1. 응답과 요청 DTO를 만든다.
2. 컨트롤러에서 어떤 형태를 리턴하는지, 어떤 파라미터가 필요한지, URI는 무엇인지 HTTP Method는 무엇인지만 정해둔다.
3. 컨버터 정의만 해둔다.

📘 DTO 구현 코드

public class StoreResponseDTO {

    @Builder
    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    public static class ReviewPreViewListDTO {
        List<ReviewPreViewDTO> reviewList;
        Integer listSize;
        Integer totalPage;
        Long totalElements;
        Boolean isFirst;
        Boolean isLast;
    }

    @Builder
    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    public static class ReviewPreViewDTO {
        String ownerNickname;
        Float score;
        String body;
        LocalDate createdAt;
    }
}

설계 원칙

  • ReviewPreViewDTO는 단일 리뷰 정보를 담고, 이를 리스트 형태로 묶은 ReviewPreViewListDTO를 반환.
  • Builder 패턴을 사용해 불변성을 보장하고 코드 가독성을 향상.

2.2. Convertor 겉 모양(정의만) 설계

public class StoreConverter {

    public static StoreResponseDTO.ReviewPreViewDTO reviewPreViewDTO(Review review) {
        return null;
    }

    public static StoreResponseDTO.ReviewPreViewListDTO reviewPreViewListDTO(List<Review> reviewList) {
        return null;
    }
}

2.3. Swagger를 이용한 API 명세 작성

@RestController
@RequiredArgsConstructor
@Validated
@RequestMapping("/stores")
public class StoreRestController {

    private final StoreQueryService storeQueryService;

    @GetMapping("/{storeId}/reviews")
    @Operation(summary = "특정 가게의 리뷰 목록 조회 API", description = "특정 가게의 리뷰들의 목록을 조회하는 API이며, 페이징을 포함합니다. Query String으로 페이지 번호를 제공합니다.")
    @ApiResponses({
        @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"),
        @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 제공해 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))),
        @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "access 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))),
        @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "access 토큰 형식 오류", content = @Content(schema = @Schema(implementation = ApiResponse.class))),
    })
    @Parameters({
        @Parameter(name = "storeId", description = "가게의 ID입니다 (Path Variable)"),
        @Parameter(name = "page", description = "페이지 번호입니다 (Query Parameter)")
    })
    public ApiResponse<StoreResponseDTO.ReviewPreViewListDTO> getReviewList(
            @ExistStore @PathVariable(name = "storeId") Long storeId,
            @RequestParam(name = "page") Integer page) {
        storeQueryService.getReviewList(storeId, page);
        return null;
    }
}
  • @Operation: API 설명을 정의(summary와 description 사용).
  • @ApiResponses: API 응답에 대한 설명 포함.
    • 에러 상황에는 content를 통해 오류 응답의 스키마를 정의.
    • 성공 응답은 스키마 없이 ApiResponse<T>를 보여줌.
  • @Parameters: 프론트엔드에서 전달해야 하는 파라미터를 명시.

2.4. 서비스 메서드 로직 작성

📘 서비스 인터페이스

public interface StoreQueryService {

    Page<Review> getReviewList(Long storeId, Integer page);
}

📘 리포지토리 인터페이스

public interface ReviewRepository extends JpaRepository<Review, Long> {

    Page<Review> findAllByStore(Store store, PageRequest pageRequest);
}

📘 서비스 구현

@Service
@RequiredArgsConstructor
public class StoreQueryServiceImpl implements StoreQueryService {

    private final StoreRepository storeRepository;
    private final ReviewRepository reviewRepository;

    @Override
    public Page<Review> getReviewList(Long storeId, Integer page) {
        Store store = storeRepository.findById(storeId).orElseThrow();
        return reviewRepository.findAllByStore(store, PageRequest.of(page, 10));
    }
}

serviceImpl

@Service
@RequiredArgsConstructor
public class StoreQueryServiceImpl implements StoreQueryService{

    private final StoreRepository storeRepository;
    
    private final ReviewRepository reviewRepository;

    // ... 다른 코드들

    @Override
    public Page<Review> getReviewList(Long StoreId, Integer page) {

        Store store = storeRepository.findById(StoreId).get();

        Page<Review> StorePage = reviewRepository.findAllByStore(store, PageRequest.of(page, 10));
        return StorePage;
    }
}

controller

@RestController
@RequiredArgsConstructor
@Validated
@RequestMapping("/stores")
public class StoreRestController {

    private final StoreQueryService storeQueryService;

    @GetMapping("/{storeId}/reviews")
    @Operation(summary = "특정 가게의 리뷰 목록 조회 API",description = "특정 가게의 리뷰들의 목록을 조회하는 API이며, 페이징을 포함합니다. query String 으로 page 번호를 주세요")
    @ApiResponses({
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200",description = "OK, 성공"),
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!",content = @Content(schema = @Schema(implementation = ApiResponse.class))),
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "acess 토큰 만료",content = @Content(schema = @Schema(implementation = ApiResponse.class))),
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "acess 토큰 모양이 이상함",content = @Content(schema = @Schema(implementation = ApiResponse.class))),
    })
    @Parameters({
            @Parameter(name = "storeId", description = "가게의 아이디, path variable 입니다!")
    })
    public ApiResponse<StoreResponseDTO.ReviewPreViewListDTO> getReviewList(@ExistStore @PathVariable(name = "storeId") Long storeId,@RequestParam(name = "page") Integer page){
        storeQueryService.getReviewList(storeId,page);
        return null;
    }
}

converter

여기서 converter를 보면,
ListDTO를 위해 리스트에 들어갈 DTO를 다른 Converter에서 제작해서 이를 Java stream을 통해 DTO의 List를 만드는 것을 알 수 있습니다.

public class StoreConverter {

    public static StoreResponseDTO.ReviewPreViewDTO reviewPreViewDTO(Review review){
        return StoreResponseDTO.ReviewPreViewDTO.builder()
                **.ownerNickname(review.getMember().getName()) // 객체그래프 탐색**
                .score(review.getScore())
                .createdAt(review.getCreatedAt().toLocalDate())
                .body(review.getBody())
                .build();
    }
    public static StoreResponseDTO.ReviewPreViewListDTO reviewPreViewListDTO(**Page**<Review> reviewList){

        List<StoreResponseDTO.ReviewPreViewDTO> reviewPreViewDTOList = reviewList.stream()
                .map(StoreConverter::reviewPreViewDTO).collect(Collectors.toList());

        return StoreResponseDTO.ReviewPreViewListDTO.builder()
                .isLast(reviewList.isLast())
                .isFirst(reviewList.isFirst())
                .totalPage(reviewList.getTotalPages())
                .totalElements(reviewList.getTotalElements())
                .listSize(reviewPreViewDTOList.size())
                .reviewList(reviewPreViewDTOList)
                .build();
    }
}

그리고 .ownerNickname(review.getMember().getName())

이 코드를 통해 review에 @MantyToOne으로 지정해둔 Member를 통해 아주 편하게 데이터를 가져오는 것을 확인 할 수 있습니다.

이는 객체 그래프 탐색 이라는 Spring Data JPA에서 사용 가능한 아주 강력한 기능입니다.

이제 컨트롤러를 controller를 converter에 맞게 바꾸고

@RestController
@RequiredArgsConstructor
@Validated
@RequestMapping("/stores")
public class StoreRestController {

    private final StoreQueryService storeQueryService;

    @GetMapping("/{storeId}/reviews")
    @Operation(summary = "특정 가게의 리뷰 목록 조회 API",description = "특정 가게의 리뷰들의 목록을 조회하는 API이며, 페이징을 포함합니다. query String 으로 page 번호를 주세요")
    @ApiResponses({
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200",description = "OK, 성공"),
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!",content = @Content(schema = @Schema(implementation = ApiResponse.class))),
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "acess 토큰 만료",content = @Content(schema = @Schema(implementation = ApiResponse.class))),
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "acess 토큰 모양이 이상함",content = @Content(schema = @Schema(implementation = ApiResponse.class))),
    })
    @Parameters({
            @Parameter(name = "storeId", description = "가게의 아이디, path variable 입니다!")
    })
    public ApiResponse<StoreResponseDTO.ReviewPreViewListDTO> getReviewList(@ExistStore @PathVariable(name = "storeId") Long storeId,@RequestParam(name = "page") Integer page){
        storeQueryService.getReviewList(storeId,page);
        return ApiResponse.onSuccess(StoreConverter.reviewPreViewListDTO(reviewList));
    }
}

3. CQRS 설계 원칙

CQRS (Command Query Responsibility Segregation)란?

CQRS는 데이터 변경 작업 (Command)조회 작업 (Query)을 분리하여 각 작업의 책임을 명확히 하는 설계 원칙입니다.


4. 객체 그래프 탐색과 Spring Data JPA

🌟 객체 그래프 탐색

Spring Data JPA에서 객체 그래프 탐색은 연관 관계에 있는 엔티티를 함께 조회하는 방법을 의미합니다. 보통 연관 관계 매핑(@OneToMany, @ManyToOne 등)된 엔티티를 처리할 때 사용됩니다.

주요 기법

  • JPQL 및 Fetch Join 사용
    • 특징
      • JPQL에서 JOIN FETCH 키워드를 사용하여 연관 데이터를 한 번에 로드합니다.
      • EAGER와 유사하지만, 개발자가 명시적으로 특정 상황에서만 데이터를 로드하도록 설계할 수 있습니다.
      • Fetch Join을 사용하면 즉시 로딩(EAGER)이 이루어지며, N+1 문제를 방지할 수 있습니다.
    • 장점
      • 한 번의 쿼리로 연관 데이터를 로드하므로 성능이 최적화됩니다.
      • 특정 조건이나 필터를 JPQL로 지정할 수 있어 유연합니다.
    • 단점
      • 복잡한 JPQL 작성이 필요하며, 쿼리가 길어질 수 있습니다.
      • 데이터가 많을 경우, 한 번의 쿼리로 많은 데이터를 로드해 메모리 문제를 일으킬 수 있습니다.
      • JPQL을 직접 작성해야 하므로 코드 유지보수가 어려울 수 있습니다.
    • 사용 예
      @Query("SELECT u FROM User u JOIN FETCH u.orders WHERE u.id = :id")
      User findUserWithOrders(@Param("id") Long id);
  • EntityGraph 사용
    • JPQL을 사용하지 않고, @EntityGraph를 통해 연관 데이터를 로드할 수 있습니다.
    • 특징
      • @EntityGraph를 사용해 JPQL 없이 객체 그래프 탐색을 정의할 수 있습니다.
      • 선언적으로 연관 엔티티를 함께 로드할 경로(attributePaths)를 지정합니다.
      • EntityGraph는 JPA Repository에서 바로 활용 가능하며, Lazy Loading 엔티티를 명시적으로 Fetch Join 형태로 로드합니다.
    • 장점
      • JPQL 작성 없이 선언적으로 연관 데이터를 로드할 수 있어 유지보수가 용이합니다.
      • 비즈니스 로직과 탐색 경로 설정이 분리되어 코드 가독성이 높아집니다.
      • 특정 메서드에만 적용 가능하므로 성능을 세밀하게 조정할 수 있습니다.
    • 단점
      • 설정된 경로(attributePaths) 이외의 데이터는 로드되지 않으므로 추가 데이터가 필요할 경우 다시 쿼리가 발생할 수 있습니다.
      • 복잡한 객체 그래프 탐색이 필요한 경우에는 사용이 제한적입니다.
    • 예제
      @EntityGraph(attributePaths = {"orders"})
      @Query("SELECT u FROM User u WHERE u.id = :id")
      User findUserWithOrders(@Param("id") Long id);
  • Hibernate 초기화 강제화
    • Lazy 로딩된 데이터를 강제로 초기화합니다.
    • 특징
      • Hibernate.initialize()를 사용해 지연 로딩된 데이터를 강제로 초기화합니다.
      • 특정 비즈니스 로직에 필요한 경우, Lazy 로딩된 연관 엔티티를 조회 시점에 로드합니다.
    • 장점
      • 매우 유연하며, Lazy 로딩된 데이터를 필요할 때만 초기화할 수 있습니다.
      • 로딩 시점과 방법을 완전히 제어할 수 있습니다.
    • 단점
      • 데이터를 강제로 초기화하기 때문에 추가적인 쿼리가 발생합니다.
      • 잘못 사용하면 N+1 문제가 심화될 수 있습니다.
      • 코드에서 명시적으로 초기화를 작성해야 하므로 코드가 복잡해질 수 있습니다.
    • 예제
      User user = userRepository.findById(1L).get();
      Hibernate.initialize(user.getOrders()); // Lazy 로딩된 orders를 강제 초기화
  • Batch Fetching
    • N+1 문제를 해결하기 위해 여러 연관 데이터를 한 번에 가져옵니다.
    • 특징
      • Hibernate의 @BatchSize 또는 hibernate.default_batch_fetch_size 설정을 통해 연관된 엔티티를 한 번에 로드합니다.
      • Lazy 로딩된 데이터를 배치(batch) 방식으로 가져와, 여러 엔티티에 대해 한 번의 쿼리를 실행합니다.
      • 기본적으로 SELECT 쿼리를 최적화하여 N+1 문제를 완화합니다.
    • 장점
      • N+1 문제를 방지하면서도 데이터의 필요 여부에 따라 Lazy 로딩을 유지합니다.
      • 데이터 양이 많아도 메모리를 과도하게 사용하지 않고, 일정 크기로 나누어 로드합니다.
    • 단점
      • 잘못된 Batch 크기 설정은 오히려 성능에 부정적인 영향을 미칠 수 있습니다.
      • 모든 경우에 최적화되지 않으며, 데이터 로드 크기를 세밀하게 조정해야 합니다.
    • 사용 예
      @BatchSize(size = 10)
      @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
      private List<Order> orders;
    • 전역 설정
      properties
      
      spring.jpa.properties.hibernate.default_batch_fetch_size=10

주요 기법 비교

기법장점단점적합한 상황
JPQL 및 Fetch Join한 번의 쿼리로 객체 그래프 탐색 가능.조건부 로딩 가능.JPQL 작성 필요.메모리 문제 발생 가능.복잡한 조건부 연관 데이터 탐색 시 적합.
EntityGraph선언적 탐색으로 코드 간결화.Lazy 로딩 문제 해결 가능.복잡한 그래프 탐색에 한계.추가 데이터 필요 시 다시 쿼리 발생.간단한 객체 그래프 탐색 시.유지보수 용이성이 중요할 때.
Hibernate 초기화 강제화로딩 시점과 방법을 완전히 제어 가능.필요할 때만 초기화.코드 복잡도 증가.추가 쿼리로 인해 성능 저하 가능.N+1 문제 발생 가능.Lazy 로딩이 기본이지만 일부 데이터만 로드해야 할 때.
Batch FetchingN+1 문제 완화.대량 데이터도 메모리 사용을 최적화하며 로드.Batch 크기 조정 필요.잘못 설정 시 성능 저하.다수의 Lazy 로딩 엔티티를 효율적으로 로드해야 할 때.

마무리

이번 글을 통해 성능 최적화와 유지보수성을 고려한 목록 조회 API의 핵심 원칙을 학습했습니다. 거의 다 이 시리즈에 끝에 와갑니다!😁

profile
열혈개발자~!!

0개의 댓글