관리 메뉴

거니의 velog

(9) 스프링 부트와 API 서버 4 본문

SpringBoot_React 풀스택 프로젝트

(9) 스프링 부트와 API 서버 4

Unlimited00 2024. 2. 28. 14:20

5. 목록 처리와 DTO

* 페이징 처리가 되는 목록 데이터는 크게
  1) 해당 페이지의 TodoDTO 리스트
  2) 페이지 번호, 전체 데이터의 수, 이전/다음 페이지 처리에 필요한 부가적인 데이터
로 구성될 수 있다.
* 부가적인 데이터를 리액트와 같은 프론트엔드 쪽에서 처리할 수도 있지만 서버에서 데이터의 가공이 많을수록 리액트에서 작업이 편해진다.
* TodoRepository에서 목록 데이터는 Spring Data JPA 관련된 API들을 이용하기 때문에 Pageable 타입의 파라미터를 사용하고 리턴 타입 역시 Page<Todo>의 형태로 결과를 생성해 낸다. 엔티티와 그에 관련된 처리는 가능하면 최소한의 영역에서만 처리하고 나머지는 DTO를 이용하는 것이 안전하기 때문에 TodoRepository의 Page<Todo>의 Todo와 Page를 다른 DTO 타입으로 만들어서 사용하도록 한다.

* API 서버의 경우 화면이 없고 순수한 데이터만을 전달하기 때문에 서버에서도 데이터의 구조를 만들어 두면
  편하다. 특히 목록 데이터와 같이 반복적으로 자주 사용되는 기능의 경우 매번 데이터를 구성하는 방식보다는
  정해진 DTO로 규정해 두어서 공통적인 표준(Application Architecture)처럼 사용하는 것이 편리하다.

* dto 패키지에 페이지 번호나 사이즈 등을 처리하기 위한 PageRequestDTO 클래스와 목록 처리에 필요한 모든 데이터를 반환하는 PageResponseDTO 클래스를 선언한다.

package com.unlimited.mallapi.dto;

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

@Data
@SuperBuilder
@AllArgsConstructor
@NoArgsConstructor
public class PageRequestDTO {

    @Builder.Default
    private int page = 1;  // 페이지 번호, 기본값은 1

    @Builder.Default
    private int size = 10;  // 페이지 크기, 기본값은 10

}

* PageResponseDTO는 나중에 다른 타입의 DTO 들을 이용할 수 있도록 제네릭 타입으로 작성한다. PageResponseDTO는 DTO의 리스트와 전체 데이터의 수를 지정하면 페이지 처리에 필요한 번호(pageNumList)나 이전/다음에 대한 처리가 이루어진다.

package com.unlimited.mallapi.dto;

import lombok.Builder;
import lombok.Data;

import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

@Data
public class PageResponseDTO<E> { // 제네릭 타입<E> 선언

    private List<E> dtoList; // 현재 페이지의 DTO 목록
    private List<Integer> pageNumList; // 페이지 번호 목록
    private PageRequestDTO pageRequestDTO; // 페이지 요청 정보
    private boolean prev, next; // 이전 페이지, 다음 페이지 존재 여부
    private int totalCount, prevPage, nextPage, totalPage, current; // 총 게시물 수, 이전 페이지 번호, 다음 페이지 번호, 총 페이지 수, 현재 페이지 번호

    @Builder(builderMethodName = "withAll")
    public PageResponseDTO(List<E> dtoList, PageRequestDTO pageRequestDTO, long totalCount) {
        this.dtoList = dtoList; // 현재 페이지의 DTO 목록 설정
        this.pageRequestDTO = pageRequestDTO; // 페이지 요청 정보 설정
        this.totalCount = (int) totalCount; // 총 게시물 수 설정

        // 페이지 번호 계산
        int end = (int) (Math.ceil(pageRequestDTO.getPage() / 10.0)) * 10;
        int start = end - 9;
        int last = (int) (Math.ceil((totalCount / (double) pageRequestDTO.getSize())));
        end = end > last ? last : end;

        // 이전 페이지, 다음 페이지 존재 여부 설정
        this.prev = start > 1;
        this.next = totalCount > end * pageRequestDTO.getSize();

        // 페이지 번호 목록 설정
        this.pageNumList = IntStream.rangeClosed(start, end).boxed().collect(Collectors.toList());

        // 이전 페이지, 다음 페이지 번호 설정
        if (prev) {
            this.prevPage = start - 1;
        }
        if (next) {
            this.nextPage = end + 1;
        }

        // 총 페이지 수, 현재 페이지 번호 설정
        this.totalPage = this.pageNumList.size();
        this.current = pageRequestDTO.getPage();
    }

}

