-
[3장] 스프링 부트에서 JPA로 데이터베이스 다뤄보자Spring/스프링 부트와 AWS로 혼자 구현하는 웹 서비스 2022. 11. 17. 13:16
JPA: 자바 표준 ORM
- ORM: 객체를 매핑
- SQL Mapper: 쿼리를 매핑
패러다임 불일치
- 관계형 데이터베이스: 어떻게 데이터를 저장하는지에 초점
- 객체지향 프로그래밍: 기능과 속성을 한 곳에서 관리하는 기술
- => 서로의 패러다임이 다른데 객체를 DB에 저장하려니 문제 발생
👉 JPA: 객체지향 프로그래밍 언어와 관계형 데이터베이스 중간에서 패러다임 일치를 시켜주는 기술
- SQL에 종속적인 개발을 하지 않을 수 있게 됨
- 객체 중심으로 개발 => 생산성 향상 & 유지 보수 용이해짐
Spring Data JPA
JPA는 인터페이스이기 때문에 구현체가 필요함
- Hibernate
- Eclipse Link
하지만, Spring에서 JPA를 사용할 때는 이 구현체들을 직접 다루지는 않음
구현체들을 좀 더 쉽게 사용할 수 있도록 추상화시킨 Spring Data JPA라는 모듈을 이용해서 JPA 기술을 다룸
- JPA ← Hibernate ← Spring Data JPA
Hibernate와 큰 차이가 없지만 Spring Data JPA를 만들어 사용을 권장하는 이유
- 구현체 교체의 용이성 : Hibernate 외에 다른 구현체로 쉽게 교체하기 위함
- 저장소 교체의 용이성 : 관계형 데이터베이스 외에 다른 저장소로 쉽게 교체하기 위함
게시판 만들기
Spring Data JPA 적용
implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'com.h2database:h2'
build.gradle에 JPA와 h2 데이터베이스 의존성 추가
- spring-boot-starter-data-jpa
- 스프링부트용 Spring Data Jpa 추상화 라이브러리
- 스프링부트 버전에 맞춰서 자동으로 JPA관련 라이브러리들의 버전을 관리해줌
- h2
- 인메모리 관계형 데이터베이스
- 메모리에서 실행되기 때문에 애플리케이션을 재시작할 때마다 초기화된다는 점을 이용하여 테스트 용도로 많이 사용됨
Posts 클래스
package com.example.techeerteama1.springboot.domain.posts; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import javax.persistence.*; @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
- JPA의 어노테이션
- 테이블과 링크될 클래스임을 나타냄
- 카멜케이스 => 언더스코어 네이밍 (SalesManager => sales_manager) 기본적으로 해줌
- @Getter
- 롬복의 어노테이션
- 클래스 내 모든 필드의 Getter 메소드를 자동생성
- @NoArgsConstructor
- 롬복의 어노테이션
- 기본 생성자 자동 추가
- @Id
- 해당 테이블의 PK 나타냄
- @GenerationValue
- PK의 생성 규칙을 나타냄
- GenerationType.AUTO 가 기본값
- identity와 auto의 차이점
- JPA는 영속성 컨텍스트에서 객체를 관리하기 위해서는 무조건 PK값이 있어야 함
- (KEY값으로 해당 객체의 값을 넣기 때문)
- auto의 경우, 애플리케이션에서는 그 값이 얼마인지 알지 못하기 때문에 DB에 insert문을 날려야만 id(PK)값을 알 수 있음
- 하지만, identity의 경우, 기본키 생성을 DB에 위임하기 때문에 id가 null일 경우 해당 객체의 Id를 DB의 auto_increment를 가져와서 할당함
- @Column
- 테이블의 칼럼
- 선언하지 않아도 해당 클래스의 필드는 모두 칼럼이 됨
- 기본값 외에 추가로 변경이 필요한 옵션이 있으면 사용하기 위함
- @Builder
- 해당 클래스의 빌더 패턴 클래스를 생성
- 생성자 상단에 선언 시 생성자에 포함된 필드만 빌더에 포함
📎 @Setter는 하지 않는 이유
- 해당 클래스의 인스턴스 값들이 언제 어디서 변해야 하는지 코드상으로 명확하게 구분할 수 없어, 차후 변경 시 매우 복잡해짐
- 따라서, Entity 클래스에서는 절대 Setter 메소드를 만들지 않음
PostsRepository 인터페이스
package com.example.techeerteama1.springboot.domain.posts; import org.springframework.data.jpa.repository.JpaRepository; public interface PostsRepository extends JpaRepository<Posts, Long> { }
- JpaRepository: Posts 클래스로 Database를 접근하게 해줌
- 인터페이스 생성 + JpaRepository<Entity 클래스, PK타입>을 상속하면 기본적인 CRUD 메소드가 자동 생성됨
- Entity 클래스와 기본 Repository 인터페이스는 함께 위치해야 함!
Spring Data JPA 테스트 코드 작성하기
PostsRepositoryTests 클래스
package com.example.techeerteama1.springboot.domain.posts; import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.junit.jupiter.SpringExtension; import java.util.List; @ExtendWith(SpringExtension.class) @SpringBootTest class PostsRepositoryTest { @Autowired PostsRepository postsRepository; @AfterEach public void cleanup() { postsRepository.deleteAll(); } @Test public void 게시글저장_불러오기() { //given String title = "테스트 게시글"; String content = "테스트 본문"; postsRepository.save(Posts.builder() .title(title) .content(content) .author("yeonjy@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); } }
책에는 JUnit4를 써서인지 @After으로 나와있는데 JUnit5에서는 @AfterEach라고 한다.
- @AfterEach
- JUnit에서 단위 테스트가 끝날 때마다 수행되는 메소드를 지정
- 보통 테스트간 데이터 침범을 막기 위해 사용
- 여러 테스트가 동시에 수행되면 H2에 데이터가 그대로 남아있어서 다음 테스트 실행 시 테스트가 실패할 수도 있음
- postsRepository.save
- 테이블 posts에 insert/update 쿼리를 실행
- id값이 있다면 update, 없다면 insert 쿼리가 실행됨
- postsRepository.findAll
- 테이블 posts에 있는 모든 데이터를 조회
- @SpringBootTest
- H2 데이터베이스 자동으로 실행됨
📎 실제로 실행된 쿼리의 형태는?
- 쿼리 로그 ON/OFF 가능
- Java 클래스로 구현 가능하지만, 스프링 부트에서는 아래의 방법 권장
- application.properties 등의 파일로 한 줄의 코드로 설정
resources 디렉토리에 해당 파일 생성 후 아래의 코드 입력
spring.jpa.show_sql=true
이후 다시 테스트를 실행하면 위와 같은 쿼리 로그 확인 가능
id bigint generated by default as identity라는 옵션으로 create table된다.
이는 H2의 쿼리 문법이 적용되었기 때문!
=> H2는 MySQL의 쿼리를 수행해도 정상적으로 작동하기 떄문에 이후 디버깅을 위해서 출력되는 쿼리 로그를 MySQL버전으로 변경 => 아래의 코드 추가
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
🧨위의 코드를 추가하고 다시 테스트를 실행하니 아래와 같은 오류가 발생했다.
org.springframework.dao.InvalidDataAccessResourceUsageException: could not prepare statement;
Caused by: org.h2.jdbc.JdbcSQLSyntaxErrorException: Table "POSTS" not found (this database is empty);
https://github.com/jojoldu/freelec-springboot2-webservice/issues/67#issuecomment-832692299
위의 코드를 그대로 입력했더니 auto_increment 로그가 뜨면서 테스트도 통과했다.
등록/수정/조회 API 만들기
API를 만들기 위해 필요한 클래스 3개
- Request 데이터를 받을 DTO
- API 요청을 받을 Controller
- 트랜잭션 도메인 기능 간의 순서를 보장하는 Service
📎Service에서 비지니스 로직 처리 X
Service는 트랜잭션, 도메인 간 순서 보장 역할만!!
비지니스 로직을 처리하는 곳은 Domain!!
PostsApiController
package com.example.techeerteama1.springboot.web; import com.example.techeerteama1.springboot.service.posts.PostsService; import com.example.techeerteama1.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.example.techeerteama1.springboot.service.posts; import com.example.techeerteama1.springboot.domain.posts.PostsRepository; import com.example.techeerteama1.springboot.web.dto.PostsSaveRequestDto; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import javax.transaction.Transactional; @RequiredArgsConstructor @Service public class PostsService { private final PostsRepository postsRepository; @Transactional public Long save(PostsSaveRequestDto requestDto) { return postsRepository.save(requestDto.toEntity()).getId(); } }
📎 Controller와 Service에서 @Autowired를 안 쓴 이유
- 스프링에서 Bean을 주입받는 방식
- @Autowired
- setter
- 생성자
👉 가장 권장하는 방식은 생성자로 주입받는 방식!!
=> 생성자는 어디있나?!
@RequiredArgsConstructor가 final이 선언된 모든 필드의 인자값으로 하는 생성자 생성해줌!
PostsSaveRequestDto
package com.example.techeerteama1.springboot.web.dto; import com.example.techeerteama1.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(); } }
📎 Entity와 거의 유사한 형태임에도 Dto 클래스를 생성한 이유
- Entity 클래스는 절대 Request/Response 클래스로 사용해서는 안 됨
- Entity 클래스는 데이터베이스와 맞닿은 핵심 클래스
- 화면 변경은 아주 사소한 기능인데, 이를 위해 테이블과 연관된 Entity 클래스를 변경하는 것은 너무 큰 변경
- Entity 클래스는 많은 클래스와 연관되어있음 => Request/Response용 DTO는 View를 위한 클래스이기 때문에 자주 변경될 것
- 따라서, View Layer와 DB Layer의 역할 분리를 철저히 하는 것 권장
HelloApiControllerTests
package com.example.techeerteama1.springboot.web; import com.example.techeerteama1.springboot.domain.posts.Posts; import com.example.techeerteama1.springboot.domain.posts.PostsRepository; import com.example.techeerteama1.springboot.web.dto.PostsSaveRequestDto; import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.test.context.junit.jupiter.SpringExtension; import java.util.List; @ExtendWith(SpringExtension.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class HelloApiControllerTest { @LocalServerPort private int port; @Autowired private TestRestTemplate restTemplate; @Autowired private PostsRepository postsRepository; @AfterEach public void tearDown() throws Exception { postsRepository.deleteAll(); } @Test public void Posts_등록된다() throws Exception { //given String title = "title"; String content = "content"; 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> all = postsRepository.findAll(); assertThat(all.get(0).getTitle()).isEqualTo(title); assertThat(all.get(0).getContent()).isEqualTo(content); } }
HelloControllerTest와 달리, @WebMvcTest를 사용하지 않은 이유는 @WebMvcTest가 JPA 기능이 작동하지 않기 때문
=> JPA 기능까지 한번에 테스트할 때는 @SpringBootTest를 사용
수정/조회 기능 구현
PostsApiController
package com.example.techeerteama1.springboot.web; import com.example.techeerteama1.springboot.service.posts.PostsService; import com.example.techeerteama1.springboot.web.dto.PostsSaveRequestDto; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @RequiredArgsConstructor @RestController public class PostsApiController { private final PostsService postsService; @PostMapping("/api/v1/posts") public long save(@RequestBody PostsSaveRequestDto requestDto) { return postsService.save(requestDto); } @PostMapping("/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.example.techeerteama1.springboot.web.dto; import com.example.techeerteama1.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(); } }
PostsResponseDto는 Entity의 필드 중 일부만 사용하므로 생성자로 Entity를 받아 필드에 값을 넣음
굳이 모든 필드를 가진 생성자가 필요하진 않으므로 Dto는 Entity를 받아 처리함
PostsUpdateRequestDto
package com.example.techeerteama1.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); } }
- update 기능에서 데이터베이스에 쿼리를 날리는 부분이 없음
- JPA의 영속성 컨텍스트 때문에 가능
- 영속성 컨텍스트: 엔티티를 영구 저장하는 환경; 일종의 논리적 개념
- JPA의 엔티티 매니저가 활성화된 상태로 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태임
- 이 상태에서 해당 데이터를 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영
- = dirty checking 더티 체킹
- JPA의 영속성 컨텍스트 때문에 가능
PostsApiController에 추가
@Test public void Posts_수정된다() throws Exception { //given Posts savedPosts = postsRepository.save(Posts.builder() .title("title") .content("content") .author("author") .build()); Long updateId = 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/" + updateId; HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto); //when ResponseEntity<Long> responseEntity = restTemplate. exchange(url, HttpMethod.PUT, requestEntity, Long.class); //then assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(responseEntity.getBody()).isGreaterThan(0L); List<Posts> all = postsRepository.findAll(); assertThat(all.get(0).getContent()).isEqualTo(expectedContent); }
'Spring > 스프링 부트와 AWS로 혼자 구현하는 웹 서비스' 카테고리의 다른 글
[4장] 머스테치로 화면 구성하기 (0) 2022.11.21 [2장] 스프링 부트에서 테스트 코드를 작성하자 (0) 2022.11.12