📝 학습 목표
- 목록 조회 API를 어떻게 만들지 고민한다.
- 홈 화면처럼 정보량이 많은 경우 어떻게 할지 고민한다.
🤔 조회에 대한 고민. PAGING!?
간단한 CRUD는 한 두번 짜다보면 마치 반복노동과 같은 느낌이 들 정도로 익숙해질 것이다.
허나, 항상 고민되는 부분은 "조회"이다. (SELECT 쿼리가 실행되는 비즈니스 로직!!)
이유가 뭘까..?
본문에서 한 번 고민해보자!!!
이번에는 목록 조회가 필요한 API는 어떻게 만들지, 그리고 홈 화면처럼 정보량이 많으면 어떻게 할지, 이를 고민해보자!
✒️ 0. 들어가기 전
Chapter 7, 8, 9 의 내용은
- 엔티티 설계 , 매핑, 프로젝트 파일 구조의 이해
- Api 응답 통일 및 에러 핸들러
- Api 설계, Swagger 설정, Annotation Customize 에 관한 내용 이었다.
해당 내용들은 동아리 내부에서 만든 기획이 포함되어 있기 때문에 생략 했다.
(또한 기본적인 API 설계에 관한 내용이기 때문에 생략했다.)
[참고] 아래 코드에 대부분의 기본적인 패키지 구조 / 스웨거 설정 / 응답 통일 및 에러 핸들러 등에 대한 코드들이 포함되어 있다.
https://github.com/jinho7/spring-boot-java-template
✒️ 1. 조회와 PAGING
💡 '조회'에 대한 고찰
본문에 들어가기전 "조회 성능"에 대한 고찰이 있었다.

이처럼 리뷰 사진을 보면 못해도 적게는 몇 십개 많게는 몇 만개 까지의 리뷰가 달려 있다.
뭔가 다량의 데이터를 조합해서 새로운 데이터를 만들고 매핑까지 하는 POST 요청보다는 쉬울 것 같다.
(DB에서 띡 조회하면 끝나는거 아냐?...?)
사실 위와 같은 리뷰 조회는 생각보다 까다롭다.
일단 가장 큰 이뉴는 [성능에 가장 큰 영향을 미치는 기능은 조회 기능 이기 때문이다.]
이유가 뭘까!??
대량의 데이터를 한 번에 조회 하면, 서버와 네트워크에 부담이 크게 오고 안정성이 떨어진다.
사용자 경험의 개선을 위해서라도 이러한 대용량 데이터를 한 번에 가져오는 것은 옳지 않다.
사용자가 앱을 떠나는 이유는 여러가지가 있지만, 많은 렉 / 긴 대기시간 등도 이유로 많이 뽑힌다고 한다.
대기 시간이란 일반적으로 사용자가 요청을 한 시점부터 해당 사용자에게 요청에 대한 응답을 받기까지 걸리는 시간

또한 대부분의 애플리케이션에서 데이터 조회는 가장 빈번한 작업이다.
시간이 지날수록 데이터베이스의 데이터 양은 증가하므로, 효율적인 조회 방법을 구현하는 것이 가장 중요하다.
그렇다면 이러한 고민에 도달할 것이다...
1. 정보량이 매우 많은데, 어떻게 가져올 것인가? (방법)
2. 페이징을 적용해볼까? 어떻게?
사실 순수 SQL 문으로 해결할 수는 있다.
Join 연산 혹은 Paging SQL을 통해서 말이다.
(5주차 SQL 파트 참고)
Chapter 5. SQL 활용하기 - Query 작성
📝 학습 목표예시를 기반으로 여러가지 요구 사항에 대한 SQL 쿼리를 고민한다.paging을 고려하여 쿼리를 작성한다. 🤔 Join과 SubQeury!SQL의 기본 문법인 JOIN과 SubQuery에 대해 알고 있다는 전제 하에
jinhos-devlog.tistory.com
허나, 우리는 Spring Data JPA를 사용하기 때문에 복잡한 SQL 쿼리를 직접 작성하지 않고도 데이터베이스 조작이 가능하며, 페이징 등의 기능을 보다 쉽게 구현할 수 있다.
* 추가적인 이야기
JPA는 사용자가 SQL을 직접 작성하지 않고도 데이터베이스에 저장된 데이터를 조작할 수 있는 점에서 굉장히 편리하다.
하지만 JPA를 통해 기존의 SQL을 통해 수행했던 기능들이 모두 가능한 것은 아니다.
Spring Data JPA가 자동으로 구현체를 만들어주는 메소드와 Entity만 사용할 경우 다음과 같은 아쉬운 점이 있다.
- 직접적인 연관관계를 맺지 않는 Entity의 Join이 어렵다.
- Entity들 중 원하는 필드만 가져올 수 없다.
해결책 :
1. @Query 어노테이션
2. JPA Criteria Query
3. Query DSL (추가적인 내용은 "Query DSL" 을 구글링 해보길 바란다!)
💡 목록 조회 API 설계

