본문 바로가기

Server Oriented/Spring

스프링 JPA, 평범한 게시판 구현 DB 테이블 1개

검색 조건으로 조회하고 페이지 링크를 거는 목록, 상세, 등록, 수정, 삭제 등을 다루는 평범한 게시판


* 파일 구조

 

/src/main/java/도메인/controller/Board2Controller.java, request/response 교통정리만 처리

/src/main/java/도메인/domain/Board2.java, 기본 데이타 구조 (DB 연계). Board 라고 해도 됨

/src/main/java/도메인/dto/PageRequestDTO.java, 목록 검색 조건과 페이지 정보

/src/main/java/도메인/repository/Board2Repository.java, DB 관리 (JPA 핵심이자 기술할게 없기도..)

/src/main/java/도메인/service/Board2Service.java, 인터페이스

/src/main/java/도메인/service/Board2ServiceImpl.java, 실제적인 비즈니스 로직 (DB & File)

/src/main/java/도메인/vo/CodeValueVO.java, 코드값 세팅용

/src/main/java/도메인/vo/PaginationVO.java, 목록 페이지 링크 세팅용

/src/main/resources/application.yml, 서비스 포트, DB datasource, logging 등등

/src/main/resources/templates/, Thymeleaf View

/src/main/resources/templates/board2/list.html, 목록 (검색 조건, 페이지 링크)

/src/main/resources/templates/board2/read.html

/src/main/resources/templates/board2/modify.html

/src/main/resources/templates/board2/register.html

/src/main/resources/templates/layouts/default.html, 기본 레이아웃, header/footer/container 구분

/src/main/resources/static/, 일반적인 파일 경로(css, js, 이미지)

/src/main/resources/static/test.html, 서버 작동 확인용

/src/main/resources/static/css/default.css, 기본 CSS

/src/main/resources/static/js/default.js, 기본 자바스크립트

/src/main/resources/static/js/jquery.min.css, 아직은 jquery 가 쓸만..


* build.gradle

 

plugins {
  id 'org.springframework.boot' version '2.7.5'
  id 'io.spring.dependency-management' version '1.0.15.RELEASE'
  id 'java'
}

group = '도메인'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

configurations {
  compileOnly {
    extendsFrom annotationProcessor
  }
}

repositories {
  mavenCentral()
}

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-jdbc' // JdbcTemplate 이지만, 혹시나
  implementation 'org.springframework.boot:spring-boot-starter-data-jpa' // JPA
  implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.2' // Mybatis 이지만, 혹시나
  implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
  implementation 'org.springframework.boot:spring-boot-starter-validation' // 유효성 검증, @Entity 멤버변수에 유효성 설정
  implementation 'org.springframework.boot:spring-boot-starter-web'
  implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect' // Layout
  compileOnly 'org.projectlombok:lombok'
  developmentOnly 'org.springframework.boot:spring-boot-devtools'
  runtimeOnly 'com.oracle.database.jdbc:ojdbc8' // JDK 1.8 용 ojdbc
  annotationProcessor 'org.projectlombok:lombok'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
  useJUnitPlatform()
}


* application.yml (들여쓰기 제대로 안 하면 에러) .xml 보다 상위(?)의 .yml

 

server.port : 8086 # 80 과 8080 은 다른 서비스가 사용하고 있어서 8086 으로..

spring :
  datasource :
    driver-class-name : oracle.jdbc.driver.OracleDriver
    url : jdbc:oracle:thin:@아이피:포트/서비스명
    username : 아이디
    password : 비밀번호
  jpa :
    generate-ddl : 'true' # true 데이터베이스 고유 기능 사용
    database : oracle
    database-platform : org.hibernate.dialect.Oracle10gDialect
    hibernate :
      ddl-auto : update  # update 변경 부분만 반영, create 기존 테이블 삭제후 재생성, none 사용하지 않음
    show-sql : 'true'   # true 로그에 쿼리 인쇄
  mybatis :
    mapper-locations : classpath:mappers/**/*.xml
    config-location : classpath:mybatis-config.xml
    
logging :
  level :
    org : 
      hibernate : info # dd


* Board2.java

 

@Entity                         // javax.persistence.*, JPA 사용 명시. JPA 에서 DB 와 매칭
@Getter @Setter @ToString       // lombok.*. 엔터티 마다 매번 기술해야 하는 코드를 줄이는 힘
@Table(name="web_test_board")   // javax.persistence.*, 실제 DB 테이블명

//@EqualsAndHashCode(of="...") 를 반드시 기술할 필요는 없음
public class Board2{

  @Id                           // PK 세팅. javax.persistence.*. 여러 칼럼 보다는 하나의 칼럼으로 id 세팅 요망
  @SequenceGenerator(name="BOARD_SEQUENCE_GEN", // javax.persistence.*
    sequenceName="seq_web_test_board", initialValue=1, allocationSize=1) // 실제 DB 시퀀스명
  @GeneratedValue(strategy=GenerationType.SEQUENCE, // javax.persistence.*
    generator="BOARD_SEQUENCE_GEN")
  
