[SB] 13. 스프링부트 블로그 v2

최재원's avatar
Apr 13, 2025
[SB] 13. 스프링부트 블로그 v2

Board테이블 ORM 맛보기

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.user.User; import java.sql.Timestamp; @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) // 연관관계 설정 // EAGER -> 처음 조회할 때 바로 join함. // LAZY -> 처음 조회할 때 board만 가져옴, 나중에 getUser()하면 그 때 조회를 한번 더함 private User user; @CreationTimestamp // 자동 now() 들어감 private Timestamp createdAt; @Builder // 빌터패턴의 메서드를 만들어줌 // 조건 모든 필드값의 생성자를 1개 만들고 그 위에 @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; } }
@ManyToOne → 연관 관계 설정
  • EAGER → 처음 조회할 때 연관된 테이블까지 같이 join 해서 가져옴
  • LAZY → 처음 조회할 때 본래 테이블만 가져옴, 나중에 연관된 테이블을 get 하면 그 때 조회함

BoardRepository

package shop.mtcoding.blog.board; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import shop.mtcoding.blog.user.User; import java.sql.Timestamp; @RequiredArgsConstructor @Repository public class BoardRepository { private final EntityManager em; public Board findByIdV1(int id) { Query query = em.createNativeQuery("select bt.id, bt.title, bt.content, bt.is_public, bt.created_at, ut.id user_id, ut.username, ut.password, ut.email, ut.created_at from board_tb bt inner join user_tb ut on bt.user_id = ut.id where bt.id = ?"); query.setParameter(1, id); Object[] obs = (Object[]) query.getSingleResult(); User user = User.builder() .id((int) obs[5]) .username((String) obs[6]) .password((String) obs[7]) .email((String) obs[8]) .createdAt((Timestamp) obs[9]) .build(); Board board = Board.builder() .id((int) obs[0]) .title((String) obs[1]) .content((String) obs[2]) .isPublic((boolean) obs[3]) .createdAt((Timestamp) obs[4]) .user(user) .build(); return board; } public Board findByIdV2(int id) { return em.find(Board.class, id); } }
  • findByIdV1 는 직접 맵핑하는 방법
  • findByIdV2EntityManager 가 연관 관계를 보고 자동 맵핑 하는 방법

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(BoardRepository.class) @DataJpaTest public class BoardRepositoryTest { @Autowired private BoardRepository boardRepository; @Test public void findById_test() { Board board = boardRepository.findByIdV1(1); System.out.println(board.getId()); System.out.println(board.getTitle()); System.out.println(board.getUser().getUsername()); System.out.println(board.getUser().getPassword()); } }

findByIdV1 결과

전부 쿼리해서 엔티티에 직접 맵핑
notion image

findByIdV2 결과 (EAGER)

전부 쿼리해서 엔티티에 자동 맵핑
notion image

findByIdV2 결과 (LAZY)

board만 쿼리해서 엔티티에 자동 맵핑, 후에 user도 쿼리해서 엔티티에 자동 맵핑
notion image

1. 회원 가입

notion image
Username이 중복되면 Exception을 만들어 터트리기 @Transactional을 사용해야함 insert 이기 때문
회원가입이 성공하면 user/join-form으로 리다이렉트 회원가입이 실패하면 그냥 예외 터트리기
JoinDTO 만들어 데이터 받기
json응답으로 사용할 Resp객체 만들기

🧔user/join-form

{{> layout/header}} <div class="container p-5"> <!-- 요청을 하면 localhost:8080/join POST로 요청됨 username=사용자입력값&password=사용자값&email=사용자입력값 --> <div class="card"> <div class="card-header"><b>회원가입을 해주세요</b></div> <div class="card-body"> <form action="/join" method="post" enctype="application/x-www-form-urlencoded" onsubmit="return valid()"> <div class="mb-3"> <input id="username" type="text" class="form-control" placeholder="Enter username" name="username"> <button type="button" class="btn btn-warning" onclick="checkUsernameAvailable()">중복확인</button> </div> <div class="mb-3"> <input type="password" class="form-control" placeholder="Enter password" name="password"> </div> <div class="mb-3"> <input type="email" class="form-control" placeholder="Enter email" name="email"> </div> <button type="submit" class="btn btn-primary form-control">회원가입</button> </form> </div> </div> </div> <script> let isUsernameAvailable = false; // 1. 유저네임 변경 감지 let usernameDom = document.querySelector("#username"); usernameDom.addEventListener("keyup", () => { isUsernameAvailable = false; }) // 2. 유저네임 중복 체크 async function checkUsernameAvailable() { let username = document.querySelector("#username").value; let response = await fetch("/check-username-available/" + username); let responseBody = await response.json(); // status = 200, msg = 성공, body: { "available" : true } isUsernameAvailable = responseBody.body.available; if (isUsernameAvailable) { alert("사용 가능한 아이디 입니다") } else { alert("사용 불가능한 아이디 입니다") } } // 3. 최종 유효성 검사 function valid() { if (!isUsernameAvailable) { alert("아이디 중복 체크를 해주세요"); return false; } return true; } </script> {{> layout/footer}}

☕User

package shop.mtcoding.blog.user; import jakarta.persistence.*; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.CreationTimestamp; import java.sql.Timestamp; @NoArgsConstructor @Getter @Table(name = "user_tb") @Entity public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; @Column(unique = true) private String username; private String password; private String email; @CreationTimestamp // 자동 now() 들어감 private Timestamp createdAt; @Builder // 빌터패턴의 메서드를 만들어줌 // 조건 모든 필드값의 생성자를 1개 만들고 그 위에 @Builder를 추가하면 된다 public User(Integer id, String username, String password, String email, Timestamp createdAt) { this.id = id; this.username = username; this.password = password; this.email = email; this.createdAt = createdAt; } }
notion image
  • @CreationTimestamp → JPA를 사용하면 자동 날짜 생성
  • @Builder → 객체 생성을 빌더 패턴으로 만들어준다. 예) User.builder().username(username).password(password).email(email).build();

☕UserController

package shop.mtcoding.blog.user; 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 org.springframework.web.bind.annotation.ResponseBody; import shop.mtcoding.blog._core.Resp; import java.util.Map; @Controller @RequiredArgsConstructor public class UserController { private final UserService userService; @GetMapping("/join-form") public String joinForm() { return "/user/join-form"; } @PostMapping("/join") public String join(UserRequest.JoinDTO joinDTO) { System.out.println(joinDTO); userService.회원가입(joinDTO); return "redirect:/join-form"; } @GetMapping("/check-username-available/{username}") public @ResponseBody Resp<?> checkUsernameAvailable(@PathVariable("username") String username) { Map<String, Object> dto = userService.유저네임중복체크(username); return Resp.ok(dto); } }
  • @ResponseBody → return Object를 JSON으로 변환 해주는 어노테이션
  • Map<String, Object> dto → dto 오브젝트의 field가 1개 뿐이라면 Map을 사용하는 것이 낫다

☕UserRequest