자 다시 목록 조회 API를 만들기 위해서 무슨 정보가 필요한 지 뽑아보자.
1. 닉네임
2. 리뷰의 점수
3. 리뷰가 작성된 날짜
4. 리뷰의 상세 내용
5. 사진
6. 사장님 답글
일단 사장님 댓글과 사진은 보류해보도록 하자.
사실 예시 사진은 정보량이 그렇게 많지는 않다.
필요한 데이터만 따졌을 때
Member 와 Review 테이블의 join이 필요함정도는 빠르게 캐치가 가능하다.
보여지는 정보는 아니지만, "기술적으로 필요한 것"은 [페이징]이 있다!
자 이제 차근차근 API를 만들어 보자.
보통 API를 만들 때 보편적으로 아래의 순서를 따르면 혼란없이 만들 수 있다.
0. API URL 설계 완료가 되었다고 가정
1. API 시그니처를 만들기
2. API 시그니처를 바탕으로 swagger에 명세
3. 데이터베이스와 연결하는 부분 만들기
4. 비즈니스 로직을 만들기
5. 컨트롤러 완성
6. validation 처리 / 예외 처리
✒️ 2. API 시그니처 만들기
사실 "API 시그니처" 라는 말이 흔히 쓰이는 말은 아니다.
'대충 "틀"을 만들어 놓는다'의 과정을 '시그니처'라고 표현을 한 것
1. 응답과 요청 DTO 작성
2. 컨트롤러에서 어떤 타입 return 하는지, 어떤 파라미터가 필요한지, 엔드포인트는 무엇인지 HttpMethod는 무엇인지만 정해둔다.
3. 컨버터의 정의만 해두기
이러한 과정을 {멤버 회원가입}의 예를 들자면,
@PostMapping("/join")
public ApiResponse<MemberResponseDTO.JoinResultDTO> join(@RequestBody MemberRequestDTO.JoinDto request){
// 비즈니스 로직
return ApiResponse.onSuccess(MemberConverter.toJoinResultDTO(member));
}
요종도만 만들어 놓고, DTO를 설정하는 것이다.
💡 DTO 작성
@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;
}
ownerNickname 말고도, 또 다른 정보들이 매우 많을 경우
그 자체로 DTO를 또 다시 구성하는 것이 좋다.
이를 테면, MemberInfoDTO memberinfo;
💡 Converter 틀 작성 하기
@GetMapping("/{storeId}/reviews")
public ApiResponse<StoreResponseDTO.ReviewPreViewListDTO> getReviewList(@ExistStore @RequestParam(name = "storeId") Long storeId){
// 비즈니스 로직
return ApiResponse.onSuccess(null);
}
이러면, 비즈니스 로직 과 responseDTO, 그리고 이 DTO를 만들어 줄 Converter를 만들어주면 된다.
public class StoreConverter {
public static StoreResponseDTO.ReviewPreViewDTO reviewPreViewDTO(Review review){
return null;
}
public static StoreResponseDTO.ReviewPreViewListDTO reviewPreViewListDTO(List<Review> reviewList){
return null;
}
}
일단 return null 해두고 서비스의 매서드를 만들면서 완성하거나 서비스 메서드 완성 후 세부 로직을 구현해도 된다.
물론 우리가 짠 컨버터의 경우는 그저 응답을 위한 Entity to DTO이기에 서비스 로직 완성 후 작성을 해도 되겠지만...
만약 클라이언트의 요청 데이터를 JPA에서 처리하기 위한 Entity로 만드는, DTO to Entity의 경우는 복잡해질 가능성이 높고
연관관계 처리를 서비스에서 하는 것이 좋은 경우가 많기 때문에,
그런 경우는 서비스 로직 작성을 하는 과정에서 컨버터를 완성을 해야하는 경우도 있다.
이렇게 API 시그니처를 작성할 수 있다.
💡 Swagger를 이용한 API 명세서 = Controller 매서드 정의만 해두기
이 단계를 소개하다보면 보통 이런 의문이 들 것이다.
'스웨거는 API가 모두 완성되고 작성하는 거 아냐...?'
API가 완성되지 않았음에도 명세를 해두는 이유는 프론트엔드 개발자와의 개발 과정에서 병목을 최대한 줄이기 위함이다.
API 하나를 모두 완성 후 명세를 하게 되면...
프론트엔드 개발자는 해당 API가 완성이 될 때 까지 다른 API의 응답을 모르기 때문에 작업을 멈추게 된다.
이런 상황을 최대한 막기 위해 우선적으로 응답 Data의 형태를 먼저 알려주어
프론트 개발자도 미리 API 연결 부분을 작업 해둬 최대한 개발을 병렬적으로 할 수 있도록 한다.
(지금이야, 하나 API 짜는 과정이 짧게 걸리지만 서비스가 복잡해질 수록 이런 방식을 추구한다.)
따라서 되도록 많은 API에 대한 시그니처를 빠르게 만들 것을 추천한다. (스웨거 명세까지)
이렇게 할 경우 더 빠른 개발 속도를 기대할 수 있다!
개발 전체의 효율을 높이는 방법이며, 일종의 배려의 영역이다.
이제 Controller 부분을 정의만 해두면서 동시에 Swagger 명세를 해보자.
아래는 Controller의 코드
@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 = "access 토큰 만료",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){
return null;
}
이상한 어노테이션들에 대한 설명
- @Operation은 이 API에 대한 설명을 넣게 되며 summary, description으로 설명을 적는다.
- @ApiResponses로 이 API의 응답을 담게 되며 내부적으로 @ApiResponse로 각각의 응답들을 담게 된다.
에러 상황에 대해서만 content = 를 통해 형태를 알려줬고 (에러는 코드, 메세지만 중요하지 result는 필요 없어서!)
성공에 대해서는 content를 지정하지 않았다.
content가 없으면 그냥 ApiResponse<StoreResponseDTO.ReviewPreViewListDTO>
여기서 StoreResponseDTO.ReviewPreViewListDTO가 응답 형태로 보여지게 된다. - @Parameters 는 프론트엔드에서 넘겨줘야 할 정보를 담으며,
위의 코드에선 일단 path variable만 기재했고, API 완성 단계에서 query String도 추가할 것.
아래는 이렇게 적용한 스웨거의 화면

