본문 바로가기

SpringBoot

SpringBoot(30) - 복습 예제(글 작성 및 수정 파일 첨부 기능 추가)

728x90
반응형

1) SpringBoot

   1-1) 복습 예제

      1-1-1) 글 작성 및 수정 파일 첨부 기능 추가

 

 

 

 

 

1) SpringBoot

1-1) 복습 예제

[src/main/resources] 안에 application.properties 파일 생성 후 아래와 같이 작성

spring.application.name=web5

#접속 포트번호
server.port=8888
#Context Path
server.servlet.context-path=/

#Logback 사용. 전체를 대상으로 로깅 레벨 지정
#error>warn>info>debug>trace
logging.level.root=info
#특정 패키지를 대상으로 로깅 레벨 지정
logging.level.net.datasa.web5=debug

# MySQL 데이터베이스 설정
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=Asia/Seoul&useUnicode=true&characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=root

# JPA 설정
# DB 구조와 Entity가 안 맞을 때 에러를 내주는 역할을 하는 것이 바로 및의 "~ddl-auto=validate" 설정이다.
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.type.descriptor.sql=trace

spring.jackson.time-zone=Asia/Seoul

# 파일 업로드 관련
spring.servlet.multipart.max-file-size=10MB
# spring.servlet.multipart.maxFileSize=10MB
spring.servlet.multipart.max-request-size=100MB
# spring.servlet.multipart.maxRequestSize=100MB
spring.servlet.multipart.location=c:/tempUpload

# 게시판 관련 설정
board.pageSize=10
board.linkSize=2
# 사람들이 글을 쓰면 이 첨부파일(c:/upload)에 모여있을 것이다!
board.uploadPath=c:/upload

 

 

1-1-1) 글 작성 및 수정 파일 첨부 기능 추가

(1) [src/main/resources] - [templates.boardView] 안에 writeForm.html 파일 생성 후 아래와 같이 작성

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://thymeleaf.org"
	xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
	<meta charset="UTF-8">
	<title>글쓰기</title>
	<style>
		#writeArea {
			width: 900px;
			margin: 0 auto;
			text-align: center;
		}
		
		#writeForm {
			display: flex;
			flex-direction: column;
			justify-content: center;
		}
			
		#formArea {
			display: flex;
			justify-content: center;
		}
		
		table, tr, td {
			border: 2px solid black;
        	border-collapse: collapse;
        	padding: 10px;
        	margin: 20px 0 15px 0;
		}
		
		th {
			width: 100px;
			background-color: grey;
			color: white;
		}
		
		td {
			width: 300px;
			text-align: left;
		}
		
		td > .writeContent {
			width: 300px;
		}
		
		td > textarea {
			height: 250px;
		}
		
	</style>
	<script th:src="@{/js/jquery-3.7.1.min.js}"></script>
	<script>
	$(document).ready(function() {
		$("#writeForm").submit(check);
	});
	
	function check() {
		let title = $("#title").val();
		let contents = $("#contents").val();
		
		if (title.length < 7) {
			alert("제목은 최소 7자 이상으로 반드시 입력해주세요!!");
			$("#title").focus();
			$("#title").val('');
			
	        return false;
		}
		
		if (contents.length < 20) {
			alert("내용은 20자 이상으로 반드시 입력해주세요!!");
			$("#contents").focus();
			$("#contents").val('');
			
	        return false;
		}
		
		return true;
		
	}
	
	</script>
</head>
<body>
	<div id="writeArea">
		<h1>[ 글쓰기 ]</h1>
		
		<!-- 파일이 안 올라가면 이 부분과 같이 작성했는지 반드시 확인할 것 -->
		<form th:action="@{/board/write}" method="post" id="writeForm"
			enctype="multipart/form-data">
			<div id="formArea">
				<table>
					<tr>
						<th>
							<label for="title">제목</label>
						</th>
						<td>
							<input type="text" name="title" id="title" class="writeContent" />
						</td>
					</tr>
					<tr>
						<th>
							<label for="contents">내용</label>
						</th>
						<td>
							<textarea name="contents" id="contents" class="writeContent"></textarea>
						</td>
					</tr>
					<tr>
						<th>파일첨부</th>
						<td>
							<input type="file" name="upload" id="upload" class="writeContent" />
						</td>
					</tr>
				</table>
			</div>
			
	        <div id="btnArea">
	        	<input type="submit" value="저장" />
	        </div>
		</form>
	</div>
</body>
</html>

 

 

(2) [src/main/java] - [net.datasa.web5.controller] 안에 BoardController.java 파일 생성 후 아래와 같이 작성

package net.datasa.web5.controller;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
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.multipart.MultipartFile;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.datasa.web5.domain.dto.BoardDTO;
import net.datasa.web5.domain.dto.ReplyDTO;
import net.datasa.web5.security.AuthenticatedUser;
import net.datasa.web5.service.BoardService;

