서로 지향하는 바가 다른 2개 영역(객체지향 프로그래밍 언어와 관계형 데이터베이스)을 중간에서 패러다임 일치를 시켜주기 위한 기술이다.
개발자는 객체지향적으로 프로그래밍을 하고 JPA가 이를 관계형 데이터베이스에 맞게 SQL을 대신 생성해서 실행한다.
Spring Data JPA
JPA는 인터페이스로서 자바 표준명세서이다. 인터페이스인 JPA를 사용하기 위해서는 구헨쳐가 필요하다. 하지만 Spring에서는 JPA를 사용할 때 구현체(Hibernate, Eclipse Link 등)들을 직접 다루진 않는다. 구현체들을 더 쉽게 사용하고자 추상화시킨 Spring Data JPA라는 모듈을 이용해 JPA 기술을 다룬다.
Spring Data JPA가 등장한 이유는
이다.
'구현체 교체의 용이성'이란 Hibernate 외에 다른 구현체로 쉽게 교체하기 위함이다.
'저장소 교체의 용이성'이란 관계형 데이터베이스 외에 다른 저장소로 쉽게 교체하기 위함입니다.
Spring Data의 하위 프로젝트들은 기본적인 CRUD의 인터페이스가 같기때문에 의존성 교체만 하면 쉽게 저장소를 교체할 수 있다.
build.gradle에 의존성 추가하기
dependencies {
implementation('org.springframework.boot:spring-boot-starter-web')
implementation('org.springframework.boot:spring-boot-starter-data-jpa')
implementation('org.springframework.boot:spring-boot-starter-mustache')
implementation('org.projectlombok:lombok')
implementation('com.h2database:h2')
testImplementation 'org.springframework.boot:spring-boot-starter-test'
annotationProcessor 'org.projectlombok:lombok'
}
Spring-boot-starter-data-jpa
h2
Posts 클래스를 생성하자
package com.jojoldu.book.springboot.domain.posts;
import com.jojoldu.book.springboot.domain.BaseTimeEntity;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
@Entity
public class Posts extends BaseTimeEntity {
@Id
@GeneratedValue
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;
}
public void update(String title, String content) {
this.title = title;
this.content = content;
}
}
Posts 클래스는 실제 DB의 테이블과 매칭될 클래스이며 보통 Entity클래스라고 한다. JPA를 사용하면 DB 데이터에 작업할 경우 실제 쿼리를 날리기보다는, 이 Entity 클래스의 수정을 통해 작업을 한다.
@Entity
@Id
GeneratedValue
@Column
참고
Entity의 PK는 Long 타입의 Auto_increment를 추천한다.
Entity 클래스에서는 절대 Setter 메서드를 생성하지 않는다.
PostsRepository 클래스를 생성하자
package com.jojoldu.book.springboot.domain.posts;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PostsRepository extends JpaRepository<Posts, Long> {
List<Posts> findAllDesc();
}
JPA에서는 Repoisoty라고 불리는 이 클래스(인터페이스)는 보통 MyBatis나 ibatis에서 Dao라고 불리는 DB Layer 접근자이다.
단순히 JPARepository<Entity 클래스, PK 타입>를 상속하면 기본적인 CRUD 메서드가 자동으로 생성된다.
PostsRepositoryTest 클래스를 생성하자
package com.jojoldu.book.springboot.domain.posts;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
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;
@ExtendWith(SpringExtension.class)
@SpringBootTest
class PostsRepositoryTest {
@Autowired
PostsRepository postsRepository;
@AfterEach
public void cleanup() {
postsRepository.deleteAll();
}
@Test
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);
}
}
@AfterEach
postsRepository.save
postsRepository.findAll
별다른 설정 없이 @SpirngBootTest
를 사용할 경우 H2 데이터베이스를 자동으로 실행해준다.
추후 JPA의 설정과 h2데이터베이스 연결을 위해 application.properties 파일을 생성하자
위치는 src/main/resources 디렉토리 아래에 만들면 된다.
실제 실행된 쿼리가 어떤 형태인지 보기위해 application.properties에 다음과 같은 코드를 추가하자.
spring.jpa.show_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb
출력되는 쿼리 로그를 MySQL 버전으로 변경하기 위해 spring.jpa.show_sql=true
아래에 두 줄의 코드를 추가하였다.
API를 만들기 위해 총 3개의 클래스가 필요하다.
비즈니스 로직을 처리를 담당하는 곳은 Domain이다
PostsApiController 클래스를 생성하자
package com.jojoldu.book.springboot.web;
import com.jojoldu.book.springboot.service.posts.PostsService;
import com.jojoldu.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;
@RestController
@RequiredArgsConstructor
public class PostsApiController {
private final PostsService postsService;
@PostMapping("/api/v1/posts")
public Long save(@RequestBody PostsSaveRequestDto requestDto) {
return postsService.save(requestDto);
}
}
PostsService 클래스를 생성하자
package com.jojoldu.book.springboot.service.posts;
import com.jojoldu.book.springboot.domain.posts.PostsRepository;
import com.jojoldu.book.springboot.web.dto.PostsSaveRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class PostsService {
private final PostsRepository postsRepository;
@Transactional
public Long save(PostsSaveRequestDto requestDto) {
return postsRepository.save(requestDto.toEntity()).getId();
}
}
Entity 클래스를 절대로 Requeset/Response 클래스로 사용해서는 안된다.
책에 나오는 Test코드는 생략하겠다.
PostsResponseDto는 Entity의 필드 중 일부만 사용하므로 생성자로 Entity를 받아 필드에 값을 넣는다. Entity를 직접 처리하는 방법은 좋지 않기 때문에 Dto로 변환해서 처리한다. 굳이 모든 필드를 가진 생성자가 필요하진 않으므로 Dto는 Entity를 받아 처리한다.
JPA 데이터 update 기능
JPA에서는 update를 할 때 굳이 데이터베이스에 쿼리를 날리지 않아도 된다. 그 이유는 JPA의 영속성 컨텍스트 때문이다.
영속성 컨텍스트란, 엔티티를 영구 저장하는 환경이다.
JPA의 엔티티 매니저(EntityManager)가 활성화된 상태로 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태이다.
이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영한다. 즉 Entity 객체의 값만 변경하면 별도로 Update 쿼리를 날릴 필요가 없다. 이 개념을 더티 체킹(dirty checking) 또는 변경 감지라고 한다.
h2 데이터베이스에 들어가보자
application.properties를 다음과 같이 수정하자.
spring.jpa.show_sql=true
spring.h2.console.enabled=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;MODE=MYSQL
spring.datasource.hikari.username=sa
수정 후 Application 클래스의 main 메서드를 실행한다. 그리고 웹 브라우저에서 http://localhost:8080/h2-console로 접속한다. JDBC URL에 jdbc:h2:mem:testdb를 입력 후 Connect 버튼을 눌러 관리 페이지로 이동한다.
select * from posts;
BaseTimeEntity 클래스를 생성하자
package com.jojoldu.book.springboot.domain;
import java.time.LocalDateTime;
import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {
@CreatedDate
private LocalDateTime createdDate;
@LastModifiedDate
private LocalDateTime modifiedDate;
}
BaseTimeEntity 클래스는 모든 Entity의 상위 클래스가 되어 Entity들의 createdDate, modifiedDate를 자동으로 관리하는 역할을 한다.
@MappedSuperClass
@EntityListeners(AuditingEntityListener.class)
@CreateDate
@LastModifiedDate
그리고 JPA Auditing 어노테이션들을 모두 활성화할 수 있도록 Application 클래스에 어노테이션을 추가하자.
package com.jojoldu.book.springboot;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@SpringBootApplication
@EnableJpaAuditing
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
@EnableJpaAuditing
추후 추가될 Entity들이 BaseTimeEntity만 상속받게 된다면 더이상 등록일/수정일로 고민할 필요가 없다.