1년전 만들었던 프로젝트의 코드를 리팩토링 해보고자 한다.
해당 프로젝트는 서울 명소 소개 페이지였는데,
오랜만에 열어보니 리팩토링할 부분들이 많이 보였다.
하나씩 고쳐보도록 하자.
1. JPQL > Spring Data JPA + QueryDSl
기존 코드는 아래와 같이 되어있었다.
public interface TripRepository extends JpaRepository<Trip, Long> {
// @Query(value = "select a.bno b.img_name, b.path, b.uuid" +
// "from Trip a" +
// "right outer join trip_image b on a.bno = b.trip_bno",nativeQuery = true)
// @Query(value = "select a.bno, b.img_name, b.path, b.uuid" +
// " from trip a " +
// " LEFT OUTER JOIN trip_image b " +
// " ON a.bno = b.trip_bno", nativeQuery = true)
@Query("select a , m , b, avg(coalesce(r.star,0)), count(distinct r)" +
" from Trip a " +
" Left OUTER JOIN TripImage b ON b.trip = a " +
" Left OUTER JOIN Member m ON m.id = a.writer.id" +
" left outer join TripReply r on r.trip = a group by a")
Page<Object[]> getListPage(Pageable pageable);
@Query("select a , m , b, avg(coalesce(r.star,0)), count(distinct r) " +
" from Trip a " +
" Left OUTER JOIN TripImage b ON b.trip = a " +
" Left OUTER JOIN Member m ON m.id = a.writer.id" +
" left outer join TripReply r on r.trip = a " +
" where a.place = :place " +
" group by a, m, b")
Page<Object[]> getListSortPage(Pageable pageable,@Param("place") String place) ;
// -----------------a file list 없다고 오류 뜸--------------------------------
// @Query(value = "SELECT b.trip_bno, a.writer_id, b.img_name, b.path, b.uuid" +
// " from trip a" +
// " LEFT OUTER JOIN trip_image b ON a.bno = b.trip_bno" +
// " LEFT OUTER JOIN member m ON a.writer_id = m.id" ,nativeQuery = true)
// Page<Object[]> getListPage(Pageable pageable);
@Query("select a.bno , m.id ,b.imgName, b.path, b.uuid" +
" from Trip a " +
" Left OUTER JOIN TripImage b ON b.trip = a " +
" Left OUTER JOIN Member m ON m.id = a.writer")
List<Trip> getList();
@Query("select a,mi, m,avg(coalesce(r.star,0)), count(distinct r)"+
" from Trip a " +
" left outer join TripImage mi on mi.trip = a " +
" left outer join TripReply r on r.trip=a" +
" left outer join Member m on m.id = a.writer.id"+
" where a.bno = :bno group by mi")
List<Object[]> getTripWithAll(@Param("bno")Long bno);
@Modifying
@Query("delete from Trip where bno=:bno")
void deleteMovieById(Long bno);
@Query("select a , m , b, avg(coalesce(r.star,0)), count(distinct r) " +
" from Trip a " +
" Left OUTER JOIN TripImage b ON b.trip = a " +
" Left OUTER JOIN Member m ON m.id = a.writer.id" +
" Left OUTER JOIN TripReply r on r.trip = a " +
" where a.content like %:keyword% or a.title like %:keyword% or a.place like %:keyword% or a.writer.id like %:keyword%" +
" group by a")
Page<Object[]> getSearch(Pageable pageable,@Param("keyword") String keyword);
@Query("select a , m , b, avg(coalesce(r.star,0)), count(distinct r) " +
" from Trip a " +
" Left OUTER JOIN TripImage b ON b.trip = a " +
" Left OUTER JOIN Member m ON m.id = a.writer.id" +
" Left OUTER JOIN TripReply r on r.trip = a " +
" where a.writer.id like %:Myid%" +
" group by a")
Page<Object[]> getMySearch(Pageable pageable,@Param("Myid") String Myid);
}
무슨 쿼리인줄 알기도 힘들뿐더러 반환값도 Object[]로 반환하고 있었다.
Function<Object[], TripDTO> fn = (arr -> entitiesToDTO(
(Trip) arr[0],
(List<Member>) (Arrays.asList((Member) arr[1])),
(List<TripImage>) (Arrays.asList((TripImage) arr[2])),
(Double) arr[3],
(Long)arr[4]
));
게다가 이런식으로 함수형 인터페이스를 사용하여 타입을 변경시켜주고 있었는데,
지금 생각해보니 여기에는 여러가지 문제점이 존재한다.
1. 타입 캐스팅의 취약성
-> 컴파일 시 타입을 보장받지 못하고 런타임시 타입을 변환하기 때문에 예상하지 못한 에러가 발생할 수 있다.
2. 배열 인덱스 사용으로 인한 가독성 저하 및 파라미터의 역할이 불문명
-> 각 파라미터의 정보를 얻을때 배열의 인덱스를 통하여 접근하고 있어, 코드를 쉽게 파악하기 힘들다.
-> 각 파라미터들이 어떤 의미를 갖고 있는지 명확히 파악하기가 힘들다.
리팩토링하기 전 주의할 점은 리팩토링 전과 후에 동일한 쿼리를 반환해야 한다는 것이다.
이를 위해 p6spy를 나가는 쿼리를 확인해보자.
p6spy 적용방법
먼저 아래 쿼리부터 리팩토링 해보자
@Query("select a , m , b, avg(coalesce(r.star,0)), count(distinct r)" +
" from Trip a " +
" Left OUTER JOIN TripImage b ON b.trip = a " +
" Left OUTER JOIN Member m ON m.id = a.writer.id" +
" left outer join TripReply r on r.trip = a group by a")
Page<Object[]> getListPage(Pageable pageable);
위 쿼리는 게시판 목록을 불러올때 사용하는 쿼리이다.
QueryDsl로 변경해보자.
// as-is
@Query("select a , m , b, avg(coalesce(r.star,0)), count(distinct r)" +
" from Trip a " +
" Left OUTER JOIN TripImage b ON b.trip = a " +
" Left OUTER JOIN Member m ON m.id = a.writer.id" +
" left outer join TripReply r on r.trip = a group by a")
Page<Object[]> getListPage(Pageable pageable);
// to-be
public List<Tuple> getListPage() {
return queryFactory
.select(trip, member, tripImage,
tripReply.star.avg().coalesce(0d).as("avgStat"),
tripReply.countDistinct().as("replyCount"))
.from(trip)
.leftJoin(tripImage).on(trip.eq(tripImage.trip))
.leftJoin(member).on(trip.writer.eq(member))
.leftJoin(tripReply).on(trip.eq(tripReply.trip))
.groupBy(trip)
.fetch();
}
위와같이 간단하게 변경할 수 있다.
크게 달라지지 않은 것 같은데? 라고 생각할수도 있지만
QueryDsl을 사용할 때 장점이 많다.
1. 컴파일 시 문법 오류 검출
-> 간단한 오류는 jpql도 캐치를 해주지만, QueryDsl 처럼 세세한 캐치는 해주지 못한다.
2. IDE 자동 완성 기능이 편리하다.
3. 복잡한 쿼리 및 동적 쿼리 작성 용이
4. 객체지향적 코드 작성
이런 장점들을 통해 QueryDsl은 코드 품질을 높이고 개발자가 더 효과적으로 쿼리를 작성하고 디버깅할 수 있도록 도와준다.
그럼 변경했으니 실제 나가는 쿼리를 확인해보자.
// JPQL -------------------------
select trip0_.bno as col_0_0_,
member2_.id as col_1_0_,
tripimage1_.inum as col_2_0_,
avg(coalesce(tripreply3_.star, 0)) as col_3_0_,
count(distinct tripreply3_.rno) as col_4_0_,
trip0_.bno as bno1_5_0_,
member2_.id as id1_2_1_,
tripimage1_.inum as inum1_6_2_,
trip0_.moddate as moddate2_5_0_,
trip0_.regdate as regdate3_5_0_,
trip0_.content as content4_5_0_,
trip0_.place as place5_5_0_,
trip0_.title as title6_5_0_,
trip0_.writer_id as writer_i7_5_0_,
member2_.moddate as moddate2_2_1_,
member2_.regdate as regdate3_2_1_,
member2_.from_social as from_soc4_2_1_,
member2_.password as password5_2_1_,
tripimage1_.img_name as img_name2_6_2_,
tripimage1_.path as path3_6_2_,
tripimage1_.trip_bno as trip_bno5_6_2_,
tripimage1_.uuid as uuid4_6_2_
from trip trip0_
left outer join trip_image tripimage1_ on (tripimage1_.trip_bno = trip0_.bno)
left outer join member member2_ on (member2_.id = trip0_.writer_id)
left outer join trip_reply tripreply3_ on (tripreply3_.trip_bno = trip0_.bno)
group by trip0_.bno
order by trip0_.bno desc
limit 12;
// queryDSL ------------------------
select trip0_.bno as col_0_0_,
member2_.id as col_1_0_,
tripimage1_.inum as col_2_0_,
coalesce(avg(tripreply3_.star), 0.0) as col_3_0_,
count(distinct tripreply3_.rno) as col_4_0_,
// ...... 생략
from trip trip0_
left outer join trip_image tripimage1_ on (trip0_.bno = tripimage1_.trip_bno)
left outer join member member2_ on (trip0_.writer_id = member2_.id)
left outer join trip_reply tripreply3_ on (trip0_.bno = tripreply3_.trip_bno)
group by trip0_.bno;
실제로 동일한 쿼리가 나가는 것을 확인할 수 있었지만,
또 다른 문제점이 드러났다.
바로 select 절에서 필요없는 필드까지 전부 가져오고 있다는 것이다.
Member 엔티티의 password 및 여러가지 중복된 데이터들을 불필요 하게 가져오고 있다.
이건 어떻게 해결할 수 있을까?
public List<Tuple> getListPage() {
return queryFactory
.select(trip.bno, trip.writer, member.roleSet, tripImage.inum,
tripReply.star.avg().coalesce(0d).as("avgStat"),
tripReply.countDistinct().as("replyCount"))
.from(trip)
.leftJoin(tripImage).on(trip.eq(tripImage.trip))
.leftJoin(member).on(trip.writer.eq(member))
.leftJoin(tripReply).on(trip.eq(tripReply.trip))
.groupBy(trip)
.fetch();
}
select 절에서 가져올 필드들을 명시적으로 지정해주었다.
이렇게 하면 필요한 필드만 선택적으로 가져오게 된다.
이렇게 함으로써 불필요한 데이터를 가져오는 문제를 해결할 수 있다.
그러나........ 또 다른 문제가 발생한다.
타입이 2가지 이상이면 QueryDsl 의 쿼리 결과 리턴 타입이 Tuple인것이다.
Tuple은 QueryDsl에서 여러 타입을 조회하기 위해 만든것인데,
Tuple타입의 데이터를 가져오는 방법은 아래와 같다.
List<Tuple> listPage = tripQueryRepository.getListPage();
for (Tuple tuple : listPage) {
String content = tuple.get(QTrip.trip.content);
}
얼마나 비효율적인가....
위 문제를 해결하기 위해 DTO를 생성하여 Projections를 사용할 수 있다.
먼저 DTO를 만들어 주자.
public class TripDTO {
@Getter
@AllArgsConstructor
@Builder
public static class Response {
private Long bno;
private String title;
private String content;
private String place;
private String writerId;
private LocalDateTime regDate;
private LocalDateTime modDate;
private long replyCount;
private double startAvg;
}
그 후 QueryDsl코드를 Projections.fields를 이용하여 매핑해주자.
public List<TripDTO.Response> getListPage() {
return queryFactory
.select(Projections.fields(TripDTO.Response.class,
trip.bno, trip.writer, trip.title, trip.content, trip.place,
trip.regDate, trip.modDate,
tripReply.star.avg().coalesce(0d).as("startAvg"),
tripReply.countDistinct().as("replyCount")))
.from(trip)
.leftJoin(tripImage).on(trip.eq(tripImage.trip))
.leftJoin(member).on(trip.writer.eq(member))
.leftJoin(tripReply).on(trip.eq(tripReply.trip))
.groupBy(trip)
.fetch();
}
그럼 기존 코드보다 아주 아름답게 변환할 수 있다...
유지보수, 가독성, 클린코드 등 모든 면에서 훨씬 나아졌다.