inblog logo
|
parangdajavous
    Spring Boot

    [Spring Boot] 11. Spring Boot Project (Blog v2 - jpa)_7.Board - Detail

    김미숙's avatar
    김미숙
    Jul 22, 2025
    [Spring Boot] 11. Spring Boot Project (Blog v2 - jpa)_7.Board - Detail
    Contents
    📌 title, content, username만 화면에 나오게 하기BoardControllerBoardServiceBoardRepositoryBoardResponsedetail📌 작성자만 수정,삭제 버튼 보이기 & 비로그인 시 상세보기 BoardResponseBoardControllerBoardServiceBoardRepositorydetail📌 좋아요 (Ajax)➡ Table & Dummy Data 생성 후 화면에 뿌려보기화면에 user 별로 좋아요 표시해보기loveCount 표시한방 Query version좋아요 & 좋아요 취소📌 댓글Table 생성Dummy Data 넣고 확인양방향 Mapping댓글 - 작성자만 삭제 가능하게댓글 - 쓰기댓글 - 삭제findByIdJoinUser() Test

    📌 title, content, username만 화면에 나오게 하기

    notion image

    BoardController

    package shop.mtcoding.blog.board; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import shop.mtcoding.blog.user.User; @RequiredArgsConstructor @Controller public class BoardController { private final BoardService boardService; private final HttpSession session; @GetMapping("/") public String list(HttpServletRequest request) { User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) { request.setAttribute("models", boardService.글목록보기(null)); } else { request.setAttribute("models", boardService.글목록보기(sessionUser.getId())); } return "board/list"; } @PostMapping("/board/save") public String save(BoardRequest.SaveDTO saveDTO) { // 인증로직 User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증이 필요합니다."); boardService.글쓰기(saveDTO, sessionUser); return "redirect:/"; } @GetMapping("/board/save-form") public String saveForm() { // 인증로직 User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증이 필요합니다."); return "board/save-form"; } @GetMapping("/board/{id}") public String detail(@PathVariable("id") Integer id, HttpServletRequest request) { Board board = boardService.글상세보기(id); request.setAttribute("model", board); return "board/detail"; } }

    BoardService

    package shop.mtcoding.blog.board; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import shop.mtcoding.blog.user.User; import java.util.List; @RequiredArgsConstructor @Service public class BoardService { private final BoardRepository boardRepository; @Transactional public void 글쓰기(BoardRequest.SaveDTO saveDTO, User sessionUser) { Board board = saveDTO.toEntity(sessionUser); boardRepository.save(board); } public List<Board> 글목록보기(Integer userId) { return boardRepository.findAll(userId); } public Board 글상세보기(Integer id) { return boardRepository.findByIdJoinUser(id); } }

    BoardRepository

    Join Query 필요
    package shop.mtcoding.blog.board; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.util.List; @RequiredArgsConstructor @Repository public class BoardRepository { private final EntityManager em; public void save(Board board) { em.persist(board); } public List<Board> findAll(Integer userId) { // Integer를 써야 null을 넘길 수 있다 // 동적 query String s1 = "select b from Board b where b.isPublic = true or b.user.id = :userId order by b.id desc"; String s2 = "select b from Board b where b.isPublic = true order by b.id desc"; Query query = null; if (userId == null) { query = em.createQuery(s2, Board.class); } else { query = em.createQuery(s1, Board.class); query.setParameter("userId", userId); } return query.getResultList(); } public Board findById(Integer id) { return em.find(Board.class, id); } public Board findByIdJoinUser(Integer id) { // b -> board에 있는 필드만 프로잭션 fetch를 써야 board안에 있는 user 객체도 같이 프로잭션됨 Query query = em.createQuery("select b from Board b join fetch b.user u where b.id = :id", Board.class); // inner join (on절은 생략가능하다) -> 객체지향 쿼리 query.setParameter("id", id); return (Board) query.getSingleResult(); } }

    BoardResponse

    package shop.mtcoding.blog.board; import lombok.Data; import java.sql.Timestamp; public class BoardResponse { // 깊은 복사 @Data public static class DetailDTO { private Integer id; private String title; private String content; private Boolean isPublic; private Boolean isOwner; // 대문자는 값을 안 넣으면 null, 소문자는 false private Integer userId; private String username; private Timestamp createdAt; // 템플릿엔진이 조건문 비교를 허용해주지 않기 때문에 필요함 public DetailDTO(Board board, Integer sessionUserId) { this.id = board.getId(); this.title = board.getTitle(); this.content = board.getContent(); this.isPublic = board.getIsPublic(); this.isOwner = sessionUserId == board.getUser().getId(); // 같으면 true 같지 않으면 false this.userId = board.getUser().getId(); this.username = board.getUser().getUsername(); this.createdAt = board.getCreatedAt(); } } }

    detail

    {{> layout/header}} <div class="container p-5"> <!-- 수정삭제버튼 --> <div class="d-flex justify-content-end"> <a href="/board/{{model.id}}/update-form" class="btn btn-warning me-1">수정</a> <form action="/board/{{model.id}}/delete" method="post"> <button class="btn btn-danger">삭제</button> </form> </div> <div class="d-flex justify-content-end"> <b>작성자</b> : {{model.user.username}} </div> <!-- 게시글내용 --> <div> <h2><b>{{model.title}}</b></h2> <hr/> <div class="m-4 p-2"> {{model.content}} </div> </div> <!-- AJAX 좋아요 영역 --> <div class="my-3 d-flex align-items-center"> <i id="likeIcon" class="fa fa-heart" style="font-size:20px; color:black" onclick="likeToggle()"></i> <span class="ms-1"><b id="likeCount">12</b>명이 이 글을 좋아합니다</span> </div> <!-- 댓글 --> <div class="card mt-3"> <!-- 댓글등록 --> <div class="card-body"> <form action="/reply/save" method="post"> <textarea class="form-control" rows="2" name="comment"></textarea> <div class="d-flex justify-content-end"> <button type="submit" class="btn btn-outline-primary mt-1">댓글등록</button> </div> </form> </div> <!-- 댓글목록 --> <div class="card-footer"> <b>댓글리스트</b> </div> <div class="list-group"> <!-- 댓글아이템 --> <div class="list-group-item d-flex justify-content-between align-items-center"> <div class="d-flex"> <div class="px-1 me-1 bg-primary text-white rounded">cos</div> <div>댓글 내용입니다</div> </div> <form action="/reply/1/delete" method="post"> <button class="btn">🗑</button> </form> </div> <!-- 댓글아이템 --> <div class="list-group-item d-flex justify-content-between align-items-center"> <div class="d-flex"> <div class="px-1 me-1 bg-primary text-white rounded">ssar</div> <div>댓글 내용입니다</div> </div> <form action="/reply/1/delete" method="post"> <button class="btn">🗑</button> </form> </div> </div> </div> </div> <script> let liked = false; // 처음엔 좋아요 상태라고 가정 function likeToggle() { let icon = document.querySelector('#likeIcon'); if (liked) { icon.style.color = 'black'; } else { icon.style.color = 'red'; } liked = !liked; } </script> {{> layout/footer}}
    notion image
    notion image
    notion image

    📌 작성자만 수정,삭제 버튼 보이기 & 비로그인 시 상세보기

    BoardResponse

    package shop.mtcoding.blog.board; import lombok.Data; import java.sql.Timestamp; public class BoardResponse { // 깊은 복사 @Data public static class DetailDTO { private Integer id; private String title; private String content; private Boolean isPublic; private Boolean isOwner; // 대문자는 값을 안 넣으면 null, 소문자는 false private String username; private Timestamp createdAt; // 템플릿엔진이 조건문 비교를 허용해주지 않기 때문에 필요함 public DetailDTO(Board board, Integer sessionUserId) { this.id = board.getId(); this.title = board.getTitle(); this.content = board.getContent(); this.isPublic = board.getIsPublic(); this.isOwner = sessionUserId == board.getUser().getId(); // 같으면 true 같지 않으면 false this.username = board.getUser().getUsername(); this.createdAt = board.getCreatedAt(); } } }

    BoardController

    package shop.mtcoding.blog.board; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import shop.mtcoding.blog.user.User; @RequiredArgsConstructor @Controller public class BoardController { private final BoardService boardService; private final HttpSession session; @GetMapping("/") public String list(HttpServletRequest request) { User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) { request.setAttribute("models", boardService.글목록보기(null)); } else { request.setAttribute("models", boardService.글목록보기(sessionUser.getId())); } return "board/list"; } @PostMapping("/board/save") public String save(BoardRequest.SaveDTO saveDTO) { // 인증로직 User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증이 필요합니다."); boardService.글쓰기(saveDTO, sessionUser); return "redirect:/"; } @GetMapping("/board/save-form") public String saveForm() { // 인증로직 User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증이 필요합니다."); return "board/save-form"; } @GetMapping("/board/{id}") public String detail(@PathVariable("id") Integer id, HttpServletRequest request) { User sessionUser = (User) session.getAttribute("sessionUser"); // 비로그인 시 상세보기 Integer sessionUserId = (sessionUser == null ? null : sessionUser.getId()); BoardResponse.DetailDTO detailDTO = boardService.글상세보기(id, sessionUserId); request.setAttribute("model", detailDTO); return "board/detail"; } }

    BoardService

    package shop.mtcoding.blog.board; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import shop.mtcoding.blog.user.User; import java.util.List; @RequiredArgsConstructor @Service public class BoardService { private final BoardRepository boardRepository; @Transactional public void 글쓰기(BoardRequest.SaveDTO saveDTO, User sessionUser) { Board board = saveDTO.toEntity(sessionUser); boardRepository.save(board); } public List<Board> 글목록보기(Integer userId) { return boardRepository.findAll(userId); } public BoardResponse.DetailDTO 글상세보기(Integer id, Integer userId) { Board board = boardRepository.findByIdJoinUser(id); BoardResponse.DetailDTO detailDTO = new BoardResponse.DetailDTO(board, userId); return detailDTO; } }

    BoardRepository

    package shop.mtcoding.blog.board; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.util.List; @RequiredArgsConstructor @Repository public class BoardRepository { private final EntityManager em; public void save(Board board) { em.persist(board); } public List<Board> findAll(Integer userId) { // Integer를 써야 null을 넘길 수 있다 // 동적 query String s1 = "select b from Board b where b.isPublic = true or b.user.id = :userId order by b.id desc"; String s2 = "select b from Board b where b.isPublic = true order by b.id desc"; Query query = null; if (userId == null) { query = em.createQuery(s2, Board.class); } else { query = em.createQuery(s1, Board.class); query.setParameter("userId", userId); } return query.getResultList(); } public Board findById(Integer id) { return em.find(Board.class, id); } public Board findByIdJoinUser(Integer id) { // b -> board에 있는 필드만 프로잭션 fetch를 써야 board안에 있는 user 객체도 같이 프로잭션됨 Query query = em.createQuery("select b from Board b join fetch b.user u where b.id = :id", Board.class); // inner join (on절은 생략가능하다) -> 객체지향 쿼리 query.setParameter("id", id); return (Board) query.getSingleResult(); } }

    detail

    {{> layout/header}} <div class="container p-5"> <!-- 수정삭제버튼 --> {{#model.isOwner}} <div class="d-flex justify-content-end"> <a href="/board/{{model.id}}/update-form" class="btn btn-warning me-1">수정</a> <form action="/board/{{model.id}}/delete" method="post"> <button class="btn btn-danger">삭제</button> </form> </div> {{/model.isOwner}} <div class="d-flex justify-content-end"> <b>작성자</b> : {{model.username}} </div> <!-- 게시글내용 --> <div> <h2><b>{{model.title}}</b></h2> <hr/> <div class="m-4 p-2"> {{model.content}} </div> </div> <!-- AJAX 좋아요 영역 --> <div class="my-3 d-flex align-items-center"> <i id="likeIcon" class="fa fa-heart" style="font-size:20px; color:black" onclick="likeToggle()"></i> <span class="ms-1"><b id="likeCount">12</b>명이 이 글을 좋아합니다</span> </div> <!-- 댓글 --> <div class="card mt-3"> <!-- 댓글등록 --> <div class="card-body"> <form action="/reply/save" method="post"> <textarea class="form-control" rows="2" name="comment"></textarea> <div class="d-flex justify-content-end"> <button type="submit" class="btn btn-outline-primary mt-1">댓글등록</button> </div> </form> </div> <!-- 댓글목록 --> <div class="card-footer"> <b>댓글리스트</b> </div> <div class="list-group"> <!-- 댓글아이템 --> <div class="list-group-item d-flex justify-content-between align-items-center"> <div class="d-flex"> <div class="px-1 me-1 bg-primary text-white rounded">cos</div> <div>댓글 내용입니다</div> </div> <form action="/reply/1/delete" method="post"> <button class="btn">🗑</button> </form> </div> <!-- 댓글아이템 --> <div class="list-group-item d-flex justify-content-between align-items-center"> <div class="d-flex"> <div class="px-1 me-1 bg-primary text-white rounded">ssar</div> <div>댓글 내용입니다</div> </div> <form action="/reply/1/delete" method="post"> <button class="btn">🗑</button> </form> </div> </div> </div> </div> <script> let liked = false; // 처음엔 좋아요 상태라고 가정 function likeToggle() { let icon = document.querySelector('#likeIcon'); if (liked) { icon.style.color = 'black'; } else { icon.style.color = 'red'; } liked = !liked; } </script> {{> layout/footer}}

    비로그인

    ➡ 비로그인 시 상세보기 확인가능
    notion image
     

    ssar login

    ➡ ssar이 작성한 글에는 수정,삭제 버튼이 보임
    notion image
    ➡ ssar이 작성하지 않은 글에는 수정,삭제 버튼이 보이지 않음
    notion image
     

    📌 좋아요 (Ajax)

    ❗ user는 board 여러 개를 좋아요 누를 수 있고, board는 여러 user의 좋아요가 눌릴 수 있다
    user_tb와 board_tb는 N:N 관계 → love_tb(중간 table) 생성 필요
    N : N 관계는 중간 table(love_tb)에 FK가 들어온다
    notion image
    love_tb 입장에서는 user(1):love(N) / board(1):love(N)

    ➡ Table & Dummy Data 생성 후 화면에 뿌려보기

    Love

    package shop.mtcoding.blog.love; import jakarta.persistence.*; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.CreationTimestamp; import shop.mtcoding.blog.board.Board; import shop.mtcoding.blog.user.User; import java.sql.Timestamp; @NoArgsConstructor @Getter @Table( name = "love_tb", uniqueConstraints = { @UniqueConstraint(columnNames = {"user_id", "board_id"}) } ) @Entity public class Love { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; @ManyToOne(fetch = FetchType.LAZY) private User user; @ManyToOne(fetch = FetchType.LAZY) private Board board; @CreationTimestamp private Timestamp createdAt; @Builder public Love(Integer id, User user, Board board, Timestamp createdAt) { this.id = id; this.user = user; this.board = board; this.createdAt = createdAt; } }

    data

    insert into user_tb(username, password, email, created_at) values ('ssar', '1234', 'ssar@nate.com', now()); insert into user_tb(username, password, email, created_at) values ('cos', '1234', 'cos@nate.com', now()); insert into user_tb(username, password, email, created_at) values ('love', '1234', 'love@nate.com', now()); insert into board_tb(title, content, user_id, is_public, created_at) values ('제목1', '내용1', 1, true, now()); insert into board_tb(title, content, user_id, is_public, created_at) values ('제목2', '내용2', 1, true, now()); insert into board_tb(title, content, user_id, is_public, created_at) values ('제목3', '내용3', 2, true, now()); insert into board_tb(title, content, user_id, is_public, created_at) values ('제목4', '내용4', 3, true, now()); insert into board_tb(title, content, user_id, is_public, created_at) values ('제목5', '내용5', 1, false, now()); insert into love_tb(board_id, user_id, created_at) values (5, 1, now()); insert into love_tb(board_id, user_id, created_at) values (4, 2, now()); insert into love_tb(board_id, user_id, created_at) values (4, 1, now());
    notion image
    notion image

    화면에 user 별로 좋아요 표시해보기

    Board

    package shop.mtcoding.blog.board; import lombok.Data; import java.sql.Timestamp; public class BoardResponse { // 깊은 복사 @Data public static class DetailDTO { private Integer id; private String title; private String content; private Boolean isPublic; private Boolean isOwner; // 대문자는 값을 안 넣으면 null, 소문자는 false private Boolean isLove; private Integer loveCount; private String username; private Timestamp createdAt; // 템플릿엔진이 조건문 비교를 허용해주지 않기 때문에 필요함 public DetailDTO(Board board, Integer sessionUserId) { this.id = board.getId(); this.title = board.getTitle(); this.content = board.getContent(); this.isPublic = board.getIsPublic(); this.isOwner = sessionUserId == board.getUser().getId(); // 같으면 true 같지 않으면 false this.username = board.getUser().getUsername(); this.createdAt = board.getCreatedAt(); this.isLove = sessionUserId == 1 ? true : false; // 확인 위한 given data this.loveCount = 3; // 확인 위한 given data } } }

    detail

    {{> layout/header}} <div class="container p-5"> <!-- 수정삭제버튼 --> {{#model.isOwner}} <div class="d-flex justify-content-end"> <a href="/board/{{model.id}}/update-form" class="btn btn-warning me-1">수정</a> <form action="/board/{{model.id}}/delete" method="post"> <button class="btn btn-danger">삭제</button> </form> </div> {{/model.isOwner}} <div class="d-flex justify-content-end"> <b>작성자</b> : {{model.username}} </div> <!-- 게시글내용 --> <div> <h2><b>{{model.title}}</b></h2> <hr/> <div class="m-4 p-2"> {{model.content}} </div> </div> <!-- AJAX 좋아요 영역 --> <div class="my-3 d-flex align-items-center"> {{#model.isLove}} <i id="likeIcon" class="fa fa-heart" style="font-size:20px; color:red" onclick="likeToggle()"></i> {{/model.isLove}} {{^model.isLove}} <i id="likeIcon" class="fa fa-heart" style="font-size:20px; color:black" onclick="likeToggle()"></i> {{/model.isLove}} <span class="ms-1"><b id="likeCount">{{model.loveCount}}</b>명이 이 글을 좋아합니다</span> </div> <!-- 댓글 --> <div class="card mt-3"> <!-- 댓글등록 --> <div class="card-body"> <form action="/reply/save" method="post"> <textarea class="form-control" rows="2" name="comment"></textarea> <div class="d-flex justify-content-end"> <button type="submit" class="btn btn-outline-primary mt-1">댓글등록</button> </div> </form> </div> <!-- 댓글목록 --> <div class="card-footer"> <b>댓글리스트</b> </div> <div class="list-group"> <!-- 댓글아이템 --> <div class="list-group-item d-flex justify-content-between align-items-center"> <div class="d-flex"> <div class="px-1 me-1 bg-primary text-white rounded">cos</div> <div>댓글 내용입니다</div> </div> <form action="/reply/1/delete" method="post"> <button class="btn">🗑</button> </form> </div> <!-- 댓글아이템 --> <div class="list-group-item d-flex justify-content-between align-items-center"> <div class="d-flex"> <div class="px-1 me-1 bg-primary text-white rounded">ssar</div> <div>댓글 내용입니다</div> </div> <form action="/reply/1/delete" method="post"> <button class="btn">🗑</button> </form> </div> </div> </div> </div> <script> let liked = false; // 처음엔 좋아요 상태라고 가정 function likeToggle() { let icon = document.querySelector('#likeIcon'); if (liked) { icon.style.color = 'black'; } else { icon.style.color = 'red'; } liked = !liked; } </script> {{> layout/footer}}
    <div class="my-3 d-flex align-items-center"> <i id="likeIcon" class="fa fa-heart" style="font-size:20px; color:{{#model.isLove}}red{{/model.isLove}}{{^model.isLove}}black{{/model.isLove}} " onclick="likeToggle()"></i> <span class="ms-1"><b id="likeCount">{{model.loveCount}}</b>명이 이 글을 좋아합니다</span> </div>

    LoveRepository

    package shop.mtcoding.blog.love; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @RequiredArgsConstructor @Repository public class LoveRepository { private final EntityManager em; public Love findByUserIdAndBoardId(int userId, int boardId) { Query query = em.createQuery("select lo from Love lo where lo.user.id = :userId and lo.board.id = :boardId", Love.class); query.setParameter("userId", userId); query.setParameter("boardId", boardId); try { return (Love) query.getSingleResult(); // unique 제약조건이기 때문에 SingleResult } catch (Exception e) { return null; } } }

    BoardService

    package shop.mtcoding.blog.board; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import shop.mtcoding.blog.love.Love; import shop.mtcoding.blog.love.LoveRepository; import shop.mtcoding.blog.user.User; import java.util.List; @RequiredArgsConstructor @Service public class BoardService { private final BoardRepository boardRepository; private final LoveRepository loveRepository; @Transactional public void 글쓰기(BoardRequest.SaveDTO saveDTO, User sessionUser) { Board board = saveDTO.toEntity(sessionUser); boardRepository.save(board); } public List<Board> 글목록보기(Integer userId) { return boardRepository.findAll(userId); } public BoardResponse.DetailDTO 글상세보기(Integer id, Integer userId) { Board board = boardRepository.findByIdJoinUser(id); Love love = loveRepository.findByUserIdAndBoardId(userId, id); Boolean isLove = love == null ? false : true; BoardResponse.DetailDTO detailDTO = new BoardResponse.DetailDTO(board, userId, isLove); return detailDTO; } }
    ssar 로그인
    notion image
    cos 로그인
    notion image
     

    loveCount 표시

    BoardResponse

    package shop.mtcoding.blog.board; import lombok.Data; import shop.mtcoding.blog.reply.Reply; import java.sql.Timestamp; import java.util.ArrayList; import java.util.List; public class BoardResponse { @Data public static class DTO { private List<Board> boards; private Integer prev; private Integer next; private Integer current; private Integer size; // 3 private Integer totalCount; private Integer totalPage; private boolean isFirst; // current == 0 private boolean isLast; // 다음페이지에서 못 넘어가게 계산 필요 -> totalCount, size = 3 totalPages == current private List<Integer> numbers; public DTO(List<Board> boards, Integer current, Integer totalCount) { this.boards = boards; this.prev = current - 1; this.next = current + 1; this.size = 3; // 보통은 final로 따로 빼서 씀 - 그래야 수정이 적어진다 this.totalCount = totalCount; // given 으로 처리 후 따로 연산(given으로 test 먼저 필요) -> test 끝나면 DB에서 들고옴 this.totalPage = makeTotalPage(totalCount, size); this.isFirst = current == 0; this.isLast = (totalPage - 1) == current; // totalPages는 1부터 시작하는데 current는 0부터 시작하니까 totalPages-1 필요 System.out.println("isLast: " + isLast); this.numbers = makeNumbers(current, totalPage); } // page 계산 함수 private Integer makeTotalPage(int totalCount, int size) { int rest = totalCount % size > 0 ? 1 : 0; // 나머지 -> 5 / 3 = 나머지 2 , 6 / 3 = 나머지 0 // 나머지가 0이 아니면 rest = 1을 page에 더함 return totalCount / size + rest; // 전체 페이지 } private List<Integer> makeNumbers(int current, int totalPage) { List<Integer> numbers = new ArrayList<>(); int start = (current / 5) * 5; int end = Math.min(start + 5, totalPage); for (int i = start; i < end; i++) { numbers.add(i); } return numbers; } } // 깊은 복사 @Data public static class DetailDTO { private Integer id; private String title; private String content; private Boolean isPublic; private Boolean isOwner; // 대문자는 값을 안 넣으면 null, 소문자는 false private Boolean isLove; private Integer loveCount; private String username; private Timestamp createdAt; private Integer loveId; private List<ReplyDTO> replies; @Data // DetailDTO안에 있기 때문에 외부 클래스가 아닌 내부클래스 public class ReplyDTO { private Integer id; private String content; private String username; private Boolean isOwner; public ReplyDTO(Reply reply, Integer sessionUserId) { this.id = reply.getId(); this.content = reply.getContent(); this.username = reply.getUser().getUsername(); this.isOwner = reply.getUser().getId().equals(sessionUserId); } } // 템플릿엔진이 조건문 비교를 허용해주지 않기 때문에 필요함 public DetailDTO(Board board, Integer sessionUserId, Boolean isLove, Integer loveCount, Integer loveId) { this.id = board.getId(); this.title = board.getTitle(); this.content = board.getContent(); this.isPublic = board.getIsPublic(); this.isOwner = sessionUserId == board.getUser().getId(); // 같으면 true 같지 않으면 false this.username = board.getUser().getUsername(); this.createdAt = board.getCreatedAt(); this.isLove = isLove; this.loveCount = loveCount; this.loveId = loveId; List<ReplyDTO> repliesDTO = new ArrayList<>(); for (Reply reply : board.getReplies()) { ReplyDTO replyDTO = new ReplyDTO(reply, sessionUserId); repliesDTO.add(replyDTO); } this.replies = repliesDTO; } } }

    LoveRepository

    package shop.mtcoding.blog.love; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @RequiredArgsConstructor @Repository public class LoveRepository { private final EntityManager em; public Love findByUserIdAndBoardId(int userId, int boardId) { Query query = em.createQuery("select lo from Love lo where lo.user.id = :userId and lo.board.id = :boardId", Love.class); query.setParameter("userId", userId); query.setParameter("boardId", boardId); try { return (Love) query.getSingleResult(); // unique 제약조건이기 때문에 SingleResult } catch (Exception e) { return null; } } public Long findByLoveCount(int boardId) { Query query = em.createQuery("select count(lo) from Love lo where lo.board.id = :boardId"); query.setParameter("boardId", boardId); Long count = (Long) query.getSingleResult(); try { return count; // unique 제약조건이기 때문에 SingleResult } catch (Exception e) { return null; } } }

    BoardService

    package shop.mtcoding.blog.board; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import shop.mtcoding.blog.love.Love; import shop.mtcoding.blog.love.LoveRepository; import shop.mtcoding.blog.user.User; import java.util.List; @RequiredArgsConstructor @Service public class BoardService { private final BoardRepository boardRepository; private final LoveRepository loveRepository; @Transactional public void 글쓰기(BoardRequest.SaveDTO saveDTO, User sessionUser) { Board board = saveDTO.toEntity(sessionUser); boardRepository.save(board); } public List<Board> 글목록보기(Integer userId) { return boardRepository.findAll(userId); } public BoardResponse.DetailDTO 글상세보기(Integer id, Integer userId) { Board board = boardRepository.findByIdJoinUser(id); Love love = loveRepository.findByUserIdAndBoardId(userId, id); Boolean isLove = love == null ? false : true; Long loveCount = loveRepository.findByLoveCount(board.getId()); BoardResponse.DetailDTO detailDTO = new BoardResponse.DetailDTO(board, userId, isLove, loveCount.intValue()); return detailDTO; } }
    ➡ 게시글 5번 → 좋아요 1명
    notion image
    ➡ 게시글 4번 → 좋아요 2명
    notion image
    ➡ 게시글 3~1번 → 좋아요 0명
    notion image
    notion image
    notion image

    한방 Query version

    SELECT b.id, b.title, b.content, b.is_public, CASE WHEN b.user_id = 4 THEN true ELSE false END AS is_owner, u.username, b.created_at, ( SELECT COUNT(l.id) FROM love_tb l WHERE l.board_id = 4 ) AS love_count, ( SELECT CASE WHEN COUNT(l2.id) > 0 THEN true ELSE false END FROM love_tb l2 WHERE l2.board_id = 4 AND l2.user_id = 1 ) AS is_loved FROM board_tb b JOIN user_tb u ON b.user_id = u.id WHERE b.id = 4;
    BoardRepository
    package shop.mtcoding.blog.board; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.util.List; @RequiredArgsConstructor @Repository public class BoardRepository { private final EntityManager em; // 한방쿼리 (h2 query, om -> dto) public BoardResponse.DetailDTO findDetail(Integer boardId, Integer userId) { String sql = """ SELECT new shop.mtcoding.blog.board.BoardResponse$DetailDTO( b.id, b.title, b.content, b.isPublic, CASE WHEN b.user.id = :userId THEN true ELSE false END isOwner, b.user.username, b.createdAt, (SELECT COUNT(l.id) FROM Love l WHERE l.board.id = :boardId), (SELECT CASE WHEN COUNT(l2) > 0 THEN true ELSE false END FROM Love l2 WHERE l2.board.id = :boardId AND l2.user.id = :userId) ) FROM Board b WHERE b.id = :boardId """; Query query = em.createQuery(sql); query.setParameter("boardId", boardId); query.setParameter("userId", userId); return (BoardResponse.DetailDTO) query.getSingleResult(); } public void save(Board board) { em.persist(board); } public List<Board> findAll(Integer userId) { // Integer를 써야 null을 넘길 수 있다 // 동적 query String s1 = "select b from Board b where b.isPublic = true or b.user.id = :userId order by b.id desc"; String s2 = "select b from Board b where b.isPublic = true order by b.id desc"; Query query = null; if (userId == null) { query = em.createQuery(s2, Board.class); } else { query = em.createQuery(s1, Board.class); query.setParameter("userId", userId); } return query.getResultList(); } public Board findById(Integer id) { return em.find(Board.class, id); } public Board findByIdJoinUser(Integer id) { // b -> board에 있는 필드만 프로잭션 / fetch를 써야 board안에 있는 user 객체도 같이 프로잭션됨 Query query = em.createQuery("select b from Board b join fetch b.user u where b.id = :id", Board.class); // inner join (on절은 생략가능하다) -> 객체지향 쿼리 query.setParameter("id", id); return (Board) query.getSingleResult(); } }
    BoardService
    package shop.mtcoding.blog.board; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import shop.mtcoding.blog.love.LoveRepository; import shop.mtcoding.blog.user.User; import java.util.List; @RequiredArgsConstructor @Service public class BoardService { private final BoardRepository boardRepository; private final LoveRepository loveRepository; @Transactional public void 글쓰기(BoardRequest.SaveDTO saveDTO, User sessionUser) { Board board = saveDTO.toEntity(sessionUser); boardRepository.save(board); } public List<Board> 글목록보기(Integer userId) { return boardRepository.findAll(userId); } public BoardResponse.DetailDTO 글상세보기(Integer id, Integer userId) { BoardResponse.DetailDTO detailDTO = boardRepository.findDetail(id, userId); return detailDTO; } }

    좋아요 & 좋아요 취소

    JavaScript로 화면에 있는 data 사용하는 방법

    1. 함수의 매개변수에 인수로 전달한다
    notion image
     
    1. input type ‘ hidden’ 사용
      1. → 통신할 때 사용하기 위해서 hidden으로 id 심어놓기
        → 바깥에 심어서 JavaScript로 땡겨옴
    notion image
    notion image
     
    1. dataset 사용
    notion image
    notion image
    notion image

    LoveRequest

    package shop.mtcoding.blog.love; import lombok.Data; import shop.mtcoding.blog.board.Board; import shop.mtcoding.blog.user.User; public class LoveRequest { @Data public static class SaveDTO { private Integer boardId; public Love toEntity(Integer sessionUserId) { return Love.builder() .board(Board.builder().id(boardId).build()) .user(User.builder().id(sessionUserId).build()) .build(); } } }

    LoveResponse

    package shop.mtcoding.blog.love; import lombok.Data; public class LoveResponse { @Data public static class SaveDTO { private Integer loveId; private Integer loveCount; public SaveDTO(Integer loveId, Integer loveCount) { this.loveId = loveId; this.loveCount = loveCount; } } @Data public static class DeleteDTO { private Integer loveCount; public DeleteDTO(Integer loveCount) { this.loveCount = loveCount; } } }

    LoveController

    package shop.mtcoding.blog.love; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import shop.mtcoding.blog._core.Resp; import shop.mtcoding.blog.user.User; @RequiredArgsConstructor @RestController // 파일이 아닌 데이터 리턴하는 컨트롤러 (Ajax) public class LoveController { private final LoveService loveService; private final HttpSession session; @PostMapping("/love") public Resp<?> saveLove(@RequestBody LoveRequest.SaveDTO reqDTO) { // 인증로직 User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증이 필요합니다."); LoveResponse.SaveDTO respDTO = loveService.좋아요(reqDTO, sessionUser.getId()); return Resp.ok(respDTO); } @DeleteMapping("/love/{id}") public Resp<?> deleteLove(@PathVariable("id") Integer id) { // 인증로직 User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증이 필요합니다."); LoveResponse.DeleteDTO respDTO = loveService.좋아요취소(id); // loveId return Resp.ok(respDTO); } }

    LoveService

    package shop.mtcoding.blog.love; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import shop.mtcoding.blog.board.BoardRepository; import shop.mtcoding.blog.user.UserRepository; @RequiredArgsConstructor @Service public class LoveService { private final LoveRepository loveRepository; private final BoardRepository boardRepository; private final UserRepository userRepository; @Transactional public LoveResponse.SaveDTO 좋아요(LoveRequest.SaveDTO reqDTO, Integer sessionUserId) { Love lovePs = loveRepository.save(reqDTO.toEntity(sessionUserId)); Long loveCount = loveRepository.findByBoardId(reqDTO.getBoardId()); return new LoveResponse.SaveDTO(lovePs.getId(), loveCount.intValue()); } @Transactional public LoveResponse.DeleteDTO 좋아요취소(Integer id) { Love lovePs = loveRepository.findById(id); if (lovePs == null) throw new RuntimeException("취소할 좋아요가 없습니다."); Integer boardId = lovePs.getBoard().getId(); loveRepository.deleteById(id); Long loveCount = loveRepository.findByBoardId(boardId); return new LoveResponse.DeleteDTO(loveCount.intValue()); } }

    LoveRepository

    package shop.mtcoding.blog.love; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @RequiredArgsConstructor @Repository public class LoveRepository { private final EntityManager em; public Love findByUserIdAndBoardId(Integer userId, Integer boardId) { Query query = em.createQuery("select lo from Love lo where lo.user.id = :userId and lo.board.id = :boardId", Love.class); query.setParameter("userId", userId); query.setParameter("boardId", boardId); try { return (Love) query.getSingleResult(); // unique 제약조건이기 때문에 SingleResult } catch (Exception e) { return null; } } public Long findByBoardId(int boardId) { Query query = em.createQuery("select count(lo) from Love lo where lo.board.id = :boardId"); query.setParameter("boardId", boardId); Long count = (Long) query.getSingleResult(); try { return count; // unique 제약조건이기 때문에 SingleResult } catch (Exception e) { return null; } } public Love save(Love love) { em.persist(love); return love; } public void deleteById(Integer id) { em.createQuery("delete from Love lo where lo.id = :id") .setParameter("id", id) .executeUpdate(); } public Love findById(Integer id) { return em.find(Love.class, id); } }

    detail

    {{> layout/header}} <input type="hidden" id="boardId" value="{{model.id}}"> <div class="container p-5"> <!-- 수정삭제버튼 --> {{#model.isOwner}} <div class="d-flex justify-content-end"> <a href="/board/{{model.id}}/update-form" class="btn btn-warning me-1">수정</a> <form action="/board/{{model.id}}/delete" method="post"> <button class="btn btn-danger">삭제</button> </form> </div> {{/model.isOwner}} <div class="d-flex justify-content-end"> <b>작성자</b> : {{model.username}} </div> <!-- 게시글내용 --> <div> <h2><b>{{model.title}}</b></h2> <hr/> <div class="m-4 p-2"> {{model.content}} </div> </div> <!-- AJAX 좋아요 영역 --> <div class="my-3 d-flex align-items-center"> {{#model.isLove}} <i id="loveIcon" class="fa fa-heart" style="font-size:20px; color:red" onclick="deleteLove({{model.loveId}})"></i> {{/model.isLove}} {{^model.isLove}} <i id="loveIcon" class="fa fa-heart" style="font-size:20px; color:black" onclick="saveLove()"></i> {{/model.isLove}} <span class="ms-1"><b id="loveCount">{{model.loveCount}}</b>명이 이 글을 좋아합니다</span> </div> <!-- 댓글 --> <div class="card mt-3"> <!-- 댓글등록 --> <div class="card-body"> <form action="/reply/save" method="post"> <textarea class="form-control" rows="2" name="comment"></textarea> <div class="d-flex justify-content-end"> <button type="submit" class="btn btn-outline-primary mt-1">댓글등록</button> </div> </form> </div> <!-- 댓글목록 --> <div class="card-footer"> <b>댓글리스트</b> </div> <div class="list-group"> <!-- 댓글아이템 --> <div class="list-group-item d-flex justify-content-between align-items-center"> <div class="d-flex"> <div class="px-1 me-1 bg-primary text-white rounded">cos</div> <div>댓글 내용입니다</div> </div> <form action="/reply/1/delete" method="post"> <button class="btn">🗑</button> </form> </div> <!-- 댓글아이템 --> <div class="list-group-item d-flex justify-content-between align-items-center"> <div class="d-flex"> <div class="px-1 me-1 bg-primary text-white rounded">ssar</div> <div>댓글 내용입니다</div> </div> <form action="/reply/1/delete" method="post"> <button class="btn">🗑</button> </form> </div> </div> </div> </div> <script> // setInterval(()=>{ // location.reload(); // },1000) let boardId = document.querySelector("#boardId").value; async function saveLove() { let requestBody = {boardId: boardId}; let response = await fetch(`/love`, { method: "POST", body: JSON.stringify(requestBody), headers: {"Content-Type": "application/json"} }); let responseBody = await response.json(); // { loveId, loveCount } console.log(responseBody); // DOM 업데이트 let loveIcon = document.querySelector('#loveIcon'); let loveCount = document.querySelector('#loveCount'); loveIcon.style.color = 'red'; loveIcon.setAttribute('onclick', `deleteLove(${responseBody.body.loveId})`); loveCount.innerHTML = responseBody.body.loveCount; } async function deleteLove(loveId) { let response = await fetch(`/love/${loveId}`, { method: "DELETE" }); let responseBody = await response.json(); // response.text(); (X) console.log(responseBody); // DOM 업데이트 let loveIcon = document.querySelector('#loveIcon'); let loveCount = document.querySelector('#loveCount'); loveIcon.style.color = 'black'; loveIcon.setAttribute('onclick', `saveLove()`); loveCount.innerHTML = responseBody.body.loveCount; } </script> {{> layout/footer}}
    1. ssar user가 게시글 3번 좋아요
    notion image
    notion image
    notion image
    1. ssar user가 게시글 4번 좋아요 취소
      1. → 게시글 4번의 좋아요를 취소하면 하트가 빨간색에서 검은색으로 바뀜
    notion image
    notion image
     
    notion image
    → DB에서도 삭제
    notion image

    Polling

    → 하나의 장치(또는 프로그램)가 충돌 회피 또는 동기화 처리 등을 목적으로 다른 장치(또는 프로그램)의 상태를 주기적으로 검사하여 일정한 조건을 만족할 때 송수신 등의 자료처리를 하는 방식

    setInterval(()=>{ location.reload(); },1000)

    ✅ Polling의 동작 방식
    1. 어떤 대상(예: 장치, 서버)을 주기적으로 확인.
    1. 조건이 만족되면 → 처리.
    1. 만족되지 않으면 → 잠시 대기 후 다시 확인.

    📌 Polling vs. Interrupt
    구분
    Polling
    Interrupt (인터럽트)
    방식
    계속 확인 (루프)
    이벤트 발생 시 알림
    자원 사용
    CPU 낭비 큼
    효율적
    반응 속도
    느릴 수 있음
    빠름
    사용 예
    간단한 센서 확인
    키보드 입력, 네트워크 등

    ⚠️ 단점
    • CPU를 계속 사용하므로 비효율적일 수 있음.
    • 실시간성이 요구될 때는 부적합할 수 있음.
    • 타이밍 이슈: 너무 짧게 하면 리소스 낭비, 너무 길게 하면 반응 느림.

    📌 댓글

    ❗ reply_tb 생성 필요

    Table 생성

    Reply

    package shop.mtcoding.blog.reply; import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.CreationTimestamp; import shop.mtcoding.blog.board.Board; import shop.mtcoding.blog.user.User; import java.sql.Timestamp; @NoArgsConstructor @Getter @Table(name = "reply_tb") @Entity public class Reply { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; private String content; // 댓글내용 @ManyToOne(fetch = FetchType.LAZY) private User user; @ManyToOne(fetch = FetchType.LAZY) private Board board; @CreationTimestamp private Timestamp createdAt; }
    notion image
     

    Dummy Data 넣고 확인

    data

    insert into user_tb(username, password, email, created_at) values ('ssar', '1234', 'ssar@nate.com', now()); insert into user_tb(username, password, email, created_at) values ('cos', '1234', 'cos@nate.com', now()); insert into user_tb(username, password, email, created_at) values ('love', '1234', 'love@nate.com', now()); insert into board_tb(title, content, user_id, is_public, created_at) values ('제목1', '내용1', 1, true, now()); insert into board_tb(title, content, user_id, is_public, created_at) values ('제목2', '내용2', 1, true, now()); insert into board_tb(title, content, user_id, is_public, created_at) values ('제목3', '내용3', 2, true, now()); insert into board_tb(title, content, user_id, is_public, created_at) values ('제목4', '내용4', 3, true, now()); insert into board_tb(title, content, user_id, is_public, created_at) values ('제목5', '내용5', 1, false, now()); insert into love_tb(board_id, user_id, created_at) values (5, 1, now()); insert into love_tb(board_id, user_id, created_at) values (4, 2, now()); insert into love_tb(board_id, user_id, created_at) values (4, 1, now()); insert into reply_tb(board_id, user_id, content, created_at) values (4, 1, '댓글1', now()); insert into reply_tb(board_id, user_id, content, created_at) values (4, 2, '댓글2', now()); insert into reply_tb(board_id, user_id, content, created_at) values (4, 1, '댓글3', now()); insert into reply_tb(board_id, user_id, content, created_at) values (3, 1, '댓글4', now()); insert into reply_tb(board_id, user_id, content, created_at) values (2, 1, '댓글5', now());

    Board

    package shop.mtcoding.blog.board; import jakarta.persistence.*; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.CreationTimestamp; import shop.mtcoding.blog.reply.Reply; import shop.mtcoding.blog.user.User; import java.sql.Timestamp; import java.util.ArrayList; import java.util.List; @NoArgsConstructor @Getter @Table(name = "board_tb") @Entity public class Board { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; private String title; private String content; private Boolean isPublic; @ManyToOne(fetch = FetchType.LAZY) private User user; // ORM @OneToMany(mappedBy = "board", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) // board = one , reply = many , mappedBy -> FK의 주인 @OneToMany -> 조회용도 private List<Reply> replies = new ArrayList<>(); //조회할 때만 넣기위함 @CreationTimestamp private Timestamp createdAt; @Builder public Board(Integer id, String title, String content, Boolean isPublic, User user, Timestamp createdAt) { this.id = id; this.title = title; this.content = content; this.isPublic = isPublic; this.user = user; this.createdAt = createdAt; } // 게시글 수정 Setter public void update(String title, String content, String isPublic) { this.title = title; this.content = content; this.isPublic = isPublic == null ? false : true; } }

    BoardResponse

    package shop.mtcoding.blog.board; import lombok.Data; import shop.mtcoding.blog.reply.Reply; import java.sql.Timestamp; import java.util.List; public class BoardResponse { // 깊은 복사 @Data public static class DetailDTO { private Integer id; private String title; private String content; private Boolean isPublic; private Boolean isOwner; // 대문자는 값을 안 넣으면 null, 소문자는 false private Boolean isLove; private Integer loveCount; private String username; private Timestamp createdAt; private Integer loveId; private List<Reply> replies; // 템플릿엔진이 조건문 비교를 허용해주지 않기 때문에 필요함 public DetailDTO(Board board, Integer sessionUserId, Boolean isLove, Integer loveCount, Integer loveId) { this.id = board.getId(); this.title = board.getTitle(); this.content = board.getContent(); this.isPublic = board.getIsPublic(); this.isOwner = sessionUserId == board.getUser().getId(); // 같으면 true 같지 않으면 false this.username = board.getUser().getUsername(); this.createdAt = board.getCreatedAt(); this.isLove = isLove; this.loveCount = loveCount; this.loveId = loveId; } } }

    ReplyRepository

    package shop.mtcoding.blog.reply; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.util.List; @RequiredArgsConstructor @Repository public class ReplyRepository { private final EntityManager em; public List<Reply> findAllByBoardId(int boardId) { Query query = em.createQuery("select r from Reply r join fetch r.user where r.board.id = :boardId", Reply.class); query.setParameter("boardId", boardId); List<Reply> replies = query.getResultList(); return replies; } }

    BoardService

    package shop.mtcoding.blog.board; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import shop.mtcoding.blog.love.Love; import shop.mtcoding.blog.love.LoveRepository; import shop.mtcoding.blog.reply.Reply; import shop.mtcoding.blog.reply.ReplyRepository; import shop.mtcoding.blog.user.User; import java.util.List; @RequiredArgsConstructor @Service public class BoardService { private final BoardRepository boardRepository; private final LoveRepository loveRepository; private final ReplyRepository replyRepository; @Transactional public void 글쓰기(BoardRequest.SaveDTO saveDTO, User sessionUser) { Board board = saveDTO.toEntity(sessionUser); boardRepository.save(board); } public List<Board> 글목록보기(Integer userId) { return boardRepository.findAll(userId); } public BoardResponse.DetailDTO 글상세보기(Integer id, Integer userId) { Board board = boardRepository.findByIdJoinUser(id); Love love = loveRepository.findByUserIdAndBoardId(userId, id); Boolean isLove = love == null ? false : true; Integer loveId = love == null ? null : love.getId(); Long loveCount = loveRepository.findByBoardId(board.getId()); List<Reply> replies = replyRepository.findAllByBoardId(id); BoardResponse.DetailDTO detailDTO = new BoardResponse.DetailDTO(board, userId, isLove, loveCount.intValue(), loveId, replies); return detailDTO; } }

    BoardResponse.DetailDTO

    package shop.mtcoding.blog.board; import lombok.Data; import shop.mtcoding.blog.reply.Reply; import java.sql.Timestamp; import java.util.List; public class BoardResponse { // 깊은 복사 @Data public static class DetailDTO { private Integer id; private String title; private String content; private Boolean isPublic; private Boolean isOwner; // 대문자는 값을 안 넣으면 null, 소문자는 false private Boolean isLove; private Integer loveCount; private String username; private Timestamp createdAt; private Integer loveId; private List<Reply> replies; // 템플릿엔진이 조건문 비교를 허용해주지 않기 때문에 필요함 public DetailDTO(Board board, Integer sessionUserId, Boolean isLove, Integer loveCount, Integer loveId, List<Reply> replies) { this.id = board.getId(); this.title = board.getTitle(); this.content = board.getContent(); this.isPublic = board.getIsPublic(); this.isOwner = sessionUserId == board.getUser().getId(); // 같으면 true 같지 않으면 false this.username = board.getUser().getUsername(); this.createdAt = board.getCreatedAt(); this.isLove = isLove; this.loveCount = loveCount; this.loveId = loveId; this.replies = replies; } } }
    detail
    {{> layout/header}} <input type="hidden" id="boardId" value="{{model.id}}"> <div class="container p-5"> <!-- 수정삭제버튼 --> {{#model.isOwner}} <div class="d-flex justify-content-end"> <a href="/board/{{model.id}}/update-form" class="btn btn-warning me-1">수정</a> <form action="/board/{{model.id}}/delete" method="post"> <button class="btn btn-danger">삭제</button> </form> </div> {{/model.isOwner}} <div class="d-flex justify-content-end"> <b>작성자</b> : {{model.username}} </div> <!-- 게시글내용 --> <div> <h2><b>{{model.title}}</b></h2> <hr/> <div class="m-4 p-2"> {{model.content}} </div> </div> <!-- AJAX 좋아요 영역 --> <div class="my-3 d-flex align-items-center"> {{#model.isLove}} <i id="loveIcon" class="fa fa-heart" style="font-size:20px; color:red" onclick="deleteLove({{model.loveId}})"></i> {{/model.isLove}} {{^model.isLove}} <i id="loveIcon" class="fa fa-heart" style="font-size:20px; color:black" onclick="saveLove()"></i> {{/model.isLove}} <span class="ms-1"><b id="loveCount">{{model.loveCount}}</b>명이 이 글을 좋아합니다</span> </div> <!-- 댓글 --> <div class="card mt-3"> <!-- 댓글등록 --> <div class="card-body"> <form action="/reply/save" method="post"> <textarea class="form-control" rows="2" name="comment"></textarea> <div class="d-flex justify-content-end"> <button type="submit" class="btn btn-outline-primary mt-1">댓글등록</button> </div> </form> </div> <!-- 댓글목록 --> <div class="card-footer"> <b>댓글리스트</b> </div> <div class="list-group"> <!-- 댓글아이템 --> {{#model.replies}} <div class="list-group-item d-flex justify-content-between align-items-center"> <div class="d-flex"> <div class="px-1 me-1 bg-primary text-white rounded">{{user.username}}</div> <div>{{content}}</div> </div> <form action="/reply/{{id}}/delete" method="post"> <button class="btn">🗑</button> </form> </div> {{/model.replies}} </div> </div> </div> <script> // setInterval(()=>{ // location.reload(); // },1000) let boardId = document.querySelector("#boardId").value; async function saveLove() { let requestBody = {boardId: boardId}; let response = await fetch(`/love`, { method: "POST", body: JSON.stringify(requestBody), headers: {"Content-Type": "application/json"} }); let responseBody = await response.json(); // { loveId, loveCount } console.log(responseBody); // DOM 업데이트 let loveIcon = document.querySelector('#loveIcon'); let loveCount = document.querySelector('#loveCount'); loveIcon.style.color = 'red'; loveIcon.setAttribute('onclick', `deleteLove(${responseBody.body.loveId})`); loveCount.innerHTML = responseBody.body.loveCount; } async function deleteLove(loveId) { let response = await fetch(`/love/${loveId}`, { method: "DELETE" }); let responseBody = await response.json(); // response.text(); (X) console.log(responseBody); // DOM 업데이트 let loveIcon = document.querySelector('#loveIcon'); let loveCount = document.querySelector('#loveCount'); loveIcon.style.color = 'black'; loveIcon.setAttribute('onclick', `saveLove()`); loveCount.innerHTML = responseBody.body.loveCount; } </script> {{> layout/footer}}
    notion image
    notion image

    양방향 Mapping

    Board (EAGER)

    package shop.mtcoding.blog.board; import jakarta.persistence.*; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.CreationTimestamp; import shop.mtcoding.blog.reply.Reply; import shop.mtcoding.blog.user.User; import java.sql.Timestamp; import java.util.ArrayList; import java.util.List; @NoArgsConstructor @Getter @Table(name = "board_tb") @Entity public class Board { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; private String title; private String content; private Boolean isPublic; @ManyToOne(fetch = FetchType.EAGER) private User user; // ORM @OneToMany(mappedBy = "board", fetch = FetchType.EAGER) // board = one , reply = many , mappedBy -> FK의 주인 @OneToMany -> 조회용도 private List<Reply> replies = new ArrayList<>(); //조회할 때만 넣기위함 @CreationTimestamp private Timestamp createdAt; @Builder public Board(Integer id, String title, String content, Boolean isPublic, User user, Timestamp createdAt) { this.id = id; this.title = title; this.content = content; this.isPublic = isPublic; this.user = user; this.createdAt = createdAt; } }

    BoardRepository

    package shop.mtcoding.blog.board; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.util.List; @RequiredArgsConstructor @Repository public class BoardRepository { private final EntityManager em; public void save(Board board) { em.persist(board); } public List<Board> findAll(Integer userId) { // Integer를 써야 null을 넘길 수 있다 // 동적 query String s1 = "select b from Board b where b.isPublic = true or b.user.id = :userId order by b.id desc"; String s2 = "select b from Board b where b.isPublic = true order by b.id desc"; Query query = null; if (userId == null) { query = em.createQuery(s2, Board.class); } else { query = em.createQuery(s1, Board.class); query.setParameter("userId", userId); } return query.getResultList(); } public Board findById(Integer id) { return em.find(Board.class, id); } public Board findByIdJoinUser(Integer id) { // b -> board에 있는 필드만 프로잭션 / fetch를 써야 board안에 있는 user 객체도 같이 프로잭션됨 Query query = em.createQuery("select b from Board b join fetch b.user u where b.id = :id", Board.class); // inner join (on절은 생략가능하다) -> 객체지향 쿼리 query.setParameter("id", id); return (Board) query.getSingleResult(); } public Board findByIdJoinUserAndReplies(Integer id) { Query query = em.createQuery("select b from Board b join fetch b.user u left join fetch b.replies where b.id = :id", Board.class); // left join (on절은 생략가능하다) -> 객체지향 쿼리 query.setParameter("id", id); return (Board) query.getSingleResult(); } }

    BoardRepositoryTest

    package shop.mtcoding.blog.board; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; import java.util.List; @Import(BoardRepository.class) // BoardRepository @DataJpaTest // EntityManager, PC public class BoardRepositoryTest { @Autowired // DI private BoardRepository boardRepository; @Test public void findById_test() { // given Integer boardId = 4; Board board = boardRepository.findById(boardId); } }
    notion image

    Board (LAZY)

    package shop.mtcoding.blog.board; import jakarta.persistence.*; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.CreationTimestamp; import shop.mtcoding.blog.reply.Reply; import shop.mtcoding.blog.user.User; import java.sql.Timestamp; import java.util.ArrayList; import java.util.List; @NoArgsConstructor @Getter @Table(name = "board_tb") @Entity public class Board { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; private String title; private String content; private Boolean isPublic; @ManyToOne(fetch = FetchType.LAZY) private User user; // ORM @OneToMany(mappedBy = "board", fetch = FetchType.LAZY) // board = one , reply = many , mappedBy -> FK의 주인 @OneToMany -> 조회용도 private List<Reply> replies = new ArrayList<>(); //조회할 때만 넣기위함 @CreationTimestamp private Timestamp createdAt; @Builder public Board(Integer id, String title, String content, Boolean isPublic, User user, Timestamp createdAt) { this.id = id; this.title = title; this.content = content; this.isPublic = isPublic; this.user = user; this.createdAt = createdAt; } }

    BoardRepository

    package shop.mtcoding.blog.board; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.util.List; @RequiredArgsConstructor @Repository public class BoardRepository { private final EntityManager em; public void save(Board board) { em.persist(board); } public List<Board> findAll(Integer userId) { // Integer를 써야 null을 넘길 수 있다 // 동적 query String s1 = "select b from Board b where b.isPublic = true or b.user.id = :userId order by b.id desc"; String s2 = "select b from Board b where b.isPublic = true order by b.id desc"; Query query = null; if (userId == null) { query = em.createQuery(s2, Board.class); } else { query = em.createQuery(s1, Board.class); query.setParameter("userId", userId); } return query.getResultList(); } public Board findById(Integer id) { return em.find(Board.class, id); } public Board findByIdJoinUser(Integer id) { // b -> board에 있는 필드만 프로잭션 / fetch를 써야 board안에 있는 user 객체도 같이 프로잭션됨 Query query = em.createQuery("select b from Board b join fetch b.user u where b.id = :id", Board.class); // inner join (on절은 생략가능하다) -> 객체지향 쿼리 query.setParameter("id", id); return (Board) query.getSingleResult(); } public Board findByIdJoinUserAndReplies(Integer id) { Query query = em.createQuery("select b from Board b join fetch b.user u left join fetch b.replies r where b.id = :id", Board.class); // left join (on절은 생략가능하다) -> 객체지향 쿼리 query.setParameter("id", id); return (Board) query.getSingleResult(); } }

    BoardRepositoryTest

    package shop.mtcoding.blog.board; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; import shop.mtcoding.blog.reply.Reply; import java.util.List; @Import(BoardRepository.class) // BoardRepository @DataJpaTest // EntityManager, PC public class BoardRepositoryTest { @Autowired // DI private BoardRepository boardRepository; @Test public void findByIdJoinUserAndReplies_test() { // given Integer boardId = 4; Board board = boardRepository.findByIdJoinUserAndReplies(boardId); for (Reply reply : board.getReplies()) { System.out.println("-----------------------------------Lazy Loading Select"); System.out.println(reply.getUser().getUsername()); System.out.println("------------------------"); } } }
    notion image
    notion image

    Board (LAZY)

    package shop.mtcoding.blog.board; import jakarta.persistence.*; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.CreationTimestamp; import shop.mtcoding.blog.reply.Reply; import shop.mtcoding.blog.user.User; import java.sql.Timestamp; import java.util.ArrayList; import java.util.List; @NoArgsConstructor @Getter @Table(name = "board_tb") @Entity public class Board { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; private String title; private String content; private Boolean isPublic; @ManyToOne(fetch = FetchType.LAZY) private User user; // ORM @OneToMany(mappedBy = "board", fetch = FetchType.LAZY) // board = one , reply = many , mappedBy -> FK의 주인 @OneToMany -> 조회용도 private List<Reply> replies = new ArrayList<>(); //조회할 때만 넣기위함 @CreationTimestamp private Timestamp createdAt; @Builder public Board(Integer id, String title, String content, Boolean isPublic, User user, Timestamp createdAt) { this.id = id; this.title = title; this.content = content; this.isPublic = isPublic; this.user = user; this.createdAt = createdAt; } }

    BoardRepository

    package shop.mtcoding.blog.board; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.util.List; @RequiredArgsConstructor @Repository public class BoardRepository { private final EntityManager em; public void save(Board board) { em.persist(board); } public List<Board> findAll(Integer userId) { // Integer를 써야 null을 넘길 수 있다 // 동적 query String s1 = "select b from Board b where b.isPublic = true or b.user.id = :userId order by b.id desc"; String s2 = "select b from Board b where b.isPublic = true order by b.id desc"; Query query = null; if (userId == null) { query = em.createQuery(s2, Board.class); } else { query = em.createQuery(s1, Board.class); query.setParameter("userId", userId); } return query.getResultList(); } public Board findById(Integer id) { return em.find(Board.class, id); } public Board findByIdJoinUser(Integer id) { // b -> board에 있는 필드만 프로잭션 / fetch를 써야 board안에 있는 user 객체도 같이 프로잭션됨 Query query = em.createQuery("select b from Board b join fetch b.user u where b.id = :id", Board.class); // inner join (on절은 생략가능하다) -> 객체지향 쿼리 query.setParameter("id", id); return (Board) query.getSingleResult(); } public Board findByIdJoinUserAndReplies(Integer id) { Query query = em.createQuery("select b from Board b join fetch b.user u left join fetch b.replies r join fetch r.user where b.id = :id", Board.class); // left join (on절은 생략가능하다) -> 객체지향 쿼리 query.setParameter("id", id); return (Board) query.getSingleResult(); } }

    BoardRepositoryTest

    package shop.mtcoding.blog.board; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; import shop.mtcoding.blog.reply.Reply; import java.util.List; @Import(BoardRepository.class) // BoardRepository @DataJpaTest // EntityManager, PC public class BoardRepositoryTest { @Autowired // DI private BoardRepository boardRepository; @Test public void findByIdJoinUserAndReplies_test() { // given Integer boardId = 4; Board board = boardRepository.findByIdJoinUserAndReplies(boardId); for (Reply reply : board.getReplies()) { System.out.println("-----------------------------------Lazy Loading Select"); System.out.println(reply.getUser().getUsername()); System.out.println("------------------------"); } } }
    notion image
    캐싱되서 조회없이 값을 바로 가져온다
    캐싱되서 조회없이 값을 바로 가져온다

    BoardService

    package shop.mtcoding.blog.board; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import shop.mtcoding.blog.love.Love; import shop.mtcoding.blog.love.LoveRepository; import shop.mtcoding.blog.reply.ReplyRepository; import shop.mtcoding.blog.user.User; import java.util.List; @RequiredArgsConstructor @Service public class BoardService { private final BoardRepository boardRepository; private final LoveRepository loveRepository; private final ReplyRepository replyRepository; @Transactional public void 글쓰기(BoardRequest.SaveDTO saveDTO, User sessionUser) { Board board = saveDTO.toEntity(sessionUser); boardRepository.save(board); } public List<Board> 글목록보기(Integer userId) { return boardRepository.findAll(userId); } public BoardResponse.DetailDTO 글상세보기(Integer id, Integer userId) { Board board = boardRepository.findByIdJoinUserAndReplies(id); Love love = loveRepository.findByUserIdAndBoardId(userId, id); Boolean isLove = love == null ? false : true; Integer loveId = love == null ? null : love.getId(); Long loveCount = loveRepository.findByBoardId(board.getId()); BoardResponse.DetailDTO detailDTO = new BoardResponse.DetailDTO(board, userId, isLove, loveCount.intValue(), loveId); return detailDTO; } }

    BoardResponse

    package shop.mtcoding.blog.board; import lombok.Data; import shop.mtcoding.blog.reply.Reply; import java.sql.Timestamp; import java.util.List; public class BoardResponse { // 깊은 복사 @Data public static class DetailDTO { private Integer id; private String title; private String content; private Boolean isPublic; private Boolean isOwner; // 대문자는 값을 안 넣으면 null, 소문자는 false private Boolean isLove; private Integer loveCount; private String username; private Timestamp createdAt; private Integer loveId; private List<Reply> replies; // 템플릿엔진이 조건문 비교를 허용해주지 않기 때문에 필요함 public DetailDTO(Board board, Integer sessionUserId, Boolean isLove, Integer loveCount, Integer loveId) { this.id = board.getId(); this.title = board.getTitle(); this.content = board.getContent(); this.isPublic = board.getIsPublic(); this.isOwner = sessionUserId == board.getUser().getId(); // 같으면 true 같지 않으면 false this.username = board.getUser().getUsername(); this.createdAt = board.getCreatedAt(); this.isLove = isLove; this.loveCount = loveCount; this.loveId = loveId; this.replies = board.getReplies(); } } }
    notion image

    댓글 - 작성자만 삭제 가능하게

    BoardResponse

    package shop.mtcoding.blog.board; import lombok.Data; import shop.mtcoding.blog.reply.Reply; import java.sql.Timestamp; import java.util.ArrayList; import java.util.List; public class BoardResponse { // 깊은 복사 @Data public static class DetailDTO { private Integer id; private String title; private String content; private Boolean isPublic; private Boolean isOwner; // 대문자는 값을 안 넣으면 null, 소문자는 false private Boolean isLove; private Integer loveCount; private String username; private Timestamp createdAt; private Integer loveId; private List<ReplyDTO> replies; @Data // DetailDTO안에 있기 때문에 외부 클래스가 아닌 내부클래스 public class ReplyDTO { private Integer id; private String content; private String username; private Boolean isOwner; public ReplyDTO(Reply reply, Integer sessionUserId) { this.id = reply.getId(); this.content = reply.getContent(); this.username = reply.getUser().getUsername(); this.isOwner = reply.getUser().getId().equals(sessionUserId); } } // 템플릿엔진이 조건문 비교를 허용해주지 않기 때문에 필요함 public DetailDTO(Board board, Integer sessionUserId, Boolean isLove, Integer loveCount, Integer loveId) { this.id = board.getId(); this.title = board.getTitle(); this.content = board.getContent(); this.isPublic = board.getIsPublic(); this.isOwner = sessionUserId == board.getUser().getId(); // 같으면 true 같지 않으면 false this.username = board.getUser().getUsername(); this.createdAt = board.getCreatedAt(); this.isLove = isLove; this.loveCount = loveCount; this.loveId = loveId; List<ReplyDTO> repliesDTO = new ArrayList<>(); for (Reply reply : board.getReplies()) { ReplyDTO replyDTO = new ReplyDTO(reply, sessionUserId); repliesDTO.add(replyDTO); } this.replies = repliesDTO; } } }

    JSON data 확인해보기

    @GetMapping("/v2/board/{id}") public @ResponseBody BoardResponse.DetailDTO v2Detail(@PathVariable("id") Integer id) { // 비로그인 시 상세보기 Integer sessionUserId = 1; BoardResponse.DetailDTO detailDTO = boardService.글상세보기(id, sessionUserId); return detailDTO; }
    notion image

    detail

    {{> layout/header}} <input type="hidden" id="boardId" value="{{model.id}}"> <div class="container p-5"> <!-- 수정삭제버튼 --> {{#model.isOwner}} <div class="d-flex justify-content-end"> <a href="/board/{{model.id}}/update-form" class="btn btn-warning me-1">수정</a> <form action="/board/{{model.id}}/delete" method="post"> <button class="btn btn-danger">삭제</button> </form> </div> {{/model.isOwner}} <div class="d-flex justify-content-end"> <b>작성자</b> : {{model.username}} </div> <!-- 게시글내용 --> <div> <h2><b>{{model.title}}</b></h2> <hr/> <div class="m-4 p-2"> {{model.content}} </div> </div> <!-- AJAX 좋아요 영역 --> <div class="my-3 d-flex align-items-center"> {{#model.isLove}} <i id="loveIcon" class="fa fa-heart" style="font-size:20px; color:red" onclick="deleteLove({{model.loveId}})"></i> {{/model.isLove}} {{^model.isLove}} <i id="loveIcon" class="fa fa-heart" style="font-size:20px; color:black" onclick="saveLove()"></i> {{/model.isLove}} <span class="ms-1"><b id="loveCount">{{model.loveCount}}</b>명이 이 글을 좋아합니다</span> </div> <!-- 댓글 --> <div class="card mt-3"> <!-- 댓글등록 --> <div class="card-body"> <form action="/reply/save" method="post"> <textarea class="form-control" rows="2" name="comment"></textarea> <div class="d-flex justify-content-end"> <button type="submit" class="btn btn-outline-primary mt-1">댓글등록</button> </div> </form> </div> <!-- 댓글목록 --> <div class="card-footer"> <b>댓글리스트</b> </div> <div class="list-group"> <!-- 댓글아이템 --> {{#model.replies}} <div class="list-group-item d-flex justify-content-between align-items-center"> <div class="d-flex"> <div class="px-1 me-1 bg-primary text-white rounded">{{username}}</div> <div>{{content}}</div> </div> {{#isOwner}} <form action="/reply/{{id}}/delete" method="post"> <button class="btn">🗑</button> </form> {{/isOwner}} </div> {{/model.replies}} </div> </div> </div> <script> // setInterval(()=>{ // location.reload(); // },1000) let boardId = document.querySelector("#boardId").value; async function saveLove() { let requestBody = {boardId: boardId}; let response = await fetch(`/love`, { method: "POST", body: JSON.stringify(requestBody), headers: {"Content-Type": "application/json"} }); let responseBody = await response.json(); // { loveId, loveCount } console.log(responseBody); // DOM 업데이트 let loveIcon = document.querySelector('#loveIcon'); let loveCount = document.querySelector('#loveCount'); loveIcon.style.color = 'red'; loveIcon.setAttribute('onclick', `deleteLove(${responseBody.body.loveId})`); loveCount.innerHTML = responseBody.body.loveCount; } async function deleteLove(loveId) { let response = await fetch(`/love/${loveId}`, { method: "DELETE" }); let responseBody = await response.json(); // response.text(); (X) console.log(responseBody); // DOM 업데이트 let loveIcon = document.querySelector('#loveIcon'); let loveCount = document.querySelector('#loveCount'); loveIcon.style.color = 'black'; loveIcon.setAttribute('onclick', `saveLove()`); loveCount.innerHTML = responseBody.body.loveCount; } </script> {{> layout/footer}}
    1. ssar login
    notion image
    1. cos login
    notion image

    댓글 - 쓰기

    Reply

    package shop.mtcoding.blog.reply; import jakarta.persistence.*; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.CreationTimestamp; import shop.mtcoding.blog.board.Board; import shop.mtcoding.blog.user.User; import java.sql.Timestamp; @NoArgsConstructor @Getter @Table(name = "reply_tb") @Entity public class Reply { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; private String content; // 댓글내용 @ManyToOne(fetch = FetchType.LAZY) private User user; @ManyToOne(fetch = FetchType.LAZY) private Board board; @CreationTimestamp private Timestamp createdAt; @Builder public Reply(Integer id, String content, User user, Board board, Timestamp createdAt) { this.id = id; this.content = content; this.user = user; this.board = board; this.createdAt = createdAt; } }

    ReplyRequest

    package shop.mtcoding.blog.reply; import lombok.Data; import shop.mtcoding.blog.board.Board; import shop.mtcoding.blog.user.User; public class ReplyRequest { @Data public static class SaveDTO { private Integer boardId; private String content; public Reply toEntity(User sessionUser) { return Reply.builder() .content(content) .board(Board.builder().id(boardId).build()) .user(sessionUser) .build(); } } }

    ReplyController

    package shop.mtcoding.blog.reply; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import shop.mtcoding.blog.user.User; @RequiredArgsConstructor @Controller public class ReplyController { private final ReplyService replyService; private final HttpSession session; @PostMapping("/reply/save") public String save(ReplyRequest.SaveDTO reqDTO) { // 인증로직 User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증이 필요합니다."); replyService.댓글쓰기(reqDTO, sessionUser); return "redirect:/board/" + reqDTO.getBoardId(); } }

    ReplyService

    package shop.mtcoding.blog.reply; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import shop.mtcoding.blog.user.User; @RequiredArgsConstructor @Service public class ReplyService { private final ReplyRepository replyRepository; @Transactional public void 댓글쓰기(ReplyRequest.SaveDTO reqDTO, User sessionUser) { replyRepository.save(reqDTO.toEntity(sessionUser)); } }

    ReplyRepository

    package shop.mtcoding.blog.reply; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.util.List; @RequiredArgsConstructor @Repository public class ReplyRepository { private final EntityManager em; public List<Reply> findAllByBoardId(int boardId) { Query query = em.createQuery("select r from Reply r join fetch r.user where r.board.id = :boardId", Reply.class); query.setParameter("boardId", boardId); List<Reply> replies = query.getResultList(); return replies; } public Reply save(Reply reply) { em.persist(reply); return reply; } }

    detail

    {{> layout/header}} <input type="hidden" id="boardId" value="{{model.id}}"> <div class="container p-5"> <!-- 수정삭제버튼 --> {{#model.isOwner}} <div class="d-flex justify-content-end"> <a href="/board/{{model.id}}/update-form" class="btn btn-warning me-1">수정</a> <form action="/board/{{model.id}}/delete" method="post"> <button class="btn btn-danger">삭제</button> </form> </div> {{/model.isOwner}} <div class="d-flex justify-content-end"> <b>작성자</b> : {{model.username}} </div> <!-- 게시글내용 --> <div> <h2><b>{{model.title}}</b></h2> <hr/> <div class="m-4 p-2"> {{model.content}} </div> </div> <!-- AJAX 좋아요 영역 --> <div class="my-3 d-flex align-items-center"> {{#model.isLove}} <i id="loveIcon" class="fa fa-heart" style="font-size:20px; color:red" onclick="deleteLove({{model.loveId}})"></i> {{/model.isLove}} {{^model.isLove}} <i id="loveIcon" class="fa fa-heart" style="font-size:20px; color:black" onclick="saveLove()"></i> {{/model.isLove}} <span class="ms-1"><b id="loveCount">{{model.loveCount}}</b>명이 이 글을 좋아합니다</span> </div> <!-- 댓글 --> <div class="card mt-3"> <!-- 댓글등록 --> <div class="card-body"> <form action="/reply/save" method="post"> <input type="hidden" name="boardId" value="{{model.id}}"> <textarea class="form-control" rows="2" name="content"></textarea> <div class="d-flex justify-content-end"> <button type="submit" class="btn btn-outline-primary mt-1">댓글등록</button> </div> </form> </div> <!-- 댓글목록 --> <div class="card-footer"> <b>댓글리스트</b> </div> <div class="list-group"> <!-- 댓글아이템 --> {{#model.replies}} <div class="list-group-item d-flex justify-content-between align-items-center"> <div class="d-flex"> <div class="px-1 me-1 bg-primary text-white rounded">{{username}}</div> <div>{{content}}</div> </div> {{#isOwner}} <form action="/reply/{{id}}/delete" method="post"> <button type="submit" class="btn">🗑</button> </form> {{/isOwner}} </div> {{/model.replies}} </div> </div> </div> <script> // setInterval(()=>{ // location.reload(); // },1000) let boardId = document.querySelector("#boardId").value; async function saveLove() { let requestBody = {boardId: boardId}; let response = await fetch(`/love`, { method: "POST", body: JSON.stringify(requestBody), headers: {"Content-Type": "application/json"} }); let responseBody = await response.json(); // { loveId, loveCount } console.log(responseBody); // DOM 업데이트 let loveIcon = document.querySelector('#loveIcon'); let loveCount = document.querySelector('#loveCount'); loveIcon.style.color = 'red'; loveIcon.setAttribute('onclick', `deleteLove(${responseBody.body.loveId})`); loveCount.innerHTML = responseBody.body.loveCount; } async function deleteLove(loveId) { let response = await fetch(`/love/${loveId}`, { method: "DELETE" }); let responseBody = await response.json(); // response.text(); (X) console.log(responseBody); // DOM 업데이트 let loveIcon = document.querySelector('#loveIcon'); let loveCount = document.querySelector('#loveCount'); loveIcon.style.color = 'black'; loveIcon.setAttribute('onclick', `saveLove()`); loveCount.innerHTML = responseBody.body.loveCount; } </script> {{> layout/footer}}
    1. ssar login
    notion image
    notion image
    notion image

    댓글 - 삭제

    ReplyController

    package shop.mtcoding.blog.reply; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import shop.mtcoding.blog.user.User; @RequiredArgsConstructor @Controller public class ReplyController { private final ReplyService replyService; private final HttpSession session; @PostMapping("/reply/save") public String save(ReplyRequest.SaveDTO reqDTO) { // 인증로직 User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증이 필요합니다."); replyService.댓글쓰기(reqDTO, sessionUser); return "redirect:/board/" + reqDTO.getBoardId(); } @PostMapping("/reply/{id}/delete") public String delete(@PathVariable("id") Integer id) { // 인증로직 User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증이 필요합니다."); Integer boardId = replyService.댓글삭제(id, sessionUser.getId()); replyService.댓글삭제(id, sessionUser.getId()); return "redirect:/board/" + boardId; } }

    ReplyService

    package shop.mtcoding.blog.reply; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import shop.mtcoding.blog.user.User; @RequiredArgsConstructor @Service public class ReplyService { private final ReplyRepository replyRepository; @Transactional public void 댓글쓰기(ReplyRequest.SaveDTO reqDTO, User sessionUser) { replyRepository.save(reqDTO.toEntity(sessionUser)); } @Transactional public Integer 댓글삭제(Integer id, Integer sessionUserId) { Reply replyPs = replyRepository.findById(id); if (replyPs == null) throw new RuntimeException("삭제할 댓글이 없습니다."); if (!(replyPs.getUser().getId().equals(sessionUserId))) throw new RuntimeException("삭제할 권한이 없습니다."); Integer boardId = replyPs.getBoard().getId(); replyRepository.deleteById(id); return boardId; } }

    ReplyRepository

    package shop.mtcoding.blog.reply; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.util.List; @RequiredArgsConstructor @Repository public class ReplyRepository { private final EntityManager em; public List<Reply> findAllByBoardId(int boardId) { Query query = em.createQuery("select r from Reply r join fetch r.user where r.board.id = :boardId", Reply.class); query.setParameter("boardId", boardId); List<Reply> replies = query.getResultList(); return replies; } public Reply save(Reply reply) { em.persist(reply); return reply; } public void deleteById(Integer id) { em.createQuery("delete from Reply r where r.id = :id") .setParameter("id", id) .executeUpdate(); } public Reply findById(Integer id) { return em.find(Reply.class, id); } }

    detail

    {{> layout/header}} <input type="hidden" id="boardId" value="{{model.id}}"> <div class="container p-5"> <!-- 수정삭제버튼 --> {{#model.isOwner}} <div class="d-flex justify-content-end"> <a href="/board/{{model.id}}/update-form" class="btn btn-warning me-1">수정</a> <form action="/board/{{model.id}}/delete" method="post"> <button class="btn btn-danger">삭제</button> </form> </div> {{/model.isOwner}} <div class="d-flex justify-content-end"> <b>작성자</b> : {{model.username}} </div> <!-- 게시글내용 --> <div> <h2><b>{{model.title}}</b></h2> <hr/> <div class="m-4 p-2"> {{model.content}} </div> </div> <!-- AJAX 좋아요 영역 --> <div class="my-3 d-flex align-items-center"> {{#model.isLove}} <i id="loveIcon" class="fa fa-heart" style="font-size:20px; color:red" onclick="deleteLove({{model.loveId}})"></i> {{/model.isLove}} {{^model.isLove}} <i id="loveIcon" class="fa fa-heart" style="font-size:20px; color:black" onclick="saveLove()"></i> {{/model.isLove}} <span class="ms-1"><b id="loveCount">{{model.loveCount}}</b>명이 이 글을 좋아합니다</span> </div> <!-- 댓글 --> <div class="card mt-3"> <!-- 댓글등록 --> <div class="card-body"> <form action="/reply/save" method="post"> <input type="hidden" name="boardId" value="{{model.id}}"> <textarea class="form-control" rows="2" name="content"></textarea> <div class="d-flex justify-content-end"> <button type="submit" class="btn btn-outline-primary mt-1">댓글등록</button> </div> </form> </div> <!-- 댓글목록 --> <div class="card-footer"> <b>댓글리스트</b> </div> <div class="list-group"> <!-- 댓글아이템 --> {{#model.replies}} <div class="list-group-item d-flex justify-content-between align-items-center"> <div class="d-flex"> <div class="px-1 me-1 bg-primary text-white rounded">{{username}}</div> <div>{{content}}</div> </div> {{#isOwner}} <form action="/reply/{{id}}/delete" method="post"> <button type="submit" class="btn">🗑</button> </form> {{/isOwner}} </div> {{/model.replies}} </div> </div> </div> <script> // setInterval(()=>{ // location.reload(); // },1000) let boardId = document.querySelector("#boardId").value; async function saveLove() { let requestBody = {boardId: boardId}; let response = await fetch(`/love`, { method: "POST", body: JSON.stringify(requestBody), headers: {"Content-Type": "application/json"} }); let responseBody = await response.json(); // { loveId, loveCount } console.log(responseBody); // DOM 업데이트 let loveIcon = document.querySelector('#loveIcon'); let loveCount = document.querySelector('#loveCount'); loveIcon.style.color = 'red'; loveIcon.setAttribute('onclick', `deleteLove(${responseBody.body.loveId})`); loveCount.innerHTML = responseBody.body.loveCount; } async function deleteLove(loveId) { let response = await fetch(`/love/${loveId}`, { method: "DELETE" }); let responseBody = await response.json(); // response.text(); (X) console.log(responseBody); // DOM 업데이트 let loveIcon = document.querySelector('#loveIcon'); let loveCount = document.querySelector('#loveCount'); loveIcon.style.color = 'black'; loveIcon.setAttribute('onclick', `saveLove()`); loveCount.innerHTML = responseBody.body.loveCount; } </script> {{> layout/footer}}
    1. ssar login
    notion image
    notion image
    notion image

    findByIdJoinUser() Test

    BoardRepository

    fetch를 썼을 때
    package shop.mtcoding.blog.board; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.util.List; @RequiredArgsConstructor @Repository public class BoardRepository { private final EntityManager em; public void save(Board board) { em.persist(board); } public List<Board> findAll(Integer userId) { // Integer를 써야 null을 넘길 수 있다 // 동적 query String s1 = "select b from Board b where b.isPublic = true or b.user.id = :userId order by b.id desc"; String s2 = "select b from Board b where b.isPublic = true order by b.id desc"; Query query = null; if (userId == null) { query = em.createQuery(s2, Board.class); } else { query = em.createQuery(s1, Board.class); query.setParameter("userId", userId); } return query.getResultList(); } public Board findById(Integer id) { return em.find(Board.class, id); } public Board findByIdJoinUser(Integer id) { // b -> board에 있는 필드만 프로잭션 fetch를 써야 board안에 있는 user 객체도 같이 프로잭션됨 Query query = em.createQuery("select b from Board b join fetch b.user u where b.id = :id", Board.class); // inner join (on절은 생략가능하다) -> 객체지향 쿼리 query.setParameter("id", id); return (Board) query.getSingleResult(); } }

    BoardRepositoryTest

    package shop.mtcoding.blog.board; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; import java.util.List; @Import(BoardRepository.class) // BoardRepository @DataJpaTest // EntityManager, PC public class BoardRepositoryTest { @Autowired // DI private BoardRepository boardRepository; @Test public void findAll_test() { // given Integer userId = 1; // when List<Board> boardList = boardRepository.findAll(userId); // Lazy -> Board -> User(id=1) // Eager -> N+1 -> Board조회 -> 연관된 User 유저 수 만큼 주회 // Eager -> Join -> 한방쿼리 // System.out.println("--------------------"); // boardList.get(0).getUser().getUsername(); // System.out.println("--------------------"); // eye for (Board board : boardList) { System.out.print(board.getId() + "," + board.getTitle() + "," + board.getContent() + "," + board.getIsPublic() + "," + board.getUser().getId() + "," + board.getCreatedAt()); System.out.println(); } } @Test public void findByIdJoinUser_test() { // given Integer id = 1; // when boardRepository.findByIdJoinUser(id); // eye } }
    ⬇ board안에 있는 user 객체도 같이 프로잭션됨
    Hibernate: select b1_0.id, b1_0.content, b1_0.created_at, b1_0.is_public, b1_0.title, u1_0.id, u1_0.created_at, u1_0.email, u1_0.password, u1_0.username from board_tb b1_0 join user_tb u1_0 on u1_0.id=b1_0.user_id where b1_0.id=?
     

    BoardRepository

    fetch를 안 쓸때
    package shop.mtcoding.blog.board; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.util.List; @RequiredArgsConstructor @Repository public class BoardRepository { private final EntityManager em; public void save(Board board) { em.persist(board); } public List<Board> findAll(Integer userId) { // Integer를 써야 null을 넘길 수 있다 // 동적 query String s1 = "select b from Board b where b.isPublic = true or b.user.id = :userId order by b.id desc"; String s2 = "select b from Board b where b.isPublic = true order by b.id desc"; Query query = null; if (userId == null) { query = em.createQuery(s2, Board.class); } else { query = em.createQuery(s1, Board.class); query.setParameter("userId", userId); } return query.getResultList(); } public Board findById(Integer id) { return em.find(Board.class, id); } public Board findByIdJoinUser(Integer id) { // b -> board에 있는 필드만 프로잭션 fetch를 써야 board안에 있는 user 객체도 같이 프로잭션됨 Query query = em.createQuery("select b from Board b join b.user u where b.id = :id", Board.class); // inner join (on절은 생략가능하다) -> 객체지향 쿼리 query.setParameter("id", id); return (Board) query.getSingleResult(); } }

    BoardRepositoryTest

    package shop.mtcoding.blog.board; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; import java.util.List; @Import(BoardRepository.class) // BoardRepository @DataJpaTest // EntityManager, PC public class BoardRepositoryTest { @Autowired // DI private BoardRepository boardRepository; @Test public void findAll_test() { // given Integer userId = 1; // when List<Board> boardList = boardRepository.findAll(userId); // Lazy -> Board -> User(id=1) // Eager -> N+1 -> Board조회 -> 연관된 User 유저 수 만큼 주회 // Eager -> Join -> 한방쿼리 // System.out.println("--------------------"); // boardList.get(0).getUser().getUsername(); // System.out.println("--------------------"); // eye for (Board board : boardList) { System.out.print(board.getId() + "," + board.getTitle() + "," + board.getContent() + "," + board.getIsPublic() + "," + board.getUser().getId() + "," + board.getCreatedAt()); System.out.println(); } } @Test public void findByIdJoinUser_test() { // given Integer id = 1; // when boardRepository.findByIdJoinUser(id); // eye } }
    ⬇ board만 프로잭션
    Hibernate: select b1_0.id, b1_0.content, b1_0.created_at, b1_0.is_public, b1_0.title, b1_0.user_id from board_tb b1_0 join user_tb u1_0 on u1_0.id=b1_0.user_id where b1_0.id=?
    Share article
    Contents
    📌 title, content, username만 화면에 나오게 하기BoardControllerBoardServiceBoardRepositoryBoardResponsedetail📌 작성자만 수정,삭제 버튼 보이기 & 비로그인 시 상세보기 BoardResponseBoardControllerBoardServiceBoardRepositorydetail📌 좋아요 (Ajax)➡ Table & Dummy Data 생성 후 화면에 뿌려보기화면에 user 별로 좋아요 표시해보기loveCount 표시한방 Query version좋아요 & 좋아요 취소📌 댓글Table 생성Dummy Data 넣고 확인양방향 Mapping댓글 - 작성자만 삭제 가능하게댓글 - 쓰기댓글 - 삭제findByIdJoinUser() Test

    parangdajavous

    RSS·Powered by Inblog