✒️ 3. 서비스 메서드 로직 작성 + 레포지토리 메서드 작성 (필요 시에 Converter 완성)
💡 서비스 로직
항상 서비스 비즈니스 로직이 "메인 코드"라고 보면 된다.
지금까지 짠 시그니처는 반복 노동에 가깝고,
실제 [알고리즘]에 해당 되는 부분은 이 부분이다.
Service를 먼저 다 완성 후 Repository 메서드를 완성하거나 혹은 반대로 하면,
이렇게 깔끔하게 코드를 작성하기가 힘들다.
Service 로직을 작성하다 보면 Repository의 메서드가 필요하고,
Repository의 메서드를 처음부터 만들기에는 어떤 비즈니스 로직에서 필요한지 모르기에 두 과정을 섞어가며 진행하게 된다.
그리고 외부 API를 호출 할 경우, Feign Client등과 같은 외부 API 호출 부분도 해당 과정에서 이뤄진다.
💡 서비스 메서드 로직 작성
(코드 설명 굳이 없이 쭈욱 써보겠다.)
- Service
public interface StoreQueryService {
StoreResponseDTO.ReviewPreViewListDTO getReviewList(Long StoreId, Integer page);
}
import org.springframework.data.domain.Page;
위의 Page는 Spring Data JPA에서 제공하는 Paging을 위한 추상화를 제공한다.Page 자체에 페이징과 관련된 여러 정보가 담기게 되며 위에서 작성한 DTO에서 그 흔적을 찾아볼 수 있다.
- Repository
public interface ReviewRepository extends JpaRepository<Review, Long> {
Page<Review> findAllByStore(Store store, PageRequest pageRequest);
}
- ServiceImpl
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class StoreQueryServiceImpl implements StoreQueryService{
private final StoreRepository storeRepository;
private final ReviewRepository reviewRepository;
@Override
public StoreResponseDTO.ReviewPreViewListDTO getReviewList(Long StoreId, Integer page) {
Store store = storeRepository.findById(StoreId).get();
Page<Review> StorePage = reviewRepository.findAllByStore(store, PageRequest.of(page, 10));
return StoreConverter.reviewPreViewListDTO(StorePage);
}
}
- Controller
@Operation(summary = "특정 가게의 리뷰 목록 조회 API",description = "특정 가게의 리뷰들의 목록을 조회하는 API이며, 페이징을 포함합니다. query String 으로 page 번호를 주세요")
@GetMapping("/{storeId}/reviews")
@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 입니다!"),
@Parameter(name = "page", description = "페이지 번호, 0번이 1 페이지 입니다."),
})
public ApiResponse<StoreResponseDTO.ReviewPreViewListDTO> getReviewList(@ExistStore @PathVariable(name = "storeId") Long storeId, @RequestParam(name = "page") Integer page){
return ApiResponse.onSuccess(storeQueryService.getReviewList(storeId,page));
}
- Converter
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를 보면, "Page를 0번이 1 페이지라고 해 두었는데"
이에 대한 검증(page가 음수로 오는 경우)을 커스텀 어노테이션을 이용해 처리하면 좋을 것 같다.
또한, 다음 구조를 보면서 드는 생각은?
- Service
@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;
}
- Store
@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Store extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 50)
private String name;
@Column(nullable = false, length = 50)
private String address;
private Float score;
@OneToMany(mappedBy = "store", cascade = CascadeType.ALL)
private List<Review> reviewList = new ArrayList<>();
}
-> "뭐야.. 이미 Store 객체 찾을 때, 해당 Review 를 싹 긁어 오잖아.."

