SpringBoot 프로젝트

[Spring Boot] JPA로 REST API 개발하기 (2)

orin602 2025. 4. 3. 16:57

이번 글에서는 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문을 실행한 후 생성된 키 값을 가져옴.
  • @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 하나의 엔티티가 다른 하나의 엔티티와 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 구현하기
  • 페이징 기능을 추가하여 대량의 게시글을 효율적으로 조회하기