Showing

[스프링부트] Spring Data JPA 적용(4) 게시글 수정/조회 기능 API 완성 및 JPA Auditing으로 생성 및 수정 시간 자동화 본문

JAVA, SPRING/스프링 부트와 AWS로 혼자 구현하는 웹서비스

[스프링부트] Spring Data JPA 적용(4) 게시글 수정/조회 기능 API 완성 및 JPA Auditing으로 생성 및 수정 시간 자동화

RabbitCode 2023. 6. 3. 06:27

1. 수정/조회 기능 제작

(1) PostsApiController

package com.jojo.book.springbootwebservice.web;
import com.jojo.book.springbootwebservice.service.posts.PostsService;
import com.jojo.book.springbootwebservice.web.dto.PostsSaveRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PathVariable;
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 PostsSaveRequestDto requestDto){
        return postsService.save(requestDto);
    }
    @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 findId(@PathVariable Long id){
        return postsService.findById(id);
    }
}

(2) PostsResponseDto

package com.jojo.book.springbootwebservice.web.dto;

import com.jojo.book.springbootwebservice.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();
    }
}

(3) PostsUpdateRequestDto

package com.jojo.book.springbootwebservice.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;
    }
}

(4) Posts

    public void update(String title, String content) { //추가
        this.title = title;
        this.content = content;
    }

(5) PostsService

package com.jojo.book.springbootwebservice.service.posts;
import com.jojo.book.springbootwebservice.domain.posts.Posts;
import com.jojo.book.springbootwebservice.domain.posts.PostsRepository;
import com.jojo.book.springbootwebservice.web.dto.PostsResponseDto;
import com.jojo.book.springbootwebservice.web.dto.PostsSaveRequestDto;
import com.jojo.book.springbootwebservice.web.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(PostsSaveRequestDto requestDto) {
        return postsRepository.save(requestDto.toEntity()).getId();
    }
    @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);
    }
}

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

(6) 테스트 코드로 검증 PostApiControllerTest

package com.jojo.book.springbootwebservice.web;
import com.jojo.book.springbootwebservice.domain.posts.Posts;
import com.jojo.book.springbootwebservice.domain.posts.PostsRepository;
import com.jojo.book.springbootwebservice.web.dto.PostsSaveRequestDto;
import com.jojo.book.springbootwebservice.web.dto.PostsUpdateRequestDto;
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.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@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);
    }

    @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);
    }
}

테스트 결과를 보면 update 쿼리가 수행되는 것을 확인할 수 있다.

2. 조회 기능 확인(실제 톰캣 실행)

로컬 환경에선 데이터베이스로 H2를 사용한다. 메모리에서 실행하기 때문에 직접 접근하려면 웹 콘솔을 사용해야만 한다. 먼저 웹콘솔을 활성화한다. 

application.properties

spring.h2.console.enabled=true

위의 코드를 추가한 뒤 Application 클래스의 main 메소드를 실행한다. 정상 실행됐다면 톰캣이 8080 포트로 실행되었을 것이다. 여기서 웹 브라우저에 http://localhost:8080/h2-console 로 접속하면 웹 콘솔화면이 등장한다.

jdbc url를 밑 화면과 같이 jdbc:h2:mem:testdb로 되어 있지 않다면 똑같이 작성해야 한다.

 

connect를 누르면 아래와 같은 화면으로 접속하게 된다

현재 프로젝트의 H2를 관리할 수 있는 관리 페이지
posts를 누르고 run을 눌렀을 때의 상태

현재 등록된 데이터가 없으므로, 간단하게 insert 쿼리를 실행해보고 이를 API로 조회해보도록 한다.

insert into posts (author, content, title) values ('author', 'content', 'title')

등록된 데이터를 확인한 후 API를 요청해보도록 한다. 브라우저에 http://localhost:8080/api/v1/posts/1 을 입력해 API 조회 기능을 테스트 해본다

굿~!!!

JSON Viewer이라는 확장 프로그램을 크롬에 설치하면 아래와 같이 받아볼 수 있다.

지금까지 기본적인 등록/수정/조회 기능을 모두 만들고 테스트 해보았다. 특히 등록/ 수정은 테스트 코드로 보호해주므로 이후 변경 사항이 있어도 안전하게 변경할 수 있다!

 

3. JPA Auditing으로 생성 및 수정 시간 자동화

보통 엔티티에는 해당 테이터의 생성시간과 수정시간을 포함한다. 언제 만들어졌는지, 언제 수정되었는지 등은 차후 유지보수에 있어 굉장히 중요한 정보이기 때문이다. 매번 DB에 삽입하기 전, 갱신하기 전에 날짜 데이터를 등록/수정하는 코드가 여기저기 반복해서 쓰여진다면 프로제그가 몹시 지저분해질 것이다. 대안으로 JPA Auditing을 사용할 수 있다. 

 

(1) LocalData 사용

 날짜타입을 사용하도록 한다. Java8부터 LocalData와 LocalDataTime이 등장했다. 그간 Java의 기본 날짜 타입인 Date의 문제점을 제대로 고친 타입이다. 

 

domain 패키지에 BaseTimeEntity 클래스를 생성한다.

package com.jojo.book.springbootwebservice.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;

}

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

코드설명


import javax.persistence.MappedSuperclass; // JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우 필드들(createdDate, modifiedDate)도 칼럼으로 인식하도록 한다.

@EntityListeners(AuditingEntityListener.class) // BaseTimeEntity 클래스에 Auditing 기능을 포함시킨다.


@CreatedDate // Entity가 생성되어 저장될 때 시간이 자동 저장된다. 

@LastModifiedDate // 조회한 Entity의 값을 변경할 때 시간이 자동 저장된다.

 

그리고 Posts  클래스가 BaseTimeEntity를 상속받도록 변경한다.

package com.jojo.book.springbootwebservice.domain.posts;
import com.jojo.book.springbootwebservice.domain.BaseTimeEntity;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@Getter
@NoArgsConstructor
@Entity
public class Posts extends BaseTimeEntity {

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

package com.jojo.book.springbootwebservice;

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

@EnableJpaAuditing
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

코드는 완성되었다. 

 

(2) JPA Auditing 테스트 코드 작성

PostsRepositoryTest 클래스에 테스트 메소드를 하나 더 추가한다.

package com.jojo.book.springbootwebservice.domain.posts;
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.assertThat;

@RunWith(SpringRunner.class)
@SpringBootTest
public class PostsRepositoryTest {
    @Autowired
    PostsRepository postsRepository;

    @After
    public void cleanup(){
        postsRepository.deleteAll();
    }
    @Test
    public void 게시글저장_불러오기(){
        //given
        String title = "테스트 게시글";
        String content = "테스트 본문";

        postsRepository.save(Posts.builder()
                .title(title)
                .content(content)
                .author("jojo@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);
    }
    @Test
    public void BaseTimeEntity_등록() {
        //given
        LocalDateTime now = LocalDateTime.of(2023,6,3,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(">>>>>>> created="+ posts.getCreatedDate()+", modifiedDate="+posts.getModifiedDate());
        assertThat(posts.getCreatedDate()).isAfter(now);
        assertThat(posts.getModifiedDate()).isAfter(now);

    }
}

테스트 코드를 수행해보면 위와 같이 실제 시간이 잘 저장된 것을 확인할 수 있다.

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