package shop.mtcoding.blog.user; import lombok.Data; public class UserRequest { // insert 용도의 dto에는 toEntity 메서드를 만든다 @Data public static class JoinDTO { private String username; private String password; private String email; // dto에 있는 데이터를 바로 Entity 객체로 변환 하는 메서드 public User toEntity() { return User.builder() .username(username) .password(password) .email(email) .build(); } } }
  • DTOEntity로 만들어주는 메서드를 추가한다
  • toEntity()insert할 때 만들어야 한다

☕Resp

package shop.mtcoding.blog._core; import lombok.AllArgsConstructor; import lombok.Data; @AllArgsConstructor @Data // getter가 있어야 @ResponseBody 이 어노테이션으로 json으로 변환해 준다 public class Resp<T> { private Integer status; private String msg; private T body; // 앞부분의 <B> 는 받는 타입을 임시로 Object로 받아라고 하는 문법 public static <B> Resp<?> ok(B body) { return new Resp<>(200, "성공", body); } public static Resp<?> fail(Integer status, String msg) { return new Resp<>(status, msg, null); } }
객체에 데이터를 담아서 응답해야 할 때(중복체크 응답용)
  • 객체를 만든다
  • new 객체() → 방법으로 사용하기 까다롭다. 어떤 데이터를 넣어야 할지 모른다
  • 객체.이름() → 방법으로 사용한다. 메서드 이름으로 넣어야 할 데이터를 짐작할 수 있다
  • 받을 데이터의 타입이 명확하지 않을 때 제네릭을 사용한다
제네릭 사용 방법
  • ? → Object를 뜻함. java의 모든 타입을 받을 수 있음
  • 자신의 블록 범위 안에서는 같은 이름을 사용해야 함
  • public class Resp<T> = private T body; 같은 이름을 사용해야 함
  • public static <B> Resp<?> ok(B body) 같은 이름을 사용해야 함
문법
  • public static <B> Resp<?> ok(B body)<B> 이 부분에 제네릭을 넣으면 일단 임시로 받는 type을 Object로 한다
  • public static <B> Resp<?> ok(B body)Resp<?> ?를 사용하는 이유는 일단 리턴 타입을 명시하기가 귀찮기 때문

☕UserService

package shop.mtcoding.blog.user; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class UserService { private final UserRepository userRepository; @Transactional public void 회원가입(UserRequest.JoinDTO joinDTO) { // 1. 해당 username이 사용 중인지 확인 User alreadyUser = userRepository.findByUsername(joinDTO.getUsername()); // 2. 사용 중이면 예외! if (alreadyUser != null) { throw new RuntimeException("해당 username은 이미 사용중 입니다"); } // 3. 아니면 회원가입 성공 userRepository.save(joinDTO.toEntity()); } public Map<String, Object> 유저네임중복체크(String username) { User user = userRepository.findByUsername(username); Map<String, Object> dto = new HashMap<>(); if (user == null) { dto.put("available", true); } else { dto.put("available", false); } return dto; } }
유저네임중복체크의 결과 값을 만들때
  • 매우 단순한 {isSameUsername:true} -> 이런 데이터는 map 으로 만들어 리턴한다
중복체크 쿼리
notion image

☕UserRepository

package shop.mtcoding.blog.user; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @Repository @RequiredArgsConstructor public class UserRepository { private final EntityManager em; /* * 1. createNativeQuery -> 기본 쿼리 * 2. createQuery -> JPA가 제공해주는 객체지향 쿼리(JPQL) * "select u from User u where u.username = :username" * user_tb -> User 객체 * u -> 별칭 * 3. createNamedQuery -> Query Method 함수 이름으로 쿼리 생성 x * 4. createEntityGraph -> x * */ public void save(User user) { em.persist(user); // user object에 pk 값이 null 이면 자동 insert 쿼리 실행 } public User findByUsername(String username) { try { return em.createQuery("select u from User u where u.username = :username", User.class) .setParameter("username", username) .getSingleResult(); } catch (Exception e) { return null; } } }
  • persist → 들어오는 Entity의 pk값이 null 이면 자동 insert 쿼리 실행
  • → 들어오는 Entity의 pk값이 존재하면 Exception 터짐
notion image
insert 쿼리
notion image
 

2. 로그인

notion image
Username에 대한 아이디가 있는지 확인 비밀번호가 같은지 확인 다음 로그인
로그인이 성공하면 user/login-form으로 리다이렉트 로그인이 실패하면 그냥 예외 터트리기
LoginDTO만들어 데이터 받기
아이디를 기억하겠습니까 → 쿠키를 사용해 브라우저에 정보 저장 체크 해제하면 브라우저에 있는 쿠키 정보 삭제

🧔user/login-form

{{> layout/header}} <div class="container p-5"> <div class="card"> <div class="card-header"><b>로그인을 해주세요</b></div> <div class="card-body"> <form action="/login" method="post" enctype="application/x-www-form-urlencoded"> <div class="mb-3"> <input id="username" type="text" class="form-control" placeholder="Enter username" name="username"> </div> <div class="mb-3"> <input type="password" class="form-control" placeholder="Enter password" name="password"> </div> <!-- ✅ 공개 여부 체크박스 --> <div class="form-check mb-3"> <input id="isUsernameCheck" class="form-check-input" type="checkbox" name="rememberMe" checked> <label class="form-check-label" for="isUsernameCheck"> 아이디를 기억하겠습니까? </label> </div> <button type="submit" class="btn btn-primary form-control">로그인</button> </form> </div> </div> </div> <script> let rememberUsername = getCookie("username"); if (rememberUsername != null) { let usernameInput = document.querySelector("#username"); usernameInput.value = rememberUsername; } function getCookie(name) { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop().split(';').shift(); return null; } </script> {{> layout/footer}}
아이디 기억하기 체크하면
notion image
notion image
아이디 기억하기 체크 해제 하면
notion image
notion image

🧔layout/header

<!DOCTYPE html> <html lang="en"> <head> <title>Blog</title> <meta charset="utf-8"/> <meta name="viewport" content="width=device-width, initial-scale=1"/> <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"/> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> <style> .my-like-heart { font-size: 24px; color: gray; cursor: pointer; } .my-like-heart.liked { color: red; } </style> </head> <body> <nav class="navbar navbar-expand-sm bg-dark navbar-dark"> <div class="container-fluid"> <a class="navbar-brand" href="/">Metacoding</a> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#collapsibleNavbar"> <span class="navbar-toggler-icon"></span> </button> <div class="collapse navbar-collapse" id="collapsibleNavbar"> <ul class="navbar-nav"> {{#sessionUser}} <li class="nav-item"> <a class="nav-link" href="/board/save-form">글쓰기</a> </li> <li class="nav-item"> <a class="nav-link" href="/user/update-form">회원정보보기</a> </li> <li class="nav-item"> <a class="nav-link" href="/logout">로그아웃</a> </li> {{/sessionUser}} {{^sessionUser}} <li class="nav-item"> <a class="nav-link" href="/join-form">회원가입</a> </li> <li class="nav-item"> <a class="nav-link" href="/login-form">로그인</a> </li> {{/sessionUser}} </ul> </div> </div> </nav>

☕UserController

private final HttpSession session; @GetMapping("/login-form") public String loginForm() { return "/user/login-form"; } @PostMapping("/login") public String login( UserRequest.LoginDTO loginDTO, HttpServletResponse response) { System.out.println(loginDTO); User sessionUser = userService.로그인(loginDTO); session.setAttribute("sessionUser", sessionUser); if (loginDTO.getRememberMe() == null) { Cookie cookie = new Cookie("username", null); cookie.setMaxAge(0); // 브라우저가 MaxAge가 0인 쿠키는 자동 삭제함 response.addCookie(cookie); } else { Cookie cookie = new Cookie("username", loginDTO.getUsername()); cookie.setMaxAge(24 * 60 * 60 * 7); // MaxAge에 값을 넣으면 브라우저를 새로 켜도 유지됨 response.addCookie(cookie); } return "redirect:/login-form"; } @GetMapping("/logout") public String logout() { session.invalidate(); return "redirect:/"; }

☕UserRequest

@Data public static class LoginDTO { private String username; private String password; private String rememberMe; }

☕UserService

public User 로그인(UserRequest.LoginDTO loginDTO) { // 1. username에 대한 데이터가 있는지 확인 User user = userRepository.findByUsername(loginDTO.getUsername()); // 2. 없으면 예외! if (user == null) { throw new RuntimeException("해당 아이디가 없습니다"); } // 3. 있으면 password 비교 if (!(user.getPassword().equals(loginDTO.getPassword()))) { throw new RuntimeException("password가 맞지 않습니다"); } return user; }

3. 글쓰기

notion image
로그인 된 유저만 해당 페이지 접근 가능
글쓰기 성공하면 /로 리다이렉트 로그인이 실패하면 그냥 예외 터트리기
SaveDTO만들어 데이터 받기
em.persist() 로 데이터 저장

🧔board/save-form

{{> layout/header}} <div class="container p-5"> <div class="card"> <div class="card-header"><b>글쓰기 화면입니다</b></div> <div class="card-body"> <form action="/board/save" method="post"> <div class="mb-3"> <input type="text" class="form-control" placeholder="Enter title" name="title"> </div> <div class="mb-3"> <textarea class="form-control" rows="5" name="content"></textarea> </div> <!-- ✅ 공개 여부 체크박스 --> <div class="form-check mb-3"> <input id="isPublic" class="form-check-input" type="checkbox" name="isPublic" checked> <label class="form-check-label" for="isPublic"> 공개 글로 작성하기 </label> </div> <button class="btn btn-primary form-control">글쓰기완료</button> </form> </div> </div> </div> {{> layout/footer}}

☕BoardController

package shop.mtcoding.blog.board; 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.PostMapping; import shop.mtcoding.blog.user.User; @Controller @RequiredArgsConstructor public class BoardController { private final BoardService boardService; private final HttpSession session; @GetMapping("/") public String list() { return "/board/list"; } @GetMapping("/board/save-form") public String saveForm() { User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증이 필요합니다"); return "/board/save-form"; } @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:/"; } }

☕BoardRequest

package shop.mtcoding.blog.board; import lombok.Builder; import lombok.Data; import shop.mtcoding.blog.user.User; public class BoardRequest { @Data public static class SaveDTO { private String title; private String content; private String isPublic; public Board toEntity(User user) { return Board.builder() .title(title) .content(content) .isPublic(isPublic == null ? false : true) .user(user) // user 객체 필요 .build(); } } }
  • toEntity()insert할 때 만들어야 한다

☕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; @Service @RequiredArgsConstructor public class BoardService { private final BoardRepository boardRepository; @Transactional public void 글쓰기(BoardRequest.SaveDTO saveDTO, User sessionUser) { Board board = saveDTO.toEntity(sessionUser); boardRepository.save(board); } }

☕BoardRepository

package shop.mtcoding.blog.board; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @Repository @RequiredArgsConstructor public class BoardRepository { private final EntityManager em; public void save(Board board) { em.persist(board); } }
notion image
insert 쿼리
notion image

4. 목록 보기

notion image
isPublic이 true인 것과 자신의 게시글을 볼 수 있어야 한다

🧔board/list

{{> layout/header}} <div class="container p-5"> <div class="mb-3 d-flex justify-content-end"> <form class="d-flex"> <input class="form-control me-2" type="text" placeholder="검색..."> <button class="btn btn-primary flex-shrink-0" type="button">검색</button> </form> </div> {{#models}} <div class="card mb-3"> <div class="card-body"> <h4 class="card-title mb-3">{{title}}</h4> <a href="/board/{{id}}" class="btn btn-primary">상세보기</a> </div> </div> {{/models}} <ul class="pagination d-flex justify-content-center"> <li class="page-item disabled"><a class="page-link" href="#">Previous</a></li> <li class="page-item"><a class="page-link" href="#">Next</a></li> </ul> </div> {{> layout/footer}}

☕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.user.User; import java.sql.Timestamp; @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) // 연관관계 설정 -> ORM 하려고 EAGER -> fk에 들어간 오브젝트를 바로 연관관계 맵핑을 해서 select를 여러 번 한다 , LAZY -> 무조건 LAZY를 사용한다. 연관관계 맵핑을 하지 않는다 private User user; @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; } }
notion image

☕BoardController

@GetMapping("/") public String list(HttpServletRequest request) { User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) { List<Board> boardList = boardService.목록보기(null); request.setAttribute("models", boardList); } else { List<Board> boardList = boardService.목록보기(sessionUser.getId()); request.setAttribute("models", boardList); } return "/board/list"; }
sessionUser가
  • 있으면 ⭕ userId
  • 없으면 ❌ null
을 넣어서 목록보기를 호출한다

☕BoardService

public List<Board> 목록보기(Integer userId) { return boardRepository.findAll(userId); }

☕BoardRepository

public List<Board> findAll(Integer userId) { 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(); }
동적 쿼리로
  • userId가 ⭕ s1
  • userId가 ❌ s2
기준으로 다르게 쿼리를 실행한다

☕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 private BoardRepository boardRepository; @Test public void findAll_test() { // given // when List<Board> boardList = boardRepository.findAll(); // Lazy -> Board -> User(id=1) // Eager -> N+1 -> Board조회 -> 연관된 User 유저 수 만큼 주회 // Eager -> Join -> 한방쿼리 System.out.println("--------------------"); boardList.forEach((board) -> { System.out.println(board.getId() + ": " + board.getTitle()); }); System.out.println("--------------------"); // eye } }

test-1 @ManyToOne(fetch = FetchType.EAGER)

연관관계에 있는 user_id를 보고 user_tb에서 조회해서 넣어준다. user_id가 n개 있으면 n번 select를 해서 넣어준다
board_tb 를 쿼리
notion image
user_tb 를 쿼리
notion image
notion image
notion image
더미 데이터
notion image

test-2 @ManyToOne(fetch = FetchType.LAZY)

연관관계에 있는 user_id가 있더라도 board.getUser().getUsername(); 으로 찾을 때만 조회(select)한다. board.getUser().getId(); 로 조회하면 select 쿼리를 하지 않는다 이미 user_id 있기 때문
board_tb 를 쿼리
notion image
user_tb 를 쿼리
notion image
더미 데이터
notion image
 

5. 회원 정보 수정

notion image
session에 있는 유저 정보로 input에 기본값으로 넣어준다
em.find(User.class, userId) 로 pk가 있는 데이터 접근해서 user 객체 가져온다
UpdateDTO를 만들어 데이터를 받는다

🧔user/update-form

{{> layout/header}} <div class="container p-5"> <div class="card"> <div class="card-header"><b>회원수정을 해주세요</b></div> <div class="card-body"> <form action="/user/update" method="post" enctype="application/x-www-form-urlencoded"> <div class="mb-3"> <input value="{{sessionUser.username}}" type="text" class="form-control" placeholder="Enter username" disabled> </div> <div class="mb-3"> <input value="{{sessionUser.password}}" type="password" class="form-control" placeholder="Enter password" name="password"> </div> <div class="mb-3"> <input value="{{sessionUser.email}}" type="email" class="form-control" placeholder="Enter email" name="email"> </div> <button type="submit" class="btn btn-primary form-control">회원가입수정</button> </form> </div> </div> </div> {{> layout/footer}}

☕UserRequest

@Data public static class UpdateDTO { private String password; private String email; }
수정할 때 필요한 데이터만 받아오는 DTO

☕UserController

@GetMapping("/user/update-form") public String updateForm() { User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증이 필요합니다"); return "user/update-form"; // view resolver -> prefix 로 templates/가 되어 있다. subfix 로 .mustache가 되어 있다 } @PostMapping("/user/update") public String update(UserRequest.UpdateDTO updateDTO) { User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증이 필요합니다"); User user = userService.회원정보수정(updateDTO, sessionUser.getId()); // 세션 동기화 session.setAttribute("sessionUser", user); return "redirect:/"; }
유저정보를 수정한 뒤 세션유저 정보도 동기화 시켜야 한다

☕User

// 회원정보 수정 setter public void update(String password, String email) { this.password = password; this.email = email; }
  • setter는 의미 있는 용도여야 한다
  • 유저정보를 수정하는 메서드인 update setter를 만들자

☕UserService

@Transactional public User 회원정보수정(UserRequest.UpdateDTO updateDTO, Integer userId) { User user = userRepository.findByUserId(userId); if (user == null) throw new RuntimeException("회원을 찾을 수 없습니다"); user.update(updateDTO.getPassword(), updateDTO.getEmail()); // 영속화 된 객체(db에서 조회한 것)의 상태변경 return user; } // 더티체킹 -> 상태가 변경되면 update를 날린다
  • select로 조회한 데이터는 PC에 영속화 되어 있다
  • 이 영속화 된 객체를 수정하면 PC가 자동으로 변경 사항을 update한다
    • notion image
  • 더티체킹 → 할 일을 바로 처리하지 않고 몇 개 모아뒀다가 같은 작업이 어느 정도 쌓이면 한번에 처리한다(게으르게 일을 처리한다)

☕UserRepository

public User findByUserId(Integer userId) { return em.find(User.class, userId); }
  • pk 키로 user객체를 select 하고 그 객체를 반환 한다
  • 이때 그 객체는 PC에 영속화 된 객체다

6. 상세 보기

notion image
board_tbuser_tbjoin해서 데이터를 가져와 뿌린다
DetailDTO 를 만들어 mustache에 뿌린다
로그인 사용자와 board의 작성자가 같은 사람이면 수정, 삭제 버튼이 보인다

🧔board/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}}

☕BoardController

@GetMapping("/board/{id}") public String board(@PathVariable 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"; }
  • 로그인 사용자의 요청이면 sessionUserId를 전달한다
  • 비로그인 사용자의 요청이면 null을 전달한다

☕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; 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 = board.getUser().getId() == sessionUserId; this.username = board.getUser().getUsername(); this.createdAt = board.getCreatedAt(); } } }
detail 페이지에 보낼 데이터를 만든다

☕BoardService

public BoardResponse.DetailDTO 상세보기(Integer id, Integer sessionUserId) { Board board = boardRepository.findByIdWithUser(id); BoardResponse.DetailDTO detailDTO = new BoardResponse.DetailDTO(board, sessionUserId); return detailDTO; }
board데이터를 가져와서 detail페이지에 보여줄 데이터를 추가한 DTO를 만들어 전달한다

☕BoardRepository

public Board findById(Integer id) { return em.find(Board.class, id); // em.find()는 PC에 있는 캐싱된 데이터를 먼저 찾는다 } // inner join -> join // on b.user.id = u.id -> 생략 가능 // left outer join -> left join // fk자리에는 Board에 있는 User객체를 넣어줘야 한다 // fetch 를 작성해야 b 라고만 적었을 때 user 정보도 같이 프로젝션해서 보여준다 public Board findByIdWithUser(Integer id) { Query query = em.createQuery("select b from Board b join fetch b.user u where b.id = :id", Board.class); query.setParameter("id", id); return (Board) query.getSingleResult(); }
  • inner join -> join 축약 작성 가능
  • on b.user.id = u.id -> 생략 가능
  • left outer join -> left join 축약 작성 가능
  • fk자리에는 Board에 있는 User객체를 넣어줘야 한다
  • fetch 를 작성해야 b 라고만 적었을 때 user 정보도 같이 프로젝션해서 보여준다
  • Board 객체에 User객체를 넣어서 결과를 받는다

☕BoardRepositoryTest

@Test public void findByIdWithUser_test() { //given Integer boardId = 1; // when Board board = boardRepository.findByIdWithUser(boardId); // eye System.out.println(board); }
notion image

select b from Board b join fetch b.user u where b.id = :id

fetch를 작성하면 user 정보도 같이 프로젝션 해서 보여준다
notion image

select b from Board b join b.user u where b.id = :id

fetch가 없으면 board정보만 보여준다
notion image

select b from Board b join User where b.id = :id

board객체 안의 user가 아닌 User 객체를 넣으면
notion image
엔티티 조인이 조인 조건을 지정하지 않았습니다 [SqmEntityJoin(shop.mtcoding.blog.user(u))] ( 조인 조건을 '켜짐'으로 지정하거나 '교차 조인'을 사용)

7. 좋아요(ajax)

notion image

BoardResponseDTO 디자인

notion image
User 와 Board의 N vs N 관계인 Love 동사 테이블 생성
detail 페이지에 보여줄 ResponseDTO 를 만들어야 한다

좋아요 유무 & 좋아요 숫자를 표기

data.sql

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());

🧔board/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 = {{model.isLove}}; function likeToggle() { let icon = document.querySelector("#likeIcon"); liked = !liked; if (liked) { icon.style.color = "red"; } else { icon.style.color = "black"; } } </script> {{> layout/footer}}

☕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; } }
notion image

☕LoveRepository

package shop.mtcoding.blog.love; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.util.List; @Repository @RequiredArgsConstructor 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(); } catch (Exception e) { return null; } } public List<Love> findByBoardId(Integer boardId) { Query query = em.createQuery("select lo from Love lo where lo.board.id = :boardId", Love.class); query.setParameter("boardId", boardId); return query.getResultList(); } }

☕LoveRepositoryTest

@Test public void findByIdWithUser_test() { //given Integer boardId = 1; // when Board board = boardRepository.findByIdWithUser(boardId); // eye System.out.println(board); }
notion image

select b from Board b join fetch b.user u where b.id = :id

fetch를 작성하면 user 정보도 같이 프로젝션 해서 보여준다
notion image

select b from Board b join b.user u where b.id = :id

fetch가 없으면 board정보만 보여준다
notion image

select b from Board b join User where b.id = :id

board객체 안의 user가 아닌 User 객체를 넣으면
notion image
엔티티 조인이 조인 조건을 지정하지 않았습니다 [SqmEntityJoin(shop.mtcoding.blog.user(u))] ( 조인 조건을 '켜짐'으로 지정하거나 '교차 조인'을 사용)

☕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; private String username; private Timestamp createdAt; private Boolean isLove; private Integer loveCount; public DetailDTO(Board board, Integer sessionUserId, Boolean isLove, Integer loveCount) { this.id = board.getId(); this.title = board.getTitle(); this.content = board.getContent(); this.isPublic = board.getIsPublic(); this.isOwner = board.getUser().getId() == sessionUserId; this.username = board.getUser().getUsername(); this.createdAt = board.getCreatedAt(); this.isLove = isLove; this.loveCount = loveCount; } } }
필드 값 추가
  • isLove
  • loveCount

☕BoardService

public BoardResponse.DetailDTO 상세보기(Integer id, Integer sessionUserId) { Board board = boardRepository.findByIdJoinUser(id); Love love = loveRepository.findByUserIdAndBoardId(sessionUserId, id); List<Love> loves = loveRepository.findByBoardId(id); Boolean isLove = love == null ? false : true; Integer loveCount = loves.size(); BoardResponse.DetailDTO detailDTO = new BoardResponse.DetailDTO(board, sessionUserId, isLove, loveCount); return detailDTO; }
board데이터를 가져와서 detail페이지에 보여줄 데이터를 추가한 DTO를 만들어 전달한다

☕BoardRepository(한방 쿼리)

이 ⬇️ DTO를 보고 만들면 됨

☕BoardResponse

package shop.mtcoding.blog.board; import lombok.Data; import java.sql.Timestamp; public class BoardResponse { // 상세보기 화면에 필요한 데이터 @Data @AllArgsConstructor public static class DetailDTO { private Integer id; private String title; private String content; private Boolean isPublic; private Boolean isOwner; private String username; private Timestamp createdAt; private Boolean isLove; private Integer loveCount; } }
필드 값 추가
  • isLove
  • loveCount
Native Query
SELECT bt.id, bt.title, bt.content, bt.is_public, CASE WHEN bt.user_id = 1 THEN true ELSE false END AS is_owner, ut.username, bt.created_at, CASE WHEN MAX(CASE WHEN lt.user_id = 1 THEN 1 ELSE 0 END) = 1 THEN true ELSE false END AS is_love, COUNT(lt.id) AS love_count FROM board_tb bt INNER JOIN user_tb ut ON bt.user_id = ut.id LEFT OUTER JOIN love_tb lt ON bt.id = lt.board_id WHERE bt.id = 4 GROUP BY bt.id;
JPQL Query
public BoardResponse.DetailDTO findDetail(Integer id, Integer sessionUserId) { Query query = em.createQuery(""" 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, b.user.username, b.createdAt, CASE WHEN MAX(CASE WHEN l.user.id = :userId THEN 1 ELSE 0 END) = 1 THEN true ELSE false END, COUNT(l.id) ) FROM Board b LEFT JOIN Love l on b.id = l.board.id WHERE b.id = :boardId GROUP BY b.id, b.title, b.content, b.isPublic, b.user.id, b.user.username, b.createdAt """); query.setParameter("boardId", id); query.setParameter("userId", sessionUserId); return (BoardResponse.DetailDTO) query.getSingleResult(); }
  • count로 가져오는 type은 Long타입이다
  • SELECT new shop.mtcoding.blog.board.BoardResponse$DetailDTO → DTO가 있는 풀 주소를 넣어야 한다
    • $ → static 클래스를 찾을 때 사용한다
  • GROUP BY b.id, b.title, b.content, b.isPublic, b.user.id, b.user.username, b.createdAt → ⚠️ JPQL의 특징: GROUP BY 사용 시 SELECT에 있는 모든 비집계 필드는 명시해야 함
notion image

좋아요 버튼을 누르면 Ajax 통신해서 데이터 받아오기

notion image

자바스크립트로 화면에 있는 데이터 사용 하는 방법

  1. 자바스크립트 함수에 인수로 데이터를 전달
    1. notion image
  1. html에 input의 value에 값을 넣고 숨긴 다음 꺼내어 쓴다
    1. notion image
      notion image
  1. element에 data-set으로 값을 주입한다
    1. notion image

🧔board/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> let boardId = document.querySelector("#boardId").value; // setInterval(() => { <- 크론 또는 polling 이라 한다 // location.reload(); // }, 1000); 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(); // 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() -> html or text를 받으면 사용; // 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}}
<input type="hidden" id="boardId" value="{{model.id}}"/> let boardId = document.querySelector("#boardId").value;
위와 같은 방법으로 변하지 않을 데이터를 html 에 주입한다
  • saveLove()boardId 를 가지고 post 요청
    • 응답으로 loveId, loveCount 를 받는다
  • deleteLove(loveId)loveId 를 가지고 delete 요청
    • 응답으로 loveCount 를 받는다
fetch를 하고 json을 받으면 json() 파싱을 하고 html or text 를 받으면 text() 로 파싱한다

☕Resp

package shop.mtcoding.blog._core; import lombok.AllArgsConstructor; import lombok.Data; @AllArgsConstructor @Data // getter가 있어야 @ResponseBody 이 어노테이션으로 json으로 변환해 준다 public class Resp<T> { private Integer status; private String msg; private T body; // 앞부분의 <B> 는 받는 타입을 임시로 Object로 받아라고 하는 문법 public static <B> Resp<?> ok(B body) { return new Resp<>(200, "성공", body); } public static Resp<?> fail(Integer status, String msg) { return new Resp<>(status, msg, null); } }
  • 응답을 일관적으로 하기 위한 클래스
  • 응답하고 싶은 데이터를 body에 넣으면 된다

☕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() .user(User.builder().id(sessionUserId).build()) .board(Board.builder().id(boardId).build()) // board 객체에 id만 넣어서 insert를 해도 자동으로 이 객체의 키값을 외래키로 적용함 .build(); } } }
  • JPA 를 사용해 Love 테이블에 데이터를 insert 하려면 Love 객체를 만들어 주면 된다
  • Love 테이블에는 연관 관계가 있는 User 테이블과 Board 테이블이 있다
  • Love 테이블에 연관 관계가 있는 테이블의 id 를 넣어주면 된다
  • JPA 를 활용하려면 Love 객체에 필드값으로 id 를 넣는게 아닌 객체를 넣어야 한다
  • JPA 는 빈 객체에 id만 있어도 자동으로 fk로 인지하고 작동한다

☕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; @RestController @RequiredArgsConstructor public class LoveController { private final LoveService loveService; private final HttpSession session; @PostMapping("/love") public Resp<?> saveLove(@RequestBody LoveRequest.SaveDTO reqDTO) { // 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); return Resp.ok(respDTO); } }
@RestController → json으로 응답하는 방법
@RequestBody → json으로 데이터를 맴핑하는 방법

☕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; } } }
  • JPA 를 사용해 Love 테이블에 데이터를 insert 하려면 Love 객체를 만들어 주면 된다
  • Love 테이블에는 연관 관계가 있는 User 테이블과 Board 테이블이 있다
  • Love 테이블에 연관 관계가 있는 테이블의 id 를 넣어주면 된다
  • JPA 를 활용하려면 Love 객체에 필드값으로 id 를 넣는게 아닌 객체를 넣어야 한다
  • JPA 는 빈 객체에 id만 있어도 자동으로 fk로 인지하고 작동한다

☕LoveSevice

package shop.mtcoding.blog.love; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class LoveService { private final LoveRepository loveRepository; @Transactional public LoveResponse.SaveDTO 좋아요(LoveRequest.SaveDTO reqDTO, Integer sessionUserId) { Love lovePS = loveRepository.save(reqDTO.toEntity(sessionUserId)); Long loveCount = loveRepository.findByBoardIdCount(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.findByBoardIdCount(boardId); return new LoveResponse.DeleteDTO(loveCount.intValue()); } }
  • 좋아요 → 요청 DTO 와 sessionUserId 를 받아서 save 를 하고
    • 응답으로 Love 데이터의 id 와 loveCount 를 돌려준다
  • 좋아요취소 → 요청으로 Love 의 id 를 받아서 delete 를 하고
    • 응답으로 Love 데이터의 loveCount 를 돌려준다
    • loveCount는 삭제할 Love 데이터를 조회한 뒤 조회된 Love 객체에서 boardId를 꺼내와 조회한다

☕LoveRepository

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); }
  • insert 는 persist() 를 사용해 저장
    • notion image
  • delete 는 createQuery() 를 사용해 삭제
    • notion image
  • select 는 find() 를 사용해 조회
    • notion image

8. 댓글

notion image

댓글 표기

data.sql

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/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> let boardId = document.querySelector("#boardId").value; // setInterval(() => { <- 크론 또는 polling 이라 한다 // location.reload(); // }, 1000); 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(); 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() -> html or text를 받으면 사용; 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}}

☕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; @ManyToOne(fetch = FetchType.LAZY) private User user; @ManyToOne(fetch = FetchType.LAZY) private Board board; private String content; // 댓글 내용 @CreationTimestamp private Timestamp createdAt; @Builder public Reply(Integer id, User user, Board board, String content, Timestamp createdAt) { this.id = id; this.user = user; this.board = board; this.content = content; this.createdAt = createdAt; } }
  • Reply 는 User 와 N vs 1
  • Reply 는 Board 와 N vs 1
  • 따라서 @ManyToOne 을 설정한다
notion image

☕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; @Repository @RequiredArgsConstructor public class ReplyRepository { private final EntityManager em; public List<Reply> findAllByBoardId(Integer 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); return query.getResultList(); } }
Board에 대한 Reply 들을 List 로 받는다

☕BoardService

public BoardResponse.DetailDTO 상세보기(Integer id, Integer sessionUserId) { Board board = boardRepository.findByIdJoinUser(id); // Board 조회 Love love = loveRepository.findByUserIdAndBoardId(sessionUserId, id); // Board에 대한 Love 조회 Long loveCount = loveRepository.findByBoardIdCount(id); // Board에 대한 Love 개수 조회 List<Reply> replies = replyRepository.findAllByBoardId(id); // Board에 대한 Reply 테이블 조회 Boolean isLove = love == null ? false : true; Integer loveId = love == null ? null : love.getId(); BoardResponse.DetailDTO detailDTO = new BoardResponse.DetailDTO(board, sessionUserId, isLove, loveCount.intValue(), loveId, replies); return detailDTO; }
Board 에 대한 Reply 들을 조회한 뒤 DTO에 담는다

☕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; private String username; private Timestamp createdAt; private Boolean isLove; private Integer loveCount; 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 = board.getUser().getId() == sessionUserId; this.username = board.getUser().getUsername(); this.createdAt = board.getCreatedAt(); this.isLove = isLove; this.loveCount = loveCount; this.loveId = loveId; this.replies = replies; } } }
⬆️ 까지의 내용이 댓글을 따로 조회 해서 가져 오는 방법

⬇️ 부터의 내용은 Board 객체에 @OneToMany 를 설정해서 연관 관계 맵핑을 하는 것
쿼리로 한번에 가져올 경우
notion image

@ManyToOne@OneToMany

☕Board

package shop.mtcoding.blog.board; import jakarta.persistence.*; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.ToString; 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; @ToString @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) // 연관관계 설정 -> ORM 하려고 EAGER -> fk에 들어간 오브젝트를 바로 연관관계 맵핑을 해서 select를 여러 번 한다 , LAZY -> 무조건 LAZY를 사용한다. 연관관계 맵핑을 하지 않는다 private User user; @OneToMany(mappedBy = "board", fetch = FetchType.LAZY) // mappedBy -> fk 의 주인인 reply의 필드 이름을 적어야 한다 private List<Reply> replies = new ArrayList<Reply>(); @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; } }
  • @OneToMany → 1 vs N 에서 1이 정하는 방법
    • mappedBy -> fk 의 주인인 reply의 필드 이름을 적어야 한다
    • 이 때 이 필드는 테이블의 컬럼으로 생성되지 않는다
    • 오직 조회를 했을 때 데이터를 담는 용으로 사용된다
    • JPQL 로 조회할 때 객체지향적으로 조회를 할 수 있다
      • em.createQuery("select b from Board b join fetch b.user u left join fetch b.replies r where b.id = :id", Board.class);
    • 사용하는 이유는 one에 해당하는 Entity 객체에 연관 되어 있는 many에 해당하는 데이터를 담기 위해 사용한다 → join 쿼리를 때리면 one에 해당하는 Entity에 알아서 담아주기 때문에 사용하는 것
      • oneToMany를 사용하기 싫으면 DTO에 따로 조회해서 담으면 된다
  • EAGER 전략을 사용하면
    • 바로 연관 관계에 있는 데이터를 한번에 join 해서 가져온다
  • LAZY 전략을 사용하면
    • getter 를 호출 할 때 각각 필요한 데이터를 select 를 실행해 가져온다

board + user + replies 를 join 했을 때

replies 안에 있는 user 정보가 없기 때문에 getter가 호출되면 그 때 user 정보를 조회한다
em.createQuery("select b from Board b join fetch b.user u left join fetch b.replies r where b.id = :id", Board.class);
notion image
notion image

board + user + replies + user 를 join 했을 때

한번에 전부 가져온다
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);
notion image
notion image
⬆️ 위 처럼 단일 데이터가 아니라 복수의 데이터를 Entity에 넣고 싶다면 @oneToMany 를 사용 하면 된다

