게시글 이미지 올리기 위해서 필요한 설정
config.properties
# 게시글 이미지 요청 주소 : 클라이언트가 어떤 이미지를 보고싶어 할 때 요청 보낼 주소
my.board.resource-handler=/images/board/**
# 게시글 이미지 요청 시 연결할 서버 폴더 경로
my.board.resource-location=file:///C:/uploadFiles/board/
# 게시글 이미지 요청 주소 (DB 저장용)
my.board.web-path=/images/board/
# 게시글 이미지를 서버에 저장할 때 사용할 경로 ( transferTo() )
my.board.folder-path=C:/uploadFiles/board/
FileConfig
// 게시판 이미지
@Value("${my.board.resource-handler}")
private String boardResourceHandler; // 요청 주소
@Value("${my.board.resource-location}")
private String boardResourceLocation; // 연결될 서버 폴더 경로
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 게시글 이미지 요청 - 서버 폴더 연결 추가
registry.addResourceHandler(boardResourceHandler)
.addResourceLocations(boardResourceLocation);
}
기존에 있던 메서드에 게시글 이미지 요청 -서버 폴더 연결 추가 하는 구문 추가 작성
/* BOARD_IMG 테이블용 시퀀스 생성 */
CREATE SEQUENCE SEQ_IMG_NO NOCACHE;
/* BOARD_IMG 테이블에 샘플 데이터 삽입 */
INSERT INTO "BOARD_IMG" VALUES(
SEQ_IMG_NO.NEXTVAL, '/images/board/', '원본1.jpg', 'test1.jpg', 0, 1950
);
INSERT INTO "BOARD_IMG" VALUES(
SEQ_IMG_NO.NEXTVAL, '/images/board/', '원본2.jpg', 'test2.jpg', 1, 1950
);
INSERT INTO "BOARD_IMG" VALUES(
SEQ_IMG_NO.NEXTVAL, '/images/board/', '원본3.jpg', 'test3.jpg', 2, 1950
);
INSERT INTO "BOARD_IMG" VALUES(
SEQ_IMG_NO.NEXTVAL, '/images/board/', '원본4.jpg', 'test4.jpg', 3, 1950
);
INSERT INTO "BOARD_IMG" VALUES(
SEQ_IMG_NO.NEXTVAL, '/images/board/', '원본5.jpg', 'test5.jpg', 4, 1950
);
COMMIT;
boardList.html
<tr th:each="board : ${boardList}" th:object="${board}">
<td th:text="*{boardNo}">게시글 번호</td>
<td>
<!-- 썸네일 추가 예정 -->
<a th:text="*{boardTitle}"
th:href="@{/board/{boardCode}/{boardNo}(boardCode=${boardCode}, boardNo=*{boardNo}, cp=${pagination.currentPage})}">게시글 제목</a>
<th:block th:text="|[*{commentCount}]|">댓글 수</th:block>
</td>
<!-- 작성자 닉네임 -->
<td th:text="*{memberNickname}">닉네임</td>
<!-- 작성일 -->
<td th:text="*{boardWriteDate}">2023-10-26</td>
<!-- 조회수 -->
<td th:text="*{readCount}">0</td>
<!-- 좋아요 수 -->
<td th:text="*{likeCount}">0</td>
</tr>
href () 안에서 {}로 자리 마련해둔 거 뒤에 , 뒤에 적힌 값은 쿼리스트링으로 넘어감
뒤에 cp 는 상세조회에서 목록으로 버튼 눌렀을 때 돌아오기 위해서
주소 확인 http://localhost/board/1/1950?cp=2
@GetMapping("{boardCode:[0-9]+}/{boardNo:[0-9]+}")
public String boardDetail(@PathVariable("boardCode") int boardCode,
@PathVariable("boardNo") int boardNo,
Model model,
RedirectAttributes ra) {
return "board/boardDetail"; // board/boardDetail.html 로 forward
}
내용이 복잡해서 3개로 나눠서 조회
-------------------------------------------------------
/* 게시글 상세 조회 */
SELECT BOARD_NO, BOARD_TITLE, BOARD_CONTENT, BOARD_CODE, READ_COUNT,
MEMBER_NO, MEMBER_NICKNAME, PROFILE_IMG,
TO_CHAR(BOARD_WRITE_DATE, 'YYYY"년" MM"월" DD"일" HH24:MI:SS') BOARD_WRITE_DATE,
TO_CHAR(BOARD_UPDATE_DATE, 'YYYY"년" MM"월" DD"일" HH24:MI:SS') BOARD_UPDATE_DATE,
(SELECT COUNT(*)
FROM "BOARD_LIKE"
WHERE BOARD_NO = 1950) LIKE_COUNT,
(SELECT IMG_PATH || IMG_RENAME
FROM "BOARD_IMG"
WHERE BOARD_NO = 1950
AND IMG_ORDER = 0) THUMBNAIL
FROM "BOARD"
JOIN "MEMBER" USING(MEMBER_NO)
WHERE BOARD_DEL_FL = 'N'
AND BOARD_CODE = 1
AND BOARD_NO = 1950;
---------------------------------------------
/* 상세조회 되는 게시글의 모든 이미지 조회 */
SELECT *
FROM "BOARD_IMG"
WHERE BOARD_NO = 1950
ORDER BY IMG_ORDER;
---------------------------------------------
/* 상세조회 되는 게시글의 모든 댓글 조회 */
/*계층형 쿼리*/
SELECT LEVEL, C.* FROM
(SELECT COMMENT_NO, COMMENT_CONTENT,
TO_CHAR(COMMENT_WRITE_DATE, 'YYYY"년" MM"월" DD"일" HH24"시" MI"분" SS"초"') COMMENT_WRITE_DATE,
BOARD_NO, MEMBER_NO, MEMBER_NICKNAME, PROFILE_IMG, PARENT_COMMENT_NO, COMMENT_DEL_FL
FROM "COMMENT"
JOIN MEMBER USING(MEMBER_NO)
WHERE BOARD_NO = 1950) C
WHERE COMMENT_DEL_FL = 'N'
OR 0 != (SELECT COUNT(*) FROM "COMMENT" SUB
WHERE SUB.PARENT_COMMENT_NO = C.COMMENT_NO
AND COMMENT_DEL_FL = 'N')
START WITH PARENT_COMMENT_NO IS NULL
CONNECT BY PRIOR COMMENT_NO = PARENT_COMMENT_NO
ORDER SIBLINGS BY COMMENT_NO;
계층형 쿼리 (계단식 구조 부모 댓글, 자식 댓글)
SELECT LEVEL
START WITH PARENT_COMMENT_NO IS NULL
-> 부모 댓글이 없는 애들을 1레벨로 사용
CONNECT BY PRIOR COMMENT_NO = PARENT_COMMENT_NO
-> 누군가의 PARENT_COMMNET_NO 가 COMMENT_NO 라면 그 아래 레벨로 삼겠다.(대댓글)
부모 아래 자식 계층
inline view = 조회된 결과를 하나의 테이블처럼 쓰는 것
C.* : C 테이블에 대한 모든 것
BoardImg 클래스
import org.springframework.web.multipart.MultipartFile;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@Builder
@ToString
@AllArgsConstructor
@NoArgsConstructor
@Setter
@Getter
public class BoardImg {
private int imgNo;
private String imgPath;
private String imgOriginalName;
private String imgRename;
private int imgOrder;
private int boardNo;
// 게시글 이미지 삽입/수정할 때 사용
private MultipartFile uploadFile;
}
Comment 클래스
@Builder
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class Comment {
private int commentNo;
private String commentContent;
private String commentWriteDate;
private String commentDelFl;
private int boardNo;
private int memberNo;
private int parentCommentNo;
// 댓글 조회 시 회원 프로필, 닉네임
private String memberNickname;
private String profileImg;
}
Board 클래스 필드 추가
// 특정 게시글 이미지 목록 (xml collection property와 똑같이)
private List<BoardImg> imageList;
// 특정 게시글에 작성된 댓글 목록
private List<Comment> commentList;
BoardController
@GetMapping("{boardCode:[0-9]+}/{boardNo:[0-9]+}")
public String boardDetail(@PathVariable("boardCode") int boardCode,
@PathVariable("boardNo") int boardNo,
Model model,
RedirectAttributes ra) {
// 게시글 상세 조회 서비스 호출
// 1) Map 으로 전달할 파라미터 묶기
Map<String, Integer> map = new HashMap<>();
map.put("boardCode", boardCode);
map.put("boardNo", boardNo);
// 2) 서비스 호출 (하나의 게시글 돌려받을 거임)
Board board = service.selectOne(map);
이전에는 SQL 여러 개면 mapper 여러 번 호출해서 사용
Service 단에서 1번의 mapper 호출로 3개 SELECT 조회
수행하려는 SQL이
1) 모두 SELECT 이면서
2) 먼저 조회된 결과 중 일부를 이용해서 나중에 수행되는 SQL의 조건으로 삼을 수 있을 때
--> Mybatis 에서 제공하는 태그 중 <resultMap>
, resultMap 안에서 사용하는 <collection>
태그를 이용해서 Mapper 메서드 1회 호출로 여러 SELECT 한 번에 수행 가능
BoardServiceImpl
@Override
public Board selectOne(Map<String, Integer> map) {
return mapper.selectOne(map);
}
1) 조회된 컬럼명과 DTO의 필드명이 일치하지 않을 때
매핑(연결) 시켜주는 역할
사용 예)
<resultMap type="Board" id="board_rm">
<result javaType="title" column="BOARD_TITLE"/>
</resultMap>
2) <collection>
태그를 추가 작성하여 여러 행 결과가 조회되는 다른 SELECT를 수행한 후 그 결과를 지정된 DTO의 필드에 대입
type 속성 : 연결할 DTO 경로 또는 별칭
id 속성 : 해당 태그를 식별할 값(이름 지정)
<!-- 게시글 상세 조회 -->
<select id="selectOne" resultMap="board_rm">
SELECT BOARD_NO, BOARD_TITLE, BOARD_CONTENT, BOARD_CODE, READ_COUNT,
MEMBER_NO, MEMBER_NICKNAME, PROFILE_IMG,
TO_CHAR(BOARD_WRITE_DATE, 'YYYY"년" MM"월" DD"일" HH24:MI:SS') BOARD_WRITE_DATE,
TO_CHAR(BOARD_UPDATE_DATE, 'YYYY"년" MM"월" DD"일" HH24:MI:SS') BOARD_UPDATE_DATE,
(SELECT COUNT(*)
FROM "BOARD_LIKE"
WHERE BOARD_NO = #{boardNo}) LIKE_COUNT,
(SELECT IMG_PATH || IMG_RENAME
FROM "BOARD_IMG"
WHERE BOARD_NO = #{boardNo}
AND IMG_ORDER = 0) THUMBNAIL
FROM "BOARD"
JOIN "MEMBER" USING(MEMBER_NO)
WHERE BOARD_DEL_FL = 'N'
AND BOARD_CODE = #{boardCode}
AND BOARD_NO = #{boardNo}
</select>
수행한 결과값 가지고
resultMap 의 id 값을 써줌
resultMap 은 최상단에 만듦
select로 조회된 결과를 컬렉션(List)에 담아 지정된 필드에 세팅
property : List를 담을 DTO의 필드명
select : 실행할 select의 id
column : 조회 결과 중 지정된 컬럼의 값을 파라미터로 전달
javaType : List(컬렉션)의 타입을 지정
ofType : List(컬렉션)의 제네릭(타입 제한) 지정
<resultMap type="Board" id="board_rm">
<!-- id 태그 : PK 역할을 하는 컬럼, 필드를 작성하는 태그 -->
<id property="boardNo" column="BOARD_NO" />
<!-- 해당 게시글 이미지 목록 조회 후 필드에 저장 -->
<collection
property="imageList"
select="selectImageList"
column="BOARD_NO"
javaType="java.util.ArrayList"
ofType="BoardImg" />
</resultMap>
2번째 SQL문
<!-- 상세 조회한 게시글의 이미지 목록 조회 (resultType 꼭 써줘야함) -->
<select id="selectImageList" resultType="BoardImg">
SELECT *
FROM "BOARD_IMG"
WHERE BOARD_NO = #{boardNo}
ORDER BY IMG_ORDER
</select>
Board
// 특정 게시글 이미지 목록 (xml collection property와 똑같이)
private List<BoardImg> imageList;
// 특정 게시글에 작성된 댓글 목록
private List<Comment> commentList;
3번째 SQL문
<!-- 상세 조회한 게시글의 댓글 목록 조회 -->
<select id="selectCommentList" resultType="Comment">
SELECT LEVEL, C.* FROM
(SELECT COMMENT_NO, COMMENT_CONTENT,
TO_CHAR(COMMENT_WRITE_DATE, 'YYYY"년" MM"월" DD"일" HH24"시" MI"분" SS"초"') COMMENT_WRITE_DATE,
BOARD_NO, MEMBER_NO, MEMBER_NICKNAME, PROFILE_IMG, PARENT_COMMENT_NO, COMMENT_DEL_FL
FROM "COMMENT"
JOIN MEMBER USING(MEMBER_NO)
WHERE BOARD_NO = #{boardNo}) C
WHERE COMMENT_DEL_FL = 'N'
OR 0 != (SELECT COUNT(*) FROM "COMMENT" SUB
WHERE SUB.PARENT_COMMENT_NO = C.COMMENT_NO
AND COMMENT_DEL_FL = 'N')
START WITH PARENT_COMMENT_NO IS NULL
CONNECT BY PRIOR COMMENT_NO = PARENT_COMMENT_NO
ORDER SIBLINGS BY COMMENT_NO
</select>
collection 추가
<collection
property="commentList"
select="selectCommentList"
column="BOARD_NO"
javaType="java.util.ArrayList"
ofType="Comment" />
String path = null;
// 조회 결과가 없는 경우
if(board == null) {
path = "redirect:/board/" + boardCode; // 목록 재요청
ra.addFlashAttribute("message", "게시글이 존재하지 않습니다.");
// 조회 결과가 있는 경우
} else {
path = "board/boardDetail"; // board/boardDetail.html 로 forward
// board - 게시글 일반내용 + imageList + commentList
model.addAttribute("board", board);
// 이미지가 없는 글일 수 도 있음
// 조회된 이미지 목록(imageList)가 있을 경우
if(!board.getImageList().isEmpty()) {
BoardImg thumbnail = null;
// imageList 의 0번 인덱스 == 가장 빠른 순서(imgOrder)
// 이미지 목록의 첫번째 행이 순서 0 == 썸네일인 경우
if(board.getImageList().get(0).getImgOrder() == 0) {
thumbnail = board.getImageList().get(0);
}
model.addAttribute("thumbnail", thumbnail);
model.addAttribute("start", thumbnail != null ? 1 : 0);
}
}
return path;
}
comment.html
<div id="commentArea">
<!-- 댓글 목록 -->
<div class="comment-list-area">
<ul id="commentList">
<!-- 대댓글(자식)인 경우 child-comment 클래스 추가 -->
<li class="comment-row"
th:each="comment : ${board.commentList}"
th:classappend="${comment.parentCommentNo} != 0 ? child-comment"
th:object="${comment}">
<th:block th:if="*{commentDelFl} == 'Y'">
삭제된 댓글 입니다
</th:block>
<th:block th:if="*{commentDelFl} == 'N'">
<p class="comment-writer">
<!-- 프로필 이미지 없을 경우 -->
<img th:unless="*{profileImg}" th:src="#{user.default.image}">
<!-- 프로필 이미지 있을 경우 -->
<img th:if="*{profileImg}" th:src="*{profileImg}">
<span th:text="*{memberNickname}">닉네임</span>
<span class="comment-date" th:text="*{commentWriteDate}">작성일</span>
</p>
<p class="comment-content" th:text="*{commentContent}">댓글 내용</p>
<!-- 버튼 영역 -->
<div class="comment-btn-area">
<button th:onclick="|showInsertComment(*{commentNo}, this)|">답글</button>
<th:block th:if="${session.loginMember != null and session.loginMember.memberNo == comment.memberNo}">
<button th:onclick="|showUpdateComment(*{commentNo}, this)|">수정</button>
<button th:onclick="|deleteComment(*{commentNo})|">삭제</button>
</th:block>
<!-- 로그인 회원과 댓글 작성자가 같은 경우 -->
</div>
</th:block>
</li>
</ul>
</div>
<!-- 댓글 작성 부분 -->
<div class="comment-write-area">
<textarea id="commentContent"></textarea>
<button id="addComment">
댓글<br>
등록
</button>
</div>
</div>
boardDetail.html
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<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 th:text="|${board.boardNo}번 글|">게시판 이름</title>
<th:block th:replace="~{common/common}"></th:block>
<link rel="stylesheet" href="/css/board/boardDetail-style.css">
<link rel="stylesheet" href="/css/board/comment-style.css">
</head>
<body>
<main>
<th:block th:replace="~{common/header}"></th:block>
<section class="board-detail" th:object="${board}">
<!-- 제목 -->
<h1 class="board-title"
th:text="*{boardTitle}"> 게시글 제목 </h1>
<!-- 프로필 + 닉네임 + 작성일 + 조회수 -->
<div class="board-header">
<div class="board-writer">
<!-- 프로필 이미지 -->
<!-- 프로필 이미지가 없을 경우 기본 이미지 출력 -->
<img th:unless="*{profileImg}"
th:src="#{user.default.image}">
<!-- 프로필 이미지가 있을 경우 출력-->
<img th:if="*{profileImg}"
th:src="*{profileImg}">
<span th:text="*{memberNickname}">작성자 닉네임</span>
<!-- 좋아요 하트 -->
<span class="like-area">
<!-- 비동기로 좋아요 누를 때 동작 (4/22 월요일 예정) -->
<!-- 좋아요 누른적이 있으면 fa-solid, 없으면 fa-regular 클래스 추가 -->
<i class="fa-heart fa-regular" id="boardLike"></i>
<!-- 좋아요 개수 -->
<span th:text="*{likeCount}">0</span>
</span>
</div>
<div class="board-info">
<p> <span>작성일</span>[[*{boardWriteDate}]]</p>
<!-- 수정한 게시글인 경우 -->
<!-- 참조하는 객체가 있으면 true, 없으면 false -->
<p th:if="*{boardUpdateDate}">
<span>마지막 수정일</span> [[*{boardUpdateDate}]]
</p>
<!-- (4/22 월요일 예정) -->
<p> <span>조회수</span> [[*{readCount}]]</p>
</div>
</div>
<!-- ====================== 이미지가 있을 경우 출력하는 구문 ====================== -->
<th:block th:if="${ #lists.size(board.imageList) > 0}">
<!-- 썸네일이 있을 경우 -->
<th:block th:if="*{thumbnail}">
<!-- board DTO 에 thumbnail DB 조회 /images/board/test1.jpg -->
<h5>썸네일</h5>
<div class="img-box">
<div class="boardImg thumbnail">
<img th:src="|${thumbnail.imgPath}${thumbnail.imgRename}|">
<!-- BoardImg 이용해 setting 한 이미지 imgPath == /images/board imgRename == /test1.jpg -->
<a th:href="|${thumbnail.imgPath}${thumbnail.imgRename}|"
th:download="${thumbnail.imgOriginalName}">다운로드</a>
</div>
</div>
</th:block>
<th:block th:if="${#lists.size(board.imageList) > start}">
<!-- 썸네일이 있으면 start = 1 / 썸네일이 없다면 start = 0 -->
<h5>업로드 이미지</h5>
<th:block th:each="i : ${#numbers.sequence(start, #lists.size(board.imageList) - 1)}">
<div class="img-box">
<div class="boardImg">
<img th:src="|${board.imageList[i].imgPath}${board.imageList[i].imgRename}|">
<a th:href="|${board.imageList[i].imgPath}${board.imageList[i].imgRename}|"
th:download="${board.imageList[i].imgOriginalName}">다운로드</a>
</div>
</div>
</th:block>
</th:block>
</th:block>
<!-- ====================== 이미지가 있을 경우 출력하는 구문 ====================== -->
<!-- 내용 -->
<div class="board-content" th:text="*{boardContent}">내용</div>
<!-- 버튼 영역-->
<div class="board-btn-area">
<!-- 로그인한 회원과 게시글을 작성한 회원의 번호가 같은 경우 -->
<!-- <th:block th:if="${session.loginMember != null and board.memberNo == session.loginMember.memberNo}"> -->
<!-- <th:block th:if="${board.memberNo == session.loginMember?.memberNo}"> 안전 탐색 연산자 null 이 아닌 경우에만 memberNo 접근 -->
<th:block th:if="${board.memberNo == session.loginMember?.memberNo}">
<button id="updateBtn">수정</button>
<button id="deleteBtn">삭제(GET)</button>
<button id="deleteBtn2">삭제(POST)</button>
</th:block>
<button id="goToListBtn">목록으로</button>
</div>
</section>
<!-- 댓글 영역-->
<th:block th:replace="~{board/comment}"></th:block>
</main>
<th:block th:replace="~{common/footer}"></th:block>
</body>
</html>