개발자꿈나무

FureverHomes 프로젝트 - 검색과 페이징 본문

기술블로그

FureverHomes 프로젝트 - 검색과 페이징

망재이 2023. 9. 26. 20:53

페이징 너무 어려웠다.. 검색기능까지 구현을 다 했다가 페이징하면서 몇 번이나 코드를 대폭 수정하고 드디어 성공했다..!!

먼저 검색 기능부터 정리한 후에 페이징을 정리하도록 하겠다.

 

 

 

1. 정보 검색하기

아참 그리고 또 하나 완전 수정을 한 부분이 있다. 동물 정보를 조회해올 때 JSON으로 변환하는 과정을 일일히 수행하도록 메서드를 생성했었는데 스프링부트에서는 DTO를 반환하면 알아서 JSON으로 변환해준다고 한다..!! 요청을 받을 때 DTO로 받아올 수 있다는건 알고있었는데 당연히 반대로 될거란 생각을 왜 못했는지 모르겠다.. 멍청해 ㅠ 그래서 JSON으로 변환하던 부분을 다 지워버리고 응답코드에 DTO를 반환하도록 수정했다.

 

 

AnimalSearchDTO

 

 

먼저 검색 키워드를 받아올 DTO를 먼저 생성해줬다.

@Data
@NoArgsConstructor
public class AnimalSearchDTO {
    private Species species; //종
    private Sex sex; //성별
    private Region region; //지역
}

 

 

 

 

AnimalService - 정보 검색

 

 

내가 검색할 수 있도록 만들어 놓은 키워드는 동물의 종, 성별, 보호 지역 이 세가지였는데 리퀘스트 파람으로 각각의 값을 가져오고 그 값을 DTO에 넣어줄 때 셋 다 이넘 클래스로 관리하고 있어서 이 부분에서 조금 오류가 많이 났다. Map 타입의 값을 넘겨받고 파라미터 값이 null인지 아닌지 확인 후에 SearchDTO를 생성해줬다.

public AnimalResDTO searchAnimal(final Map<String, String> requestparams) {
    //키워드를 확인 후 SearchDTO 생성
        AnimalSearchDTO animalSearchDTO = new AnimalSearchDTO();
        String species = requestparams.get("species");
        String sex = requestparams.get("sex");
        String region = requestparams.get("region");

        if (species != null) {
            animalSearchDTO.setSpecies(Species.valueOf(species));
        }
        if (sex != null) {
            animalSearchDTO.setSex(Sex.valueOf(sex));
        }
        if (region != null) {
            animalSearchDTO.setRegion(Region.valueOf(region));
        }
        
        //검색 조건에 맞는 쿼리 생성
    }

여기까지는 수월하게 진행이 되었는데 검색 조건에 맞는 쿼리를 생성할 때 문제가 생겼다. 저번 포스팅에서 정리했던 조회 방법은 아무런 조건이 없을 때, 페이지에 제일 처음 접근했을 때 모든 데이터 값들을 조회해오는 메소드였는데 이번에는 검색 조건에 따라 조건문이 달라져야 하므로 조건문을 이용해서 처리하려고 했다.

더보기
List<Animal> animals;
      if (animalSearchDTO == null) animals = findAllByOrderByRegiDate(); //검색 조건이 없을 때
      else //검색 조건이 있을 때

그런데 생각을 해보니까 경우의 수가 너무 많은 것이다. 아무런 검색 키워드가 없을 때, 하나만 있을 때, 두 개가 있을 때 이렇게 총 7가지 경우의 수가 있는데 모든 조건의 메소드를 만들 수가 없고 고민을 하다가 이럴 때 동적 쿼리를 사용하는거란걸 깨달았다. 동적 쿼리를 생성해주기 위해서 몇 가지 세팅을 해줬다.

더보기

1. 빌드 그래들 수정