  //@Column(name="board_no")  // javax.persistence.*. 실제 DB 칼럼명. Camel 표기방식을 따르면 생략 가능
  private Integer boardNo;    // int 나 long 등등 primitive type 불가, Integer 나 Long 은 가능

  // Long 으로 쌓이는 데이타 보다는 Integer 가 적당할듯.. 작은 데이타가 빠르다
  

  @NotBlank // javax.validation.constraints.*, .html 타임리프에 입력값에 대한 검증 메시지 표시

  @Column(length=200, nullable=false) // DB 제한 가능

  private String title;
  
  @Lob // javax.persistence.*
  private String content1;
  
  @Lob
  private String content2; // 하나의 DB Table 에 Clob 2개를 입력해 봄
  // JdbcTemplate 에서는 select 로 시퀀스 잡고, 기본 데이타 insert ~ for update 후에 update 로 clob 을 넣는데..
  // JPA 에선 repository.save() 가 DB 호출 1번에 insert 하고 insert 된 시퀀스 포함 전체 데이타를 리턴해 줌
  
  private String writer;
  
  @CreationTimestamp // insert 시 저장. org.hibernate.annotations.*. DB 에선 Date type 이어도 됨
  private LocalDateTime regDate;
  
  @UpdateTimestamp   // update 시 저장. org.hibernate.annotations.*
  private LocalDateTime updDate;
}


* CodeValueVO.java

 

@Getter // lombok.*. 생성자로 세팅하므로 @Setter 불필요
public class CodeValueVO{ // Value Object
  
  private String code;
  private String value;
  
  public CodeValueVO() {
    super();
  }
  
  public CodeValueVO(String code, String value) {
    this.code = code;
    this.value = value;
  }
}


* PaginationVO.java

 

@Getter // lombok.*
// 추출된 데이타에 대한 페이지 링크 처리
public class PaginationVO<T>{ // Value Object

  private Page<T> page; // org.springframework.data.domain.*
  private Pageable prevPageable; // org.springframework.data.domain.*
  private Pageable nextPageable;
  private Pageable currentPageable;
  private List<Pageable> pageableList; // java.util.*
  
  private int currentPageNumber;
  private int totalPageCount;
  
  public PaginationVO(Page<T> page) {
    this.page = page;
    this.currentPageable = page.getPageable();
    this.currentPageNumber = this.currentPageable.getPageNumber() +1;
    this.totalPageCount = page.getTotalPages();
    this.pageableList = new ArrayList<>();
    calcPages();
  }

  private void calcPages() {
    int endPageNumber = (int)(Math.ceil(this.currentPageNumber /10.0) *10);
    int startPageNumber = endPageNumber -9;
    Pageable startPageable = this.currentPageable;
    
    for(int i = startPageNumber; i<this.currentPageNumber; i++) {
      startPageable = startPageable.previousOrFirst();
    }
    
    this.prevPageable = startPageable.getPageNumber()<=0 ? null : startPageable.previousOrFirst();
    
    if(this.totalPageCount < endPageNumber) {
      endPageNumber = this.totalPageCount;
      this.nextPageable = null;
    }
    
    for(int i=startPageNumber; i<=endPageNumber; i++) {
      pageableList.add(startPageable);
      startPageable = startPageable.next();
    }
    
    this.nextPageable = ((startPageable.getPageNumber() + 1) < totalPageCount) ? startPageable : null;
  }
}


* Board2Repository.java (인터페이스이긴 한데, 구현하는 .java 를 만들 필요는 없음)

 

public interface Board2Repository extends JpaRepository<Board2, Integer>{ // org.springframework.data.jpa.repository.*

  // Pageable 을 사용함으로써, 페이지 내비게이션이 가능해짐
  Page<Board2> findAll(Pageable pageable); // org.springframework.data.domain.*

  // ..Containing 가 특수문자가 입력되는 경우에도, DB 쿼리 생성시 escape 키워드를 사용하면서 잘 찾아 줌
  public Page<Board2> findByTitleContaining(String title,Pageable pageable);
  public Page<Board2> findByContent1Containing(String content1,Pageable pageable);
  public Page<Board2> findByContent2Containing(String content2,Pageable pageable);
  public Page<Board2> findByWriterContaining(String writer,Pageable pageable);
  public Page<Board2> findByTitleContainingOrContent1Containing(String title, String content1,Pageable pageable);
  public Page<Board2> findByContent1ContainingOrWriterContaining(String content1, String writer,Pageable pageable);
  public Page<Board2> findByTitleContainingOrContent1ContainingOrWriterContaining(String title, String content1, String writer,Pageable pageable);
}