(1) 목록(페이징) 처리 구현

* 목록 데이터의 처리는 PageRequestDTO 타입으로 파라미터를 처리하고, PageResponseDTO 타입을 리턴 타입으로 지정한다.

package com.unlimited.mallapi.service;

import com.unlimited.mallapi.dto.PageRequestDTO;
import com.unlimited.mallapi.dto.PageResponseDTO;
import com.unlimited.mallapi.dto.TodoDTO;

public interface TodoService {

    Long register(TodoDTO todoDTO);

    TodoDTO get(Long tno);

    void modify(TodoDTO todoDTO);

    void remove(Long tno);

    PageResponseDTO<TodoDTO> list(PageRequestDTO pageRequestDTO);

}

* TodoServiceImpl의 구현은 아래와 같다(org.springframework.data.domain 패키지의 타입을 이용하므로 import 시에 주의한다).

package com.unlimited.mallapi.service;

import java.util.Optional;
import java.util.List;
import java.util.stream.Collectors;

import org.modelmapper.ModelMapper;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.unlimited.mallapi.domain.Todo;
import com.unlimited.mallapi.dto.PageRequestDTO;
import com.unlimited.mallapi.dto.PageResponseDTO;
import com.unlimited.mallapi.dto.TodoDTO;
import com.unlimited.mallapi.repository.TodoRepository;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

@Service
@Transactional(rollbackFor = Exception.class)
@Log4j2
@RequiredArgsConstructor // 생성자 자동 주입
public class TodoServiceImpl implements TodoService {

    // ModelMapper와 TodoRepository를 생성자 주입
    private final ModelMapper modelMapper;     // DTO와 Entity 간 변환을 위한 ModelMapper
    private final TodoRepository todoRepository;  // 할일 데이터 처리를 위한 TodoRepository

    // 할일 등록 메서드
    @Override
    public Long register(TodoDTO todoDTO) {
        log.info("할일 등록 서비스 호출");

        // TodoDTO를 Todo 엔티티로 변환
        Todo todo = modelMapper.map(todoDTO, Todo.class);

        // TodoRepository를 통해 할일 저장
        Todo savedTodo = todoRepository.save(todo);

        // 저장된 할일의 번호 반환
        return savedTodo.getTno();
    }

    // 특정 번호의 할일 조회 메서드
    @Override
    public TodoDTO get(Long tno) {
        // TodoRepository를 통해 특정 번호의 할일 조회
        // import java.util.Optional;
        Optional<Todo> result = todoRepository.findById(tno);

        // 조회된 할일이 없으면 예외를 던짐
        Todo todo = result.orElseThrow();

        // 조회된 할일을 TodoDTO로 변환
        TodoDTO dto = modelMapper.map(todo, TodoDTO.class);

        // 변환된 TodoDTO 반환
        return dto;
    }

    // 할일 수정 메서드
    @Override
    public void modify(TodoDTO todoDTO) {
        // TodoRepository를 통해 수정할 할일 조회
        Optional<Todo> result = todoRepository.findById(todoDTO.getTno());

        // 조회된 할일이 없으면 예외를 던짐
        Todo todo = result.orElseThrow();

        // 할일 정보 변경
        todo.changeTitle(todoDTO.getTitle());
        todo.changeDueDate(todoDTO.getDueDate());
        todo.changeComplete(todoDTO.isComplete());

        // 변경된 할일을 TodoRepository를 통해 저장
        todoRepository.save(todo);
    }

    // 할일 삭제 메서드
    @Override
    public void remove(Long tno) {
        // TodoRepository를 통해 할일 삭제
        todoRepository.deleteById(tno);
    }