/**
 * 게시판 관련 Controller
 */
@Slf4j
@RequestMapping("board")
@RequiredArgsConstructor
@Controller
public class BoardController {
	
	private final BoardService boardService;
	
	//application.properties 파일의 게시판 관련 설정값
	@Value("${board.pageSize}")
	int pageSize;
	
	@Value("${board.linkSize}")
	int linkSize;
	
	@Value("${board.uploadPath}")
	String uploadPath;
	
	// 메인화면에서 "board/list" 경로를 클릭했을 때 처리하는 메소드
	// templates/boardView/list.html 파일로 포워딩
	// 로그인 안 한 상태에서 해당 페이지의 "게시판"이라는 제목이 보여야 함
	/**
     * 게시판 목록을 조회하고 페이징 및 검색 기능을 제공
     *
     * @param model       모델 객체
     * @param page        현재 페이지 (default: 0)
     * @param searchType  검색 대상 (default: "")
     * @param searchWord  검색어 (default: "")
     * @return 글 목록  한 페이지
     */
	@GetMapping("list")
	public String list(Model model,
			@RequestParam(name="page", defaultValue = "1") int page,
			@RequestParam(name="searchType", defaultValue = "") String searchType,
			@RequestParam(name="searchWord", defaultValue = "") String searchWord) {
		
		log.debug("properties의 값 : pageSize={}, linkSize={}, uploadPath={}",
				pageSize, linkSize, uploadPath);
		log.debug("요청 파라미터 : page={}, searchType={}, searchWord={}",
				page, searchType, searchWord);
		
		// 서비스로 (페이지, 페이지당 글 수, 검색조건, 검색어) 전달하여 결과를 Page 객체로 받음
		Page<BoardDTO> boardPage = boardService.getList(page, pageSize, searchType, searchWord);
		
		// Page 객체 내의 정보들
		log.debug("목록정보 getContent() : {}", boardPage.getContent());
		log.debug("현재페이지 getNumber() : {}", boardPage.getNumber());
		log.debug("전체 개수 getTotalElements() : {}", boardPage.getTotalElements());
		log.debug("전체 페이지수 getTotalPages() : {}", boardPage.getTotalPages());
		log.debug("한페이지당 글 수 getSize() : {}", boardPage.getSize());
		log.debug("이전페이지 존재 여부 hasPrevious() : {}", boardPage.hasPrevious());
		log.debug("다음페이지 존재 여부 hasNext() : {}", boardPage.hasNext());
		
		// HTML로 포워딩하기 전에 모델에 필요 정보들을 저장
		model.addAttribute("boardPage", boardPage);
		model.addAttribute("page", page);
		model.addAttribute("linkSize", linkSize);
		model.addAttribute("searchType", searchType);
		model.addAttribute("searchWord", searchWord);
		
		// HTML로 포워딩
		return "boardView/list";
		
		/*
		// 서비스에서 전체 글 목록 전달받음
		List<BoardDTO> boardList = boardService.getList(searchWord);
		
		log.debug("전달된 글 목록 : {}", boardList);
		
		// 글 목록을 모델에 저장하고 HTML로 포워딩하여 출력
		model.addAttribute("boardList", boardList);
		
		return "boardView/list";
		*/

	}
	
	/**
	 * 글쓰기 폼으로 이동
	 * @return 글쓰기 폼을 출력하는 HTML 파일
	 * */
	@GetMapping("write")
	public String write() {
		return "boardView/writeForm";
	}
	
	/**
     * 글 저장
     * @param boardDTO 작성한 글 정보 (제목, 내용)
     * @param user 로그인한 사용자 정보
     * @return 게시판 글목록 경로
     */
	@PostMapping("write")
	public String write(@AuthenticationPrincipal AuthenticatedUser user,
			@ModelAttribute BoardDTO boardDTO,
			@RequestParam("upload") MultipartFile upload) {
		log.debug("전달된 글정보 : {}", boardDTO);
		
		// 작성한 글에 사용자 아이디 추가
		boardDTO.setMemberId(user.getUsername());
		
		log.debug("저장할 글 정보 : {}", boardDTO);
		
		if (upload != null) {
			log.debug("파일 존재 여부 : {}", upload.isEmpty());
			log.debug("파라미터 이름 : {}", upload.getName());
			log.debug("파일의 이름 : {}", upload.getOriginalFilename());
			log.debug("크기 : {}", upload.getSize());
			log.debug("파일 종류 : {}", upload.getContentType());
		}
		
		// 서비스로 전달하여 저장
		boardService.saveWrite(boardDTO, uploadPath, upload);
		
		return "redirect:list";
	}
	
