본문 바로가기

[개인프로젝트] 개발 공부

[프로젝트] 2. SPRINGBOOT 3.0 + Query DSL

728x90
반응형

검색 기능을 구현하던 중 딜레마에 빠지게 되었다...

카테고리 별로 원하는 검색어를 넣고 검색된 리스트를 불러오게 하는 단순한 검색 기능이라면 문제가 없었겠지만.. 

내가 구현하고자 하는 검색 기능은 검색어에 여러 검색 조건들을 추가로 더해 검색된 리스트를 조회하는 것이었다.

 

 

위 사진은 현재 만들고 있는 프로젝트에서 강의 리스트들을 보여주는 부분이다.

최상위인 지역 카테고리에서 다시 성인, 청소년, 아동 카테고리로 나뉘고 다시 수영, 댄스, 악기 등 카테고리로 나뉘게 된다. 각 카테고리들의 값들과 함께 검색어와 검색 조건 선택을 넣어 조회를 해주어야 한다.

 

상당히 로직이 복잡해지게 될 것이라 생각이 되었고 실제로 구현을 해보았다.

 

위 사진들은 일부분이며, 어떻게든 쥐어짜 내서 만들어 보았다.. 찍어낸 듯한 공장식 코드가 마음에 안 들지만 동작도 잘되기에 만족하고 다음 부분을 진행하려는데 한 가지가 걸리게 되었다.

 

 

검색 조건 선택에서 강좌구분 탭처럼 다중 선택을 해서 검색을 하게 되는 경우 코드가 기하급수적으로 늘어났고 기존 방식으로는 너무 비효율적이고 문제가 될 것이라 생각을 하게 되었다.

 

그래서 생각이 든 게 동적쿼리를 사용해야겠다는 생각이 들었으며, Query DSL을 채택하여 사용하기로 했다.

 

Query DSL은 자바 코드로 SQL 문을 작성할 수 있어 컴파일 시 오류를 발생하여 잘못된 쿼리가 실행되는 것을 방지할 수 있으며, 자동완성 기능도 지원받을 수 있다. 그리고 무엇보다 복잡한 쿼리나 동적 쿼리 작성이 편리하다!

 

Query DSL 사용을 위해 build.gradle에 코드를 추가해 준다. 

plugins {
	...
	id 'org.springframework.boot' version '3.0.4'
}

....

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

....

dependencies {
	...
    
    // QueryDSL Implementation
	implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
	annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
	annotationProcessor "jakarta.annotation:jakarta.annotation-api"
	annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}

....

/*QueryDSL Build Options*/
def querydslDir = "src/main/generated"

sourceSets {
	main.java.srcDirs += [ querydslDir ]
}

tasks.withType(JavaCompile) {
	options.getGeneratedSourceOutputDirectory().set(file(querydslDir))
}

clean.doLast {
	file(querydslDir).deleteDir()
}

 

728x90

기존 build.gradle에 위 코드들을 추가하였다. 결과는 아주 잘 실행되었다.

코드를 작성한 후 build clean을 한 후 build를 해주어야 한다.

그러면 아래 사진처럼 Q file이라는 게 생성이 되는데 이는 Query DSL을 통해 쿼리문을 작성할 때 사용되는 것 같다.

그러므로 꼭 build.gradle 코드 작성 후 clean => build를 해주자

 

생성된 Q file

 

다음으로 Configuration 파일과 동적 쿼리를 구현할 파일을 생성해준다.

Configuration은 아래와 같다.

@Configuration
public class QuerydslConfig {

    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

 

 

그리고 repository Custom과 Impl 파일을 새로 만들어준다.

먼저 Custom 파일은 아래와 같이 생성한 뒤

public interface LectureRepositoryCustom {

    ....
}

 

기존 사용하던 JPA repository에 상속을 해준다.

public interface LectureRepository extends JpaRepository<Lecture, Long>, LectureRepositoryCustom {

    ....
}

 

 

마지막으로 쿼리를 작성하는 Impl 파일을 생성해 준다.

앞서 생성한 Custom 파일을 상속해주고 오버라이딩 하여 사용한다. 이곳에서 쿼리문을 작성하게 될 것이다.

public class LectureRepositoryImpl extends QuerydslRepositorySupport implements LectureRepositoryCustom {

    @Autowired
    private JPAQueryFactory queryFactory;

    public LectureRepositoryImpl() {
        super(Test.class);
    }
    
}

 

상속에 대한 개념만 충분하다면 그리 복잡하게 느껴지지 않을 것이다. 

여기까지 설정해준다면 Query DSL을 사용할 수 있을 것이다. 이제 Query DSL을 응용하여 조건 검색을 만들어보자

 

Custom 파일에 메서드를 정의해 주고 Impl에서 오버라이딩해서 쿼리를 작성해 준다.

여기서 생성한 메서드를 서비스 코드에서 사용이 가능한 것이다. 아래와 같이 원하는 메서드 이름과 파라미터 값들을 작성해준다.

public interface LectureRepositoryCustom {