9. 댓글 등록 & 삭제

🧔board/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 name="boardId" type="hidden" 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 class="btn">🗑</button> </form> {{/isOwner}} </div> {{/model.replies}} </div> </div> </div> <script> let boardId = document.querySelector("#boardId").value; // setInterval(() => { <- 크론 또는 polling 이라 한다 // location.reload(); // }, 1000); 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(); 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() -> html or text를 받으면 사용; 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

☕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.PostMapping; import shop.mtcoding.blog.user.User; @Controller @RequiredArgsConstructor public class ReplyController { private final ReplyService replyService; private final HttpSession session; @PostMapping("/reply/save") public String saveReply(ReplyRequest.SaveDTO reqDTO) { User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증이 필요합니다"); replyService.댓글등록(reqDTO, sessionUser.getId()); return "redirect:/board/" + reqDTO.getBoardId(); } }

☕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(Integer sessionUserId) { return Reply.builder() .content(content) .board(Board.builder().id(boardId).build()) .user(User.builder().id(sessionUserId).build()) .build(); } } }
  • insert 에 사용되는 DTOtoEntity 를 만들어야 한다
  • Reply 객체에 연관 관계(manyToOne)로 있는 User 객체와 Board 객체는 빈 객체에 id 만 넣어 줘도 insert 해준다

