개발/Spring, Spring Data JPA, Querydsl

Spring - REST API 간단한 연습 (RestController 사용)

잇(IT) 2023. 11. 27. 04:55
728x90

지금까지 프로젝트를 진행하면서 REST API에 대한 반환값으로 String 즉, View로 전달하는 방식을 많이 사용하였다.

@RestController를 사용하는 방법 또한 크게 다른점은 없지만 많이 사용해보지 않았기 때문에 익숙함을 위해 연습하기 위한 작은 프로젝트다.


 

- ERD

 

- RestaurantApi.java

@RequiredArgsConstructor
@RestController
public class RestaurantApi {

    private final RestaurantService restaurantService;

    @GetMapping("/restaurants")
    public List<RestaurantView> getRestaurants() {
        return restaurantService.getAllRestaurants();
    }

    @GetMapping("/restaurant/{restaurantId}")
    public RestaurantDetailView getRestaurant(
            @PathVariable Long restaurantId
    ) {
        return restaurantService.getRestaurantDetail(restaurantId);
    }

    @PostMapping("/restaurant")
    public void createRestaurant(
            @RequestBody CreateAndEditRestaurantRequest request
            ) {
        restaurantService.createRestaurant(request);
    }

    @PutMapping("/restaurant/{restaurantId}")
    public void updateRestaurant(
            @PathVariable Long restaurantId,
            @RequestBody CreateAndEditRestaurantRequest request
    ) {
        restaurantService.editRestaurant(restaurantId, request);
    }

    @DeleteMapping("/restaurant/{restaurantId}")
    public void deleteRestaurant(
            @PathVariable Long restaurantId
    ) {
        restaurantService.deleteRestaurant(restaurantId);
    }
}

 

1. Get - /restaurants : 음식점 전체 항목을 가져오는 요청한다.

2. Get - /restaurant/{restaurantId} : PathVariable로 전달된 인덱스에 해당하는 음식점에 대한 정보를 요청한다.

3. Post - /restaurant : Body에 JSON 형식으로 데이터를 전달하면 DB에 해당 정보를 저장한다.

4. Put - /restaurant/{restaurantId} : Body에 JSON 형식으로 데이터를 전달하면 DB에 데이터를 수정한다.

5. Delete - /restaurant/{restaurantId} : PathVariable로 전달된 인덱스에 해당하는 음식점에 대한 정보를 삭제한다.

 


 

- 엔티티와 DTO

1. 엔티티 : 엔티티는 데이터베이스와 직접적인 관련이 있기 때문에 중요한 정보를 포함하고 있다. 엔티티를 직접 클라이언트와 서버 간의 데이터를 전송하게 되면 중요 데이터가 노출될 위험이 있다.

2. DTO : DTO(Data Transfer Object)는 데이터 전송 목적으로 사용되며 주로 클라이언트와 서버 간의 데이터 전송을 위한 목적으로 사용한다. DTO는 엔티티의 필드 필요한 정보만 담아서 전달하기 때문에 엔티티의 중요 정보가 노출되는 위험이 줄어든다. 또한 필요한 데이터만 전달되기 때문에 네트워크 트래픽을 최적화 할 수 있게 된다.

 

위 RestaurantApi를 보면 @RequestBody를 통해 별도로 작성한 클래스를 통해 데이터를 전달 받는 것을 볼 수 있다. 또 반환타입으로 특정 클래스를 사용하여 응답을 전달하는 것을 볼 수 있다.

해당 클래스들은 전부 DTO를 위한 Request, Response인 것을 알 수 있다.

 

@PostMapping("/restaurant")
    public void createRestaurant(
            @RequestBody CreateAndEditRestaurantRequest request
            ) {
        restaurantService.createRestaurant(request);
    }

 

위 레스토랑을 입력하는 코드를 보게 되면 CreateAndEditRestaurantRequest 클래스를 @RequestBody로 전달받는 것을 볼 수 있다.

 

