본문 바로가기

Server Programming/Spring Boot Backend Programming

5장-4. 게시물 관리 프로젝트 구현 (1) (+ withAll, RootConfig)

반응형

사용 기술

  • Spring Data JPA로 영속 계층 처리
  • Thymeleaf로 화면 처리
  • 스프링 부트로 컨트롤러와 서비스 처리

구현 순서

  1. 서비스 계층과 DTO 구현
  2. 목록/검색 처리
  3. 컨트롤러와 화면 처리

서비스 계층과 DTO 구현

  • ModelMapper 설정
  • CRUD 작업 처리 : 등록 / 조회 / 수정 / 삭제

HTTP 요청 -> 컨트롤러에서 매핑 -> 서비스 계층에서 리포지토리 메서드 호출-> BoardRepository-> 서비스 계층에서 DTO로 변환

: 모든 DB로 접근은 리포지토리를 거쳐 엔티티에 접근하는 방식으로 엔티티 객체는 영속 컨텍스트에서 관리한다.


ModelMapper 설정

ModelMapper : 엔티티 <-> DTO를 담당한다.

 

(1) 의존성 추가

implementation 'org.modelmapper:modelmapper:3.1.0'

 

(2) config 패키지 생성해 RootConfig 클래스 작성

-@Configuration : 스프링 설정 클래스라는 것을 명시하는 어노테이션

-@Bean : ModelMapper를 스프링의 빈으로 등록하기 위한 어노테이션

 

package org.zerock.b01.config;

import org.modelmapper.ModelMapper;
import org.modelmapper.convention.MatchingStrategies;
import org.modelmapper.spi.MatchingStrategy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.persistence.Column;

@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.LOOSE);
        return modelMapper;
    }
}

 


CRUD 작업 처리

dto 패키지 생성해 BoardDTO 작성

-@Data : @Getter, @Setter, @ToString을 포함하는 어노테이션
-@Builder : 빌더 패턴을 이용해 객체를 생성하는 어노테이션
-@AllArgsConstructor / @NoArgsConstructor : 기본 생성자 생성, 모든 멤버변수가 존재하는 생성자 생성 어노테이션

package org.zerock.b01.dto;

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

import java.time.LocalDateTime;
import java.time.LocalTime;

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

}

-> regDate와 modDate의 경우 BaseEntity를 이용해, 공통 속성을 가지는 클래스로 만든다.

 

1. 등록 처리

(1) BoardService 인터페이스 생성해 register() 메서드 선언 

package org.zerock.b01.service;

import org.zerock.b01.dto.BoardDTO;

public interface BoardService {
    Long register(BoardDTO boardDTO);
}

 

(2) BoardServiceImpl에 register() 메서드 구현

-@Service : 서비스라는 것을 알리고, 빈 등록하는 어노테이션
-@Log4j2 : Logging하기 위한 어노테이션
-@RequiredArgsConstructor : final이 붙거나 @NotNull이 붙은 필드의 생성자를 생성해주는 어노테이션
-@Transactional : 해당 객체를 감싸는 별도의 클래스를 생성해 선언적 트랜잭션 처리를 담당하는 어노테이션

(여러 번의 DB 연결이 있을 경우에 사용한다.)

package org.zerock.b01.service;

import com.sun.org.apache.xpath.internal.operations.Mod;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.modelmapper.ModelMapper;
import org.springframework.stereotype.Service;
import org.zerock.b01.domain.Board;
import org.zerock.b01.dto.BoardDTO;
import org.zerock.b01.repository.BoardRepository;

import javax.transaction.Transactional;

@Service
@Log4j2
@RequiredArgsConstructor
@Transactional
public class BoardServiceImpl implements BoardService{
    private final ModelMapper modelMapper;
    private final BoardRepository boardRepository;

    @Override
    public Long register(BoardDTO boardDTO) {
        //DTO -> Entity 변환
        Board board=modelMapper.map(boardDTO, Board.class);

        //리파지토리에 저장 (영속 컨텍스트에 저장) -> 데이터베이스와 동기화
        Long bno = boardRepository.save(board).getBno();
        
        return bno;
    }
}

 

(3) 테스트 코드 작성