현재 Store 엔티티의 구조와 getReviewList 메소드의 구현 사이에 불일치가 있어 보인다.
근데, 이미 Lazy가 다 적용이 되어 있다..
Review 엔티티에:
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "store_id")
private Store store;
Store 엔티티에:
@OneToMany(mappedby = "store", cascade = CascadeType.ALL)
private List<Review> reviewList = new ArrayList<>();
Review에서 Store로의 접근은 지연 로딩된다.
Store에서 Review 리스트로의 접근도 기본적으로 지연 로딩된다 (Collection은 기본이 LAZY).
...
다시 구글링를 해보니,
reviewList = {PersistentBag@13965} size = 13:
이 출력은 실제로 리뷰 데이터를 로드했다는 의미가 아니라고 한다.
PersistentBag은 Hibernate가 사용하는 지연 로딩을 위한 프록시 컬렉션이다.
size = 13은 데이터베이스에 있는 리뷰의 개수를 나타내지만, 실제 리뷰 객체들은 아직 로드되지 않았을 수 있다.
즉, Lazy 상태의 프록시 객체는 실제 DB 조회 전이다..
(잘 굴러가는 코드라는거~)
다만, Review가 Store와의 연관관계가 있으므로, 굳이 Store 객체를 가져오지 않고,
Page<Review> StorePage = reviewRepository.findReviewsByStoreId(storeId, PageRequest.of(page, 10));
로 처리해도 될 거 같다.
📝 학습 목표
- 목록 조회 API를 어떻게 만들지 고민한다.
- 홈 화면처럼 정보량이 많은 경우 어떻게 할지 고민한다.
🤔 조회에 대한 고민. PAGING!?
간단한 CRUD는 한 두번 짜다보면 마치 반복노동과 같은 느낌이 들 정도로 익숙해질 것이다.
허나, 항상 고민되는 부분은 "조회"이다. (SELECT 쿼리가 실행되는 비즈니스 로직!!)
이유가 뭘까..?
본문에서 한 번 고민해보자!!!
이번에는 목록 조회가 필요한 API는 어떻게 만들지, 그리고 홈 화면처럼 정보량이 많으면 어떻게 할지, 이를 고민해보자!
✒️ 0. 들어가기 전
Chapter 7, 8, 9 의 내용은
- 엔티티 설계 , 매핑, 프로젝트 파일 구조의 이해
- Api 응답 통일 및 에러 핸들러
- Api 설계, Swagger 설정, Annotation Customize 에 관한 내용 이었다.
해당 내용들은 동아리 내부에서 만든 기획이 포함되어 있기 때문에 생략 했다.
(또한 기본적인 API 설계에 관한 내용이기 때문에 생략했다.)
[참고] 아래 코드에 대부분의 기본적인 패키지 구조 / 스웨거 설정 / 응답 통일 및 에러 핸들러 등에 대한 코드들이 포함되어 있다.
https://github.com/jinho7/spring-boot-java-template
✒️ 1. 조회와 PAGING
💡 '조회'에 대한 고찰
본문에 들어가기전 "조회 성능"에 대한 고찰이 있었다.

