Contents
Board테이블 ORM 맛보기Board 엔티티BoardRepositoryBoardRepositoryTest1. 회원 가입2. 로그인3. 글쓰기4. 목록 보기5. 회원 정보 수정6. 상세 보기7. 좋아요(ajax)좋아요 유무 & 좋아요 숫자를 표기좋아요 버튼을 누르면 Ajax 통신해서 데이터 받아오기8. 댓글댓글 표기@ManyToOne과 @OneToMany9. 댓글 등록 & 삭제등록삭제10. 예외 처리(error controller)1. 커스텀 예외 클래스 만들기2. 커스텀 예외 처리 메서드 만들기3. 예외 처리 확인 해보기11. 글수정12. 인터셉터13. 글삭제(예정)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는 직접 맵핑하는 방법
findByIdV2는EntityManager가 연관 관계를 보고 자동 맵핑 하는 방법
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());
}
}1. 회원 가입

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;
}
}
@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();
}
}
}DTO→Entity로 만들어주는 메서드를 추가한다
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으로 만들어 리턴한다
중복체크 쿼리

☕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 터짐

insert 쿼리

2. 로그인

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}}아이디 기억하기 체크하면


아이디 기억하기 체크 해제 하면


🧔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. 글쓰기

로그인 된 유저만 해당 페이지 접근 가능
글쓰기 성공하면 /로 리다이렉트 로그인이 실패하면 그냥 예외 터트리기
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);
}
}
insert 쿼리

4. 목록 보기

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;
}
}
☕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)
@ManyToOne(fetch = FetchType.EAGER)test-2 @ManyToOne(fetch = FetchType.LAZY)
@ManyToOne(fetch = FetchType.LAZY)5. 회원 정보 수정

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에 영속화 되어 있다
- 더티체킹 → 할 일을 바로 처리하지 않고 몇 개 모아뒀다가 같은 작업이 어느 정도 쌓이면 한번에 처리한다(게으르게 일을 처리한다)
☕UserRepository
public User findByUserId(Integer userId) {
return em.find(User.class, userId);
}- pk 키로 user객체를 select 하고 그 객체를 반환 한다
- 이때 그 객체는 PC에 영속화 된 객체다
6. 상세 보기

board_tb와user_tb를join해서 데이터를 가져와 뿌린다
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);
}
select b from Board b join fetch b.user u where b.id = :id
fetch를 작성하면 user 정보도 같이 프로젝션 해서 보여준다

select b from Board b join b.user u where b.id = :id
fetch가 없으면 board정보만 보여준다

select b from Board b join User where b.id = :id
board객체 안의 user가 아닌 User 객체를 넣으면

엔티티 조인이 조인 조건을 지정하지 않았습니다 [SqmEntityJoin(shop.mtcoding.blog.user(u))] ( 조인 조건을 '켜짐'으로 지정하거나 '교차 조인'을 사용)
7. 좋아요(ajax)

BoardResponseDTO 디자인

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;
}
}
☕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);
}
select b from Board b join fetch b.user u where b.id = :id
fetch를 작성하면 user 정보도 같이 프로젝션 해서 보여준다

select b from Board b join b.user u where b.id = :id
fetch가 없으면 board정보만 보여준다

select b from Board b join User where b.id = :id
board객체 안의 user가 아닌 User 객체를 넣으면

엔티티 조인이 조인 조건을 지정하지 않았습니다 [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에 있는 모든 비집계 필드는 명시해야 함

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

자바스크립트로 화면에 있는 데이터 사용 하는 방법
🧔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);
}8. 댓글

댓글 표기
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을 설정한다

☕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 를 설정해서 연관 관계 맵핑을 하는 것@ManyToOne과 @OneToMany
@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);- 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);

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

⬆️ 위 처럼 단일 데이터가 아니라 복수의 데이터를
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}}등록

☕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에 사용되는DTO는toEntity를 만들어야 한다
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
삭제

☕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;
}- 삭제할
Replyid를 받아온다. 주소로. pk이기 때문
Service에서Boardid를 받아온다. 리다이렉트 해야하기 때문
☕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
10. 예외 처리(error controller)
1. 커스텀 예외 클래스 만들기

☕_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. 예외 처리 확인 해보기
11. 글수정

🧔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. 인터셉터

.png%253FspaceId%253Df009cc3b-07a8-4ac8-9adf-3ff59840d1f3%3Ftable%3Dblock%26id%3D1d2b195b-130e-8081-b7ff-c4fcc3403cb7%26cache%3Dv2&w=1920&q=75)
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. 글삭제(예정)

🧔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

