	/**
     * 게시글 상세보기
     * @param model     모델
     * @param boardNum  조회할 글 번호
     * @return 게시글 상세보기 HTML 경로
     */
	@GetMapping("read")
	public String read(@RequestParam("boardNum") int boardNum,
			Model model) {
		
		try {
			BoardDTO dto = boardService.getBoard(boardNum);
			
			log.debug("선택한 글 정보 : {}", dto);
			
			// 해당 게시글을 모델에 저장하고 HTML로 포워딩하여 출력
			model.addAttribute("boardDTO", dto);
			
			return "boardView/readForm";
			
		} catch (Exception e) {
			// e.printStackTrace() : 예외가 발생할 시 Console에 에러를 출력해주는 역할을 함
			e.printStackTrace();
			return "redirect:list";
		}
		
	}
	
	/**
	 * 본인 게시글 삭제
	 * @param boardNum  삭제할 글 번호
	 * @param user      로그인한 사용자 정보
	 * @return          게시판 글 목록 보기 경로
	 * */
	@GetMapping("delete")
	public String delete(@RequestParam("boardNum") int boardNum,
			@AuthenticationPrincipal AuthenticatedUser user) {
		
		try {
			// 삭제할 글 번호와 로그인한 아이디를 서비스로 전달하여 본인 글인 경우에만 삭제
			boardService.delete(boardNum, user.getUsername(), uploadPath);
		} catch (Exception e) {
			e.printStackTrace();
		}
		
		return "redirect:list";
	}
	
	/**
     * 게시글 수정 폼으로 이동
     * @param boardNum      수정할 글번호
     * @param user          로그인한 사용자 정보
     * @return              수정폼 HTML
     */
	@GetMapping("update")
	public String update(@RequestParam("boardNum") int boardNum,
			@AuthenticationPrincipal AuthenticatedUser user,
			Model model) {
		
		try {
            BoardDTO boardDTO = boardService.getBoard(boardNum);
            if (!user.getUsername().equals(boardDTO.getMemberId())) {
                throw new RuntimeException("수정 권한이 없습니다.");
            }
            model.addAttribute("board", boardDTO);
            return "boardView/updateForm";
        }
        catch (Exception e) {
            e.printStackTrace();
            return "redirect:list";
        }
		
	}
	
	/**
     * 게시글 수정 처리
     * @param boardDTO      수정할 글 정보
     * @param user          로그인한 사용자 정보
     * @return              수정폼 HTML
     */
	// update form에서 글 내용 수정하고 "수정" 버튼 누르면 받아서 수정된 글 저장
	@PostMapping("update")
	public String update(@ModelAttribute BoardDTO boardDTO,
			@AuthenticationPrincipal AuthenticatedUser user,
			@RequestParam("upload") MultipartFile upload) {
		
		try {
            boardService.update(boardDTO, user.getUsername(), uploadPath, upload);
            return "redirect:read?boardNum=" + boardDTO.getBoardNum();

        }
        catch (Exception e) {
            e.printStackTrace();
            return "redirect:list";
        }
	}
	
	/**
     * 리플 쓰기
     * @param replyDTO      저장할 리플 정보
     * @param user          로그인 사용자 정보
     * @return              게시글 보기 경로로 이동
     */
	// read form에서 리플 입력란에 내용 입력 후 "확인" 버튼 누르면 새로 입력한 리플 저장
	@PostMapping("rippleWrite")
	public String rippleWrite(@ModelAttribute ReplyDTO replyDTO,
			@AuthenticationPrincipal AuthenticatedUser user) {
			
		replyDTO.setMemberId(user.getUsername());
			
		boardService.rippleWrite(replyDTO);
			
		return "redirect:read?boardNum=" + replyDTO.getBoardNum();
	}
	
	/**
     * 리플 삭제
     * @param replyDTO 삭제할 리플번호와 본문 글번호
     * @param user 로그인한 사용자 정보
     * @return 게시글 상세보기 경로
     */
	@GetMapping("rippleDelete")
	public String rippleDelete(@ModelAttribute ReplyDTO replyDTO,
			@AuthenticationPrincipal AuthenticatedUser user) {
		
		try {
			log.debug("삭제할 replyDTO : {}", replyDTO);
			
            boardService.rippleDelete(replyDTO.getReplyNum(), user.getUsername());
        }
        catch (Exception e) {
            e.printStackTrace();
        }
        return "redirect:read?boardNum=" + replyDTO.getBoardNum();
		
	}
	
}

 

 

(3) [src/main/java] - [net.datasa.web5.service] 안에 BoardService.java 파일 생성 후 아래와 같이 작성

package net.datasa.web5.service;

import java.io.File;
import java.io.IOException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import jakarta.persistence.EntityNotFoundException;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.datasa.web5.domain.dto.BoardDTO;
import net.datasa.web5.domain.dto.ReplyDTO;
import net.datasa.web5.domain.entity.BoardEntity;
import net.datasa.web5.domain.entity.MemberEntity;
import net.datasa.web5.domain.entity.ReplyEntity;
import net.datasa.web5.repository.BoardRepository;
import net.datasa.web5.repository.MemberRepository;
import net.datasa.web5.repository.ReplyRepository;

