[SECTION22. JPA를 이용한 CRUD 구현]
| 어노테이션 | 설명 |
|---|---|
@Entity | JPA 엔티티 클래스 선언(테이블과 연결하는 역할) |
@Table(name="...") | 매핑할 테이블 이름 지정 |
@Id | 기본 키(PK) 지정 |
@GeneratedValue | PK 자동 생성 전략 설정 |
@Column | 컬럼 속성 지정 (nullable, length 등) |
@CreatedDate / @LastModifiedDate | 생성일/수정일 자동 관리 |
src/main/java/com/example/demo/
├── DemoApplication.java # 메인 클래스 (@EnableJpaAuditing)
├── entity/
│ └── Post.java # JPA 엔티티
├── repository/
│ └── PostRepository.java # Spring Data JPA 레포지토리
├── service/
│ └── PostService.java # 비즈니스 로직
├── controller/
│ └── PostController.java # REST API 엔드포인트
└── dto/
└── PostDto.java # 요청/응답 DTO
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// DB 드라이버 (H2 인메모리 - 실습용)
runtimeOnly 'com.h2database:h2'
// 실제 운영 환경이라면 MySQL 사용
// runtimeOnly 'com.mysql:mysql-connector-j'
}
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop # 개발용: 앱 시작 시 테이블 생성, 종료 시 삭제
# 운영 환경: validate 또는 none 사용
show-sql: true # SQL 콘솔 출력 (개발용)
properties:
hibernate:
format_sql: true # SQL 포맷팅하여 출력
# H2 콘솔 활성화 (개발용)
h2:
console:
enabled: true
path: /h2-console
server:
port: 8080
// Post.java
package com.example.demo.entity;
import jakarta.persistence.*;
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Entity
@Table(name = "posts")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // AUTO_INCREMENT
private Long id;
@Column(nullable = false, length = 100)
private String title;
@Column(nullable = false, columnDefinition = "TEXT")
private String content;
@Column(nullable = false)
private String author;
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
// 생성 메서드 (정적 팩토리 패턴)
public static Post create(String title, String content, String author) {
Post post = new Post();
post.title = title;
post.content = content;
post.author = author;
return post;
}
// 수정 메서드
public void update(String title, String content) {
this.title = title;
this.content = content;
}
}
// PostRepository.java
package com.example.demo.repository;
import com.example.demo.entity.Post;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface PostRepository extends JpaRepository<Post, Long> {
// 메서드 이름으로 쿼리 자동 생성
List<Post> findByAuthor(String author);
List<Post> findByTitleContaining(String keyword);
// JPQL 직접 작성
@Query("SELECT p FROM Post p WHERE p.title LIKE %:keyword% OR p.content LIKE %:keyword%")
List<Post> searchByKeyword(@Param("keyword") String keyword);
}
JpaRepository<엔티티 타입, PK 타입>을 상속받으면 기본 CRUD 메서드가 자동 제공됩니다.
| 메서드 | 설명 |
|---|---|
save(entity) | 저장 / 수정 |
findById(id) | ID로 단건 조회 → Optional<T> 반환 |
findAll() | 전체 조회 |
deleteById(id) | ID로 삭제 |
count() | 전체 개수 |
existsById(id) | 존재 여부 확인 |
// PostDto.java
package com.example.demo.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.NoArgsConstructor;
public class PostDto {
// 생성 요청 DTO
@Getter
@NoArgsConstructor
public static class CreateRequest {
@NotBlank(message = "제목은 필수입니다.")
@Size(max = 100, message = "제목은 100자 이하여야 합니다.")
private String title;
@NotBlank(message = "내용은 필수입니다.")
private String content;
@NotBlank(message = "작성자는 필수입니다.")
private String author;
}
// 수정 요청 DTO
@Getter
@NoArgsConstructor
public static class UpdateRequest {
@NotBlank(message = "제목은 필수입니다.")
private String title;
@NotBlank(message = "내용은 필수입니다.")
private String content;
}
// 응답 DTO
@Getter
public static class Response {
private Long id;
private String title;
private String content;
private String author;
private String createdAt;
private String updatedAt;
public static Response from(com.example.demo.entity.Post post) {
Response response = new Response();
response.id = post.getId();
response.title = post.getTitle();
response.content = post.getContent();
response.author = post.getAuthor();
response.createdAt = post.getCreatedAt() != null ? post.getCreatedAt().toString() : null;
response.updatedAt = post.getUpdatedAt() != null ? post.getUpdatedAt().toString() : null;
return response;
}
}
}
// PostService.java
package com.example.demo.service;
import com.example.demo.dto.PostDto;
import com.example.demo.entity.Post;
import com.example.demo.repository.PostRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true) // 기본적으로 읽기 전용 트랜잭션
public class PostService {
private final PostRepository postRepository;
// ✅ CREATE
@Transactional // 쓰기 작업은 @Transactional 별도 선언
public PostDto.Response createPost(PostDto.CreateRequest request) {
Post post = Post.create(request.getTitle(), request.getContent(), request.getAuthor());
Post savedPost = postRepository.save(post);
return PostDto.Response.from(savedPost);
}
// ✅ READ - 전체 조회
public List<PostDto.Response> getAllPosts() {
return postRepository.findAll()
.stream()
.map(PostDto.Response::from)
.collect(Collectors.toList());
}
// ✅ READ - 단건 조회
public PostDto.Response getPost(Long id) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다. id: " + id));
return PostDto.Response.from(post);
}
// ✅ UPDATE
@Transactional
public PostDto.Response updatePost(Long id, PostDto.UpdateRequest request) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다. id: " + id));
post.update(request.getTitle(), request.getContent()); // 더티 체킹(Dirty Checking)으로 자동 업데이트
return PostDto.Response.from(post);
}
// ✅ DELETE
@Transactional
public void deletePost(Long id) {
if (!postRepository.existsById(id)) {
throw new RuntimeException("게시글을 찾을 수 없습니다. id: " + id);
}
postRepository.deleteById(id);
}
// ✅ SEARCH
public List<PostDto.Response> searchPosts(String keyword) {
return postRepository.searchByKeyword(keyword)
.stream()
.map(PostDto.Response::from)
.collect(Collectors.toList());
}
}
// PostController.java
package com.example.demo.controller;
import com.example.demo.dto.PostDto;
import com.example.demo.service.PostService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/posts")
@RequiredArgsConstructor
public class PostController {
private final PostService postService;
// POST /api/posts - 게시글 생성
@PostMapping
public ResponseEntity<PostDto.Response> createPost(@Valid @RequestBody PostDto.CreateRequest request) {
PostDto.Response response = postService.createPost(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
// GET /api/posts - 전체 조회
@GetMapping
public ResponseEntity<List<PostDto.Response>> getAllPosts() {
return ResponseEntity.ok(postService.getAllPosts());
}
// GET /api/posts/{id} - 단건 조회
@GetMapping("/{id}")
public ResponseEntity<PostDto.Response> getPost(@PathVariable Long id) {
return ResponseEntity.ok(postService.getPost(id));
}
// PUT /api/posts/{id} - 수정
@PutMapping("/{id}")
public ResponseEntity<PostDto.Response> updatePost(
@PathVariable Long id,
@Valid @RequestBody PostDto.UpdateRequest request) {
return ResponseEntity.ok(postService.updatePost(id, request));
}
// DELETE /api/posts/{id} - 삭제
@DeleteMapping("/{id}")
public ResponseEntity<Void> deletePost(@PathVariable Long id) {
postService.deletePost(id);
return ResponseEntity.noContent().build();
}
// GET /api/posts/search?keyword=xxx - 검색
@GetMapping("/search")
public ResponseEntity<List<PostDto.Response>> searchPosts(@RequestParam String keyword) {
return ResponseEntity.ok(postService.searchPosts(keyword));
}
}
