이를 해결하기 위해 페이징(Paging)과 같은 기법이 필요합니다.
자 이제 차근차근 API를 만들어 봅시다.
보통 API를 만들 때 아래의 순서를 따르면 우왕좌왕 하지 않고 만들 수 있습니다.
🧭 전제 조건: API URL은 설계가 되었다는 전제입니다!
1. API 시그니처를 만든다!
- 응답과 요청 DTO를 만든다.
- 컨트롤러에서 어떤 형태를 리턴하는지, 어떤 파라미터가 필요한지, URI는 무엇인지 HTTP Method는 무엇인지만 정해둔다.
- 컨버터 정의만 해둔다.
- API 시그니처를 바탕으로 Swagger에 명세를 해준다.
- 데이터베이스와 연결하는 부분을 만든다.
- 비즈니스 로직을 만든다.
- 컨트롤러를 완성한다.
- Validation 처리를 한다.
API 시그니처는 API의 기본 설계 정보로, 클라이언트와 서버 간에 어떤 데이터를 주고받을지 정의한 약속입니다. API가 어떤 역할을 하고, 어떤 데이터를 입력받고 출력하는지 명확히 알려주는 가이드라고 볼 수 있습니다.
주요 구성 요소
1. URI: API의 주소 (예: /stores/{storeId}/reviews)
2. HTTP Method: 작업 유형 (예: GET, POST)
3. 요청 데이터:
{storeId})?page=1)200 OK, 400 Bad Request)/stores/{storeId}/reviewsGETstoreId (가게 ID)page (페이지 번호){
"reviewList": [...],
"totalElements": 100,
"isLast": false
}200 OK, 401 Unauthorized해당 글에서 API 시그니처를 만든다는 말은 아래와 같습니다.
1. 응답과 요청 DTO를 만든다.
2. 컨트롤러에서 어떤 형태를 리턴하는지, 어떤 파라미터가 필요한지, URI는 무엇인지 HTTP Method는 무엇인지만 정해둔다.
3. 컨버터 정의만 해둔다.
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를 반환.public class StoreConverter {
public static StoreResponseDTO.ReviewPreViewDTO reviewPreViewDTO(Review review) {
return null;
}
public static StoreResponseDTO.ReviewPreViewListDTO reviewPreViewListDTO(List<Review> reviewList) {
return null;
}
}
@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;
}
}
content를 통해 오류 응답의 스키마를 정의. ApiResponse<T>를 보여줌. 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));
}
}
CQRS는 데이터 변경 작업 (Command)과 조회 작업 (Query)을 분리하여 각 작업의 책임을 명확히 하는 설계 원칙입니다.
Spring Data JPA에서 객체 그래프 탐색은 연관 관계에 있는 엔티티를 함께 조회하는 방법을 의미합니다. 보통 연관 관계 매핑(@OneToMany, @ManyToOne 등)된 엔티티를 처리할 때 사용됩니다.
JOIN FETCH 키워드를 사용하여 연관 데이터를 한 번에 로드합니다.Fetch Join을 사용하면 즉시 로딩(EAGER)이 이루어지며, N+1 문제를 방지할 수 있습니다.@Query("SELECT u FROM User u JOIN FETCH u.orders WHERE u.id = :id")
User findUserWithOrders(@Param("id") Long id);@EntityGraph를 통해 연관 데이터를 로드할 수 있습니다.@EntityGraph를 사용해 JPQL 없이 객체 그래프 탐색을 정의할 수 있습니다.attributePaths)를 지정합니다.EntityGraph는 JPA Repository에서 바로 활용 가능하며, Lazy Loading 엔티티를 명시적으로 Fetch Join 형태로 로드합니다.attributePaths) 이외의 데이터는 로드되지 않으므로 추가 데이터가 필요할 경우 다시 쿼리가 발생할 수 있습니다.@EntityGraph(attributePaths = {"orders"})
@Query("SELECT u FROM User u WHERE u.id = :id")
User findUserWithOrders(@Param("id") Long id);Hibernate.initialize()를 사용해 지연 로딩된 데이터를 강제로 초기화합니다.User user = userRepository.findById(1L).get();
Hibernate.initialize(user.getOrders()); // Lazy 로딩된 orders를 강제 초기화@BatchSize 또는 hibernate.default_batch_fetch_size 설정을 통해 연관된 엔티티를 한 번에 로드합니다.SELECT 쿼리를 최적화하여 N+1 문제를 완화합니다.@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 Fetching | N+1 문제 완화.대량 데이터도 메모리 사용을 최적화하며 로드. | Batch 크기 조정 필요.잘못 설정 시 성능 저하. | 다수의 Lazy 로딩 엔티티를 효율적으로 로드해야 할 때. |
이번 글을 통해 성능 최적화와 유지보수성을 고려한 목록 조회 API의 핵심 원칙을 학습했습니다. 거의 다 이 시리즈에 끝에 와갑니다!😁