3장에서는 JPA에대한 설명과 사용방법에 대해 설명합니다.
Java Persistence API (JPA)는 Java 애플리케이션에서 관계형 데이터베이스와 상호 작용하는 데 사용되는 자바 ORM (Object-Relational Mapping) 기술입니다.
웹 어플리케이션 개발에서 관계형 데이터베이스는 빠질 수 없는 요소입니다. 그래서 객체를 데이터베이스에서 관리하는것이 중요하지만 몇가지 문제가 있습니다.
1.비효율적이다
2.패러다임 불일치
JPA를 사용합니다. 개발자는 객체지향적으로 프로그래밍을 하고, JPA가 이를 관계형 데이터베이스에 맞게 SQL을 대신 생성해주니 SQL에 종속적인 개발을 하지 않을 수 있습니다.
JPA는 인터페이스로 사용하기 위해선 구현체가 필요합니다.
대표적으로 Hibernate, Eclipse Link등이 있습니다.
하지만 스프링에서는 Spring Data JPA의 사용을 권장합니다.
1.구현체 교체의 용이성
2.저장소 교체의 용이성
application.properties에 의존성을 추가합니다.
implementation('org.springframework.boot:spring-boot-starter-data-jpa')
implementation('com.h2database:h2')
도메인 패키지는 도메인을 담을 패키지입니다. MyBatis의 dao 패키지와는 조금 결이 다릅니다. MyBatis 사용시 xml에서 쿼리를 실행하고 클래스에 쿼리의 결과를 담던 일들이 JPA에서는 모두 도메인 클래스라고 불리는 곳에서 해결됩니다.
@Getter
@NoArgsConstructor
@Entity
public class Posts extends BaseTimeEntity {
@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 클래스에서는 절대 Setter 메서드를 만들지 않습니다. 해당 필드의 값 변경시에는 명확히 그 목적과 의도를 나타낼 수 있는 메서드를 추가하여 변경해줍니다.
@Builder에서 제공하는 클래스를 사용하면 어느 필드에 어떤 값을 채워야할지 명확하게 인지할 수 있습니다.
Example.builder()
.a(a)
.b(b)
.build()
플러터로 모바일 개발을 해보고싶어서 Dart를 잠깐 공부한적이 있는데 위 예시 코드를 보고 개인적으로 Dart의 Named Constructor와 유사한것 같다는 생각이 들었습니다.
import org.springframework.data.jpa.repository.JpaRepository;
public interface PostsRepository extends JpaRepository<Posts, Long> {}
DB Layer 접근자로 JPA에서는 Repository라고 부릅니다. 인터페이스를 생성 후, JpaRepository<Entity 클래스, PK 타입>를 상속하면 CRUD 메서드가 자동으로 생성됩니다.
Entity 클래스와 기본 Entity repository는 함께 위치해야 합니다. 둘은 밀접한 관계이기 때문에 Entity 클래스는 기본 Repository 없이는 제대로 역할을 할 수가 없습니다.
나중에 프로젝트 모가 커져 도메인별로 프로젝트를 분리해야 한다면 도메인 패키지에서 함께 관리합니다.
@ExtendWith(SpringExtension.class)
@SpringBootTest
public 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("duawodud66@naver.com")
.build()
);
//when
List<Posts> postsList = postsRepository.findAll();
//then
Posts posts = postsList.get(0);
assertThat(posts.getTitle()).isEqualTo(title);
assertThat(posts.getContent()).isEqualTo(content);
}
}
JUnit5에서 @After -> @AfterEach로 변경되었습니다.
별다른 설정 없이 @SpringBootTest를 사용할 경우 H2데이터베이스를 자동으로 실행해 줍니다.
쿼리 로그를 on/off 하기 위해서 스프링 부트에서는 application.properties, application.yaml등의 파일에 설정해주면 됩니다.
application.properties 파일을 생성후 다음 옵션을 추가합니다.
spring.jpa.show-sql=true
기본값이 H2쿼리 문법으로 나옵니다. H2에서는 MySQL도 정상적으로 작동하기 때문에 MySQL을 적용해보겠습니다.
책에 나온 다음코드는 스프링부트가 버전업 되면서 Deprecated 되었다고합니다.
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
현재 제가 실습하고 있는 버전에서는 다음 코드를 사용해야합니다.
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;MODE=MYSQL
spring.datasource.hikari.username=sa
이와 관련해 검색해보니 Issue에 이미 물어보신 분이 계셨고 이후 이동욱님 블로그에
정리가 되어있으니 한번 보시는걸 추천합니다.
API를 만들기 위해 3가지 클래스가 필요합니다.
여기서 Service는 비즈니스 로직을 처리하는 것이 아니라 트랜잭션, 도메인 간 순서 보장의 역할만 합니다.
스프링 웹 계층에서 비즈니스 처리를 담당해야 할 곳은 Domain 계층입니다.
**기존에 Service에서 처리하던 방식을 트랜잭션 스크립트라고 합니다.
@RequiredArgsConstructor
@RestController
public class PostsApiController {
private final PostsService postsService;
@PostMapping("/api/v1/posts")
public Long save(@RequestBody PostsSaveRequestDto requestDto) {
return postsService.save(requestDto);
}
}
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
@Transactional
public long save(PostsSaveRequestDto requestDto) {
return postsRepository.save(requestDto.toEntity()).getId();
}
}
스프링에서 Bean을 주입받는 방식은 다음 세가지입니다.
이중 권장하는 방법이 생성자로 주입받는 방식입니다. @RequiredArgsConstructor을 사용할 수 있습니다.
final이 선언된 모든 필드를 인자값으로 하는 생성자를 대신 생성해줍니다.
롬복 어노테이션을 사용함으로써 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 수정하는 번거로움을 해결할 수 있습니다.
@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 클래스를 변경하면 여러 클래스에 영향을 끼칩니다. 하지만 실제로 Controller에서 결괏값으로 여러 테이블을 조인해서 줘야 할 경우가 빈번하므로 Entity 클래스만으로 표현하기 어려운 경우가 많습니다. 그래서 꼭 Entity 클래스와 Controller에서 쓸 DTO는 분리해서 사용해야 합니다.
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {
@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기능이 연동되지 않습니다. Controller와 ControllerAdvice등 외부 연동과 관련된 부분만 활성화되니 JPA 기능까지 테스트할 때에는 @SpringBootTest 와 TestRestTemplate을 사용하면 됩니다.
...
@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);
}
@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.content = entity.getAuthor();
}
}
PostsResponseDto는 Entity의 필드 중 일부만 사용하므로 생성자로 Entity를 받아 필드에 값을 넣습니다.
@Getter
@NoArgsConstructor
public class PostsUpdateRequestDto {
private String title;
private String content;
@Builder
public PostsUpdateRequestDto(String title, String content) {
this.title = title;
this.content = content;
}
}
...
public void update(String title, String content) {
this.title = title;
this.content = content;
}
...
@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의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함되어 있냐 아니냐로 갈립니다.
JPA의 엔티티 매니저가 활성화된 상태로 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태라고 합니다. 이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영합니다. 즉 Entity 객체의 값만 변경하면 됩니다. 이 개념을 더티 체킹이라고 합니다.
...
@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).getTitle()).isEqualTo(expectedTitle);
assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
}
조회기능은 실제로 톰캣을 실행해서 확인해보겠습니다. H2는 메모리에서 실행하기 때문에 직접 접근하려면 웹 콘솔을 사용해야 합니다.
그러기 위해서 application.properties에 다음 코드를 추가해줍니다.
spring.h2.console.enabled=true
추가 후 Application 클래스의 main 메서드를 실행합니다. 웹 브라우저에서 http://localhost:8080/h2-console 로 접속하면 웹 콘솔 화면이 나타납니다.
JDBC URL이 사진처럼 되있지 않다면 바꿔주시면 됩니다. connect 버튼을 클릭해 H2관리 페이지로 이동합니다.
쿼리를 입력하고 run버튼을 클릭하면 사진처럼 나올것입니다.
이번에는 insert문을 실행해 보겠습니다.
insert into posts (author, content, title)
values ('author','content','title');
insert 실행 후 다음 주소로 이동하여 결과를 확인하면 됩니다.
http://localhost:8080/api/v1/posts/1
보통 엔티티에는 차후 유지보수에 있어 중요한 정보이기 때문에 해당 데이터의 생성시간과 수정시간을 포함합니다. 그렇다보니 매번 DB에 삽입하기 전, 갱신 전에 날짜 데이터를 등록/수정하는 코드가 여기저기 들어가게 됩니다. 이런 번거로움을 해결하기 위해 JPA Auditing을 사용하겠습니다.
Java8 부터 LocalDate와 LocalDateTime이 등장했습니다. Java의 기본 날짜 타입인 Date의 문제점을 고친 타입입니다.
1.불변객체가 아닙니다.
2.Calendar는 월 값 설계가 잘못되었습니다.
Domain 패키지에 BaseTimeEntity 클래스를 생성합니다.
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseTimeEntity {
@CreatedDate
private LocalDateTime createdDate;
@LastModifiedDate
private LocalDateTime modifiedDate;
}
BaseTimeEntity 클래스는 모든 Entity 클래스의 상위 클래스가 되어 Entity들의 createDate, modifiedDate를 자동으로 관리하는 역할을 합니다.
JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우 필드들도 컬럼으로 인식하도록 합니다.
BaseTimeEntity 클래스에 Auditing 기능을 포함시킵니다.
Entity가 생성되어 저장될 때 자동 저장됩니다.
조회한 Entity의 값을 변경할 때 시간이 자동 저장됩니다.
...
public class Posts extends BaseTimeEntity {
...
}
@EnableJpaAuditing // JPA Auditing 활성화
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
...
@Test
public void BaseTimeEntity_등록() {
//given
LocalDateTime now = LocalDateTime.now();
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(">>>>>>>> createDate = " + posts.getCreatedDate() + ", modifiedDate = " + posts.getModifiedDate());
assertThat(posts.getCreatedDate()).isAfter(now);
assertThat(posts.getModifiedDate()).isAfter(now);
}
이로써 앞으로 추가될 엔티티들은 BaseTimeEntity만 상속받으면 되기 때문에 더 이상 등록일/수정일로 고민할 필요가 없습니다.