이처럼 리뷰 사진을 보면 못해도 적게는 몇 십개 많게는 몇 만개 까지의 리뷰가 달려 있다.
뭔가 다량의 데이터를 조합해서 새로운 데이터를 만들고 매핑까지 하는 POST 요청보다는 쉬울 것 같다.
(DB에서 띡 조회하면 끝나는거 아냐?...?)
사실 위와 같은 리뷰 조회는 생각보다 까다롭다.
일단 가장 큰 이뉴는 [성능에 가장 큰 영향을 미치는 기능은 조회 기능 이기 때문이다.]
이유가 뭘까!??
대량의 데이터를 한 번에 조회 하면, 서버와 네트워크에 부담이 크게 오고 안정성이 떨어진다.
사용자 경험의 개선을 위해서라도 이러한 대용량 데이터를 한 번에 가져오는 것은 옳지 않다.
사용자가 앱을 떠나는 이유는 여러가지가 있지만, 많은 렉 / 긴 대기시간 등도 이유로 많이 뽑힌다고 한다.
대기 시간이란 일반적으로 사용자가 요청을 한 시점부터 해당 사용자에게 요청에 대한 응답을 받기까지 걸리는 시간

또한 대부분의 애플리케이션에서 데이터 조회는 가장 빈번한 작업이다.
시간이 지날수록 데이터베이스의 데이터 양은 증가하므로, 효율적인 조회 방법을 구현하는 것이 가장 중요하다.
그렇다면 이러한 고민에 도달할 것이다...
1. 정보량이 매우 많은데, 어떻게 가져올 것인가? (방법)
2. 페이징을 적용해볼까? 어떻게?
사실 순수 SQL 문으로 해결할 수는 있다.
Join 연산 혹은 Paging SQL을 통해서 말이다.
(5주차 SQL 파트 참고)
Chapter 5. SQL 활용하기 - Query 작성
📝 학습 목표예시를 기반으로 여러가지 요구 사항에 대한 SQL 쿼리를 고민한다.paging을 고려하여 쿼리를 작성한다. 🤔 Join과 SubQeury!SQL의 기본 문법인 JOIN과 SubQuery에 대해 알고 있다는 전제 하에
jinhos-devlog.tistory.com
허나, 우리는 Spring Data JPA를 사용하기 때문에 복잡한 SQL 쿼리를 직접 작성하지 않고도 데이터베이스 조작이 가능하며, 페이징 등의 기능을 보다 쉽게 구현할 수 있다.
* 추가적인 이야기
JPA는 사용자가 SQL을 직접 작성하지 않고도 데이터베이스에 저장된 데이터를 조작할 수 있는 점에서 굉장히 편리하다.
하지만 JPA를 통해 기존의 SQL을 통해 수행했던 기능들이 모두 가능한 것은 아니다.
Spring Data JPA가 자동으로 구현체를 만들어주는 메소드와 Entity만 사용할 경우 다음과 같은 아쉬운 점이 있다.
- 직접적인 연관관계를 맺지 않는 Entity의 Join이 어렵다.
- Entity들 중 원하는 필드만 가져올 수 없다.
해결책 :
1. @Query 어노테이션
2. JPA Criteria Query
3. Query DSL (추가적인 내용은 "Query DSL" 을 구글링 해보길 바란다!)
💡 목록 조회 API 설계

