게시판 - 글 작성 기능 구현 (23.08.28)

·2023년 8월 28일
1

Spring

목록 보기
23/36
post-thumbnail

🌷 게시글 작성


오늘은 게시글 작성 기능을 구현해 보자. 😉

- 삽입 : /board2/1/insert (code == BOARD_CODE, 게시판 종류)
- 수정 : /board2/1/1500/update (no == BOARD_NO, 게시글 번호)
- 삭제 : /board2/1/1500/delete

URL은 @PathVariable을 사용하여 위와 같은 형태로 만들어 볼 것이니 기억하고 있자!

또한 오늘은 MyBatis의 가장 강력한 기능인 동적 SQL을 사용해 볼 것이다.

💡 동적 SQL

프로그램 수행 중 SQL을 변경하는 기능

🌱 useGeneratedKeys 속성

DB 내부적으로 생성한 키(시퀀스)를 전달된 파라미터의 필드로 대입 가능 여부 지정

대입 가능 : true / 불가능 : false

🌱 selectKey 태그

INSERT/UPDATE 시 사용할 키(시퀀스)를 조회해서 파라미터의 지정된 필드에 대입

  • order 속성

    메인 SQL이 수행되기 전/후에 selectKey가 수행되도록 지정

    전 : "BEFORE" / 후 : "AFTER"
  • keyProperty 속성

    selectKey 조회 결과를 저장할 파라미터의 필드명


🌱 foreach 태그

특정 SQL 구문을 반복할 때 사용하는 태그
-> 반복되는 사이에 구분자(separator)를 추가할 수 있음

  • collection

    반복할 객체의 타입 작성(list, set, map...)

  • item

    collection에서 순차적으로 꺼낸 하나의 요소를 저장하는 변수

  • index

    현재 반복 접근중인 인덱스 (0,1,2,3,4 ..)

  • open

    반복 전에 출력할 sql

  • close

    반복 종료 후에 출력한 sql

  • separator

    반복 사이사이 구분자


MyBatis에 이런 기능이 있었다니 대혼란... 🤔
이 기능은 아래 Spring에서 코드로 자세히 다뤄 보자!


👀 코드로 살펴보기

🌼 VS Code

🌱 boardWrite.jsp

<%@ 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">&times;</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">&times;</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">&times;</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">&times;</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">&times;</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>

🌱 boardWrite-style.css

/* 상세조회 전체 영역 */
.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;
}

🌱 boardList.js

💭 location.href = "주소"

해당 "주소" 요청 (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
    })

}

🌱 boardWrite.js

// 미리보기 관련 요소 모두 얻어오기

// 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;
    }

})

🌼 Spring

💭 @PathVariable

주소 값 가져오기 + Request Scope에 값 올리기

🌱 BoardController2.java

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;
	}
	
}

🌱 BoardService2.java

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;

}

🌱 BoardServiceImpl2.java

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;
	}
	
}

🌱 BoardDAO2.java

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);
	}

}

🌱 board-mapper.xml

...
	<!-- 게시글 삽입 -->
	<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>

🌱 Util.java

package edu.kh.project.common.utility;

import java.text.SimpleDateFormat;

public class Util {

	// Cross Site Scripting(XSS) 방지 처리
	// - 웹 애플리케이션에서 발생하는 취약점
	// - 권한이 없는 사용자가 사이트에 스크립트를 작성하는 것
	public static String XSSHandling(String content) {

		// 스크립트나 마크업 언어에서 기호나 기능을 나타내는 문자를 변경 처리

		//   &  - &amp;
		//   <  - &lt;
		//   >  - &gt;
		//   "  - &quot;

		content = content.replaceAll("&", "&amp;");
		content = content.replaceAll("<", "&lt;");
		content = content.replaceAll(">", "&gt;");
		content = content.replaceAll("\"", "&quot;");

		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;
	}
}

🌱 FileUploadException.java

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

먼저 servlet-context.xml의 하단 Namespaces 탭에서 aoptx를 체크해 준다.
그리고 아래의 코드를 작성한다.

	<!-- namespaces 탭에서 aop, tx 체크 -->
	
	<!-- @Transactional 어노테이션 인식, 활성화 -->
	<tx:annotation-driven transaction-manager="transactionManager"/>

	<!-- AOP Proxy를 이용한 관점 제어 자동화 -->	
	<aop:aspectj-autoproxy/>

💻 구현 화면

제목, 이미지, 내용을 모두 작성한 뒤 '등록' 버튼을 클릭하면

위와 같은 alert 창이 출력되며 게시글이 정상적으로 작성된다!

profile
풀스택 개발자 기록집 📁

0개의 댓글