최원종의 개발 블로그

V7-3 댓글 조회 기능 추가 본문

Spring boot 입문

V7-3 댓글 조회 기능 추가

chl6698 2026. 5. 21. 11:16

샘플 데이터 입력⬇️

더보기
-- User 테이블 데이터 (5명의 사용자)
INSERT INTO user_tb (username, password, email, created_at) VALUES
                                                                ('admin', '1234', 'admin@blog.com', NOW()),
                                                                ('ssar', '1234', 'ssar@nate.com', NOW()),
                                                                ('cos', '1234', 'cos@gmail.com', NOW()),
                                                                ('hong', '1234', 'hong@naver.com', NOW()),
                                                                ('kim', '1234', 'kim@daum.net', NOW());

-- 2단계: Board 테이블 데이터 (10개의 게시글)
-- 주의: user_id는 위에서 생성된 사용자의 id를 참조

-- admin 사용자가 작성한 게시글 (3개)
INSERT INTO board_tb (title, content, user_id, created_at) VALUES
                                                               ('블로그 개설을 환영합니다!', '안녕하세요! 새로운 블로그가 오픈했습니다. 많은 관심과 참여 부탁드립니다.', 1, NOW()),
                                                               ('공지사항: 이용수칙 안내', '블로그 이용 시 지켜야 할 기본적인 수칙들을 안내드립니다. 건전한 소통 문화를 만들어가요.', 1, NOW()),
                                                               ('업데이트 소식', '새로운 기능들이 추가되었습니다. 댓글 기능과 좋아요 기능을 곧 만나보실 수 있습니다.', 1, NOW());

-- ssar 사용자가 작성한 게시글 (3개)
INSERT INTO board_tb (title, content, user_id, created_at) VALUES
                                                               ('Spring Boot 학습 후기', 'Spring Boot를 처음 배우면서 느낀 점들을 공유합니다. JPA가 정말 편리하네요!', 2, NOW()),
                                                               ('JPA 연관관계 정리노트', '오늘 배운 @ManyToOne, @OneToMany 연관관계에 대해 정리해봤습니다. 헷갈리는 부분이 많아요.', 2, NOW()),
                                                               ('코딩테스트 문제 추천', '백준과 프로그래머스에서 풀어볼 만한 문제들을 추천드립니다. 알고리즘 공부 화이팅!', 2, NOW());

-- cos 사용자가 작성한 게시글 (2개)
INSERT INTO board_tb (title, content, user_id, created_at) VALUES
                                                               ('React vs Vue 비교', '프론트엔드 프레임워크 선택에 고민이 많았는데, 각각의 장단점을 비교해봤습니다.', 3, NOW()),
                                                               ('개발자 취업 팁 공유', '신입 개발자로 취업하면서 도움이 되었던 팁들을 공유합니다. 포트폴리오가 중요해요!', 3, NOW());

-- hong 사용자가 작성한 게시글 (1개)
INSERT INTO board_tb (title, content, user_id, created_at) VALUES
    ('첫 번째 게시글입니다', '안녕하세요! 블로그에 처음 글을 올려봅니다. 앞으로 자주 소통해요~', 4, NOW());

-- kim 사용자가 작성한 게시글 (1개)
INSERT INTO board_tb (title, content, user_id, created_at) VALUES
    ('맛집 추천 - 강남역 근처', '강남역 근처에서 점심 먹기 좋은 맛집들을 추천드립니다. 가성비도 좋아요!', 5, NOW());


-- 댓글 테이블 데이터 (각 게시글에 댓글들을 추가)

-- 1번 게시글 (admin의 '블로그 개설을 환영합니다!')에 대한 댓글
INSERT INTO reply_tb (comment, board_id, user_id, created_at) VALUES
('축하드립니다! 새로운 블로그 기대되네요.', 1, 2, NOW()),
('관리자님 수고 많으셨습니다. 좋은 컨텐츠 부탁드려요!', 1, 3, NOW()),
('드디어 오픈했군요. 자주 방문하겠습니다.', 1, 4, NOW());