자 다시 목록 조회 API를 만들기 위해서 무슨 정보가 필요한 지 뽑아보자.
1. 닉네임
2. 리뷰의 점수
3. 리뷰가 작성된 날짜
4. 리뷰의 상세 내용
5. 사진
6. 사장님 답글
일단 사장님 댓글과 사진은 보류해보도록 하자.
사실 예시 사진은 정보량이 그렇게 많지는 않다.
필요한 데이터만 따졌을 때
Member 와 Review 테이블의 join이 필요함정도는 빠르게 캐치가 가능하다.
보여지는 정보는 아니지만, "기술적으로 필요한 것"은 [페이징]이 있다!
자 이제 차근차근 API를 만들어 보자.
보통 API를 만들 때 보편적으로 아래의 순서를 따르면 혼란없이 만들 수 있다.
0. API URL 설계 완료가 되었다고 가정
1. API 시그니처를 만들기
2. API 시그니처를 바탕으로 swagger에 명세
3. 데이터베이스와 연결하는 부분 만들기
4. 비즈니스 로직을 만들기
5. 컨트롤러 완성
6. validation 처리 / 예외 처리
✒️ 2. API 시그니처 만들기
사실 "API 시그니처" 라는 말이 흔히 쓰이는 말은 아니다.
'대충 "틀"을 만들어 놓는다'의 과정을 '시그니처'라고 표현을 한 것
1. 응답과 요청 DTO 작성
2. 컨트롤러에서 어떤 타입 return 하는지, 어떤 파라미터가 필요한지, 엔드포인트는 무엇인지 HttpMethod는 무엇인지만 정해둔다.
3. 컨버터의 정의만 해두기
이러한 과정을 {멤버 회원가입}의 예를 들자면,
@PostMapping("/join")
public ApiResponse<MemberResponseDTO.JoinResultDTO> join(@RequestBody MemberRequestDTO.JoinDto request){
// 비즈니스 로직
return ApiResponse.onSuccess(MemberConverter.toJoinResultDTO(member));
}
요종도만 만들어 놓고, DTO를 설정하는 것이다.
💡 DTO 작성
@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;
}
ownerNickname 말고도, 또 다른 정보들이 매우 많을 경우
그 자체로 DTO를 또 다시 구성하는 것이 좋다.
이를 테면, MemberInfoDTO memberinfo;
💡 Converter 틀 작성 하기
@GetMapping("/{storeId}/reviews")
public ApiResponse<StoreResponseDTO.ReviewPreViewListDTO> getReviewList(@ExistStore @RequestParam(name = "storeId") Long storeId){
// 비즈니스 로직
return ApiResponse.onSuccess(null);
}
이러면, 비즈니스 로직 과 responseDTO, 그리고 이 DTO를 만들어 줄 Converter를 만들어주면 된다.
public class StoreConverter {
public static StoreResponseDTO.ReviewPreViewDTO reviewPreViewDTO(Review review){
return null;
}
public static StoreResponseDTO.ReviewPreViewListDTO reviewPreViewListDTO(List<Review> reviewList){
return null;
}
}
일단 return null 해두고 서비스의 매서드를 만들면서 완성하거나 서비스 메서드 완성 후 세부 로직을 구현해도 된다.
물론 우리가 짠 컨버터의 경우는 그저 응답을 위한 Entity to DTO이기에 서비스 로직 완성 후 작성을 해도 되겠지만...
만약 클라이언트의 요청 데이터를 JPA에서 처리하기 위한 Entity로 만드는, DTO to Entity의 경우는 복잡해질 가능성이 높고
연관관계 처리를 서비스에서 하는 것이 좋은 경우가 많기 때문에,
그런 경우는 서비스 로직 작성을 하는 과정에서 컨버터를 완성을 해야하는 경우도 있다.
이렇게 API 시그니처를 작성할 수 있다.
💡 Swagger를 이용한 API 명세서 = Controller 매서드 정의만 해두기
이 단계를 소개하다보면 보통 이런 의문이 들 것이다.
'스웨거는 API가 모두 완성되고 작성하는 거 아냐...?'
API가 완성되지 않았음에도 명세를 해두는 이유는 프론트엔드 개발자와의 개발 과정에서 병목을 최대한 줄이기 위함이다.
API 하나를 모두 완성 후 명세를 하게 되면...
프론트엔드 개발자는 해당 API가 완성이 될 때 까지 다른 API의 응답을 모르기 때문에 작업을 멈추게 된다.
이런 상황을 최대한 막기 위해 우선적으로 응답 Data의 형태를 먼저 알려주어
프론트 개발자도 미리 API 연결 부분을 작업 해둬 최대한 개발을 병렬적으로 할 수 있도록 한다.
(지금이야, 하나 API 짜는 과정이 짧게 걸리지만 서비스가 복잡해질 수록 이런 방식을 추구한다.)
따라서 되도록 많은 API에 대한 시그니처를 빠르게 만들 것을 추천한다. (스웨거 명세까지)
이렇게 할 경우 더 빠른 개발 속도를 기대할 수 있다!
개발 전체의 효율을 높이는 방법이며, 일종의 배려의 영역이다.
이제 Controller 부분을 정의만 해두면서 동시에 Swagger 명세를 해보자.
아래는 Controller의 코드
@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 = "access 토큰 만료",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){
return null;
}
이상한 어노테이션들에 대한 설명
- @Operation은 이 API에 대한 설명을 넣게 되며 summary, description으로 설명을 적는다.
- @ApiResponses로 이 API의 응답을 담게 되며 내부적으로 @ApiResponse로 각각의 응답들을 담게 된다.
에러 상황에 대해서만 content = 를 통해 형태를 알려줬고 (에러는 코드, 메세지만 중요하지 result는 필요 없어서!)
성공에 대해서는 content를 지정하지 않았다.
content가 없으면 그냥 ApiResponse<StoreResponseDTO.ReviewPreViewListDTO>
여기서 StoreResponseDTO.ReviewPreViewListDTO가 응답 형태로 보여지게 된다. - @Parameters 는 프론트엔드에서 넘겨줘야 할 정보를 담으며,
위의 코드에선 일단 path variable만 기재했고, API 완성 단계에서 query String도 추가할 것.
아래는 이렇게 적용한 스웨거의 화면

