[React+Spring] CRUD API 생성 및 페이징 처리

HJ·2024년 1월 18일
0

React+Spring

목록 보기
2/11
post-thumbnail

아래 내용은 코드로 배우는 React with 스프링부트 API서버 강의와 함께 설명이 부족한 부분에 대해서 조사하여 추가한 내용입니다.

강의 코드를 그대로 따라 친 것이 아닌 제 나름대로 작성한 코드들이 있기 때문에 강의 코드와 동일하진 않습니다.


Maria DB 세팅


MySQL Client 프롬프트를 활용해서 database, user, 권한 부여 등의 작업을 할 수 있습니다. 원래 복사, 붙여넣기로 진행했는데 직접 입력해서 진행해보니 더 도움이 되는 것 같아 하나씩 입력해보는 것을 추천드립니다. 처음 접속할 때는 root 계정의 비밀번호를 입력하면 됩니다.

database 생성

create datebase [데이터베이스명];

데이터베이스를 생성 후, show databases 명령어를 이용하면 데이터베이스가 정상적으로 생성된 것을 확인할 수 있습니다.



계정 생성

create user '[userId]'@'[host]' identified by '[password]';

userId 는 사용할 유저의 ID 를 적어주고, host 부분에는 해당 계정을 사용할 호스트의 정보를 적어주면 됩니다. 만약 로컬에서 사용할 것이라면 localhost 로, 모든 호스트에서 접근할 수 있도록 하려면 % 를 입력하면 됩니다.

use mysql;
select host, user, password from user;

생성된 계정을 확인하기 위해서는 반드시 use mysql 명령어를 입력하여 database 이동 후에 해야 합니다. 그렇지 않으면 ERROR 1046 (3D000): No database selected 라고 에러가 발생하게 됩니다.

만약 명령어를 입력한 후 세미콜론을 추가하지 않아 -> 가 뜨는 경우, 그냥 세미콜론을 입력하면 명령어를 실행할 수 있습니다. 참고로 계정을 삭제할 때는 drop user 'userId'@'host' 를 입력하면 됩니다.



권한 부여

grant [privileges] on [데이터베이스].[table] to '[userId]'@'[host]';

privileges 에는 ALL, ALTER, CREATE 등 여러 권한이 있는데 위의 명령어에서는 모든 권한을 부여하였습니다. 또 testdb.* 을 입력했는데 이렇게 하면 testdb 의 모든 테이블에 대해 권한을 갖는다는 의미입니다. show grants for '[userId]'@'[host]' 명령어로 부여된 권한을 확인할 수 있습니다.




QueryDSL 설정


// Querydsl 추가
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 추가, 자동 생성된 Q클래스 gradle clean으로 제거
clean {
	delete file('src/main/generated')
}

QueryDSL 사용을 위해 위의 코드를 build.gradle 에 추가합니다.




페이징 처리 흐름


[ PageRequestDTO ]

@SuperBuilder
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PageRequestDTO {
    @Builder.Default
    private int page = 1;

    @Builder.Default
    private int size = 10;  // 한 페이지에 담기는 데이터 수

}

위의 DTO 는 페이지 처리와 관련된 쿼리 파라미터를 받아서 객체로 만들기 위해 사용되는 DTO 입니다. 이때 쿼리 파라미터로 page 와 size 가 들어오지 않을 때는 각각 1과 10의 값을 갖도록 설정하였습니다.



[ ToDoController ]

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/todo")
public class ToDoController {

    private final ToDoService toDoService;

    @GetMapping("/list")
    public PageResponseDTO<ToDoDTO> getList(PageRequestDTO pageRequestDTO) {
        log.info("pageRequestDTO = {}", pageRequestDTO);
        return toDoService.getPageList(pageRequestDTO);
    }
    ...
}

위의 예시는 쿼리 파라미터를 객체로 만들어 service 에 전달하는 코드입니다. /list를 호출하면 1번에서의 기본값에 의해 page = 1, size = 10 인 PageRequestDTO 가, 만약 /list?page=3&size=5 를 호출하면 page = 3, size = 5 인 객체가 만들어지게 됩니다.