* PageRequestDTO.java

 

// 목록에서 페이지 내비게이션 담당
@Getter @Setter @ToString
public class PageRequestDTO{ // Data Transfer Object

  private int page;
  private int sizePerPage;
  
  private String searchType;
  private String keyword;
  
  public PageRequestDTO() {
    this.page = 1;
    this.sizePerPage = 1; // 사용자 화면에서 받아올 수도 있어서 set method 세팅. 통상 화면당 10개의 레코드 표시
  }
  
  public void setPage(int page) { // @Setter 와 중복시 사용자가 기술한 내용이 적용됨
    this.page = page<=0 ? 1 : page; // 마이너스 페이지 불허
  }
  
  public void setSizePerPage(int size) { // @Setter 와 중복시 사용자가 기술한 내용이 적용됨
    this.sizePerPage = (size<=0 || size>100) ? 10 : size; // 하나의 페이지에 마이너스 라인이나 100 라인 이상 불허
  }
  
  public int getPageStart() {
    return (this.page -1) * this.sizePerPage;
  }
}


* Board2Controller.java

 

@Controller                     // org.springframework.stereotype.*, 애너테이션이 있어야 컨트롤 타워가 됨
@RequestMapping("/board2")      // org.springframework.web.bind.annotation.*. 기본 상위 경로
@RequiredArgsConstructor        // lombok.*. final 멤버변수 인젝션용
@Slf4j

public class Board2Controller{

  private final Board2Service service; // 비즈니스 로직 처리용
  private final List<CodeValueVO> searchTypes = new ArrayList<CodeValueVO>(); list() 에서 매번 세팅하지 않고 한 번 만 적용

  // 신규 등록한 다음엔 첫 페이지로 가기 때문에 @ModelAttribute("pr") 설정하지 않음

  // Model 에 담으면 View 에서 Thymeleaf 가 활용
  @GetMapping("/register") // class 에 기술된 애너테이션과 합쳐져 '/board2/register' 경로가 됨
  public void registerForm(Model model) throws Exception{
    model.addAttribute(new Board2()); // TemplateInputException 방지. 타임리프 form 문제 id 를 board2 로 세팅하기 때문
  }

  // 목록과의 검색조건과 페이지 연계를 위해 @ModelAttribute 이용 (목록 -> 상세 -> 수정 -> (DB) -> 목록)

  // @ModelAttribute() 로 form 데이타를 받아서 Control 에서 사용하고 (Model 에 다시 담지 않고) View 에 전달

  //    form 태그의 id 속성과 동일하면(여기선 'pr') 상하관계를 따져서 세팅하고,

  //    상이하면(pr 이 아닌 'board2' 같은..) input 등등의 name 기준으로 파라미터를 담아준다
  @GetMapping("/modify")
  public void modifyForm(Integer boardNo, @ModelAttribute("pr") PageRequestDTO pr, Model model) throws Exception{
    model.addAttribute(service.read(boardNo)); // 수정할 상세 정보 추출
  }

  // 목록과의 연계를 위해 @ModelAttribute 이용 (목록 -> 목록)
  // @ModelAttribute("pr") PageRequestDTO pr 이 아니라 PageRequestDTO pr 였으면 pr 에 해당하는 object 가 없을 때 에러.

  // @ModelAttribute("pr") 는 list() 매핑시 request 에서 PageRequestDTO 에 해당하는 값을 선별해서 담아주고 view 에서도 그대로 사용
  @GetMapping("/list")
  public void list(@ModelAttribute("pr") PageRequestDTO pr, Model model) throws Exception{
    model.addAttribute("board2", new Board2());// 목록에선 체크된 게시글이 없으므로 빈 내용으로 생성. 상세 조회시 사용하므로 추가
    Page<Board2> pg = service.list(pr); // {"page":6,"sizePerPage":1,"pageStart":5}
    model.addAttribute("pgn", new PaginationVO(pg));

    if(searchTypes.size()==0) { // 데이타가 없는 경우 1회만 설정. 목록 검색 조건 기본값 설정
      searchTypes.add(new CodeValueVO("","선택해 주세요"));
      searchTypes.add(new CodeValueVO("t","제목"));
      searchTypes.add(new CodeValueVO("c1","내용1")); // Lob 이 여럿일 때 어떻게 작용하는지 확인하기 위해 1과 2로 구분
      searchTypes.add(new CodeValueVO("w","저자"));
      searchTypes.add(new CodeValueVO("tc1","제목 OR 내용1")); // 많이 사용
      searchTypes.add(new CodeValueVO("c1w","내용1 OR 저자"));
      searchTypes.add(new CodeValueVO("tcw","제목 OR 내용1 OR 저자"));
    }

    model.addAttribute("searchTypes",searchTypes);
  }

