JPA에 대해서 다룬 포스트가 있다.
https://youarethebestcoding.tistory.com/115
Spring Data JPA begins
http://projects.spring.io/spring-data/ Spring Data Spring Data’s mission is to provide a familiar and consistent, Spring-based programming model for data access while still retaining the special traits of the underlying data store. It makes it easy to us
youarethebestcoding.tistory.com
이번 포스팅에서는 프로젝트에서 실제로 활용한 사례와 JPQL까지 다룬다.
# 메소드명을 작성할 때 객체를 참조하는 법
ublic interface CategoryRepository extends JpaRepository<Category, Long>{
List<Category> findByParentNull();
List<Category> findByCategorySets_Product_OptionSets_Id(Long optionSetId);
}
JpaRepository 인터페이스를 상속받아 목적에 맞게 메소드명을 작성한다.
여기서 주목할 점은 두번째 메소드의 _언더바이다.
학원에서 배울때도 그렇고 인터넷에 올라온 대부분의 포스팅은
객체를 해당 객체의 pk로 찾거나 객체의 다른 프로퍼티로 찾는 방법만 소개하고 있다.
Category findByName(String name);
심지어 공식 api문서에서도 찾기 어려웠다.
https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html
JPA Query Methods :: Spring Data JPA
As of Spring Data JPA release 1.4, we support the usage of restricted SpEL template expressions in manually defined queries that are defined with @Query. Upon the query being run, these expressions are evaluated against a predefined set of variables. Sprin
docs.spring.io
그래서 실제 프로젝트에서는 다른 객체로 찾게 되는 경우가 많은데 시작부터 난관에 부딪혀 멘붕이었다.
그런데 Jpa는 ORM 즉 객체 관계 매핑이다.
객체를 참조하는 방법이 없다면 ORM이라고 부를 수 없고 메소드명을 정의할때 객체를 참조하는 방법이 존재한다.
List<Category> findByCategorySets_Product_OptionSets_Id(Long optionSetId);
여기서 _언더바는 객체의 참조를 의미한다.
JpaRepository<Category, Long>
Category 타입을 가지는 JpaRepository를 상속했으므로
Category를 찾을텐데 optionset의 id로 카테고리를 찾고 싶은 경우이다.
public class Category {
private List<CategorySet> categorySets = new ArrayList<>();
}
category는 categorysets을 프로퍼티로 가지고
categoryset은 product를 가지고 product는 다시 optionsets을 가진다.
이렇게 참조에 참조를 더해서 메소드명을 작성할 수도 있다.
이때 category가 가지는 List<CategorySet>의 변수명이 categorySets이므로
메소드명에서도 CategorySet이 아니라 CategorySets (변수명 그대로) 작성해주어야 한다.
물론 첫글자는 대분자로 작성한다.
**한가지 주의할 점이 있다.
메소드명으로 작성할때는 대소문자에 매우 유의해야한다.
카멜케이스를 사용한다고 mId와 같이 소문자 한글자에 Id를 붙이면
메소드명에 MId라고 적어줘야할것 같지만 실제로 사용해본 결과 그렇지 않았다.
어차피 객체에 member라는 의미가 있으므로 불필요하게 m을 붙이는 일을 하지 않는게 좋을것 같다.
#다중조건
public interface InterestRepository extends JpaRepository<Interest, Long> {
Boolean existsByMemberIdAndOptionSetId(Long memberId,Long optionSetId);
void deleteByOptionSetIdAndMemberId(Long optionSetId, Long memberId);
void deleteByMemberId(Long memberId);
}
멤버의 pk와 Optionset pk로 해당상품이 멤버의 관심상품인지 체크하기 위한 메서드로 boolean 타입을 반환받는다.
여기서 조건은 memberId와 OptionSetId 두가지를 And로 받고 있다.
아래는 실제 날아가는 쿼리문이다.
select
*
from
(select
i1_0.id c0,
rownum rn
from
interest i1_0
where
i1_0.member_id=?
and i1_0.option_set_id=?) r_0_
where
r_0_.rn<=?
order by
r_0_.rn
member_id=? and option_set_id=? 으로 두가지 조건을 모두 적용하고 있다.
delete도 마찬가지로 일단 두가지 조건에 해당하는 interest를 먼저 찾고 삭제하는 모습을 볼 수 있다.
select
i1_0.id,
i1_0.create_time,
i1_0.member_id,
i1_0.option_set_id,
i1_0.update_time
from
interest i1_0
where
i1_0.option_set_id=?
and i1_0.member_id=?
delete
from
interest
where
id=?
#Projections : 필요한 속성만 조회하는 방법
Projections :: Spring Data JPA
Spring Data query methods usually return one or multiple instances of the aggregate root managed by the repository. However, it might sometimes be desirable to create projections based on certain attributes of those types. Spring Data allows modeling dedic
docs.spring.io
public interface OptionsRepository extends JpaRepository<Options, Long> {
List<OptionNamesValues> findDistinctByOptionSet_Product_CategorySets_Category_Id(Long id);
}
이 기능을 구현하기 위해서
나는 카테고리를 선택했을때
해당 카테고리에 속한 모든 product 그리고 그 product의 모든 optionset들이 가지는 option들의 name과 value가 필요했다.
일반적인 방법으로 Options 객체를 그대로 가져오면 중복되는 option name과 value를 거를 수가 없었고
Options 객체들을 모아서 그중 name과 value 속성만 골라서 각각 distinct 하는 작업을 하는것은 매우 비효율적이라고 생각했다.
그래서 projections에 관해 찾아보고 interface 방식으로 적용했다.
public interface OptionNamesValues {
String getName();
String getValue();
}
인터페이스는 getName, getValue 메소드를 가진다.
찾은 name과 value는 getName, getValue 메소드를 사용해 꺼낸다.
옵션명(ex.운영체제)는 옵션값(ex.윈도우11, 윈도우10, 미포함...) 과 같이
옵션명을 키 값으로 하고 옵션값을 value로 하는 맵 형식으로 가공되어져야 한다.
아래 코드는 service에서 name과 value를 map 형태로 가공해 dto 형태로 변환하는 과정이다.
List<OptionNamesValues> optionNameValue = optionDao.findOptionNameValueMapByCategoryId(categoryId);
Map<String, Set<String>> dto = optionNameValue.stream().collect(Collectors.groupingBy(
OptionNamesValues::getName, Collectors.mapping(OptionNamesValues::getValue, Collectors.toSet())));
#EntityManager 사용하기
검색 기능을 구현하기 위해 신경을 많이 썼다.
검색을 할때 들어갈 조건으로는
카테고리, 옵션(다중선택), 가격범위(최소, 최대), 제품명이 있고 정렬기준도 선택이 가능하다.
문제는 모든 검색이 이 모든 조건을 포함하고 있지 않고 옵션의 수에 제한이 없고
같은 옵션 내에서 다른 옵션값을 다중 선택한 경우와 다른 옵션을 다중 선택한 경우에 논리연산이 달라진다는 것이다.
성능을 위해서는 입력된 조건값에 따라서 필요한 조건만 동적으로 쿼리를 생성하는 방법이 최선이라고 생각했다.
처음에는 queryDSL을 적용하려고 했었는데
eclipse에서 스프링부트3.13의 환경에서 여러 방법으로 세팅을 시도해보았는데 거의 이틀동안 세팅을 하지 못했다...
더이상 팀원들은 슬슬 서비스 들어가는데 아직 queryDSL을 세팅 못해서 repository만 붙잡고 있을 수가 없어서
결국 jpql로 하기로 했다...ㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠ
기본적으로 학원의 오라클 db를 사용하였지만 나중에 배포하고 rds로 mysql을 사용할 것을 생각해서
nativeQuery는 지양하기로 했고 책 사서 jpql을 좀 보니까 오히려 jpql이 객체 지향형쿼리라
쿼리를 작성하기 더 쉬웠다.
public class OptionSetSearchQuery {
private String searchQuery;
public void setOrderType(String orderType) {
this.searchQuery = this.searchQuery.replace(":orderType", orderType);
}
public OptionSetSearchQuery() {
this.searchQuery = "SELECT os " + "FROM OptionSet os " + " join fetch os.product p" + " WHERE os.stock >0 "
+ " Order By :orderType ";
setOrderType(OptionSetQueryData.BY_ORDER_COUNT);// default 설정
}
public OptionSetSearchQuery(QueryStringDataDto searchDto) {
if (searchDto.getCategory() != null) {
CategoryDto category = searchDto.getCategory();
this.searchQuery = "SELECT os " + "FROM OptionSet os " + " join fetch os.product p "
+ " join fetch p.categorySets cs " + " join fetch cs.category c " + " WHERE os.stock >0 ";
categoryFilter(category);
} else {
this.searchQuery = "SELECT os " + "FROM OptionSet os " + " join fetch os.product p" + " WHERE os.stock >0 ";
}
if (searchDto.getOptionset() != null) {
List<OptionDto.OptionNameValueMapDto> optionset = searchDto.getOptionset();
for (int i = 0; i < optionset.size(); i++) {
String key = optionset.get(i).getOptionName();
optionFilter(key, optionset.get(i).getOptionValue());
}
}
if (searchDto.getNameKeyword() != null) {
nameKeyword(searchDto.getNameKeyword());
}
if (searchDto.getMinPrice() != null && searchDto.getMaxPrice() != null) {
priceRange(searchDto.getMinPrice(), searchDto.getMaxPrice());
}
if (searchDto.getMinPrice() != null && searchDto.getMaxPrice() == null) {
onlyMinConstraint(searchDto.getMinPrice());
}
if (searchDto.getMaxPrice() != null && searchDto.getMinPrice() == null) {
onlyMaxConstraint(searchDto.getMaxPrice());
}
this.searchQuery += " Order By :orderType ";
if (searchDto.getOrderType() != null) {
String orderType = searchDto.getOrderType();
if (orderType.equals("판매순")) {
setOrderType(OptionSetQueryData.BY_ORDER_COUNT);
} else if (orderType.equals("조회순")) {
setOrderType(OptionSetQueryData.BY_VIEW_COUNT);
} else if (orderType.equals("최신순")) {
setOrderType(OptionSetQueryData.BY_CREATE_TIME);
} else if (orderType.equals("최저가순")) {
setOrderType(OptionSetQueryData.BY_TOTAL_PRICE);
} else {// default
setOrderType(OptionSetQueryData.BY_ORDER_COUNT);
}
} else {
setOrderType(OptionSetQueryData.BY_ORDER_COUNT);
}
}
public void categoryFilter(CategoryDto category) {
String category_filter = "";
if (category.getName().equals("전체")) {
category_filter = "AND c.parent.id = :categoryFilter ";
category_filter = category_filter.replace(":categoryFilter", "" + category.getId() + "");
} else {
category_filter = "AND c.id = :categoryFilter ";
category_filter = category_filter.replace(":categoryFilter", "" + category.getId() + "");
}
this.searchQuery += category_filter;
}
public void optionFilter(String optionName, List<String> optionValue) {
if (optionValue != null) {
String valueString = "o.value= :optionValue";
if (optionValue.size() == 1) {
valueString = valueString.replace(":optionValue", "'" + optionValue.get(0) + "' ");
} else if (optionValue.size() > 1) {
valueString = valueString.replace(":optionValue", "'" + optionValue.get(0) + "' ");
for (int i = 1; i < optionValue.size(); i++) {
valueString += " OR o.value=" + "'" + optionValue.get(i) + "' ";
}
}
String option_filter = "AND EXISTS ( SELECT 1 FROM Options o WHERE o.optionSet = os AND o.name = :optionName AND ("
+ valueString + ") ) ";
option_filter = option_filter.replace(":optionName", "'" + optionName + "'");
this.searchQuery += option_filter;
}
}
public void priceRange(int minPrice, int maxPrice) {
String price_range = "AND os.totalPrice between :minPrice and :maxPrice ";
price_range = price_range.replace(":minPrice", String.valueOf(minPrice));
price_range = price_range.replace(":maxPrice", String.valueOf(maxPrice));
this.searchQuery += price_range;
}
private void onlyMinConstraint(int minPrice) {
String minConstraint = "AND os.totalPrice >= :minPrice ";
minConstraint = minConstraint.replace(":minPrice", String.valueOf(minPrice));
this.searchQuery += minConstraint;
}
private void onlyMaxConstraint(int maxPrice) {
String maxConstraint = "AND os.totalPrice <= :maxPrice ";
maxConstraint = maxConstraint.replace(":maxPrice", String.valueOf(maxPrice));
this.searchQuery += maxConstraint;
}
public void nameKeyword(String nameKeyword) {
String name_keyword = "AND LOWER(os.product.name) like LOWER(:nameKeyword) ";
name_keyword = name_keyword.replace(":nameKeyword", "'%" + nameKeyword + "%'");
this.searchQuery += name_keyword;
}
public String build() {
return this.searchQuery;
}
}
이 클래스는 입력받아 전달받은 데이터를 확인하고 그 데이터에 맞춰 쿼리를 build하는 클래스로 작성하였다.
기본 생성자로
public OptionSetSearchQuery() {
this.searchQuery = "SELECT os " + "FROM OptionSet os " + " join fetch os.product p" + " WHERE os.stock >0 "
+ " Order By :orderType ";
setOrderType(OptionSetQueryData.BY_ORDER_COUNT);// default 설정
}
기본 쿼리를 작성하고
카테고리가 선택되면
this.searchQuery = "SELECT os " + "FROM OptionSet os " + " join fetch os.product p "
+ " join fetch p.categorySets cs " + " join fetch cs.category c " + " WHERE os.stock >0 ";
categoryFilter(category);
기본쿼리는 필요한 객체들을 join하는 쿼리로 기본 쿼리를 수정하고 카테고리를 수정한다.
String name_keyword = "AND LOWER(os.product.name) like LOWER(:nameKeyword) ";
name_keyword = name_keyword.replace(":nameKeyword", "'%" + nameKeyword + "%'");
제품명 키워드가 들어있으면 위와같이 쿼리를 위치에 붙여준다.
public void optionFilter(String optionName, List<String> optionValue) {
if (optionValue != null) {
String valueString = "o.value= :optionValue";
if (optionValue.size() == 1) {
valueString = valueString.replace(":optionValue", "'" + optionValue.get(0) + "' ");
} else if (optionValue.size() > 1) {
valueString = valueString.replace(":optionValue", "'" + optionValue.get(0) + "' ");
for (int i = 1; i < optionValue.size(); i++) {
valueString += " OR o.value=" + "'" + optionValue.get(i) + "' ";
}
}
String option_filter = "AND EXISTS ( SELECT 1 FROM Options o WHERE o.optionSet = os AND o.name = :optionName AND ("
+ valueString + ") ) ";
option_filter = option_filter.replace(":optionName", "'" + optionName + "'");
this.searchQuery += option_filter;
}
}
옵션이 선택되면 실행될 코드이다.
valueString은 옵션값이 다중선택된 경우 or 연산을 해야하기 때문에 value의 size만큼 or연산을 반복하여 valueString을 완성하고
name을 붙여
exists 쿼리를 작성하고 조건문 위치에 붙여준다.
이렇게 작성한 쿼리를 사용할 repository 를 만들어준다.
@Repository
@RequiredArgsConstructor
public class OptionSetQueryRepository {
@PersistenceContext
private EntityManager em;
public List<OptionSet> findByFilter(QueryStringDataDto dataDto){
String jpql = new OptionSetSearchQuery(dataDto).build();
TypedQuery<OptionSet> query = em.createQuery(jpql,OptionSet.class);
return query.getResultList();
}
public List<ProductListOutputDto> findForMemberByFilter(QueryStringDataDto dataDto, String username){
String mainJpql = new OptionSetSearchQuery(dataDto).build();
TypedQuery<OptionSet> query = em.createQuery(mainJpql,OptionSet.class);
String findHeartJpql = "SELECT i.optionSet.id FROM Interest i WHERE i.member.userName= :username";
TypedQuery<Long> heart = em.createQuery(findHeartJpql,Long.class);
heart.setParameter("username", username);
List<Long> heartOptionSetId = heart.getResultList();
List<OptionSet> searchResult = query.getResultList();
List<ProductListOutputDto> finalResult = searchResult.stream().map(t -> {
ProductListOutputDto productDto = new ProductListOutputDto(t);
productDto.setIsInterested(heartOptionSetId.contains(t.getId()));
return productDto;
}).collect(Collectors.toList());
return finalResult;
}
}
로그인한 경우 조회한 아이템에 멤버 아이디로 관심상품 여부도 판별해야하기 때문에 메소드를 두가지로 만든다.
일단 jpql 쿼리로 결과를 얻기 위해 entityManager가 필요했다.
EntityManager에 @PersistenceContext 어노테이션을 붙여준다.
QueryStringDataDto는 카테고리, 가격, 키워드 등 조건 중 입력된 값이 들어있고
그 값을 받아
String jpql = new OptionSetSearchQuery(dataDto).build();
jpql 쿼리를 생성했다.
TypedQuery<OptionSet> query = em.createQuery(jpql,OptionSet.class);
entityManager의 createQuery 메서드에 jpql쿼리와 반환받을 타입을 파라메터로 넣는다.
TypedQuery는 쿼리를 실행하고 결과를 핸들링할 타입을 받을 인터페이스이다.
쿼리 실행 결과는 getResultList() 메소드로 얻을 수 있다.
'Java > Project' 카테고리의 다른 글
스프링부트 프로젝트 CI/CD 환경 구축 (with.AWS + GIthub Actions) (0) | 2023.11.24 |
---|---|
스프링부트 프로젝트 서비스와 예외처리 (1) | 2023.11.22 |
스프링부트 프로젝트 계층에 따른 데이터 전송 형태 (0) | 2023.11.21 |
다나가 쇼핑몰 프로젝트 ER diagram과 Entity (0) | 2023.11.20 |
쇼핑몰 웹사이트 제작 프로젝트 (0) | 2023.11.09 |