-- 2번 게시글 (admin의 '공지사항: 이용수칙 안내')에 대한 댓글
INSERT INTO reply_tb (comment, board_id, user_id, created_at) VALUES
('이용수칙 잘 읽어보겠습니다.', 2, 2, NOW()),
('건전한 소통 문화 만들기에 동참하겠습니다!', 2, 5, NOW());

-- 3번 게시글 (admin의 '업데이트 소식')에 대한 댓글
INSERT INTO reply_tb (comment, board_id, user_id, created_at) VALUES
('댓글 기능 추가 감사합니다!', 3, 2, NOW()),
('좋아요 기능도 빨리 나왔으면 좋겠어요.', 3, 3, NOW()),
('업데이트 소식 감사합니다. 잘 사용하겠습니다.', 3, 4, NOW()),
('새로운 기능들이 기대됩니다.', 3, 5, NOW());

-- 4번 게시글 (ssar의 'Spring Boot 학습 후기')에 대한 댓글
INSERT INTO reply_tb (comment, board_id, user_id, created_at) VALUES
('저도 Spring Boot 공부 중인데 많은 도움이 되었습니다!', 4, 1, NOW()),
('JPA 정말 편리하죠. 처음엔 어려웠지만 익숙해지면 좋더라구요.', 4, 3, NOW()),
('학습 후기 공유해주셔서 감사합니다.', 4, 5, NOW());

-- 5번 게시글 (ssar의 'JPA 연관관계 정리노트')에 대한 댓글
INSERT INTO reply_tb (comment, board_id, user_id, created_at) VALUES
('연관관계 정말 헷갈리죠 ㅠㅠ 정리 잘 해주셨네요!', 5, 1, NOW()),
('@ManyToOne 부분이 특히 어려웠는데 덕분에 이해했습니다.', 5, 3, NOW()),
('저도 공부하면서 참고하겠습니다.', 5, 4, NOW()),
('양방향 매핑 부분도 추가로 설명해주시면 좋을 것 같아요.', 5, 5, NOW());

-- 6번 게시글 (ssar의 '코딩테스트 문제 추천')에 대한 댓글
INSERT INTO reply_tb (comment, board_id, user_id, created_at) VALUES
('문제 추천 감사합니다! 바로 풀어보겠습니다.', 6, 1, NOW()),
('백준 문제 중에서 어떤 걸 먼저 풀어보면 좋을까요?', 6, 4, NOW());

-- 7번 게시글 (cos의 'React vs Vue 비교')에 대한 댓글
INSERT INTO reply_tb (comment, board_id, user_id, created_at) VALUES
('React 쪽이 더 인기가 많은 것 같긴 하네요.', 7, 1, NOW()),
('Vue가 더 배우기 쉽다고 들었는데 실제로는 어떤가요?', 7, 2, NOW()),
('둘 다 써봤는데 각각 장단점이 있는 것 같아요.', 7, 4, NOW()),
('프로젝트 성격에 따라 선택하면 될 것 같습니다.', 7, 5, NOW());

-- 8번 게시글 (cos의 '개발자 취업 팁 공유')에 대한 댓글
INSERT INTO reply_tb (comment, board_id, user_id, created_at) VALUES
('취업 준비 중인데 정말 유용한 정보네요!', 8, 2, NOW()),
('포트폴리오 작성 방법도 자세히 알려주세요.', 8, 4, NOW()),
('면접 준비는 어떻게 하셨나요?', 8, 5, NOW());

-- 9번 게시글 (hong의 '첫 번째 게시글입니다')에 대한 댓글
INSERT INTO reply_tb (comment, board_id, user_id, created_at) VALUES
('첫 게시글 축하드려요! 환영합니다.', 9, 1, NOW()),
('앞으로 자주 소통해요~', 9, 2, NOW()),
('좋은 게시글 기대하겠습니다!', 9, 3, NOW());

