2024. 3. 6. 22:39ㆍ팀프로젝트일기/SCHOOLPICKS
[ 문제 상황 ]
SCHOOLPICKS라는 교내 프로젝트를 하며 Service과 Repository 계층의 코드 작성을 담당했었다. 당시 버그가 가장 많이 났었던 부분은 맛집 검색을 위한 동적 쿼리를 작성하는 부분이었다.
아래의 그림을 보면 우측 상단에는 학교 캠퍼스를 선택하는 버튼이 있었고 이는 반드시 둘 중 하나가 들어왔다. 그리고 중앙 부분에는 음식점, 카페, 술집이 0개부터 3개까지 전부 들어올 수 있었다. 그리고 가격도 설정한 범위의 값이 들어왔었다.
이걸 해결하려고 검색 조건을 jpql로 동적으로 짜려고 했었다. 아래의 코드를 보면 알 수 있는데, 처음으로 짜보는 동적 쿼리이고 JPQL로 짜다보니 다음과 같은 코드가 나왔다. shopTypes가 0개부터 3개이니 해당 값마다 케이스 별로 나누어서 쿼리를 작성해서 가독성이 썩 나쁘지는 않다. 하지만 만약 shopTypes가 반드시 1개 들어온다는 보장이 없이 0개가 들어온다면? 만약 음식점 종류가 더 증가하면? 새로운 검색 조건이 추가되면? 이런 고려를 해봤을 때 이 코드는 좋은 코드가 아니라고 생각을 한다.
public List<Shop> findSelectedShop(String schoolTypes, List<String> shopTypes, int priceMin, int priceMax) {
List<ShopType> shopType = setShopType(shopTypes);// shopType 설정
SchoolType schoolType = setSchoolType(schoolTypes); // schoolType 설정
List<Shop> shops = null;
if(shopTypes.isEmpty() || shopTypes.size() == 3){ // All인 경우에는 전부 다 가져옴.
TypedQuery<Shop> query = em.createQuery("SELECT s FROM Shop s WHERE s.price >= :price_low AND s.price <= :price_high AND s.schoolType = :school_type", Shop.class);
query.setParameter("price_low", priceMin);
query.setParameter("price_high", priceMax);
schoolParam(query, schoolType); // 자연과학 인문사회 설정
shops = query.getResultList(); // 모든 객체 반환
} else if (shopTypes.size() == 1) { // 1개일 때에는 해당 설정에 맞게 가져옴
// 음식점 종류별 DB 질의
TypedQuery<Shop> query = em.createQuery("SELECT s FROM Shop s WHERE s.shopType = :shop_type AND s.price >= :price_low AND s.price <= :price_high AND s.schoolType = :school_type", Shop.class);
query.setParameter("price_low", priceMin); // 가격 하한선
query.setParameter("price_high", priceMax); // 가격 상한선
schoolParam(query, schoolType); // 자연과학 인문사회 설정
shopParam(query, shopType, 0); // 식당 설정
shops = query.getResultList(); // 결과 리스트
}
else if(shopTypes.size() == 2){
// 음식점 종류별 DB 질의
TypedQuery<Shop> query = em.createQuery("SELECT s FROM Shop s WHERE (s.shopType = :shop_type OR s.shopType = :shop_type2) AND s.price >= :price_low AND s.price <= :price_high AND s.schoolType = :school_type", Shop.class);
query.setParameter("price_low", priceMin); // 가격 하한선
query.setParameter("price_high", priceMax); // 가격 상한선
schoolParam(query, schoolType); // 자연과학 인문사회 설정
shopParam(query, shopType, 0); // 식당 설정
shopParam2(query, shopType, 1);
shops = query.getResultList(); // 결과 리스트
}
return shops;
}
[ Querydsl을 통한 결과 리팩토링 ]
Querydsl을 사용하면 각각의 검색 조건을 booleanExpression이라는 타입의 조건으로 놓을 수 있고 이를 조합할 수 있게 해준다. 특히 booleanExpression에 아무런 조건이 없는 경우 null로 반환해서 입력하면 해당 조건을 무시한다. 그래서 마치 그 검색 조건은 없는 것처럼 작동하게 된다. 아래의 과정을 따라 하다보면 무슨 이야기인지 알 수 있을 것이다.
[ 엔티티 작성 ]
우선 Entity부터 작성을 해보자. Shop엔티티에는 부가적인 기능은 모두 빼고 검색 조건 부분만 남겨놓았다.
확인용 코드:
Shop.java
@Entity
@Builder
@AllArgsConstructor
public class Shop {
@Id @GeneratedValue
@Column(name = "SHOP_ID")
private Long Id;
private String name;
private int price;
@Enumerated(EnumType.STRING)
private ShopType shopType;
@Enumerated(EnumType.STRING)
private SchoolType schoolType;
내부 Enum 타입들은 다음과 같이 작성하였다. HSSC는 성균관대학교 인문사회캠퍼스, NSC는 성균관대학교 자연과학캠퍼스이다.
확인용 코드:
ShopType.java
public enum ShopType {
RESTAURANT, CAFE, PUB
}
SchoolType.java
public enum SchoolType {
NSC, HSSC
}
[ 리포지토리 작성 ]
DB로는 별도의 설정없이 사용할 수 있는 H2 데이터베이스를 사용하였고 Querydsl 설정과 관련된 부분은 Spring2.7 Querydsl 설정은 링크된 글에서 확인하면 된다.
Querydsl 코드는 다음과 같이 기본 JPAQuery를 작성하였다. 우선 findShopQuery에서 where 절에는 가격에 관한 정보, 학교 종류에 관한 정보, 가게 종류에 관한 정보가 들어간다. 가독성을 위해 Service계층에서는 dto를 받게 코드를 작성해놨는데 int은 원시타입이라 null이 아니라 0이 되므로 Integer로 작성을 했다.
처음 언급한 BooleanExpression과 null을 설명하자면, shopTypesIn은 검색 조건을 in 절로 검색해서 식당 조건에 개수를 추후에 바꾸어도 이 코드를 바꿀 일이 없게 만들었다. 내부에 if 절로 값이 null이면 null로 작성한 이유는 Querydsl에서 eq나 in이나 그런 조건 안에 null을 넣으면 에러가 발생한다. 그래서 이렇게 조건문을 따로 바깥에 메서드로 빼서 내부 값을 확인해주는 작업이 반드시 필요하다 . 이 때 null을 반환하면 querydsl에서는 where 절에 이 조건을 무시한다. 그래서 특정 조건이 들어가거나 들어가지 않는 동적쿼리를 작성할 때 가독성도 높아지고 변화에 유연한 코드를 작성할 수 있게 도와준다.
확인용 코드:
ShopQuerydslRepository.java
private JPAQuery<Shop> findShopQuery(ShopQuerydslDto dto) {
return queryFactory
.select(shop)
.from(shop)
.where(
priceBetween(dto),
schoolTypesEq(dto),
shopTypesIn(dto)
);
}
private static BooleanExpression shopTypesIn(ShopQuerydslDto dto) {
if(dto.getShopTypes() == null)
return null;
return shop.shopType.in(dto.getShopTypes());
}
private static BooleanExpression schoolTypesEq(ShopQuerydslDto dto) {
if(dto.getSchoolType() == null){
return null;
}
return shop.schoolType.eq(dto.getSchoolType());
}
private static BooleanExpression priceBetween(ShopQuerydslDto dto) {
if(dto.getPriceMin() == null || dto.getPriceMax() == null)
return null;
return shop.price.between(dto.getPriceMin(), dto.getPriceMax());
}
확인용 코드:
ShopQuerydslDto.java
@Data
@AllArgsConstructor
public class ShopQuerydslDto {
public SchoolType schoolType;
public List<ShopType> shopTypes;
Integer priceMin;
Integer priceMax;
}
그리고 위에 findShopQuery로 따로 만들어준 이유는 테스트를 위해서이다. 검색 조건이 뽑기라 랜덤하게 하나를 페이징 조건을 이용해서 뽑아주어야 한다. 그런데 문제는 이러면 테스트를 하기가 너무 어렵다. 그래서 따로 동적쿼리가 잘 작동하는지 확인하기 위한 findAll 메서드를 만들어서 findAll 메서드를 통해서 동적쿼리가 잘 작동하는지 확인할 예정이다.
참고로 findShopPaging 쿼리는 모든 값을 다 퍼올려서 size로 랜덤을 돌려도 되긴하는데, 예전에 프로젝트 때는 그렇게 구현을 해놓았다. 하지만 이런 코드 습관은 나중에 OutOfMemory가 나기 좋은 코드이기 때문에 쿼리를 두 개로 나누어서 카운트 쿼리를 통해 개수를 확인하고 1개 크기로 페이징한 것이다.
확인용 코드:
public Shop findShopPaging(ShopQuerydslDto dto){
// 모든 데이터를
Long shopCount = getShopCount(dto);
if(shopCount == 0)
return null;
int shopIndex = random.nextInt(Math.toIntExact(shopCount));
Shop findShop = findShopQuery(dto)
.offset(shopIndex)
.limit(1)
.fetchOne();
return findShop;
}
public List<Shop> findAll(ShopQuerydslDto dto){
return findShopQuery(dto)
.fetch();
}
private Long getShopCount(ShopQuerydslDto dto) {
Long shopCount = queryFactory
.select(shop.count())
.from(shop)
.where(
priceBetween(dto),
schoolTypesEq(dto),
shopTypesIn(dto)
)
.fetchOne();
return shopCount;
}
private JPAQuery<Shop> findShopQuery(ShopQuerydslDto dto) {
return queryFactory
.select(shop)
.from(shop)
.where(
priceBetween(dto),
schoolTypesEq(dto),
shopTypesIn(dto)
);
}
[ 테스트 코드 작성 ]
H2 데이터베이스는 스프링에서 테스트로 돌아갈 때 메모리 모드로 돌릴 수 있게 제공을 한다 그래서 별 다른 설정이 없어도 된다. @Transactional을 통해 각각의 테스트가 끝나면 롤백하게 만들어주었다. 또한 매번 지울 데이터를 다음과 같이 @BeforeEach로 작성을 해놓았고 저장하기 위한 shopRepository, 꺼내기 위한 shopQuerydslRepository를 주입해주었다.
확인용 코드:
@Transactional
@SpringBootTest
class ShopQuerydslRepositoryTest {
@Autowired
ShopQuerydslRepository shopQuerydslRepository;
@Autowired
ShopRepository shopRepository;
@BeforeEach
void initData(){
Shop shop = Shop.builder()
.name("식당1")
.price(1000)
.shopType(ShopType.RESTAURANT)
.schoolType(SchoolType.NSC)
.build();
shopRepository.save(shop);
Shop shop2 = Shop.builder()
.name("식당2")
.price(2000)
.shopType(ShopType.CAFE)
.schoolType(SchoolType.NSC)
.build();
shopRepository.save(shop2);
Shop shop3 = Shop.builder()
.name("식당3")
.price(3000)
.shopType(ShopType.PUB)
.schoolType(SchoolType.NSC)
.build();
shopRepository.save(shop3);
Shop shop4 = Shop.builder().name("식당4")
.price(4000)
.shopType(ShopType.RESTAURANT)
.schoolType(SchoolType.HSSC)
.build();
shopRepository.save(shop4);
Shop shop5 = Shop.builder()
.name("식당5")
.price(5000)
.shopType(ShopType.CAFE)
.schoolType(SchoolType.HSSC)
.build();
shopRepository.save(shop5);
Shop shop6 = Shop.builder()
.name("식당6")
.price(6000)
.shopType(ShopType.PUB)
.schoolType(SchoolType.HSSC)
.build();
shopRepository.save(shop6);
}
각각의 상황은 다음처럼 개별 검색 조건이 잘 작동하는지 확인하고 복합적으로 했을 때도 문제가 없는지 확인하였으며 모든 테스트 상황에서 문제없이 작동하였다.
확인용 코드:
@Test
void 학교별_검색확인(){
List<Shop> findNSCShop = shopQuerydslRepository
.findAll(new ShopQuerydslDto(SchoolType.NSC, null, null, null));
assertThat(findNSCShop.size()).isEqualTo(3);
List<Shop> findHSSCShop = shopQuerydslRepository
.findAll(new ShopQuerydslDto(SchoolType.HSSC, null, null, null));
assertThat(findHSSCShop.size()).isEqualTo(3);
}
@Test
void 데이터전체_검색확인(){
List<Shop> findShops = shopQuerydslRepository
.findAll(new ShopQuerydslDto(null, null, null, null));
assertThat(findShops.size()).isEqualTo(6);
}
@Test
void 식당타입_개수별_검색확인(){
List<Shop> findShops = shopQuerydslRepository
.findAll(new ShopQuerydslDto(null, List.of(ShopType.CAFE), null, null));
assertThat(findShops.size()).isEqualTo(2);
List<Shop> findShops2 = shopQuerydslRepository
.findAll(new ShopQuerydslDto(null, List.of(ShopType.CAFE, ShopType.RESTAURANT), null, null));
assertThat(findShops2.size()).isEqualTo(4);
List<Shop> findShops3 = shopQuerydslRepository
.findAll(new ShopQuerydslDto(null, List.of(ShopType.CAFE, ShopType.RESTAURANT, ShopType.PUB), null, null));
assertThat(findShops3.size()).isEqualTo(6);
}
@Test
void 식당가격별_검색확인(){
List<Shop> findShops1 = shopQuerydslRepository
.findAll(new ShopQuerydslDto(null, null, 1000, 3000));
assertThat(findShops1.size()).isEqualTo(3);
List<Shop> findShops2 = shopQuerydslRepository
.findAll(new ShopQuerydslDto(null, null, 1000, 6000));
assertThat(findShops2.size()).isEqualTo(6);
List<Shop> findShops3 = shopQuerydslRepository
.findAll(new ShopQuerydslDto(null, null, 10000, 20000));
assertThat(findShops3.size()).isEqualTo(0);
}
@Test
void 복합조건_검사(){
List<Shop> findShops1 = shopQuerydslRepository
.findAll(new ShopQuerydslDto(SchoolType.NSC, null, 1000, 2000));
assertThat(findShops1.size()).isEqualTo(2);
List<Shop> findShops2 = shopQuerydslRepository
.findAll(new ShopQuerydslDto(SchoolType.NSC, List.of(ShopType.CAFE, ShopType.PUB), null, null));
assertThat(findShops2.size()).isEqualTo(2);
List<Shop> findShops3 = shopQuerydslRepository
.findAll(new ShopQuerydslDto(SchoolType.NSC, List.of(ShopType.CAFE, ShopType.PUB, ShopType.RESTAURANT), 0, 1000));
assertThat(findShops3.size()).isEqualTo(1);
}
[ GITHUB 링크 ]
'팀프로젝트일기 > SCHOOLPICKS' 카테고리의 다른 글
School Picks - Weekly Demo Feedback2 (0) | 2023.10.08 |
---|---|
SchoolPicks - Weekly Demo Feedback 1 (0) | 2023.10.06 |