Spring Data JPA1

유요한·2023년 2월 18일
0

JPA

목록 보기
8/10
post-thumbnail

Spring Data JPA

JPA는 인터페이스로서 자바 표준명세서입니다. 인터페이스인 JPA를 사용하기 위해서는 구현체가 필요합니다. 구현체들을 좀 더 쉽게 사용하고자 추상화시킨 Spring Data JPA라는 모듈을 이용하여 JPA 기술을 다룹니다. 이들의 관계를 보면 다음과 같습니다.

JPA ← Hibernate ← Spring Data JPA

Hibernate를 쓰는 것과 Spring Data JPA를 쓰는 것 사이에는 큰 차이가 없습니다. 그럼에도 스프링 진영에서는 Spring Data JPA를 개발했고 이를 권장합니다. Spring Data JPA가 등장한 이유는 크게 두 가지가 있습니다.

  • 구현체 교체의 용이성
  • 저장소 교체의 용이성

먼저, 구현체 교체의 용이성이란 Hibernate 외에 다른 구현체로 쉽게 교체하기 위함입니다.

Hibernate가 언젠가 수명을 다해서 새로운 JPA 구현체가 대세로 떠오를 때 Spring Data JPA를 쓰는 중이라면 아주 쉽게 교체 가능합니다. 내부에서 구현체 매핑을 지원해주기 때문입니다.

저장소 교체의 용이성이란 관계형 데이터베이스 외에 다른 저장소로 쉽게 교체하기 위함입니다. 서비스 초기에는 관계형 데이터베이스로 모든 기능을 처리했지만 점점 트래픽이 많아져 관계형 데이터베이스로는 도저히 감당이 안될 때 올 수 있는데 이 때 MongoDB로 교체가 필요하다면 개발자는 Spring Data JPA에서 Spring Data MongoDB로 의존성만 교체하면 됩니다. 이는 Spring Data의 하위 프로젝트들은 기본적인 CRUD의 인터페이스가 같기 때문입니다.


domain 패키지는 도메인을 담을 패키지입니다. 여기서 도메인이란 게시글, 댓글, 회원, 정산, 결제 등 소프트웨어에 대한 요구사항 혹은 문제영역이라고 생각하면 됩니다.

package com.example.rest_book1.domain;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

// 실제 DB의 테이블과 매칭될 클래스이며 보통 Entity클래스라고 합니다.
// JPA를 사용하면 DB 데이터에 작업을 할경우 실제 쿼리를 날리기 보다는
// 이 Entity 클래스의 수정을 통해 작업을 합니다.

@Getter
@NoArgsConstructor
// JPA 어노테이션
// 테이블과 링크될 클래스임을 나타냄
// 기본값으로 클래스의 카멜케이스 이름을 언더스코어 네이밍(_)으로 테이블 이름을 매칭
@Entity
public class Posts {

    // 해당 테이블의 PK를 나타냄
    @Id
    // PK 생성 규칙을 나타낸다.
    @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;
    }
}

여기서 보면 setter가 빠지고 @Builder 패턴을 사용했는데 setter을 사용하면 해당 클래스의 인스턴스 값들이 언제 어디서 변해야하는지 코드상으로 명확하게 구분할 수가 없어, 차후 기능 변경 시 매우 복잡해집니다.

그래서 Entity 클래스에서는 절대 setter 메소드를 만들지 않고 해당 필드의 값 변경이 필요하면 명확히 그 목적과 의도를 나타낼 수 있는 메소드를 추가해야만 합니다.

예를들어 주문 취소 메소드를 만든다고 가정하면 다음 코드로 비교해보면 됩니다.

그러면 setter 없이 어떻게 값을 채워 DB에 삽입할 수 있을까?

기본적인 구조는 생성자를 통해 최종값을 채운 후 DB에 삽입하는 것이며, 값 변경이 필요한 경우 해당 이벤트에 맞는 public 메소드를 호출하여 변경하는 것을 전제로 합니다.

생성자를 통해 값을 채우는 것을 @Builder를 사용할 수 있습니다.

레포지토리를 하나 생성하겠습니다.

package com.example.rest_book1.repository;

import com.example.rest_book1.domain.Posts;
import org.springframework.data.jpa.repository.JpaRepository;

public interface PostsRepository extends JpaRepository<Posts, Long> {
}