- CreateAndEditRestaurantRequest.java

@Getter
@AllArgsConstructor
public class CreateAndEditRestaurantRequest {

    private final String name;
    private final String address;
    private final List<CreateAndEditRestaurantRequestMenu> menus;

}

 

- CreateAndEditRestaurantRequestMenu.java

@Getter
@AllArgsConstructor
@NoArgsConstructor
public class CreateAndEditRestaurantRequestMenu {

    private String name;
    private Integer price;

}

 

Post 요청을 통해 JSON 형식으로 위 필드들을 전달하게 되면, 해당 클래스 필드에 대입된다.

 

PostMan을 통해 위와 같이 JSON 형태로 Body에 데이터를 담아 전달하게 되면,

 

위와 같이 DB에 저장되는 것을 확인할 수 있다.

 

- RestaurantService.java

@Transactional
    public RestaurantEntity createRestaurant(
            CreateAndEditRestaurantRequest request
    ) {
        RestaurantEntity restaurantEntity = RestaurantEntity.builder()
                .name(request.getName())
                .address(request.getAddress())
                .createdAt(ZonedDateTime.now())
                .updatedAt(ZonedDateTime.now())
                .build();
        restaurantRepository.save(restaurantEntity);


        request.getMenus().forEach((menu) ->
        {
            MenuEntity menuEntity = MenuEntity.builder()
                    .restaurantId(restaurantEntity.getId())
                    .name(menu.getName())
                    .price(menu.getPrice())
                    .createdAt(ZonedDateTime.now())
                    .updatedAt(ZonedDateTime.now())
                    .build();
            menuRepository.save(menuEntity);
        });


        return restaurantEntity;
    }

 

위에서 POST를 통해 restaurant 정보를 저장할 때 사용된 Service의 createRestaurant 메서드다.

 

JSON형태의 Body로 전달받은 데이터를 Builder 패턴을 이용하여 엔티티에 매핑 시킨 다음 Repository를 통해 엔티티를 실제 DB에 저장한다.

 

Menu의 경우 하나의 restaurant에 여러개의 menu가 있을 수 있으므로 List로 전달 받은 menu를 forEach를 통해 각각의 request로 전달받은 데이터를 menu 엔티티에 매핑하여 저장한다.


- 페이징

 

페이징은 대량의 데이터를 작은 덩어리로 나누어 표시하는 기술이다. 웹 페이지나 애플리케이션에서 데이터를 페이지 단위로 표시하거나 검색 결과를 여러 페이지로 나누어 표시할 때 사용된다.

 

Spring에서 제공하는 페이징 기능을 이용하면 보다 간편하게 페이징을 사용할 수 있다.

 

Spring에서의 페이징은 기본적으로 Page, Pageable 인터페이스를 사용하고, 구현체로 PageRequest를 사용한다.

 

- Querydsl

 

Querydsl은 타입 안정성과 직관적인 문법의 장점을 기반으로 동적 쿼리를 작성하는데 유용한 도구이다.


 

- ReviewEntity.java

@NoArgsConstructor
@AllArgsConstructor
@Table(name = "review")
@Entity
@Getter
@Builder
public class ReviewEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Long restaurantId;
    private String content;
    private Double score;
    private ZonedDateTime createdAt;
}

 

Review는 특정 Restaurant에 대해 작성되고, 하나의 Restaurant에 여러개의 Review가 작성 될 수 있으므로 OneToMany 연관관계 매핑이 이루어 져야 한다. (하지만 위 코드는 연관관계 매핑을 하지 않은 상태다.)

 

- CreateReviewRequest.java

@AllArgsConstructor
@Getter
public class CreateReviewRequest {

    private final Long restaurantId;
    private String content;
    private Double score;
}

 

Review의 Request DTO 필드에는 위와 같이 3개의 필드를 받고 있다.

 

- ReviewDto.java

@Builder
@AllArgsConstructor
@Getter
public class ReviewDto {
    private Double avgScore;
    private List<ReviewEntity> reviews;
    private ReviewDtoPage page;