☕ReplyService

package shop.mtcoding.blog.reply; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class ReplyService { private final ReplyRepository replyRepository; @Transactional public void 댓글등록(ReplyRequest.SaveDTO reqDTO, Integer sessionUserId) { replyRepository.save(reqDTO.toEntity(sessionUserId)); } }
insert 를 위한 Reply 객체를 만들어 넘겨준다

☕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; @Repository @RequiredArgsConstructor public class ReplyRepository { private final EntityManager em; public Reply save(Reply reply) { em.persist(reply); return reply; } }
persist(reply)id 가 없는 Entity 를 영속화 시키면 자동 insert
notion image

삭제

notion image

☕ReplyController

@PostMapping("/reply/{id}/delete") public String deleteReply(@PathVariable Integer id) { User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증이 필요합니다"); Integer boardId = replyService.댓글삭제(id, sessionUser.getId()); return "redirect:/board/" + boardId; }
  • 삭제할 Reply id 를 받아온다. 주소로. pk이기 때문
  • Service 에서 Board id 를 받아온다. 리다이렉트 해야하기 때문

☕ReplyService

@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; }
  • 삭제할 Reply 가 있는지 조회
  • 삭제할 수 있는 권한 체크
  • 삭제

☕ReplyRepository

public void deleteById(Integer id) { Query query = em.createQuery("delete from Reply r where r.id = :id"); query.setParameter("id", id); query.executeUpdate(); } public Reply findById(Integer id) { return em.find(Reply.class, id); }
  • 삭제는 JPQL로 작성
  • 조회는 JPA 사용