레포지토리는 인터페이스로 생성 후 extends JpaRepository<Entity, PK타입> 하면 기본적인 CRUD 메소드가 자동으로 생성됩니다. @Repository를 추가할 필요도 없습니다. 여기서 주의할 점은 Entity 클래스와 기본 Entity Repository는 함께 위치해야 하는 점입니다. 둘은 아주 밀접한 관계이고, Entity 클래스는 기본 Repository 없이는 제대로 역할을 수행할 수 없습니다. 나중에 프로젝트 규모가 커지면 domain패키지에서 함께 관리합니다.

이제 PostsRepositoryTest를 만들고 save, findAll 기능을 테스트합니다.

package com.example.rest_book1.domain;

import lombok.extern.log4j.Log4j2;
import org.aspectj.lang.annotation.After;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
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.junit.jupiter.api.Assertions.*;

@ExtendWith(SpringExtension.class)
@SpringBootTest
@Log4j2
class PostsRepositoryTest {


    @Autowired
    PostsRepository postsRepository;

    @AfterEach
    public void cleanUp() {
        postsRepository.deleteAll();
    }

    @Test
    @DisplayName("게시글 저장 불러오기")
    public void saveBoardTest() {
        // given
        String title = "테스트 게시글";
        String content = "테스트 본문";

        // 테이블에 posts에 insert/update 쿼리를 실행합니다.
        // id 값이 있다면 update, 없다면 insert 쿼리가 실행됩니다.
        postsRepository.save(Posts.builder()
                .title(title)
                .content(content)
                .author("tester@naver.com")
                .build());

        // when
        // 테이블 posts에 있는 모든 데이터를 조회해오는 메소드입니다.
        List<Posts> postsList = postsRepository.findAll();

        // then
        Posts posts = postsList.get(0);
        log.info("게시물 보기 : " +posts);
        Assertions.assertThat(posts.getTitle()).isEqualTo(title);
        Assertions.assertThat(posts.getContent()).isEqualTo(content);
    }
}

여기서 보면 원래 테스트에서는 log4j2를 사용하지 못했었는데 gradle에 build를 추가하니 된다.

 compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testCompileOnly 'org.projectlombok:lombok'
    testAnnotationProcessor 'org.projectlombok:lombok'

application.yml

spring:
  jpa:
    database-platform: org.hibernate.dialect.MySQL8Dialect
    # hibernate 사용 설정
    hibernate:
      # 애플리케이션 실행 시점에 테이블을 다 지우고, 내가 가진 entity 정보를 보고 다시 테이블 자동 생성
      # if exists drop table 해주고 다시 만들어준다고 보면 된다.
      ddl-auto: create
    properties:
      hibernate:
        # JPA 처리 시에 발생하는 SQL을 보여줄 것인지 결정
        show_sql: true
        # 실제 JPA의 구현체인 Hibernate가 동작하면서 발생하는 SQL을 포맷팅해서 출력합니다.
        # 실행되는 SQL의 가독성을 높여 줍니다.
        format_sql: true
    open-in-view: false



  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/study01
    username: root
    password: 1234

logging.level:
  org.hibernate.SQL: debug
  org.hibernate.type.descriptor: trace

이설정을 추가하고 테스트를 실행하면 다음과 같이 나옵니다.


등록/수정/조회 API 만들기

API를 만들기 위해서 총 3개의 클래스가 필요합니다.

  • Request 데이터를 받을 DTO
  • API 요청을 받을 Controller
  • 트랜잭션, 도메인 기능 간의 순서를 보장하는 Serivce

service에서 비즈니스 로직을 처리해야한다고 오해하고 있습니다. 하지만 service는 트랜잭션, 도메인 간 순서 보장의 역할만 합니다.

