1.  서비스 계층과 DTO의 구현

BoardRepository의 모든 메서드는 서비스 계층을 통해서 DTO로 변환되어 처리되도록 구성
엔티티 객체는 영속 컨텍스트에서 관리되므로 가능하면 많은 계층에서 사용되지 않는 것이 좋음

'서비스 계층에서 엔티티 객체를 DTO로 변환하거나 반대의 작업'을 처리하도록 함.
  ➡️  이 때 ModelMapper를 이용


1)  ModleMapper 설정

DTO와 엔티티 간의 변환 처리를 간단히 처리하기 위해서 ModelMapper를 이용할 것이므로 build.gradle 파일에 ModelMapper 라이브러리가 존재하는지 우선 확인

implementation 'org.modelmapper:modelmapper:3.1.0'

 

  • 프로젝트에 config 패키지를 구성하고 RootConfig 클래스를 구성
  • @Confuguration을 이용해서 해당 클래스가 스프링의 설정 클래스임을 명시
  • @Bean을 이용해서 ModelMapper를 스프링의 빈으로 설정
@Configuration
public class RootConfig {
    @Bean
    public ModelMapper getMapper() {
        ModelMapper modelMapper = new ModelMapper();
        modelMapper.getConfiguration()
                .setFieldMatchingEnabled(true)
                .setFieldAccessLevel(org.modelmapper.config.Configuration.AccessLevel.PRIVATE)
                .setMatchingStrategy(MatchingStrategies.STRICT);
        return modelMapper;
    }
}

 


 

2) CRUD 작업 처리

프로젝트에 dto 패키지를 추가하고 dto 패키지에 BoardDTO 클래스를 추가
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class BoardDTO {

    private Long bno;
    private String title;
    private String content;
    private String writer;
    private LocalDateTime regDate;
    private LocalDateTime modDate;

}

 


프로젝트에 service 패키지를 추가하고 BoardService 인터페이스와 BoardServiceImpl 클래스를 추가

 

    📍  등록 작업 처리       -  BoardService 인터페이스에 register()를 선언

public interface BoardService {

    Long register(BoardDTO boardDTO);
}

 

    📍  BoardServiceImpl은 ModelMapper와 BoardRepository를 주입 받도록 구현

@Service
@Log4j2
@RequiredArgsConstructor
@Transactional
public class BoardServiceImpl implements BoardService {

    private final ModelMapper modelMapper;

    private final BoardRepository boardRepository;

    @Override
    public Long register(BoardDTO boardDTO) {
        Board board = modelMapper.map(boardDTO, Board.class);
        Long bno = boardRepository.save(board).getBno();
        return bno;
    }
}

 

  ✓  BoardServiceImpl 클래스에는 의존성 주입 외에도 @Transactional 어노테이션을 적용
         ➡️  @Transactional을 적용하면 스프링은 해당 객체를 감싸는 별도의 클래스를 생성해 내는데, 간혹 여러 번의 데이터베이스 연결이 있을 수도 있으므로 트랜잭션 처리는 기본적으로 적용해 두는 것이 좋음

 

    📍  test 폴더에 service 패키지를 추가하고 BoardServiceTests 클래스를 추가

@SpringBootTest
@Log4j2
class BoardServiceImplTest {
    @Autowired
    private BoardService boardService;

    @Test
    public void registerTest() {
        log.info(boardService.getClass().getName());
    }
}

 

  ✓  실제 boardService 변수가 가르키는 객체의 클래스명을 출력하는데 실행해 보면 BoardServiceImpl이 나오지 않고 스프링에서 BoardServiceImpl을 감싸서 만든 클래스 정보가 출력

 

 

    📍  등록처리의 구현은 다음과 같이 작성  -  콘솔에서 insert문이 동작하는지 확인하고 최종적으로 데이터 베이스에서 확인

    @Test
    public void registerTest() {
        log.info(boardService.getClass().getName());

        BoardDTO boardDTO = BoardDTO.builder()
                .title("test...")
                .content("test...")
                .writer("user")
                .build();

        Long bno = boardService.register(boardDTO);
        log.info("bno: " + bno);
    }

 


 

