📃 요약
네이버 카페 등의 게시판을 기본 기능을 제작하는 예제입니다.
기본적인 DB CRUD 를 사용해 제작합니다.
DB 프레임워크는 JPA 를 사용해 자동화기능을 강화합니다.
요소 기술 및 테이블 설계는 아래와 같습니다.
요소 기술 :
– 프론트엔드 : JSP
– 벡엔드 : 스프링부트 & JPA & Oracle 18xe(Oracle Cloud 19c)
결과 화면 :
프로젝트 탐색기 :
함수 URL :
메소드 | URL | 설명 |
---|---|---|
GET | /advanced/fileDb | 전체 조회 |
GET | /advanced/fileDb/get/{uuid} | 상세조회 |
GET | /advanced/fileDb/addition | 저장(추가) 페이지 열기 |
POST | /advanced/fileDb/add | 버튼 클릭시 저장 함수 |
GET | /advanced/fileDb/{uuid} | 이미지 다운로드 함수, return(JSON 데이터) |
GET | /advanced/fileDb/edition/{uuid} | 수정 페이지 열기 |
PUT | /advanced/fileDb/edit/{uuid} | 수정 |
DELETE | /advanced/fileDb/delete/{uuid} | 삭제 |
📃 기술 구현
스펙 :
- jdk 17 - spring boot 3.x - gradle
테이블 설계
CREATE TABLE TB_FILE_DB ( UUID VARCHAR2(1000) NOT NULL PRIMARY KEY, -- 파일 UUID FILE_TITLE VARCHAR2(1000), -- 제목 FILE_CONTENT VARCHAR2(1000), -- 내용 FILE_NAME VARCHAR2(1000), -- 파일명 FILE_DATA BLOB, -- 바이너리 파일(이미지파일) FILE_URL VARCHAR2(1000), -- 파일 다운로드 URL DELETE_YN VARCHAR2(1) DEFAULT 'N', INSERT_TIME VARCHAR2(255), UPDATE_TIME VARCHAR2(255), DELETE_TIME VARCHAR2(255) );
업로드 게시판 구현을 위한 테이블 설계입니다.
데이터베이스를 오라클을 사용하여 구현해 보겠습니다.
파일 업로드 로직
파일 업로드
파일을 업로드하는 방법은 대체적으로 2가지임
1) DB 에 파일을 업로드하는 방법과
2) 서버컴퓨터의 일정 폴더에 업로드하는 방법이 있음
DB 업로드를 진행하면 오라클 SQL 을 이용해서 간편하게 업로드가 가능하고, 수정/삭제 등 관리가 용이함
요즘은 DB 보다 가격이 싼 아마존 클라우드(AWS) 의 S3 공간을 임대해서 파일을 업로드하고 사용하기도 함 , 이때는 아마존에서 제공하는 함수를 이용해서 업로드 / 삭제를 진행함
이 예제에서는 과거부터 현재까지 이용되고 있는 DB 에 파일을 저장하는 방법으로 진행하기로 함
- 특이사항 :
- UUID 를 사용하여 유일한 값을 저장(시퀀스 사용 않함)
- 프론트 서비스 함수
1) 업로드(저장/수정) : FormData 객체를 사용하여 (키, 값) 형태로 저장 후 벡엔드 전송
2) 헤더 : multipart/form-data 타입으로 전송 - 벡엔드 엔티티 객체 정의
1) DB 에 저장하는 이미지 는 BLOB 타입으로 테이블 정의
2) 엔티티 정의 시 첨부파일 속성은 @Lob 를 붙이고, 타입은 byte[] 로 정의해야함
3) 서비스 함수에서 저장시 이미지를 다운로드 받을 url 을 생성하여 테이블에 저장하고 프론트의 img 태그에서 그 url 넣어 화면에 이미지를 표시함
4) 컨트롤러 함수에서 이미지 다운로드 함수를 추가 정의함
이때 ResponseEntitiy 객체에 헤더/바디를 구분하여 프론트쪽으로 전달해야 이미지 다운로드가 됨 - 헤더 : CONTENT_DISPOSITION, attachment; filename=”첨부파일명” 이 포함
- 바디 : 이미지 데이터 저장
모델 : 엔티티
공통 모델
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 { // todo: 공통속성 : yyyy-MM-dd HH:mm:ss 아니고 기본 패턴으로 보임 private String insertTime; private String updateTime; // todo: 해당 테이블에 데이터가 만들어 질때(insert 문) 실행되는 이벤트 함수 @PrePersist void OnPrePersist() { this.insertTime = LocalDateTime.now() .format(DateTimeFormatter .ofPattern("yyyy-MM-dd HH:mm:ss")); } // todo: 해당 테이블에 데이터가 수정 질때(update 문) 실행되는 이벤트 함수 @PreUpdate void OnPreUpdate() { this.updateTime = LocalDateTime.now() .format(DateTimeFormatter .ofPattern("yyyy-MM-dd HH:mm:ss")); this.insertTime = this.updateTime; // 생성일시 == 수정일시 동일하게 처리 } }
파일 업로드 레포지토리
package com.example.jpaexam.repository.advanced; import com.example.jpaexam.model.entity.advanced.FileDb; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; /** * packageName : com.example.jpaexam.repository * fileName : DeptRepostory * author : kangtaegyung * date : 2022/10/16 * description : JPA CRUD 인터페이스 ( DAO 역할 ) * 요약 : * <p> * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2022/10/16 kangtaegyung 최초 생성 */ @Repository public interface FileDbRepository extends JpaRepository<FileDb, String> { // title like 검색 Page<FileDb> findAllByFileTitleContaining(String fileTitle, Pageable pageable); }
select : @Query 이용한 오라클 기반 쿼리(nativeQuery = true) 임
파일 업로드 게시판 서비스
package com.example.jpaexam.service.advanced; import com.example.jpaexam.model.entity.advanced.FileDb; import com.example.jpaexam.repository.advanced.FileDbRepository; import lombok.extern.slf4j.Slf4j; 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 org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import java.io.IOException; import java.util.Optional; import java.util.UUID; /** * packageName : com.example.modelexam.service * fileName : DeptService * author : kangtaegyung * date : 2022/10/12 * description : Qna 서비스 클래스 * 요약 : * <p> * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2022/10/12 kangtaegyung 최초 생성 */ @Slf4j @Service public class FileDbService { @Autowired FileDbRepository fileDbRepository; // 샘플데이터 DB에 접근하는 객체 public Page<FileDb> findAllByFileTitleContaining(String fileTitle, Pageable pageable) { Page<FileDb> page = fileDbRepository.findAllByFileTitleContaining(fileTitle, pageable); return page; } public Page<FileDb> findAll(Pageable pageable) { Page<FileDb> page = fileDbRepository.findAll(pageable); return page; } public Optional<FileDb> findById(String uuid) { return fileDbRepository.findById(uuid); } // TODO: uuid 를 생성해서 insert 또는 기존 uuid 를 전달받아 update public FileDb save(String uuid, String fileTitle, String fileContent, MultipartFile file) throws IOException { FileDb fileDb2 = null; try { // 기본키가 없으면 insert if (uuid == null) { // 램덤 고유값 구하기 : UUID, 중간에 - 있음, uuid 생성 String tmpUuid = UUID.randomUUID().toString().replace("-", ""); // 다운로드 url 만들기 String fileDownloadUri = ServletUriComponentsBuilder .fromCurrentContextPath() .path("/advanced/fileDb/") .path(tmpUuid) .toUriString(); FileDb fileDb = new FileDb(tmpUuid, fileTitle, fileContent, file.getOriginalFilename(), file.getBytes(), fileDownloadUri); fileDb2 = fileDbRepository.save(fileDb); } else { // 기본키가 있으면 update // 다운로드 url 만들기, 기존 uuid 사용 String fileDownloadUri = ServletUriComponentsBuilder .fromCurrentContextPath() .path("/advanced/fileDb/") .path(uuid) .toUriString(); FileDb fileDb = new FileDb(uuid, fileTitle, fileContent, file.getOriginalFilename(), file.getBytes(), fileDownloadUri); fileDb2 = fileDbRepository.save(fileDb); } } catch (Exception e) { log.debug(e.getMessage()); } return fileDb2; } public boolean removeById(String uuid) { if (fileDbRepository.existsById(uuid)) { fileDbRepository.deleteById(uuid); return true; } return false; } }
파일 업로드 게시판 컨트롤러
package com.example.jpaexam.controller.advanced; import com.example.jpaexam.model.entity.advanced.FileDb; import com.example.jpaexam.model.entity.basic.Dept; import com.example.jpaexam.service.advanced.FileDbService; 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.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.view.RedirectView; import java.util.HashMap; import java.util.Map; import java.util.Optional; /** * packageName : com.example.modelexam.controller * fileName : FileDbController * author : kangtaegyung * date : 2022/10/12 * description : 부서 컨트롤러 * 요약 : * <p> * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2022/10/12 kangtaegyung 최초 생성 */ @Slf4j @Controller @RequestMapping("/advanced") public class FileDbController { @Autowired FileDbService fileDbService; @GetMapping("/fileDb") public String findAllByFileTitleContaining( @RequestParam(defaultValue = "") String fileTitle , @RequestParam(defaultValue = "0") int page , @RequestParam(defaultValue = "3") int size , Model model ) { // todo: 페이징 요청 객체에 정보 저장 // page : 현재페이지 번호, size : 1 페이지당 개수 Pageable pageable = PageRequest.of(page, size); // todo: 전체 조회 함수 호출 // 다운로드 url 을 만들어 DTO 에 저장함 Page<FileDb> pageRes = fileDbService.findAllByFileTitleContaining(fileTitle, pageable); // todo: jsp 정보전달( 부서배열, 페이징정보 ) model.addAttribute("fileDb", pageRes.getContent()); // 이미지배열 model.addAttribute("currentPage", pageRes.getNumber()); // 현재 페이지 번호(0 ~) model.addAttribute("totalItems", pageRes.getTotalElements()); // 전체 테이블 건수 model.addAttribute("totalPages", pageRes.getTotalPages()); // 전체 페이지 개수 long blockStartPage = 0; // 블럭 시작 페이지 번호(0 ~) long blockEndPage = 0; // 현재 끝 페이지 번호 // 테이블 데이터가 있다면 페이지 계산 후 jsp 로 전송 if (pageRes.getTotalElements() > 0) { // 공식 : 블럭 시작페이지 번호 = 현재페이지번호 * 1페이지당개수 blockStartPage = ((int) Math.floor((double) (pageRes.getNumber()) / size) * size); // 공식 : 블럭 끝페이지 번호 = 블럭 시작페이지번호 + 1페이자당개수 - 1 // 만약 블럭 끝페이지번호 >= 전체페이지수 =>(오류이므로 오류보정) 블럭끝페이지번호 = 전체페이지수 -1 넣기 blockEndPage = blockStartPage + size - 1; blockEndPage = (blockEndPage >= pageRes.getTotalPages()) ? pageRes.getTotalPages() - 1 : blockEndPage; } model.addAttribute("startPage", blockStartPage); // 블럭 시작 페이지 번호(0 ~) model.addAttribute("endPage", blockEndPage); // 현재 끝 페이지 번호 // log.debug(model.toString()); // 로그 출력 return "advanced/fileDb/fileDb_all.jsp"; } // 화면 출력 시 자동적으로 ID로 조회됨(이미지 id 는 uuid 임) // jsp 에서 실행될때 자동 실행되는 함수 @GetMapping("/fileDb/{uuid}") @ResponseBody public ResponseEntity<byte[]> findByIdDownloading(@PathVariable String uuid) { FileDb fileDb = fileDbService.findById(uuid).get(); return ResponseEntity.ok() // Todo : attachment: => attachment; .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileDb.getFileName() + "\"") .body(fileDb.getFileData()); } /** * 저장함수 : 저장 페이지로 이동 */ @GetMapping("/fileDb/addition") public String addFileDb() { return "advanced/fileDb/add_fileDb.jsp"; } /** * 저장함수 : db 저장 */ @PostMapping("/fileDb/add") public RedirectView createFileDb( @RequestParam String fileTitle, @RequestParam String fileContent, @RequestParam MultipartFile image ) { try { fileDbService.save(null, fileTitle, fileContent, image); } catch (Exception e) { log.debug(e.getMessage()); } // 전체 조회 페이지로 강제 이동 return new RedirectView("/advanced/fileDb"); } @DeleteMapping("/fileDb/delete/{uuid}") public RedirectView delete(@PathVariable String uuid) { fileDbService.removeById(uuid); // db 삭제 return new RedirectView("/advanced/fileDb"); } /** 수정함수 : 수정 페이지로 이동 + 상세조회(1건조회) */ @GetMapping("/fileDb/edition/{uuid}") public String editDept(@PathVariable String uuid, Model model ) { // 서비스 상세조회 함수 호출 Optional<FileDb> optionalFileDb = fileDbService.findById(uuid); // jsp 전달 model.addAttribute("fileDb", optionalFileDb.get()); return "advanced/fileDb/update_fileDb.jsp"; } /** 수정함수 : db 수정 저장 */ @PutMapping("/fileDb/edit/{uuid}") public RedirectView updateDept(@RequestParam String uuid, @RequestParam String fileTitle, @RequestParam String fileContent, @RequestParam MultipartFile image ) { try { fileDbService.save(uuid, fileTitle, fileContent, image); } catch (Exception e) { log.debug(e.getMessage()); } // 전체 조회 페이지로 강제 이동 return new RedirectView("/advanced/fileDb"); } }
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="/advanced/fileDb" method="get"> <div class="col-auto"> <%-- todo: 제목 라벨 --%> <label for="fileTitle" class="visually-hidden">searchTitle</label> <%-- todo: 검색창--%> <input type="text" class="form-control" id="fileTitle" placeholder="fileTitle" name="fileTitle"> <%-- 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: 테이블 반복문 시작--%> <div class="row"> <c:forEach var="data" items="${fileDb}"> <div class="col-3"> <div class="card" style="width: 18rem;"> <img src="${data.fileUrl}" class="card-img-top" alt="강의"/> <div class="card-body"> <h5 class="card-title"><a href="/advanced/fileDb/edition/${data.uuid}">${data.fileTitle}</a> </h5> <p class="card-text">${data.fileContent}</p> <form id="delete-form" action="/advanced/fileDb/delete/${data.uuid}" method="post"> <%-- TODO: springboot 에서 아래와 같이 hidden 값을 전송하면 : delete 방식으로 인식해서 연결해줌 --%> <input type="hidden" name="_method" value="delete"/> <button type="submit" class="btn btn-danger btn-sm">Delete</button> </form> </div> </div> </div> </c:forEach> </div> <%-- 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="/advanced/fileDb/addition" class="btn btn-primary center">Add</a> </div> </div> <script> let obj2 = "${currentPage}"; let obj3 = "${totalItems}"; let obj4 = "${totalPages}"; let obj5 = "${startPage}"; let obj6 = "${endPage}"; console.log("currentPage", obj2); console.log("totalItems", obj3); console.log("totalPages", obj4); console.log("startPage", obj5); console.log("endPage", obj6); </script> <%--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: /advanced/fileDb/add 해당되는 컨트롤러 함수 실행 --%> <form action="/advanced/fileDb/add" method="post" enctype="multipart/form-data"> <%-- todo: 이름 --%> <div class="mb-3"> <label for="fileTitle" class="form-label">이름</label> <input type="text" class="form-control" id="fileTitle" required name="fileTitle"> </div> <%-- todo: 내용 --%> <div class="mb-3"> <label for="fileContent" class="form-label">내용</label> <input type="text" class="form-control" id="fileContent" required name="fileContent"> </div> <%-- todo: 업로드 --%> <div class="input-group mb-3"> <input type="file" class="form-control" name="image" /> <button class="btn btn-outline-secondary" type="submit" > Upload </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="/advanced/fileDb/edit/${fileDb.uuid}" method="post" enctype="multipart/form-data"> <%-- TODO: springboot 에서 아래와 같이 hidden 값을 전송하면 : put 방식으로 인식해서 연결해줌 --%> <input type="hidden" name="_method" value="put"/> <input type="hidden" name="uuid" value="${fileDb.uuid}"/> <div class="mb-3"> <label for="fileTitle" class="form-label">이름</label> <input type="text" class="form-control" id="fileTitle" required name="fileTitle" value="${fileDb.fileTitle}" > </div> <div class="mb-3"> <label for="fileContent" class="form-label">내용</label> <input type="text" class="form-control" id="fileContent" required name="fileContent" value="${fileDb.fileContent}" > </div> <div class="mb-3 col-md-12" style="width: 18rem;"> <img src="${fileDb.fileUrl}" class="card-img-top" alt="강의"/> </div> <%-- todo: 업로드 --%> <div class="input-group mb-3"> <input type="file" class="form-control" name="image" /> <button class="btn btn-outline-secondary" type="submit" > Update </button> </div> </form> </div> </div> <%-- header 시작 --%> <jsp:include page="../../common/footer.jsp"/> <%-- header 끝 --%> </script> </body> </html>
📃 결론
부서 게시판 샘플 예제를 살펴보았습니다.
기본적은 CRUD 기능을 구현했으며 게시판의 페이징 처리도 해봤습니다.
DB 프레임워크는 JPA 를 이용해서 sql 문을 직접 제작하지 않고 자동화기능을 이용해 구현했습니다.
Mybatis 의 직접 sql 문 제작 기능에 대응해 자주 반복되고 쉬운 기능은 JPA 의 sql 자동생성 기능을 이용하고,
복잡한 sql 문은 @Query 를 이용해 직접 작성할 수 있어 요즘 많이 서비스 업체 기준으로 사용되고 있습니다.
감사합니다.