이는 요청 파라미터로 받은 값으로 객체를 생성해주는 @ModelAttribute 가 해주는 기능입니다. 요청 파라미터를 받는 방법에는 @RequestParam 을 이용하는 방법도 있는데 위의 예시에서는 @ModelAttribute 를 생략하였고, 생략한 경우 스프링은 String, Integer 같은 단순 타입은 @RequestParam 으로, 객체의 경우 @ModelAttribute 로 인식합니다. ( 참고 게시글 )



[ ToDoService ]

@Service
@Transactional
@RequiredArgsConstructor
public class ToDoServiceImpl implements ToDoService{

    private final ToDoRepository toDoRepository;

    @Override
    public PageResponseDTO<ToDoDTO> getPageList(PageRequestDTO requestDTO) {
        // ToDo List 가 반환됨
        Page<ToDo> searchResult = toDoRepository.search(requestDTO);
        
        // 반환 받은 것은 ToDo List, 반환해야 하는 것은 ToDoDTO List
        List<ToDoDTO> dtoList = searchResult.get().map(this::entityToDTO).toList(); 

        return PageResponseDTO.<ToDoDTO>withAll()
                .dtoList(dtoList)
                .requestDTO(requestDTO)
                .total(searchResult.getTotalElements())
                .build();
    }
    ...
}

Service 코드는 위와 같은데 여기서 아래 두 가지를 하나씩 확인해보도록 하겠습니다.

  • ToDoRepository 의 search()

  • PageResponseDTO 의 withAll()



[ ToDoSearch ]

// ToDoService
Page<ToDo> searchResult = toDoRepository.search(requestDTO);

ToDoRepositorysearch() 메서드에 page 와 size 정보가 담긴 객체를 전달해주면 Page 객체가 반환됩니다. ToDoRepository 를 확인해보겠습니다.


public interface ToDoRepository extends JpaRepository<ToDo, Long>, ToDoSearch { }

ToDoRepository 는 위와 같은 형태의 인터페이스이고, ToDoSearch 를 상속 받고 있기 때문에 ToDoSearch 를 확인하도록 하겠습니다.


public class ToDoSearchImpl extends QuerydslRepositorySupport implements ToDoSearch {
    public ToDoSearchImpl() {
        super(ToDo.class);
    }

    @Override
    public Page<ToDo> search(PageRequestDTO requestDTO) {
        // 쿼리 실행을 위한 가상의 객체
        QToDo toDo = QToDo.toDo;

        // 쿼리 생성
        JPQLQuery<ToDo> query = from(toDo);

        // 페이징 처리 적용
        // PageRequest 는 0부터 시작하기 때문에 -1 이 필요
        Pageable pageable = PageRequest.of(requestDTO.getPage() - 1, requestDTO.getSize(), Sort.by("toDoNo").descending());
        this.getQuerydsl().applyPagination(pageable, query);

        // 쿼리 실행 결과 반환
        return new PageImpl<>(query.fetch(), pageable, query.fetchCount());
    }
}

QuerydslRepositorySupportfrom() 메서드에 Q 객체를 넘겨주어 JPQLQuery 를 반환받는데, JPQLQuery 는 JPQL 쿼리를 위한 쿼리 인터페이스입니다.

페이징 처리를 하기 위해서 몇 번 페이지가 필요한지, 한 페이지에 몇 개의 데이터가 있는지, 어떻게 정렬할 지에 대한 정보가 필요하고, 해당 기준에 맞게 조회한 후에 화면으로 넘겨주게 됩니다.

페이징 처리를 할 때 Page<T>Pageable 을 사용합니다. Page 는 페이지 정보를 담는 인터페이스이고, Pageable 은 페이지 처리에 필요한 정보를 담게 되는 인터페이스입니다.


protected PageRequest(int pageNumber, int pageSize, Sort sort) { ... }
  1. PageRequest 를 사용해 페이징 처리와 관련된 정보를 전달하여 Pageable 객체를 생성합니다. 이때 PageRequestpageNumber는 0 부터 시작하기 때문에 1 페이지를 원한다면 0 을 전달해야 합니다.