package org.zerock.b01.service;

import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
@Log4j2
public class ServiceTests {

    //필드 주입
    @Autowired
    private BoardService boardService;

    @Test
    public void testRegister(){
        log.info(boardService.getClass().getName());
        //"org.zerock.b01.service.ServiceTests.testRegister"'.
    }
}

 

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

    BoardDTO boardDTO= BoardDTO.builder()
            .title("Sample Title...")
            .content("Sample Content...")
            .writer("user00")
            .build();
    
    Long bno = boardService.register(boardDTO);
    
    log.info("bno : "+bno);
}

 

    insert 
    into
        board
        (moddate, regdate, content, title, writer) 
    values
        (?, ?, ?, ?, ?)

 

2. 조회 처리

-BoardService에 readOne() 메서드 선언, BoardServiceImpl에 readOne() 메서드 구현

@Override
public BoardDTO readOne(Long bno) {
    
    //Null 처리를 위해 Optional 타입을 사용한다.
    Optional<Board> result = boardRepository.findById(bno);
    Board board=result.orElseThrow(IllegalAccessError::new);
    BoardDTO boardDTO = modelMapper.map(board, BoardDTO.class);
    
    return boardDTO;
}

 

3. 수정 처리

(1) BoardService에 modify() 메서드 선언, BoardServiceImpl에 readOne() 메서드 구현

-select()로 해당 객체가 존재하는지 확인 후, 존재하면 chage() 메서드 호출해 수정

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

 

(3) 테스트 코드 작성

@Test
public void testUpdate(){
    //변경에 필요한 데이터만으로 객체 생성
    BoardDTO boardDTO = BoardDTO.builder()
            .bno(10L)
            .title("Updated...101")
            .content("Updated content 101...")
            .build();
    boardService.modify(boardDTO);
}

 

4. 삭제 처리

-BoardService에 remove() 메서드 선언, BoardServiceImpl에 remove() 메서드는 bno를 이용해 삭제 처리

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

 


목록/검색 처리

  • 페이징(PageRequestDTO, PageResponseDTO)
  • 서비스(BoardService, BoardServiceImpl)

페이징

  1. PageRequestDTO
  2. PageResponseDTO
  3. BoardService / BoardServiceImpl

1. PageRequestDTO

-@Data : @Getter, @Setter, @ToString를 포함하는 어노테이션

-@AllArgsConstructor / @NoArgsConstructor :DTO, VO는 파라미터를 모두 갖거나 하나도 없을 때 생성자 생성

-@Builder : Builder 패턴을 이용해 객체 생성

-@Builder.Default : 값이 null일 때 기본값으로 생성하는 어노테이션 

 

(1) PageRequestDTO의 구성 요소

  • 페이징 관련 정보
  • 검색 조건
  • 키워드

(2) PageRequestDTO의 구현

  • page : 현재 페이지 번호
    • 1페이지 ~ (전체 데이터 수/size)까지
    • 기본값 @Builder.default, @Min 최소 1, @Positive 양수만 가능
  • size : 한 페이지당 보여주는 데이터 수
    • 설정가능한 한 페이지당 보여줄 데이터 개수 
    • 한 페이지당 10개에서 ~100개까지
    • 기본값 @Builder.default, @Min 최소 10 ~ @Max 최대 100, @Positive 양수만 가능
  • type : 문자열 하나로 처리해 나중에 각 문자를 분리하도록 검색 조건
  • keyword : 키워드
  • getTypes() : 리포지토리에서 문자열이 아니라 문자열 배열로 검색조건을 처리하기 때문에 배열로 반환하는 메서드
  • getPageable() : 페이징 처리를 위해 Pageable 타입을 반환하는 메서드
  • getLink() : 검색 조건과 페이징 조건 등을 문자열로 구성해 링크를 생성하는 메서드
