Group Study (2023-2024)/Spring 입문

[Spring 입문] 1주차 chap 03 - 스프링 부트에서 JPA로 데이터베이스 다뤄보자

GDSCMJ 2023. 11. 10. 21:28

CHAPTER 03 스프링 부트에서 JPA로 데이터베이스 다뤄보자

 

들어가면서...

기존의 Java 개발자분들과 이 책의 저자는 다음과 같은 고민을 하게 된다. 

'어떻게 하면 관계형 데이터베이스를 이용하는 프로젝트에서 객체지향 프로그래밍을 할 수 있을까?'

그 문제의 해결책으로 JPA라는 자바 표준 ORM 기술을 제시한다.

기존 프로젝트 환경을 개편하는 사람들은 대부분 JPA를 선택한다.

* ORM이란?

메타데이터 설명자를 사용하여 객체 코드를 관계형 데이터베이스에 연결하는 프로그래밍 기술을 말한다.

https://velog.io/@rhee519/Object-Relational-Mapping

 

Object-Relational Mapping(객체 관계형 매핑, ORM)

Object-Relational Mapping(객체 관계형 매핑, ORM)의 개념을 정리한 포스트입니다. 💧

velog.io

 

1. JPA 소개

웹 어플리케이션에서 관계형 DB는 빠질 수 없는 요소로써, 객체를 관계형 DB에서 관리하는 것은 무엇보다 중요하다. 

자바 클래스를 잘 설계해도 SQL을 통해야만 DB에 저장, 조회가 가능하다. 

하지만 여기에는 두 가지 문제가 있다.

      - 단순 반복 작업의 문제 : 수백 개의 테이블의 몇 배의 SQL을 만들고 유지해야하함.

      - 패러다임 불일치 : 관계형 DB는 데이터 저장, 객체지향 언어는 기능과 속성을 한 곳에서 관리하는 데 중점을  둠.

이렇듯 패러다임이 서로 다른데, 객체를 DB에 저장하는 것은 많은 문제가 있다.

 

이러한 문제들을 해결하기 위해 JPA가 등장하였다.

      - 개발자는 객체지향적으로 프로그래밍을 할 수 있다.

      - SQL에 종속적인 개발을 하지 않아도 된다.

JPA는 인터페이스로 이를 구현할 구현체가 필요하다. 구현체에는 Hibernate, Eclipse, Link 등이 있다. 그리고 이 구현체들

을 좀 더 쉽게 사용하게끔 Spring Data JPA 모듈을 이용한다. 이 모듈이 나온 이유는 다음과 같다.

      - 구현체 교체의 용이성 : 쓰고 있던 구현체 외 다른 구현체가 대세로 떠오를 때 그 구현체로 쉽게 교체할 수 있다.

      - 저장소 교체의 용이성 : 관계형 DB 외에 다른 저장소로 쉽게 교체할 수 있다.

 

실무에서 JPA는 높은 러닝 커브로 인해 사용하지 않는 경우가 많다.

여기서 말하는 러닝 커브(Learning Curves)는 무언가를 습득하는 데 드는 시간(학습 비용)을 말한다.

하지만 그럼에도 불구하고 JPA를 사용하면 다음의 장점이 있다.

      - Create, Read, Update, Delete 쿼리를 직접 작성할 필요가 없다.

      - 객체지향 프로그래밍을 쉽게 할 수 있다.

      - 여러 성능 이슈 해결책들을 이미 준비해 놓은 상태다.

https://www.hanbit.co.kr/media/channel/view.html?cms_code=CMS6309270814

 

아리송한 IT 용어/은어 사전 : 러닝 커브, 기술 부채, 홀라크라시, 긱 이코노미, 다이내믹 프로그

아리송한 IT 용어/은어 사전은 개발자 지망생, 신입 개발자 등이 뜻을 잘못 알거나 모르는 개발 용어를 설명하고 관련 도서나 글을 추천하는 콘텐츠입니다. IT 관련 재미있는 용어/은어를 알고 계

www.hanbit.co.kr

 

이제 게시판 만들기를 시작하려고 한다.

      - 게시판 기능 : 게시글 조회, 게시글 등록, 게시글 수정, 게시글 삭제

      - 회원 기능 : 구글/네이버 로그인, 로그인한 사용자 글 작성 권한, 본인 작성 글에 대한 권한 관리

 

