📃 요약
네이버 카페 등의 게시판을 기본 기능을 제작하는 예제입니다.
기본적인 DB CRUD 를 사용해 제작합니다.
DB 프레임워크는 JPA 를 사용해 자동화기능을 강화합니다.
요소 기술 및 테이블 설계는 아래와 같습니다.
요소 기술 :
– 프론트엔드 : JSP
– 벡엔드 : 스프링부트 & JPA & Oracle 18xe(Oracle Cloud 19c)
결과 화면 :
프로젝트 탐색기 :
함수 URL :
메소드 | URL | 설명 |
---|---|---|
GET | dept | 전체 조회 |
GET | dept/{dno} | 상세조회 |
GET | /dept/addition | 추가페이지 열기 |
POST | /dept/add | 저장 |
GET | /dept/edition/{dno} | 수정페이지 열기(상세조회) |
PUT | /dept/edit/{dno} | 수정 |
DELETE | /dept/delete/{dno} | 삭제 |
📃 기술 구현
스펙 :
- jdk 17 - spring boot 3.x - gradle
테이블 설계
-- 부서 게시판 DROP SEQUENCE SQ_DEPT; CREATE SEQUENCE SQ_DEPT START WITH 50 INCREMENT BY 10; CREATE TABLE TB_DEPT ( DNO NUMBER NOT NULL PRIMARY KEY, DNAME VARCHAR2(255), LOC VARCHAR2(255), INSERT_TIME VARCHAR2(255), UPDATE_TIME VARCHAR2(255) );
부서 게시판 구현을 위한 테이블 설계입니다.
데이터베이스를 오라클을 사용하여 구현해 보겠습니다.
모델 : 엔티티
공통 모델
package com.example.jpaexam.model.common; import lombok.Getter; import org.springframework.data.jpa.domain.support.AuditingEntityListener; import javax.persistence.EntityListeners; import javax.persistence.MappedSuperclass; import javax.persistence.PrePersist; import javax.persistence.PreUpdate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; /** * packageName : com.example.jpaexam.model * fileName : BaseTimeEntity * author : GGG * date : 2023-10-16 * description : JPA 에서 자동으로 생성일자/수정일자를 만들어 주는 클래스 * 요약 : * <p> * =========================================================== * DATE AUTHOR NOTE * ————————————————————————————— * 2023-10-16 GGG 최초 생성 */ @Getter // todo: 자동으로 생성일자/수정일자 컬럼을 sql 문에 추가시키는 어노테이션 2개 @MappedSuperclass @EntityListeners(AuditingEntityListener.class) public abstract class BaseTimeEntity { private String insertTime; private String updateTime; }
부서 게시판 : 엔티티
package com.example.jpaexam.model.entity.basic; import com.example.jpaexam.model.common.BaseTimeEntity; import lombok.*; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; import javax.persistence.*; /** * packageName : com.example.jpaexam.model * fileName : Dept * author : GGG * date : 2023-10-16 * description : 부서 모델 클래스 ( 엔티티(entity) ) * 요약 : * <p> * =========================================================== * DATE AUTHOR NOTE * ————————————————————————————— * 2023-10-16 GGG 최초 생성 */ // todo: @Entity - JPA 기능을 클래스에 부여하는 어노테이션 @Entity // todo: @Table(name = "생성될테이블명") @Table(name = "TB_DEPT") // todo 사용법 : @SequenceGenerator( // name = "시퀀스함수이름" // , sequenceName = "DB에생성된시퀀스이름" // , initialValue = 시작값 // , allocationSize = jpa에서관리용숫자(성능지표) //) @SequenceGenerator( name = "SQ_DEPT_GENERATOR" , sequenceName = "SQ_DEPT" , initialValue = 1 , allocationSize = 1 ) @Getter @Setter @ToString @NoArgsConstructor @AllArgsConstructor // todo: jpa 어노테이션 sql 자동 생성시 null 값 컬럼은 제외하고 생성 // 예) insert into 테이블명(컬럼1, 컬럼2, 컬럼3) values(1, 2, null); // => insert into 테이블명(컬럼1, 컬럼2) values(1, 2); @DynamicInsert @DynamicUpdate public class Dept extends BaseTimeEntity { @Id // todo: @GeneratedValue(strategy = GenerationType.SEQUENCE // , generator = "시퀀스함수이름" // ) @GeneratedValue(strategy = GenerationType.SEQUENCE , generator = "SQ_DEPT_GENERATOR" ) private Integer dno; // 부서번호(기본키) - 시퀀스 기능 부여 private String dname; // 부서명 private String loc; // 부서위치 }
JPA 어노테이션 :
(1) 클래스에 붙임
1) @Entity : JPA 프레임워크 사용시 기능을 대상 클래스 부여하는 어노테이션 2) @Table(name = "테이블명") : JPA ddl(테이블,인덱스등생성) 생성기능 사용시 그 이름으로 DB 생성해주는 어노테이션 3) @SequenceGenerator( name = "시퀀스_제너레이터이름" , sequenceName = "DB시퀀스명" , initialValue = 초기값 ( 시퀀스 처음값 ) , allocationSize = 할당값 (JPA 공간에서 생성될 값: 보통 1 사용) : 오라클 DB 제품의 시퀀스를 JPA 에서 사용하기 위한 어노테이션, 클래스 위에 붙임 4) @DynamicInsert(옵션) : 옵션 기능임, JPA 에서 insert SQL 자동 생성시 null 값 들어오는 컬럼은 제외하고 sql 작성해주는 어노테이션 예) insert into dept(dno, dname, loc) values(1,2, null); => insert into dept(dno, dname) values(1,2); // 이렇게 바뀜(null 에러 방지) 5) @DynamicUpdate(옵션) : 옵션 기능임, JPA 에서 update SQL 자동 생성시 null 값 들어오는 컬럼은 제외하고 sql 작성해주는 어노테이션 6) 기타 : 롬북 어노테이션 상황에따라 추가 (setter/getter 등) ``` (2) 속성(필드)에 붙임 ``` 1) @Id : 속성(필드) 기본키를 정의하는 어노테이션 (필수) 2) @GeneratedValue(strategy = GenerationType.SEQUENCE , generator = "시퀀스_제너레이터이름" : 시퀀스를 어느 속성(필드)에 연결할 것인가를 지시하는 어노테이션 보통 기본키 컬럼(속성)에 사용함 3) @Column(columnDefinition = "DB컬럼자료형") : 생략가능, JPA 의 ddl(테이블,인덱스등) 생성기능을 사용한다면 DB 테이블을 만들때 지시된 자료형으로 생성함
부서 레포지토리
package com.example.jpaexam.repository.basic; import com.example.jpaexam.model.entity.advanced.FileDb; import com.example.jpaexam.model.entity.basic.Dept; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; /** * packageName : com.example.jpaexam.repository * fileName : DeptRepository * author : GGG * date : 2023-10-16 * description : JPA 레포지토리 인터페이스 ( DB 접속 함수들(CRUD) 있음) * == DAO 비슷함 * 요약 : * <p> * =========================================================== * DATE AUTHOR NOTE * ————————————————————————————— * 2023-10-16 GGG 최초 생성 */ // todo: @Repository - 클래스 위에 붙이고, 스프링서버가 실행될때 자동으로 // 객체 1개를 만들어줌 ( IOC ) // 사용법 : 인터페이스명 extends JpaRepository<모델클래스명, 기본키의자료형> @Repository public interface DeptRepository extends JpaRepository<Dept, Integer> { // dname like 검색 // Page<Dept> findAllByDnameContaining(String dname, Pageable pageable); @Query(value = "SELECT TD.* FROM TB_DEPT TD " + "WHERE TD.DNAME LIKE '%' || :dname || '%' " , countQuery = "SELECT COUNT(*) FROM TB_DEPT D " + "WHERE D.DNAME LIKE '%' || :dname || '%'" , nativeQuery = true) Page<Dept> findAllByDnameContaining(@Param("dname") String dname, Pageable pageable); }
Repository 인터페이스 : DB CRUD 함수가 있는 인터페이스
사용법 : interface 이름 extends JpaRepository<엔티티명,기본키속성자료형> => 엔티티명 : DB 와 연결될 엔티티 클래스명 => 기본키속성자료형 : 엔티티 클래스의 기본키 속성 자료형 명시 => JPA 기본 함수 사용 가능 - findAll() : 전체 조회 , 자동 sql 문 생성 - findById(기본키) : 상세 조회(1건), 자동 sql 문 생성 - save(객체) : 저장/수정을 알아서 실행함 저장 : 기본키가 없으면 insert 수정 : 기본키가 있으면 update - deleteById(기본키): 삭제 , 자동 sql 문 생성
@Query : 직접 sql 문 작성 기능, 오라클 기반 쿼리(nativeQuery = true)
검색어를 넣어 like 검색을 할수 있게 하는 함수로 sql 문을 직접 작성가능
페이징 처리를 위해 countQuery 가 필요함 : 조건에 따른 결과 개수세기
@Query 사용법 : @Query(value = "SELECT 컬럼명 FROM 테이블명 별명 " + "WHERE 별명.컬럼명 LIKE '%' || :변수명 || '%' " , countQuery = "SELECT COUNT(*) FROM 테이블명 별명 " + "WHERE 별명.컬럼명 LIKE '%' || :변수명 || '%'" , nativeQuery = true) Page<자료형> 함수명(@Param("변수명") 자료형 변수명, Pageable pageable);
부서 게시판 서비스
package com.example.jpaexam.service.basic; import com.example.jpaexam.model.entity.advanced.FileDb; import com.example.jpaexam.model.entity.basic.Dept; import com.example.jpaexam.repository.basic.DeptRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import java.util.List; import java.util.Optional; /** * packageName : com.example.jpaexam.service * fileName : DeptService * author : GGG * date : 2023-10-16 * description : 부서 업무 서비스 * 요약 : * <p> * =========================================================== * DATE AUTHOR NOTE * ————————————————————————————— * 2023-10-16 GGG 최초 생성 */ @Service public class DeptService { @Autowired DeptRepository deptRepository; // DI 객체 가져오기 /** 전체조회 */ public List<Dept> findAll() { List<Dept> list = deptRepository.findAll(); // db 전체조회 함수 호출 return list; } /** 전체조회 : 페이징 */ public Page<Dept> findAll(Pageable pageable) { Page<Dept> list = deptRepository.findAll(pageable); // db 전체조회 함수 호출 return list; } public Page<Dept> findAllByDnameContaining(String dname, Pageable pageable) { Page<Dept> page = deptRepository.findAllByDnameContaining(dname, pageable); return page; } /** 상세조회(1건조회) */ public Optional<Dept> findById(int dno) { Optional<Dept> optionalDept = deptRepository.findById(dno); return optionalDept; } /** 저장(수정)함수 */ public Dept save(Dept dept) { // todo: jpa 저장함수 호출 ( 기본키 없으면 insert, 있으면 update ) Dept dept2 = deptRepository.save(dept); return dept2; // 저장된 부서객체 } /** 삭제함수 */ public boolean removeById(int dno) { // existsById : jpa 함수 - 리턴값: 있으면 true, 없으면 false if(deptRepository.existsById(dno)) { deptRepository.deleteById(dno); // DB 삭제(dno) return true; } return false; } }
부서 게시판 컨트롤러
package com.example.jpaexam.controller.basic; import com.example.jpaexam.model.entity.basic.Dept; import com.example.jpaexam.service.basic.DeptService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.view.RedirectView; import java.util.Optional; /** * packageName : com.example.mybatisexam.controller.exam01 * fileName : DeptController * author : GGG * date : 2023-10-12 * description : 부서 컨트롤러 : jsp 연동 : @Controller * 요약 : * <p> * =========================================================== * DATE AUTHOR NOTE * ————————————————————————————— * 2023-10-12 GGG 최초 생성 */ @Slf4j @Controller @RequestMapping("/basic") public class DeptJspController { @Autowired DeptService deptService; // 서비스 객체 가져오기 /** 전체 조회 : dname like 기능 (+) */ // todo: @RequestParam - url?변수=값&변수2=값2 (쿼리스트링 방식) @GetMapping("/dept") public String getDeptAll( @RequestParam(defaultValue = "") String dname , @RequestParam(defaultValue = "0") int page , @RequestParam(defaultValue = "3") int size , Model model ){ // todo: 페이징 요청 객체에 정보 저장 // page : 현재페이지 번호, size : 1 페이지당 개수 Pageable pageable = PageRequest.of(page, size); // todo: 전체 조회 함수 호출 Page<Dept> pageRes = deptService.findAllByDnameContaining(dname, pageable); // todo: jsp 정보전달( 부서배열, 페이징정보 ) model.addAttribute("dept", pageRes.getContent()); // 부서배열 model.addAttribute("currentPage", pageRes.getNumber()); // 현재 페이지 번호(0 ~) model.addAttribute("totalItems", pageRes.getTotalElements()); // 전체 테이블 건수 model.addAttribute("totalPages", pageRes.getTotalPages()); // 전체 페이지 개수 // 공식 : 블럭 시작페이지 번호 = (Math.floor(현재페이지번호/1페이지당개수)) * 1페이지당개수 // 예) 1블럭 : 0(블럭시작페이지번호) ~ 2(블럭끝페이지번호) , 2블럭 : 3(블럭시작페이지번호) ~ 5(블럭끝페이지번호), 3블럭 : 6(블럭시작페이지번호) ~ 8(블럭끝페이지번호) // 현재페이지 2, 1페이지당 개수 3 => Math.floor(2/3) * 3 = 0 // 현재페이지 5, 1페이지당 개수 3 => Math.floor(5/3) * 3 = 3 long blockStartPage = (long) Math.floor((double)(pageRes.getNumber()) / size) * size; model.addAttribute("startPage", blockStartPage); // 블럭 시작 페이지 번호(0 ~) // 공식 : 블럭 끝페이지 번호 = 블럭 시작페이지번호 + 1페이자당개수 - 1 // 만약 블럭 끝페이지번호 >= 전체페이지수 =>(블럭 끝페이지번호는 전체페이지수보다 클수 없음) 블럭끝페이지번호 = 전체페이지수 -1 넣기(0부터 시작) long blockEndPage = blockStartPage + size - 1; blockEndPage = (blockEndPage >= pageRes.getTotalPages())? pageRes.getTotalPages() - 1 : blockEndPage; model.addAttribute("endPage", blockEndPage); // 현재 끝 페이지 번호 log.debug(model.toString()); // 로그 출력 return "basic/dept/dept_all.jsp"; } /** 상세조회 */ @GetMapping("/dept/{dno}") public String getDeptId(@PathVariable int dno, Model model ) { // 서비스 상세조회 함수 호출 Optional<Dept> optionalDept = deptService.findById(dno); model.addAttribute("dept", optionalDept.get()); return "basic/dept/dept_id.jsp"; } /** 저장함수 : 저장 페이지로 이동 */ @GetMapping("/dept/addition") public String addDept(){ return "basic/dept/add_dept.jsp"; } /** 저장함수 : db 저장 */ @PostMapping("/dept/add") public RedirectView createDept( @ModelAttribute Dept dept ){ deptService.save(dept); // db 저장 // 전체 조회 페이지로 강제 이동 return new RedirectView("/basic/dept"); } /** 수정함수 : 수정 페이지로 이동 + 상세조회(1건조회) */ @GetMapping("/dept/edition/{dno}") public String editDept(@PathVariable int dno, Model model ) { // 서비스 상세조회 함수 호출 Optional<Dept> optionalDept = deptService.findById(dno); // jsp 전달 model.addAttribute("dept", optionalDept.get()); return "basic/dept/update_dept.jsp"; } /** 수정함수 : db 수정 저장 */ @PutMapping("/dept/edit/{dno}") public RedirectView updateDept(@PathVariable int dno, @ModelAttribute Dept dept ) { log.debug("---------------start-------------------------------"); deptService.save(dept); // db 수정 저장 log.debug("---------------end-------------------------------"); // 전체 조회 페이지로 강제 이동 return new RedirectView("/basic/dept"); } /** 삭제함수 */ @DeleteMapping("/dept/delete/{dno}") public RedirectView deleteDept(@PathVariable int dno) { deptService.removeById(dno); // db 삭제 return new RedirectView("basic/dept"); } // todo: 연습 5) 부서 클래스를 참고하여 사원 삭제기능을 추가하세요 // empDao, emp.xml, EmpService, EmpController, update_emp.jsp 수정 // url : /emp/delete/{eno} // redirect jsp : /emp }
- 전체 조회 : getDeptAll() 은 검색어가 “” 전달되면 전체조회가 되고
“검색어” 넣으면 like 검색이 실행됨 - 페이징 처리 : 기본 요약 페이징 처리 부분 참고
jsp 페이지
jsp 전체조회 페이지
결과 화면 :
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> <%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %> <!doctype html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Document</title> </head> <body> <%--header--%> <jsp:include page="../../common/header.jsp"/> <div class="container"> <%-- todo: 검색어 시작--%> <%-- form 안에 input 태그의 value 값들이 -> springboot 함수로 전달됨 --%> <form class="row g-3 justify-content-center" action="/basic/dept" method="get"> <div class="col-auto"> <%-- todo: 부서명 라벨 --%> <label for="dname" class="visually-hidden">Dname</label> <%-- todo: 검색창--%> <input type="text" class="form-control" id="dname" placeholder="dname" name="dname"> <%-- todo: hidden(숨김) page = 0, size = 3 --%> <input type="hidden" class="form-control" id="page" name="page" value="0"> <input type="hidden" class="form-control" id="size" name="size" value="3"> </div> <div class="col-auto"> <button type="submit" class="btn btn-primary mb-3">Search</button> </div> </form> <%-- todo: 검색어 끝--%> <%-- todo: 테이블 반복문 시작--%> <table class="table"> <thead class="thead-dark"> <tr> <th scope="col">ID</th> <th scope="col">Name</th> <th scope="col">위치</th> <th scope="col">등록일자</th> <th scope="col">수정일자</th> </tr> </thead> <tbody> <c:forEach var="data" items="${dept}"> <tr> <td><a href="/basic/dept/edition/${data.dno}">${data.dno}</a></td> <td>${data.dname}</td> <td>${data.loc}</td> <td>${data.insertTime}</td> <td>${data.updateTime}</td> </tr> </c:forEach> </tbody> </table> <%-- todo: 테이블 반복문 끝--%> <%-- todo: 페이지 번호 시작--%> <div class="d-flex justify-content-center"> <ul class="pagination"> <%-- todo: 첫페이지 번호 --%> <%-- startPage : 0부터 시작 --%> <%-- currentPage : 0부터 시작--%> <li class="page-item ${(startPage+1==1)? 'disabled': ''}"> <a class="page-link" href="/basic/dept?page=${startPage-1}&size=">Previous</a> </li> <%-- todo: 실제 페이지 번호들 --%> <%-- 사용법 : <c:forEach var="data" begin="시작값" end="끝값">반복문</c:forEach>--%> <c:forEach var="data" begin="${startPage}" end="${endPage}"> <li class="page-item ${(currentPage==data)? 'active': ''}"> <a class="page-link" href="/basic/dept?page=${data}&size="> ${data+1} </a> </li> </c:forEach> <%-- todo: 끝페이지 번호--%> <li class="page-item ${(endPage+1==totalPages)? 'disabled': ''}"> <a class="page-link" href="/basic/dept?page=${endPage+1}&size=">Next</a> </li> </ul> </div> <%-- todo: 페이지 번호 끝--%> <%-- todo: Add 버튼 추가 --%> <div class="text-center"> <a href="/basic/dept/addition" class="btn btn-primary center">Add</a> </div> </div> <%--footer--%> <jsp:include page="../../common/footer.jsp"/> </body> </html>
jsp 추가 페이지
결과 화면 :
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> <%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %> <!DOCTYPE html> <!-- thymeleaf 설정 --> <html lang="en"> <head> <meta charset="UTF-8"> <title>Title</title> </head> <body> <%-- header 시작 --%> <jsp:include page="../../common/header.jsp" /> <%-- header 끝 --%> <div class="container mt-5"> <div> <%-- todo: /basic/dept/add 해당되는 컨트롤러 함수 실행 --%> <form action="/basic/dept/add" method="post"> <%-- todo: 부서명 --%> <div class="mb-3"> <label for="dname" class="form-label">부서 이름</label> <input type="text" class="form-control" id="dname" required name="dname"> </div> <%-- todo: 부서위치 --%> <div class="mb-3"> <label for="loc" class="form-label">부서 위치</label> <input type="text" class="form-control" id="loc" required name="loc"> </div> <div class="mb-3"> <button type="submit" class="btn btn-primary">Submit</button> </div> </form> </div> </div> <%-- header 시작 --%> <jsp:include page="../../common/footer.jsp"/> <%-- header 끝 --%> </body> </html>
jsp 수정 페이지
결과 화면 :
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> <%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %> <!DOCTYPE html> <!-- thymeleaf 설정 --> <html lang="en"> <head> <meta charset="UTF-8"> <title>Title</title> </head> <body> <%-- header 시작 --%> <jsp:include page="../../common/header.jsp" /> <%-- header 끝 --%> <div class="container mt-5"> <div> <form action="/basic/dept/edit/${dept.dno}" method="post"> <%-- TODO: springboot 에서 아래와 같이 hidden 값을 전송하면 : put 방식으로 인식해서 연결해줌 --%> <input type="hidden" name="_method" value="put"/> <input type="hidden" name="dno" value="${dept.dno}"/> <div class="mb-3"> <label for="dname" class="form-label">부서 이름</label> <input type="text" class="form-control" id="dname" required name="dname" value="${dept.dname}" > </div> <div class="mb-3"> <label for="loc" class="form-label">부서 위치</label> <input type="text" class="form-control" id="loc" required name="loc" value="${dept.loc}" > </div> <div class="mb-3"> <button type="submit" class="btn btn-primary">Update</button> </div> </form> <%-- todo: 삭제 버튼 form --%> <form id="delete-form" action="/basic/dept/delete/${dept.dno}" method="post"> <%-- TODO: springboot 에서 아래와 같이 hidden 값을 전송하면 : delete 방식으로 인식해서 연결해줌 --%> <input type="hidden" name="_method" value="delete"/> <button type="submit" class="btn btn-danger">Delete</button> </form> </div> </div> <%-- header 시작 --%> <jsp:include page="../../common/footer.jsp"/> <%-- header 끝 --%> </script> </body> </html>
📃 결론
부서 게시판 샘플 예제를 살펴보았습니다.
기본적은 CRUD 기능을 구현했으며 게시판의 페이징 처리도 해봤습니다.
DB 프레임워크는 JPA 를 이용해서 sql 문을 직접 제작하지 않고 자동화기능을 이용해 구현했습니다.
Mybatis 의 직접 sql 문 제작 기능에 대응해 자주 반복되고 쉬운 기능은 JPA 의 sql 자동생성 기능을 이용하고,
복잡한 sql 문은 @Query 를 이용해 직접 작성할 수 있어 요즘 많이 서비스 업체 기준으로 사용되고 있습니다.
감사합니다.