//쿼리 DSL 추가 - 이 방법은 스프링부트 2.6 ~ 2.7 방법
buildscript {
    ext {
        queryDslVersion = "5.0.0"
    }
}

plugins {
    id 'java'
    id 'org.springframework.boot' version '2.7.15'
    id 'io.spring.dependency-management' version '1.0.15.RELEASE'
    id "com.ewerk.gradle.plugins.querydsl" version "1.0.10" //쿼리 DSL 추가
}

...

dependencies {
	//queryDSL
    implementation "com.querydsl:querydsl-jpa:${queryDslVersion}"
    annotationProcessor "com.querydsl:querydsl-apt:${queryDslVersion}"
}

...

// queryDSL 추가 : QueryDSL 빌드 옵션
def querydslDir = "$buildDir/generated/querydsl"

querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
}
sourceSets {
    main.java.srcDir querydslDir
}
configurations {
    querydsl.extendsFrom compileClasspath
}
compileQuerydsl {
    options.annotationProcessorPath = configurations.querydsl
}
// 여기까지

 

2. Q도메인 생성

gradle > Tasks > build > clean

gradle > Tasks > other > compileQuerydsl 을 해주면 내가 지정해준 장소에 querydsl이 생성된 것을 확인할 수 있다.

build > generated > querydsl > entity가 생성된 루트에 Q가 붙은 엔티티들이 생성되어 있으면 성공이다.

import com.querydsl.core.BooleanBuilder;
import static com.example.fureverhomes_project.entity.QAnimal.animal;
import com.querydsl.jpa.impl.JPAQueryFactory;


//검색조건으로 검색하기
    private List<Animal> findAllBySearchKeword(final AnimalSearchDTO animalSearchDTO) {

	//동적 쿼리를 생성하기 위한 JPAQueryFactory 생성
        JPAQueryFactory queryFactory = new JPAQueryFactory(em);
        BooleanBuilder builder = new BooleanBuilder();

        if (animalSearchDTO.getSpecies() != null) {
            builder.and(animal.species.stringValue().eq(animalSearchDTO.getSpecies().toString()));
        }
        if (animalSearchDTO.getSex() != null) {
            builder.and(animal.sex.stringValue().eq(animalSearchDTO.getSex().toString()));
        }
        if (animalSearchDTO.getRegion() != null) {
            builder.and(animal.region.stringValue().eq(animalSearchDTO.getRegion().toString()));
        }


        return queryFactory
                .selectFrom(animal)
                .where(builder)
                .orderBy(animal.regiDate.desc())
                .fetch();
    }

검색 키워드의 유무에 따라 조건을 다르게 주기 위해 BooleanBuilder를 생성해서 DTO에 값이 존재할 경우 일치하는 값이 존재하는지 존재하지 않는지를 찾아주도록 설정했다. 동적 쿼리에 사용하고자 하는 엔티티를 위해Q[엔티티이름]을 경로에 맞게 import 해주면 간편하게 사용할 수 있다. 그리고 EntityManager도 서비스 클래스에 주입해줘야 한다.

return 값을 해석해보면 "SELECT * FROM ANIMAL WHERE [조건문] ORDER BY REGIDATE DESC"이며 queryDSL을 사용하면 실제 쿼리문과 유사한 문법으로 손쉽게 사용할 수 있다. 이렇게 받아온 값은 저번 포스팅에서 정리했던 converToResDTO메소드를 이용해 DTO로 변환 후 반환해줬다.

 

 

 

 

AnimalController - 검색

 

 

@GetMapping("/animal.list")
public ResponseEntity<AnimalResDTO> getAnimalList(
        @RequestParam Map<String, String> requestParams
) {
    AnimalResDTO animals = animalService.searchAnimal(requestParams);
    if(animals == null) return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
    return ResponseEntity.ok(animals);
}

ResponseEntity에 DTO를 담아 리턴해주면 준다.

 

 

 

 

 