2. 프로젝트에 Spring Data Jpa 적용하기

   1) 먼저 build.gradle에 의존성 등록하기

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa' // 스프링 부트용 Spring Data Jpa 추상화 라이브러리
	implementation 'com.h2database:h2' // h2는 인메모리 관계형 DB, 별도의 설치 없이 프로젝트 의존성만으로 관리 가능
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

   2) domain 패키지 만들기 (게시글, 댓글, 회원, 정산, 결제 등 sw에 대한 요구사항, 문제 영역인 도메인을 담을 패키지)

   3) domain 패키지에 posts 패키지와 Posts 클래스 만들기 (Posts 클래스는 실제 DB의 테이블과 매칭될 클래스)

     tip!   주요 어노테이션을 클래스에 가까이 두기 -> 유지보수하기 좋음

     Posts 클래스에서는 Setter 메소드가 없다. 자바빈 규약으로 인해 getter나 setter를 무작정 생성하는 경우가 있는데, 이러면 해당 클래서의 인스턴스 값들이 언제 어디서 변해야 하는지 코드상으로 명확하게 구분할 수 없어, 기능 변경 시 복잡해짐. 그래서 Entity 클래스에서는 절대 Setter 메소드를 만들지 않는다.

setter 메소드 - 외부에서 메소드를 통해 데이터에 접근하도록 유도. setter 가 없는 상황에서는 빌더 클래스를 사용해 값을 채워준다.

자바빈 규약 - 참고

https://dololak.tistory.com/133

 

[JAVAEE] 자바빈(JavaBean) 이란? 자바빈 규약에 대해

자바빈(JavaBean) 이란? 자바빈이란 특정한 기능을 지닌 컴포넌트를 말하는 것이 아닙니다. 간단히 말하자면 자바빈 규약 또는 자바빈 관례에 따라 만들어진 클래스를 의미합니다. 자바빈 사용 배

dololak.tistory.com

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter // 클래스 내 모든 필드의 Getter 메소드를 자동생성
@NoArgsConstructor // 기본 생성자 자동 추가
@Entity // 테이블과 링크될 클래스임을 나타냄
public class Posts {

    @Id // 해당 테이블의 PK 필드를 나타냄
    @GeneratedValue(strategy = GenerationType.IDENTITY) // PK의 생성 규칙을 나타냄
    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;
    }
}

   4) Posts 클래스로 Database를 접근하게 해줄 JpaRepository를 생성한다. (이 부분에서 바보같은 행동을 했다. 아무리 new를 눌러도 jparepository가 보이지 않아서 구글링을 반복했지만 아무것도 찾지 못했다. 그래서 그냥 자바클래스로 만들어버려야지 하는 순간 그 밑에 인터페이스로 생성하는 것을 발견했다. 이래서 머리가 안좋으면 몸이 고생한다는 얘기가...)

이것은 DB 계층 접근자이다. 인터페이스를 생성 후, JpaRepository<Entity 클래스, PK 타입>을 상속하면 기본적인 CRUD 메소드가 자동으로 생성된다. 하지만 여기서 주의해야 할 점은 Entity 클래스와 기본 Entity Repository는 함께 위치해야 하는 점이다. Entity 클래스는 기본 Repository 없이는 제대로 역할하지 못한다.

JpaRepository 조금 더.

https://velog.io/@minju0426/JPARepository%EC%97%90-%EB%8C%80%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90%EC%82%AC%EC%9A%A9%EB%B2%95-Method

 

[JPA] JPARepository에 대해 알아보자(+사용법, Method)

안녕하세요. 쭈피셜입니다!9기 여러분 모두 개발은 잘하고 계신가요?Spring 수업도 끝이 나고 이제 대망의 프론트수업만 남아 있습니다.점점 종강이 가까워지고 있습니다. 종강이 가까워진다는

velog.io

import org.springframework.data.jpa.repository.JpaRepository;

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

 

3. Spring Data JPA 테스트 코드 작성하기

   1) test에서 domain.posts 패키지 생성, PostsRepositoryTest 테스트 클래스 생성. save, findAll 기능을 테스트함.(H2자동실행), 테스트 통과 확인하기.

import org.aspectj.lang.annotation.After;
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.test.context.junit.jupiter.SpringExtension;
import static org.assertj.core.api.Assertions.assertThat;

import java.util.List;

@ExtendWith(SpringExtension.class)
@SpringBootTest
public class PostsRepositoryTest {
    @Autowired
    PostsRepository postsRepository;

    @AfterEach // Junit에서 단위 테스트가 끝날 때마다 수행되는 메소드를 저장
    public void cleanup() {
        postsRepository.deleteAll();
    }

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

        postsRepository.save(Posts.builder().title(title).content(content).author("jojoldu@gmail.com").build());
        //테이블 posts에 insert/update 쿼리를 실행함

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

        //then
        Posts posts = postsList.get(0);
        assertThat(posts.getTitle()).isEqualTo(title);
        assertThat(posts.getContent()).isEqualTo(content);
    }
}

   2) 콘솔에서 쿼리 로그를 확인하기 위해 src/main/resources 아래 application.properties 파일에 옵션을 추가한다. 쿼리로그 확인하기. drop, create, insert into, select, delete 등. 