notion image

10. 예외 처리(error controller)

1. 커스텀 예외 클래스 만들기

notion image

☕_core/error/ex/Exception401

package shop.mtcoding.blog._core.error.ex; public class Exception401 extends RuntimeException { public Exception401(String message) { super(message); } }
  • form으로 요청이 왔을 때 사용하는 예외 처리
  • RuntimeException 을 상속받는 커스텀 예외 클래스를 만든다
  • 받은 메시지는 최상위 클래스 Throwalbe 의 필드값 detailMessage 에 저장된다

☕_core/error/ex/ExceptionApi401

package shop.mtcoding.blog._core.error.ex; public class ExceptionApi401 extends RuntimeException { public ExceptionApi401(String message) { super(message); } }
  • ajax로 요청이 왔을 때 사용하는 예외 처리
  • RuntimeException 을 상속받는 커스텀 예외 클래스를 만든다
  • 받은 메시지는 최상위 클래스 Throwalbe 의 필드값 detailMessage 에 저장된다

2. 커스텀 예외 처리 메서드 만들기

  • 400 → 잘못된 요청
  • 401 → 인증 안됨
  • 403 → 권한 없음
  • 404 → 자원 찾을 수 없음

☕_core/error/GlobalExceptionHandler

package shop.mtcoding.blog._core.error; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import shop.mtcoding.blog._core.error.ex.*; import shop.mtcoding.blog._core.util.Resp; @RestControllerAdvice // 모든 exception을 다 받는다. dispatcher servlet이 모든 ex // @RestControllerAdvice -> 데이터 리턴, @ControllerAdvice -> 파일 리턴 public class GlobalExceptionHandler { // 400 -> 잘못된 요청 @ExceptionHandler(Exception400.class) public String ex400(Exception400 e) { String html = """ <script> alert('${msg}'); </script>이 """.replace("${msg}", e.getMessage()); return html; // 브라우저는 html text를 받으면 해석한다 } // 401 -> 인증 안됐을때 @ExceptionHandler(Exception401.class) // catch 부분에서 실행되는 메서드 public String ex401(Exception401 e) { String html = """ <script> alert('${msg}'); location.href = '/login-form'; </script> """.replace("${msg}", e.getMessage()); return html; } // 401 -> 인증 안됐을때 @ExceptionHandler(ExceptionApi401.class) public Resp<?> exApi401(ExceptionApi401 e) { return Resp.fail(401, e.getMessage()); } // 403 -> 권한 없음 @ExceptionHandler(Exception403.class) public String ex403(Exception403 e) { String html = """ <script> alert('${msg}'); </script> """.replace("${msg}", e.getMessage()); return html; } // 403 -> 권한 없음 @ExceptionHandler(ExceptionApi403.class) public Resp<?> exApi403(ExceptionApi403 e) { return Resp.fail(403, e.getMessage()); } // 404 -> 자원 없음 @ExceptionHandler(Exception404.class) public String ex404(Exception404 e) { String html = """ <script> alert('${msg}'); </script> """.replace("${msg}", e.getMessage()); return html; } // 404 -> 자원 없음 @ExceptionHandler(ExceptionApi404.class) public Resp<?> exApi404(ExceptionApi404 e) { return Resp.fail(404, e.getMessage()); } @ExceptionHandler(Exception.class) // 알지 못하는 모든 에러를 처리하는 방법 public String exUnknown(Exception e) { String html = """ <script> alert('${msg}'); history.back(); </script> """.replace("${msg}", "관리자에게 문의해주세요"); System.out.println("관리자님 보세요 : " + e.getMessage()); return html; } }
클래스 위에 다음 어노테이션을 사용해야 예외 처리할 수 있다
애플리케이션 전체에서 발생하는 예외를 한 곳에서 처리할 수 있게 해준다
  • @RestControllerAdvice데이터를 리턴하는 예외 처리 클래스
  • @ControllerAdvice파일을 리턴하는 예외 처리 클래스