✒️ 3. 서비스 메서드 로직 작성 + 레포지토리 메서드 작성 (필요 시에 Converter 완성)
💡 서비스 로직
항상 서비스 비즈니스 로직이 "메인 코드"라고 보면 된다.
지금까지 짠 시그니처는 반복 노동에 가깝고,
실제 [알고리즘]에 해당 되는 부분은 이 부분이다.
Service를 먼저 다 완성 후 Repository 메서드를 완성하거나 혹은 반대로 하면,
이렇게 깔끔하게 코드를 작성하기가 힘들다.
Service 로직을 작성하다 보면 Repository의 메서드가 필요하고,
Repository의 메서드를 처음부터 만들기에는 어떤 비즈니스 로직에서 필요한지 모르기에 두 과정을 섞어가며 진행하게 된다.
그리고 외부 API를 호출 할 경우, Feign Client등과 같은 외부 API 호출 부분도 해당 과정에서 이뤄진다.
💡 서비스 메서드 로직 작성
(코드 설명 굳이 없이 쭈욱 써보겠다.)
- Service
public interface StoreQueryService {
StoreResponseDTO.ReviewPreViewListDTO getReviewList(Long StoreId, Integer page);
}
import org.springframework.data.domain.Page;
위의 Page는 Spring Data JPA에서 제공하는 Paging을 위한 추상화를 제공한다.Page 자체에 페이징과 관련된 여러 정보가 담기게 되며 위에서 작성한 DTO에서 그 흔적을 찾아볼 수 있다.
- Repository
public interface ReviewRepository extends JpaRepository<Review, Long> {
Page<Review> findAllByStore(Store store, PageRequest pageRequest);
}
- ServiceImpl
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class StoreQueryServiceImpl implements StoreQueryService{
private final StoreRepository storeRepository;
private final ReviewRepository reviewRepository;
@Override
public StoreResponseDTO.ReviewPreViewListDTO getReviewList(Long StoreId, Integer page) {
Store store = storeRepository.findById(StoreId).get();
Page<Review> StorePage = reviewRepository.findAllByStore(store, PageRequest.of(page, 10));
return StoreConverter.reviewPreViewListDTO(StorePage);
}
}
- Controller
@Operation(summary = "특정 가게의 리뷰 목록 조회 API",description = "특정 가게의 리뷰들의 목록을 조회하는 API이며, 페이징을 포함합니다. query String 으로 page 번호를 주세요")
@GetMapping("/{storeId}/reviews")
@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 입니다!"),
@Parameter(name = "page", description = "페이지 번호, 0번이 1 페이지 입니다."),
})
public ApiResponse<StoreResponseDTO.ReviewPreViewListDTO> getReviewList(@ExistStore @PathVariable(name = "storeId") Long storeId, @RequestParam(name = "page") Integer page){
return ApiResponse.onSuccess(storeQueryService.getReviewList(storeId,page));
}
- Converter
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를 보면, "Page를 0번이 1 페이지라고 해 두었는데"
이에 대한 검증(page가 음수로 오는 경우)을 커스텀 어노테이션을 이용해 처리하면 좋을 것 같다.
또한, 다음 구조를 보면서 드는 생각은?
- Service
@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;
}
- Store
@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Store extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 50)
private String name;
@Column(nullable = false, length = 50)
private String address;
private Float score;
@OneToMany(mappedBy = "store", cascade = CascadeType.ALL)
private List<Review> reviewList = new ArrayList<>();
}
-> "뭐야.. 이미 Store 객체 찾을 때, 해당 Review 를 싹 긁어 오잖아.."