그러면 비즈니스 로직은 누가처리하는가?

  • Web Layer

    • 흔히 사용하는 컨트롤러와 JSP/Freemarker 등의 뷰 템플릿 영역입니다.
    • 이외에도 필터(@Filter), 인터셉터, 컨트롤러 어드바이스(@ControllerAdvice)등 외부 요청과 응답에 대한 전반적인 영역을 이야기합니다.
  • Service Layer

    • @Service에 사용되는 서비스 영역입니다.
    • 일반적으로 Controller와 DAO의 중간 영역에서 사용됩니다.
    • @Transactional이 사용되어야 하는 영역이기도 합니다.
  • Repository Layer

    • Database와 같이 데이터 저장소에 접근하는 영역입니다.
    • DAO 영역이라고 보면 됩니다.
  • DTOs

    • DTO(Data Transfer Object)는 계층 간에 데이터 교환을 위한 객체를 이야기하며 DTOs는 이들의 영역을 이야기 합니다.
    • 예를들어, 뷰 템블릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체 등이 이들을 이야기 합니다.
  • Domain Model

    • 도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화시킨 것을 도메인 모델이라고 합니다.
    • 이를테면 택시 앱이라고 하면 배차, 탑승, 요금 등이 모두 도메인이 될 수 있습니다.
    • @Entity를 사용해보신 분들은 @Entity가 사용된 영역 역시 도메인 모델이라고 이해해주시면 됩니다.
    • 다만, 무조건 데이터베이스의 테이블과 관계가 있어야만 하는 것은 아닙니다.
    • VO처럼 값 객체들도 이 영역에 해당하기 때문입니다.

Web, Service, Repository, DTO, Domain 이중에서 비지니스 처리를 담당하는 곳은 Domain입니다.

도메인에서 처리할 경우 다음과 같은 코드가 됩니다.

order, billing, delivery가 각자 본인의 취소 이벤트 처리를 하며, 서비스 메소드는 트랜잭션과 도메인 간의 순서만 보장해 줍니다.

그럼 등록, 수정, 삭제 기능을 만들어 보겠습니다.

PostsApiController

package com.example.rest_book1.controller;

import com.example.rest_book1.dto.PostsSaveRequestsDTO;
import com.example.rest_book1.service.PostsService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
public class PostsApiController {

    private final PostsService postsService;

    @PostMapping("/api/v1/posts")
    public Long save(@RequestBody PostsSaveRequestsDTO postsSaveRequestsDTO) {
        return postsService.save(postsSaveRequestsDTO);
    }
}

PostsService

package com.example.rest_book1.service;

import com.example.rest_book1.domain.PostsRepository;
import com.example.rest_book1.dto.PostsSaveRequestsDTO;
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(PostsSaveRequestsDTO postsSaveRequestsDTO) {
        return postsRepository.save(postsSaveRequestsDTO.toEntity()).getId();
    }
}

PostsSaveRequestDTO

package com.example.rest_book1.dto;

import com.example.rest_book1.domain.Posts;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class PostsSaveRequestsDTO {
    private String title;
    private String content;
    private String author;

    @Builder
    public PostsSaveRequestsDTO(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 클래스가 변경되면 여러 클래스에 영향을 끼치지만 DTO는 view를 위한 클래스라 정말 자주 변경이 필요합니다.

View Layer와 DB Layer의 역할 분리를 철저하게 하는 것이 좋습니다. 실제로 Controller에서 결괏값으로 여러 테이블을 조인해서 줘야 할 경우가 빈번하므로 Entity 클래스만으로 표현하기가 어려운 경우가 많습니다.

꼭 Entity 클래스와 Controller에서 쓸 DTO는 분리해서 사용해야 합니다. 등록 기능의 코드가 완성되었으니 테스트 코드로 검증해 보겠습니다.

이러한 오류가 발생하는 이유는 빈을 생성하지 못했다는 것이다. @Autowired의 기본설정은 required=true 이므로 빈을 찾아서 주입해야 하는데 빈을 생성할 수 없다는 것이다. 그리고 @SpringBootTest 어노테이션에 답이 있는데 @SpringBootTest는 여러 환경의 웹 환경을 만들어서 테스트 할 수 있다. 그 기능으로 실제 정의된 혹은 랜덤의 포트로 웹서버를 띄울 수 있다. 또한 TestRestTemplate 를 열려있는 포트로 자동으로 등록한다. 즉, 열려있는 포트가 없다면 TestRestTemplate를 빈으로 등록할 수 없기 때문에 이러한 오류가 난다. 그래서 랜덤포트를 사용한다는 속성으로 testRestTemplate를 등록할 수 있도록 설정하면 된다.

이렇게 오류가 없어지는 것을 확인할 수 있습니다.

package com.example.rest_book1.controller;

import com.example.rest_book1.domain.Posts;
import com.example.rest_book1.domain.PostsRepository;
import com.example.rest_book1.dto.PostsSaveRequestsDTO;
import org.assertj.core.api.Assertions;
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.test.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.junit.jupiter.api.Assertions.*;

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
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 {
        // given
        String title = "title";
        String content = "content";
        PostsSaveRequestsDTO requestsDTO = PostsSaveRequestsDTO.builder()
                .title(title)
                .content(content)
                .author("author")
                .build();

        String url = "http://localhost:" + port + "/api/v1/posts";

        // when
        // 기본 http 헤더를 사용하며 결과를 ResponseEntity로 반환받는다.
        ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestsDTO, Long.class);

        // then
        Assertions.assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
        Assertions.assertThat(responseEntity.getBody()).isGreaterThan(0L);

        List<Posts> all = postsRepository.findAll();
        Assertions.assertThat(all.get(0).getTitle()).isEqualTo(title);
        Assertions.assertThat(all.get(0).getContent()).isEqualTo(content);
    }


}