/**
 * 게시판 관련 서비스
 * */
@Slf4j
@RequiredArgsConstructor
@Service
// @Transactional : Commit, Rollback을 감지하는 역할을 하는 Annotation
@Transactional
public class BoardService {
	
	private final BoardRepository boardRepository;
	private final MemberRepository memberRepository;
	private final ReplyRepository replyRepository;

	/**
	 * 게시판 글 저장
	 * @param boardDTO 게시판 글 정보
	 * */
	public void saveWrite(BoardDTO boardDTO, String uploadPath, MultipartFile upload) {
		MemberEntity memberEntity = memberRepository.findById(boardDTO.getMemberId())
				.orElseThrow(() -> new EntityNotFoundException("회원 아이디가 없습니다."));
		
		BoardEntity entity = new BoardEntity();
		entity.setMember(memberEntity);
		entity.setTitle(boardDTO.getTitle());
		entity.setContents(boardDTO.getContents());
		
		// 첨부파일이 있는지 확인
		if (upload != null && !upload.isEmpty()) {
			// 저장할 경로의 디렉토리가 있는지 확인 -> 없으면 생성
			File directoryPath = new File(uploadPath);
			if (!directoryPath.isDirectory()) {
				directoryPath.mkdirs();
			}
			
			// 저장할 파일명 생성
			// 내 이력서.doc -> 20240806_6156df53-a49b-4419-a336-cb6da0fa9640.doc
			String originalName = upload.getOriginalFilename();
			String extension = originalName.substring(originalName.lastIndexOf("."));  // 확장자를 가져옴
			String dateString = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
			String uuidString = UUID.randomUUID().toString();
			String fileName = dateString + "_" + uuidString + extension;
			
			// 파일 이동
			try {
				File file = new File(uploadPath, fileName);
				upload.transferTo(file);
				
				// entity에 원래 파일명, 저장된 파일명 추가
				entity.setOriginalName(originalName);
				entity.setFileName(fileName);
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
		
		log.debug("저장되는 entity : {}", entity);
		
		// DB에 저장
		boardRepository.save(entity);
		
	}

	/**
	 * 검색 결과 글 목록을 지정한 한 페이지 분량의 Page 객체로 리턴
	 * @param page         현재 페이지
	 * @param pageSize     페이지당 글 수
	 * @param searchType   검색조건(제목 검색: title, 본문 검색: contents, 작성자 검색: id)
	 * @param searchWord   검색어
	 * @return             게시글 목록 정보
	 * */
	public Page<BoardDTO> getList(int page, int pageSize, String searchType, String searchWord) {
		// 조회 조건을 담은 Pageable 객체 생성
		// page의 경우, 배열의 인덱스처럼 0부터 시작하기에 1페이지(인덱스로 0페이지에 해당)를 가져오기 위해 "page - 1"로 작성함
		Pageable p = PageRequest.of(page - 1, pageSize, Sort.Direction.DESC, "boardNum");
		
		Page<BoardEntity> entityPage;
		
		// repository의 메소드로 Pageable 전달하여 조회. Page 리턴받음.		
		if (searchType.equals("title")) {
			entityPage = boardRepository.findByTitleContaining(searchWord, p);
		} else if (searchType.equals("contents")) {
			entityPage = boardRepository.findByContentsContaining(searchWord, p);
		} else if (searchType.equals("id")) {
			entityPage = boardRepository.findByMember_MemberId(searchWord, p);
		} else {
			entityPage = boardRepository.findAll(p);
		}
		
		log.debug("조회된 결과 Entity 페이지 : {}", entityPage.getContent());
		
		// entityPage 객체 내의 Entity들을 DTO 객체로 변환하여 새로운 Page 객체 생성
		Page<BoardDTO> dtoPage = entityPage.map(this::convertToDTO);
		
		return dtoPage;
	}
	
	/**
     * DB에서 조회한 게시글 정보인 BoardEntity 객체를 BoardDTO 객체로 변환
     * @param entity    게시글 정보 Entity 객체
     * @return          게시글 정보 DTO 개체
     */
    private BoardDTO convertToDTO(BoardEntity entity) {
        return BoardDTO.builder()
            .boardNum(entity.getBoardNum())
            .memberId(entity.getMember() != null ? entity.getMember().getMemberId() : null)
            .memberName(entity.getMember() != null ? entity.getMember().getMemberName() : null)
            .title(entity.getTitle())
            .contents(entity.getContents())
            .viewCount(entity.getViewCount())
            .likeCount(entity.getLikeCount())
            .originalName(entity.getOriginalName())
            .fileName(entity.getFileName())
            .createDate(entity.getCreateDate())
            .updateDate(entity.getUpdateDate())
            .build();
    }

    /**
     * ReplyEntity객체를 ReplyDTO 객체로 변환
     * @param entity    리플 정보 Entity 객체
     * @return          리플 정보 DTO 객체
     */
    private ReplyDTO convertToReplyDTO(ReplyEntity entity) {
        return ReplyDTO.builder()
                .replyNum(entity.getReplyNum())
                .boardNum(entity.getBoard().getBoardNum())
                .memberId(entity.getMember().getMemberId())
                .memberName(entity.getMember().getMemberName())
                .contents(entity.getContents())
                .createDate(entity.getCreateDate())
                .build();
    }

    /**
     * 게시글 1개 조회
     * @param boardNum          글번호
     * @return the BoardDTO     글 정보
     * @throws EntityNotFoundException 게시글이 없을 때 예외
     */
	// localhost:8888/board/read?boardNum=1
	public BoardDTO getBoard(int boardNum) {
		BoardEntity entity = boardRepository.findById(boardNum)
				.orElseThrow(() -> new EntityNotFoundException("해당 번호의 글이 없습니다."));
		
		entity.setViewCount(entity.getViewCount() + 1);
        log.debug("{}번 게시물 조회 결과 : {}", boardNum, entity);
		
		BoardDTO dto = convertToDTO(entity);
		
		List<ReplyDTO> replyDTOList = new ArrayList<ReplyDTO>();
        for (ReplyEntity replyEntity : entity.getReplyList()) {
            ReplyDTO replyDTO = convertToReplyDTO(replyEntity);
            replyDTOList.add(replyDTO);
        }
        dto.setReplyList(replyDTOList);
        return dto;
	}

	/**
     * 게시글 삭제
     * @param boardNum  삭제할 글번호
     * @param username  로그인한 아이디
     */
	public void delete(int boardNum, String username, String uploadPath) {
		BoardEntity boardEntity = boardRepository.findById(boardNum)
				.orElseThrow(() -> new EntityNotFoundException("게시글이 없습니다."));
		
		if (!boardEntity.getMember().getMemberId().equals(username)) {
            throw new RuntimeException("삭제 권한이 없습니다.");
        }
		// 첨부파일이 있으면 삭제
		if (boardEntity.getFileName() != null && !boardEntity.getFileName().isEmpty()) {
			File file = new File(uploadPath, boardEntity.getFileName());
			file.delete();
		}
		
        boardRepository.delete(boardEntity);
		
	}

	/**
     * 게시글 수정
     * @param boardDTO      수정할 글정보
     * @param username      로그인한 아이디
     */
	public void update(BoardDTO boardDTO, String username, String uploadPath, MultipartFile upload) {
		BoardEntity boardEntity = boardRepository.findById(boardDTO.getBoardNum())
				.orElseThrow(() -> new EntityNotFoundException("게시글이 없습니다."));
		
		if (!boardEntity.getMember().getMemberId().equals(username)) {
            throw new RuntimeException("수정 권한이 없습니다.");
        }
		
		log.debug("boardDTO 수정 전 첨부 파일 확인용 : {}", boardDTO);

		// 수정하면서 새로 첨부한 파일이 있으면(새로 첨부한 파일에 관한 정보는 "upload"에 담겨 있다.)
		if (upload != null && !upload.isEmpty()) {
			// 그 전에 업로드한 기존 파일이 있으면 먼저 파일 삭제
			if (boardEntity.getFileName() != null && !boardEntity.getFileName().isEmpty()) {
				File file = new File(uploadPath, boardEntity.getFileName());
				file.delete();
			}
			
			// 새로운 파일의 이름을 바꿔서 복사
			String originalName = upload.getOriginalFilename();
			String extension = originalName.substring(originalName.lastIndexOf("."));  // 확장자를 가져옴
			String dateString = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
			String uuidString = UUID.randomUUID().toString();
			String updateFileName = dateString + "_" + uuidString + extension;
			
			String updateOriginName = upload.getOriginalFilename();
			
			// 파일 이동
			try {
				File file = new File(uploadPath, updateFileName);
				upload.transferTo(file);
							
				// Entity에 새 파일의 원래이름, 저장된 이름 추가
				boardEntity.setOriginalName(updateOriginName);
				boardEntity.setFileName(updateFileName);
			} catch (IOException e) {
				e.printStackTrace();
			}

		}
		
        //전달된 정보 수정
        boardEntity.setTitle(boardDTO.getTitle());
        boardEntity.setContents(boardDTO.getContents());
	}

	/**
     * 리플 저장
     * @param replyDTO 작성한 리플 정보
     * @throws EntityNotFoundException 사용자 정보가 없을 때 예외
     */
	public void rippleWrite(ReplyDTO replyDTO) {
		MemberEntity memberEntity = memberRepository.findById(replyDTO.getMemberId())
                .orElseThrow(() -> new EntityNotFoundException("사용자 아이디가 없습니다."));

        BoardEntity boardEntity = boardRepository.findById(replyDTO.getBoardNum())
                .orElseThrow(() -> new EntityNotFoundException("게시글이 없습니다."));

        ReplyEntity entity = ReplyEntity.builder()
                .board(boardEntity)
                .member(memberEntity)
                .contents(replyDTO.getContents())
                .build();
		
		log.debug("저장되는 entity : {}", entity);
		
		// DB에 저장
		replyRepository.save(entity);
		
	}

	/**
     * 리플 삭제
     * @param replyNum  삭제할 리플 번호
     * @param username  로그인한 아이디
     */
	public void rippleDelete(Integer replyNum, String username) {
		ReplyEntity replyEntity = replyRepository.findById(replyNum)
				.orElseThrow(() -> new EntityNotFoundException("리플이 없습니다."));
		
		if (!replyEntity.getMember().getMemberId().equals(username)) {
            throw new RuntimeException("삭제 권한이 없습니다.");
        }
		
		replyRepository.delete(replyEntity);
		
	}

	
	/**
	 * 게시판의 글 목록 조회
	 * @return 글 목록 정보
	 * */
	/*
	public List<BoardDTO> getList(String searchWord) {
		// BoardRepository의 메소드를 호출하여 게시판의 모든 글 정보를 조회
		// Entity의 개수만큼 반복하면서 Entity의 값을 BoardDTO 객체를 생성하여 저장
		// 생성된 BoardDTO 객체를 ArrayList에 저장
		// 최종 완성된 ArrayList 객체를 리턴함
		
		// Sort : SQL의 order by 구문을 만들어주는 역할
		Sort sort = Sort.by(Sort.Direction.DESC, "boardNum");
		
		List<BoardEntity> entityList = boardRepository.findByTitleContaining(searchWord, sort);

		// List<BoardEntity> entityList = boardRepository.findTop3ByTitleContainingOrderByBoardNum(searchWord);
		
		// List<BoardEntity> entityList = boardRepository.findAll(sort);
		
		List<BoardDTO> boardDTOList = new ArrayList<>();
				
		// 반복문으로 Entity 객체를 DTO로 변환해서 ArrayList에 저장
		for (BoardEntity entity : entityList) {
			BoardDTO dto = BoardDTO.builder()
					.boardNum(entity.getBoardNum())
					.memberId(entity.getMember().getMemberId())
					.memberName(entity.getMember().getMemberName())
					.title(entity.getTitle())
					.viewCount(entity.getViewCount())
					.createDate(entity.getCreateDate())
					.updateDate(entity.getUpdateDate())
					.build();
			boardDTOList.add(dto);
		}
		
		return boardDTOList;
	}
	*/

}

 

 

(4) [src/main/java] - [net.datasa.web5.domain.entity] 안에 BoardEntity.java 파일 생성 후 아래와 같이 작성

package net.datasa.web5.domain.entity;

import java.time.LocalDateTime;
import java.util.List;

import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name="web5_board")
@EntityListeners(AuditingEntityListener.class)
public class BoardEntity {
	