현재 Store 엔티티의 구조와 getReviewList 메소드의 구현 사이에 불일치가 있어 보인다.
근데, 이미 Lazy가 다 적용이 되어 있다..
Review 엔티티에:
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "store_id")
private Store store;
Store 엔티티에:
@OneToMany(mappedby = "store", cascade = CascadeType.ALL)
private List<Review> reviewList = new ArrayList<>();
Review에서 Store로의 접근은 지연 로딩된다.
Store에서 Review 리스트로의 접근도 기본적으로 지연 로딩된다 (Collection은 기본이 LAZY).
...
다시 구글링를 해보니,
reviewList = {PersistentBag@13965} size = 13:
이 출력은 실제로 리뷰 데이터를 로드했다는 의미가 아니라고 한다.
PersistentBag은 Hibernate가 사용하는 지연 로딩을 위한 프록시 컬렉션이다.
size = 13은 데이터베이스에 있는 리뷰의 개수를 나타내지만, 실제 리뷰 객체들은 아직 로드되지 않았을 수 있다.
즉, Lazy 상태의 프록시 객체는 실제 DB 조회 전이다..
(잘 굴러가는 코드라는거~)
다만, Review가 Store와의 연관관계가 있으므로, 굳이 Store 객체를 가져오지 않고,
Page<Review> StorePage = reviewRepository.findReviewsByStoreId(storeId, PageRequest.of(page, 10));
로 처리해도 될 거 같다.