  // 목록과의 연계를 위해 @ModelAttribute 이용 (목록 -> 상세 -> 목록)
  // View 에 데이타 전달하기 위해 Model 이용
  @GetMapping("/read")
  public void read(Integer boardNo, @ModelAttribute("pr") PageRequestDTO pr, Model model) throws Exception{
    model.addAttribute(service.read(boardNo));
  }

  // 등록한 다음엔 첫 페이지로 가기 때문에 @ModelAttribute("pr") 설정하지 않음 (필요시 세팅 가능)
  // redirect return 하기 때문에 Model 대신 RedirectAttributes 이용

  // @Validated, org.springframework.validation.annotation.*

  // BindingResult, org.springframework.validation.*

  // RedirectAttributes, org.springframework.web.servlet.mvc.support.*
  @PostMapping("/register")
  public String register(@Validated Board2 board, BindingResult br, RedirectAttributes ra) throws Exception{

    if(br.hasErrors()) return "board2/register" ; // @Entity @NotNull @Column 등으로 설정한 오류시 get register 로 이동하여 메시징
    Board2 result= service.register(board); // 등록 성공시 true. regDate 세팅하지 않아도 @CreateTimestamp 에 의해 자동 설정

    // DB 서버와 웹서버의 시차가 있는 경우, 간혹 웹서버의 시각을 세팅하기도 하는데..

    // 가급적이면 두 서버의 시간은 동기화 하고, DB 서버 중심으로 돌아가는 것이 좋다

    // 여러 테이블을 다루는 경우 각 테이블에 동일 시각을 넣기 위해서는 작업 개시 시각을 select 한 다음 재사용

    // addFlashAttribute() 로 세팅하면, 해당 화면을 새로고침 했을 때 다음 화면에선 값이 소멸되고 없음. 1회용 메시징
    ra.addFlashAttribute("msg", result.getBoardNo()!=null ? "등록되었습니다." : "등록되지 않았습니다.");
    return "redirect:/board2/list"; // .html 에서와 달리 절대경로 사용.

    // 작업 성공 url 을 한번 더 보여주고 사용자가 어디로 이동할 지 구성하는 화면도 가능하긴 하다

    // 이런 경우 RedirectAttributes 가 아니라 Model 을 사용하면 된다

    // Model 사용시 return "board2/list" 와 같이 기술. '/board2/list' 라 해도 되긴 하지만 주소창에 '//board2/list' 라고 표시된다. ㅠ.
  }

  // 목록과의 연계를 위해 @ModelAttribute 이용 (목록 -> 상세 -> 수정 -> (DB) -> 목록)
  // redirect return 하기 때문에 Model 대신 RedirectAttributes 이용
  @PostMapping("/modify")
  public String modify(@Validated Board2 board, @ModelAttribute("pr") PageRequestDTO pr, BindingResult br, RedirectAttributes ra) throws Exception{
    if(br.hasErrors()) return "board2/modify" ; // @Entity @NotNull @Column 등으로 설정한 오류시 get modify 로 이동하여 메시징
    boolean result = service.modify(board); // 수정 성공시 true
    ra.addAttribute("page", pr.getPage()); // redirect 하기 때문에 @ModelAttribute() 로 전달받았어도 RedirectAttributes() 에 담는다
    ra.addAttribute("sizePerPage", pr.getSizePerPage());
    ra.addAttribute("searchType", pr.getSearchType());
    ra.addAttribute("keyword", pr.getKeyword());
    ra.addFlashAttribute("msg", result ? "수정되었습니다. " : "수정되지 않았습니다. ");
    return "redirect:/board2/list"; // 수정 후 조회 화면 또는 수정 화면으로 갈 수도 있지만 여기선 목록 화면으로 이동
  }

  // 목록과의 연계를 위해 @ModelAttribute 이용 (목록 -> 상세 -> 삭제(DB) -> 목록)
  // redirect return 하기 때문에 Model 대신 RedirectAttributes 이용
  @PostMapping("/remove")
  public String remove(Integer boardNo, @ModelAttribute("pr") PageRequestDTO pr, RedirectAttributes ra) throws Exception{
    boolean result = service.remove(boardNo); // 삭제 성공시 true
    ra.addAttribute("page", 1); // 삭제한 다음에는 첫 페이지로 이동
    ra.addAttribute("sizePerPage", pr.getSizePerPage()); // redirect 하기 때문에 @ModelAttribute() 로 전달받았어도

                                                                                        // RedirectAttributes() 에 담는다
    ra.addAttribute("searchType", pr.getSearchType());
    ra.addAttribute("keyword", pr.getKeyword());
    ra.addFlashAttribute("msg", result ? "삭제되었습니다. " : "삭제되지 않았습니다. ");
    return "redirect:/board2/list";
  }
}


* Board2Service.java

 

