본문 바로가기
카테고리 없음

[Spring Boot] Spring Boot 프로젝트 리팩토링하기 - (1)

by LeeGangEun 2023. 8. 7.

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();
    }


그럼 기존 코드보다 아주 아름답게 변환할 수 있다...

유지보수, 가독성,  클린코드 등 모든 면에서 훨씬 나아졌다.