조회 작업 처리


    📍  조회 작업 처리는 특정한 게시물의 번호를 이용하므로 BoardService와 BoardServiceImpl에 코드를 추가

BoardDTO readOne(Long bno);
   @Override
    public BoardDTO readOne(Long bno) {

        Optional<Board> optionalBoard = boardRepository.findById(bno);
        Board board = optionalBoard.orElseThrow();

        return modelMapper.map(board, BoardDTO.class);
    }

 

Optional<Board> optionalBoard = boardRepository.findById(bno);:

boardRepository.findById(bno)는 주어진 bno를 사용하여 데이터베이스에서 Board 객체를 찾음

이 메서드는 Optional<Board>를 반환 (Optional은 값이 존재할 수도 있고, 존재하지 않을 수도 있는 컨테이너)

이를 통해 NullPointerException을 피할 수 있음

 

Board board = optionalBoard.orElseThrow();:

optionalBoard.orElseThrow()는 Optional 객체가 비어있으면 예외를 던지고, 값이 존재하면 그 값을 반환

여기서는 Optional에 Board 객체가 없으면 기본적으로 NoSuchElementException이 발생

이렇게 함으로써, 해당 bno에 해당하는 Board가 없는 경우 예외가 발생하게 됩니다.

 

return modelMapper.map(board, BoardDTO.class);:

modelMapper.map(board, BoardDTO.class)는 Board 객체를 BoardDTO 객체로 변환

ModelMapper는 객체 변환을 쉽게 해주는 라이브러리로, 여기서는 Board 엔티티를 BoardDTO로 변환하는 데 사용

최종적으로 변환된 BoardDTO 객체를 반환

    @Test
    public void readOneTest() {
        log.info(boardService.readOne(100L));
    }

 

수정 작업 처리


    📍  수정 작업은 기존 엔티티 객체에서 필요한 부분만 변경하도록 작성

void modify(BoardDTO boardDTO);
    @Override
    public void modify(BoardDTO boardDTO) {
        Optional<Board> optionalBoard = boardRepository.findById(boardDTO.getBno());
        Board board = optionalBoard.orElseThrow();
        board.change(boardDTO.getTitle(), boardDTO.getContent());
        boardRepository.save(board);
    }

 

    📍  테스트 할 때는 반드시 실제 데이터베이스에 존재하는 번호를 이용해서 확인

    @Test
    public void testModify() {
        BoardDTO boardDTO = BoardDTO.builder()
                .bno(100L)
                .title("update...100")
                .content("update content 100")
                .build();
        boardService.modify(boardDTO);
    }

 


삭제 작업 처리


    📍  삭제 처리에는 게시물의 번호 (bno)만 필요

void remove(Long bno);
    @Override
    public void remove(Long bno) {
        boardRepository.deleteById(bno);
    }

 


 

3)  목록 / 검색 처리

dto 패키지에 PageRequestDTO, PageResponseDTO 클래스 추가

 