create table 쿼리에 id bigint generated by default as identity 옵션으로 생성되는데 이는 H2 쿼리 문법이 적용된 것이다. 이후 디버깅을 위해서 출력되는 쿼리로그를 MySQL 버전으로 변경하는 옵션까지 추가한다. 교재에 나와 있는대로 작성하면 실행이 안 된다. 그 부분은 저자가 수정해주신 글을 링크로 걸어두었다. 밑의 코드처럼 작성해야 실행이 된다.

추가한 뒤 다시 테스트 코드를 수행한다. create table 쿼리에 id bigint not null auto_increment, 라고 뜨는 것을 확인할 수 있다.

spring.jpa.show-sql=true

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb
spring.datasource.hikari.jdbc-url=jdbc:h2:mem:testdb;MODE=MYSQL
spring.datasource.hikari.username=sa

 

이 부분 실습을 수행할 때 쿼리 로그에 빨간 글씨가 나타나는 경우가 있다. 이 부분에 대한 참고자료이다.

https://www.inflearn.com/questions/917150/openjdk-64-bit-server-vm-warning

 

OpenJDK 64-Bit Server VM warning - 인프런 | 질문 & 답변

학습하는 분들께 도움이 되고, 더 좋은 답변을 드릴 수 있도록 질문전에 다음을 꼭 확인해주세요.1. 강의 내용과 관련된 질문을 남겨주세요.2. 인프런의 질문 게시판과 자주 하는 질문(링크)을 먼

www.inflearn.com

옵션 추가 시 교재와 다른 점

https://jojoldu.tistory.com/539

 

(2020.12.16) 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 최신 코드로 변경하기

작년 11월 말에 스프링 부트와 AWS로 혼자 구현하는 웹 서비스를 출판 하였습니다. Spring Boot가 2.1 -> 2.4로, IntelliJ IDEA가 2019 -> 2020으로 오면서 너무 많은 변화가 있다보니, 집필할 때와 비교해 실습

jojoldu.tistory.com

 

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

API를 만들기 위한 3가지 클래스

     - Request 데이터를 받을 Dto

     - API 요청을 받을 Controller 

     - 트랜잭션, 도메인 기능 간의 순서를 보장하는 Service

Spring 웹 계층

     - Web Layer : 외부 요청과 응답에 대한 전반적인 영역

     - Service Layer : 일반적으로 Controller와 Dao의 중간 영역에서 사용

     - Repository Layer : 데이터 저장소에 접근하는 영역

     - Dtos : 계층 간에 데이터 교환을 위한 객체

     - Domain Model : 도메인을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화시킨 것

도메인 모델을 이용하면 각각 본인의 이벤트 처리를 하고, 서비스 메소드는 트랜젝션과 도메인 간의 순서만 보장함.

 

등록/수정/삭제 기능 만들기

   1) web 패키지에 PostsApiController, web.dto 패키지에 PostsSaveRequestDto, service.posts 패키지에 PostsService 클래스 생성

// PostsApiController

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 PostsSaveRequestDto requestDto) {
        return postsService.save(requestDto);
    }
}
// PostsService

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

스프링에서 Bean을 주입받는 형식은 @Autowired, setter, 생성자(가장 권장)가 있다. @RequiredArgsConstructor가 final이 선언된 모든 필드를 인자값으로 하는 생성자를 생성한다. 생성자를 직접 쓰지 않고 롬복 어노테이션을 활용하는 이유는 변경 시 수정해야하는 번거로움을 줄이기 위함이다. 

   2) Dto 클래스 생성. 여기서 매우 주의해야 할 사항은 절대로 Entity 클래스를 Request/Response 클래스로 사용해서는 안 된다. Entity 클래스는 데이터베이스와 맞닿은 핵심 클래스로써 이를 변경하는 것은 매우 큰 변경이다. 수많은 서비스와 로직이 Entity 클래스를 기준으로 동작하기 때문에 꼭 Entity 클래스와 Controller에서 쓸 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();
    }
}

   3) 등록 기능의 코드가 완성되었으니, 테스트 패키지의 web 패키지에 PostsApiControllerTest를 생성하여 테스트.

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 org.aspectj.lang.annotation.After;
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.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.beans.factory.annotation.Autowired;

import java.util.List;

import static org.assertj.core.api.AssertionsForClassTypes.assertThat;

@ExtendWith(SpringExtension.class)
@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() 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);
    }
}

JPA 기능까지 한번에 테스트할 때는 @SpringBootTest와 TestRestTemplate를 사용

   4) 수정/조회 기능 만들기

