spring boot + JPA + React 페이징 만들기

강낭콩·2023년 7월 4일

JPA로 게시물 + 댓글을 페이징 작업을 통해 React에 전송하기

개인 프로젝트를 만들면서 게시판도 같이 만들어 보는데 내장 JSP나 thymeleaf를 사용하지 않고 프론트엔드에 보내는 방법을 찾다가 성공해서 정리해본다.


JPA 및 QueryDsl

JPA를 사용해서 게시물 + 댓글을 가져오려고 했지만 N+1성능 문제와 복잡한 쿼리의 경우에는 그냥 JPA보다는 따로 QueryDsl을 이용하여 만드는게 효과적이다.


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

QueryDsl사용

QuesryDsl을 사용하는 방법은 3가지 정도가 있다. 근데 난 따로 class를 만들고 QuerydslRepositorySupport을 상속받고 Custom Class를 만들어서 JPA에게 상속하는 방법을 사용한다.

  • DI로 의존성을 주입 할 것이고. 클래스 이름은 Impl을 뒤에 붙여줘야 스프링이 찾아서 대입해준다.

Custom Interface 생성

아래와 같이 List와 bno을 주면 게시물의 상세페이지를 넘겨줄 것이다.

public interface SearchBoardRepository {
    Page<Tuple> getList(Pageable pageable);

    List<Tuple> getbno(long bno);

}

Impl Class 생성

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

}

PageRequest DTO 생성

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

PageResultDTO

게시물 + 댓글의 정보를 받고 페이징 작업을 해서 객체를 프론트엔드에 전달해 준다.
프론트엔드에 결과를 전달 할 결과물은 맨 아래에 적어 놓겠다.

@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> 로 담기 위해 등)
        //기능을 수행하기 위해 존재합니다.
    }
}

JPA적용

이제 만든 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);
}

Service계층 사용

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

Controller

@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
    ]
}

0개의 댓글