JPA Setting 및 Test
이번시간에는 실제 디비와 매핑될 domain 클래스를 작성하고 간략히 테스트한다.
도메인 예시
@Entity
@Getter
@NoArgsConstructor
public class Restaurant {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "restaurant_id")
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "category_key")
private Category category;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "region_Id")
private Region region;
private String name;
private String address;
@Column(name = "phone_number")
private String phoneNumber;
private String latitude;
private String longitude;
@Column(name = "open_time")
private String openTime;
@OneToMany(mappedBy = "restaurant",cascade = CascadeType.ALL)
private List<ImageRestaurant> imagesRestaurants = new ArrayList<>();
@OneToMany(mappedBy = "restaurant",cascade = CascadeType.ALL)
private List<Review> reviews = new ArrayList<>();
public void setImagesRestaurants(ImageRestaurant imageRestaurant) {
this.imagesRestaurants.add(imageRestaurant);
imageRestaurant.setRestaurant(this);
}
public void setReviews(Review review){
this.reviews.add(review);
review.setRestaurant(this);
}
public void setCategory(Category category){
this.category = category;
category.getRestaurants().add(this);
}
public void setRegion(Region region){
this.region = region;
region.getRestaurants().add(this);
}
}
가장 중요한 Restaurant 만 첨부해본다.
pk가될 컬럼에 @Id를 붙여주고
Generate value 전략을 identity로 설정하였다.
이렇게 설정하면 insert가 될때 auto_increment로 pk컬럼에 값이 들어가기 때문에 영속성컨텍스트에 persist되는 순간에
인서트가 된다고 한다. mySQL과 mariaDB에서 통상적으로 쓰는 전략이다.
그리고 연관관계편의메서드는 전부 Restaurant 클래스에 선언되어있다. 왜냐하면 거의 대부분 로직이 해당 도메인을 기준으로 처리된다.
식당의 이미지는 한개의 식당당 여러개가 있는구조여서 1:n관계이고 식당이 사라지면 당연히 이미지도 필요없어서 cascade all로 설정했다. 리뷰또한 같은원리
Region과 Category는 각각 지역과 해당음식점의 카테고리를 뜻하는 도메인인데 구색맞추기용으로 약식으로 구성하였다.
나머진 Restaurant에 매핑하는 기본컬럼들이다.
Region과 Category가 lazy로딩이긴한데.. 사실뭐 페치조인으로 그냥 한번에 긁어올거다. 어차피 중심은 Restaurant 도메인이다.
컨트롤러
@RestController
@RequiredArgsConstructor
@RequestMapping("/mobile")
public class RestaurantController {
private final RestaurantService restaurantService;
@GetMapping("mainList")
public Page<RestaurantDto> getMainList(MainParamDto mainParamDto){
PageRequest pageRequest = PageRequest.of(mainParamDto.getCurPage()-1,4);
return restaurantService.getMainList(mainParamDto,pageRequest);
}
}
이번 api는 모바일 메인화면에 뿌려질 음식점 목록이다.
모바일에서 호출하는 api는 컨텍스트 패스 뒤에 mobile이라고 뎁스 한단계를 더 놓도록 약속했다.
페이징은 프론트에서는 1부터 넘겨줄텐데 data - jpa 에서는 0부터시작이라 -1을 했다..
프론트에서 현재 요청하는 페이지 넘버와 소팅을 평점순으로 할지 리뷰개수 순으로 할지를 받는다.
그후 서비스에 넘겨줘버리기
서비스
@Service
@RequiredArgsConstructor
public class RestaurantService {
private final RestaurantRepository restaurantRepository;
public Page<RestaurantDto> getMainList(MainParamDto mainParamDto, Pageable pageable){
return restaurantRepository.getMainList(mainParamDto,pageable);
}
}
별거없다 그냥 repository에 넘겨주는게 끝 핵심은 repository
repository
public interface RestaurantRepository extends JpaRepository<Restaurant, Long>,RestaurantCustom{
@Query("select r from Restaurant r")
List<Restaurant> getList();
}
public interface RestaurantCustom {
Page<RestaurantDto> getMainList(MainParamDto mainParamDto, Pageable pageable);
}
package com.project.BingoApi.jpa.repository;
import com.project.BingoApi.jpa.domain.*;
import com.project.BingoApi.jpa.dto.MainParamDto;
import com.project.BingoApi.jpa.dto.RestaurantDto;
import com.querydsl.core.Tuple;
import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.dsl.MathExpressions;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.support.PageableExecutionUtils;
import java.util.ArrayList;
import java.util.List;
import static com.project.BingoApi.jpa.domain.QCategory.*;
import static com.project.BingoApi.jpa.domain.QRegion.region;
import static com.project.BingoApi.jpa.domain.QRestaurant.restaurant;
import static com.project.BingoApi.jpa.domain.QReview.review;
@RequiredArgsConstructor
public class RestaurantCustomImpl implements RestaurantCustom{
private final JPAQueryFactory jpaQueryFactory;
@Override
public Page<RestaurantDto> getMainList(MainParamDto mainParamDto, Pageable pageable) {
List<Tuple> result = jpaQueryFactory.select(
restaurant,
MathExpressions.round(review.rating.avg(),1),
review.rating.count()
)
.from(restaurant)
.leftJoin(restaurant.reviews, review)
.leftJoin(restaurant.category, category).fetchJoin()
.leftJoin(restaurant.region, region).fetchJoin()
.groupBy(restaurant.id)
.orderBy(sortStandard(mainParamDto))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> total = jpaQueryFactory.select(
restaurant.count()
)
.from(restaurant);
List<RestaurantDto> resultList = new ArrayList<>();
for(Tuple tuple : result){
Restaurant tmpRestaurant = tuple.get(restaurant);
Double avgRating = tuple.get(MathExpressions.round(review.rating.avg(),1));
Long cnt = tuple.get(review.rating.count());
resultList.add(new RestaurantDto(tmpRestaurant,avgRating,cnt));
}
return PageableExecutionUtils.getPage(resultList,pageable,total::fetchOne);
}
private OrderSpecifier<?> sortStandard(MainParamDto mainParamDto) {
if("avg".equals(mainParamDto.getGubun())){
return review.rating.avg().desc();
}
return review.rating.count().desc();
}
}
첫번째 화면은 repository인데 여기선 jpa리포지토리 상속받고 도메인타입이랑 해당 도메인의 pk타입 적어주면 끝난다.
이렇게만해줘도 사실 save findById 등 간단한 crud는 가능하다 참고로 저 getlist는 테스트용이라 무시해도된다.
이렇게만해주면 좋지만 모바일 메인은 사실 모바일기능의 절반정도가되는 핵심 화면이고 리뷰순 필터 등등 파라미터에 따라
뿌려지는 데이터가 달라지는 구조라 동적쿼리를 쓸수 밖에 없다.
메인에있는 모든기능을 하나의 로직으로 짤수있을진 모르지만 일단 해보자.
동적쿼리.. 복잡한쿼리 data-jpa만 쓰기엔 번거롭다 querydsl을 활용하였다.
쿼리 dsl을 활용하려면 custom인터페이스 하나와 그 구현체 클래스를 작성한후 custom인터페이스가 그 구현체를 상속 받도록 하면된다.
대신 custom,customImpl 이 명명규칙은 지켜야된다고한다 지켜주자.
해당 로직은 파라미터에 따라 평점순 또는 리뷰개수순으로 페이징하여 4개씩 뿌려주는 로직이다
지역 및 카테고리 테이블은 페치조인으로 한번에 다긁어온다. 메인화면에는 리뷰정보가 필요없어서 리뷰 내용은 가져오지 않지만
각식당 별 평균 평점과 리뷰개수를 가져와 리턴한다.
사실 리턴을 tuple로 실무에서 잘안한다고 하고 dto로 바로매핑한다고하는데 이러면 식당에 대한 이미지들을 지연로딩하는 로직을 수동으로 짜야할거 같았다.. 나름 고민을 많이했는데 튜플로 리턴받고 튜플을 dto로 매핑할때 dto에서 생성자를 통해 각 식당별 이미지들을 지연로딩하여 끌어오는식으로 구성했다.
@Data
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL) // Null 값인 필드 제외
public class RestaurantDto {
private Long restaurantId;
private CategoryDto category;
private RegionDto region;
private String name;
private String address;
private String phoneNumber;
private String latitude;
private String longitude;
private String openTime;
private List<ImageRestaurantDto> imagesRestaurants = new ArrayList<>();
private Double avgRating;
private Long cnt;
public RestaurantDto(Restaurant restaurant, Double avgRating, Long cnt){
this.restaurantId = restaurant.getId();
this.name = restaurant.getName();
this.address = restaurant.getAddress();
this.phoneNumber = restaurant.getPhoneNumber();
this.latitude = restaurant.getLatitude();
this.longitude = restaurant.getLongitude();
this.openTime = restaurant.getOpenTime();
this.category = new CategoryDto(restaurant.getCategory());
this.region = new RegionDto(restaurant.getRegion());
this.avgRating = avgRating;
this.cnt = cnt;
restaurant.getImagesRestaurants().stream().forEach(r -> r.getImageUrl());
imagesRestaurants = restaurant.getImagesRestaurants().stream().map(imageRestaurant -> new ImageRestaurantDto(imageRestaurant))
.collect(Collectors.toList());
}
}
dto에서 생성자를 통해 매핑할때 지연로딩하여 이미지들을 가져와 리스트에 담는다.
아 그리고 로직에 쿼리가 두개가 날라가는데 아래껀 식당의 총 개수이다 페이징처리를 위해 별도로 날려줘야한다.
근데 총 개수에서는 group by, order by, join절이이 필요없다.
조인이 필요없는 이유는 어차피 식당을 기준으로 left join이라 식당 개수가 변하진 않고 식당과 n의 관계가 되는 리뷰또한 그룹바이로 식당이랑 1:1로 매핑되게 해놨고.. 식당과 1:n이 되는 이미지 테이블은 애초에 지연로딩으로 가져올거라 로직에서 조인을 하지도 않는다.
사실 지연로딩시 활용되는 저 람다식과 스트림이 아직은 익숙치가 않다.. 현재 실무플젝에서는 사용하지도 않고 아마 모르시는분들도 많을거다. 프로젝트하면서 람다식과 스트림 공부를 조금 해야될거같다.
@Bean
JPAQueryFactory jpaQueryFactory(EntityManager em){
return new JPAQueryFactory(em);
}
참고로 querydsl에 필요한 jpaqueryFactory는 빈으로 등록하여 스프링 컨테이너에서 주입받도록 하였음.
테스트 결과
이런식으로 페이징정보와 데이터정보가 잘나오는데 일부만 캡쳐했다.. 같이 진행하는 친구들 이름으로 샘플데이터를 넣었고 해당 식당 이미지들 url이랑 키값은 보여주면 맘만먹으면 키값으로 이미지 삭제할수있으니 캡처안한다... ㅎㅎ 근데 카테고리랑 지역이랑 식당 이름과 주소가 매칭이 안되니까 샘플데이터 넣을때 잘생각하면서 해야겠다..