TestRestTemplate

TestRestTemplate은 REST 방식으로 개발한 API의 Test를 최적화 하기 위해 만들어진 클래스이다. HTTP 요청 후 데이터를 응답 받을 수 있는 템플릿 객체이며 ResponseEntity와 함께 자주 사용된다. Header와 Content-Type 등을 설정하여 API를 호출 할 수 있다. TestRestTemplate에서 대표적으로 사용되는 것들을 살펴보면,

  1. testRestTemplate.getForObject()
    기본 http 헤더를 사용하며 결과를 객체로 반환받는다.

     ProductDetailResponseDto item =
     this.testRestTemplate.getForObject(url, ProductDetailResponseDto.class);
  2. testRestTemplate.getForEntity()
    마찬가지로 기본 http 헤더를 사용하며 결과를 ResponseEntity로 반환받는다.

    ResponseEntity<Long> responseEntity = 
    this.testRestTemplate.postForEntity(url, productRegisterRequestDto, Long.class);
  3. testRestTemplate.exchange()
    update할때 주로 사용된다. 결과를 ResponseEntity로 반환받는다. Http header를 변경할 수 있다.

   // given
HttpEntity<ProductUpdateRequestDto> requestEntity = 
new HttpEntity<>(productUpdateRequestDto);>
// when
ResponseEntity<Long> responseEntity = 
this.testRestTemplate.exchange(url, HttpMethod.PUT, requestEntity, Long.class);

예제

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class UserControllerTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private UserService userService;

    @AfterEach
    public void tearDown() throws Exception {
        this.userRepository.deleteAll();
    }

    @Test
    public void 회원가입() throws Exception {
        // given
        UserRegisterDto userRegisterDto = UserRegisterDto.builder()
                .nickName("닉네임3")
                .email("asdf@naver.com")
                .password("123456")
                .address("대전광역시")
                .phone("010-0000-0000")
                .role(Role.GUEST)
                .build();

        String url = "http://localhost:" + this.port + "/login/doRegister";

        // when
        ResponseEntity<User> responseEntity = this.restTemplate.postForEntity(url, userRegisterDto, User.class);

        // then
     Assertions.assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
       Assertions.assertThat(responseEntity.getBody().getNickName()).isEqualTo(userRegisterDto.getNickName());

        List<User> all = this.userRepository.findAll();
       Assertions.assertThat(all.get(0).getNickName()).isEqualTo(userRegisterDto.getNickName());
    }
}

ResponseEntity

스프링에서 제공하는 클래스 중에서 HttpEntity라는 클래스가 존재하는데 이는 Http 리퀘스트/리스폰스가 이루어질 때 Http 헤더와 바디를 포함하는 클래스이다. RequestEntity와 ResponseEntity는 이 HttpEntity를 상속받는다. 즉 ResponseEntity는 사용자의 HttpRequest에 대한 응답하는 데이터를 가진다. Http Status, Header, Body를 포함한다.

여기서는 test할 때 HelloController와 달리 @WebMvcTest를 사용하지 않았습니다. @WebMvcTest의 경우 JPA 기능이 작동하지 않기 때문인데 Controller와 ControllerAdvice 등 외부 연동과 관련된 부분만 활성화되니 지금 같이 JPA 기능까지 한번에 테스트 할 때는 @SpringBootTest와 TestRestTemplate을 사용하면 됩니다.