-- 10번 게시글 (kim의 '맛집 추천 - 강남역 근처')에 대한 댓글
INSERT INTO reply_tb (comment, board_id, user_id, created_at) VALUES
('강남역 자주 가는데 맛집 정보 감사해요!', 10, 1, NOW()),
('가성비 좋은 곳 추천해주셔서 고마워요.', 10, 2, NOW()),
('저도 가봐야겠네요. 위치 정보도 알려주세요.', 10, 3, NOW()),
('점심 메뉴 추천도 해주시면 좋을 것 같아요.', 10, 4, NOW());

ReplyRepository - 댓글 조회 기능 ( JOIN FETCH User, Board 한 번에 가져 옴 )

package com.tenco.blog.reply;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;

// @Repository - 부모의 클래스에 정의 되어 있음()
public interface ReplyRepository extends JpaRepository<Reply, Integer> {
    // 기본적인 CRUD 자동 완성 및 추가 편의 기능 자동 생성


//    select r.*, b.*, u.*
//    from reply_tb r
//    inner join board_tb b on r.board_id = b.id
//    inner join user_tb u on r.user_id = u.id
//    where r.board_id = 1
//    order by r.created_at asc;
    // JPQL 문법으로 변환
    // 게시글 ID로 댓글 목록 조회(한번에 댓글 작성자 정보 포함 - JOIN FETCH 사용)
    @Query("""
    SELECT r FROM Reply r 
    JOIN FETCH r.user 
    JOIN FETCH r.board 
    WHERE r.board.id = :boardId 
    ORDER BY r.createdAt ASC
""")
    List<Reply> findByBoardIdWithUser(@Param("board_id") Integer boardId);

}

ReplyService

댓글 조회시 List<Reply> 데이터 타입을 List<ReplyResponse.ListDTO> 로 변환 ( stream api 활용)
package com.tenco.blog.reply;

import com.tenco.blog._core.errors.Exception404;
import com.tenco.blog.board.Board;
import com.tenco.blog.board.BoardRepository;
import com.tenco.blog.user.User;
import com.tenco.blog.user.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;

// Service 계층에서는 여러 Repository를 조합해서 비즈니스 규칙을 완성한다
// 즉, 서비스 계층이 필요한 이유 중 하나이다.
@Service // IoC
@RequiredArgsConstructor // DI 처리
public class ReplyService {

    private final ReplyRepository replyRepository;
    private final BoardRepository boardRepository;
    private final UserRepository userRepository;

    // 댓글 목록 조회
    public List<ReplyResponse.ListDTO> 댓글목록조회(Integer boardId, Integer sessionUserId) {

        List<Reply> replyList = replyRepository.findByBoardIdWithUser(boardId);
//        [방법 3]
//        List<ReplyResponse.ListDTO> listDtoList = new ArrayList<>();
//        for(int i =0; i < replyList.size(); i++) {
//            Reply tempReply = replyList.get(i);
//            // 객체 생성을 위해 필요한 데이터
//            ReplyResponse.ListDTO listDTO = new ReplyResponse.ListDTO(tempReply, sessionUserId);
//            listDtoList.add(listDTO);
//        }
        // Stream API 활용 [방법 2] <-- 사용 
        return replyList.stream()
                .map(reply -> new ReplyResponse.ListDTO(reply, sessionUserId))
                .toList();
    }


    @Transactional
    public Reply 댓글작성(ReplyRequest.SaveDTO saveDTO, Integer id) {
        // 게시글 조회
        Board boardEntity = boardRepository.findById(saveDTO.getBoardId()).orElseThrow(
                () -> new Exception404("해당 게시글을 찾을 수 없습니다"));

        // id 로 사용자 조회
        User userEntity = userRepository.findById(id).orElseThrow(
                () -> new Exception404("사용자를 찾을 수 없습니다")
        );
        Reply reply = saveDTO.toEntity(userEntity, boardEntity);
        replyRepository.save(reply);
        return reply;
    }
}