	/*
	board_num         int            auto_increment primary key,    		-- 게시글 일련번호  
	member_id         varchar(30),       						            -- 작성자 ID(외래키 : web5_member 테이블 참조)      
	title       	  varchar(1000)  not null,       						-- 글제목     
	contents          text           not null,                   			-- 글내용  
	view_count        int            default 0,                   			-- 조회수
	like_count        int            default 0,                  			-- 추천수
	original_name     varchar(300),                                         -- 첨부파일 원래 이름
	file_name         varchar(100),                                         -- 첨부파일 저장된 이름
	create_date       timestamp      default current_timestamp,             -- 작성 시간
	update_date       timestamp      default current_timestamp
										on update current_timestamp,        -- 수정 시간
	constraint foreign key (member_id)
    			references web5_member(member_id) on delete set null
	*/
	
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name="board_num")
	private Integer boardNum;
	
	// 작성자 아이디
	// @Column(name="member_id", length = 30)
	// private String memberId;
	
	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name="member_id", referencedColumnName = "member_id")
	MemberEntity member;
	
	// 글 제목
	@Column(name="title", nullable = false, length = 1000)
	private String title;
	
	// 글 내용
	@Column(name="contents", nullable = false, columnDefinition = "text")
	private String contents;
	
	// 조회수
	@Column(name="view_count", columnDefinition = "int default 0")
	private Integer viewCount = 0;
	
	// 추천수
	@Column(name="like_count", columnDefinition = "int default 0")
	private Integer likeCount = 0;
	
	// 첨부파일 원래 이름
	@Column(name="original_name", length = 300)
	private String originalName;
	
	// 첨부파일 저장된 이름
	@Column(name="file_name", length = 100)
	private String fileName;
	
	// 작성 시간
	@CreatedDate
	@Column(name="create_date", columnDefinition = "timestamp default current_timestamp")
	private LocalDateTime createDate;
	
	// 수정 시간
	@LastModifiedDate
	@Column(name="update_date", columnDefinition = "timestamp default current_timestamp on update current_timestamp")
	private LocalDateTime updateDate;
	
	// 해당 글에 달린 리플들 목록
	// 여기서 'mappedBy = "board"'는 ReplyEntity의 "board(BoardEntity 타입의 객체) 변수"를 가리킴
	@OneToMany(mappedBy = "board", cascade = CascadeType.ALL, orphanRemoval = true)
	private List<ReplyEntity> replyList;
}

 

 