    @AllArgsConstructor
    @Builder
    @Getter
    public static class ReviewDtoPage {

        private Integer offset;

        private Integer limit;
    }
}

 

Review의 Response DTO 필드에는 위와 같이 3개의 필드와 페이징을 위한 DTO를 받고 있다.

 

- ReviewApi.java

@RestController
@RequiredArgsConstructor
public class ReviewApi {

    private final ReviewService reviewService;

    @PostMapping("/review")
    public void createReview(
            @RequestBody CreateReviewRequest request
    ) {
        reviewService.createReview(request.getRestaurantId(),
                request.getContent(), request.getScore());
    }

    @DeleteMapping("/review/{reviewId}")
    public void deleteReview(
            @PathVariable("reviewId") Long reviewId
    ) {
        reviewService.deleteReview(reviewId);
    }

    @GetMapping("/restaurant/{restaurantId}/reviews")
    public ReviewDto getRestaurantReviews(
            @PathVariable("restaurantId") Long restaurantId,
            @RequestParam("offset") Integer offset,
            @RequestParam("limit") Integer limit
    ) {
        return reviewService.getRestaurantReview(restaurantId,
                PageRequest.of(offset / limit, limit));
    }
}

 

- Controller

@PostMapping("/review")
    public void createReview(
            @RequestBody CreateReviewRequest request
    ) {
        reviewService.createReview(request.getRestaurantId(),
                request.getContent(), request.getScore());
    }

 

Review 생성 api의 경우 body에 JSON 형태로 넘어온 데이터를 createReview 메서드를 통해 DB에 저장한다.

 

- Service

@Transactional
    public void createReview(Long restaurantId, String content, Double score) {

        restaurantRepository.findById(restaurantId).orElseThrow();

        ReviewEntity review = ReviewEntity.builder()
                .restaurantId(restaurantId)
                .content(content)
                .score(score)
                .createdAt(ZonedDateTime.now())
                .build();

        reviewRepository.save(review);
    }

 

DTO를 통해 전달받은 데이터를 실제 Review 엔티티에 builder를 통해 객체를 생성하여 Repository의 save를 통해 DB에 저장한다.

 

다음으로 특정 음식점에 대한 Review를 페이징 처리를 통해 데이터를 받아 올 것이다.

 

- Controller

@GetMapping("/restaurant/{restaurantId}/reviews")
    public ReviewDto getRestaurantReviews(
            @PathVariable("restaurantId") Long restaurantId,
            @RequestParam("offset") Integer offset,
            @RequestParam("limit") Integer limit
    ) {
        return reviewService.getRestaurantReview(restaurantId,
                PageRequest.of(offset / limit, limit));
    }

 

쿼리파라미터를 통해 offset, limit 데이터를 전달하게 되면, 해당 데이터를 기반으로 PageRequest.of()의 파라미터 1. PageNumber 2. PageSize 로 넘기게되면 pageable 클래스를 통해 Page 혹은 Slice로 인터페이스로 반환할 수 있다. 

 

- Service

@Transactional
    public ReviewDto getRestaurantReview(Long restaurantId, Pageable pageable) {
        Double avgScore = reviewRepository.getAvgScoreByRestaurantId(restaurantId);
        Slice<ReviewEntity> reviews = reviewRepository.findSliceByRestaurantId(restaurantId, pageable);

        return ReviewDto.builder()
                .avgScore(avgScore)
                .reviews(reviews.getContent())
                .page(
                        ReviewDto.ReviewDtoPage.builder()
                                .offset(pageable.getPageNumber() * pageable.getPageSize())
                                .limit(pageable.getPageSize())
                                .build()
                )
                .build();
    }

 

Service 코드를 보게 되면, Repository의 2개의 커스텀 메서드를 사용하는 것을 볼 수 있다. 해당 메서드들은 Querydsl를 이용한 동적 쿼리를 작성한 메서드에 해당한다.

