[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

📌 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

parangdajavous