public interface Board2Service{
  public Board2 register(Board2 board) throws Exception;
  public boolean modify(Board2 board) throws Exception;
  public Board2 read(Integer boardNo) throws Exception; // 파라미터를 Board2 board 로 해도 되지만, 가벼울수록 좋다
  public boolean remove(Integer boardNo) throws Exception; // 파라미터를 Board2 board 로 해도 되지만, 가벼울수록 좋다
  public Page<Board2> list(PageRequestDTO pr) throws Exception;
}


* Board2ServiceImpl.java

 

@Service // org.springframework.stereotype.*
@RequiredArgsConstructor // lombok.*
@Slf4j // lombok.extern.slf4j.*
public class Board2ServiceImpl implements Board2Service{

  private final Board2Repository repository; // @RequiredArgsConstructor 가 interface 에 자동 주입


  @Override // java.lang.*
  public Board2 register(Board2 board) throws Exception{
    return repository.save(board); // DB 저장. select 1회 insert(또는 update) 1회, DB 호출
  }

  @Override
  public boolean modify(Board2 board){
    boolean result = false;
    try {
      Board2 bd2 = this.read(board.getBoardNo()); // select 1회, DB 호출
      if(bd2.getRegDate()==null) return result;   // 수정 대상이 없음
      bd2.setTitle(board.getTitle());             // 전달받은 데이타를 DB 데이타에 옮겨 담음
      bd2.setContent1(board.getContent1());
      bd2.setContent2(board.getContent2());
      bd2.setWriter(board.getWriter());
      repository.save(bd2);                       // DB 저장. 에러시 예외처리. select 1회 update 1회, DB 호출
      result  = true;
    }catch(Exception e) {
      String  msg = e.getMessage();
      if(msg.indexOf("EntityNotFoundException")>-1) ; // 수정 대상 없음
      else log.info("\n-.Board2ServiceImpl.modify.Exception: "+msg); // 추가적인 예외 발생하는지 체크
    }finally {
    }
    return result;
  }

  @Override
  public Board2 read(Integer boardNo) throws Exception{
    Board2 result = new Board2();                     // 조회 내역이 없어도 화면에 표시하려고 생성자 할당
    if(boardNo==null) return result;
    result  = repository.getReferenceById(boardNo); // getOne() 은 Deprecated. select 1회, DB 호출
    return result;
  }

  @Override
  public boolean remove(Integer boardNo){
    boolean result = false;
    try {
      repository.deleteById(boardNo);                         // return 없음. Exception 으로 처리, select 1회 delete 1회, DB 호출
      result  = true; // deleteById() 실행시 예외발생하면 result = true; 실행되지 않으므로 return false 됨. 그래서 try 문 사용
    }catch(Exception e) {
      String  msg = e.getMessage();
      if(msg.indexOf("EmptyResultDataAccessException")>-1) ;  // 삭제 대상 없음
      else log.info("\n-.Board2ServiceImpl.remove.Exception: "+msg); // 추가적인 예외 발생하는지 체크
    }finally {
    }
    return result;
  }

  @Override
  public Page<Board2> list(PageRequestDTO pr) throws Exception{
    int     pn  = pr.getPage() -1;      // 사용자 화면에선 1페이지, java 에선 0번째
    int     spp = pr.getSizePerPage();  // 페이지 당 레코드수. 한 화면에 보여지는 라인수

                                                         // 대체로 화면당 10 건 표시
    String  st  = pr.getSearchType();   // 검색 유형
    String  kw  = pr.getKeyword();      // 검색 단어

    Pageable p = PageRequest.of(pn,  spp, Sort.Direction.DESC, "boardNo");
    Page<Board2> pg = null;

    // 검색 유형에 맞게 처리
    if(st==null || kw==null || st.length()==0 || kw.length()==0) pg = repository.findAll(p);

    else {
      switch(st) { // String 도 switch 가능, 현재 JDK1.8 이용 중
        case "n"    : pg = repository.findAll(p); break;
        case "t"    : pg = repository.findByTitleContaining(kw, p); break;
        case "c1"   : pg = repository.findByContent1Containing(kw,p); break;
        case "w"    : pg = repository.findByWriterContaining(kw,p); break;
        case "tc1"  : pg = repository.findByTitleContainingOrContent1Containing(kw,kw,p); break;
        case "c1w"  : pg = repository.findByContent1ContainingOrWriterContaining(kw,kw,p); break;
        case "tcw"  : pg = repository.findByTitleContainingOrContent1ContainingOrWriterContaining(kw,kw,kw,p); break;
      }
    }
    return pg;
  }
}


* default.html 기본 레이어

 