findSliceByRestaurantId 메서드의 경우 pageable 객체를 이용하여 페이징처리 할 것을 알 수 있다.

또한 ReviewDto의 내부 클래스로 ReviewDtoPage 클래스를 포함하여 builder 패턴을 이용하여 ReviewDto 클래스 객체를 생성하여 반환하게 되고, 반환 값은 응답 데이터로 전달된다.

 

 

- Repository

 

- getAvgScoreByRestaurantId

@Override
    public Double getAvgScoreByRestaurantId(Long restaurantId) {
        return queryFactory.select(QReviewEntity.reviewEntity.score.avg())
                .from(QReviewEntity.reviewEntity)
                .where(QReviewEntity.reviewEntity.restaurantId.eq(restaurantId))
                .fetchFirst();
    }

 

Querydsl을 이용한 첫번째 메서드는 요청 데이터로 넘어온 점수들의 평균을 계산하는 쿼리를 작성하는 코드다.

restaurantId를 통해 해당 음식점에 해당하는 Review의 점수들을 select 하여 avg()를 통해 평균 점수를 반환하는 코드에 해당한다.

 

- findSliceByRestaurantId

@Override
    public Slice<ReviewEntity> findSliceByRestaurantId(Long restaurantId, Pageable pageable) {
        List<ReviewEntity> reviews = queryFactory.select(QReviewEntity.reviewEntity)
                .from(QReviewEntity.reviewEntity)
                .where(QReviewEntity.reviewEntity.restaurantId.eq(restaurantId))
                .offset((long) pageable.getPageNumber() * pageable.getPageSize())
                .limit(pageable.getPageSize() + 1)
                .fetch();

        return new SliceImpl<>(
                reviews.stream().limit(pageable.getPageSize()).toList(),
                pageable,
                reviews.size() > pageable.getPageSize()
        );
    }

 

두번째 Querydsl을 이용한 메서드는 Pageable을 파라미터로 전달 받아 페이징을 통해 Slice를 반환하는 메서드에 해당한다.

SliceImpl(List<T> content, Pageable pageable, boolean hasNext)

 

SliceImpl은 3개의 파라미터를 전달 받고, 1. 반환 데이터 2. Pageable 객체 3. hasNext : 현재 슬라이스 뒤에 다른 슬라이스가 있는지 여부에 대한 값을 전달한다.

List<ReviewEntity> reviews = queryFactory.select(QReviewEntity.reviewEntity)
                .from(QReviewEntity.reviewEntity)
                .where(QReviewEntity.reviewEntity.restaurantId.eq(restaurantId))
                .offset((long) pageable.getPageNumber() * pageable.getPageSize())
                .limit(pageable.getPageSize() + 1)
                .fetch();

 

위 Querydsl 동적 쿼리 코드를 통해 원하는 데이터를 가져오고,

return new SliceImpl<>(
                reviews.stream().limit(pageable.getPageSize()).toList(),
                pageable,
                reviews.size() > pageable.getPageSize()
        );

 

SliceImpl을 반환함으로서 페이징 처리한 값을 반환하게 된다.

 

현재 데이터가 5개 있는 상황에서 offset=2&limit=3을 전달하게 될 경우, 

return reviewService.getRestaurantReview(restaurantId,
                PageRequest.of(offset / limit, limit));
    }

 

위 코드를 통해 PageNumber=0, PageSize=3의 값을 가지게 되고 해당 데이터가 ReviewDtoPage로 전달되어 위와 같은 응답을 반환하게 된다.

현재 데이터가 5개 있는 상황에서 offset=3&limit=3을 전달하게 될 경우, 

return reviewService.getRestaurantReview(restaurantId,
                PageRequest.of(offset / limit, limit));
    }

 

위 코드를 통해 PageNumber=1, PageSize=3의 값을 가지게 되고 해당 데이터가 ReviewDtoPage로 전달되어 위와 같은 응답을 반환하게 된다.

728x90