PageRequestDTO

 

    📍  페이징 관련 정보 외에 검색의 종류 type와 키워드 keyword를 추가해서 지정

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

    @Builder.Default
    private int page = 1;

    @Builder.Default
    private int size = 10;

    private String type; // 검색의 종류 t, c, w, tc, tw, twc

    private String keyword;
}

 

  ✓  검색의 종류는 문자열 하나로 처리해서 나중에 각 문자를 분리하도록 구성

    📍  PageRequestDTO에는 몇 가지 필요한 기능들이 존재

  • 현재 검색 조건들을 BoardRepository에서 String[]로 처리하기 때문에 type이라는 문자열을 배열로 변환해 주는 기능이 필요.
  • 페이징 처리를 위해서 사용하는 Pageable 타입을 반환하는 기능도 필요.
    public String[] getTypes() {
        if (this.type == null || type.isEmpty()) {
            return null;
        }
        return this.type.split("");
    }

    public Pageable getPageable(String...props) {
        return PageRequest.of(this.page - 1, this.size, Sort.by(props).descending());
    }

    private String link;

    public String getLink() {
        if (link == null) {
            StringBuilder stringBuilder = new StringBuilder();
            stringBuilder.append("page=").append(this.page);
            stringBuilder.append("&size=").append(this.size);

            if (type != null && type.length() > 0) {
                stringBuilder.append("&type=").append(this.type);
            }
            if (keyword != null) {
                stringBuilder.append("&keyword=").append(URLEncoder.encode(this.keyword, StandardCharsets.UTF_8));
            }
            link = stringBuilder.toString();
        }
        return link;
    }

 

  • getTypes(): type 문자열을 분리하여 문자열 배열로 반환합니다. type이 null이거나 비어있으면 빈 배열을 반환
  • getPageable(String... props): 페이지 요청(PageRequest) 객체를 생성. 페이지 번호는 page - 1 (0부터 시작하기 때문), 페이지 크기는 size, 정렬 기준은 주어진 속성(props)을 기준으로 내림차순
  • getLink 메소드는 객체의 page, size, type, keyword 값을 기반으로 URL 쿼리 스트링을 동적으로 생성하고, 한 번 생성된 링크를 캐시하여 이후 호출 시 재사용. 이는 불필요한 문자열 연산을 피하기 위해 유용
  • if (type != null && type.length() > 0): type이 null이 아니고 비어 있지 않은 경우에만 type 파라미터를 추가. 예: &type=tc
  • if (keyword != null): keyword가 null이 아닌 경우에만 keyword 파라미터를 추가. 이때 URLEncoder.encode를 사용하여 keyword를 UTF-8로 인코딩. 이는 URL에 안전하게 포함시키기 위해 필요. 예: &keyword=example

 

PageResponseDTO

 

    📍  화면에 DTO 목록과 시작 페이지 / 끝 페이지 등에 대한 처리를 담당

@Getter
@ToString
public class PageResponseDTO<E> {
    private int page; // 페이지 번호
    private int size; // 페이지 크기
    private int total; // 전체 항목수

    private int startPage; // 시작 페이지 번호
    private int endPage; // 끝 페이지 번호

    private boolean prev; // 이전 페이지 존재 여부
    private boolean next; // 다음 페이지 존재 여부

    private List<E> dtoList; // 페이지에 포함된 데이터 목록

    @Builder(builderMethodName = "withAll")
    public PageResponseDTO(PageRequestDTO pageRequestDTO, List<E> dtoList, int total) {
        if (total <= 0) { // 총 항목 수가 0 이하인 경우, 생성자 실행을 중단
            return;
        }
        this.page = pageRequestDTO.getPage(); // 현재 페이지 번호
        this.size = pageRequestDTO.getSize(); // 한 페이지에 포함된 항목 수
        this.total = total;                   // 전체 항목 수
        this.dtoList = dtoList;               // 데이터 리스트

        this.endPage = (int)(Math.ceil(this.page / 10.0) * 10); // 끝 페이지 번호를 계산
        this.startPage = this.endPage - 9;   // 시작 페이지 번호를 계산
        int last = (int) (Math.ceil(this.total / (double)this.size)); // 실제 마지막 페이지 번호를 계산
        this.endPage = Math.min(endPage, last); // 더 작은 값을 사용
        this.prev = this.startPage > 1; // 시작 페이지 번호가 1보다 큰 경우 이전 페이지가 존재함
        this.next = total > this.endPage * this.size; 
        // 전체 항목 수가 끝 페이지 번호에 페이지 크기를 곱한 값보다 큰 경우 다음 페이지가 존재함

    }
}

 

  ✓  PageResponseDTO 클래스는 페이지네이션 정보와 함께 데이터를 전달하기 위한 데이터 전송 객체

  ✓  public PageResponseDTO(PageRequestDTO pageRequestDTO, List<E> dtoList, int total): 생성자로, PageRequestDTO, 데이터 리스트(dtoList), 전체 항목 수(total)를 매개변수로 받는다.

 