search.js

 

 

$(document).ready(function (){
    searchGet(1,null,null,null);

    $("#search-form").submit(function (e){
        e.preventDefault();

        let species = $("#species").val();
        let sex = $("#sex").val();
        let region = $("#region").val();

        searchGet(1, species, sex, region);
    });
});

function searchGet(page, species, sex, region){
    let apiUrl = "/fureverhomes/animal.list"
    let params = [];

    if (species) {
        params.push("species=" + species);
    }

    if (sex) {
        params.push("sex=" + sex);
    }

    if (region) {
        params.push("region=" + region);
    }

    if (params.length > 0) {
        apiUrl += "?" + params.join("&");
    }

//ajax 호출
}

"search-form"을 제출하면 각각 선택한 키워드의 값이 변수에 저장이 되고 아무런 키워드도 선택하지 않을 때는 null이 넘어간다. 실제 페이징까지 추가가 되면 url패턴이 /fureverhomes/animal.list?page={}&size={} 로 고정이 되므로 각각의 키워드 값이 존재하면 url에 "&species="를 추가해주면 되지만 현재는 페이지 정보를 url에 추가하지 않았으므로 param에 값들을 담고 하나 이상일 때 ?와 값들을 추가하도록 만들어줬다.

 

 

 

 

2. 페이징

드디어 페이징을 정리할 차롄데 정말 너어무 어려웠다. 먼저 Pageable에 대해서 알아보자면 pagination 정보가 들어있는 제일  인터페이스이다. Pageable의 메소드 중에서 눈여겨볼 메소드가 있다면 getPageNumber(), getPageSize(), getOffSet()인데 PageNumber는 클라이언트가 요청한 페이지 번호를 그대로 반환한다. 클라이언트가 1페이지를 요청하면 pageNumber는 1이 되는 것이다. offSet은 페이지 시작 지점을 뜻하는 것이다. 하지만 pageNumber과 offSet이 같다고 생각하면 안되는게 오프셋은 0부터 시작하기 때문에 1페이지를 요청하면 offSet은 0이 되는 것이다.

예를 들어 한 페이지에 6개의 데이터를 보여주려고 한다면 1페이지의 오프셋은 0, 2페이지의 오프셋은 6이 되는 것이다. Pageable을 사용하기 위해서는페이지 번호, 페이지 크기, 정렬 정보가 필요한데 이는 Pageable을 구현한 클래스 중 하나인 PageRequest로 생성한다. 

 

 

 

 

AnimalPageDTO

 

 

동물 엔티티의 정보들과 페이지 갯수, 데이터 갯수 정보를 함께 반환하기 위해 DTO를 하나 생성해준다.

@Getter
public class AnimalPageDTO {
    private List<AnimalResDTO> animals;
    private int totalPage;
    private Long totalCount;

    public AnimalPageDTO(List<AnimalResDTO> animals, int totalPage, Long totalCount) {
        this.animals = animals;
        this.totalPage = totalPage;
        this.totalCount = totalCount;
    }
}

 

 

 

 

AnimalSearch - 검색 & 페이징

 

 

위에서 짰던 메소드에 페이징 기능을 추가해서 수정해보도록 하겠다.

//검색 & 페이징
public AnimalPageDTO searchAnimal(final Map<String, String> requestparams, Pageable pageable) {
    AnimalSearchDTO animalSearchDTO = new AnimalSearchDTO();
    String species = requestparams.get("species");
    String sex = requestparams.get("sex");
    String region = requestparams.get("region");

    if (species != null) {
        animalSearchDTO.setSpecies(Species.valueOf(species));
    }
    if (sex != null) {
        animalSearchDTO.setSex(Sex.valueOf(sex));
    }
    if (region != null) {
        animalSearchDTO.setRegion(Region.valueOf(region));
    }

    Page<Animal> animals = findAllBySearchKeword(animalSearchDTO, pageable);

    List<AnimalResDTO> animalResDTO = animals.getContent().stream() //리스트 animals를 스트림으로 변환해서
            .map(this::converToResDTO) //각 요소에 대해 converToResDTO 메소드를 적용해 (엔티티 -> DTO)
            .collect(Collectors.toList()); //변환된 DTO를 다시 리스트로 수집

    return new AnimalPageDTO(animalResDTO, animals.getTotalPages(), animals.getTotalElements());
}
    
