스프링부트 실습 Chapter03 스프링 부트에서 JPA로 데이터베이스 다뤄보자
이동욱 作 '스프링 부트와 AWS로 혼자 구현하는 웹 서비스'
🔎 JPA / Hibernate / Spring Data Jpa의 관계
JPA 소개
JAP 등장 배경
관계형 데이터베이스(Oracle, Mysql..)는 웹 어플리케이션에서 빠질 수 없는 요소가 되었다. 이에 따라 객체를 관계형 데이터 베이스에서 관리하는 것이 중요해졌다. 이러한 관계형 데이터베이스는 SQL만 인식할 수 있기 때문에 각 테이블마다 CRUD SQL을 매번 생성해야 하고 방대한 테이블의 수 만큼 SQL을 만들고 유지보수를 해야하는 문제점이 생긴다.
또 다른 문제점으로 패러다임 불일치를 들 수 있다. 관계형 데이터베이스는 데이터를 어떻게 저장할 지에 초점이 맞춰진 기술이고 객체지향 프로그래밍 언어는 메시지를 기반으로 기능과 속성을 한 곳에서 관리하는 기술이다. 관계형 데이터베이스로 이러한 객체지향을 표현하기란 어렵고 객체를 데이터베이스에 저장하려고 하면 여러 문제가 발생한다.
객체지향 프로그래밍에서 부모가 되는 객체를 가져오는 방법
User user = findUser();
Group group = user.getGroup();
코드만 봐도 User와 Group은 부모-자식 관계임을 파악할 수 있다. User는 상속받은 Group을 가져온 코드이기 때문
데이터베이스가 추가되어 객체를 가져오는 방법
User user = userDao.findUser();
Group group = groupDao.findGroup(user.getGroupId());
User, Group을 따로따로 조회해서 가져온다. User와 Group의 관계를 파악하기 어렵다.
이렇게 되면 웹 어플리케이션 개발은 데이터 베이스 모델링에만 집중하게 된다.
이러한 문제점을 해결하기 위해 JPA가 등장하게 되었다.
JPA
- 객체지향 프로그래밍 언어과 관계형 데이터베이스의 중간에서 패러다임 일치를 시켜주기 위한 기술
개발자는 객체지향적으로 프로그래밍을 하고, JPA가 관계형 데이터베이스에 맞게 SQL을 대신 생성해서 실행한다. 이렇게되면, 개발자는 SQL에 종속적인 개발을 하지 않아도 된다.
Spring Data JPA
JPA는 인터페이스로 이를 사용하기 위해서는 구현체가 필요하다.
대표적인 구현체 ) Hibernate, Eclipse LInk
Spring에서 JPA를 사용할 때는 이 구현체들을 직접다루지 않고
추상화시킨 Spring Data JPA라는 모듈을 이용한다.
JPA < Hibernate < Spring Data JPA
Spring Data JPA의 용이성
Hibernate 외에 다른 구현체로 쉽게 교체할 수 있다
관계형 데이터베이스 외에 다른 저장소로 쉽게 교체할 수 있다.
🔎 Spring Data Jpa를 이용하여 관계형 데이터베이스를 객체지향적으로 관리하는 방법
👉 build.gradle에 jpa 의존성 등록하기
- org.springframework.boot:spring-boot-starter-data-jpa
- com.h2database:h2
dependencies {
compile('org.springframework.boot:spring-boot-starter-web')
compile('org.projectlombok:lombok')
annotationProcessor('org.projectlombok:lombok')
compile('org.springframework.boot:spring-boot-starter-data-jpa')
compile('com.h2database:h2')
testCompile('org.springframework.boot:spring-boot-starter-test')
}
spring-boot-starter-data-jpa
스프링 부트용 Spring Data Jpa 추상화 라이브러리
스프링 부트 버전에 맞춰 자동으로 JPA관련 라이브러리들의 버전을 관리해준다.
h2
인메모리 관계형 데이터베이스
별도의 설치없이 프로젝트 의존성만으로 관리할 수 있음
메모리에서 실행되기 때문에 애플리케이션을 재시작할 때마다 초기화 된다.
테스트 용도로 많이 사용된다.
👉 패키지 & 클래스 만들기
domain
도메인(게시글, 댓글, 회원, 정산, 결제 등 소프트웨어에 대한 요구사항 혹은 문제영역)을 담을 패키지
Posts 클래스
- 실제 DB의 테이블과 매칭 될 클래스
- Entity 클래스
- JPA를 사용하여 DB 데이터에 작업할 경우 실제 쿼리를 날리기보다는, 이 Entity 클래스의 수정을 통해 작업한다.
import com.jojoldu.book.springboot.domain.BaseTimeEntity;
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;
}
public void update(String title, String content) {
this.title = title;
this.content = content;
}
}
@Entity
테이블과 링크될 클래스임을 나타냄
기본값으로 클래스의 카멜케이스 이름을 언더스코어 네이밍(_)으로 테이블 이름 매칭
@Id
해당 테이블의 PK 필드를 나타냄
@GeneratedValue
PK의 생성 규칙을 나타냄
GenerationType.IDENTITY 옵션을 추가하면 auto_increment가 됨.
@Column
테이블의 컬럼을 나타냄. 선언하지 않아도 되지만,
기본값 외에 추가로 변경이 필요한 옵션이 있으면 사용한다.
@NoArgsConstructor
기본 생성자 자동 추가
@Builder
해당 클래스의 빌더 패턴 클래스 생성
생성자 상단에 선언 시 생성자에 포함된 필드만 빌더에 포함
💡 Entity 클래스에서는 Setter 메소드를 절대 만들지 않는다.
- setter 생성 시 해당 클래스의 인스터스 값들이 언제 어디서 변해야 하는지
코드상으로 명확하게 구분할 수가 없기 때문에 기능 변경 시 복잡해진다.
Setter가 없으면 어떻게 DB에 값을 채워 삽입하나?
- 기본적인 구조는 생성자 혹은 @Builder를 통해 최종값을 채운 후 DB에 삽입하는 것.
public class Order {
public void cancelOrder() {
this.status = fasle;
}
}
public void 주문서비스의_취소이벤트() {
order.cancleOrder();
}
- 값 변경이 필요한 경우는 해당 이벤트에 맞는 public 메소드를 호출하여 변경한다
JpaRepository 클래스
- Posts(Entity) 클래스로 Database를 접근하게 해주는 클래스
- DB Layer 접근자 (Mybatis의 Dao)
- JPA에서는 Repository라고 부르며 인터페이스로 생성
import org.springframework.data.jpa.repository.JpaRepository;
public interface PostsRepository extends JpaRepository<Posts, Long> {
}
인터페이스 생성 후 JpaRepository<Entity 클래스, PK타입>을 상속하면 기본적인 CRUD 메소드가 자동으로 생성된다.
👉 테스트 코드로 테스트 해보기
@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);
}
}
@After
Junit에서 단위 테스트가 끝날 때마다 수행되는 메소드
@postRepository.save
테이블 posts에 insert/update 쿼리를 실행한다.
id 값이 있다면 update, 없다면 insert 쿼리가 실행됨
@postsRepository.findAll
테이블 posts에 있는 모든 데이터를 조회해오는 메소드
💡 실행된 쿼리를 로그로 보기 - spring data jpa 로그 설정
👉 등록/수정/조회 API 만들기
테스트가 성공했다면 본격적으로 API를 만든다.
API를 만들기 위해 필요한 클래스
Dto
Request 데이터를 받는다
Controller
API 요청을 받는다
Service
트랜잭션, 도메인 기능 간의 순서를 보장한다.
Spring 웹 처리 과정
Controller > Service > Repository
👑 등록
Controller
@RequiredArgsConstructor
@RestController
public class PostsApiController {
private final PostsService postsService;
@PostMapping("/api/v1/posts")
public long save(@RequestBody PostsSaveRequestDto requestDto) {
return postsService.save(requestDto);
}
}
Service
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
@Transactional
public Long save(PostsSaveRequestDto requestDto) {
return postsRepository.save(requestDto.toEntity()).getId();
}
}
@Autowired가 없는 이유
- 생성자로 주입받기 때문
@RequiredArgsConstructor
final이 선언된 모든 필드를 인자값으로 하는 생성자를 대신 생성해줌.
해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정하는 과정을 거치지 않아도 된다.
Dto
@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();
}
}
💡 앞에서 Posts 라는 Entity 클래스를 만들었는데 또 Dto 클래스를 추가로 만드는 이유
Entity 클래스는 데이터베이스와 맞닿은 핵심 클래스이다.
Entity 클래스를 기준으로 테이블이 생성되고, 스키마가 변경된다.
Entity 클래스가 변경되면 여러 클래스에 영향을 끼치지만,
Request와 Response용 Dto는 View를 위한 클래스이기 때문에 자주 변경이 필요하다.
때문에 View Layer와 DB Layer의 역할 분리를 철저하게 해줘야 한다.
Controller에서 결괏값으로 여러 테이블을 조인해서 줘야 할 경우 -> Entity 클래스만으로 표현하기 어려움
> 절대로 Entity 클래스를 Request/Response 클래스로 사용해서는 안된다.
👑 수정/조회
Controller
@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);
}
Dto (수정)
@Getter
@NoArgsConstructor
public class PostsUpdateRequestDto {
private String title;
private String content;
@Builder
public PostsUpdateRequestDto(String title, String content) {
this.title = title;
this.content = content;
}
}
Dto (조회)
@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();
}
}
Service
@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);
}
🔎 JPA의 더티 체킹을 이용하면 Update 쿼리 없이 테이블 수정이 가능하다
위 코드를 보면 update 기능에서 데이터베이스에 쿼리를 날리는 부분이 없다.
이는 JPA의 영속성 컨텍스트 때문이다.
영속성 컨텍스트
엔티티를 영구 저장하는 환경
JPA의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함되어 있냐 아니냐로 갈린다.
JPA의 엔티티 매니저가 활성화된 상태로(Spring Data Jpa를 쓴다면 기본 옵션) 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태이다.
이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영한다.
Entity 객체의 값만 변경하면 별도로 Update 쿼리를 날릴 필요가 없다는 것!
- 이 개념을 더티체킹 dirty checking 이라고 한다.
🔎 JPA Auditing을 이용하여 등록/수정 시간을 자동화하는 방법
👉 domain 패키지에 BaseTimeEntity 클래스를 생성
- BaseTimeEntity 클래스는 모든 Entity의 상위 클래스가 되어
Entity들의 createDate, modifiedDate를 자동으로 관리하는 역할이다.
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {
@CreatedDate
private LocalDateTime createdDate;
@LastModifiedDate
private LocalDateTime modifiedDate;
}
@MappedSuperclass
JPA Entity 클래스들이 BaseTimeEntity을 상속할 경우 필드들도 칼럼으로 인식하도록 한다.
@EntityListensers(AuditingEntityListener.class)
BaseTimeEntity 클래스에 Auditing 기능을 포함시킨다.
@CreatedDate
Entity가 생성되어 저장될 때 시간이 자동 저장된다.
@LastModifiedDate
조회한 Entity의 값을 변경할 때 시간이 자동 저장된다.
👉 Posts 클래스가 BaseTimeEntity를 상속받도록 변경
public class Posts extends BaseTimeEntity {
...
}
👉 Application 클래스에 활성화 어노테이션 추가
@EnableJpaAuditing // JPA Auditing 활성화
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
테스트 코드를 통해 시간 저장이 된 것을 확인할 수 있다!
✨ 해당 실습 코드는 GitHub에 업로드 합니다.