Goal
1. JPA / Hibernate / Spring Data Jpa 의 관계
2. Spring Data Jpa를 이용하여 관계형 데이터베이스를 객체지향적으로 관리하는 방법
3. JPA의 더티 체킹을 이용하여 Update쿼리 없이 테이블 수정
4. JPA Auditing을 이용하여 등록/수정 시간을 자동화하는 방법
JPA 란?
객체지향적으로 프로그래밍을 하고 SQL에 종속적인 개발을 하지 않도록 중간에서 패러다임 일치를 시켜주기 위한 기술
기존의 개발 문제점
- RDB(관계형 데이터베이스가) 중심이 되면서 코드가 SQL 중심이 되어감
- 각 테이블마다 기본적인 CRUD SQL 매번 생성해야하는 번거러움
- 관계형 데이터베이스와 객체지향 프로그래밍 언어의 패러다임의 불일치 문제
예제1. 프로젝트에 Spring Data Jpa 적용하기
1.1 build.gradle에 dependency 추가하기
implementation('org.springframework.boot:spring-boot-starter-data-jpa')
implementation('com.h2database:h2')
※ 코드 설명
- spring-boot-starter-data-jpa
- 스프링 부트용 Spring Data Jpa 추상화 라이브러리
- 스프링 부트 버전에 맞춰 JPA관련 라이브러리들의 버전 관리
- h2
- 인메모리 관계형 데이터베이스
- 별도의 설치가 필요 없이 프로젝트 의존성으로 관리가능
- 메모리에서 실행되기 때문에 애플리케이션을 재시작할 때마다 초기화된다는 점을 이용하여 테스트 용도로 사용
1.2 domain.posts 패키지 생성후 Posts 클래스 생성
package com.jojoldu.book.springboot.domain.posts;
@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;
}
}
※ 팁 ! 어노테이션 순서를 주요 어노테이션을 클래스에 가깝게 둘 것!
1.3 Posts클래스로 Database를 접근하게 해줄 JpaRepository (PostsRespository 인터페이스) 생성
package com.jojoldu.book.springboot.domain.posts;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PostsRepository extends JpaRepository<Posts, Long> {
}
1.4 Spring Data JPA 테스트 코드 작성 (PostsRespositoryTest)
package com.jojoldu.book.springboot.domain.posts;
@RunWith(SpringRunner.class)
@SpringBootTest
public class PostsRespositoryTest {
@Autowired
PostsRepository postsRepository;
@After
public void cleanup(){
postsRepository.deleteAll();
}
@Test
public void posts_get(){
// given
String title = "테스트 게시글";
String content = "테스트 본문";
postsRepository.save(Posts.builder()
.title(title)
.content(content)
.author("rosiekim@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);
}
}
1.5 PostsRepositoryTest 테스트 결과
예제2. 등록/수정/조회 API 만들기
API 만들기 위해 총 3개의 클래스가 필요
- Request 데이터를 받을 Dto
- API 요청을 받을 Controller
- 트랜잭션, 도메인 기능 간의 순서를 보장하는 Service -> 비지니스 로직을 처리하는 것이 아니라 트랜잭션, 도메인 간 순서만 보장한다.
Spring 웹 계층
- Web Layer
- 흔히 사용하는 컨트롤러와 JSP/Freemarker 등의 뷰 템플릿 영역
- 필터, 인터셉터, 컨트롤러 어드바이스 등 외부 요청과 응답에 대한 전반적인 영역을 말한다
- Service Layer
- @Service에 사용되는 서비스 영역
- 일반적으로 Controlle와 Dao의 중간 영역에서 사용
- @Transactional이 사용되어야 하는 영역
- Repository Layer
- Database와 같이 데이터 저장소에 접근하는 영역
- 기존에는 Dao의 영역
- Dtos
- Dto(Data Transfer Object)는 계층 간에 데이터 교환을 위한 객체를 이야기하며 Dtos는 이들의 영역
- 예를 들어 뷰 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체 등이 여기에 해당
- Domain Model -> 비지니스 처리 담당
- 도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화시킨 것
- 이를테면 택시 앱이라고 하면 배차, 탑승, 요금 등이 모두 도메인이 될 수 있음
- @Entity를 사용해본 경우 @Entity가 사용된 영역 역시 도메인 모델
- 다만, 무조건 데이터베이스와의 관계가 있어야하는 것은 아니다
- VO처럼 값 객체들도 이 영역에 해당
AS-IS
기존에 서비스로 처리하던 방식( 트랜잭션 스크립트 )의 문제점
=> 모든 로직이 서비스 클래스 내부에서 처리되고, 서비스 계층이 무의미, 객체란 단순히 데이터 덩어리 역할
@Transactional
public Order cancelOrder(int orderId){
OrderDto order = orders.selectOrders(orderId);
BillingDto billing = billing.selectBilling(orderId);
DeliveryDto delivery = delivery.selectDelivery(orderId);
String deliveryStatus = delivery.getStatus();
if("IN_PROGRESS".equals(deliveryStatus)){
delivery.setStatus("CANCEL");
deliveryDao.update(delivery);
}
order.setStatus("CANCEL");
ordersDao.update(order);
billing.setStaus("CANCEL");
billingDao.update(billing);
return order;
}
TO-BE
=> 각자 본인의 취소 이벤트 처리를하며 서비스 메소드는 트랜잭션과 도메인간의 순서만 보장해준다.
@Transactional
public Order cancelOrder(int orderId){
OrderDto order = orderRepository.findById(orderId);
BillingDto billing = billingRepository.findById(orderId);
DeliveryDto delivery = deliveryRepository.findById(orderId);
delivery.cancel();
order.cancel();
billing.cancel();
return order;
}
2.1 PostsApiController 생성 후 등록 메소드
package com.jojoldu.book.springboot.web;
@RequiredArgsConstructor
@RestController
public class PostsApiController {
private final PostsService postsService;
@PostMapping("/api/v1/posts")
public Long save(@RequestBody PostsSaveRequestDto requestDto){
return postsService.save(requestDto);
}
}
2.2 PostsService 생성 후 등록 메소드
package com.jojoldu.book.springboot.service;
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
@Transactional
public Long save(PostsSaveRequestDto requestDto){
return postsRepository.save(requestDto.toEntity()).getId();
}
}
2.3 PostsSaveRequestDto 생성
package com.jojoldu.book.springboot.web.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();
}
}
2.4 등록 기능을 테스트 코드로 작성해보기
2.4.1 test web 패키지에 PostsApiTestController 생성
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private PostsRepository postsRepository;
@After
public void tearDown() throws Exception {
postsRepository.deleteAll();
}
@Test
public void Posts_register() 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);
}
}
잠깐 ! @WebMvcTest를 사용하지 않는다
@WebMvcTest의 경우 JPA기능이 작동하지 않기에, JPA 까지 한번에 테스트를 할경우 @SpringBootTest와 TestRestTemplate을 사용하면 된다.
2.4.2 Posts 등록 API 테스트 결과
2.5 PostsApiController에 수정/조회 API추가
package com.jojoldu.book.springboot.web;
@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);
}
2.6 PostsResponseDto 생성
package com.jojoldu.book.springboot.web.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();
}
}
2.7 PostsUpdateRequestDto 생성
package com.jojoldu.book.springboot.web.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;
}
}
2.8 Posts에 수정 메소드 추가
public void update(String title, String content){
this.title = title;
this.content = content;
}
2.9 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 기능에서 database에 쿼리를 날리지 않는다
JPA 영속성 컨텍스트 때문이다.
영속성 컨텍스트란, 엔티티를 영구 저장하는 환경이다. JPA의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함되어 있냐 아니냐로 갈린다. JPA의 엔티티 매니저가 활성화된 상태로 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태이다. 이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영한다. => 더티 체킹
2.10 수정/ 조회기능을 테스트 코드로 작성해보기
2.10.1 PostsApiTestController에 테스트 코드 작성
@Test
public void Posts_modify() 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.POST, 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);
}
2.4.2 Posts 수정 API 테스트 결과
예제3. JPA Auditing으로 생성시간/수정시간 자동화하기
3.1 domain 패키지에 BaseTimeEntity 클래스 생성
package com.jojoldu.book.springboot.domain;
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {
@CreatedDate
private LocalDateTime createdDate;
@LastModifiedDate
private LocalDateTime modifiedDate;
}
=> BaseTimeEntity 클래스는 모든 Entity의 상위 클래스가 되어 Entity들의 createdDate, modifiedDate를 자동으로 관리하는 역할
3.2 JPA Auditing 어노테이션들을 모두 활성화할 수 있도록 Application 클래스에 활성화 어노테이션 추가
package com.jojoldu.book.springboot;
@EnableJpaAuditing
@SpringBootApplication
public class Application {
public static void main(String[] args){
SpringApplication.run(Application.class, args);
}
}
3.3 JPA Auditing 테스트 코드 작성
package com.jojoldu.book.springboot.domain.posts;
@Test
public void BaseTimeEntity_register(){
// given
LocalDateTime now = LocalDateTime.of(2019,6,4,0,0,0);
postsRepository.save(Posts.builder()
.title("title")
.content("content")
.content("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);
}
3.3 JPA Auditing 테스트 결과
=> 주의 : 몇번이고 테스트 시에 에러가 났다
에러 메세지 java.lang.AssertionError: Expecting actual not to be null
BaseTimeEntity 클래스의 @EntityListeners(AuditingEntityListener.class)를 @EntityListeners(AutoCloseable.class)로 잘못 등록했음. 자동 완성시에는 항상 주의할 것!!
※ 1.2의 코드 설명
- @Entity
- 테이블과 링크될 클래스임을 나타낸다
- 기본값으로 클래스의 카멜케이스 이름을 언더스코어 네이밍(_)으로 테이블 이름을 매칭
- @Id
- 해당 테이블의 PK 필드를 난타낸다
- @GeneratedValue
- PK의 생성규칙을 따른다
- 스프링부트 2.0DPTJSMS GenerationType.IDENTITY 옵션을 추가해야만 auto_increment가 된다
- @Column
- 테이블의 컬럼을 나타내며 굳이 선언하지 않아도 해당 클래스의 필드는 모두 컬럼이 된다.
- 사용하는 이유는 기본값 외에 추가로 변경이 필요한 옵션이 있으면 사용한다.
- 문자열의 경우 VARCHAR(255)가 기본값인데, 사이즈를 500으로 늘리고 싶거나(ex:title), 타입을 TEXT 변경하고 싶거나(ex:content)등의 경우에 사용된다.
- @NoArsConstructor
- 기본 생성자 자동 추가
- pulbic Posts(){} 와 같은 효과
- @Getter
- 클래스 내 모든 필드의 Getter메소드 자동생성
- @Builder
- 해당 클래스의 빌더 패턴 클래스 생성
- 생성자 상단에 선언 시 생성자에 포함된 필드만 빌더에 포함
※ 1.2의 코드 Posts 클래스의 특징
Setter메소드가 없다. 자바빈 규약을 생각하며 getter/setter를 무작정 생성하는데, 이 경우 해당 클래스의 인스턴스 값들이 언제 어디서 변해야 하는지 코드상으로 명확하게 구분이 불가하고 유지보수가 어렵다.
Entity 클래스에서는 절대 Setter 메소드를 만들지 않는다!
// 잘못된 사용의 예
public class Order {
public void setStatus(boolean status){
this.status = status;
}
}
public void 주문서비스의_취소이벤트 {
order.setStaus(false);
}
// 올바른 사용의 예
public class Order {
public void cancelOrder(){
this.status = false;
}
}
public void 주문서비스의_취소이벤트 {
order.cancelOrder()
}
잠깐! Setter가 없는 상황에서 어떻게 DB에 삽입해야 하는가?
생성자를 통해 최종값을 채운 후 DB에 삽입하며, 값 변경이 필요할 시에는 해당 이벤트에 맞는 public 메소드를 호출하여 변경하는 것을 전제로 한다. 여기서는 생성자 대신 @Builder를 통해 제공되는 빌더 클래스를 사용한다.
생성자와 빌더의 역할은 같으나, 생성자의 경우 채워야 될 필드가 무엇인지 명확히 지정할 수 없다.
※ 1.3의 코드 설명
- 보통 MyBatis에서 Dao라고 불리는 DB Layer 접근자이다.
- JPA에서는 Repository라고 부르며 인터페이스로 생성한다.
- 인터페이스 생성 후, JpaRepository<Entity 클래스, PK타입>를 상속하면 기본적인 CRUD메소드가 자동 생성된다.
- @Repository를 추가할 필요가 없다
- Entity 클래스와 기본 Entity Repository는 함께 위치해야한다 (Entity클래스는 기본 Repository없이 제역할 불가)
※ 3.1의 코드 설명
- @MappedSuperclass
- JPA Entity 클래스들이 BaseTimeEntity을 상속할 경우 필드들 (createdDate, modifiedDate)도 칼럼으로 인식
- @EntityListeners(AuditingEntityListener.class)
- BaseTimeEntity 클래스에 Auditing 기능을 포함시킴
- @CreatedDate
- Entity가 생성되어 저장될 때 시간이 자동 저장
- @LastModifiedDate
- 조회한 Entity의 값을 벼경할 때 시간이 자동 저장
'책 > 스프링부트와 AWS로 혼자 구현하는 웹서비스' 카테고리의 다른 글
[스프링 부트와 AWS로 혼자 구현하는 웹 서비스 5장] - 스프링 시큐리티와 OAuth2.0으로 로그인 기능 구현하기 (0) | 2023.03.21 |
---|---|
[스프링 부트와 AWS로 혼자 구현하는 웹 서비스 4장] - 머스테치로 화면 구성하기 (0) | 2023.03.16 |
[스프링 부트와 AWS로 혼자 구현하는 웹 서비스 2장] - 스프링 부트에서 테스트 코드를 작성하자 (0) | 2023.03.13 |