//resDTO 변환
private AnimalResDTO converToResDTO(final Animal animal) {
    AnimalResDTO animalDTO =
            new AnimalResDTO(animal.getId(), animal.getName(), animal.getRegion(), animal.getSex(), animal.getAge(), animal.getPicture(),
                    animal.getNeuter(), animal.getHealth_condition(), animal.getShelter_name(), animal.getShelter_tel(), animal.getRegiDate(), animal.getPersonality());
    return animalDTO;
}

//검색조건으로 검색하기 & 페이징
private Page<Animal> findAllBySearchKeword(final AnimalSearchDTO animalSearchDTO, Pageable pageable) {

    JPAQueryFactory queryFactory = new JPAQueryFactory(em);
    BooleanBuilder builder = new BooleanBuilder();

    if (animalSearchDTO.getSpecies() != null) {
        builder.and(animal.species.stringValue().eq(animalSearchDTO.getSpecies().toString()));
    }
    if (animalSearchDTO.getSex() != null) {
        builder.and(animal.sex.stringValue().eq(animalSearchDTO.getSex().toString()));
    }
    if (animalSearchDTO.getRegion() != null) {
        builder.and(animal.region.stringValue().eq(animalSearchDTO.getRegion().toString()));
    }


    List<Animal> animals = queryFactory
            .selectFrom(animal)
            .where(builder)
            .orderBy(animal.regiDate.desc())
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();
    
    long totalCount = queryFactory.select(animal.count()).from(animal).where(builder).fetchFirst();

    return new PageImpl<>(animals, pageable, totalCount);
}
  • searchAnimal()

요청에 대해 Page정보와 응답 정보를 담고있는 DTO를 함께 가지고 있는 AnimalPageDTO를 반환타입으로 만들었다. URL에 page와 size 정보를 담아 요청을 받으면 SpringMVC가 PageRequest 객체를 생성해서 컨트롤러 메소드에 전달한다. 서버에서는 이를 그대로 가져와 사용하면 된다. 따라서 매개변수로 리퀘스트 파람 값들과 Pageable 객체를 받아온다. 페이지에 맞는 데이터들을 받아와서 AnimalResDTO로 변환한 후 PageDTO에 값들을 넣어서 반환해줬다.

  • findAllBySearchKeword()

Pageable을 사용하기 위해선 반환 타입을 전처럼 List가 아니라 Page<T>로 정해줘야 한다. 동적쿼리에 offset()과 limit() 조건을 추가해준다. offset은 위에서 말했던 것처럼 페이지를 시작할 시작 지점인데 1페이지를 요청하면 offset은 0부터 시작한다. limit은 한 페이지에 몇개를 보여줄건지를 나타낸다. pageable의 getOffset과 getPageSize를 이용해서 동적 쿼리를 완성해줬다. 리턴 타입에는 PageImpl(@NotNull java.util.List<T> content, @NotNull org.springframework.data.domain.Pageable pageable, long total) 객체를 생성해준다. (설정해준 동적 쿼리에 따른 결과들, Pageable 객체, 전체 조회 결과 갯수)

 

 

 

 

 

AnimalController

 

 