동작 방식
  • 스프링의 DispatcherServlet이 예외를 만나면, 우선 해당 예외와 매핑되는 예외 처리 메서드(@ExceptionHandler)를 찾아 호출합니다.
  • @RestControllerAdvice가 붙은 클래스 안에서 정의된 여러 @ExceptionHandler 메서드들 중, 예외의 타입에 맞는 메서드를 자동으로 선택해서 처리하게 됩니다.
@ExceptionHandler(Exception400.class)
  • @ExceptionHandler → 예외 처리 메서드를 찾기 위함
  • (Exception400.class) → 해당 클래스로 예외가 발생하면 이 메서드를 실행함
동작 방식
  • 클라이언트의 요청을 처리하는 도중 Exception400 예외가 발생하면, 스프링은 이 @ExceptionHandler(Exception400.class) 어노테이션이 붙은 메서드를 찾아 호출합니다.
@ExceptionHandler(Exception.class)
  • Exception 이 터트린 예외를 처리함
  • 모든 예외를 처리 할 수 있음

3. 예외 처리 확인 해보기

1. 로그인 실패

비번이 틀렸을 때
notion image

2. 유저정보수정 화면 요청 실패

로그인 하지 않았을 때
notion image

3. 로그인 후 다른 유저의 좋아요 삭제 실패