WebEnvironment.RANDOM_PORT로 인한 랜덤 포트 실행과 insert 쿼리가 실행된 것을 모두 확인했습니다. 등록 기능을 확인했으니 수정/조회 기능도 빠르게 만들겠습니다.

package com.example.rest_book1.controller;

import com.example.rest_book1.dto.PostsResponseDTO;
import com.example.rest_book1.dto.PostsSaveRequestsDTO;
import com.example.rest_book1.service.PostsService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RequiredArgsConstructor
@RestController
public class PostsApiController {

    private final PostsService postsService;

    @PostMapping("/api/v1/posts")
    public Long save(@RequestBody PostsSaveRequestsDTO postsSaveRequestsDTO) {
        return postsService.save(postsSaveRequestsDTO);
    }

    @PutMapping("/api/v1/posts/{id}")
    public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDTO requestsDTO) {
        return postsService.update(id,requestsDTO);
    }

    @GetMapping("/api/v1/posts/{id}")
    public PostsResponseDTO findById(@PathVariable Long id) {
        return postsService.findById(id);
    }
}
package com.example.rest_book1.dto;


import com.example.rest_book1.domain.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();
    }
}

PostsResponseDTO는 Entity의 필드 중 일부만 사용하므로 생성자로 Entity를 받아 필드에 값을 넣습니다. 굳이 모든 필드를 가진 생성자가 필요하진 않으므로 DTO는 Entity를 받아 처리합니다.

package com.example.rest_book1.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;
    }
}
package com.example.rest_book1.domain;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.*;

// 실제 DB의 테이블과 매칭될 클래스이며 보통 Entity클래스라고 합니다.
// JPA를 사용하면 DB 데이터에 작업을 할경우 실제 쿼리를 날리기 보다는
// 이 Entity 클래스의 수정을 통해 작업을 합니다.

@Getter
@ToString
// 기본 생성자
@NoArgsConstructor
@Table(name = "posts")
// JPA 어노테이션
// 테이블과 링크될 클래스임을 나타냄
// 기본값으로 클래스의 카멜케이스 이름을 언더스코어 네이밍(_)으로 테이블 이름을 매칭
@Entity
public class Posts {

    // 해당 테이블의 PK를 나타냄
    @Id
    // PK 생성 규칙을 나타낸다.
    @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;
    }
}
package com.example.rest_book1.service;

import com.example.rest_book1.domain.Posts;
import com.example.rest_book1.domain.PostsRepository;
import com.example.rest_book1.dto.PostsResponseDTO;
import com.example.rest_book1.dto.PostsSaveRequestsDTO;
import com.example.rest_book1.dto.PostsUpdateRequestDTO;
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(PostsSaveRequestsDTO postsSaveRequestsDTO) {
        return postsRepository.save(postsSaveRequestsDTO.toEntity()).getId();
    }

    @Transactional
    public Long update(Long id, PostsUpdateRequestDTO requestsDTO) {
        Posts posts = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id = "+id));

        posts.update(requestsDTO.getTitle(), requestsDTO.getContent());

        return id;
    }

    public PostsResponseDTO findById(Long id) {
        Posts entity = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id = "+id));

        return new PostsResponseDTO(entity);
    }
}

여기서 신기한 것이 있습니다. update 기능에서 데이터베이스에 쿼리를 날리는 부분이 없습니다. 이게 가능한 이유는 JPA의 영속성 컨텍스트 때문입니다. 영속성 컨텍스트란, 엔티티를 영구 저장하는 환경입니다. 일종의 논리적 개념이라고 보시면 되며, JPA의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함되어 있냐 아니냐로 갈립니다.

JPA의 엔티티 매너지가 활성화된 상태(Sprig Data JPA를 쓴다면 기본 옵션)로 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태입니다. 이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영합니다. 즉, Entity 객체의 값만 변경하면 별도로 Update 쿼리를 날릴 필요가 없다는 것입니다. 이 개념을 더티 체킹이라고 합니다.

PostsApiControllerTest