public String[] getTypes(){
    //검색 조건이 존재하지 않을 땐 null 리턴
    if(type==null || type.isEmpty()){
        return null;
    }
    //검색 조건이 존재하는 경우, 문자로 분리
    return 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(){
    //초기화 없이 선언만 했으므로 null인 상태
    if(link ==null) {
        StringBuilder builder = new StringBuilder();

        builder.append("page=" + this.page);

        builder.append("&size=" + this.size);
        
        //검색 조건이 존재할 경우
        if (type!=null &&  type.length()>0) {
            builder.append("&type=" +type);
        }
        //키워드가 존재할 경우
        if (keyword!=null) {
            try{
                builder.append("&keyword="+ URLEncoder.encode(keyword, "UTF-8"));
            }catch (UnsupportedEncodingException e) {}
        }
        link= builder().toString();
    }
    
    return link;
}

 

2. PageResponseDTO

-직접 DTO를 작성하는 것보다 생성자를 이용해 안전하게 처리하는 것을 목적으로 작성한다.

-@Getter : 생성자를 이용하므로, 직접 Setter를 작성할 필요가 없다.

-@Builder :  PageRequestDTO와 List<TodoDTO>, 전체 데이터 개수를 이용한 생성자 작성하는 어노테이션

 

(1) PageResponseDTO 구성 요소

  • TodoDTO의 목록
  • 전체 데이터 수
  • 페이지 번호의 처리를 위한 데이터들 (시작 페이지 번호 / 끝 페이지 번호)

(2) PageResponseDTO 구현

  • page : 현재 페이지 번호
  • size : 한 페이지당 보여주는 데이터 수
  • total : 전체 데이터 수
  • start : 시작 페이지 번호
  • end : 마지막 페이지 번호

  • prev : 이전 페이지 존재 여부
  • next : 이후 페이지 존재 여부

  • dtoList : 현재 페이지의 객체들을 담은 리스트
  • @Builder 어노테이션을 이용해 작성한 생성자
    • page, size는 DTO를 통해, total과 dtolist는 직접 받는다.
    • start, end의 경우, 열 개의 페이지 번호를 제공한다고 가정해
      먼저 마지막 페이지를 구하고 마지막 페이지를 통해 start 페이지를 찾는다.

      -> end : 올림한 (현재 페이지/10.0) * 10
      -올림을 하는 이유는 페이지는 데이터가 하나라도 존재한다면 페이지 수가 필요하기 때문이다.
      -> start : 마지막 페이지로부터 -9번째

    • 중요한건 마지막 페이지가 존재하지 않을 수도 있다는 것이다
      -> 페이지 개수가 10개보다 적을 수도 있기 때문에, 직접 마지막 번호를 구해 비교한다.

      last : 올림한 (전체 페이지수/한 페이지당 보여주는 데이터 수)
      -올림을 하는 이유는 페이지는 데이터가 하나라도 존재한다면 페이지 수가 필요하기 때문이다.
      end : last변수를 이용해 마지막 페이지 번호가 last번호보다 크면 last번호를 마지막 번호로 지정한다.
    • prev : 1페이지보다 크면 이전 페이지가 존재하고,
      next : 전체 데이터 수가 마지막 페이지 * 한 페이지당 보여주는 데이터 수 보다 크면 다음 페이지가 존재한다.
package org.zerock.b01.dto;


import lombok.Builder;
import lombok.Getter;
import lombok.ToString;

import java.util.List;

@Getter
@Builder
@ToString
public class PageResponseDTO<E> {

    private int page;
    private int size;
    private int total;

    //시작 페이지 번호
    private int start;
    //끝 페이지 번호
    private int end;

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

    private List<E> dtoList;

    @Builder(builderMethodName = "withAll")
    public PageResponseDTO(PageRequestDTO pageRequestDTO, List<E> dtoList, int total){

        this.page = pageRequestDTO.getPage();
        this.size = pageRequestDTO.getSize();

        this.total = total;
        this.dtoList = dtoList;

        this.end =   (int)(Math.ceil(this.page / 10.0 )) *  10;

        this.start = this.end - 9;

        int last =  (int)(Math.ceil((total/(double)size)));

        this.end =  end > last ? last: end;

        this.prev = this.start > 1;

        this.next =  total > this.end * this.size;

    }

}

 

 

(3) BoardService / BoardServiceImpl

-목록 / 검색을 위한 list() 메서드 작성

-BoardRepository 호출

-Page<Board> -> List<BoardDTO>로 직접 변환

 

Page<T> -> List<E> 변환 방법

  • 직접 변환
  • 한번에 DTO로 추출
package org.zerock.b01.service;

import com.sun.org.apache.xpath.internal.operations.Mod;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.modelmapper.ModelMapper;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.zerock.b01.domain.Board;
import org.zerock.b01.dto.BoardDTO;
import org.zerock.b01.dto.PageRequestDTO;
import org.zerock.b01.dto.PageResponseDTO;
import org.zerock.b01.repository.BoardRepository;

import javax.transaction.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

@Service
@Log4j2
@RequiredArgsConstructor
@Transactional
public class BoardServiceImpl implements BoardService{
    private final ModelMapper modelMapper;
    private final BoardRepository boardRepository;

    @Override
    public Long register(BoardDTO boardDTO) {
        //DTO -> Entity 변환
        Board board=modelMapper.map(boardDTO, Board.class);

        //리파지토리에 저장 (영속 컨텍스트에 저장) -> 데이터베이스와 동기화
        Long bno = boardRepository.save(board).getBno();

        return bno;
    }

    @Override
    public BoardDTO readOne(Long bno) {

        //Null 처리를 위해 Optional 타입을 사용한다.
        Optional<Board> result = boardRepository.findById(bno);
        Board board=result.orElseThrow(IllegalAccessError::new);
        BoardDTO boardDTO = modelMapper.map(board, BoardDTO.class);

        return boardDTO;
    }

    @Override
    public void modify(BoardDTO boardDTO) {
        Optional<Board> result = boardRepository.findById(boardDTO.getBno());
        Board board = result.orElseThrow(IllegalAccessError::new);
        board.change(boardDTO.getTitle(), boardDTO.getContent());

        boardRepository.save(board);
    }

    @Override
    public void remove(Long bno) {

        boardRepository.deleteById(bno);
    }

    @Override
    public PageResponseDTO<BoardDTO> list(PageRequestDTO pageRequestDTO) {

        //1. 리포지토리 호출
        String[] types = pageRequestDTO.getTypes();
        String keyword = pageRequestDTO.getKeyword();
        Pageable pageable = pageRequestDTO.getPageable("bno");

        Page<Board> result = boardRepository.searchAll(types, keyword, pageable);

        //2. 스트림으로 Page<E> -> List<T>를 위해 modelMapper를 호출해 변환
        List<BoardDTO> dtoList = result.getContent().stream()
                .map(board -> modelMapper.map(board, BoardDTO.class))
                .collect(Collectors.toList());

        //3. PageResponseDTO<BoardDTO>를 생성자를 이용해 생성
        //: 생성자를 호출하기위해 PageRequestDTO , dtoList, total가 필요
        return PageResponseDTO.<BoardDTO>withAll()
                .pageRequestDTO(pageRequestDTO)
                .dtoList(dtoList)
                .total((int)result.getTotalElements())
        .build();
    }
}

 

(4) 테스트 코드 작성

@Test
public void testList(){
    PageRequestDTO pageRequestDTO = PageRequestDTO.builder()
            .type("tcw")
            .keyword("1")
            .page(1)
            .size(10)
            .build();
    PageResponseDTO<BoardDTO> pageResponseDTO = boardService.list(pageRequestDTO);
    
    log.info(pageResponseDTO);
}

 

    select
        board0_.bno as bno1_0_,
        board0_.moddate as moddate2_0_,
        board0_.regdate as regdate3_0_,
        board0_.content as content4_0_,
        board0_.title as title5_0_,
        board0_.writer as writer6_0_ 
    from
        board board0_ 
    where
        (
            board0_.title like ? escape '!' 
            or board0_.content like ? escape '!' 
            or board0_.writer like ? escape '!'
        ) 
        and board0_.bno>? 
    order by
        board0_.bno desc limit ?

 

   select
        count(board0_.bno) as col_0_0_ 
    from
        board board0_ 
    where
        (
            board0_.title like ? escape '!' 
            or board0_.content like ? escape '!' 
            or board0_.writer like ? escape '!'
        ) 
        and board0_.bno>?

컨트롤러와 화면 처리

 

(1) 컨트롤러 작성

package org.zerock.b01.controller;

import lombok.AllArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.zerock.b01.dto.BoardDTO;
import org.zerock.b01.dto.PageRequestDTO;
import org.zerock.b01.dto.PageResponseDTO;
import org.zerock.b01.service.BoardService;

@Controller
@RequestMapping("/board")
@Log4j2
@RequiredArgsConstructor
public class BoardController {
    private final BoardService boardService;

 
}

 

(2) 화면 레이아웃 구성을 위한 의존성 추가

implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'

 

(3) 부트스트랩을 이용한 템플릿 디자인 적용

: simple-sidebar 템플릿을 static폴더에 추가

https://startbootstrap.com/template/simple-sidebar

 

Simple Sidebar - Bootstrap Sidebar Template - Start Bootstrap

Like our free products? Our pro products are even better! Go Pro Today!

startbootstrap.com

 

(4) 부트스트랩 탬플릿을 타임리프 레이아웃으로 변경

-layout 폴더에 index.html 내용을 복사한 basic.html 추가

 

(5) 레이아웃 적용

-레이아웃, 타임리프 네임스파이스 추가

<html xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      xmlns:th="http://www.thymeleaf.org">

- <head> 태그에 존재하는 링크를 타임리프 스타일 로 변경 (타임리프에서 링크처리는 th:href="@{/...}"로 표현한다.

 

변경 전, 링크 처리

<!-- Favicon-->
<link rel="icon" type="image/x-icon" href="assets/favicon.ico" />
<!-- Core theme CSS (includes Bootstrap)-->
<link href="css/styles.css" rel="stylesheet" />

 

변경 후, 링크 처리

<!-- Favicon-->
<link rel="icon" type="image/x-icon" th:href="@{/assets/favicon.ico}" />
<!-- Core theme CSS (includes Bootstrap)-->
<link th:href="@{/css/styles.css}" rel="stylesheet" />

- 본문에 타임리프 레이아웃 적용

-'Page Content' 부분의 div 속성으로 layout:fragment 적용

 

변경 전, div

<!-- Page content-->
<div class="container-fluid">
  <h1 class="mt-4">Simple Sidebar</h1>
  <p>The starting state of the menu will appear collapsed on smaller screens, and will appear non-collapsed on larger screens. When toggled using the button below, the menu will change.</p>
  <p>
    Make sure to keep all page content within the
    <code>#page-content-wrapper</code>
    . The top navbar is optional, and just for demonstration. Just create an element with the
    <code>#sidebarToggle</code>
    ID which will toggle the menu when clicked.
  </p>
</div>

 

변경 후, div

<!-- Page content-->
<div class="container-fluid" layout:fragment="content">
  <h1 class="mt-4">Simple Sidebar</h1>
  <p>The starting state of the menu will appear collapsed on smaller screens, and will appear non-collapsed on larger screens. When toggled using the button below, the menu will change.</p>
  <p>
    Make sure to keep all page content within the
    <code>#page-content-wrapper</code>
    . The top navbar is optional, and just for demonstration. Just create an element with the
    <code>#sidebarToggle</code>
    ID which will toggle the menu when clicked.
  </p>
</div>

-자바스크립트 블록 추가 후, 타임리프 스타일로 링크 수정

 

변경 전, 자바 스크립트

<!-- Core theme JS-->
<script src="js/scripts.js"></script>

 

변경 후, 자바 스크립트

<!-- Core theme JS-->
<script th:src="@{/js/scripts.js}"></script>

<th:block layout:fragment="script">

</th:block>

(6) 컨트롤러로 확인

-list() 메서드를 활용해 레이아웃 적용된 화면 구성

-templates/board 폴더에 list.html 추가

 

<!DOCTYPE html>
<html xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      xmlns:th="http://www.thymeleaf.org"
      layout:decorate="~{layout/basic.html}">
<!-- 타임리프 레이아웃을 적용해 본문만 적용-->
<div layout:fragment="content">
    <h1> Board List</h1>
</div>
<!--화면에 나타나지 않고 빌트인 방식으로 실행되는  자바스크립트-->
<script layout:fragment="script" th:inline="javascript">
    console.log("script.....")
</script>
반응형