BoardController ( 게시글 상세보기 조회 시 BoardController에서 시작함)

    // 게시글 상세보기 화면 요청
    // http://localhost:8080/board/1
    @GetMapping("/board/{id}")
    public String detailPage(@PathVariable(name = "id") Integer id, Model model) {
        BoardResponse.DetailDTO detailDTO = boardService.게시글상세조회(id);

        // 대글 목록 조회 기능 필요 - TODO

        model.addAttribute("board", detailDTO);
        return "board/detail";
    }

GlobalExceptionHandler ( 제약 조건 위반 오류)

package com.tenco.blog._core.errors;


import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestControllerAdvice;

// 모든 컨트롤러에서 발생하는 예외를 이 클래스에서 처리 하겠다.
// RuntimeException 이 발생되면 해당 이 파일로 예외 처리가 오게 됨.
@Slf4j
@ControllerAdvice // IoC -> 에러 페이지 찾아 가능 녀석
// @RestControllerAdvice // 에러를 데이터로 반환할 때 사용
public class GlobalExceptionHandler {

    @ExceptionHandler(Exception400.class)
    public String ex400(Exception400 e, HttpServletRequest request) {
        log.warn("=== 400 Bad Request 에러 발생 ===");
        log.warn("요청 URL: {}", request.getRequestURL());
        log.warn("에러메시지: {}", e.getMessage());

        request.setAttribute("msg", e.getMessage());
        return "err/400";
    }

//    @ExceptionHandler(Exception401.class)
//    public String ex401(Exception401 e, HttpServletRequest request) {
//        log.warn("=== 401 Unauthorized 에러 발생 ===");
//        log.warn("요청 URL: {}", request.getRequestURL());
//        log.warn("에러메시지: {}", e.getMessage());
//
//        request.setAttribute("msg", e.getMessage());
//        return "err/401";
//    }


    @ExceptionHandler(Exception401.class)
    @ResponseBody
    public String ex401(Exception401 e, HttpServletRequest request) {
        String script = """
            <script>
                alert('%s');
                location.href='/login-form';
            </script>
            """.formatted(e.getMessage());
        return script;
    }

//    @ExceptionHandler(Exception403.class)
//    public String ex403(Exception403 e, HttpServletRequest request) {
//        log.warn("=== 403 Forbidden 에러 발생 ===");
//        log.warn("요청 URL: {}", request.getRequestURL());
//        log.warn("에러메시지: {}", e.getMessage());
//
//        request.setAttribute("msg", e.getMessage());
//        return "err/403";
//    }

    @ExceptionHandler(Exception403.class)
    @ResponseBody // 파일 찾지 말고 데이터 반환
    public String ex403(Exception403 e, HttpServletRequest request) {

//        String script = "<script>alert(' " + e.getMessage() + " ');" +
//                "history.back();" +
//                "</script>";

        String script = """
        <script>
            alert('%s');
            history.back();
        </script>
        """.formatted(e.getMessage());

        return script;
    }

    @ExceptionHandler(Exception404.class)
    public String ex404(Exception404 e, HttpServletRequest request) {
        log.warn("=== 404 Not Found 에러 발생 ===");
        log.warn("요청 URL: {}", request.getRequestURL());
        log.warn("에러메시지: {}", e.getMessage());

        request.setAttribute("msg", e.getMessage());
        return "err/404";
    }

    @ExceptionHandler(Exception500.class)
    public String ex500(Exception500 e, HttpServletRequest request) {
        log.warn("=== 500 Internal Server Error 에러 발생 ===");
        log.warn("요청 URL: {}", request.getRequestURL());
        log.warn("에러메시지: {}", e.getMessage());

        request.setAttribute("msg", e.getMessage());
        return "err/500";
    }