...기존것에 추가...
@Test
    @DisplayName("Posts_수정된다 테스트")
    public void test2() {
        // 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클래스는 HTTP요청또는 응답에 해당하는 HttpHeader와 HttpBody를 포함하는 클래스다.
        HttpEntity<PostsUpdateRequestDTO> requestEntity = new HttpEntity<>(requestDTO);

        // when
        ResponseEntity<Long> responseEntity = restTemplate.exchange(
                url, HttpMethod.PUT, requestEntity, Long.class);

        // then
        Assertions.assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
        Assertions.assertThat(responseEntity.getBody()).isGreaterThan(0L);

        List<Posts> all = postsRepository.findAll();
        log.info("-----------------------------------");
        log.info("수정확인 : " + all);
        Assertions.assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
        Assertions.assertThat(all.get(0).getContent()).isEqualTo(expectedContent);

    }

post맨과 브라우저에서 체크해보자!

브라우저

포스트맨

파라미터 추가하기


JPA로 Auditing으로 생성시간/수정시간 자동화하기

보통 엔티티에는 해당 데이터의 생성시간과 수정시간을 포함합니다. 언제 만들어졌는지, 언제 수정되었는지 등은 차후 유지보수에 있어 굉장히 중요한 정보이기 때문입니다. 그렇다보니 매번 DB에 삽입하기 전 갱신 하기 전에 데이터를 등록/수정하는 코드가 여기저기 들어가게 됩니다.

이런 단순하고 반복적인 코드가 모든 테이블과 서비스 메소드에 포함된다면 귀찮고 코드가 지저분해집니다. 그래서 이 문제를 해결하고자 JPA Auditing를 사용하겠습니다.

LocalDate 사용

여기서부터는 날짜 타입을 사용합니다. Java 8부터 LocalDate와 LocalDateTime이 등장했습니다. 그간 Java의 기본날짜 타입인 Date의 문제점을 제대로 고친 타입이라 무조건 써야 합니다.

package com.example.rest_book1.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
// JPA Entity 클래스들이 BaseTimeEntity을 상속할 경우
// 필드들(createdDate, modifiedDate)도 칼럼으로 인식하도록 합니다.
@MappedSuperclass
// BaseTimeEntity 클래스에 Auditing 기능을 포함시킵니다.
@EntityListeners(AuditingEntityListener.class)
public class BaseTimeEntity {

    /*
    *   @CreationTimestamp : 생성된 시간
        @Column(updatable = false) : 수정시에는 관여하지 않음
        @UpdateTimestamp : 업데이트가 발생했을 때 시간
    *   @Column(insertable = false) : 입력시에는 관여하지 않음
    * */
    // Entity가 생성되어 저장될 때 시간이 자동 저장
    @CreatedDate
    private LocalDateTime createdDate;

    // 조회한 Entity의 값을 변경할 때 시간이 자동 저장
    @LastModifiedDate
    private LocalDateTime modifiedDate;
}

BaseTimeEntity 클래스는 모든 Entity의 상위 클래스가 되어 Entity들의 createdDate, modifiedDate를 자동으로 관리하는 역할입니다.

Posts 클래스가 BaseTimeEntity를 상속받습니다.

public class Posts extends BaseTimeEntity {

마지막으로 JPA Auditing 어노테이션들을 모두 활성화할 수 있도록 Application 클래스에 활성화 어노테이션을 추가

RestBook1Application

package com.example.rest_book1;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@SpringBootApplication
// JPA Auditing 활성화
@EnableJpaAuditing
public class RestBook1Application {

    public static void main(String[] args) {
        SpringApplication.run(RestBook1Application.class, args);
    }

}

PostsRepositoryTest

 @Test
    public void BaseTimeEntityTest() {
        // given
        LocalDateTime now = LocalDateTime.of(2022,3, 7,6,51);
        postsRepository.save(Posts.builder()
                        .title("title")
                        .content("content")
                        .author("author")
                .build());

        // when
        List<Posts> postsList = postsRepository.findAll();

        // then
        Posts posts = postsList.get(0);

        log.info(">>>>>>>>> createDate = "
                + posts.getCreatedDate()
                + ", modifiedDate = "
                + posts.getModifiedDate());

        // isAfter : 날짜 비교
        Assertions.assertThat(posts.getCreatedDate()).isAfter(now);
        Assertions.assertThat(posts.getModifiedDate()).isAfter(now);
    }

앞으로 추가될 엔티티들을 더이상 등록일/수정일로 고민할 필요가 없습니다. BaseTimeEntity만 상속받으면 자동으로 해결되기 때문입니다.

profile
발전하기 위한 공부

0개의 댓글