    List<Lecture> findBySearch(LectureInstitution lectureInstitution, String searchText,
                               Long mainCategoryNo, Long subCategoryNo,
                               List<LectureListRequestDto.DivisionItemList> searchDivision,
                               List<LectureListRequestDto.StateItemList> searchState);
}

 

그리고 다시 Impl 파일에서 해당 메서드에 대한 동적 쿼리를 구현해준다.

public class LectureRepositoryImpl extends QuerydslRepositorySupport implements LectureRepositoryCustom {

    @Autowired
    private JPAQueryFactory queryFactory;
    @Autowired
    private LectureStateRepository lectureStateRepository;
    @Autowired
    private LectureMainCategoryRepository lectureMainCategoryRepository;
    @Autowired
    private LectureSubCategoryRepository lectureSubCategoryRepository;

    public LectureRepositoryImpl() {
        super(Lecture.class);
    }

    @Override
    public List<Lecture> findBySearch(LectureInstitution lectureInstitution, String searchText,
                                      Long mainCategoryNo, Long subCategoryNo,
                                      List<LectureListRequestDto.DivisionItemList> searchDivision,
                                      List<LectureListRequestDto.StateItemList> searchState) {
        return queryFactory
                .selectFrom(lecture)
                .where(lecture.lectureInstitution.eq(lectureInstitution),
                        lecture.lectureTitle.contains(searchText),
                        eqMainCategory(mainCategoryNo),
                        eqSubCategory(subCategoryNo),
                        (eqDivision(searchDivision)),
                        (eqState(searchState)))
                .fetch();
    }

    private BooleanExpression eqMainCategory(Long mainCategoryNo) {
        if(mainCategoryNo == 0) {
            return null;
        } else {
            LectureMainCategory lectureMainCategory = lectureMainCategoryRepository.findById(mainCategoryNo)
                    .orElseThrow(() -> new IllegalArgumentException("해당 번호가 없습니다. No. : " + mainCategoryNo));
            return lecture.lectureMainCategory.eq(lectureMainCategory);
        }
    }

    private BooleanExpression eqSubCategory(Long subCategoryNo) {
        if(subCategoryNo == 0) {
            return null;
        } else {
            LectureSubCategory lectureSubCategory = lectureSubCategoryRepository.findById(subCategoryNo)
                    .orElseThrow(() -> new IllegalArgumentException("해당 번호가 없습니다. No. : " + subCategoryNo));
            return lecture.lectureSubCategory.eq(lectureSubCategory);
        }
    }

    private BooleanBuilder eqDivision(List<LectureListRequestDto.DivisionItemList> searchDivision) {
        BooleanBuilder builder = new BooleanBuilder();
        if(searchDivision.size() < 1) {
            return null;
        } else {
            for(int i=0; i<searchDivision.size(); i++) {
                builder.or(lecture.lectureDivision.eq(searchDivision.get(i).getDvItem()));
            }
            return builder;
        }
    }

    private BooleanBuilder eqState(List<LectureListRequestDto.StateItemList> searchState) {
        BooleanBuilder builder = new BooleanBuilder();
        if(searchState.size() < 1) {
            return null;
        } else {
            for(int i=0; i<searchState.size(); i++) {
                LectureState lectureState = lectureStateRepository.findById(searchState.get(i).getStItem())
                        .orElseThrow(() -> new IllegalArgumentException("해당 번호가 없습니다. No. : " + searchState));
                builder.or(lecture.lectureState.eq(lectureState));
            }
            return builder;
        }
    }
}

 

반응형

BooleanExpression과 BooleanBuilder는 return 값이 null이면 쿼리문에서 제외를 시켜버린다.

검색 조건에서 다중 선택을 하게 되면 BooleanBuilder와 반복문을 통해 OR를 추가하여 쿼리를 작성하게 설정하였다.

 

이제 공장식으로 작성된 듯한 코드는 치우고 Query DSL로 작성한 코드를 호출하여 사용을 해보자

 

그 많던 분기처리와 repository 쿼리가 사라지고 단 하나로 줄여지게 되었다.

새로 구현한 검색 기능을 사용해 본 결과 동작이 원하는 대로 잘 되었다.

 

하루동안 앓던 부분이 고작 Query DSL 하나로 해결이 되었다는 게 정말 시원했다. 코드량이 대폭 줄어들고 검색 조건을 추가하더라도 간편하게 작성하여 사용할 수 있다는 것에 정말 놀라웠다.

항상 새로운 기능을 찾아보고 사용하는 것이 새삼 중요하게 느껴졌고 계속해서 편리하고 좋은 기능들을 찾아서 사용해야겠다는 생각이 들었다. 

 

 

 

 

 

 

 

 

 

728x90
반응형