개인 프로젝트를 만들면서 게시판도 같이 만들어 보는데 내장 JSP나 thymeleaf를 사용하지 않고 프론트엔드에 보내는 방법을 찾다가 성공해서 정리해본다.
JPA를 사용해서 게시물 + 댓글을 가져오려고 했지만 N+1성능 문제와 복잡한 쿼리의 경우에는 그냥 JPA보다는 따로 QueryDsl을 이용하여 만드는게 효과적이다.
Gradle에 설정을 우선 적어줘야 한다. 아래와 같이 코드를 적고 Tasks -> other -> complieQueryDsl을 클릭하면 Entity를 적용한 클래스들이 자동으로 Q클래스들이 생성 될 것이다.
// queryDsl버전
buildscript {
ext {
queryDslVersion = "5.0.0"
}
}
plugins {
//querydsl 추가
id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}
dependencies {
// Querydsl 의존성
implementation "com.querydsl:querydsl-jpa:${queryDslVersion}"
implementation "com.querydsl:querydsl-apt:${queryDslVersion}"
}
//querydsl 사용 경로
def querydslDir = "$buildDir/generated/querydsl"
//querydsl 사용 설정
querydsl {
jpa = true
querydslSourcesDir = querydslDir
}
// build 시 사용할 sourceSet
sourceSets {
main.java.srcDir querydslDir
}
// compileClasspath와 annotationProcessor 상속
configurations {
compileOnly {
extendsFrom annotationProcessor
}
querydsl.extendsFrom compileClasspath
}
//feature/improve-all
// querydsl 컴파일 시 사용할 옵션.
compileQuerydsl {
options.annotationProcessorPath = configurations.querydsl
}
//feature/improve-all
// QType 정리
clean {
delete file(querydslDir)
}
QuesryDsl을 사용하는 방법은 3가지 정도가 있다. 근데 난 따로 class를 만들고 QuerydslRepositorySupport을 상속받고 Custom Class를 만들어서 JPA에게 상속하는 방법을 사용한다.
- DI로 의존성을 주입 할 것이고. 클래스 이름은 Impl을 뒤에 붙여줘야 스프링이 찾아서 대입해준다.
아래와 같이 List와 bno을 주면 게시물의 상세페이지를 넘겨줄 것이다.
public interface SearchBoardRepository {
Page<Tuple> getList(Pageable pageable);
List<Tuple> getbno(long bno);
}
SearchBoardRepositoryImpl 이라는 구현체를 만들고 QueryDsl을 사용해 만든 QClass와 JPQLQuery을 사용한다.
- QuerydslRepositorySupport는 Spring Data JPA와 Querydsl을 함께 사용할 때 도움을 주는 클래스입니다. 이 클래스는 JpaRepository를 상속하고 있으며, Querydsl 관련 기능을 제공합니다.
- QuerydslRepositorySupport을 사용하려면 JPA처럼 Entity타입의 Class를 생성자로 주입 해야함.
public class SearchBoardRepositoryImpl extends QuerydslRepositorySupport implements SearchBoardRepository {
public SearchBoardRepositoryImpl() {
super(Board.class);
}
@Override
public Page<Tuple> getList(Pageable pageable) {
log.info("getList.................");
QBoard board = QBoard.board;
QReply reply = QReply.reply;
QUser user = QUser.user;
//먼저 where을 통해 조건식으로 board를 선별한다.
//2.왼쪽에 board테이블을 놓고 오른쪽에 user테이블을 배치한다.
//3.on을 실행하는데 board.writer 즉 board테이블의 witer칼럼(FK)과 member테이블의 PK값을 비교하고 값을 반환
//4.2개의 왼쪽테이블이 존재하고 reply테이블을 가지고 left join 실행
//5.reply에서 fk로 참조한 gno = board에 있는 pk와 비교한다.
//아래 코드는 보기 어렵지만 상세하게 적은것이다.
JPQLQuery<Board> jpqlQuery = from(board);
jpqlQuery.leftJoin(user).on(board.writer.eq(user));//user의 PK(uid)와 Board의 FK(Uid)를 비교
jpqlQuery.leftJoin(reply).on(reply.board.eq(board)); //reply의 FK(bno)와 Board의 PK를 비교
JPQLQuery<Tuple> tuple = jpqlQuery.select(board, user.nickname, reply.count());
tuple.groupBy(board);
log.info("-------------------------");
log.info(tuple);
log.info("-------------------------");
List<Tuple> result = tuple.fetch();
log.info(result);
return convert(result, pageable.getPageNumber(), pageable.getPageSize());
}
@Override
public List<Tuple> getbno(long bno) {
log.info("getbno.................");
QBoard board = QBoard.board;
QReply reply = QReply.reply;
//위에 코드보다 쉽게 가독성 있게 적은 코드다.
return from(board)
.select(board.title, board.content, reply)
.leftJoin(reply).on(reply.board.eq(board))
.where(board.bno.eq(bno))
.groupBy(board)
.fetch();
}
//이 코드는 List를 Page로 형변환 하기 위해 사용
private Page<Tuple> convert(List<Tuple> tupleList, int page, int pageSize) {
int start = page * pageSize;
int end = Math.min(start + pageSize, tupleList.size());
List<Tuple> content = tupleList.subList(start, end);
Pageable pageable = PageRequest.of(page, pageSize);
return new PageImpl<>(content, pageable, tupleList.size());
}
}
Get으로 요청받을때 size(한페이지에 몇개를 보내줄지) , page 현재 페이지는 몇인지 받는 DTO를 생성
@Builder
@AllArgsConstructor
@Data
public class PageRequestDTO {
private int page;
private int size;
public PageRequestDTO() {
this.page = 1;
this.size = 10;
}
public Pageable getPageable(Sort sort){
// page, pageLimit(한페이지에 보여줄 갯수), sort
return PageRequest.of(page -1, size, sort);
}
}
게시물 + 댓글의 정보를 받고 페이징 작업을 해서 객체를 프론트엔드에 전달해 준다.
프론트엔드에 결과를 전달 할 결과물은 맨 아래에 적어 놓겠다.
@Data
@ToString
public class PageResultDTO {
//DTO
private List<BoardDTO> dtoList;
private int totalPage;
//현재페이지 번호
private int page;
//목록 사이즈
private int size;
//시작번호, 끝번호
private int start, end;
//이전 다음
private boolean prev, next;
//페이지 번호 목록
private List<Integer> pageList;
public PageResultDTO(Page<Tuple> result, Function<Tuple, BoardDTO> fn) {
dtoList = result.stream().map(fn).collect(Collectors.toList());
//Stream은 배열이나 리스트 등의 데이터 소스를 바탕으로 생성할 수 있으며, 여러 개의 중간 연산과 한 개의 최종 연산으로 구성됩니다.
// 중간 연산은 스트림을 반환하여 연속된 처리를 가능하게 하며, 최종 연산은 스트림을 처리하여 결과를 반환합니다.
// Filter: 주어진 조건에 맞는 요소만 걸러내는 기능입니다.
// Map: 요소를 다른 요소로 변환하는 기능입니다.
// Reduce: 요소들을 결합하여 하나의 값을 도출하는 기능입니다.
// Collect: 요소들을 수집하여 컬렉션으로 반환하거나 결과값으로 반환하는 기능입니다.
// List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
// numbers.stream()
// .filter(n -> n % 2 == 0)
// .map(n -> n * n)
// .forEach(System.out::println);
this.totalPage = result.getTotalPages();
makePageList(result.getPageable());
}
private void makePageList(Pageable pageable){
System.out.println("pageable = " + pageable);
this.page = pageable.getPageNumber() + 1; // 0부터 시작하니 1 추가
this.size = pageable.getPageSize(); // 한 화면에 몇개의 게시물을 보여주는 사이즈
//현재 화면 폐이지의 페이지 번호 가져오기 ceil은 반올림 하기때문에 9 -> 10이고 11폐이지는 20이 나온다.
int tempEnd = (int)(Math.ceil(page / 10.0)) * 10;
//페이지는 10단위 이기때문에 맨앞은 1, 11, 21 단위로 만들어 줄 수 있다.
start = tempEnd -9;
//1~9까지는 이전으로 가는 링크가 안나오도록 한다. 1~9만 false
prev = start > 1;
//end는 실제 마지막 페이지와 다시 비교할 필요가 있다. 33 > 40, 끝번호, 둘중 작은 수 를 정해줌
end = totalPage > tempEnd ? tempEnd : totalPage;
//현재 폐이지 임시 폐이지번호가 토탈페이지보다 작으면 next번호를 만들어 준다.
next = totalPage > tempEnd;
pageList = IntStream.rangeClosed(start, end).boxed().collect(Collectors.toList());
//boxed() 메소드는 IntStream 같이 원시 타입에 대한 스트림 지원을 클래스 타입(예: IntStream -> Stream<Integer>)으로 전환하여 전용으로
//실행 가능한 (예를 들어 본문과 같이 int 자체로는 Collection에 못 담기 때문에 Integer 클래스로 변환하여 List<Integer> 로 담기 위해 등)
//기능을 수행하기 위해 존재합니다.
}
}
이제 만든 Custom클래스를 JPA에 적용하고 사용하면 된다.
- SearchBoardRepository를 상속 받아서 Service계층에서 바로 구현했던 메서드들을 호출해서 사용하면 완성
@Repository
public interface BoardRepository extends JpaRepository<Board, Long>, SearchBoardRepository {
@Query(value = "select b " +
" from Board b " +
" where b.writer = :nickname")
Board findByWriter(@Param("nickname") String nickname);
}
public PageResultDTO getList(PageRequestDTO pageRequestDTO){
log.info("getList . . .");
Function<Tuple, BoardDTO> fn = (tuple -> EntityToDto(tuple.get(0, Board.class), tuple.get(1, String.class), tuple.get(2, Long.class)));
Page<Tuple> result = boardRepository.getList(pageRequestDTO.getPageable(Sort.by("bno").descending()));
PageResultDTO pageResultDTO = new PageResultDTO(result, fn);
System.out.println("pageResultDTO = " + pageResultDTO);
return pageResultDTO;
}
@RestController로 생성 해주고 아래처럼 Get을 받아준다. Size나 Page는 PageRequestDTO에서 Setter가 있기 때문에 URL주소에 요청에 맞게 변수 값이 변경돼서 결과를 돌려준다.
@GetMapping("/board/list")//board/list?page=1 같은 값이들어오면 자동으로 매핑해준다.
public PageResultDTO ListForm( PageRequestDTO pageRequestDTO) {
return boardService.getList(pageRequestDTO);
}
http://localhost:8080/board/list?page=3 을 전달 page -1 을 했으니 결과가 잘 생성된다.
{
"dtoList": [
{
"bno": 21,
"title": "title...21",
"content": "content...21",
"writer": "강낭콩21",
"replyCount": 0,
"regDate": "2023-06-29T15:08:32.258216",
"modDate": "2023-06-29T15:08:32.258216"
},
{
"bno": 22,
"title": "title...22",
"content": "content...22",
"writer": "강낭콩22",
"replyCount": 0,
"regDate": "2023-06-29T15:08:32.259555",
"modDate": "2023-06-29T15:08:32.259555"
},
{
"bno": 23,
"title": "title...23",
"content": "content...23",
"writer": "강낭콩23",
"replyCount": 0,
"regDate": "2023-06-29T15:08:32.260876",
"modDate": "2023-06-29T15:08:32.260876"
},
{
"bno": 24,
"title": "title...24",
"content": "content...24",
"writer": "강낭콩24",
"replyCount": 0,
"regDate": "2023-06-29T15:08:32.262129",
"modDate": "2023-06-29T15:08:32.262129"
},
{
"bno": 25,
"title": "title...25",
"content": "content...25",
"writer": "강낭콩25",
"replyCount": 0,
"regDate": "2023-06-29T15:08:32.263391",
"modDate": "2023-06-29T15:08:32.263391"
},
{
"bno": 26,
"title": "title...26",
"content": "content...26",
"writer": "강낭콩26",
"replyCount": 0,
"regDate": "2023-06-29T15:08:32.264554",
"modDate": "2023-06-29T15:08:32.264554"
},
{
"bno": 27,
"title": "title...27",
"content": "content...27",
"writer": "강낭콩27",
"replyCount": 0,
"regDate": "2023-06-29T15:08:32.265731",
"modDate": "2023-06-29T15:08:32.265731"
},
{
"bno": 28,
"title": "title...28",
"content": "content...28",
"writer": "강낭콩28",
"replyCount": 0,
"regDate": "2023-06-29T15:08:32.266916",
"modDate": "2023-06-29T15:08:32.266916"
},
{
"bno": 29,
"title": "title...29",
"content": "content...29",
"writer": "강낭콩29",
"replyCount": 0,
"regDate": "2023-06-29T15:08:32.268113",
"modDate": "2023-06-29T15:08:32.268113"
},
{
"bno": 30,
"title": "title...30",
"content": "content...30",
"writer": "강낭콩30",
"replyCount": 0,
"regDate": "2023-06-29T15:08:32.269297",
"modDate": "2023-06-29T15:08:32.269297"
}
],
"totalPage": 10,
"page": 3,
"size": 10,
"start": 1,
"end": 10,
"prev": false,
"next": false,
"pageList": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10
]
}