<!doctype html>
<html xmlns:th="http://www.thymeleaf.org"
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8" /><!--/* 요즘 대세 UTF-8 */-->
<link rel="stylesheet" type="text/css" href="/css/default.css"/><!-/* /static/ 경로에 위치 *-->
<script src="/js/jquery-1.10.2.min.js"></script><!-/* /static/ 경로에 위치 *-->
<script src="/js/default.js"></script><!-/* /static/ 경로에 위치 *-->
</head>
<body>
  <div class="header">페이지 헤더</div><!-/* header 를 파편 처리하기도 한다 *-->
  <div layout:fragment="content" class="content"></div><!-/* default.html 레이어의 파편 *-->
  <div class="footer">페이지 푸터</div><!-/* footer 를 파편 처리하기도 한다 *-->
</body>
</html>


* default.css

 

.header, .footer {width:100%; padding:10px; background:#ddd}
.header {margin-bottom:20px}
.footer {margin-top:20px}
div.content {width:700px; margin:0 auto}

.btn {padding:10px}
.btn a {cursor:pointer; border:1px #ccc solid;border-radius:5px; padding:5px 10px; text-decoration:none}

table.df {width:600px; border-spacing:0}
table.df th, table.df td {text-align:center; padding:5px; border:1px #ccc solid}
table.df th.l, table.df td.l {text-align:left}
table.df th.r, table.df td.r {text-align:right}

div.pgn {width:600px; margin:0 auto; text-align:center}


* default.js

 

function act(method,action){
  frmObj // 화면용 .html 에서 정의. 타임리프의 주석처리 방식이 적용되지 않음
  .attr('method',method)
  .attr('action',action)
  .submit();
}

function goRead(bn){
  $('#boardNo').val(bn);
  act('get','read');  // read 는 get
}

function goList(pn){
  $('#page').val(pn);
  act('get','list') // list 는 get
}

function validForm1(){ // 목록 검색시 필수요소 점검
  let $kw = $('#keyword');
  if($('#searchType option:selected').val()==''){ alert('검색 유형을 선택해 주세요. '); return false; }// 검색 유형을 선택하지 않았으면, 검색어 리셋
  else if($kw.val()==''){ alert('검색 단어를 입력해 주세요. '); return false; }
  return true;
}

function validForm2(){ // 등록/수정시 필수요소 점검
  if($('#title').val()==''){ alert('제목을 입력해 주세요. '); return false; }
  if($('#content1').val()==''){ alert('내용1 을 입력해 주세요. '); return false; }
  if($('#content2').val()==''){ alert('내용2 를 입력해 주세요. '); return false; }
  if($('#writer').val()==''){ alert('작가를 입력해 주세요. '); return false; }
  return true;
}


* list.html

 

<html xmlns:th="http://www.thymeleaf.org"
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorate="~{/layouts/default.html}">
<head>
<title>[JPA] Board, List</title>
</head>
<body>
<div layout:fragment="content"><!--/*  default.html 의 <div layout:fragment="content"></div> 대체 */-->

 

<!--/* form 태그의 method 와 action 속성은 자바스크립트에서 처리하므로 생략 */-->
<form id="pr" th:object="${pr}"><!--/* @ModelAttribute("pr") PageRequestDTO 에 담는다 */-->
<input type="hidden" th:field="${board2.boardNo}">
<input type="hidden" th:field="*{page}">
<input type="hidden" th:field="*{sizePerPage}">

<h2>List</h2>

<div class="btn">
  <select th:field="*{searchType}"><!--/* *{searchType} 과 ${e.code} 가 동일할 때 해당 option selected */-->
    <option th:each="e : ${searchTypes}" th:value="${e.code}" th:text="${e.value}"></option>
  </select>
  <input type="text" th:field="*{keyword}">
  <a id="btnList">검색</a>
  <a th:href="@{register}">등록</a>
</div>

<table class="df" th:with="list = ${pgn.page.content}">
  <col width="80">
  <col width="320">
  <col width="100">
  <col width="200">
  <tr>
    <th>No</th>
    <th>Title</th>
    <th>Writer</th>
    <th>RegDate</th>
  </tr>

  <tr th:if="${#lists.isEmpty(list)}">
    <td colspan="4">List is empty</td>
  </tr>

  <tr th:each="board : ${list}">
    <td th:text="${board.boardNo}"></td>
    <td><a th:href="|javascript:goRead(${board.boardNo})|" th:text="${board.title}"></a></td>
    <td th:text="${board.writer}"></td>
    <td th:text="${#temporals.format(board.regDate,'yyyy-MM-dd HH:mm:ss')}"></td>
  </tr>

</table>

<div class="pgn">
  <th:block th:if="${pgn.prevPageable}" th:with="prevNum = ${pgn.prevPageable.pageNumber} +1">
    <a th:href="|javascript:goList(${prevNum})|">Prev [[${prevNum}]]</a>
  </th:block><!--/* 이전 */-->

  <th:block th:each="p : ${pgn.pageableList}" th:with="pNum = ${p.pageNumber} +1">
    <a th:href="|javascript:goList(${pNum})|">[[${pNum}]]</a>
  </th:block><!--/* 페이지 번호 목록 */-->
  <th:block th:if="${pgn.nextPageable}" th:with="nextNum = ${pgn.nextPageable.pageNumber} +1">
    <a th:href="|javascript:goList(${nextNum})|">Next [[${nextNum}]]</a>
  </th:block><!--/*이후 */-->
