오늘은 게시글 작성 기능을 구현해 보자. 😉
- 삽입 : /board2/1/insert (code == BOARD_CODE, 게시판 종류)
- 수정 : /board2/1/1500/update (no == BOARD_NO, 게시글 번호)
- 삭제 : /board2/1/1500/delete
URL은 @PathVariable
을 사용하여 위와 같은 형태로 만들어 볼 것이니 기억하고 있자!
또한 오늘은 MyBatis의 가장 강력한 기능인 동적 SQL을 사용해 볼 것이다.
프로그램 수행 중 SQL을 변경하는 기능
DB 내부적으로 생성한 키(시퀀스)를 전달된 파라미터의 필드로 대입 가능 여부 지정
대입 가능 : true / 불가능 : false
INSERT/UPDATE 시 사용할 키(시퀀스)를 조회해서 파라미터의 지정된 필드에 대입
메인 SQL이 수행되기 전/후에 selectKey가 수행되도록 지정
전 : "BEFORE" / 후 : "AFTER"
selectKey 조회 결과를 저장할 파라미터의 필드명
특정 SQL 구문을 반복할 때 사용하는 태그
-> 반복되는 사이에 구분자(separator)
를 추가할 수 있음
반복할 객체의 타입 작성(list, set, map...)
collection에서 순차적으로 꺼낸 하나의 요소를 저장하는 변수
현재 반복 접근중인 인덱스 (0,1,2,3,4 ..)
반복 전에 출력할 sql
반복 종료 후에 출력한 sql
반복 사이사이 구분자
MyBatis에 이런 기능이 있었다니 대혼란... 🤔
이 기능은 아래 Spring에서 코드로 자세히 다뤄 보자!
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
<c:forEach items="${boardTypeList}" var="boardType">
<c:if test="${boardType.BOARD_CODE == boardCode}" >
<c:set var="boardName" value="${boardType.BOARD_NAME}"/>
</c:if>
</c:forEach>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${boardName}</title>
<link rel="stylesheet" href="/resources/css/board/boardWrite-style.css">
</head>
<body>
<main>
<jsp:include page="/WEB-INF/views/common/header.jsp"/>
<%-- @PathVariable에서 등록해 놓은 boardCode --%>
<form action="/board2/${boardCode}/insert" method="POST"
class="board-write" id="boardWriteFrm" enctype="multipart/form-data">
<%-- enctype="multipart/form-data" : 제출 데이터 인코딩 X
-> 파일 제출 가능
-> MultiPartResolver가 문자열, 파일을 구분
--> 문자열 -> String, int, DTO, Map (HttpMessageConverter)
--> 파일 -> MultiPartFile 객체 -> transferTo() (파일을 서버에 저장)
--%>
<h1 class="board-name">${boardName}</h1>
<!-- 제목 -->
<h1 class="board-title">
<input type="text" name="boardTitle" placeholder="제목" value="">
</h1>
<!-- 썸네일 영역 -->
<h5>썸네일</h5>
<div class="img-box">
<div class="boardImg thumbnail">
<label for="img0">
<img class="preview" src="">
</label>
<input type="file" name="images" class="inputImage" id="img0" accept="image/*">
<span class="delete-image">×</span>
</div>
</div>
<!-- 업로드 이미지 영역 -->
<h5>업로드 이미지</h5>
<div class="img-box">
<div class="boardImg">
<label for="img1">
<img class="preview" src="">
</label>
<input type="file" name="images" class="inputImage" id="img1" accept="image/*">
<span class="delete-image">×</span>
</div>
<div class="boardImg">
<label for="img2">
<img class="preview" src="">
</label>
<input type="file" name="images" class="inputImage" id="img2" accept="image/*">
<span class="delete-image">×</span>
</div>
<div class="boardImg">
<label for="img3">
<img class="preview" src="">
</label>
<input type="file" name="images" class="inputImage" id="img3" accept="image/*">
<span class="delete-image">×</span>
</div>
<div class="boardImg">
<label for="img4">
<img class="preview" src="">
</label>
<input type="file" name="images" class="inputImage" id="img4" accept="image/*">
<span class="delete-image">×</span>
</div>
</div>
<!-- 내용 -->
<div class="board-content">
<textarea name="boardContent"></textarea>
</div>
<!-- 버튼 영역 -->
<div class="board-btn-area">
<button type="submit" id="writebtn">등록</button>
</div>
</form>
</main>
<jsp:include page="/WEB-INF/views/common/footer.jsp"/>
<script src="/resources/js/board/boardWrite.js"></script>
</body>
</html>
/* 상세조회 전체 영역 */
.board-write{
width: 1000px;
min-height: 700px;
border: 1px solid #ccc;
margin: 50px auto;
padding: 20px;
}
/* 게시글 제목 */
.board-title{
margin : 0;
padding: 20px 0;
border-bottom : 3px solid #ccc;
}
.board-title > input{
width: 100%;
border: 0;
outline: 0;
font-size: 24px;
}
.board-title:focus-within{
border-bottom-color: #455ba8;
}
.img-box{
display: flex;
justify-content: space-between;
}
/* 이미지를 감싸는 div */
.boardImg{
width: 230px;
height: 230px;
display: inline-block;
text-align: center;
position: relative;
}
.boardImg > label{
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
border: 1px solid #ccc;
cursor: pointer;
}
.inputImage{
display: none;
}
/* 이미지 */
.boardImg img{
max-width: 100%;
max-height: 100%;
}
/* 썸네일만 이미지 크게 */
.thumbnail{
width: 300px;
height: 300px;
}
/* 내용 */
.board-content{
padding: 30px 0;
margin: 30px 0;
border-top : 3px solid #ccc;
border-bottom : 3px solid #ccc;
font-size: 18px;
}
[name="boardContent"]{
width: 100%;
height: 400px;
resize: none;
font-size: 1.1em;
}
/* 버튼 영역 */
.board-btn-area{
text-align: right;
}
/* 버튼 */
.board-btn-area button{
width: 80px;
height: 30px;
font-weight: bold;
border: 0;
background-color: #455ba8;
color : white;
cursor: pointer;
}
.board-btn-area button:hover{
background-color: white;
color : #455ba8;
border: 2px solid #455ba8;
}
/* 삭제 버튼 */
.delete-image{
position: absolute;
top: 0;
right: 7px;
font-size: 20px;
cursor: pointer;
}
해당 "주소" 요청 (GET 방식)
const insertBtn = document.getElementById("insertBtn");
// 글쓰기 버튼 클릭 시
if(insertBtn != null){
insertBtn.addEventListener("click", ()=>{
// JS BOM 객체 중 location
// location.href = "주소"
// 해당 주소 요청 (GET 방식)
location.href = `/board2/${location.pathname.split("/")[2]}/insert`
// "/board2/" + location.pathname.split("/")[2];
// /board2/1/insert
})
}
// 미리보기 관련 요소 모두 얻어오기
// img 5개
const preview = document.getElementsByClassName("preview");
// file 5개
const inputImage = document.getElementsByClassName("inputImage");
// x 버튼 5개
const deleteImage = document.getElementsByClassName("delete-image");
// -> 위에 얻어온 요소들의 개수가 같음 == 인덱스가 일치함
for(let i = 0; i < inputImage.length; i++){
// 파일이 선택되거나, 선택 후 취소되었을 때
inputImage[i].addEventListener("change", e=>{
const file = e.target.files[0]; // 선택된 파일의 데이터
if(file != undefined){ // 파일이 선택되었을 때
const reader = new FileReader(); // 파일을 읽는 객체
reader.readAsDataURL(file);
// 지정된 파일을 읽은 후 result 변수에 URL 형식으로 저장
reader.onload = e=>{ // 파일을 다 읽은 후 수행
preview[i].setAttribute("src", e.target.result);
}
} else { // 선택 후 취소되었을 때
// -> 선택된 파일 없음 -> 미리보기 삭제
preview[i].removeAttribute("src");
}
});
// 미리보기 삭제 버튼(x버튼)
deleteImage[i].addEventListener("click", ()=>{
// 미리보기 이미지가 있을 경우
if(preview[i].getAttribute("src") != ""){
// 미리보기 삭제
preview[i].removeAttribute("src");
// input type="file" 태그의 value를 삭제
// ** input type="file"의 value는 ""(빈칸)만 대입 가능 **
inputImage[i].value = "";
}
})
}
// 게시글 등록 시 제목, 내용 작성 여부 검사
const boardWriteFrm = document.getElementById("boardWriteFrm");
const boardTitle = document.querySelector("[name='boardTitle']");
const boardContent = document.querySelector("[name='boardContent']");
boardWriteFrm.addEventListener("submit", e=>{
if(boardTitle.value.trim().length == ""){
alert("제목을 입력해 주세요.");
boardTitle.value = "";
boardTitle.focus();
e.preventDefault(); // form 기본 이벤트 제거
return;
}
if(boardContent.value.trim().length == ""){
alert("내용을 입력해 주세요.");
boardContent.value = "";
boardContent.focus();
e.preventDefault(); // form 기본 이벤트 제거
return;
}
})
주소 값 가져오기 + Request Scope에 값 올리기
package edu.kh.project.board.controller;
import java.io.IOException;
import java.util.List;
import javax.servlet.http.HttpSession;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.SessionAttribute;
import org.springframework.web.bind.annotation.SessionAttributes;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import edu.kh.project.board.model.dto.Board;
import edu.kh.project.board.model.service.BoardService2;
import edu.kh.project.member.model.dto.Member;
@Controller
@RequestMapping("/board2")
@SessionAttributes({"loginMember"})
public class BoardController2 {
@Autowired
private BoardService2 service;
// 게시글 작성 화면 전환
@GetMapping("/{boardCode:[0-9]+}/insert")
public String boardInsert(@PathVariable("boardCode") int boardCode) {
// @PathVariable : 주소 값 가져오기 + request scope에 값 올리기
return "board/boardWrite";
}
// 게시글 작성
@PostMapping("/{boardCode:[0-9]+}/insert")
public String boardInsert(
@PathVariable("boardCode") int boardCode
, Board board // 커맨드 객체(필드에 파라미터 담겨 있음)
, @RequestParam(value="images", required = false) List<MultipartFile> images
, @SessionAttribute("loginMember") Member loginMember
, RedirectAttributes ra
, HttpSession session)
throws IllegalStateException, IOException {
// 파라미터 : 제목, 내용, 파일(0~5개)
// 파일 저장 경로 : HttpSession
// 세션 : 로그인한 회원의 번호
// 리다이렉트 시 데이터 전달 : RedirectAttributes
// 작성 성공 시 이동할 게시판 코드 : @PathVariable("boardCode")
/* List<MultipartFile>
* - 업로드된 이미지가 없어도 List에 요소 MultipartFile 객체가 추가됨
* - 단, 업로드된 이미지가 없는 MultipartFile 객체는
* 파일 크기(size)가 0 또는 파일명(getOriginalFileName())이 ""(빈칸)임
* */
// 1. 로그인한 회원 번호를 얻어와 board에 세팅
board.setMemberNo(loginMember.getMemberNo());
// 2. boardCode도 board에 세팅
board.setBoardCode(boardCode);
// 3. 업로드된 이미지 서버에 실제로 저장되는 경로
// + 웹에서 요청 시 이미지를 볼 수 있는 경로(웹 접근 경로)
String webPath = "/resources/images/board/";
String filePath = session.getServletContext().getRealPath(webPath);
// 게시글 삽입 서비스 호출 후 삽입된 게시글 번호 반환 받기
int boardNo = service.boardInsert(board, images, webPath, filePath);
// 게시글 삽입 성공 시
// -> 방금 삽입한 게시글의 상세 조회 페이지로 리다이렉트
// -> /board/{boardCode}/{boardNo}
String message = null;
String path = "redirect:";
if(boardNo > 0) { // 성공 시
message = "게시글이 등록되었습니다.";
path += "/board/" + boardCode + "/" + boardNo;
} else { // 실패 시
message = "게시글 등록 실패ㅠ";
path += "insert";
}
ra.addFlashAttribute("message", message);
return path;
}
}
package edu.kh.project.board.model.service;
import java.io.IOException;
import java.util.List;
import org.springframework.web.multipart.MultipartFile;
import edu.kh.project.board.model.dto.Board;
public interface BoardService2 {
/** 게시글 삽입
* @param board
* @param images
* @param webPath
* @param filePath
* @return boardNo
*/
int boardInsert(Board board, List<MultipartFile> images, String webPath, String filePath) throws IllegalStateException, IOException;
}
package edu.kh.project.board.model.service;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import edu.kh.project.board.model.dao.BoardDAO2;
import edu.kh.project.board.model.dto.Board;
import edu.kh.project.board.model.dto.BoardImage;
import edu.kh.project.board.model.exception.FileUploadException;
import edu.kh.project.common.utility.Util;
@Service
public class BoardServiceImpl2 implements BoardService2{
@Autowired
private BoardDAO2 dao;
// 게시글 삽입
@Transactional(rollbackFor = Exception.class)
@Override
public int boardInsert(Board board, List<MultipartFile> images, String webPath, String filePath) throws IllegalStateException, IOException {
// 0. XSS 방지 처리
board.setBoardTitle(Util.XSSHandling( board.getBoardTitle() ) );
board.setBoardContent(Util.XSSHandling( board.getBoardContent() ) );
// 1. BOARD 테이블에 INSERT 하기 (제목, 내용, 작성자, 게시판 코드)
// -> boardNo(시퀀스로 생성한 번호) 반환 받기
int boardNo = dao.boardInsert(board);
// 2. 게시글 삽입 성공 시
// 업로드된 이미지가 있다면 BOARD_IMG 테이블에 INSERT 하는 DAO 호출
if(boardNo > 0) { // 게시글 삽입 성공 시
// List<MultipartFile> images
// -> 업로드된 파일이 담긴 객체 MultipartFile이 5개 존재
// -> 단, 업로드된 파일이 없어도 MultipartFile 객체는 존재
// 실제 업로드된 파일의 정보를 기록할 List
List<BoardImage> uploadList = new ArrayList<BoardImage>();
// images에 담겨 있는 파일 중 실제 업로드된 파일만 분류
for(int i=0; i<images.size(); i++) {
// i번째 요소에 업로드한 파일이 있다면
if(images.get(i).getSize() > 0) {
BoardImage img = new BoardImage();
// img에 파일 정보를 담아서 uploadList에 추가
img.setImagePath(webPath); // 웹 접근 경로
img.setBoardNo(boardNo); // 게시글 번호
img.setImageOrder(i); // 이미지 순서
// 파일 원본명
String fileName = images.get(i).getOriginalFilename();
img.setImageOriginal(fileName); // 원본명
img.setImageReName( Util.fileRename(fileName) ); // 파일 변경명
uploadList.add(img);
}
} // 분류 for문 종료
// 분류 작업 후 uploadList가 비어 있지 않은 경우
// == 업로드한 파일이 있다
if(!uploadList.isEmpty()) {
// BOARD_IMG 테이블에 INSERT하는 DAO 호출
int result = dao.insertImageList(uploadList);
// result == 삽입된 행의 개수 == uploadList.size()
// 삽입된 행의 개수와 uploadList의 개수가 같다면
// == 전체 insert 성공
if(result == uploadList.size()) {
// 서버에 파일을 저장 (transferTo())
// images : 실제 파일이 담긴 객체 리스트
// (업로드 안 된 인덱스는 빈칸)
// uploadList : 업로드된 파일의 정보 리스트
// (원본명, 변경명, 순서, 경로, 게시글 번호)
// 순서 == images에 업로드된 인덱스 번호
for(int i=0; i<uploadList.size(); i++) {
int index = uploadList.get(i).getImageOrder();
// 파일로 변환
String rename = uploadList.get(i).getImageReName();
images.get(index).transferTo(new File(filePath + rename));
}
} else { // 일부 또는 전체 insert 실패
// ** 웹 서비스 수행 중 1개라도 실패하면 전체 실패 **
// -> rollback 필요
// @Transactional(rollbackFor = Exception.class)
// -> 예외가 발생해야만 롤백
// [결론]
// 예외를 강제 발생 시켜서 rollback 해야 된다!
// -> 사용자 정의 예외 생성
throw new FileUploadException(); // 예외 강제 발생
}
}
}
return boardNo;
}
}
package edu.kh.project.board.model.dao;
import java.util.List;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;
import edu.kh.project.board.model.dto.Board;
import edu.kh.project.board.model.dto.BoardImage;
@Repository
public class BoardDAO2 {
@Autowired
private SqlSessionTemplate sqlSession;
/** 게시글 삽입
* @param board
* @return boardNo
*/
public int boardInsert(Board board) {
int result = sqlSession.insert("boardMapper.boardInsert", board);
// -> sql 수행 후 매개변수 board 객체에는 boardNo가 존재한다. -> board-mapper에서 세팅해 주었기 때문에!
// 삽입 성공 시
if(result > 0) result = board.getBoardNo();
return result; // 삽입 성공 시 boardNo, 실패 시 0 반환
}
/** 이미지 리스트(여러 개) 삽입
* @param uploadList
* @return result
*/
public int insertImageList(List<BoardImage> uploadList) {
return sqlSession.insert("boardMapper.insertImageList", uploadList);
}
}
...
<!-- 게시글 삽입 -->
<insert id="boardInsert" parameterType="Board" useGeneratedKeys="true">
<selectKey order="BEFORE" resultType="_int" keyProperty="boardNo">
SELECT SEQ_BOARD_NO.NEXTVAL FROM DUAL
</selectKey>
INSERT INTO BOARD
VALUES( #{boardNo},
#{boardTitle},
#{boardContent},
DEFAULT, DEFAULT, DEFAULT, DEFAULT,
#{memberNo},
#{boardCode})
</insert>
<!-- 이미지 리스트(여러 개) 삽입 -->
<insert id="insertImageList" parameterType="list">
INSERT INTO BOARD_IMG
SELECT SEQ_IMG_NO.NEXTVAL, A.*
FROM(
<foreach collection="list" item="img" separator=" UNION ALL ">
SELECT #{img.imagePath} IMG_PATH,
#{img.imageReName} IMG_RENAME,
#{img.imageOriginal} IMG_ORIGINAL,
#{img.imageOrder} IMG_ORDER,
#{img.boardNo} BOARD_NO
FROM DUAL
</foreach>
) A
</insert>
package edu.kh.project.common.utility;
import java.text.SimpleDateFormat;
public class Util {
// Cross Site Scripting(XSS) 방지 처리
// - 웹 애플리케이션에서 발생하는 취약점
// - 권한이 없는 사용자가 사이트에 스크립트를 작성하는 것
public static String XSSHandling(String content) {
// 스크립트나 마크업 언어에서 기호나 기능을 나타내는 문자를 변경 처리
// & - &
// < - <
// > - >
// " - "
content = content.replaceAll("&", "&");
content = content.replaceAll("<", "<");
content = content.replaceAll(">", ">");
content = content.replaceAll("\"", """);
return content;
}
// 파일명 변경 메소드
public static String fileRename(String originFileName) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss");
String date = sdf.format(new java.util.Date(System.currentTimeMillis()));
int ranNum = (int) (Math.random() * 100000); // 5자리 랜덤 숫자 생성
String str = "_" + String.format("%05d", ranNum);
String ext = originFileName.substring(originFileName.lastIndexOf("."));
return date + str + ext;
}
}
package edu.kh.project.board.model.exception;
// 사용자 정의 예외를 만드는 법
// -> Exception 관련 클래스를 상속받으면 된다.
// tip! unchecked exception을 만들고 싶으면
// RuntimeException을 상속받아 구현하면 된다.
// unchecked exception : 예외 처리 선택
// checked exception : 예외 처리 필수
// 예외 처리 : try-catch / throws
public class FileUploadException extends RuntimeException {
public FileUploadException() {
super("파일 업로드 중 예외 발생");
}
public FileUploadException(String message) {
super(message);
}
}
먼저 servlet-context.xml의 하단 Namespaces 탭에서 aop
와 tx
를 체크해 준다.
그리고 아래의 코드를 작성한다.
<!-- namespaces 탭에서 aop, tx 체크 -->
<!-- @Transactional 어노테이션 인식, 활성화 -->
<tx:annotation-driven transaction-manager="transactionManager"/>
<!-- AOP Proxy를 이용한 관점 제어 자동화 -->
<aop:aspectj-autoproxy/>
제목, 이미지, 내용을 모두 작성한 뒤 '등록' 버튼을 클릭하면
위와 같은 alert 창이 출력되며 게시글이 정상적으로 작성된다!