JPA소개
객체지향 프로그래밍에 맞추어 JPA가 관계형 데이터베이스에 맞게 SQL을 대신 생성해서 실행함
<장점>
→SQL에 종속적인 개발을 하지 않아도 됨(테이블 마다 CRUD SQL작성할 필요없음, C:create R:read U:update, D:delete) : 패러다임 일치
→JPA ← Hiberante ← Spring Data JPA( 구현체 교체의 용이성, 저장소 교체의 용이성)
<단점>
→실무에선 높은 러닝커브로 잘 안쓰임.
요구사항 분석
게시판
0.프로젝트 Spring Data JPA 적용 →테스트 코드
1.등록 API → 테스트 코드
2.수정 API → 테스트 코드
3.조회 API → 테스트 코드
4.JPA Auditing으로 조회시간/생성시간 자동화
0. 프로젝트에 Spring Data JPA 적용하기
build.gralde에 dependencies 의존성 추가
implementation ('javax.persistence:javax.persistence-api:2.2') implementation('org.springframework.boot:spring-boot-starter-web') implementation('org.springframework.boot:spring-boot-starter-validation') implementation('org.springframework.boot:spring-boot-starter-thymeleaf') implementation('org.projectlombok:lombok') implementation('org.springframework.boot:spring-boot-starter-data-jpa') implementation('com.h2database:h2') implementation ('org.springframework.boot:spring-boot-starter-data-jpa') testImplementation('org.springframework.boot:spring-boot-starter-test')
spring-boot-starter-data-jpa : JPA 관련 라이브러리 관리
h2 : 인메모리 관계형 데이터베이스
domain 패키지→ posts 패키지 → Posts 클래스
도메인: 게시글, 댓글, 회원, 정산, 결제 등 소프트웨어에 대한 요구사항 혹은 문제영역
실제 DB 테이블과 매칭될 클래스(entity class)
package com.hello.book.springboot.domain.posts; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @NoArgsConstructor @Entity public class Posts { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(length = 500, nullable = false) private String title; @Column(columnDefinition = "TEXT", nullable = false) private String content; private String author; @Builder public Posts(String title, String content, String author) { this.title = title; this.content = content; this.author = author; } }
- @Entity : 테이블과 링크될 클래스임을 나타냄.
- @Id : 해당 테이블의 PK필드를 나타냄.
- @GeneratedValue : PK의 생성규칙을 나타냄.
- @Column : 테이블의 칼럼을 나타내며 굳이 선언하지 않아도 해당 클래스의 필드는 모두 칼럼이 됨. 기본값외에 추가로 변경이 필요한 경우 사용 (ex.사이즈, 타입 변경 등 )
domain 패키지→ posts 패키지 → PostsRepository (클래스-인터페이스)
Posts 클래스로 DB접근하게 해줄 JpaRepository
package com.hello.book.springboot.domain.posts; import org.springframework.data.jpa.repository.JpaRepository; public interface PostsRepository extends JpaRepository<Posts, Long>{ }
인터페이스로 생성하고 / 상속하면 기본적인 crud 메소드 자동 생성
Spring Data JPA 테스트 코드
test 디렉→ com.~springboot 패키지→ domain.posts 패키지 생성 → PostsRepositoryTest 클래스
package com.hello.book.springboot.web.domain.posts; import com.hello.book.springboot.domain.posts.Posts; import com.hello.book.springboot.domain.posts.PostsRepository; import org.junit.After; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.junit4.SpringRunner; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @RunWith(SpringRunner.class) @SpringBootTest public class PostsRepositoryTest { @Autowired PostsRepository postsRepository; @After public void cleanup() { postsRepository.deleteAll(); } @Test public void 게시글저장_불러오기() { //given String title = "테스트 게시글"; String content = "테스트 본문"; postsRepository.save(Posts.builder() .title(title) .content(content) .author("jojoldu@gmail.com") .build() ); //when List<Posts> postsList = postsRepository.findAll(); //then Posts posts = postsList.get(0); assertThat(posts.getTitle()).isEqualTo(title); assertThat(posts.getContent()).isEqualTo(content); } }
@SpringBootTest 사용할 경우 H2 데이타베이스 자동 실행해준다
application.properties mysql문 버전으로 쿼리로그 확인 가능
spring.jpa.show_sql=true spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect spring.jpa.properties.hibernate.dialect.storage_engine=innodb spring.datasource.hikari.jdbc-url=jdbc:h2:mem:testdb spring.h2.console.enabled=true
1. 등록API 생성
PostsApiController
package com.hello.book.springboot.web; import com.hello.book.springboot.service.posts.PostsService; import com.hello.book.springboot.web.dto.PostsSaveRequestDto; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor @RestController public class PostsApiController { private final PostsService postsService; @PostMapping("/api/v1/posts") public Long save(@RequestBody PostsSaveRequestDto requestDto) { return postsService.save(requestDto); } }
PostsService
package com.hello.book.springboot.service.posts; //import com.hello.book.domain.posts.PostsRepository; //import com.hello.book.dto.PostsSaveRequestDto; import com.hello.book.springboot.domain.posts.PostsRepository; import com.hello.book.springboot.web.dto.PostsSaveRequestDto; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Service public class PostsService { private final PostsRepository postsRepository; @Transactional public Long save(PostsSaveRequestDto requestDto) { return postsRepository.save(requestDto.toEntity()).getId(); } }
@RequiredArgsConstructor에서 생성자 생성
PostsSaveRequestDto
Controller와 Service에서 사용할 DTO클래스 생성
view를 위한 클래스로, entity 클래스(테이블, 스키마 생성)와 분리!(자주 변경)
package com.hello.book.springboot.web.dto; import com.hello.book.springboot.domain.posts.Posts; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @NoArgsConstructor public class PostsSaveRequestDto { private String title; private String content; private String author; @Builder public PostsSaveRequestDto(String title, String content, String author) { this.title = title; this.content = content; this.author = author; } public Posts toEntity() { return Posts.builder() .title(title) .content(content) .author(author) .build(); } }
Test
package com.hello.book.springboot.web; import com.hello.book.springboot.domain.posts.Posts; import com.hello.book.springboot.domain.posts.PostsRepository; import com.hello.book.springboot.web.dto.PostsSaveRequestDto; import org.junit.After; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; //import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.web.server.LocalServerPort; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.test.context.junit4.SpringRunner; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class PostsApiControllerTest { @LocalServerPort private int port; @Autowired private TestRestTemplate restTemplate; @Autowired private PostsRepository postsRepository; @After public void tearDown() { postsRepository.deleteAll(); } @Test public void Posts_register() throws Exception { //given String title = "title"; String content = "content"; String author = "author"; PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder() .title(title) .content(content) .author(author) .build(); String url = "http://localhost:" + port + "/api/v1/posts"; //when ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, Long.class); //then assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(responseEntity.getBody()).isGreaterThan(0L); List<Posts> postsList = postsRepository.findAll(); assertThat(postsList.get(0).getTitle()).isEqualTo(title); assertThat(postsList.get(0).getContent()).isEqualTo(content); assertThat(postsList.get(0).getAuthor()).isEqualTo(author); } }
WebMvcTest를 사용하지 않는 이유는 JPA 기능이 작동하지 않기 때문이다.
2,3. 수정/조회 API 생성
PostApiController
@PutMapping("/api/v1/posts/{id}") public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto) { return postsService.update(id, requestDto); } @GetMapping("/api/v1/posts/{id}") public PostsResponseDto findById (@PathVariable Long id) { return postsService.findById(id); }
PostsResponseDto
package com.hello.book.springboot.web.dto; import com.hello.book.springboot.domain.posts.Posts; import lombok.Getter; @Getter public class PostsResponseDto { private Long id; private String title; private String content; private String author; public PostsResponseDto(Posts entity) { this.id = entity.getId(); this.title = entity.getTitle(); this.content = entity.getContent(); this.author = entity.getAuthor(); } }
entity 필드의 일부만 사용하므로, 생성자로 entity를 받아 필드에 값을 넣는다.
PostsUpdateRequestDto
package com.hello.book.springboot.web.dto; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @NoArgsConstructor public class PostsUpdateRequestDto { private String title; private String content; @Builder public PostsUpdateRequestDto(String title, String content) { this.title = title; this.content = content; } }
Posts
public void update(String title, String content) { this.title = title; this.content = content; }
PostsService
@Transactional public Long update(Long id, PostsUpdateRequestDto requestDto) { Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id = " + id)); posts.update(requestDto.getTitle(), requestDto.getContent()); return id; } public PostsResponseDto findById(Long id) { Posts entity = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id = " + id)); return new PostsResponseDto(entity); }
PostApiControllerTest
package com.hello.book.springboot.web; import com.hello.book.springboot.domain.posts.Posts; import com.hello.book.springboot.domain.posts.PostsRepository; import com.hello.book.springboot.web.dto.PostsSaveRequestDto; import com.hello.book.springboot.web.dto.PostsUpdateRequestDto; import org.junit.After; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; //import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.web.server.LocalServerPort; import org.springframework.http.HttpEntity; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.test.context.junit4.SpringRunner; import java.time.LocalDateTime; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class PostsApiControllerTest { @LocalServerPort private int port; @Autowired private TestRestTemplate restTemplate; @Autowired private PostsRepository postsRepository; @After public void tearDown() { postsRepository.deleteAll(); } @Test public void Posts_register() throws Exception { //given String title = "title"; String content = "content"; String author = "author"; PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder() .title(title) .content(content) .author(author) .build(); String url = "http://localhost:" + port + "/api/v1/posts"; //when ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, Long.class); //then assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(responseEntity.getBody()).isGreaterThan(0L); List<Posts> postsList = postsRepository.findAll(); assertThat(postsList.get(0).getTitle()).isEqualTo(title); assertThat(postsList.get(0).getContent()).isEqualTo(content); assertThat(postsList.get(0).getAuthor()).isEqualTo(author); } @Test public void Posts_수정된다() throws Exception{ Posts savedPosts = postsRepository.save(Posts.builder() .title("title") .content("content") .author("author") .build()); Long updatedId = savedPosts.getId(); String expectedTitle = "title2"; String expectedContent = "content2"; PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder() .title(expectedTitle) .content(expectedContent) .build(); String url = "http://localhost:" + port + "/api/v1/posts/"+updatedId; HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto); ResponseEntity<Long> responseEntity = restTemplate.exchange(url, HttpMethod.PUT,requestEntity,Long.class); assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(responseEntity.getBody()).isGreaterThan(0L); List<Posts> all = postsRepository.findAll(); assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle); assertThat(all.get(0).getContent()).isEqualTo(expectedContent); } }
확인
- Application의 main 메소드 실행
- jdbc:h2:mem:testdb
- [Connet]
- POSTS 테이블 확인 → SELECT * FROM posts;
- insert into posts (author, content, title) values (’author’, ‘content’, ‘title’);
4. JPA Auditing으로 생성시간/조회시간 자동화
BaseTimeEntity 생성
도메인 패키지에 생성
package com.hello.book.springboot.domain; import lombok.Getter; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; import javax.persistence.EntityListeners; import javax.persistence.MappedSuperclass; import java.time.LocalDateTime; @Getter @MappedSuperclass @EntityListeners(AuditingEntityListener.class) public abstract class BaseTimeEntity { @CreatedDate private LocalDateTime createdDate; @LastModifiedDate private LocalDateTime modifiedDate; }
테스트 코드
@Test public void BaseTimeEntity_등록() { LocalDateTime now = LocalDateTime.of(2023, 11, 7, 0, 0, 0); postsRepository.save(Posts.builder() .title("title") .content("content") .author("author") .build()); //when List<Posts> postsList = postsRepository.findAll(); //then Posts posts = postsList.get(0); System.out.println(">>>>>>>>> createdDate = " + posts.getCreatedDate() + ", modifiedDate = " + posts.getModifiedDate()); assertThat(posts.getCreatedDate()).isAfter(now); assertThat(posts.getModifiedDate()).isAfter(now); }