    // 할일 목록 조회 메서드
    @Override
    public PageResponseDTO<TodoDTO> list(PageRequestDTO pageRequestDTO) {
        // 크기가 양수인지 확인하여 음수 크기 방지
        if (pageRequestDTO.getSize() <= 0) {
            throw new IllegalArgumentException("크기는 양수여야 합니다.");
        }

        // Pageable 객체 생성
        Pageable pageable = PageRequest.of(
                pageRequestDTO.getPage() - 1, // 1페이지가 0이므로 주의!
                pageRequestDTO.getSize(),
                Sort.by("tno").descending());

        // TodoRepository를 통해 페이지 정보에 따른 할일 목록 조회
        Page<Todo> result = todoRepository.findAll(pageable);

        // 조회된 할일 목록을 TodoDTO로 변환하여 리스트로 저장
        List<TodoDTO> dtoList = result.getContent()
                .stream()
                .map(todo -> modelMapper.map(todo, TodoDTO.class))
                .collect(Collectors.toList());

        // 전체 할일 개수 저장
        long totalCount = result.getTotalElements();

        // PageResponseDTO 생성
        PageResponseDTO<TodoDTO> responseDTO = PageResponseDTO.<TodoDTO>withAll()
                .dtoList(dtoList)               // 변환된 할일 목록 저장
                .pageRequestDTO(pageRequestDTO) // 요청한 페이지 정보 저장
                .totalCount(totalCount)         // 전체 할일 개수 저장
                .build();

        // 생성된 PageResponseDTO 반환
        return responseDTO;
    }

}

* 테스트 코드를 사용해서 목록 처리를 확인한다.

@Test
public void testList() {
    // 테스트를 위한 페이지 요청 정보 생성
    PageRequestDTO pageRequestDTO = PageRequestDTO.builder()
            .page(2)    // 조회할 페이지 번호
            .size(10)   // 페이지 크기
            .build();

    // 할일 목록 조회 서비스 호출
    PageResponseDTO<TodoDTO> response = todoService.list(pageRequestDTO);

    // 조회 결과 로그 출력
    log.info(response);
}

* 테스트 코드를 실행하면 SQL 이 실행되면서 TodoRepository에서 만들어진 결과물은 모두 DTO 타입으로 변환된 것을 확인할 수 있다.


(2) RESTful 서비스를 위한 컨트롤러

* 서비스 계층까지의 구현이 끝났다는 의미는 필요한 기능에 대한 구현은 모두 끝났다고 할 수 있다. 남은 작업은 컨트롤러로 앱이나 웹으로 데이터를 제공하는 RESTful API 서비스를 구현하는 것이다.
* 프로젝트에 controller 패키지를 구성하고 TodoController 클래스를 추가한다.

* TodoController 에는 우선 별도의 도구 없이 테스트가 가능한 GET 방식으로 동작하는 메서드들만을 처리한다.

package com.unlimited.mallapi.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.unlimited.mallapi.dto.PageRequestDTO;
import com.unlimited.mallapi.dto.PageResponseDTO;
import com.unlimited.mallapi.dto.TodoDTO;
import com.unlimited.mallapi.service.TodoService;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

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

    private final TodoService service;

    // 특정 번호의 할일 조회 컨트롤러 메서드
    @GetMapping("/{tno}")
    public TodoDTO get(@PathVariable(name = "tno") Long tno) {
        // 할일 서비스를 통해 특정 번호의 할일 정보를 조회하여 반환
        return service.get(tno);
    }

    // 할일 목록 조회 컨트롤러 메서드
    @GetMapping("/list")
    public PageResponseDTO<TodoDTO> list(PageRequestDTO pageRequestDTO) {
        log.info(pageRequestDTO);
        // 할일 서비스를 통해 페이지 정보에 따른 할일 목록을 조회하여 반환
        return service.list(pageRequestDTO);
    }

}

* GET 방식은 별도의 프로그램(Postman 등)이 없이도 테스트가 가능하므로 프로젝트를 실행하고 브라우저를 통해서 JSON 데이터가 출력되는지를 확인한다.
- http://localhost:8080/api/todo/34

- http://localhost:8080/api/todo/list?page=3&size=10