69일차 내용 정리

채공부·2025년 8월 29일

실습 (Gallery)

갤러리 글 작성

  • GalleryUploadRequest 클래스 생성

@Builder

  • builder() 형식으로 객체를 생성할 수 있도록 한다
  • 필드의 모든 값을 전달받는 생성자를 사용

@Data

  • setter, getter 메소드 + toString() 메소드 오버라이딩
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Data
public class GalleryUploadRequest {
	private String title;
	private String content;
	// <input type="file" name="images" multiple>
	// images 라는 파라미터명으로 파일이 여러개 전송되니까 MultipartFile[] type 으로 선언한다
	private MultipartFile[] images;
}
  • GalleryService 인터페이스에 createGallery 메소드 생성
public void createGallery(GalleryUploadRequest galleryRequest);

@Transactional 어노테이션의 동작

  1. 작업 중에 DataAccessException type 의 예외가 발생하면 자동 rollback

  2. @Repository 어노테이션이 붙은 dao 에서 DB 관련 작업중에
    SQLException 이 발생하면 spring 이 해당 예외를 잡아서 @DataAccessException 을 자동으로 발생시킨다
    (transaction 에 영향을 주기 위해서)

  3. @Mapper 를 이용해서 dao 를 만들면 해당 dao 에 자동으로 @Repository 어노테이션이 붙는다

  4. 서비스에서 어떤 동작을 하다가 에러가 난 경우 transaction 에 영향을 주고 싶으면 일반 예외를 발생시키지 말고 DataAccessException 을 throw 하면 transaction 관리가 된다

  5. 커스텀 Exception 을 발생시켜서 transaction 을 관리하고 싶으면 커스텀 Exception 클래스를 만들 때 반드시 RuntimeException 클래스 말고 DataAccessException 클래스를 상속받아서 만들고 확정 조건 하에서 해당 Exception 를 발생시키면 자동으로 transaction 관리가 된다

  • GalleryServiceImpl 에 메소드 오버라이드
// 업로드된 이미지를 저장할 위치 얻어내기
@Value("${file.location}")
private String fileLocation;