BoardService / BoardServiceImpl

 

    📍  list()라는 이름으로 목록 / 검색 기능을 선언

PageResponseDTO<BoardDTO> list(PageRequestDTO pageRequestDTO);
    @Override
    public PageResponseDTO<BoardDTO> list(PageRequestDTO pageRequestDTO) {
        String[] types = pageRequestDTO.getTypes(); // 검색 조건을 문자열 배열로 반환
        String keyword = pageRequestDTO.getKeyword(); // 검색 키워드를 반환
        Pageable pageable = pageRequestDTO.getPageable("bno"); // 페이지 요청 객체를 생성

        // 데이터베이스에서 Board 엔티티를 검색하고 페이징 결과를 Page<Board> 객체로 반환
        Page<Board> boardPage = boardRepository.searchAll(types, keyword, pageable);

        // BoardDTO 객체를 저장할 리스트를 생성
        List<BoardDTO> boardDTOList = new ArrayList<>();
        
        // Board 엔티티를 BoardDTO로 변환하여 boardDTOList에 추가
        for (Board board : boardPage.getContent()) {
            // Board 엔티티를 BoardDTO로 변환
            boardDTOList.add(modelMapper.map(board, BoardDTO.class));
        }
        return PageResponseDTO.<BoardDTO>withAll() // PageResponseDTO의 빌더를 생성
                .pageRequestDTO(pageRequestDTO)
                .dtoList(boardDTOList)
                .total((int)boardPage.getTotalElements())
                .build();
    }

 

  ✓  PageRequestDTO 객체를 사용하여 페이지 요청을 처리하고, 결과를 BoardDTO 객체로 변환하여 PageResponseDTO로 반환

 

테스트 코드로 목록 / 검색 기능 확인


    📍  BoardServiceTests에는 테스트 코드를 작성해서 페이징 처리나 쿼리문이 정상적으로 동작하는지 확인

    @Test
    public void testPaging() {
        PageRequestDTO pageRequestDTO = PageRequestDTO.builder()
                .type("tcw")
                .keyword("user")
                .page(1)
                .size(10)
                .build();
        PageResponseDTO<BoardDTO> responseDTO = boardService.list(pageRequestDTO);
        log.info(responseDTO);
        log.info("1) 페이지 번호: " + responseDTO.getPage());
        log.info("2) 전체 게시물 수: " + responseDTO.getTotal());
        log.info("3) 현재 페이지에 출력될 게시물을 반복문을 이용해서 순서대로 출력: ");
        responseDTO.getDtoList().forEach(boardDTO -> {log.info(boardDTO);});
    }

 

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  페이징 처리를 위한 TodoMapper

👩🏻‍💻  Todo 데이터의 수가 많아진다면 데이터베이스에서 많은 시간이 걸릴 수 있고, 화면에서 이를 출력하는 데도 많은 시간과 자원이 소모
👩🏻‍💻  일반적으로는 많은 데이터를 보여주는 작업은 페이징 처리를 해서 최소한의 데이터들을 보여주는 방식을 선호
        ⚡️  페이징 처리를 하게 되면 데이터베이스에서 필요한 만큼의 최소한의 데이터를 가져오고 이를 출력하기 때문에 성능 개선에도 많은 도움이 됨

 

1)  페이징을 위한 SQL 연습

개발 전에 데이터베이스 상에서 원하는 동작을 미리 구현.


A. 더미 데이터 추가하기

 

많은 양의 데이터를 만드는 방법. 2배씩 늘어나기 때문에 몇번만 실행하면 많은 데이터를 생성함.
더미 데이터 추가를 위해 아래의 SQL을 여러번 처리.

insert into tbl_todo(title,dueDate,writer) (select title,dueDate,writer from tbl_todo);

 

  📍 최종적으로 데이터의 수를 확인

select count(tno) from tbl_todo;

B.  limit 실습


페이징 처리를 위해서는 select의 마지막 부분에는 limit 처리를 사용.
일반적으로 웹에서는 가장 최근에 등록된 데이터를 우선으로 보여주므로 가장 마지막에 등록된 데이터가 순차적으로 보이도록 쿼리를 작성