//동물 목록 조회
@GetMapping("/animal.list")
public ResponseEntity<AnimalPageDTO> getAnimalList(
        @RequestParam Map<String, String> requestParams, Pageable pageRequest
        ) {
    Sort sort = Sort.by(Sort.Order.asc("regiDate")); // 필요한 정렬을 여기에 추가 - 컬럼명이 아니라 필드명
    Pageable pageable = PageRequest.of(pageRequest.getPageNumber() - 1, pageRequest.getPageSize(), sort);
    AnimalPageDTO animals = animalService.searchAnimal(requestParams, pageable);
    if(animals == null) return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
    return ResponseEntity.ok(animals);
}

Pageable 매개변수 앞에 @PageableDefault를 이용해서 page와 size의 디폴트 값을 설정해줄 수도 있지만 어차피 클라이언트에서 사이즈 정보를 넘길거기 때문에 굳이 설정을 하지는 않았다. 페이징을 해올 때 정렬정보를 담아주기 위해 Sort 객체를 만들어서 Pageable 객체에 추가해줬다. PageRequest.of(페이지, 사이즈, 정렬) 정보를 담아서 생성해줄 수 있다.

정렬을 할 때는 동적쿼리에서 정렬 기준으로 정해줬다면 똑같은 조건으로 설정해줘야 한다. 서로 다르게 지정해두면 원하는 정렬 결과를 얻지 못할 수도 있다. 

그리고 현재 pageRequest.getPageNumber는 1이 반환되는데 이를 그대로 넘겨주게 되면 getOffset을 해올 때 두 번째 페이지라고 인식하기 때문에 6번째 ~ 11번째 데이터를 가져오기 때문에 - 1 을 해주었다.

 

 

 

 

search.js

 

 

$(document).ready(function (){
    searchGet(1,null,null,null);

    $("#search-form").submit(function (e){
        e.preventDefault();

        let species = $("#species").val();
        let sex = $("#sex").val();
        let region = $("#region").val();

        searchGet(1, species, sex, region);
    });
});

function searchGet(page, species, sex, region){
    let apiUrl = "/fureverhomes/animal.list?page={page}&size={size}"
    apiUrl = apiUrl.replace("{page}",page);
    apiUrl = apiUrl.replace("{size}",6);
    
    if(species) {
        apiUrl += "&species=" + species;
    }
    if(sex) {
        apiUrl += "&sex=" + sex;
    }
    if(region) {
        apiUrl += "&region=" + region;
    }

    $.ajax({
        url: apiUrl,
        type: "GET",
        async: true,
        success: function (data, status, xhr) {
            let all_list = data.animals;
            let total = data.totalCount;

            let dataBody = $("#animal-card");
            dataBody.empty();

            //동적 쿼리 생성 ...
            
            //페이징 함수
            pagination(page,total);
        },error: function (xhr) {
            alert("페이지를 불러오는데 실패했습니다.")
        }
    });
}

function pagination(currentPage, total) { //(페이지,데이터갯수)
    let totalPages = Math.ceil(total / 6); //총 페이지 수 (ex. 한 페이지에 6개이고 데이터가 60개면 10페이지)
    let pageGroupSize = 5; //페이지 묶음 (5페이지씩 보여주겠다)
    let startPage = Math.ceil(currentPage / pageGroupSize) * pageGroupSize - pageGroupSize + 1; //페이지 묶음이 시작되는 수
    let endPage = Math.min(startPage + pageGroupSize - 1, totalPages); //페이지 묶음이 끝나는 수
    //만약 10페이지 이렇게 딱 맞게 떨어지는게 아니라 6페이지가 마지막일 수도 있기에 min 실시

    if(total == 0) {
        alert("검색 결과가 없습니다.")
        location.reload();
    }
    let pagination = $(".pagination");
    pagination.empty();

    //이전 페이지 링크 추가
    //1페이지라면 이전으로 갈 페이지가 없으므로 disabled 처리
    let prevDisabled = startPage === 1 ? "disabled" : "";
    if(total > 0) {
        pagination.append(`<li class="page-item ${prevDisabled}">
                      <a class="page-link" href="#" aria-label="Previous" data-page="${startPage - 1}">Previous</a>
                    </li>`);
    }

    //페이지 번호 링크 추가
    for (let i = startPage; i <= endPage; i++) {
        let activeClass = i === currentPage ? "active" : "";
        let listItem = `<li class="page-item ${activeClass}">
                <a class="page-link" href="#" data-page="${i}">${i}</a>
              </li>`;
        pagination.append(listItem);
    }

    //다음 페이지 링크 추가
    //마지막 페이지랑 전체 페이지 갯수가 같다면 다음 페이지가 없으므로 disabled 처리
    let nextDisabled = endPage === totalPages ? "disabled" : "";
    if(total > 0) {
        pagination.append(`<li class="page-item ${nextDisabled}">
                      <a class="page-link" href="#" aria-label="Next" data-page="${endPage + 1}">Next</a>
                    </li>`);
    }
}