(5) [src/main/resources] - [templates.boardView] 안에 readForm.html 파일 생성 후 아래와 같이 작성

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://thymeleaf.org"
	xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
	<meta charset="UTF-8">
	<title>게시글 읽기</title>
	<style>
		a {
			text-decoration-line: none;
			color: black;
			font-weight: bold;
		}
	
		#readArea {
			width: 900px;
			margin: 0 auto;
			text-align: center;
		}
		
		#readForm {
			display: flex;
			flex-direction: column;
			justify-content: center;
		}
			
		#formArea {
			display: flex;
			justify-content: center;
			margin-bottom: 50px;
		}
		
		#boardRead {
			width: 800px;
		}
		
		#boardRead, #boardRead tr, #boardRead td {
			border: 2px solid black;
        	border-collapse: collapse;
        	padding: 10px;
        	margin: 20px 0 15px 0;
		}
		
		th {
			width: 100px;
			height: 40px;
			background-color: grey;
			color: white;
		}
		
		#boardRead td {
			width: 500px;
			height: 40px;
			text-align: left;
		}
		
		#replyForm {
			margin: 50px 0;
		}
		
		#rippleList {
		    width: 800px;
		    margin: 0 auto;
		    border-left: 2px solid black;
        	border-collapse: collapse;
		}
		
		#rippleList .visible {
			border: 2px solid black;
        	border-collapse: collapse;
		}
		
		#rippleList th {
			width: 120px;
		}
		
		#rippleContents {
			width: 470px;
		}
		
		#rippleDate {
			width: 150px;
		}
		
		#rippleDel {
			border: none;
		}
		
	</style>
	<script th:src="@{/js/jquery-3.7.1.min.js}"></script>
	<script>
		$(document).ready(function() {
			//글 삭제
			$('#deleteBtn').click(function() {
				let boardNum = $(this).data('num');
				if (confirm('삭제하시겠습니까?')) {
					location.href = 'delete?boardNum=' + boardNum;
				}
			});
	
			//글 수정
			$('#updateBtn').click(function() {
				let boardNum = $(this).data('num');
				location.href = 'update?boardNum=' + boardNum;
			});
	
			//리플 작성
			$('#replyForm').submit(function() {
				if ($('#contents').val().length < 5) {
					alert('리플 내용을 5자 이상으로 입력하세요.');
					$('#contents').focus();
					$('#contents').select();
					return false;
				}
				return true;
			});
		});
	
		//리플 삭제
		function rippleDelete(replyNum, boardNum) {
			if (confirm('삭제하시겠습니까?')) {
				location.href = `rippleDelete?replyNum=${replyNum}&boardNum=${boardNum}`;
			}
		}
	</script>
