const insertBtn = document.getElementById("insertBtn");
// 글쓰기 버튼을 클릭했을 때
if(insertBtn != null){
insertBtn.addEventListener("click", () => {
// JS BOM 객체 중 location
// location.href = '주소'
// 해당 주소 요청(GET 방식)
// 숫자가 나옴(boardCode)
// location.href = "/board2/" + location.pathname.split("/")[2] + "/insert";
location.href = `/board2/${location.pathname.split("/")[2]}/insert`;
// /board2/1/insert
})
}
// 게시글 작성 화면 전환
@GetMapping("/{boardCode:[0-9]+}/insert")
public String boardInsert(@PathVariable("boardCode") int boardCode) {
// @PathVariable : 주소 값 가져오기 + request scope에 값 올리기
return "board/boardWrite";
}
<%@ 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 가져옴(자동으로 request scope에 등록되어 있음) --%>
<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)
--> 파일 -> MutipartFile 객체 -> 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>
🔑 사진 선택 시 미리보기, x버튼 클릭 시/선택 후 취소 누를 시 다시 빈 화면
// 미리보기 관련 요소 모두 얻어오기
// 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("#boardWriteFrm > h1.board-title > input[type=text]"); // 해당 요소 우클릭 후 copy -> copy selector
const boardContent = document.getElementsByName("boardContent")[0];
boardWriteFrm.addEventListener("submit", e => {
if(boardTitle.value.trim().length == 0){
alert("제목을 입력해주세요");
boardTitle.focus();
boardTitle.value = "";
e.preventDefault();
return;
}
if(boardContent.value.trim().length == 0){
alert("내용을 입력해주세요");
boardContent.focus();
boardContent.value = "";
e.preventDefault();
return;
}
})
🔑 업로드된 이미지가 없다 != MultipartFile 객체 없다
--> 다만, 파일 크기가 0이다.
// 게시글 작성
@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<MulipartFile>
* - 업로드된 이미지가 없어도 List에 요소 MultipartFile객체가 추가됨
*
* - 단, 업로드된 이미지가 없는 MultipartFile 객체는
* 파일 크기(size)가 0 또는 파일명(getOriginalFileName())이 ""
* */
// 1. 로그인한 회원 번호를 얻어 와 board에 세팅
board.setMemberNo(loginMember.getMemberNo());
// 2. boardCode도 board에 세팅
System.out.println("보드코드 : " +board.getBoardCode()); // ????????????????????????
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;
}
📍 Cross Site Scripting(XSS) 방지 처리
권한이 없는 사용자가 사이트에 스크립트를 작성하는 것을 방지
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;
}
}
🔑 게시글(이미지 제외) 먼저 삽입
--> 게시글이 삽입될 번호를 알아야 한다.
// 게시글 삽입
@Transactional(rollbackFor = {Exception.class})
@Override
public int boardInsert(Board board, List<MultipartFile> images, String webPath, String filePath) throws IllegalStateException, IOException {
// 0. XSS 방지 처리
board.setBoardContent(Util.XSSHandling(board.getBoardContent()));
board.setBoardTitle(Util.XSSHandling(board.getBoardTitle()));
// 1. BOARD 테이블에 INSERT 하기(제목, 내용, 작성자, 게시판 코드)
// -> boardNo(시퀀스로 생성한 번호) 반환 받기
int boardNo = dao.boardInsert(board);
/** 게시글 삽입
* @param board
* @return boardNo
*/
public int boardInsert(Board board) {
int result = sqlSession.insert("boardMapper.boardInsert", board);
// -> sql 수행 후 매개변수 board객체에는 boardNo 존재 o
// 삽입 성공 시
if(result>0) result = board.getBoardNo();
return result; // 삽입 성공 시 boardNo, 실패 시 0 반환
}
📍 useGeneratedKeys
DB 내부적으로 생성한 키(시퀀스)를 전달된 파라미터의 필드로 대입 가능 여부 지정
📍 동적 SQL
프로그램 수행 중 SQL을 변경하는 기능
- selectKey 태그 : INSERT/UPDATE 시 사용할 키(시퀀스)를 조회해서 파라미터의 지정된 필드에 대입
- order 속성 : 메인 SQL이 수행되기 전/후에 selectKey가 수행되도록 지정
- 전 : before
- 후 : after
- keyProperty 속성 : selectKey 조회 결과를 저장할 파라미터의 필드
DB 내부적으로 생성한 키를 전달된 파라미터(BOARD)의 필드로 대입 가능하다.
insert문 전에 select문을 실행하고, select의 결과인 boardNo를 Board에 대입하며 결과는 int형이다.
<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>
📍 사용자 정의 예외 생성 -> 예외 강제로 발생
Exception 관련 클래스를 상속받는 방법으로 class 생성
cf. exception
- unchecked exception : 예외 처리 선택
- checked exception : 예외 처리 필수
public class FileUploadException extends RuntimeException{
// 기본 생성자
public FileUploadException() {
super("파일 업로드 중 예외 발생");
}
public FileUploadException(String message) {
super(message);
}
}
🔑 게시글 삽입 성공 시 이미지 업로드
(uploadList와 images 구별 주의)
// 2. 게시글 삽입 성공 시
// 업로드된 이미지가 있다면 BOARD_IMG 테이블에 삽입하는 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 필요
// @Tracsactional(rollback = Exceptional.class)
// -> 예외가 발생해야지만 롤백
// [결론]
// 예외를 강제 발생시켜서 rollback 해야 한다.
// -> 사용자 정의 예외 생성
throw new FileUploadException(); // 예외 강제로 발생시킴
}
}
}
return boardNo;
}
/** 이미지 리스트(여러 개)삽입
* @param uploadList
* @return result
*/
public int insertImageList(List<BoardImage> uploadList) {
return sqlSession.insert("boardMapper.insertImageList", uploadList);
}
📍 foreach
특정 SQL 구문을 반복할 때 사용(구분자 seperator를 추가한다)
- collection : 반복할 객체의 타입 작성(list, set, map...)
- item : collection에서 순차적으로 꺼낸 하나의 요소를 저장하는 변수
- index : 현재 반복 접근중인 인덱스 (0,1,2,3,4 ..)
- open : 반복 전에 출력할 sql
- close : 반복 종료 후에 출력한 sql
- separator : 반복 사이사이 구분자(양 끝에 띄어쓰기 넣어주기)
insert 구문 실행 중 foreach문 안에 있는 구문 반복해라.
list 타입 parameter인 uploadList의 각각 하나하나를 item이라고 칭하고, 한 번 반복문을 돌 때마다 구분자를 UNION ALL이라고 하겠다.
--> 향상된 for문과 비슷
<!-- 이미지 리스트(여러 개) 삽입 -->
<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>
위에서 이미지가 하나라도 삽입 실패할 경우, rollback할 수 있도록 작성해놨다. 하지만 실제로 이미지 삽입이 실패하더라도 게시글의 내용은 rollback되지 않고 commmit 됨
--> 설정 추가하기
<!-- @Transactional 어노테이션 인식, 활성화 -->
<tx:annotation-driven transaction-manager="transactionManager"/>
<!-- AOP Proxy를 이용한 관점 제어 자동화 -->
<aop:aspectj-autoproxy/>