구현체 교체의 용이성
저장소 교체의 용이성
build.gradle에 추가합시다
implementation('org.springframework.boot:spring-boot-starter-data-jpa')
implementation('com.h2database:h2')
Spring Data JPA
스프링 부트용 Spring Data Jpa 추상화 라이브러리
스프링 부트 버전에 맞쳐 자동으로 JPA관련 라이브러리들의 버전을 관리함
h2
인메모리 관계형 데이터베이스
별도의 설치가 필요없이 프로젝트 의존성만으로 관리 가능
메모리에 실행되기 때문에 애플리케이션 재시작할 때마다 초기화 된다는 점에 테스트 용도로 씀
domain 패키지를 하나 만든다
domain패키지는 도메인을 담을 패키지 입니다
domain 패키지에 posts 패키지를 추가합니다
Posts클래스
@Getter
@NoArgsConstructor
@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;
}
public void update(String tile, String content){
this.title = title;
this.content = content;
}
@Entity
테이블과 링크될 클래스임을 나타냄
기본값으로 클래스의 카멜케이스 이름을 언더스코어 네이밍(_)으로 테이블 이름을 매칭
name이라는 속성으로 엔티티명을 바꿀수 있음
@Id
해당 테이블의 PK필드를 나타냄
@GeneratedValue
PK의 생성 규칙을 나타냄
@Column
테이블의 컬럼을 나타내며 굳이 선언하지 않더라도 해당 클래스의 필드는 모드 컬럼이 됨
사용하는 이유는, 기본값 외에 추가로 변경이 필요한 옵션이 있다면 사용함
@NoArgsConstructor
기본 생성자 자동 추가
@Getter
클래스 내 모든 필드의 Getter 메소드를 자동생성
@Builder
해당 클래스이 빌드 패턴 클래스를 생성
생성자 상단에 선언시 생성자에 포함된 필드만 빌더에 포함
Posts클래스로 Database를 접근하기 위해서 JpaRepository를 생성합시다.
JpaRepository
public interface PostsRepository extends JpaRepository<Posts, Long> {
}
save, findAll 기능을 테스트 할 PostsRepositoryTest 클래스를 만듭시다.
PostsRepositoryTest
package com.jojoldu.book.springboot.domain.posts;
import org.assertj.core.api.Assertions;
import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
@RunWith(SpringRunner.class)
@SpringBootTest
public class PostsRepositoryTest {
@Autowired
PostsRepository postsRepository;
@After
public void cleanup() {
postsRepository.deleteAll();
}
@Test
public void 게시글저장_불러오기() throws Exception{
//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에서 단위 테스트가 끝날 때마다 수행되는 메소드를 지정
보통은 배포 전 전체 테스트를 수행할 때 테스트간 데이터 침범을 막기 위해 사용함
여러 테스트가 동시에 수행되면 테스트용 데이터베이스인 H2에 데이터가 그대로 남기때문에
다음 테스트 실행시 테스트가 실패 할 수 있음
postsRepository.save
테이블 posts에 insert/updata 쿼리를 실행
id 값이 있다면 update가, 없다면 insert쿼리가 실행
postsRepository.findAll
테이블 posts에 있는 모든 데이터를 조회하는 메소드
실제로 실행된 쿼리를 보고 싶으면 src/main/resources 디렉토리 아래에 application.properties 파일을 생성해야 합니다.
spring.jpa.show-sql=true
추가하면 테스트를 수행할 때 쿼리 로그를 확인할 수 있습니다.
먼저 Spring 웹계층부터 알아야한다.
Web Layer
흔히 사용하는 컨틀롤러와 JSP/Freemarker등의 뷰 템블릿 영역
이외에도 필터,인터셉터,컨틀로러 어드바이스등 외부 요청과 응답에 전체적인 영역을 의미
Service Layer
@Service에 사용되는 서비스 영역
일반적으로 Contorller와 Dto중간 영역에 사용됨
@Transactional이 사용되어야 하는 영역임
Repository Layer
Database와 같이 데이터 저장소에 접근하는 영역
Dtos
계층 간에 데이터 교환을 위한 객체
Domain Model
개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화시킨것
서비스 클래스에 모든 로직을 처리하면 안된다.
만약 할경우 서비스 계층이 무이믜하며, 객체란 단순한 데이터 덩어리 역활만 하게 되기 때문이다.
그래서 모든 로직은 도메인에 처리하고, 서비스 메서드에는 트랜잭션과 도메인 간의 순서만 보장해야한다.
web 패키지에 PostsApiContorller클래스, web.dto 패키지에 PostsSaveRequestDto를,
service.posts패키지에 PostsService를 생성합니다.
PostsApiContorller
@RequiredArgsConstructor
@RestController
public class PostsApiController {
private final PostsService postsService;
@PostMapping("/api/v1/posts")
public Long save(@RequestBody PostsSaveRequestDto requestDto) {
return postsService.save(requestDto);
}
}
PostsService
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
@Transactional
public Long save(PostsSaveRequestDto requestDto){
return postsRepository.save(requestDto.toEntity()).getId();
}
}
PostsSaveRequestDto
@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();
}
}
등록 기능이 만들었으니 테스트 코드로 검증을 합시다.
PostsApiContorllerTest
@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_등록된다() 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);
}
}
등록 기능을 완성했으니 수정/조회기능도 추가하자
PostsAppController 추가 코드
@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);
}
PostsResponseDto
@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();
}
}
PostsUpdateRequestDto
@Getter
@NoArgsConstructor
public class PostsUpdateRequestDto {
private String title;
private String content;
@Builder
public PostsUpdateRequestDto(String title, String content) {
this.title = title;
this.content = content;
}
}
Posts 추가 코드
public void update(String tile, String content){
this.title = title;
this.content = content;
}
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;
}
@Transactional
public PostsResponseDto findById(Long id) {
Posts entity = postsRepository.findById(id).orElseThrow(() ->
new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
return new PostsResponseDto(entity);
}
updata 기능에서 데이터 베이스에 쿼리를 날리는 부분이 다
그 이유는 JPA의 영속성 컨텍스트 때문이다
영속성 컨텍스트란
엔티티를 영구 저정하는 환경
JPA의 엔티티 매니저가 활성화된 상태로 트랜잭션 안에서 데이터 베이스에서 데이터를 가져오면
이 데이터는 영속성 컨텍스트가 유지된 상태입니다.
이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영합니다.
즉 엔티티 객체의 값만 변경하면 별로도 Update쿼리를 날릴 필요가 없습니다.
이개념을 더티 체킹이라고 합니다.
수정기능 테스트 코드 추가
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);
}
BaseTimeEntity
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseTimeEntity {
@CreatedDate
private LocalDateTime createDate;
@LastModifiedDate
private LocalDateTime modifienDate;
}
@MappedSuperclass
JPA Entity클래스들이 BaseTimeEntity을 상속할 경우 필드들도 컬럼으로 인식
@EntityListeners(AuditingEntityListener.class)
BaseTimeEntity 클래스에 Auditing 기능을 포함함
@CreatedDate
Entity가 생성되어 저장할 때 시간이 자동 저장됨
@LastModifiedDate
조회한 Entity의 값을 변경할 때 시간이 자동 저장됨
이렇게 만든 BaseTimeEntity를 Posts에 상속하자
public class Posts extends BaseTimeEntity
JPA Auditing 테스트 코드
PostsRepositoryTest에 추가하자
@Test
public void BaseTimeEntity_등록() throws Exception{
//given
LocalDateTime now = LocalDateTime.of(2022, 4, 11, 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.getCreateDate() +
", modifiedDate=" + posts.getModifienDate());
assertThat(posts.getCreateDate()).isEqualTo(now);
assertThat(posts.getModifienDate()).isEqualTo(now);
}
수행하면 실제 시간이 저장될것을 알수 있다.