JPA 소개
현대의 웹 애플리케이션에서 관계형 데이터베이스는 빠질 수 없는 요소이다. Oracle, MySQL, MSSQL 등을 쓰지 않는 웹 애플리케이션은 거의 없다. 그러다 보니 객체를 관계형 데이터베이스에서 관리하는 것이 무엇보다 중요하다.
관계형 데이터베이스가 계속해서 웹 서비스의 중김이 되면서 모든 코드는 SQL 중심이 되어 간다. 이는 관계형 데이터베이스가 SQL만 인식할 수 있기 때문인데, SQL로만 가능하니 각 테이블마다 기본적인 (CRUD)를 매번 생성해야 된다.
개발자가 아무리 자바 클래스를 아름답게 설계해도, SQL을 통해야만 데이터베이스에 저장하고, 조회할 수 있다. 결국 관계형 데이터베이스를 사용해야만 하는 상황에서 SQL은 피할 수 없다.
JPA는 이런 문제점을 해결하기 위해 등장하게 된다. 즉, 개발자는 객체 지향적으로 프로그래밍을 하고, JPA가 관계형 데이터베이스에 맞게 SQL을 대신 생성해서 실행한다. 개발자는 항상 객체지향적으로 코드를 표현할 수 있으니 더는 SQL에 종속적인 개발을 하지 않아도 된다.
더 자세한 설명은 다음 포스팅을 확인하자.
https://myeongju00.tistory.com/35?category=1023488
요구사항 분석
- 게시판 기능
- 게시글 조회
- 게시글 등록
- 게시글 수정
- 게시글 삭제
- 회원 기능
- 구글 / 네이버 로그인
- 로그인한 사용자 글 작성 권한
- 본인 작성 글에 대한 권한 관리
프로젝트에 Spring Data Jpa 적용하기
일단, springboot 패키지 밑에 domain 패키지를 만들자. 이 domain 패키지는 도메인을 담을 패키지이다. 여기서 도메인이란 게시글, 댓글, 회원, 정산, 결제 등 소프트웨어에 대한 요구사항 혹은 문제 영역이라고 생각하면 된다.
기존의 MyBatis와 같은 쿼리 매퍼를 사용했다면, dao 패키지를 떠올리겠지만, dao 패키지와는 조금 결이 다르다고 생각하면 된다. 그간 xml에 쿼리를 담고, 클래스는 오로지 쿼리의 결과만 담던 일들이 모두 도메인 클래스라고 불리는 곳에서 해결된다.
도메인이란 용어가 조금 어색할 수 있지만 차근차근 따라 해 보자.
domain 패키지에 posts 패키지와 Posts 클래스를 만들어보자.
src/main/java/com/jojoldu/book/springboot/domain/posts/Posts.java
package com.jojoldu.book.springboot.domain.posts;
import com.jojoldu.book.springboot.domain.BaseTimeEntity;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@NoArgsConstructor
@Getter
@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;
}
}
- Post 클래스는 실제 DB의 테이블과 매칭 될 클래스. (Entity 클래스)
- JPA를 사용하면 DB에 작업할 경우, 이 Entity 클래스의 수정을 통해 작업을 진행.
- 어노테이션 순서 : 주요 어노테이션을 클래스에 가깝게 둔다.
@Getter
,@NoArgsConstructor
: 롬복 어노테이션
⇒ 주요 어노테이션인@Entity
를 클래스에 가깝게 두고, 롬복 어노테이션을 그 위에 둠.- 이후에 코들린 등의 새 언어 전환으로 롬복이 더 이상 필요 없을 경우, 쉽게 삭제할 수 있음
JPA에서 제공하는 어노테이션들
@Entity
- 테이블과 링크될 클래스임을 나타냄
- 기본값으로 카멜클래스 이름을 어더 스코어 네이밍으로 테이블 이름을 매칭
- ex) AlesManager.java -> sales_manager table
@Id
- 해당 테이블의 PK 필드
@GeneratedValue
- PK 생성 규칙
@Column
- 테이블의 칼럼. 굳이 선언하지 않더라도 해당 클래스의 필드는 모두 칼럼이 됨
- 사용 이유 : 기본값 이외에 추가로 변경이 필요한 옵션이 있을 때 사용
- 문자열의 경우 : 기본값이 VARCHAR(255)인데, 사이즈를 500으로 늘리고 싶거나, 타입을 TEXT로 변경하고 싶은 경우 등에 사용
@NoArgsConstructor
- 기본 생성자 자동 추가
- Public Post(){}와 같은 효과
@Getter
- 클래스 내 모든 필드의
Getter
메소드를 자동 생성
- 클래스 내 모든 필드의
@Builder
- 해당 클래스의 빌더 패턴 클래스를 생성
- 생성자 상단에 선언 시 생성자에 포함된 필드만 빌더에 포함
❓Post 클래스에는 왜 Setter 메소드가 없을까?
setter을 생성해버리면 해당 클래스의 인스턴스 값들이 언제 어디서 변해야 하는지 코드 상으로 명확하게 구분할 수가 없어, 차후 기능 변경 시 복잡해진다.
따라서 Entity 클래스에서는 절대 Setter 메소드를 만들지 않는다. 대신, 해당 필드의 값 변경이 필요하면 명확히 그 목적과 의도를 나타낼 수 있는 메소드를 추가해야 한다.
❓ 그러면 Setter가 없는데 어떻게 값을 채워서 DB에 삽입해야 할까?
- 기본적인 구조 : 생성자를 통해 최종 값을 채운 후 DB에 삽입
- 값 변경이 필요한 경우 : 해당 이벤트에 맞는 public 메소드를 호출하여 변경
여기서는 생성자 대신 @Builder
를 통해 제공되는 빌더 클래스를 사용한다.
생성자나 빌더나 생성 시점에 값을 채워주는 역할은 똑같다. 다만, 생성자의 경우 지금 채워야 할 필드가 무엇인지 명확히 지정할 수가 없다.
그럼 이제 Post 클래스로 Database를 접근하게 해 줄 JpaRepository를 생성하자.
src/main/java/com/jojoldu/book/springboot/domain/posts/PostsRepository.java
package com.jojoldu.book.springboot.domain.posts;
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 메서드가 자동 생성됨. - ⚠️주의 : Entity 클래스와 기본 Entity Repository는 함께 위치해야 함
-> Entity 클래스는 기본 Repository 없이는 제대로 역할을 할 수 ❌ - 나중에 프로젝트 규모가 커져 도메인별로 프로젝트를 분리해야 한다면, Entity 클래스와 기본 Repository는 함께 움직여야 하므로 도메인 패키지에서 함께 관리
Spring Data JPA 테스트 코드 작성하기
test 디렉토리에 domain.posts 패키지를 생성하고, PostRepositoryTest란 이름으로 생성하자.
src/test/java/com/jojoldu/book/springboot/domain/posts/PostRepositoryTest.java
package com.jojoldu.book.springboot.domain.posts;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
//@WebMvcTest(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostRepositoryTest {
@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
- Junit에서 단위 테스트가 끝날 때마다 수행되는 메소드를 지정
- 보통은 배포 전 전체 테스트를 수행할 때 테스트 간 데이터 침범을 막기 위해 사용
- 여러 테스트가 동시에 수행되면 테스트용 데이터베이스인 H2가 데이터에 그대로 남아 있어 다음 테스트 실행 시 테스트가 실패할 수 있다.
postsRepository.save
- 테이블 posts에 insert/update 쿼리를 실행
- id값이 있다면 update가, 없다면 insert 쿼리가 실행
postsRepository.findAll()
- 테이블 posts에 있는 모든 데이터를 조회해오는 메소드
등록/수정/조회 API 만들기
API를 만들기 위해선 3개의 클래스가 필요하다.
- Request 데이터를 받을 Dto
- API 요청을 받을 Controller
- 트랜잭션, 도메인 기능 간의 순서를 보장하는 Service
- Web Layer
- 흔히 사용하는 컨트롤러(
@Controller
)와 JSP 등의 뷰 템플릿 영역 - 이외에도 필터(
@Filter
) 등 외부 요청과 응답에 대한 전반적인 영역
- 흔히 사용하는 컨트롤러(
- Service Layer
@Service
에 사용되는 영역입니다.- 일반적으로 Controller와 Dao의 중간영역에서 사용
@Transactional
이 사용되어야 하는 영역이기도 합니다.
- Repository Layer
- DB와 같이 데이터 저장소에 접근하는 영역입니다.
- DTOs
- Dto(Data Transfer Object): 계층 간에 데이터 교환을 위한 객체, Dtos는 이들의 영역.
- 예) 뷰 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체 등
- Domain Model
- 도메인이라 불리는 개발 대상을 모두 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화시킨 것을 도메인 모델이라고 합니다.
@Entity
가 사용된 영역. 단, 무조건 DB Table과 관계가 있어야 하는 것은 ❌
❓Web, Service, Repository, Dto, Domain, 이 5가지 레이어에서 비즈니스 처리를 담당해야 할 곳은 어디일까?
⇒ 바로 Domain이다.
기존에 서비스로 처리하던 방식을 트랜잭션 스크립트라고 한다. 주문 취소 로직을 작성한다면 다음과 같다.
## 슈도 코드
@Transactional
public Order cancelOrder(int orderId) {
1) DB 로 부터 주문 정보 (Orders), 결제정보(Billing), 배송정보(Delivery) 조회
2) 배송 취소를 해야하는지 확인
3) if(배송중이면) {
배송 취소로 변경
}
4) 각 테이블에 취소 상태 Update
}
이 코드를 트랜잭션 스크립트 방식으로 처리해보자.
@Transactional
public Order cancleOrder(int orderId) {
// 1)
OrderDto order = ordersDao.selectOrders(orderId);
BillingDto billing = billingDao.selectBilling(orderId);
DeliveryDto delivery = deliveryDao.selectDelivery(orderId);
// 2)
String deliveryStatus = delivery.getStatus();
// 3)
if ("IN_PROGRSS".equals(deliveryStatus)) {
delivery.setStatus("CANCEL");
deliveryDao.update("delivery");
}
// 4)
order.setStatus("CANCLE");
orderDao.update(order);
// 5)
billing.setStatus("CANCLE");
billingDao.update(billing);
return order;
}
모든 로직이 서비스 클래스 내부에서 처리됩니다. 그러다 보니 서비스 계층이 무의미하며, 객체란 단순히 데이터 덩어리 역할만 하게 된다. 반면 도메인 모델에서 처리할 경우, 다음과 같은 코드가 될 수 있다.
@Transactional
public Order cancelOrder(int orderId) {
// 1)
Orders order = ordersRepository.findOrderById(orderId);
Billings billing = billingRepository.findBillingByOrderId(orderId);
Deliverys delivery = deliveryRepository.findDeliveryByOrderId(orderId);
// 2-3)
delivery.cancel();
// 4)
order.cancel();
billing.cancle();
return order;
}
Order, billing, delivery 가 각자 본인의 취소 이벤트 처리를 하며, 서비스 메소드는 트랜잭션과 도메인 간의 순서만 보장해준다. 이후 코드를 구성할 땐 도메인에서 서비스로직을 처리하는 식의 코드를 작성해보자.
등록
PostsApicontroller를 web 패키지에, PostSaveRequestDto를 web.dto 패키지에, PostsService를 service.posts 패키지에 생성한다.
src/main/java/com/jojoldu/book/springboot/web/PostApiController.java
package com.jojoldu.book.springboot.web;
import com.jojoldu.book.springboot.service.PostsService;
import com.jojoldu.book.springboot.web.dto.PostsResponseDto;
import com.jojoldu.book.springboot.web.dto.PostsSaveRequestDto;
import com.jojoldu.book.springboot.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
@RestController
public class PostApiController {
private final PostsService postsService;
@PostMapping("/api/v1/posts")
public long save(@RequestBody PostsSaveRequestDto requestDto) {
return postsService.save(requestDto);
}
}
src/main/java/com/jojoldu/book/springboot/service/PostsService.java
package com.jojoldu.book.springboot.service;
import com.jojoldu.book.springboot.domain.posts.Posts;
import com.jojoldu.book.springboot.domain.posts.PostsRepository;
import com.jojoldu.book.springboot.web.dto.PostsResponseDto;
import com.jojoldu.book.springboot.web.dto.PostsSaveRequestDto;
import com.jojoldu.book.springboot.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import javax.transaction.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 클래스를 생성해보자.
src/main/java/com/jojoldu/book/springboot/web/dto/PostsSaveRequestDto.java
package com.jojoldu.book.springboot.web.dto;
import com.jojoldu.book.springboot.domain.posts.Posts;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@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 클래스를 변경하는 것은 너무 큰 변경이다.
수많은 서비스 클래스나 비즈니스 로직들이 Entity 클래스를 기준으로 동작한다. Entity 클래스가 변경되면 여러 클래스에 영향을 끼치지만, Request와 Response용 Dto는 View를 위한 클래스라 정말 자주 변경이 필요하다.
View Layer와 DB Layer의 역할 분리를 철저하게 하는 것이 좋다. 실제로 Controller에서 결과값으로 여러 테이블을 조인해서 줘야 할 경우가 빈번하므로 Entity 클래스만으로 표현하기가 어려운 경우가 많다.
꼭 Entity 클래스와 Controller에서 쓸 Dto는 분리해서 사용해야 한다.
등록 기능의 코드가 완성되었으니. 테스트 코드로 검증해보자.
src/test/java/com/jojoldu/book/springboot/web/PostsApiControllerTest.java
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 com.jojoldu.book.springboot.web.dto.PostsUpdateRequestDto;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
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.test.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@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() {
postsRepository.deleteAll();
}
@Test
void Posts_등록된다() throws Exception {
//given
String title = "title";
String content = "content";
String author = "author";
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> postsList = postsRepository.findAll();
assertThat(postsList.get(0).getTitle()).isEqualTo(title);
assertThat(postsList.get(0).getContent()).isEqualTo(content);
assertThat(postsList.get(0).getAuthor()).isEqualTo(author);
}
}
- HelloController와 달리 @WebMvcTest를 사용 ❌
@WebMvcTest
의 경우 JPA 기능이 작동하지 않기 때문
: Controller와 ControllerAdvice 등 외부 연동과 관련된 부분만 활성화되니 지금 같이 JPA 기능까지 한 번에 테스트할 때는@SpringBootTest
와TestRestTemplate
을 사용하면 된다.
RANDOM_PORT 를 사용하기 때문에 @SpringBootTest
의 기본값인 Mock 웹서버를 띄우는 것과 다르게 실제로 Tomcat 을 띄우고 포트를 랜덤으로 생성해 띄운다. Tomcat 은 기본값으로 8080 으로 띄워지지만 실제로 배포할 땐 8080이 다른 애플리케이션에서 사용할 수도 있고 악의적인 의도를 가진 사용자가 8080 번으로 Spring Boot 가 띄워진것을 알고 공격을 할 수 있기 때문에 8080으로 띄우지 않은 경우가 있다.
따라서 최대한 배포 환경과 비슷하게 배포하기 위해 RANDOM_PORT 를 사용하였다.
수정, 조회
src/main/java/com/jojoldu/book/springboot/web/PostApiController.java
package com.jojoldu.book.springboot.web;
import com.jojoldu.book.springboot.service.PostsService;
import com.jojoldu.book.springboot.web.dto.PostsResponseDto;
import com.jojoldu.book.springboot.web.dto.PostsSaveRequestDto;
import com.jojoldu.book.springboot.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
@RestController
public class PostApiController {
private final PostsService postsService;
...
@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);
}
}
src/main/java/com/jojoldu/book/springboot/web/dto/PostsResponseDto.java
package com.jojoldu.book.springboot.web.dto;
import com.jojoldu.book.springboot.domain.posts.Posts;
import lombok.Getter;
@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();
}
}
src/main/java/com/jojoldu/book/springboot/web/dto/PostsUpdateRequestDto.java
package com.jojoldu.book.springboot.web.dto;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
public class PostsUpdateRequestDto {
private String title;
private String content;
@Builder
public PostsUpdateRequestDto(String title, String content) {
this.title = title;
this.content = content;
}
}
src/main/java/com/jojoldu/book/springboot/domain/posts/Posts.java
public class Posts {
...
public void update(String title, String content) {
this.title = title;
this.content = content;
}
}
src/main/java/com/jojoldu/book/springboot/service/PostsService.java
package com.jojoldu.book.springboot.service;
import com.jojoldu.book.springboot.domain.posts.Posts;
import com.jojoldu.book.springboot.domain.posts.PostsRepository;
import com.jojoldu.book.springboot.web.dto.PostsResponseDto;
import com.jojoldu.book.springboot.web.dto.PostsSaveRequestDto;
import com.jojoldu.book.springboot.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
...
@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 쿼리를 수행하는지 테스트 코드로 확인해보자.
src/test/java/com/jojoldu/book/springboot/web/PostsApiControllerTest.java
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 com.jojoldu.book.springboot.web.dto.PostsUpdateRequestDto;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
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.test.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {
...
@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를 사용한다. 메모리에서 실행하기 때문에 직접 접근하려면 웹 콘솔을 사용해야 한다. applicaion.properties에 다음과 같이 옵션을 추가한다.
spring.h2.console.enabled=true
추가한 뒤, Application 클래스의 main 메소드를 실행해보자. 이 후 application 클래스의 main 메소드를 실행하자. 이 후 인터넷으로 아래의 url로 접근하도록 하자.
http://localhost:8080/h2-console
아래와 같은 창이 나타날 것인데 JDBC URL을 다음과 같이 바꾼 후 connect를 하도록 하자.
select 쿼리를 실행하면 아무것도 없음을 확인할 수 있으며, insert쿼리를 실행해 API로 내용을 조회해 보도록 하자.
insert into posts (author, content, title) values ('author', 'content', 'title');
http://localhost:8080/api/v1/posts/1
JPA Auditing 으로 생성시간/수정시간 자동화 하기
보통의 Entity 들은 생성 시간과 수정시간이 포함됩니다. 언제 만들어졌는지, 언제 수정되었는지 등은 차후 유지보수에 있어 굉장히 중요한 정보이기 때문이다. 그렇다 보니 매번 DB에 삽입하기 전, 갱신하기 전에 날짜 데이터를 등록/수정하는 코드가 여기저기 필요하다. 하지만 이를 등록하거나 수정할 때 하나하나 넣는 것은 매우 번거롭고, 가독성 측면에서도 좋지 않다.
이 문제를 해결하고자 우리는 JPA Auditing을 사용해보자.
LocalDate 사용
Java8부터 LocalDate와 LocalDateTime이 등장했다. Java의 기본 타입인 Date의 문제점을 제대로 고친 타입이라 Java 8일 경우, 무조건 써야한다고 생각하자.
domain 패키지에 BaseTimeEntity 클래스를 생성하자.
src/main/java/com/jojoldu/book/springboot/domain/BaseTimeEntity.java
package com.jojoldu.book.springboot.domain;
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 createdDate;
@LastModifiedDate
private LocalDateTime modifiedDate;
}
@MappedSuperclass
- JPA Entity 클래스들이 해당 클래스를 상속할 경우 해당 클래스의 필드들도 칼럼으로 인식하게 된다.
@EntityListeners(AuditingEntityListener.class)
- BaseTimeEntity 클래스에 Auditing 기능을 포함시킨다.
- 참고로 Audit 이란 감사(Inspect) 란 뜻이 있습니다.
@CreatedDate
- Entity 가 생성되어 저장될 때 시간이 자동으로 저장
@LastModifiedDate
- Entity 의 값을 변경할 때 시간이 자동으로 저장
JPA Auditing 어노테이션들을 모두 활성화 하려면, Application 클래스에 활성화 어노테이션을 하나 추가해줘야 한다.
@EnableJpaAuditing
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
JPA Auditing 테스트 코드 작성하기
package com.jojoldu.book.springboot.domain.posts;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostRepositoryTest {
...
@Test
void BaseEntity_등록() {
//given
LocalDateTime now = LocalDateTime.of(2022, 7, 5, 0, 0, 0);
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);
}
}
테스트를 돌려보면, 잘 되는 것을 확인할 수 있다.