</head>
<body>
	<div id="readArea">
		<h1>[ 게시글 읽기 ]</h1>
		
		<div id="readForm">
			<div id="formArea">
				<table id="boardRead">
					<tr>
						<th>작성자</th>
						<td th:text="${boardDTO.memberId}"></td>
					</tr>
					<tr>
						<th>작성일</th>
						<td th:text="${#temporals.format(boardDTO.createDate, 'yyyy.MM.dd HH:mm:ss')}"></td>
					</tr>
					<tr>
						<th>조회수</th>
						<td th:text="${boardDTO.viewCount}"></td>
					</tr>
					<tr>
						<th>제목</th>
						<td th:text="${boardDTO.title}"></td>
					</tr>
					<tr>
						<th>내용</th>
						<td th:text="${boardDTO.contents}"></td>
					</tr>
					<tr>
						<th>파일첨부</th>
						<td th:text="${boardDTO.originalName}"></td>
					</tr>
				</table>
			</div>
			
			<!-- 글 수정/삭제(본인글일 때만 보임) -->
	        <div id="btnArea" th:if="${#authentication.name} == ${boardDTO.memberId}">
	        	<input type="button" id="updateBtn" value="수정" th:data-num="${boardDTO.boardNum}">
	        	<input type="button" id="deleteBtn" value="삭제" th:data-num="${boardDTO.boardNum}">
	        </div>
	        
	        <!-- 리플 작성 폼(로그인했을 때만 보임) -->
	        <div id="rippleArea" sec:authorize="isAuthenticated()">
	        	<form th:action="@{/board/rippleWrite}" method="post" id="replyForm">
	        		<span>리플내용 </span>
	        		<input type="text" name="contents" id="contents">
	        		<!-- controller의 rippleWrite 메소드(PostMapping)에 글 번호를 넘겨주기 위해 추가한 내용이다. -->
	        		<input type="hidden" name="boardNum" id="boardNum" th:value="${boardDTO.boardNum}" />
	        		<!-- <button>저장</button> -->
	        		<input type="submit" value="저장" />
	        	</form>
	        </div>
	        
	        <!-- 리플 목록 출력(다 보임) -->
	        <table id="rippleList">
	        	<tr th:each="reply : ${boardDTO.replyList}">
	        		<th th:text="${reply.memberId}" class="visible"></th>
	        		<td th:text="${reply.contents}" id="rippleContents" class="visible"></td>
	        		<td th:text="${#temporals.format(reply.createDate, 'yy.MM.dd HH:mm')}" id="rippleDate" class="visible"></td>
	        		<td th:if="${#authentication.name} == ${reply.memberId}" id="rippleDel">
	        			<a th:href="|javascript:rippleDelete(${reply.replyNum}, ${reply.boardNum})|" id="rippleDelBtn">X</a>
	        		</td>
	        	</tr>
	        </table>

		</div>
	</div>