// 이 서비스에서 일어나는 DB 관련 작업을 하나의 transaction 단위로 묶기
@Transactional
@Override
public void createGallery(GalleryUploadRequest galleryRequest) {
		
	// 이 Gallery 의 pk 를 미리 얻어낸다 (이미지 정보를 DB 에 저장할 때 galleryNum 으로 사용된다)
	int num = galleryMapper.getSequence();
	// 로그인된 userName
	String userName = SecurityContextHolder.getContext().getAuthentication().getName();
	// Gallery 정보도 DB 에 저장한다
	GalleryDto dto = GalleryDto.builder()
			.num(num)
			.title(galleryRequest.getTitle())
			.content(galleryRequest.getContent())
			.writer(userName)
			.build();
	galleryMapper.insert(dto);
		
	// 업로드된 이미지 파일의 정보를 가지고 있는 배열
	MultipartFile[] images = galleryRequest.getImages();
		
	// 반복문 돌면서 배열에 저장된 MultipartFile 객체를 순서대로 참조하면서 이미지 관련 처리를 한다
	for(int i=0; i<images.length; i++) {
		// 배열에서 원하는 인덱스에 해당하는 MultipartFile 객체를 참조
		MultipartFile image = images[i];
		// 원본 파일명
		String orgFileName = image.getOriginalFilename();
		// 이미지의 확장자를 유지하기 위해 뒤에 원본 파일명을 추가
		String saveFileName = UUID.randomUUID().toString() + orgFileName;
		// 저장할 파일의 전체 경로 구성하기
		String filePath = fileLocation + File.separator + saveFileName;
		try {
			// 업로드된 파일을 저장할 파일 객체 생성
			File saveFile = new File(filePath);
			image.transferTo(saveFile);
		} catch(Exception e) {
			e.printStackTrace();
		}
		// GalleryImageDto 객체에 이미지 하나의 정보를 담고
		GalleryImageDto imageDto = GalleryImageDto.builder()
				.galleryNum(num)
				.saveFileName(saveFileName)
				.build();
		// DB 에 저장
		galleryMapper.insertImage(imageDto);
	}
}
⭐ gallery 테이블의 num 을
   gallery_image 테이블의 galleryNum 칼럼에서 Reference 하기 때문에
   GalleryDto 를 먼저 DB 에 저장해야 한다
  • GalleryController 에 `gallerySave" 메소드 생성
@PostMapping("/gallery/save")
public String gallerySave(GalleryUploadRequest uploadRequest, RedirectAttributes ra) {
	// 업로드된 파일의 모든 정보를 GalleryUploadRequest 의 images 라는 MultipartFile[] 객체에 담아서 전달
	galleryService.createGallery(uploadRequest);
	// 리다일렉트 된 페이지에서 전달한 메세지
	ra.addFlashAttribute("message", "Gallery 정보를 성공적으로 저장 완료");
		
	return "redirect:/gallery/list";
}

갤러리 상세보기

  • GalleryController 에 galleryView 메소드 생성
@GetMapping("/gallery/view")
public String galleryView(int num) {
	return "gallery/view";
}
  • GalleryViewResponse 클래스 생성
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Data
public class GalleryViewResponse {
	private String userName;
	private boolean isLogin;
	private GalleryDto dto;
	private List<GalleryImageDto> images;
	private List<CommentDto> commentList;
}

➜ 해당 구조로 표시된다

{
	"userName" : "kim",
    "isLogin" : true,
    "dto" : {   },
    "list" : [{}, {}, {}, ...],
    "commentList" : [{}, {}, {}, ...]
}
  • GalleryService 에 getGallery 메소드 생성
public GalleryViewResponse getGallery(int num);
  • GalleryServiceImpl 에 의존 객체 주입 & 추상 메소드 오버라이드
private final CommentDao commentDao;

@Override
public GalleryViewResponse getGallery(int num) {
	// 로그인된 userName 을 얻어낸 다음 로그인을 안 했으면 "anonymousUser" 이다
	String userName = SecurityContextHolder.getContext().getAuthentication().getName();
	// 로그인 여부
	boolean isLogin = userName.equals("anonymousUser") ? false : true;
	// GalleryDto 얻어내기
	GalleryDto dto = galleryMapper.getData(num);
    // dto 의 content 에서 개행기호를 <br> 요소로 변경한 다음 다시 넣기
	String result = dto.getContent().replace("\r\n", "<br>").replace("\n", "<br>");
	dto.setContent(result);
	// 이미지 목록
	List<GalleryImageDto> images = galleryMapper.getImageList(num);
	// 댓글 목록
	List<CommentDto> commentList = commentDao.selectList(num);
		
	return GalleryViewResponse.builder()
			.userName(userName)
			.isLogin(isLogin)
			.dto(dto)
			.images(images)
			.commentList(commentList)
			.build();
}
  • GalleryController 에 galleryView 메소드 완성
@GetMapping("/gallery/view")
public String galleryView(int num, Model model) {
	GalleryViewResponse response = galleryService.getGallery(num);
	model.addAttribute("res", response);
		
	return "gallery/view";
}
  • view.html 생성
<style>
	/* 대댓글이 처음에는 보이지 않도록 하기 위해*/
	.re-re{
		display:none;
	}
</style>
<div class="container">
	<div class="card shadow">
		<!-- 작성자 정보 -->
		<div class="card-header d-flex align-items-center">
			<i th:if="${res.dto.profileImage == null}" style="font-size:100px;" class="bi bi-person-circle"></i>
			<img th:unless="${res.dto.profileImage == null}"
				th:src="@{/upload/{name}(name=${res.dto.profileImage})}" 
				style="width:100px;height:100px;border-radius:50%;"/>
			<strong class="ms-3" th:text="${res.dto.writer}"></strong>
			<span class="text-muted ms-auto" th:text="${res.dto.createdAt}"></span>
		</div>
		<!-- 본문 -->
		<div class="card-body">
			<h5 class="card-title" th:text="${res.dto.title}"></h5>
			<!-- textarea 로 입력한 글에서 개행기호는 br 요소로 변경해서 출력하기 -->
			<p class="card-text">[(${res.dto.content})]</p>
			<div class="row">
				<div th:each="tmp : ${res.images}" class="col-md-6 mb-4">
					<img th:src="@{/upload/{name}(name=${tmp.saveFileName})}"
					class="img-fluid rounded" alt="photo">
				</div>
			</div>
		</div>			
	</div> <!-- .card -->

	<div class="card my-3">
		<div class="card-header bg-primary text-white">
			댓글을 입력해 주세요
		</div>
		<div class="card-body">
			<!-- 원글의 댓글을 작성할 폼 -->
			<form th:action="@{/gallery/save-comment}" method="post">
				<!-- 숨겨진 입력값 -->
				<input type="hidden" name="parentNum" th:value="${res.dto.num}"/>
				<input type="hidden" name="targetWriter" th:value="${res.dto.writer}" />
				<div class="mb-3">
					<label for="commentContent" class="form-label">댓글 내용</label>
					<textarea id="commentContent" name="content" rows="5" class="form-control" placeholder="댓글을 입력하세요"></textarea>
				</div>
				<button type="submit" class="btn btn-success">등록</button>
			</form>
		</div>
	</div>

	<!-- 댓글 목록을 출력하기 -->
	<div class="comments">
		<!-- 대댓글은 자신의 글번호와 댓글의 그룹번호가 다르다. 그런경우 왼쪽 마진을 부여한다 -->
		<div th:each="tmp : ${res.commentList}" 
			class="card mb-3"
			th:classappend="${tmp.num eq tmp.groupNum ? null : 'ms-5 re-re'}">
			
			<div th:if="${tmp.deleted eq 'yes'}" class="card-body bg-light text-muted rounded">삭제된 댓글 입니다</div>
			
			<div th:unless="${tmp.deleted eq 'yes'}" class="card-body d-flex flex-column flex-sm-row position-relative">
				
				<button th:if="${tmp.replyCount ne 0 and tmp.num eq tmp.groupNum}"
					class="dropdown-btn btn btn-outline-secondary btn-sm position-absolute"
					style="bottom:16px; right:16px;">
					<i class="bi bi-caret-down"></i>
					답글 [[${tmp.replyCount}]] 개
				</button>
				
				<i th:if="${tmp.num ne tmp.groupNum}"
					class="bi bi-arrow-return-right position-absolute" style="top:0;left:-30px"></i>
				
				<button th:if="${tmp.writer eq userName}"
					th:attr="data-num=${tmp.num}" 
					class="btn-close position-absolute top-0 end-0 m-1"></button>
				
				<i th:if="${tmp.profileImage eq null}"
					style="font-size:50px" class="bi bi-person-circle me-3 align-self-center"></i>
				<img th:unless="${tmp.profileImage eq null}"
					class="rounded-circle me-3 align-self-center" 
					th:src="@{/upload/{name}(name=${tmp.profileImage})}" 
					alt="프로필 이미지"
					style="width:50px;height:50px">
				
				<div class="flex-grow-1">
					<div class="d-flex justify-content-between">
						<div>
							<strong th:text="${tmp.writer}"></strong>
							<span>@[[${tmp.targetWriter}]]</span>
							<small class="text-muted" th:text="${tmp.createdAt}"></small>
						</div>
					</div>
					<pre th:text="${tmp.content}"></pre>
					
					<th:block th:if="${tmp.writer eq res.userName}">
						<!-- 수정 버튼 (본인에게만 보임) -->
						<button class="btn btn-sm btn-outline-secondary edit-btn">수정</button>
						<!-- 댓글 수정 폼 (처음에는 숨김) -->
						<div class="d-none form-div">
							<form th:action="@{/gallery/comment-update}" method="post">
								<!-- 댓글을 수정하기 위한 댓글의 번호, 이페지이로 다시 돌아오기위한 parentNum 도 같이 전송되도록 -->
								<input type="hidden" name="num" th:value="${tmp.num}"/>
								<input type="hidden" name="parentNum" th:value="${res.dto.num}"/>
								<textarea name="content" class="form-control mb-2" rows="2" >[[${tmp.content}]]</textarea>
								<button type="submit" class="btn btn-sm btn-success">수정 완료</button>
								<button type="reset" class="btn btn-sm btn-secondary cancel-edit-btn">취소</button>
							</form>
						</div>  
					</th:block>
					
					<th:block th:unless="${tmp.writer eq res.userName}">
						<button class="btn btn-sm btn-outline-primary show-reply-btn">댓글</button>  
						<!-- 댓글 입력 폼 (처음에는 숨김) -->
						<div class="d-none form-div">
							<form th:action="@{/gallery/save-comment}" method="post">
								<!-- 원글의 글번호, 댓글 대상자의 userName, 댓글의 그룹번호도 같이 전송해야한다 -->
								<input type="hidden" name="parentNum" th:value="${res.dto.num}" />
								<input type="hidden" name="targetWriter" th:value="${tmp.writer}"/>
								<input type="hidden" name="groupNum" th:value="${tmp.groupNum}"/>
								<textarea name="content" class="form-control mb-2" rows="2" 
									placeholder="댓글을 입력하세요..."></textarea>
								<button type="submit" class="btn btn-sm btn-success">등록</button>
								<button type="reset" class="btn btn-sm btn-secondary cancel-reply-btn">취소</button>
							</form>
						</div> 
					</th:block>
				</div>
			</div> <!-- .card-body -->		    	
		</div><!-- .card -->
	</div>	<!-- .comments -->	
</div><!-- .container -->

<script>
	//클라이언트가 로그인 했는지 여부
	const isLogin = [[${res.isLogin}]];
	
	//대댓글 보기 버튼을 눌렀을때 실행할 함수 등록 
	document.querySelectorAll(".dropdown-btn").forEach(item => {
		item.addEventListener("click", (e) => {
			const caret = item.querySelector(".bi-caret-up, .bi-caret-down");
			caret.classList.toggle("bi-caret-down");
			caret.classList.toggle("bi-caret-up");
			const grandParent = item.parentElement.parentElement;
			let next = grandParent.nextElementSibling;
			while (next) {
				if (next.classList.contains("re-re")) {
					next.classList.toggle("d-block");
				} else {
					break;
				}
				next = next.nextElementSibling;
			}
		});
	});    	

	document.querySelectorAll(".btn-close").forEach(item => {
		item.addEventListener("click", ()=>{
			const num=item.getAttribute("data-num");
			const isDelete=confirm(num+" 번 댓글을 삭제 하시겠습니까?");
			if(isDelete){
				location.href=`[[@{/gallery/comment-delete}]]?num=${num}&parentNum=[[${res.dto.num}]]`;
			}
		});
	});

	document.querySelectorAll(".edit-btn").forEach(item => {
		item.addEventListener("click", ()=>{
			item.nextElementSibling.classList.remove("d-none");
			item.classList.add("d-none");
		});
	});
	document.querySelectorAll(".cancel-edit-btn").forEach(item=>{
		item.addEventListener("click", ()=>{
			const formDiv=item.closest(".form-div");
			formDiv.classList.add("d-none");
			formDiv.previousElementSibling.classList.remove("d-none");
		});
	});    	

	document.querySelector("#commentContent").addEventListener("input", ()=>{
		if(!isLogin){
			alert("댓글 작성을 위해 로그인이 필요합니다!");
				location.href=
					"[[@{/user/loginform}]]?url=[[@{/gallery/view(num=${res.dto.num})}]]";
		}
	});

	//모든 댓글 버튼에 이벤트 등록
	document.querySelectorAll(".show-reply-btn").forEach(item=>{
		item.addEventListener("click", ()=>{
			if(!isLogin){
				alert("댓글 작성을 위해 로그인이 필요합니다!");
				location.href=
					"[[@{/user/loginform}]]?url=[[@{/gallery/view(num=${res.dto.num})}]]";
				return;
			}
			item.nextElementSibling.classList.remove("d-none");
			item.classList.add("d-none");
		});
	});

	document.querySelectorAll(".cancel-reply-btn").forEach(item=>{
		item.addEventListener("click", ()=>{
			const formDiv=item.closest(".form-div");
			formDiv.classList.add("d-none");
			formDiv.previousElementSibling.classList.remove("d-none");
		});
	});
</script>

댓글 구조 만들어두기

  • include 폴더에 comment.html 파일 생성

  • 로그인 여부 isLogin
    로그인 되었다면 로그인된 userName

<th:block th:fragment="commentUi(category, parentNum, parentWriter, list)">
    <div class="card my-3">
        <div class="card-header bg-primary-subtle text-white">
            댓글을 입력해 주세요
        </div>
        <div class="card-body">

            <!-- 원글의 댓글을 작성할 폼 -->
            <form th:action="@{|/${category}/save-comment|}" method="post">

                <!-- 숨겨진 입력값 -->
                <input type="hidden" name="parentNum" th:value="${parentNum}" />
                <input type="hidden" name="targetWriter" th:value="${parentWriter}" />

                <div class="mb-3">
                    <label for="commentContent" class="form-label">댓글 내용</label>
                    <textarea id="commentContent" name="content" rows="5" class="form-control" placeholder="댓글을 입력하세요"></textarea>
                </div>

                <button type="submit" class="btn bg-primary-subtle text-white">등록</button>
            </form>
        </div>
    </div>

    <!-- 댓글 목록 출력 -->
    <div class="comments">

        <!-- 대댓글은 자신의 글번호와 댓글의 그룹번호가 다르다, 그런 경우 왼쪽 마진을 부여 -->
        <div th:each="tmp : ${list}" class="card mb-3"
            th:classappend="${tmp.num == tmp.groupNum ? null : 'ms-5 re-re d-none'}">

            <!-- 삭제된 댓글 -->
            <div th:if="${tmp.deleted eq 'yes'}" class="card-body bg-light text-muted rounded">삭제된 댓글입니다</div>

            <!-- 삭제되지 않은 댓글 -->
            <div th:unless="${tmp.deleted eq 'yes'}" class="card-body d-flex flex-column flex-sm-row position-relative">
                <button th:if="${tmp.replyCount ne 0 and tmp.num eq tmp.groupNum}"
                    class="dropdown-btn btn btn-outline-secondary btn-sm position-absolute"
                    style="bottom:16px; right:16px;">
                    <i class="bi bi-caret-down"></i>
                    답글 [[${tmp.replyCount}]]</button>

                <i th:if="${tmp.num ne tmp.groupNum}" class="bi bi-arrow-return-right position-absolute"
                    style="top:0;left:-30px"></i>

                <!--/*
                    로그인된 userName 은 #authentication.name 으로 참조하면 된다
                    로그인이 안되어 있으면 "anonymousUser" 로 참조된다
                */-->
                <button th:if="${tmp.writer eq #authentication.name}" th:attr="data-num=${tmp.num}"
                    class="btn-close position-absolute top-0 end-0 m-1"></button>

                <i th:if="${tmp.profileImage == null}" style="font-size:50px;"
                    class="bi bi-person-circle me-3 align-self-center"></i>
                <img th:unless="${tmp.profileImage eq null}" class="rounded-circle me-3 align-self-center"
                    th:src="@{/upload/{name}(name=${tmp.profileImage})}" alt="프로필 이미지"
                    style="width:50px; height:50px;">

                <div class="flex-grow-1">
                    <div class="d-flex justify-content-between">
                        <div>
                            <strong th:text="${tmp.writer}"></strong>
                            <span>@[[${tmp.targetWriter}]]</span>
                            <small class="text-muted" th:text="${tmp.createdAt}"></small>
                        </div>
                    </div>
                    <pre th:text="${tmp.content}"></pre>

                    <!-- 댓글 작성작가 로그인된 userName 과 같으면 수정폼, 다르면 댓글폼을 출력한다 -->
                    <th:block th:if="${tmp.writer eq #authentication.name}">
                        <!-- 수정 버튼 (본인에게만 보임)-->
                        <button class="btn btn-sm bg-primary-subtle btn-outline-secondary edit-btn">수정</button>
                        <!-- 댓글 수정 폼 (처음에는 숨김) -->
                        <div class="d-none form-div">
                            <form th:action="@{|/${category}/comment-update|}" method="post">
                                <input type="hidden" name="num" th:value="${tmp.num}" />
                                <input type="hidden" name="parentNum" th:value="${parentNum}" />
                                <textarea name="content" class="form-control mb-2" rows="2"
                                    th:text="${tmp.content}"></textarea>
                                <button type="submit" class="btn btn-sm btn-success">수정 완료</button>
                                <button type="reset" class="btn btn-sm btn-secondary cancel-edit-btn">취소</button>
                            </form>
                        </div>
                    </th:block>

                    <th:block th:unless="${tmp.writer eq #authentication.name}">
                        <button class="btn btn-sm bg-primary-subtle btn-outline-secondary show-reply-btn">댓글</button>
                        <!-- 댓글 입력 폼 (처음에는 숨김) -->
                        <div class="d-none form-div">
                            <form th:action="@{|/${category}/save-comment|}" method="post">
                                <input type="hidden" name="parentNum" th:value="${parentNum}" />
                                <input type="hidden" name="targetWriter" th:value="${tmp.writer}" />
                                <input type="hidden" name="groupNum" th:value="${tmp.groupNum}" />
                                <textarea name="content" class="form-control mb-2" rows="2"
                                    placeholder="댓글을 입력하세요"></textarea>
                                <button type="submit" class="btn btn-sm btn-outline-primary">등록</button>
                                <button type="reset" class="btn btn-sm btn-outline-secondary cancel-reply-btn">취소</button>
                            </form>
                        </div>
                    </th:block>
                </div>
            </div>
        </div>
    </div>

    <script>
        // 클라이언트가 로그인 했는지 여부
        const isLogin = [[${#authentication.name == 'anonymousUser' ? false: true}]];

        document.querySelector("#commentContent").addEventListener("input", () => {
            if (!isLogin) {
                alert("댓글 작성을 위해 로그인이 필요합니다!");
                location.href = "[[@{/user/loginform}]]?url=[[@{|/${category}/view?num=${parentNum}|}]]";
            }
        });

        document.querySelectorAll(".show-reply-btn").forEach(item => {
            item.addEventListener("click", () => {
                if (!isLogin) {
                    alert("댓글 작성을 위해 로그인이 필요합니다!");
                    location.href = "[[@{/user/loginform}]]?url=[[@{|/${category}/view?num=${parentNum}|}]]";
                    return;
                }
                item.nextElementSibling.classList.remove("d-none");
                item.classList.add("d-none");
            });
        });

        document.querySelectorAll(".cancel-reply-btn").forEach(item => {
            item.addEventListener("click", () => {
                const formDiv = item.closest(".form-div");
                formDiv.classList.add("d-none");
                formDiv.previousElementSibling.classList.remove("d-none");
            });
        });

        document.querySelectorAll(".edit-btn").forEach(item => {
            item.addEventListener("click", () => {
                item.nextElementSibling.classList.remove("d-none");
                item.classList.add("d-none");
            });
        });

        document.querySelectorAll(".cancel-edit-btn").forEach(item => {
            item.addEventListener("click", () => {
                const formDiv = item.closest(".form-div");
                formDiv.classList.add("d-none");
                formDiv.previousElementSibling.classList.remove("d-none");
            });
        });

        document.querySelectorAll(".btn-close").forEach(item => {
            item.addEventListener("click", () => {
                const num = item.getAttribute("data-num");
                const isDelete = confirm(num + "번 댓글을 삭제하시겠습니까?");
                if (isDelete) {
                    location.href = `[[@{|/${category}/comment-delete|}]]?num=${num}&parentNum=[[${parentNum}]]`;
                }
            });
        });

        document.querySelectorAll(".dropdown-btn").forEach(item => {
            item.addEventListener("click", (e) => {
                const caret = item.querySelector(".bi-caret-up, .bi-caret-down");
                caret.classList.toggle("bi-caret-down");
                caret.classList.toggle("bi-caret-up");

                const grandParent = item.parentElement.parentElement;
                let next = grandParent.nextElementSibling;

                while (next) {
                    if (next.classList.contains("re-re")) {
                        next.classList.toggle("d-none");
                    } else {
                        break;
                    }
                    next = next.nextElementSibling;
                }
            });
        });
    </script>
</th:block>
  • CommentController 클래스 생성
@RequiredArgsConstructor
@Controller
public class CommentController {
	private final CommentService service;
	
	@PostMapping("/{category}/comment-update")
	public String commentUpdate(CommentDto dto, 
			@PathVariable("category") String category) {
		
		service.updateComment(dto);
		
		return "redirect:/"+category+"/view?num="+dto.getParentNum();
	}
	
	@GetMapping("/{category}/comment-delete")
	public String boardDelete(CommentDto dto, 
			@PathVariable("category") String category) {
		//dto 에는 삭제할 댓글의 글번호와 원글의 글번호가 들어 있다.
		service.deleteComment(dto.getNum());
		
		return "redirect:/"+category+"/view?num="+dto.getParentNum();
	}
	
	@PostMapping("/{category}/save-comment")
	public String boardSave(CommentDto dto, 
			@PathVariable("category") String category) {
		//서비스를 이용해서 새로운 댓글을 저장한다 
		service.createComment(dto);
		//댓글을 작성한 원글 자세히 보기로 다시 리다일렉트 이동시키기
		return "redirect:/"+category+"/view?num="+dto.getParentNum();
	}
}
  • CommentService 인터페이스 생성
public interface CommentService {
	public List<CommentDto> getComments(int parentNum);
	public void createComment(CommentDto dto); // 댓글 저장
	public void updateComment(CommentDto dto); //댓글 수정
	public void deleteComment(int num); // 댓글 삭제
}
  • CommentServiceImpl 클래스 생성
@RequiredArgsConstructor
@Service
public class CommentServiceImpl implements CommentService{
	private final CommentDao commentDao;

	@Override
	public List<CommentDto> getComments(int parentNum) {

		return commentDao.selectList(parentNum);
	}

	@Override
	public void createComment(CommentDto dto) {
		// 댓글의 글번호가 넘어오지 않으면 dto.getGroupNum() 은 0 을 리턴한다
		
		// 저장할 댓글의 pk 를 미리 얻어낸다
		int num = commentDao.getSequence();
		dto.setNum(num);
		
		// 만일 원글의 댓글이면
		if(dto.getGroupNum() == 0) {
			dto.setGroupNum(num); // 원글의 댓글은 자신의 글번호가 댓글의 그룹번호이고
		}
			
		// 댓글 작성자를 얻어내서 dto 에 담는다
		String userName = SecurityContextHolder.getContext().getAuthentication().getName();
		dto.setWriter(userName);
		
		// 대댓글이면 이미 dto 에 댓글의 그룹번호가 들어 있다
		commentDao.insert(dto);		
	}

	@Override
	public void updateComment(CommentDto dto) {
		// 글 작성자와 로그인된 userName 이 동일한지 비ㅛ해서 동일하지 않으면 예외를 발생시킨다
		String writer = commentDao.getByNum(dto.getNum()).getWriter();
		String userName = SecurityContextHolder.getContext().getAuthentication().getName();
		if(!writer.equals(userName)) {
			throw new RuntimeException("남의 글을 수정할 수 없습니다");
		}
		commentDao.update(dto);
	}

	@Override
	public void deleteComment(int num) {
		// 글 작성자와 로그인된 userName 이 동일한지 비교해서 동일하지 않으면 예외를 발생시킨다
		String writer = commentDao.getByNum(num).getWriter();
		String userName = SecurityContextHolder.getContext().getAuthentication().getName();
		if(!writer.equals(userName)) {
			throw new RuntimeException("남의 글을 지울 수 없습니다");
		}
		// 글 삭제하기
		commentDao.delete(num);
	}
}
  • BoardController 에 메소드 수정
@PostMapping("/{category}/comment-update")
public String boardUpdateComment(CommentDto dto,
		@PathVariable String category) {
	service.updateComment(dto);
		
	return "redirect:/"+category+"/view?num="+dto.getParentNum();
}
	
@GetMapping("/{category}/comment-delete")
public String boardDeleteComment(CommentDto dto,
		@PathVariable String category) {
	// dto 에는 삭제할 댓글의 글번호와 원글의 글번호가 들어 있다
	service.deleteComment(dto.getNum());
	return "redirect:/"+category+"/view?num="+dto.getParentNum();
}
		
@PostMapping("/{category}/save-comment")
public String boardSaveComment(CommentDto dto,
		@PathVariable String category) {
	// 서비스를 이용해서 새로운 댓글을 저장
	service.createComment(dto);
	// 댓글을 작성한 원글 자세히 보기로 다시 리다일렉트 이동		
	return "redirect:/"+category+"/view?num="+dto.getParentNum();
}

CommentController 클래스 생성


@RequiredArgsConstructor
@Controller
public class CommentController {

}
  • BoardController 에 commentService 의존성 주입 & boardView 메소드 수정
private final CommentService commentService;

List<CommentDto> comments = commentService.getComments(requestDto.getNum());
  • CommentController 클래스에 메소드 생성
@RequiredArgsConstructor
@Controller
public class CommentController {
	private final CommentService service;
	
	@PostMapping("/{category}/comment-update")
	public String commentUpdate(CommentDto dto, 
			@PathVariable("category") String category) {
		
		service.updateComment(dto);
		
		return "redirect:/"+category+"/view?num="+dto.getParentNum();
	}
	
	@GetMapping("/{category}/comment-delete")
	public String boardDelete(CommentDto dto, 
			@PathVariable("category") String category) {
		//dto 에는 삭제할 댓글의 글번호와 원글의 글번호가 들어 있다.
		service.deleteComment(dto.getNum());
		
		return "redirect:/"+category+"/view?num="+dto.getParentNum();
	}
	
	@PostMapping("/{category}/save-comment")
	public String boardSave(CommentDto dto, 
			@PathVariable("category") String category) {
		//서비스를 이용해서 새로운 댓글을 저장한다 
		service.createComment(dto);
		//댓글을 작성한 원글 자세히 보기로 다시 리다일렉트 이동시키기
		return "redirect:/"+category+"/view?num="+dto.getParentNum();
	}
}

게시글 & 갤러리 상세보기

  • board 의 view.html
<th:block th:insert="/include/navbar :: nav('view')"></th:block>
<div class="container pt-3">
	<nav aria-label="breadcrumb">
		<ol class="breadcrumb">
			<li class="breadcrumb-item">
				<a th:href="@{/}">Home</a>
			</li>
			<li class="breadcrumb-item">
				<a th:href="@{/board/list}">Board</a>
			</li>
			<li class="breadcrumb-item active">Detail</li>
		</ol>
	</nav>
	<h1>게시글 상세보기</h1>

	<!--/*
		th:if="${message != null}"
		th:if="${!#strings.isEmpty(message)}"
		대신에
		th:if="${message}" 로 사용하면 편리하다
		만일 message 가 존재한다면 이라고 읽으면 된다
	*/-->
	<p th:if="${message}" class="alert alert-success p-3" th:text="${message}"></p>

	<!--/*
		Thymeleaf view page 에서 요청 파라미터로 전달되었던 값도 추출할 수 있다
		${param.파라미터명}
		${#strings } 는 문자열에 관련된 유틸리티 객체를 활용할 수 있다
		${!#strings.isEmpty(param.search)} 는 search 라는 파라미터명으로 전달된 값이 있는지 확인
	*/-->
	<p th:if="${!#strings.isEmpty(param.search)}" 
	   class="alert alert-info px-3 py-2 rounded-3 shadow-sm">
		<i class="bi bi-search me-2"></i>
		<strong th:text="${param.search}"></strong> 조건
		<strong th:text="${param.keyword}"></strong> 검색 결과 입니다
	</p>

	<div class="btn-group mb-2">
		<a class="btn btn-outline-secondary bg-primary-subtle btn-sm"
		   th:classappend="${dto.prevNum eq 0 ? 'disabled' : null}"
		   th:href="@{|/board/view?num=${dto.prevNum}${query}|}">
			<i class="bi bi-arrow-left"></i>
			Prev
		</a>
		<a class="btn btn-outline-secondary bg-primary-subtle btn-sm"
		   th:classappend="${dto.nextNum eq 0 ? 'disabled' : null}"
		   th:href="@{|/board/view?num=${dto.nextNum}${query}|}">
			Next
			<i class="bi bi-arrow-right"></i>
		</a>
	</div>

	<table class="table table-striped">
		<colgroup>
			<col class="col-2" />
			<col class="col" />
		</colgroup>
		<tr>
			<th>글번호</th>
			<td th:text="${dto.num}"></td>
		</tr>
		<tr>
			<th>작성자</th>
			<td>
				<i th:if="${dto.profileImage == null}" style="font-size:100px;" class="bi bi-person-circle"></i>
				<img th:unless="${dto.profileImage == null}"
				     th:src="@{/upload/{name}(name=${dto.profileImage})}"
				     style="width:100px;height:100px;border-radius:50%;" />
				[[${dto.writer}]]
			</td>
		</tr>
		<tr>
			<th>제목</th>
			<td th:text="${dto.title}"></td>
		</tr>
		<tr>
			<th>조회수</th>
			<td th:text="${dto.viewCount}"></td>
		</tr>
		<tr>
			<th>작성일</th>
			<td th:text="${dto.createdAt}"></td>
		</tr>
	</table>

	<!-- 
		클라이언트가 작성한 글 제목이나 내용을 그대로 클라이언트에게 출력하는 것은
		javascript 주입 공격을 받을 수 있다
		따라서 해당 문자열을 escape 해서 출력하는 것이 안전하다
	-->
	<div class="card mt-4">
		<div class="card-header bg-light">
			<strong>본문 내용</strong>
		</div>
		<div class="card-body p-1">
			[(${dto.content})]
		</div>
	</div>

	<div th:if="${dto.writer eq #authentication.name}" class="text-end pt-2">
		<a class="btn btn-warning btn-sm" th:href="@{/board/edit(num=${dto.num})}">Edit</a>
		<a class="btn btn-danger btn-sm" th:href="@{/board/delete(num=${dto.num})}">Delete</a>
	</div>

	<th:block th:insert="~{/include/comment :: commentUi('board', ${dto.num}, ${dto.writer}, ${commentList})}"></th:block>
</div> <!-- container -->
  • gallery 의 view.html
<th:block th:insert="/include/navbar :: nav('view')"></th:block>
<div class="container pt-3">
	<div class="card shadow">
		<!-- 작성자 정보 -->
		<div class="card-header d-flex align-items-center">

			<i th:if="${res.dto.profileImage == null}" style="font-size:100px;" class="bi bi-person-circle"></i>

			<img th:unless="${res.dto.profileImage == null}" 
				 th:src="@{/upload/{name}(name=${res.dto.profileImage})}" 
				 style="width:100px;height:100px;border-radius:50%;" />

			<strong class="ms-3">[[${res.dto.writer}]]</strong>
			<span class="text-muted ms-auto">[[${res.dto.createdAt}]]</span>
		</div>
		<!-- 본문 -->
		<div class="card-body">
			<h5 class="card-title">[[${res.dto.title}]]</h5>
			<p class="card-text">[(${res.dto.content})]</p>
			<div class="row">

				<div th:each="tmp : ${res.images}" class="col-md-6 mb-4">
					<img th:src="@{/upload/{name}(name=${tmp.saveFileName})}"
						 class="img-fluid rounded" alt="photo">
				</div>

			</div>
		</div>
	</div>

	<!--/*
		commentUi('카테고리명' , ${원글의 글 번호}, ${원글의 작성자}, ${댓글 목록}
		을 전달하면 댓글 관련 UI 가 여기에 랜더링
	*/-->
	<th:block th:insert="~{/include/comment :: commentUi('gallery', ${res.dto.num}, ${res.dto.writer}, ${res.commentList})}"></th:block>

</div><!-- .container -->

로그인 관련 렌더징

  • home.html 코드 추가
<h2>공지사항</h2>
<ul>
	<li th:each="tmp : ${notice}" th:text="${tmp}"></li>
</ul>
<p>
	userName : <strong>[[${#authentication.name}]]</strong>
</p>
<p>
	userName : <strong sec:authentication="name"></strong>
</p>
<p>
	userName : <strong th:text="${#authentication.name}"></strong>
</p>
<!--/* 로그인 했을 때만 특정 요소를 렌더링 하는 방법*/-->
<p sec:authorize="isAuthenticated()">로그인 중</p>
<p th:if="${#authentication.name != 'anoymousUser'}">로그인 중</p>
<!--/* 로그인을 하지 않았을 특정 요소를 렌더링 하는 방법*/-->
<p sec:authorize="!isAuthenticated()">로그인 안 한 상태</p>
<p th:if="${#authentication.name == 'anoymousUser'}">로그인 중</p>
profile
학원 공부 내용 정리

0개의 댓글