//페이지 링크에 대한 클릭 이벤트 리스너 추가
$(document).on("click", ".pagination .page-link", function (event) {
    event.preventDefault();
    let pageNumber = parseInt($(this).data("page")); //data-page의 값
    if (!isNaN(pageNumber)) {

        let species = $("#species").val();
        let sex = $("#sex").val();
        let region = $("#region").val();

        searchGet(pageNumber, species, sex, region);
    }
});

URL에 페이지와 사이즈 정보를 함께 담아서 호출해주도록 수정하였고, 페이징 함수를 추가하였다. totalPages, pageGroupSize, startPage, endPage의 개념을 헷갈리지 않는게 제일 중요하다. 코드에도 적어놨지만 totalPage는 총 나와야 하는 페이지의 갯수로 데이터 갯수가 60개이고 한 페이지에 10개씩 보여준다면 총 페이지 갯수는 6페이지이다. 그리고 Math.ceil를 이용해서 올림을 하는 이유는 만약 데이터 갯수가 61개고 한 페이지에 10개씩 보여준다면 10페이지와 한 개의 데이터가 남는다. 한 개의 데이터를 보여주기 위해서 한 페이지를 더 써야하므로 무조건 올림을 해줘야 한다.

startPage는 제일 처음 보이는 페이지 숫자로 5개의 페이지를 한 묶음으로 설정해뒀으므로 1, 6, 11 등이 될 것이다.

endPage는 마지막 페이지 숫자인데 5, 10, 15 등이 되며, 정확하게 떨어지는게 아니라 총 페이지가 11 페이지라면 마지막 페이지는 1페이지가 되는 것이다.

여기서 엄청나게 시간을 많이 소요했던 부분이 클릭 이벤트가 전혀 먹히지 않았던 이슈이다. 찾아보니까 동적으로 페이지를 생성하고 그 안에서 이벤트를 발생시켜서 작동이 되지 않았던 것이다. 보통 $("#id").click(function () { ... })으로 사용하는데 동적페이지로 변경되는 순간 동일한 id를 대상으로 이벤트가 적용되지 않는다고 한다. $(document).ready를 사용해서 그렇다고 하는데 이렇게 변경해주니까 이벤트가 잘 작동됐다.

$(document).on("click", ".pagination .page-link", function (event) { ... }

 

 

 

 

정리

 

 

아 페이징을 하는 부분이 제일로 어려웠던 것 같다. 진짜 시간도 많이 쓰고 많이 지치기도 했는데 그래도 결국 결과를 내서 너무 뿌듯했다. 좀 더 좋은 방법, 클린 코드를 짤 수 있는 방법이 있는 것 같은데 남은 기능들도 개발하면서 정리를 할 수 있는 부분이 있으면 정리를 해야할 것 같다. 이 다음에는 이제 연관관계 매핑이 이루어진 기능을 구현해야 하는데 산 넘어 산인 듯하다. 그래도 페이징을 해낸 것처럼 해낼 수 있을 것이라고 믿는다!!

728x90