// PostsApiController
// 앞의 코드에서 추가함.

import com.jojoldu.book.springboot.service.posts.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 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 findById (@PathVariable Long id) {
        return postsService.findById(id);
    }
}
// PostsResponseDto

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();
    }
}
// PostsUpdateRequestDto

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;
    }
}
// Posts
// 앞의 코드에서 추가함

import com.jojoldu.book.springboot.domain.BaseTimeEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter // 클래스 내 모든 필드의 Getter 메소드를 자동생성
@NoArgsConstructor // 기본 생성자 자동 추가
@Entity // 테이블과 링크될 클래스임을 나타냄
public class Posts {

    @Id // 해당 테이블의 PK 필드를 나타냄
    @GeneratedValue(strategy = GenerationType.IDENTITY) // PK의 생성 규칙을 나타냄
    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;
    }
}
// PostsService

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

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

        return new PostsResponseDto(entity);
    }
}

여기 update 기능에서 쿼리를 날리는 부분이 없는데, JPA의 엔티티를 영구 저장하는 환경(영속성 컨텍스트) 덕분이다. 엔티티 매니저가 활성화된 상태로 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 영속성 컨텍스트 유지된 상태. 그러면 해당 데이터 값을 변경 시 트랜잭션이 끝나는 시점에 해당 테이블에 변경을 반영한다. 그래서 update 쿼리를 날릴 필요가 없는 것이다. (더티 체킹)

   5) 수정 기능 테스트 코드 PostsApiControllerTest에 추가

// PostsApiControllerTest
// 앞의 코드에 추가함

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.aspectj.lang.annotation.After;
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.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.beans.factory.annotation.Autowired;

import java.util.List;

import static org.assertj.core.api.AssertionsForClassTypes.assertThat;

@ExtendWith(SpringExtension.class)
@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() 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);
    }
}

   6) 톰캣으로 조회 기능 확인하기, 직접 접근하려면 웹 콘솔 사용해야 하므로 웹 콘솔 옵션 활성화하기

// application.properties에 옵션 추가하기

spring.h2.console.enabled=true

그리고 http://localhost:8080/h2-console 로 접속한다. 그림을 따라가면 된다.

마지막 사진에서 좀 더 깔끔하게 보이도록 만들고 싶다면 chrome에 JSON Viewer 라는 플러그인을 설치하면 된다고 한다.

 

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

유지보수에 중요한 정보이다. 매번 반복적인 코드를 작성하면 귀찮고 코드가 지저분해지기 때문에 JPA Auditing 사용.

   1) domain 패키지에 BaseTimeEntity 클래스 생성. BaseTimeEntity 클래스는 모든 Entity의 상위 클래스가 되어 Entity들의 createdDate, modifiedDate를 자동으로 관리하는 역할을 한다.

// BaseTimeEntity

import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@Getter
@MappedSuperclass // JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우 필드들도 칼럼으로 인식하게 함
@EntityListeners(AuditingEntityListener.class) // BaseTimeEntity 클래스에 Auditing 기능을 포함시킴
public abstract class BaseTimeEntity {

    @CreatedDate // Entity가 생성되어 저장될 때 시간 자동 저장
    private LocalDateTime createdDate;

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

   2) Posts 클래스가 BaseTimeEntity를 상속받도록 변경

// 추가해야할 부분만 입력

...
public class Posts extends BaseTimeEntity{
...

   3) JPA Auditing 어노테이션들의 활성화를 위해 Application 클래스에 활성화 어노테이션 추가

// Application

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

@SpringBootApplication
@EnableJpaAuditing // JPA Auditing 활성화
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

   4) 테스트 코드 작성 (PostsRepositoryTest 클래스에 테스트 메소드 추가)

LocalDateTime.of()에 현재 날짜를 입력해주었다.

// PostsRepositoryTest

import net.bytebuddy.asm.Advice;
import org.aspectj.lang.annotation.After;
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.test.context.junit.jupiter.SpringExtension;
import static org.assertj.core.api.Assertions.assertThat;

import java.time.LocalDateTime;
import java.util.List;

@ExtendWith(SpringExtension.class)
@SpringBootTest
public class PostsRepositoryTest {
    @Autowired
    PostsRepository postsRepository;

    @AfterEach // Junit에서 단위 테스트가 끝날 때마다 수행되는 메소드를 저장
    public void cleanup() {
        postsRepository.deleteAll();
    }

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

        postsRepository.save(Posts.builder().title(title).content(content).author("jojoldu@gmail.com").build());
        //테이블 posts에 insert/update 쿼리를 실행함

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

        //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,11,10,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);
    }
}

실행 결과로 실제 시간이 잘 저장된 것을 확인할 수 있다.