select * from tbl_todo order by tno desc;

 

📍  limit 뒤의 값이 한 개만 존재하는 경우 가져와야 하는 데이터의 수를 의미. 가장 최근 데이터 10개를 가져옴

select*from tbl_todo order by tno desc limit 10;
select*from tbl_todo order by tno desc limit 0, 10;

 


C.  count()의 필요성


페이징 처리를 하기 위해서는 페이지 번호를 구성할 때 필요한 전체 데이터의 개수가 필요함.
예를 들어 전체 데이터가 30개이면 3 페이지까지만 출력해야 하는 작업에서 사용

select count(tno) from tbl_todo;

 


2)  페이지 처리를 위한 DTO

💡  페이지 처리는 현재 페이지 번호 page, 한 페이지당 보여주는 데이터의 수 size가 기본적으로 필요

      ➡️  2개의 숫자를 매번 전달할 수도 있겠지만 나중에 확장 여부를 고려해서라도 별도의 DTO를 만들어 두는 것이 좋음

 

dto 패키지에 PageRequestDTO 클래스를 정의
@Builder
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PageRequestDTO {
    @Builder.Default
    @Min(value = 1)
    @Positive
    private int page = 1;

    @Builder.Default
    @Min(value=10)
    @Max(value=100)
    @Positive
    private int size = 10;

    public int getSkip() {
        return (page - 1) * size;
    }

}

 

  ✏️  페이지 번호 page와 한 페이지당 개수 size를 보관하는 용도 외에도 limit에서 사용하는 건너뛰기 skip 의 수를 getSkip() 만들어서 사용
  ✏️  page나 size는 기본값을 가지기 위해서 Lombok의 @Builder.Default를 이용
  ✏️  @Min, @Max를 이용해서 외부에서 조작하는 것에 대해서도 대비하도록 구성

 


3)  TodoMapper의 목록 처리

TodoMapper 인터페이스는 PageRequestDTO를 파라미터로 처리하는 selectList()를 추가

 List<TodoVO> selectList(PageRequestDTO pageRequestDTO);

 

TodoMapper.xml 내에서 selectList는 다음과 같이 구현
    <select id="selectList" resultType="com.example.spring_ex_01_2404.domain.TodoVO">
        select * from tbl_todo order by tno desc limit #{skip}, #{size}
    </select>

 

  ✏️  MyBatis는 기본적으로 getXXX, setXXX를 통해서 동작하기 때문에 #{skip}의 경우는 getSkip()을 호출
 

테스트 코드를 이용해서 TodoMapper의 selectList()가 정상적으로 동작하는지 확인
 @Test
    public void testSelectList() {
        PageRequestDTO pageRequestDTO = PageRequestDTO.builder()
                .page(5)
                .build();
        List<TodoVO> todoVOList = todoMapper.selectList(pageRequestDTO);
        for (TodoVO todoVO : todoVOList) {
            log.info(todoVO);
        }

        todoVOList.forEach(item -> log.info(item));

    }

 


4)  TodoMapper의 count 처리

화면에 페이지 번호들을 구성하기 위해서는 전체 데이터의 수를 알아야만 가능
  ✓ 예를 들어 마지막 페이지가 7에서 끝나야 하는 상황이 생긴다면 화면상에서도 페이지 번호를 조정해야 하기 때문

 

TodoMapper에 getCount()를 추가


  👾  getCount()는 나중에 검색에 대비해서 PageRequestDTO를 파라미터로 받로록 설계

 int getCount(PageRequestDTO pageRequestDTO);

 

TodoMapper.xml은 우선은 전체 개수를 반환하도록 구성
<select id="getCount" resultType="int">
    select COUNT(*) FROM tbl_todo
</select>

 

test 코드 실행 확인
 @Test
    public void testGetCount() {
        log.info(todoMapper.getCount(PageRequestDTO.builder().build()));
    }

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]

+ Recent posts