3 JPA
Posts 클래스는 실제 DB의 테이블과 매칭될 클래스로 Entity 클래스
라고 부른다.
DB데이터에 작업할 경우 실제 쿼리를 날리기 보단 이 Entity 클래스의 수정을 통해 작업
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 package com.jojoldu.book.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 클래스는 Setter 메소드를 만들지 않고 해당 필드의 값 변경이 필요하면 명확히 그 목적과 의도를 나타낼 수 있는 메소드를 추가해야 한다.
1 2 3 4 5 6 7 8 public class Order { public void cancelOrder () { this .status = false ; } } public void 주문서비스의_취소이벤트() { order.cancelOrder(); }
기본적인 구조는 생성자를 통해 최종값을 채운 후 DB에 삽입하는 것이고 값 변경이 필요하면 해당 이벤트에 맞는 public 메소드를 호출하여 변경하는 것을 전제로 한다.
생성시점에 값을 넣는 방법으로 생성자와 @Builder 를 통해 제공되는 빌더 클래스를 사용할 수 있는데 차이점이 있다면
생성자는 new Example(b,a)처럼 파라미터의 위치를 바꾸더라도 실제 실행 전까지 문제를 찾기 어렵다.
1 2 3 4 public Example (String a, String b) { this .a = a; this .b = b; }
빌더를 사용한다면 어느 필드에 어떤 값을 채워야할 지 명확하게 인지할 수 있다.
1 2 3 4 Example.builder() .a(a) .b(b) .build();
Posts클래스(Entity) 생성이 끝나면 해당 클래스로 Database를 접근하게 해줄 JpaRepository를 생성한다.
src/main/java/com/jojoldu/book/springboot/domain/posts/PostsRepository
1 2 3 4 import org.springframework.data.jpa.repository.JpaRepository;public interface PostsRepository extends JpaRepository <Posts, Long> {}
보통 ibatis나 MyBatis 등에서 Dao라고 불리는 DB Layer 접근자를 JPA에선 Repository 라고 부르며 인터페이스로 생성한다.
생성 후, JpaRepository<Entity 클래스, PK 타입>
를 상속하면 기본적인 CRUD 메소드가 자동으로 생성된다.
@Repositry 어노테이션을 추가할 필요는 없지만 Entity 클래스와 기본 Entity Repository는 함께 위치해야 한다.
생성한 Repository 를 테스트하기 위해 아래 코드를 작성하며 테스트할 기능은 save , findAll 기능이다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 package com.jojoldu.book.springboot.domain.posts;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;import static org.assertj.core.api.Assertions.assertThat;@ExtendWith(SpringExtension.class) @SpringBootTest public class PostsRepositoryTest { @Autowired PostsRepository postsRepository; @AfterEach public void cleanup () { postsRepository.deleteAll(); } @Test public void 게시글저장_불러오기() { String title = "테스트 게시글" ; String content = "테스트 본문" ; postsRepository.save(Posts.builder() .title(title) .content(content) .author("jojoldu@gmail.com" ) .build()); List<Posts> postsList = postsRepository.findAll(); Posts posts = postsList.get(0 ); assertThat(posts.getTitle()).isEqualTo(title); assertThat(posts.getContent()).isEqualTo(content); } }
3.4 등록/수정/조회 API 만들기
API를 만들기 위해 총 3개의 클래스가 필요하다.
Request 데이터를 받을 Dto
API 요청을 받을 Controller
트랜잭션, 도메인 기능 간의 순서를 보장하는 Service
여기서 Service는 비지니스 로직을 처리하는 것이 아니라 트랜잭션, 도메인 간 순서 보장의 역할만 한다.
Web, Service, Repository, Dto, Domain 이 5가지 레이어에서 비지니스 처리를 담당해야 할 곳은 **Domain
**이다.
기존에 서비스로 처리하던 방식을 트랜잭션 스크립트 라고 한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 @Transactional public Order cancelOrder (int orderId) { OrdersDto order = ordersDao.selectOrders(orderId); BillingDto billing = billingDao.selectBilling(orderId); DeliveryDto delivery = deliveryDao.selectDelivery(orderId); String deliveryStatus = delivery.getStatus(); if ("IN_PROGRESS" .equals(deliveryStatus)){ delivery.setStatus("CANCEL" ); deliveryDao.update(delivery); } order.setStatus("CANCEL" ); orderDao.update(order); billing.setStatus("CANCEL" ); deliveryDao.update(billing); return order; }
모든 로직이 서비스 클래스 내부에서 처리된다면 서비스 계층이 무의미하며, 객체란 단순히 데이터 덩어리 역할만 하게 된다.
반면 도메인 모델에서 처리할 경우 아래와 같은 코드가 될 수 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @Transactional Public Order cancelOrder (int orderId) { OrdersDto order = ordersDao.selectOrders(orderId); BillingDto billing = billingDao.selectBilling(orderId); DeliveryDto delivery = deliveryDao.selectDelivery(orderId); delivery.cancel(); order.cancel(); billing.cancel(); return order; }
order, billing, delivery가 각자 본인의 취소 이벤트 처리를 하며, 서비스 메소드는 트랜잭션과 도메인 간의 순서만 보장해 준다.
스프링에서 Bean을 주입하는 방식은 다음과 같다.
가장 권장하는 방식은 생성자로 주입 받는 방식이며 @Autowired는 권장하지 않는다.
아래 Service 코드에서 생성자는 직접 쓰지 않고 @RequiredArgsConstructor 어노테이션에서 해결해 준다.
final이 선언된 모든 필드를 인자값으로 하는 생성자를 롬북에서 대신 생성해준다.
이처럼 어노테이션을 사용하는 이유는 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 변경하는 수고를 덜기 위함이다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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;@RequiredArgsConstructor @Service public class PostsService { private final PostsRepository postsRepository; @Transactional public Long save (PostsSaveRequestDto requestDto) { return postsRepository.save(requestDto.toEntity()).getId(); } }
Controller와 Service에서 사용할 Dto 클래스는 언듯 Entity 클래스와 유사한 형태지만 추가로 생성해야한다.
즉, 절대로 Entity 클래스를 Request/Response 클래스로 사용해선 안된다.
이유는 Entity 클래스는 데이터베이스와 맞닿은 핵심 클래스로 화면 변경은 사소한 변경인데 이를 건들기 위해 테이블과 연결된 Entity 클래스를 변경하는 것은 너무 큰 변경이다.
수많은 서비스 클래스나 비즈니스 로직들이 Entity 클래스를 기준으로 동작하며 Entity 클래스가 변경되면 여러 클래스에 영향을 끼지지만
Request/Response 용 Dto는 View를 위한 클래스라 자주 변경이 필요하다.
이처럼 View Layer 와 DB Layer 의 역할을 철저히 분리하는게 좋다.
예를 들어 Controller에서 결과값으로 여러 테이블을 조인해야 하는 경우 Entity 클래스만으로 표현하기 어려운 경우도 있다.
다음은 JPA를 사용한 게시판의 등록 API 테스트 코드이다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 package com.jojoldu.book.springboot.web;import com.jojoldu.book.springboot.domain.posts.Posts;import com.jojoldu.book.springboot.domain.posts.PostsRepository;import com.jojoldu.book.springboot.web.dto.PostsSaveRequestDto;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.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;import static org.assertj.core.api.Assertions.assertThat;@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 { 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" ; ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, Long.class); 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); } }
HelloController와 달리 @WebMvcTest
를 사용하지 않는데 @WebMvcTest의 경우 JPA기능이 작동하지 않기 때문인데 Controller와 ControllerAdvice 등 외부 연동과 관련된 부분만 활성화되니
지금 처럼 JPA 기능까지 한번에 테스트할 때는 @SpringBootTest 와 TestRestTemplate을 사용하면 된다.
JPA를 사용할 때 update 기능에서 데이터베이스에 쿼리를 날리는 부분이 없다.
이게 가능한 이유는 JPA의 영속성 컨텍스트 때문이다.
영속성 컨텍스트란, 엔티티를 영구 저장하는 환경 으로 일종의 논리적 개념이라고 보면 되며 JPA의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함되어 있냐 아니냐로 갈린다.
JPA의 엔티티 매니저가 활성화된 상태로(Spring Data Jpa를 쓴다면 기본 옵션) 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태이다.
이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영 한다. 즉, Entity 객체의 값만 변경하면 별도로 Update쿼리를 날릴 필요가 없는데 이를 더티 체킹(dirty checking)
이라고 한다.
3.5 JPA Auditing으로 생성시간/수정시간 자동화하기
보통 엔티티에는 해당 데이터의 생성시간과 수정시간을 포함하는데 반복적인 코드를 모든 테이블과 서비스 메소드에 포함하면 너무 귀찮으니 JPA Auditing를 사용해보자.
LocalDate 사용
Java8부터 LocalDate와 LocalDateTime이 등장하여 그간 Java의 기본 날짜 타입인 Date의 문제점을 제대로 고쳤으니 꼭 사용하자.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 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 createDate; @LastModifiedDate private LocalDateTime modifiedDate; }
BaseTimeEntity클래스는 모든 Entity의 상위 클래스가 되어 Entity들의 createdDate, modifiedDate를 자동으로 관리하는 역할이다.
@MappedSuperclass
JPA Entity 클래스들이 BaseTimeEntity을 상속할 경우 필드들(createdDate, modifiedDate)도 컬럼으로 인식하도록 한다.
@EntityListeners(AuditingEntityListener.class)
BaseTimeEntity 클래스에 Auditing 기능을 포함시킨다.
CreatedDate
Entity가 생성되어 저장될 때 시간이 자동 저장된다.
LastModifiedDate
조회한 Entity의 값을 변경할 때 시간이 자동 저장된다.
이후 앞서 만든 Posts 클래스가 BaseTimeEntity를 상속받도록 변경한다.
1 2 3 4 ... public class Posts extends BaseTimeEntity { ... }
마지막으로 JPA Auditing 어노테이션들을 모두 활성화할 수 있도록 Application 클래스에 활성화 어노테이션을 추가한다.
1 2 3 4 5 6 7 8 @EnableJpaAuditing @SpringBootApplication public class Application { public static void main (String[] args) { SpringApplication.run(Application.class, args); } }