이번 글에서는 JPA를 활용해서 포스트 관리 REST API 개발 과정입니다.
게시글 작성(Create), 게시글 조회(Read), 게시글 수정(Update), 게시글 삭제(Delete)
엔티티 클래스 작성
package com.demo.domain;
import java.util.Date;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Temporal;
import jakarta.persistence.TemporalType;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@Entity
@Builder
@ToString
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@DynamicInsert
@DynamicUpdate
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id; // 고유번호
private String title; // 제목
private String content; // 내용
@Temporal(TemporalType.TIMESTAMP)
private Date createDate; // 생성날짜
@ManyToOne
@JoinColumn(name = "member_id")
private Member member;
}
- @GeneratedValue(strategy = GenerationType.IDENTITY) : 기본키의 값을 자동으로 생성.
- strategy = GenerationType.IDENTITY : 자동 증가
- IDENTITY로 사용하면 JPA가 엔티티를 저장할 때, 데이터베이스에 INSERT문을 실행한 후 생성된 키 값을 가져옴.
- strategy = GenerationType.IDENTITY : 자동 증가
- @ManyToOne : 다대일 관계 설정. 여러개의 포스트가 하나의 회원에 속할 수 있음을 의미.
- @JoinColumn(name = "member_id") : 외래 키 설정.
- Post 테이블의 member_id 값은 Member 테이블의 id 값을 참조.
- JPA에서 연관 매핑(관계 매핑)은 객체 간의 관계를 데이터베이스의 테이블 관계와 연결.
- 대표 JPA 어노테이션
- OneToOne : 1 : 1관계
- 하나의 엔티티가 다른 하나의 엔티티와 1 : 1관계를 가질 때
- @JoinColumn으로 외래키 설정
- @ManyToOne : N : 1관계
- 여러 개의 엔티티가 하나의 엔티티와 관계를 가질 때
- @OneeToMany : 1 : N 관계
- 하나의 엔티티가 여러 개의 엔티티와 관계를 가질 때
- 반드시 mappedBy를 사용해서 외래키를 가진 쪽을 지정해야 함
- 단방향보다 양방향 매핑을 위해 사용
- @ManyToMany : N : M 관계
- 두 엔티티가 서로 다대다 관계를 가질 때
- 다대다 관계를 직접 설정하면 중간 테이블을 자동으로 생성해 관리
- 실무에서는 중간 테이블을 직접 생성해서 @manyToOne + @OneToMany로 풀어쓰는 것이 일반적
- OneToOne : 1 : 1관계
어노테이션 | 관계 | 설명 |
@OneToOne | 1 : 1 | 하나의 엔티티가 다른 하나의 엔티티와 1 : 1 관계 |
@ManyToOne | N : 1 | 여러 개의 엔티티가 하나의 엔티티를 참조(외래키가 있는 쪽) |
@OneToMany | 1 : N | 하나의 엔티티가 여러 개의 엔티티를 가짐(mappedBy 사용 필수) |
@ManyToMany | N : M | 두 엔티티가 다대다 관계를 가짐 |
Repository 생성
package com.demo.repository;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import com.demo.domain.Post;
public interface PostRepository extends JpaRepository<Post, Integer> {
// 특정 게시글 조회
Optional<Post> findById(int id);
// 회원 ID(String)로 게시글 조회
List<Post> findByMemberId(String member);
// 게시글 삭제
void deleteById(Integer id);
}
Service 생성
package com.demo.service;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.demo.domain.Post;
import com.demo.repository.PostRepository;
import jakarta.transaction.Transactional;
@Service
public class PostService {
@Autowired
private PostRepository postRepo;
// 게시글 작성
public Post createPost(Post post) {
post.setCreateDate(new Date());
return postRepo.save(post);
}
// 게시글 수정
@Transactional
public Post updatePost(int id, Post post) {
Post updatePost = postRepo.findById(id)
.orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. ID: " + id));
updatePost.setTitle(post.getTitle());
updatePost.setContent(post.getContent());
return postRepo.save(updatePost);
}
// 게시글 전체 조회
public List<Post> getAllPost() {
return postRepo.findAll();
}
// 특정 게시글 조회
public Optional<Post> getPostById(int id) {
return postRepo.findById(id);
}
// ID로 게시글 조회
public List<Post> getPostByMemberId(String id) {
return postRepo.findByMemberId(id);
}
// 게시글 삭제
public void deletePost(int id) {
postRepo.deleteById(id);
}
}
- @Transactional : 자동으로 변경 사항 반영
- setTitle()과 setContent()를 호출하면 JPA가 변경 사항을 감지하여 자동으로 업데이트(UPDATE) 수행.
- postRepo.save(updatePost);를 호출하지 않아도 트랜잭션이 끝날 때 자동 반영됨.
- @Transactional 없으면 postRepo.save(updatePost);를 반드시 호출해야 변경 사항이 반영됨.
- 트랜잭션(Transactional)은 하나의 작업 단위를 의미하고, 모든 작업이 성공하면 반영(commit), 하나라도 실패하면 되돌리기(rollback)
- 특징
- 원자성(Atomicity) : 모두 성공하거나, 전부 취소되야 한다.
- 일관성(Consistency) : 데이터가 항상 유효 상태를 유지해야 한다.
- 격리성(Isolation) : 여러 트랜잭션이 동시에 실행될 때 서로 영향을 주지 않아야 한다.
- 지속성(Durability) : 트랜잭션이 완료되면 데이터가 영구적으로 저장된다.
- 동작 방식
- 메서드 실행 전에 트랜잭션 시작 > 메서드가 정상 종료되면 commit(변경 사항 저장) > 예외 발생 시 rollback(데이터 변경 취소)
- @Transactional의 다양한 옵션
옵션 | 설명 |
@Transactional(readOnly = true) | 조회 전용 트랜잭션 (SELECT 쿼리 성능 최적화) |
@Transactional(rollbackFor = Exception.class) | 특정 예외가 발생할 때만 롤백 |
@Transactional(noRollbackFor = RuntimeException.class) | 특정 예외는 록백하지 않음 |
@Transactional(propagation = Propagation.REQUIRED) | 기본값, 기존 트랜잭션이 있으면 합쳐서 실행 |
@Transactional(propagation = Propagation.REQUIRED_NEW) | 무조건 새로운 트랜잭션 시작 |
- 트랜잭션은 언제?
- 데이터 추가, 수정, 삭제(INSERT, UPDATE, DELETE) 작업을 할 때
- 여러 개의 데이터 변경이 함께 실행될 때(롤백 필요)
- 비즈니스 로직이 중요한 경우(회원 포인트, 결제시스템 등)
Controller 생성
package com.demo.controller;
import java.util.List;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.DeleteMapping;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.SessionAttributes;
import com.demo.domain.Member;
import com.demo.domain.Post;
import com.demo.service.PostService;
import jakarta.servlet.http.HttpSession;
@Controller
@RequestMapping("/post")
@SessionAttributes("loginUser")
public class PostController {
@Autowired
private PostService postService;
// 게시글 메인
@GetMapping("/postmain")
public String postMain(Model model) {
// 게시글 조회
List<Post> posts = postService.getAllPost();
model.addAttribute("posts", posts);
return "post/postmain";
}
// 게시글 작성
@GetMapping("/insertpost")
public String insertPostPage() {
return "post/insertpost"; // insertpost.html을 반환
}
@PostMapping("/insert")
public String insertPost(@RequestBody Post post, HttpSession session) {
// 세션에서 로그인한 회원 정보 가져오기
Member loginMember = (Member) session.getAttribute("loginUser");
// 작성자 정보 설정
post.setMember(loginMember);
postService.createPost(post);
return "redirect:/post/postmain"; // 게시글 메인페이지 리다이렉트
}
// 게시글 상세 페이지
@GetMapping("/{id}")
public String postDetail(@PathVariable int id, Model model) {
// 특정 게시글 조회
Optional<Post> post = postService.getPostById(id);
model.addAttribute("post", post.get());
return "post/postdetail"; // 게시글 상세 페이지
}
// 게시글 수정
@GetMapping("/update/{id}")
public String updatePostView(@PathVariable int id, Model model) {
// 특정 게시글 조회
Optional<Post> post = postService.getPostById(id);
model.addAttribute("post", post.get());
return "post/postupdate";
}
@PutMapping("/{id}")
public ResponseEntity<Post> updatePost(@PathVariable int id, @RequestBody Post post) {
return ResponseEntity.ok(postService.updatePost(id, post));
}
// 게시글 삭제
@DeleteMapping("/{id}")
public ResponseEntity<String> deletePost(@PathVariable int id) {
postService.deletePost(id);
return ResponseEntity.ok("게시글 삭제 완료");
}
}
HTML / js
(main.html에 버튼 추가)
(main.js에 함수 추가)
postmain.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sweetalert2@11.4.10/dist/sweetalert2.min.css">
<link rel="stylesheet" th:href="@{/css/post.css}">
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11.4.10/dist/sweetalert2.min.js"></script>
<script th:src="@{/js/post.js}"></script>
</head>
<body>
<!-- 게시글 메인 -->
<div class="button-container">
<button id="post_insert_btn">게시글 작성</button>
<button id="my_post">내 게시글</button>
</div>
<!-- 전체 게시글 -->
<div class="post-container">
<h2>게시글 목록</h2>
<!-- 게시글이 있을 경우 -->
<div th:if="${not #lists.isEmpty(posts)}">
<div th:each="post : ${posts}" class="post">
<p class="post-title">
<span>제목 : </span><a th:href="@{/post/{id}(id=${post.id})}" th:text="${post.title}"></a>
</p>
<p class="post-content" th:text="${post.content}"></p>
<p class="post-date"
th:text="${#dates.format(post.createDate, 'yyyy-MM-dd HH:mm')}"></p>
</div>
</div>
<!-- 게시글이 없을 경우 -->
<div th:if="${#lists.isEmpty(posts)}" class="no-posts">
<p>작성된 게시글이 없습니다.</p>
</div>
</div>
</body>
</html>
게시글 작성 클릭
// 게시글 작성 버튼
$('#post_insert_btn').click(function() {
swal.fire({
icon: 'info',
title: '게시글 작성',
text: '게시글 작성 페이지로 이동합니다.',
confirmButtonText: '확인',
}).then(() => {
window.location.href = '/post/insertpost';
});
});
insertpost.html : 게시글 작성
<!DOCTYPE html>
<html xmlns:th="http://www.thymleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sweetalert2@11.4.10/dist/sweetalert2.min.css">
<link rel="stylesheet" th:href="@{/css/post.css}">
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11.4.10/dist/sweetalert2.min.js"></script>
<script th:src="@{/js/post.js}"></script>
</head>
<body>
<div>
<!-- 포스트 등록 폼 -->
<form id="insert_post_form" method="post">
<input type="text" id="title" placeholder="제목을 입력하세요.">
<textarea rows="5" id="content"></textarea>
</form>
<button id="insert_post">포스트 등록</button>
</div>
</body>
</html>
// 게시글 등록 버튼
$("#insert_post").click(function() {
let title = $("#title").val().trim();
let content = $("#content").val().trim();
if (!title || !content) {
swal.fire({
icon: 'warning',
title: '입력 오류',
text: '제목과 내용을 입력하세요!',
confirmButtonText: '확인'
});
return;
}
var formData = {
title: title,
content: content
};
$.ajax({
type: "POST",
url: "/post/insert",
contentType: "application/json", // JSON 데이터로 전송
data: JSON.stringify(formData), // 데이터를 JSON 문자열로 변환
success: function(response) {
swal.fire({
icon: 'success',
title: '등록 완료',
text: '게시글이 성공적으로 등록되었습니다.',
confirmButtonText: '확인'
}).then(() => {
window.location.href = "/post/postmain"; // 게시글 메인 페이지 이동
});
},
error: function(xhr) {
let errorMessage = xhr.responseText || '게시글 등록 중 오류가 발생했습니다.';
swal.fire({
icon: 'error',
title: '등록 실패',
text: errorMessage,
confirmButtonText: '확인'
});
}
});
});
mypost.html : 내 게시글
// 내 게시글 버튼
$("#my_post").click(function() {
swal.fire({
title: '이동',
text: '내 게시글 페이지로 이동합니다.',
icon: 'info',
confirmButtonText: '확인',
}).then(() => {
window.location.href = '/post/mypost';
});
});
<!DOCTYPE html>
<html xmlns:th="http://www.thymleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sweetalert2@11.4.10/dist/sweetalert2.min.css">
<link rel="stylesheet" th:href="@{/css/post.css}">
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11.4.10/dist/sweetalert2.min.js"></script>
<script th:src="@{/js/post.js}"></script>
</head>
<body>
<div class="mypost">
<h2 class="mypost-title">내 게시글</h2>
<!-- 내가 작성한 게시글이 있을 경우 -->
<table th:if="${not #lists.isEmpty(myPosts)}" class="mypost-table">
<thead>
<tr>
<th>No</th>
<th>제목</th>
<th>내용</th>
<th>작성 날짜</th>
<th>관리</th>
</tr>
</thead>
<tbody>
<tr th:each="post, iterStat : ${myPosts}">
<td th:text="${iterStat.count}"></td>
<td><a th:href="@{/post/{id}(id=${post.id})}"
th:text="${post.title}" class="mypost-link"></a></td>
<td th:text="${post.content}"></td>
<td th:text="${#dates.format(post.createDate, 'yyyy-MM-dd HH:mm')}"></td>
<td>
<button class="mypost-edit-btn"
th:onclick="|location.href='@{/post/edit/{id}(id=${post.id})}'|">수정</button>
<button class="mypost-delete-btn"
th:onclick="|location.href='@{/post/delete/{id}(id=${post.id})}'|">삭제</button>
</td>
</tr>
</tbody>
</table>
<!-- 내가 작성한 게시글이 없을 경우 -->
<div th:if="${#lists.isEmpty(myPosts)}" class="mypost-no-posts">
<p>작성한 게시글이 없습니다.</p>
</div>
</div>
</body>
</html>
postupdate.html : 수정 페이지
postdetail.html : 상세페이지
<!DOCTYPE html>
<html xmlns:th="http://www.thymleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sweetalert2@11.4.10/dist/sweetalert2.min.css">
<link rel="stylesheet" th:href="@{/css/post.css}">
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11.4.10/dist/sweetalert2.min.js"></script>
<script th:src="@{/js/post.js}"></script>
</head>
<body>
<div class="post_detail_container">
<h2>게시글 상세보기</h2>
<div class="post_detail">
<input type="hidden" class="post_id" th:value="${post.id}">
<div class="post-title" th:text="${post.title}"></div>
<div class="post-content" th:text="${post.content}"></div>
<div class="post-date"
th:text="'작성일: ' + ${#dates.format(post.createDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
<div class="post_btn_group">
<button class="post_update_btn">게시글 수정</button>
<button class="post_delete_btn">게시글 삭제</button>
</div>
<button id="post_main_btn">게시글 페이지로 이동</button>
</div>
</body>
</html>
수정 및 삭제
// 게시글 수정 버튼
$('.post_update_btn').click(function() {
let postId = $('.post_id').val();
swal.fire({
icon: 'info',
title: '게시글 수정',
text: '수정 페이지로 이동합니다.',
confirmButtonText: '확인',
}).then(() => {
window.location.href = `/post/update/${postId}`;
});
});
// 게시글 수정 완료
$('.post_update_submit').click(function() {
let postId = $('.post_id').val();
let title = $('#title').val();
let content = $('#content').val();
if (!title || !content) {
swal.fire({
icon: 'warning',
title: '입력 오류',
text: '제목과 내용을 입력해주세요!',
confirmButtonText: '확인',
});
return;
}
fetch(`/post/postupdate/${postId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content }),
})
.then((response) => {
if (response.ok) {
swal.fire({
icon: 'success',
title: '수정 완료',
text: '게시글이 수정되었습니다.',
confirmButtonText: '확인',
}).then(() => {
window.location.href = '/post/postmain'; // 게시글 목록으로 이동
});
} else {
swal.fire({
icon: 'error',
title: '수정 실패',
text: '게시글 수정 중 오류가 발생했습니다.',
confirmButtonText: '확인',
});
}
})
.catch((error) => {
swal.fire({
icon: 'error',
title: '오류 발생',
text: '네트워크 오류가 발생했습니다.',
confirmButtonText: '확인',
});
});
});
// 게시글 삭제 버튼
$('.post_delete_btn').click(function() {
let postId = $('.post_id').val();
swal.fire({
icon: 'warning',
title: '정말 삭제하시겠습니까?',
text: '삭제된 게시글은 복구할 수 없습니다.',
showCancelButton: true,
confirmButtonColor: '#d33',
cancelButtonColor: '#3085d6',
confirmButtonText: '삭제',
cancelButtonText: '취소',
}).then((result) => {
if (result.isConfirmed) {
fetch(`/post/postdelete/${postId}`, {
method: 'DELETE',
})
.then((response) => {
if (response.ok) {
swal.fire({
title: '삭제 완료',
text: '게시글이 삭제되었습니다.',
icon: 'success',
confirmButtonText: '확인',
}).then(() => {
window.location.href = '/post/postmain';
});
} else {
swal.fire({
icon: 'error',
title: '삭제 실패',
text: '게시글 삭제 중 오류 발생',
confirmButtonText: '확인',
});
}
})
.catch((error) => {
swal.fire({
icon: 'error',
title: '오류 발생',
text: '네트워크 오류가 발생했습니다.',
confirmButtonText: '확인',
});
});
}
});
});
테스트
게시글 작성 > 수정 > 삭제
수정
삭제
추후 개선할 점:
- DTO를 활용하여 응답 데이터의 구조를 명확히 분리하기
- 예외 처리 기능을 추가하여 보다 안정적인 API 구현하기
- 페이징 기능을 추가하여 대량의 게시글을 효율적으로 조회하기
'SpringBoot 프로젝트' 카테고리의 다른 글
[Spring Boot] JPA로 REST API 개발하기 (3) (0) | 2025.04.05 |
---|---|
[Spring Boot] JPA로 REST API 개발하기 (1) (0) | 2025.03.28 |
Spring Boot - 국립 도서관 Open API를 활용한 도서 검색 구현 (0) | 2024.12.10 |
Spring Boot - 국립 도서관 Open API를 활용한 사서 추천 도서 목록 구현 (0) | 2024.12.10 |
Spring Boot - 관리자 페이지 (5) (0) | 2024.12.07 |