public PageImpl(List<T> content, Pageable pageable, long total) {

	super(content, pageable);

	this.total = pageable.toOptional().filter(it -> !content.isEmpty())//
				.filter(it -> it.getOffset() + it.getPageSize() > total)//
				.map(it -> it.getOffset() + content.size())//
				.orElse(total);
}
  1. query.fetch() 메서드로 인한 쿼리 실행 결과를 Page 객체를 생성할 때 넘겨줍니다. Page 객체는 쿼리 실행 결과를 가지고, Pageable 에 저장된 정보를 통해 위처럼 데이터를 가공하여 넘겨주게 됩니다. 이렇게 반환된 결과가 가장 위의 Page<ToDo> 가 되는 것입니다.



[ PageResponseDTO ]

// ToDoService
List<ToDoDTO> dtoList = searchResult.get().map(this::entityToDTO).toList(); 

return PageResponseDTO.<ToDoDTO>withAll()
              .dtoList(dtoList)
              .requestDTO(requestDTO)
              .total(searchResult.getTotalElements())
              .build();

Service의 마지막 부분입니다. 반환할 때는 PageResponseDTO 를 반환하지만, 그 내부에는 조회될 데이터가 담겨있는데 현재 쿼리의 실행 결과로 전달 받은 것은 Entity 이기 때문에 DTO 로 변환해줍니다. 그 후 PageResponseDTO 에 넘겨주면서 객체를 생성하게 되는데 아래에서 확인해보도록 하겠습니다.


@Data
public class PageResponseDTO<E> {
    /**
     * numPerPage : 한 페이지 당 게시글 수( PageRequestDTO 의 size )
     * pageSize : 한 번에 몇 개의 페이지를 보여 줄 것인지 ( 여기서는 10개로 지정 )
     */
    private int numPerPage, pageSize = 10;
    private PageRequestDTO requestDTO;

    private List<E> dtoList;    // 페이지에 보여지는 DTO 들이 담긴 List
    private List<Integer> pageNumberList;   // 페이지 번호들이 담긴 List

    private boolean prev, next; // 이전버튼, 다음버튼이 보이는가에 대한 여부 ( 마지막 페이지라면 다음 버튼이 X )
    private int totalCount, prevPage, nextPage, totalPage, currentPage;

    @Builder(builderMethodName = "withAll")
    public PageResponseDTO(PageRequestDTO requestDTO, List<E> dtoList, long total) {
        this.requestDTO = requestDTO;
        this.currentPage = requestDTO.getPage();
        this.numPerPage = requestDTO.getSize();
        this.dtoList = dtoList;
        this.totalCount = (int) total;
        this.totalPage = (int)(Math.ceil(totalCount / (double)requestDTO.getSize()));

        // 시작 페이지 계산
        int start = (((requestDTO.getPage() - 1) / pageSize) * pageSize) + 1;
        /**
         * 끝 페이지 계산
         * 하지만 이 계산 값이 무조건 마지막이라고 할 수 없음
         * 그래서 총 페이지 수와 비교해서 작은 값이 마지막 페이지가 된다
         */
        int end = Math.min(start + pageSize - 1, this.totalPage);

        // 이전버튼, 다음버튼
        this.prev = start > 1;
        this.next = end != this.totalPage;

        // 페이지 목록
        this.pageNumberList = IntStream.rangeClosed(start, end).boxed().toList();

        // 이전 페이지, 다음 페이지
        this.prevPage = prev ? start - 1 : 0; // 이전 페이지가 있으면 start - 1
        this.nextPage = next ? end + 1 : 0;
    }
}

먼저 아래 그림과 함께 변수에 대해 설명하도록 하겠습니다.

  • pageSize 는 몇 개의 페이지를 보여줄지를 정의합니다. 위의 그림에 따르면 1부터 5페이지까지 보여주고 있기 때문에 pageSize 는 5가 됩니다.

  • pageNumberList 는 페이지를 나타내는 번호들이 담긴 리스트입니다. 위의 그림으로 보면 pageNumberList 에는 1, 2, 3, 4 5 라는 숫자가 들어있는 리스트가 됩니다.

  • 페이지 간에는 화살표로 이동할 수 있습니다. 하지만 현재 페이지가 1페이지라면 이전을 의미하는 화살표가 필요없고, 현재 페이지가 마지막 페이지라면 다음을 의미하는 화살표가 필요없습니다. 이런 것들을 판단하기 위해 prev, next 변수를 사용합니다.