</body>
</html>

 

 

(6) [src/main/resources] - [templates.boardView] 안에 updateForm.html 파일 생성 후 아래와 같이 작성

<!DOCTYPE html>
<html>
<head>
	<meta charset="UTF-8">
	<title>글수정</title>
	<style>
		#updateArea {
			width: 900px;
			margin: 0 auto;
			text-align: center;
		}
		
		#updateForm {
			display: flex;
			flex-direction: column;
			justify-content: center;
		}
			
		#formArea {
			display: flex;
			justify-content: center;
		}
		
		table, tr, td {
			border: 2px solid black;
        	border-collapse: collapse;
        	padding: 10px;
        	margin: 20px 0 15px 0;
		}
		
		th {
			width: 100px;
			background-color: grey;
			color: white;
		}
		
		td {
			width: 300px;
			text-align: left;
		}
		
		td > .writeContent {
			width: 300px;
		}
		
		td > textarea {
			height: 250px;
		}
		
	</style>
	<script th:src="@{/js/jquery-3.7.1.min.js}"></script>
	<script>
	$(document).ready(function() {
		$("#writeForm").submit(check);
		
		if ("[[${msg}]]") {
			alert("[[${msg}]]");
		}
	});
	
	function check() {
		let title = $("#title").val();
		let contents = $("#contents").val();
		
		if (title.length < 7) {
			alert("제목은 최소 7자 이상으로 반드시 입력해주세요!!");
			$("#title").focus();
			$("#title").val('');
			
	        return false;
		}
		
		if (contents.length < 20) {
			alert("내용은 20자 이상으로 반드시 입력해주세요!!");
			$("#contents").focus();
			$("#contents").val('');
			
	        return false;
		}
		
		return true;
		
	}
	
	</script>
</head>
<body>
	<div id="updateArea">
		<h1>[ 글수정 ]</h1>
		
		<form th:action="@{/board/update}" method="post" id="updateForm"
			enctype="multipart/form-data">
			<div id="formArea">
			    <!-- controller의 update 메소드(PostMapping)에 글 번호를 넘겨주기 위해 추가한 내용이다. -->
			    <input type="hidden" name="boardNum" id="boardNum" th:value="${board.boardNum}" />
				<table>
					<tr>
						<th>
							<label for="title">제목</label>
						</th>
						<td>
							<input type="text" name="title" id="title" th:value="${board.title}" class="writeContent" />
						</td>
					</tr>
					<tr>
						<th>
							<label for="contents">내용</label>
						</th>
						<td>
							<textarea name="contents" id="contents" class="writeContent">[[${board.contents}]]</textarea>
						</td>
					</tr>
					<tr>
						<th>파일첨부</th>
						<td>
							<input type="file" name="upload" id="upload" th:value="${board.originalName}" class="writeContent" />
						</td>
					</tr>
				</table>
			</div>
			
	        <div id="btnArea">
	        	<input type="submit" value="수정" />
	        </div>
		</form>
	</div>

</body>
</html>

 

 

(7) 결과 화면

첫 접속 화면 : 메인 페이지, 게시판 글 목록 페이지는 로그인 안해도 볼 수 있음(현재는 글 작성 및 수정 기능 구현을 위해 아래 게시판 글 목록 화면은 로그인을 한 상태이다!)

 

 

 

글 작성 화면 : 파일 첨부 가능함(이미지 파일)

- 글 작성을 하려면 로그인해야 함

 

 

 

글 수정 화면 : 첨부된 파일 수정 가능함(이미지 파일)

- 글 수정을 하려면 로그인해야 함