목록 조회가 필요한 API는 어떻게 만들지, 그리고 홈 화면처럼 정보량이 많으면 어떻게 할지에 대해서
API 구현을 처음부터 끝까지 해보려고 한다.
예시로 가게의 리뷰 목록 조회 API 를 구현해보자!
📜 목록 조회 API를 만들기 위해 필요한 정보 알아보기!
- 닉네임
- 리뷰의 점수
- 리뷰가 작성된 날짜
- 리뷰의 상세 내용
API 만드는 순서
🧭 (👉 전제 조건은 API URL은 설계가 되었다는 전제입니다!)
- API 시그니처를 만든다.
- API 시그니처를 바탕으로 swagger에 명세를 해준다
- 데이터베이스와 연결하는 부분을 만든다
- 비즈니스 로직을 만든다
- 컨트롤러를 완성한다
- validation 처리를 한다
1. API 시그니처 만들기
- 응답과 요청 DTO 만들기
- 컨트롤러에서 어떤 형태를 리턴하는 지, 어떤 파라미터가 필요한지, URI는 무엇인지, HTTP Method는 무엇인지만 정해두기
- 컨버터 정의만 해두기
1.1 DTO 작성
나는 이 두 DTO를 dto 파일의 ReviewResponse 클래스 안에 넣어두었다.
@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public static class ReviewPreviewListDTO{
List<ReviewPreviewListDTO> reviewList;
Integer listSize;
Integer totalPage;
Long totalElements;
Boolean isFirst;
Boolean isLast;
}
@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public static class ReviewPreviewDTO{
String ownerNickname;
Float rating;
String content;
LocalDateTime createdAt;
}
위 코드를 보면, static class로 DTO를 만들었다.
DTO 클래스 파일을 줄여서 파일 아키텍처 가독성을 높이기 위함이다.
자잘한 DTO를 전부 다 각각 하나의 클래스로 만들면,
필요한 클래스를 찾을 때 찾기가 힘들고 프로젝트 구조를 알아보기가 힘들어진다.
따라서 큰 단위의 DTO를 하나의 클래스로 두고 하위 자잘한 DTO들은 static class로 둔다.
리뷰 '목록'이기 때문에 리뷰의 정보들의 목록이 필요하다.
그래서 리뷰의 정보를 담은 DTO(ReviewPreviewDTO)를 List를 담은 또 다른 DTO를 만든다.
만약에 리뷰에서 나타내는 사용자의 정보가 닉네임 말고도 더 많았다면,
아래 코드 처럼 사용자의 정보 자체를 DTO로 구성하는 것이 좋다.
public static class ReviewPreviewDTO{
MemberInfoDTO memberinfo;
Float rating;
String content;
LocalDateTime createdAt;
}
1.2 Converter 정의만 작성 해두기
컨트롤러는 아래와 같이 일단 return null로만 만들어둔다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/store")
public class StoreRestController {
@GetMapping("/{storeId}/reviews")
public ApiResponse<ReviewResponse.ReviewPreviewListDTO> getReviewList(@ExistStore @PathVariable(name = "storeId") Long storeId) {
return null;
}
}
컨버터 코드
아래 코드를 ReviewConverter 클래스에 넣어두었다.
public static ReviewResponse.ReviewPreviewDTO reviewPreviewDTO(Review review) {
return null;
}
public static ReviewResponse.ReviewPreviewListDTO reviewPreviewListDTO(List<Review> reviewList) {
return null;
}
일단 return null로 해두고 서비스의 메서드를 만들면서 완성하거나 서비스 메서드 완성 후 세부 로직을 구현해도 된다.
1.3 Swagger를 이용한 API 명세 = 컨트롤러 메서드 정의만 해두기
스웨거에 API가 완성되지 않았음에도 명세를 해두는 이유는 프론트엔드 개발자와의 개발 과정에서 병목을 최대한 줄이기 위함 !!S
API 하나를 모두 완성한 후에 명세를 하게 되면 프엔 개발자는 해당 API가 완성이 될때까지
다른 API의 응답을 모르기 때문에 작업을 멈추게 된다.
이런 상황을 최대한 막기 위해 우선적으로 응답 Data의 형태를 알려주어 프엔 개발자도
미리 API 연결 부분을 작업 해둬 최대한 개발을 병렬적으로 할 수 있도록 한다.
따라서 되도록 많은 API 시그니처를 빠르게 만들 것을 추천 !
Controller 부분을 정의만 해두면서 동시에 스웨거 명세를 한다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/store")
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<ReviewResponse.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의 응답을 담게 되며 내부적으로 @ApiResponse로 각각의 응답들을 담음
- @Parameters 는 프론트엔드에서 넘겨줘야 할 정보를 담으며, 위의 코드에선 일단 path variable만 기재했고, API 완성 단계에서 query String도 추가할 것.
에러 상황에 대해서만 content = 를 통해 형태를 알려줬고,성공에 대해서는 content를 지정하지 않음
content가 없으면 -> ApiResponse<ReviewResponseDTO.ReviewPreViewListDTO>
여기서 ReviewResponseDTO.ReviewPreViewListDTO가 응답 형태로 보여지게 됩니다.
2. Service 메서드 로직 작성 + Repository 메서드 작성
가장 복잡한 과정ㅜㅜ
서비스 로직을 작성하다 보면 리포지토리의 메서드가 필요함을 알게 되고, 리포지토리의 메서드를 처음부터 만들기에는 어떤 비즈니스 로직에서 필요한지 모르기에 두 과정을 섞어가며 진행하게 된다.
외부 API를 호출 할 경우, Feign Client등과 같은 외부 API 호출 부분도 해당 과정에서 이뤄진다.
Review Repository
public interface ReviewRepository extends JpaRepository<Review, Long>, ReviewRepositoryCustom {
Page<Review> findAllByStore(Store store, PageRequest pageRequest);
}
이 코드는 Spring Date JPA에서 메서드 이름만으로 SQL을 만들어주는 기능을 활용한 것
PageRequest는 페이징과 관련된 옵션이 포함된다.
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;
}
}
converter
public class ReviewConverter {
public static ReviewResponse.ReviewPreviewDTO reviewPreviewDTO(Review review) {
return ReviewResponse.ReviewPreviewDTO.builder()
.ownerNickname(review.getUser().getNickname())
.rating(review.getRating())
.createdAt(review.getCreatedAt())
.content(review.getContent())
.build();
}
public static ReviewResponse.ReviewPreviewListDTO reviewPreviewListDTO(List<Review> reviewList) {
List<ReviewResponse.ReviewPreviewDTO> reviewPreviewDTOList = reviewList.stream()
.map(ReviewConverter::reviewPreviewDTO).collect(Collectors.toList());
return ReviewResponse.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에서 사용 가능한 아주 강력한 기능이다
이제 컨트롤러를 컨버터에 맞게 바꿔준다
@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){
Page<Review> reviewList = storeQueryService.getReviewList(storeId,page);
return ApiResponse.onSuccess(StoreConverter.reviewPreViewListDTO(reviewList));
}
}
결과
코드 다 짜고 나서
내가 처음에 설계할 때 리뷰를 가게에 다는 것이 아닌 미션에 달도록 설계했다는 것을 깨달았다...
다 고치고 나서 다시 돌렸다. ㅜㅜ


'SpringBoot' 카테고리의 다른 글
| 서블릿 vs. Spring MVC 비교 (0) | 2025.04.07 |
|---|---|
| AOP(Aspect-Oriented Programming) (0) | 2025.04.07 |
| [SpringBoot] 자동 삭제 기능 구현 Scheduled Job (0) | 2025.01.21 |
| [DriveMate] SpringBoot 프로젝트 생성 (1) | 2024.11.15 |
| Spring IoC 컨테이너 (1) | 2024.10.10 |