페이징 처리에서 중요한 것은 마지막 페이지입니다. (시작 페이지 + pageSize - 1) 을 한 값이 마지막 페이지 번호일텐데 만약에 8개의 페이지가 있고, pageSize가 10이라고 했을 때 앞에서 말한대로 계산하면 10이 나오지만 실제로는 8이 나와야 합니다.

(총 데이터 수 / 한 페이지에 보여줄 데이터 수) 를 하면 총 나올 수 있는 페이지가 되는데 이를 이용하여 마지막 페이지를 계산하고, 위에서 말한대로 마지막 페이지를 계산하여 더 작은 값을 마지막 페이지로 선택합니다.

그 후, 페이지 번호를 List 에 담고, 화살표를 표시할지 여부를 판단한 후 PageResponseDTO 를 반환하게 됩니다. 이것을 마지막으로 Service 의 코드가 종료되고, Controller 에서 PageResponseDTO 를 전달받아 화면으로 다시 전달하게 됩니다.




Exception


번호를 사용해서 조회할 때 없는 번호를 조회하거나, 페이지 처리를 할 때 page 에는 숫자가 들어가야 하는데 문자가 들어가면 예외가 발생하게 됩니다. 이를 처리해주는 Controller 를 생성하여 따로 처리하는 것이 필요한데 @RestControllerAdvice 로 처리할 수 있습니다.


@RestControllerAdvice
public class ExceptionController {

    // 없는 내용을 조회하는 경우
    @ExceptionHandler(NoSuchElementException.class)
    public ResponseEntity<?> notExist(NoSuchElementException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("msg", e.getMessage()));
    }

    // page 에 숫자가 아닌 다른 자료형이 들어온 경우
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<?> notMathParam(MethodArgumentNotValidException e) {
        return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE).body(Map.of("msg", e.getMessage()));
    }
}

@RestControllerAdvice전역적으로 @ExceptionHandler 를 적용할 수 있는 어노테이션입니다. @ExceptionHandlerException 클래스를 속성으로 받아 처리할 예외를 지정할 수 있습니다.

위의 예시에서 없는 내용을 조회한다면 NoSUchElementException 이 발생하게 되는데 이를 NOT_FOUND 로 변경하여 메세지와 함께 출력할 수 있도록 하였습니다.




CORS


CORS(Cross-Origin Resource Sharing) 이란 서로 다른 출처의 자원을 공유할 수 있도록 설정하는 권한 체제를 의미합니다. 여기서 출처란 URL 에서 Protocol, Host, Port 를 합친 것을 의미합니다.

브라우저는 보안적인 이유로 cross-origin HTTP 요청들을 제한하기 때문에 cross-origin 요청을 하려면 서버의 동의가 필요합니다. 서버가 동의한다면 브라우저에서는 요청을 허락하고, 동의하지 않는다면 브라우저에서 거절하게 됩니다.

이 강의에서는 리액트는 3000번 포트로 실행되고, 스프링은 8080 포트에서 실행되기 때문에 리액트에서 보내는 요청을 스프링이 받지 않게 되기 때문에 이를 허용해야 서로 간의 통신을 확인할 수 있습니다.


@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("*")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS");

    }
}
  1. WebMvcConfigurer 은 웹과 관련된 설정을 할 때 사용합니다. 설정한 내용이 반영되게 하기 위해서 @Configuration 을 사용합니다.

  2. addMapping 은 CORS 를 적용할 URL 패턴을 정의할 수 있습니다. 여기서는 모든 URL 에 적용하도록 하였습니다.

  3. allowedOrigins 는 어떤 출처를 허용할 것인지를 의미하는데 마찬가지로 모든 출처를 허용하도록 하였습니다. 리액트 포트만 허용한다면 localhost:3000 을 명시하면 됩니다.

  4. allowedMethods 는 어떤 방식의 호출을 허용할 것인지를 나타내는데 여기서 방식이란 HTTP 요청 메서드를 의미합니다.

0개의 댓글