</div>

</form>
<script th:inline="javascript">
  var frmObj;
  $(document).ready(function(){
    frmObj = $('#pr');// 여기서 사용된 form 태그. 목록 검색용과 상세 조회용 별도로 구현할 수 있지만 여기서는 하나로..
    $('#btnList').on('click',function(){
      if(!validForm1()) return;// 검색 버튼을 누를 때만 검색 유형과 검색 단어를 점검
      goList(1); // 검색 버튼 누르면 항상 첫 페이지 선택
    });
  });

  $(window).load(function(){// 목록 화면이 표시되고 alert 되기를 바랐건만, 흰 바탕에 alert 된 다음 view 가 표시됨
    let msg = [[${msg}]]; if(msg) alert(msg);
  });
</script>

</div>
</body>
</html>


* read.html

 

<html xmlns:th="http://www.thymeleaf.org"
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorate="~{/layouts/default.html}">
<head>
<title>[JPA] Board, Read</title>
</head>
<body>
<div layout:fragment="content"><!--/*  default.html 의 <div layout:fragment="content"></div> 대체 */-->

<!--/* form 태그의 method 와 action 속성은 자바스크립트에서 처리하므로 생략 */-->

<form id="board2" th:object="${board2}"><!--/* @ModelAttribute("pr") PageRequestDTO 에는 form control 중 name 기준으로 담는다 */-->
<input type="hidden" th:field="*{boardNo}"><!--/* Board2Controller.read() 의 파라미터의 boardNo 가 아니라, model.addAttribute() 에 담긴 service.read(boardNo) 결과값의 boardNo */-->
<input type="hidden" th:field="${pr.page}">
<input type="hidden" th:field="${pr.sizePerPage}">
<input type="hidden" th:field="${pr.searchType}">
<input type="hidden" th:field="${pr.keyword}">

<h2>Read</h2>

<table class="df">
  <col width="80">
  <col width="320">
  <tr>
    <th class="r">BoardNo</th>
    <td class="l" th:text="*{boardNo}"></td>
  </tr>
  <tr>
    <th class="r">Title</th>
    <td class="l" th:text="*{title}"></td>
  </tr>
  <tr>
    <th class="r">content1</th>
    <td class="l" th:text="*{content1}"></td>
  </tr>
  <tr>
    <th class="r">content2</th>
    <td class="l" th:text="*{content2}"></td>
  </tr>
  <tr>
    <th class="r">writer</th>
    <td class="l" th:text="*{writer}"></td>
  </tr>
</table>

<div class="btn">
  <a id="btnModify">수정</a>
  <a id="btnRemove">삭제</a>
  <a id="btnList">목록</a>
</div>

</form>
<script>
  var frmObj;
  $(document).ready(function(){
    frmObj = $('#board2');
    $('#btnModify').on('click',function(){
      act('get','modify');  // 수정 양식으로 이동
    });
    $('#btnRemove').on('click',function(){
      act('post','remove'); // remove 는 post
    });
    $('#btnList').on('click',function(){
      act('get','list') // list 는 get. 페이지나 목록 검색 조건 전달
    });
  });
</script>

</div>
</body>
</html>


* modify.html

 

<html xmlns:th="http://www.thymeleaf.org"
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorate="~{/layouts/default.html}">
<head>
<title>[JPA] Board, Modify</title>
</head>
<body>
<div layout:fragment="content"><!--/*  default.html 의 <div layout:fragment="content"></div> 대체 */-->

<!--/* form 태그의 method 와 action 속성은 자바스크립트에서 처리하므로 생략 */-->

<form id="pr" th:object="${pr}"><!--/* 목록으로 돌아갈 때 수정화면에 입력된 내용이 섞이지 않도록 form 을 2중화 */-->
<input type="hidden" th:field="${board2.boardNo}"><!-- Board2Controller.read() 의 파라미터의 boardNo 가 아니라, model.addAttribute() 에 담긴 service.read(boardNo) 결과값의 boardNo -->
<input type="hidden" th:field="*{page}">
<input type="hidden" th:field="*{sizePerPage}">
<input type="hidden" th:field="*{searchType}">
<input type="hidden" th:field="*{keyword}">
</form>

<form id="board2" th:object="${board2}"><!--/* DB 처리하고 RedirectAttribute 에 재설정하면서 사용자 입력 정보 리셋 */-->
<input type="hidden" th:field="*{boardNo}">
<input type="hidden" th:field="${pr.page}">
<input type="hidden" th:field="${pr.sizePerPage}">
<input type="hidden" th:field="${pr.searchType}">
<input type="hidden" th:field="${pr.keyword}">