로그인 성공
notion image
다른 유저의 좋아요 삭제 요청
notion image

4. 로그인 후 다른 유저의 댓글 삭제 실패

다른 유저의 댓글 삭제 요청
notion image

11. 글수정

notion image

🧔board/update-form

{{> layout/header}} <div class="container p-5"> <div class="card"> <div class="card-header"><b>글수정하기 화면입니다</b></div> <div class="card-body"> <form action="/board/{{model.id}}/update" method="post"> <div class="mb-3"> <input type="text" class="form-control" placeholder="Enter title" name="title" value="{{model.title}}"> </div> <div class="mb-3"> <textarea class="form-control" rows="5" name="content">{{model.content}}</textarea> </div> <!-- ✅ 공개 여부 체크박스 --> <div class="form-check mb-3"> <input id="isPublic" class="form-check-input" type="checkbox" {{#model.isPublic}}checked{{/model.isPublic}} name="isPublic"> <label class="form-check-label" for="isPublic"> 공개 글로 작성하기 </label> </div> <button class="btn btn-primary form-control">글수정하기완료</button> </form> </div> </div> </div> {{> layout/footer}}

☕BoardController

@GetMapping("/board/{id}/update-form") public String updateForm(@PathVariable("id") Integer id, HttpServletRequest request) { User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new Exception401("인증이 필요합니다"); Board board = boardService.수정상세보기(id, sessionUser.getId()); request.setAttribute("model", board); return "board/update-form"; } @PostMapping("/board/{id}/update") public String update(@PathVariable("id") Integer id, BoardRequest.UpdateDTO reqDTO) { User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new Exception401("인증이 필요합니다"); boardService.글수정(id, reqDTO, sessionUser.getId()); return "redirect:/board/" + id; }

☕BoardRequest

@Data public static class UpdateDTO { private String title; private String content; private String isPublic; }
  • input checkbox는 value 값이 “on” or null 이다
  • 따라서 isPublic 은 문자열 “on” 아니면 null 값을 받는다

☕BoardService

public Board 수정상세보기(Integer id, Integer sessionUserId) { Board boardPS = boardRepository.findById(id); if (boardPS == null) throw new Exception404("해당 게시글이 없습니다"); if (!(boardPS.getUser().getId().equals(sessionUserId))) throw new Exception403("권한이 없습니다"); return boardPS; } // TODO @Transactional public void 글수정(Integer id, BoardRequest.UpdateDTO updateDTO, Integer sessionUserId) { Board boardPS = boardRepository.findById(id); if (boardPS == null) throw new Exception404("해당 게시글이 없습니다"); if (!(boardPS.getUser().getId().equals(sessionUserId))) throw new Exception403("권한이 없습니다"); boardPS.update(updateDTO.getTitle(), updateDTO.getContent(), updateDTO.getIsPublic()); } // PS객체를 수정하는 방법 -> 더티체킹
영속화된 객체 boardPS를 수정하고 트랜잭션을 종료하면 자동 update 쿼리가 실행됨

☕Board

// 게시글 수정 setter public void update(String title, String content, String isPublic) { this.title = title; this.content = content; this.isPublic = "on".equals(isPublic); }
  • Board 엔티티에 update setter 추가
  • isPublic이 “on”이면 공개글이라 체크한 것 아니면 null 이기 때문에 false

☕BoardRepository

public Board findById(Integer id) { return em.find(Board.class, id); // em.find()는 PC에 있는 캐싱된 데이터를 먼저 찾는다 }
em.find()는 PC에 있는 캐싱된 데이터를 먼저 찾는다

12. 인터셉터

notion image
notion image
dispatcher 와 같은 공간에 있는 컴포넌트다
기능은
  • dispatcher의 invoke() 앞, 뒤에서 실행할 수 있다
  • invoke() 앞에서 실행되면 컨트롤러의 메서드를 호출 하기 전 어떠한 기능을 실행 할 수 있다는 것
  • invoke() 뒤에서 실행되면 컨트롤러의 응답이 끝난 뒤에 어떠한 기능을 실행 할 수 있다는 것
  • 인터셉터에서 예외를 터트리면 dispatcher가 그 에러를 잡아서 처리 할 수 있다

☕_core/config/WebMvcConfig

package shop.mtcoding.blog._core.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import shop.mtcoding.blog._core.interceptor.LoginInterceptor; @Configuration // Ioc 컨테이너에 설정파일을 등록 public class WebMvcConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/user/**") .addPathPatterns("/board/**") .excludePathPatterns("/board/{id:\\d+}") .addPathPatterns("/love/**") .addPathPatterns("/reply/**") .addPathPatterns("/api/**"); } }
  • WebMvcConfigurer → 스프링 MVC를 확장/커스터마이징할 수 있는 인터페이스(설정 클래스)
  • @Configuration → Ioc 컨테이너에 등록 하는 방법
  • addInterceptors → 스프링에 인터셉터를 등록할 때 사용하는 메서드
  • InterceptorRegistry registry → 인터셉터 등록 도구
  • addPathPatterns → 해당 주소로 요청이 들어오면 인터셉트 처리를 한다
  • excludePathPatterns → 해동 주소는 인터셉트 처리를 제외한다

☕_core/interceptor/LoginInterceptor

package shop.mtcoding.blog._core.interceptor; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import org.springframework.web.servlet.HandlerInterceptor; import shop.mtcoding.blog._core.error.ex.Exception401; import shop.mtcoding.blog._core.error.ex.ExceptionApi401; import shop.mtcoding.blog.user.User; public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String uri = request.getRequestURI(); System.out.println("uri: " + uri); HttpSession session = request.getSession(); User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) { if (uri.contains("/api")) { throw new ExceptionApi401("인증이 필요합니다"); // response.setStatus(401); // response.setHeader("Content-Type", "application/json"); // PrintWriter out = response.getWriter(); // Resp<?> resp = Resp.fail(401, "인증이 필요합니다"); // ObjectMapper mapper = new ObjectMapper(); // String responseBody = mapper.writeValueAsString(resp); // out.println(responseBody); // return false; } else { throw new Exception401("인증이 필요합니다"); // response.setStatus(401); // response.setHeader("Content-Type", "text/html"); // PrintWriter out = response.getWriter(); // out.println(Script.href("인증이 필요합니다", "/login-form")); // return false; } } return true; } }
  • HandlerInterceptor → 컨트롤러가 실행되기 전/후/완료 후에 동작할 수 있는 인터셉터(Interceptor) 기능을 제공하는 인터페이스
  • preHandle → invoke() 앞에서 처리하는 핸들러
  • return true → true 리턴하면 invoke()가 실행 된다. false 면 요청 처리 바로 종료

13. 글삭제(예정)

notion image

🧔board/update-form

{{> layout/header}} <div class="container p-5"> <div class="card"> <div class="card-header"><b>글수정하기 화면입니다</b></div> <div class="card-body"> <form action="/board/{{model.id}}/update" method="post"> <div class="mb-3"> <input type="text" class="form-control" placeholder="Enter title" name="title" value="{{model.title}}"> </div> <div class="mb-3"> <textarea class="form-control" rows="5" name="content">{{model.content}}</textarea> </div> <!-- ✅ 공개 여부 체크박스 --> <div class="form-check mb-3"> <input id="isPublic" class="form-check-input" type="checkbox" {{#model.isPublic}}checked{{/model.isPublic}} name="isPublic"> <label class="form-check-label" for="isPublic"> 공개 글로 작성하기 </label> </div> <button class="btn btn-primary form-control">글수정하기완료</button> </form> </div> </div> </div> {{> layout/footer}}

☕BoardController

@GetMapping("/board/{id}/update-form") public String updateForm(@PathVariable("id") Integer id, HttpServletRequest request) { User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new Exception401("인증이 필요합니다"); Board board = boardService.수정상세보기(id, sessionUser.getId()); request.setAttribute("model", board); return "board/update-form"; } @PostMapping("/board/{id}/update") public String update(@PathVariable("id") Integer id, BoardRequest.UpdateDTO reqDTO) { User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new Exception401("인증이 필요합니다"); boardService.글수정(id, reqDTO, sessionUser.getId()); return "redirect:/board/" + id; }

☕BoardRequest

@Data public static class UpdateDTO { private String title; private String content; private String isPublic; }
  • input checkbox는 value 값이 “on” or null 이다
  • 따라서 isPublic 은 문자열 “on” 아니면 null 값을 받는다

☕BoardService

public Board 수정상세보기(Integer id, Integer sessionUserId) { Board boardPS = boardRepository.findById(id); if (boardPS == null) throw new Exception404("해당 게시글이 없습니다"); if (!(boardPS.getUser().getId().equals(sessionUserId))) throw new Exception403("권한이 없습니다"); return boardPS; } // TODO @Transactional public void 글수정(Integer id, BoardRequest.UpdateDTO updateDTO, Integer sessionUserId) { Board boardPS = boardRepository.findById(id); if (boardPS == null) throw new Exception404("해당 게시글이 없습니다"); if (!(boardPS.getUser().getId().equals(sessionUserId))) throw new Exception403("권한이 없습니다"); boardPS.update(updateDTO.getTitle(), updateDTO.getContent(), updateDTO.getIsPublic()); } // PS객체를 수정하는 방법 -> 더티체킹
영속화된 객체 boardPS를 수정하고 트랜잭션을 종료하면 자동 update 쿼리가 실행됨

☕Board

// 게시글 수정 setter public void update(String title, String content, String isPublic) { this.title = title; this.content = content; this.isPublic = "on".equals(isPublic); }
  • Board 엔티티에 update setter 추가
  • isPublic이 “on”이면 공개글이라 체크한 것 아니면 null 이기 때문에 false

☕BoardRepository

public Board findById(Integer id) { return em.find(Board.class, id); // em.find()는 PC에 있는 캐싱된 데이터를 먼저 찾는다 }
em.find()는 PC에 있는 캐싱된 데이터를 먼저 찾는다

 
 
Share article

jjack1