    // 기타 모든 RuntimeException 처리 (최후의 보루)
    @ExceptionHandler(RuntimeException.class)
    public String handleRuntimeException(RuntimeException e, HttpServletRequest request) {
        log.warn("=== 예상치 못한 런타임 에러 발생 ===");
        log.warn("요청 URL: {}", request.getRequestURL());
        log.warn("에러메시지: {}", e.getMessage());

        request.setAttribute("msg", "시스템 오류가 발생했습니다. 관리자에게 문의해주세요");
        return "err/500";
    }

    // 데이터베이스 관련 및 제약조건 위반 오류 처리
    @ExceptionHandler(DataIntegrityViolationException.class)
    public String handleDataIntegrityViolationException(DataIntegrityViolationException e,
                                                        HttpServletRequest request) {
        log.warn("=== 데이터 베이스 제약 조건 위반 오류 발생 ===");
        log.warn("요청 URL: {}", request.getRequestURL());
        log.warn("에러메시지: {}", e.getMessage());

        String errorMessage = e.getMessage();
        if (errorMessage != null && errorMessage.contains("FOREIGN KEY")) {
            request.setAttribute("msg", "관련된 데이터가 있어 삭제할 수 없습니다");
        } else {
            // 실제로는 다른 내용으로 에러페이지에 내려 줘야 함.
            request.setAttribute("msg", "데이터베이스 제약조건 위반:" + e.getMessage());
        }
        return "err/500";
    }
}

board/detail.mustache (수정 삭제 버튼 섹션, 댓글 섹션)

{{> layout/header}}
<div class="container p-5">

    <!-- 수정삭제버튼 섹션 -->
    {{#checkIsOwner}}
    <div class="d-flex justify-content-end">
        <a href="/board/{{board.id}}/update-form" class="btn btn-warning me-1">수정</a>
        <form action="/board/{{board.id}}/delete" method="post">
            <button class="btn btn-danger">삭제</button>
        </form>
    </div>
    {{/checkIsOwner}}

    <!-- 작성자 정보 표시 -->
    <div class="d-flex justify-content-end">
        <b>작성자</b> : {{board.username}}
    </div>

    <!-- 게시글 내용 섹션 -->
    <div>
        <h2><b>{{board.title}}</b></h2>
        <hr />
        <div class="m-4 p-2">
            {{board.content}}
        </div>
    </div>

    <!-- 댓글 섹션 시작 -->
    <div class="card mt-3">
    {{^sessionUser}}
        <div class="card-body">
            <p class="text-muted text-center p-2">댓글을 작성하려면 <a href="/login-form">로그인</a>이 필요합니다</p>
        </div>
    {{/sessionUser}}
    {{#sessionUser}}
        <div class="card-body">
            <form action="/reply/save" method="post">
                <input type="hidden" name="boardId" value="{{board.id}}" />
                <textarea name="comment" class="form-control"></textarea>
                <div class="d-flex justify-content-end mt-2">
                    <button type="submit" class="btn btn-outline-primary">댓글 등록</button>
                </div>

            </form>
        </div>
    {{/sessionUser}}
    {{^replyList}}
        <p class="text-muted text-center p-2">현재 작성된 댓글이 없습니다</p>
    {{/replyList}}
    {{#replyList}}
        <div class="list-group">
            <div class="list-group-item d-flex justify-content-between align-items-center">
                <div class="d-flex flex-column">
                    <div class="d-flex align-items-center mb-1" >
                        <div class="px-2 py-1 bg-primary text-white rounded"> {{username}} </div>
                        <small class="text-muted">{{createdAt}}</small>
                    </div>
                    <div>{{comment}}</div>
                </div>
                {{#isOwner}}
                    <form action="/reply/{{id}}/delete" method="post">
                        <button type="submit" class="btn btn-danger btn-sm">🗑️ 삭제</button>
                    </form>
                {{/isOwner}}
            </div>
        </div>
    {{/replyList}}

    </div>
    <!-- 댓글 섹션 종료 -->
</div>

{{> layout/footer}}