<h2>Read</h2>

<table class="df">
  <col width="80">
  <col width="320">
  <tr>
    <th class="r">BoardNo</th>
    <td class="l" th:text="*{boardNo}"></td>
  </tr>
  <tr>
    <th class="r">Title</th>
    <td class="l">
      <input type="text" th:field="*{title}">
      <p th:if="${#fields.hasErrors('title')}" th:errors="*{title}"></p><!--/* BindingResult 로 세팅된 오류 메시징 */-->
    </td>
  </tr>
  <tr>
    <th class="r">content1</th>
    <td class="l">
      <textarea th:field="*{content1}"></textarea>
      <p th:if="${#fields.hasErrors('content1')}" th:errors="*{content1}"></p><!--/* BindingResult 로 세팅된 오류 메시징 */-->
    </td>
  </tr>
  <tr>
    <th class="r">content2</th>
    <td class="l">
      <textarea th:field="*{content2}"></textarea>
      <p th:if="${#fields.hasErrors('content2')}" th:errors="*{content2}"></p><!--/* BindingResult 로 세팅된 오류 메시징 */-->
    </td>
  </tr>
  <tr>
    <th class="r">writer</th>
    <td class="l">
      <input type="text" th:field="*{writer}">
      <p th:if="${#fields.hasErrors('writer')}" th:errors="*{writer}"></p><!--/* BindingResult 로 세팅된 오류 메시징 */-->
    </td>
  </tr>
</table>

<div class="btn">
  <a id="btnModify">저장</a>
  <a id="btnList">목록</a>
</div>

</form>
<script>
  var frmObj;
  $(document).ready(function(){
    $('#btnModify').on('click',function(){
      frmObj = $('#board2');
      if(!validForm2()) return;
      act('post','modify')
    });
    $('#btnList').on('click',function(){
      frmObj = $('#pr');
      act('get','list');// act('get','list') 로 하면 input textarea 값들이 웹브라우저 주소창에 뜨지만, 여기에서는 페이지나 목록 검색 조건 고려. top.location.href= '/board2/list' 또는 'list'
    });
  });
</script>

</div>
</body>
</html>


* register.html

 

<html xmlns:th="http://www.thymeleaf.org"
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorate="~{/layouts/default.html}">
<head>
<title>[JPA] Board, Register</title>
</head>
<body>
<div layout:fragment="content"><!--/*  default.html 의 <div layout:fragment="content"></div> 대체 */-->

<!--/* form 태그의 method 와 action 속성은 자바스크립트에서 처리하므로 생략 */-->

<form id="board2" th:object="${board2}"><!--/* @ModelAttribute("pr") PageRequestDTO 에 담는다 */-->

<h2>Register</h2>

<table>
  <tr>
    <th>Title</th>
    <td>
      <input type="text" th:field="*{title}">
      <p th:if="${#fields.hasErrors('title')}" th:errors="*{title}"></p><!--/* BindingResult 로 세팅된 오류 메시징 */-->
    </td>
  </tr>
  <tr>
    <th>content1</th>
    <td>
      <textarea th:field="*{content1}"></textarea>
      <p th:if="${#fields.hasErrors('content1')}" th:errors="*{content1}"></p><!--/* BindingResult 로 세팅된 오류 메시징 */-->
    </td>
  </tr>
  <tr>
    <th>content2</th>
    <td>
      <textarea th:field="*{content2}"></textarea>
      <p th:if="${#fields.hasErrors('content2')}" th:errors="*{content2}"></p><!--/* BindingResult 로 세팅된 오류 메시징 */-->
    </td>
  </tr>
  <tr>
    <th>writer</th>
    <td>
      <input type="text" th:field="*{writer}">
      <p th:if="${#fields.hasErrors('writer')}" th:errors="*{writer}"></p><!--/* BindingResult 로 세팅된 오류 메시징 */-->
    </td>
  </tr>
</table>

<div class="btn">
  <a id="btnRegister">저장</a>
  <a id="btnList">목록</a>
</div>

</form>

<script>
  var frmObj;
  $(document).ready(function(){
    frmObj = $('#board2');
    $('#btnRegister').on('click',function(){
      if(!validForm2()) return;
      act('post','register');// '/board2/register' 라고 해도 된다, 현재의 경로가 '/board2/' 이기 때문
    });
    $('#btnList').on('click',function(){
      top.location.href = 'list';// act('get','list') 로 하면 input textarea 값들이 웹브라우저 주소창에 뜬다

      // top.location.href= '/board2/list' 라고 해도 된다.

      // self.location 대신에 top 을 쓰는 이유는, 간혹 iframe 과 연계된 무언가의 영향을 받지 않기 위해..
    });
  });
  </script>

</div>
</body>
</html>