본문 바로가기
개발/게시판 만들기

[Vue] Vue.js 게시판 만들기 10 - 게시글 검색 (with QueryDSL)

by onethejay 2022. 3. 7.
728x90

Backend 게시글 검색 구현

게시글을 가져올 때, 검색 정보가 있으면 해당 정보로 검색된 리스트를 가져와야 하고 없으면 기본적인 리스트를 가져와야 합니다.

즉, 검색 정보가 Null인지 아닌지, Null이 아니라면 어떤 키의 데이터를 가져올지 만들어주어야 합니다.
쿼리를 직접 만들수도 있고, JPA + QueryDSL을 통해 자바 클래스와 메서드를 조작하는 방식으로 작업할 수 있습니다.

이번 포스팅은 JPA + QueryDSL을 통해 데이터를 가져오는 방법으로 진행해보겠습니다.

먼저 build.gradle에 QueryDSL과 관련된 부분을 추가합니다.

plugins {
    id 'org.springframework.boot' version '2.6.3'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation group: 'com.h2database', name: 'h2', version: '1.3.176'

    //* * * querydsl * * *
    implementation 'com.querydsl:querydsl-core' // querydsl
    implementation 'com.querydsl:querydsl-jpa' // querydsl
    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa" // querydsl JPAAnnotationProcessor 사용 지정
    annotationProcessor("jakarta.persistence:jakarta.persistence-api")
    annotationProcessor("jakarta.annotation:jakarta.annotation-api")
    //* * * querydsl * * *

    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

//* * * querydsl * * *
def generated='src/main/generated'
sourceSets {
    main.java.srcDirs += [ generated ]
}

tasks.withType(JavaCompile) {
    options.annotationProcessorGeneratedSourcesDirectory = file(generated)
}

clean.doLast {
    file(generated).deleteDir()
}
//* * * querydsl * * *

QueryDSL은 QClass라는 파일을 만들어 작업이 가능하게 도와줍니다.
그러나 해당 파일이 깃에 올라갈 필요는 없으므로 .gitignore 파일로 관리한다면 generated 를 추가해 주어야 합니다.

gradle build를 통해 이상 없이 빌드가 완료되는지 확인합니다.

gradle build 혹은 gradle compileJava 명령어가 실행이 완료되면 QClass가 generated 폴더에 생성됩니다.

빌드가 완료되었다면 Config 클래스 파일을 생성하도록 하겠습니다

config 패키지 아래에 QuerydslConfig.java 파일을 생성합니다. 해당 설정을 통해 쉽게 Repository 클래스에서 작업이 가능합니다.

/* config/QuerydslConfig.java */
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;

@Configuration
public class QuerydslConfig {
    @PersistenceContext
    private EntityManager entityManager;

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

화면에서 보낸 검색 정보를 받을 SearchCondition.java 파일을 model 패키지 아래에 생성합니다.

/* model/SearchCondition.java */

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class SearchCondition {
    private String sk;  //search key
    private String sv;  //search value
}

QueryDSL로 작업할 BoardRepositoryCustom 파일을 entity 패키지 아래에 생성합니다.
기존의 Repository 파일 같이 인터페이스가 아닌 클래스 파일로 생성해야하며 JPAQueryFactory를 통해 구현합니다.

/* entity/BoardRepositoryCustom.java */

import com.example.vuebackboard.model.SearchCondition;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;

import java.util.List;

import static com.example.vuebackboard.entity.QBoardEntity.boardEntity;

@RequiredArgsConstructor
@Repository
public class BoardRepositoryCustom {
    private final JPAQueryFactory queryFactory;

    public Page<BoardEntity> findAllBySearchCondition(Pageable pageable, SearchCondition searchCondition) {
        JPAQuery<BoardEntity> query = queryFactory.selectFrom(boardEntity)
                .where(searchKeywords(searchCondition.getSk(), searchCondition.getSv()));

        long total = query.stream().count();   //여기서 전체 카운트 후 아래에서 조건작업

        List<BoardEntity> results = query
                .where(searchKeywords(searchCondition.getSk(), searchCondition.getSv()))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .orderBy(boardEntity.idx.desc())
                .fetch();

        return new PageImpl<>(results, pageable, total);
    }

    private BooleanExpression searchKeywords(String sk, String sv) {
        if("author".equals(sk)) {
            if(StringUtils.hasLength(sv)) {
                return boardEntity.author.contains(sv);
            }
        } else if ("title".equals(sk)) {
            if(StringUtils.hasLength(sv)) {
                return boardEntity.title.contains(sv);
            }
        } else if ("contents".equals(sk)) {
            if(StringUtils.hasLength(sv)) {
                return boardEntity.contents.contains(sv);
            }
        }

        return null;
    }
}

BoardController를 호출할때 검색정보를 받을 수 있도록 SearchCondition 클래스를 추가하고 getBoardList를 호출할때 사용하도록 변경합니다.

/* web/boardController.java */

@Slf4j
@RequiredArgsConstructor
@CrossOrigin
@RestController
public class BoardController {
    private final BoardService boardService;

    @GetMapping("/board/list")
    public Header<List<BoardDto>> boardList(
            @PageableDefault(sort = {"idx"}) Pageable pageable,
            SearchCondition searchCondition
    ) {
        return boardService.getBoardList(pageable, searchCondition);
    }

}

BoardService에서는 기존에 boardRepository를 통해 데이터 가져오는 부분을 BoardRepositoryCustom을 사용하도록 변경합니다

/* services/BoardService.java */

@Slf4j
@RequiredArgsConstructor
@Service
public class BoardService {

    private final BoardRepository boardRepository;
    private final BoardRepositoryCustom boardRepositoryCustom;

    /**
     * 게시글 목록 가져오기
     * @return
     */
    public Header<List<BoardDto>> getBoardList(Pageable pageable, SearchCondition searchCondition) {
        List<BoardDto> dtos = new ArrayList<>();

        Page<BoardEntity> boardEntities = boardRepositoryCustom.findAllBySearchCondition(pageable, searchCondition);
        for (BoardEntity entity : boardEntities) {
            BoardDto dto = BoardDto.builder()
                    .idx(entity.getIdx())
                    .author(entity.getAuthor())
                    .title(entity.getTitle())
                    .contents(entity.getContents())
                    .createdAt(entity.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm:ss")))
                    .build();

            dtos.add(dto);
        }

        Pagination pagination = new Pagination(
                (int) boardEntities.getTotalElements()
                , pageable.getPageNumber() + 1
                , pageable.getPageSize()
                , 10
        );

        return Header.OK(dtos, pagination);
    }
}

현재까지 백엔드 프로젝트 파일목록은 아래와 같습니다.

Frontend 게시글 검색 구현

BoardList.vue 페이징 태그 아래에 검색 조건과 검색 버튼을 추가합니다.

/* views/board/BoardList.vue */

<div class="pagination w3-bar w3-padding-16 w3-small" v-if="paging.total_list_cnt > 0">
<span class="pg">
      <a href="javascript:;" @click="fnPage(1)" class="first w3-button w3-bar-item w3-border">&lt;&lt;</a>
      <a href="javascript:;" v-if="paging.start_page > 10" @click="fnPage(`${paging.start_page-1}`)"
         class="prev w3-button w3-bar-item w3-border">&lt;</a>
      <template v-for=" (n,index) in paginavigation()">
          <template v-if="paging.page==n">
              <strong class="w3-button w3-bar-item w3-border w3-green" :key="index">{{ n }}</strong>
          </template>
          <template v-else>
              <a class="w3-button w3-bar-item w3-border" href="javascript:;" @click="fnPage(`${n}`)" :key="index">{{ n }}</a>
          </template>
      </template>
      <a href="javascript:;" v-if="paging.total_page_cnt > paging.end_page"
         @click="fnPage(`${paging.end_page+1}`)" class="next w3-button w3-bar-item w3-border">&gt;</a>
      <a href="javascript:;" @click="fnPage(`${paging.total_page_cnt}`)" class="last w3-button w3-bar-item w3-border">&gt;&gt;</a>
      </span>
</div>

<div>
  <select v-model="search_key">
    <option value="">- 선택 -</option>
    <option value="author">작성자</option>
    <option value="title">제목</option>
    <option value="contents">내용</option>
  </select>
  &nbsp;
  <input type="text" v-model="search_value" @keyup.enter="fnPage()">
  &nbsp;
  <button @click="fnPage()">검색</button>
</div>

script의 data에 search_key와 search_value를, this.requestBody 안에 sk, sv를 추가하여 search_key와 search_value를 연결합니다.

<script>
export default {
  data() { //변수생성
    return {
      requestBody: {}, //리스트 페이지 데이터전송
      list: {}, //리스트 데이터
      no: '', //게시판 숫자처리
      paging: {
        block: 0,
        end_page: 0,
        next_block: 0,
        page: 0,
        page_size: 0,
        prev_block: 0,
        start_index: 0,
        start_page: 0,
        total_block_cnt: 0,
        total_list_cnt: 0,
        total_page_cnt: 0,
      }, //페이징 데이터
      page: this.$route.query.page ? this.$route.query.page : 1,
      size: this.$route.query.size ? this.$route.query.size : 10,
      search_key: this.$route.query.sk ? this.$route.query.sk : '',
      search_value: this.$route.query.sv ? this.$route.query.sv : '',
      paginavigation: function () { //페이징 처리 for문 커스텀
        let pageNumber = [] //;
        let start_page = this.paging.start_page;
        let end_page = this.paging.end_page;
        for (let i = start_page; i <= end_page; i++) pageNumber.push(i);
        return pageNumber;
      }
    }
  },
  mounted() {
    this.fnGetList()
  },
  methods: {
    fnGetList() {
      this.requestBody = { // 데이터 전송        
        sk: this.search_key,
        sv: this.search_value,
        page: this.page,
        size: this.size
      }

      this.$axios.get(this.$serverUrl + "/board/list", {
        params: this.requestBody,
        headers: {}
      }).then((res) => {      

        if (res.data.result_code === "OK") {
          this.list = res.data.data
          this.paging = res.data.pagination
          this.no = this.paging.total_list_cnt - ((this.paging.page - 1) * this.paging.page_size)
        }

      }).catch((err) => {
        if (err.message.indexOf('Network Error') > -1) {
          alert('네트워크가 원활하지 않습니다.\n잠시 후 다시 시도해주세요.')
        }
      })
    },
    fnView(idx) {
      this.requestBody.idx = idx
      this.$router.push({
        path: './detail',
        query: this.requestBody
      })
    },
    fnWrite() {
      this.$router.push({
        path: './write'
      })
    },
    fnPage(n) {
      if (this.page !== n) {
        this.page = n       
      }

      this.fnGetList()      
    }
  }
}
</script>

select에서 검색할 키와 검색어를 입력하고 엔터를 누르거나 검색을 클릭해서 데이터가 잘 검색되어 나오는지 확인합니다.

마무리

Vue.js 와 자바 Spring 으로 게시판을 구현해보았습니다.
소스는 깃허브 vue-frontboard, vue-backboard 에서 확인하실 수 있습니다.

감사합니다.

728x90

댓글