Spring/Spring Boot

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 3장 - JPA

hyecozy 2022. 8. 22. 12:46

2장 정리글

https://paradiseiswhereiam.tistory.com/132

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 2장 - 테스트 코드

1장 정리글 https://paradiseiswhereiam.tistory.com/131 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 1장 (책과 다른 부분 수정) 2019년도에 나온 책이라 그런지 업데이트 된 부분이 중간중간 있어 블로그에

paradiseiswhereiam.tistory.com

 

❓왜 JPA로 데이터베이스를 다루는 것이 좋을까.

나도 학원에서 MyBatis를 이용하여 데이터베이스의 쿼리를 사용했기 때문에 취업 공고에서 자주 보이는 JPA가 무슨 장점이 있는지 궁금했다. 책에서는 이렇게 말한다.

객체 지향 프로그래밍을 배웠는데 (생략) 객체 모델링모다는 테이블 모델링에만 집중하고,
객체를 단순히 테이블에 맞추어 데이터 전달 역할만 하는 개발은 분명 기형적인 형태였습니다.

 

이 의문을 베이스로 1. 단순 반복 작업 필수 2. 패러다임 불일치 라는 문제가 나왔다.

1번은 나도 단순 CRUD 코드를 계속 반복 작성하면서 어렴풋이 느끼고 있던 문제점이었다. 패러다임 불일치란, 관계형 데이터 베이스와 객체 지향은 기저가 되는 사상부터 다르기 때문에 같이 섞으려니 여러 문제가 발생한다는 것이다.

//객체 지향
User user = findUser();
Group group = user.getGroup();

//데이터베이스
User user = userDao.findUser();
Group group = groupDao.findGroup(user.getGroupId());

위는 User와 Group가 부모-자식 관계임을 알 수 있지만 아래는 그냥 따로 조회하기 때문에 상속을 표현하기 힘들다.

JPA는 이렇게 서로 지향하는 바가 다른 객체 지향 프로그래밍 언어와 관계형 데이터베이스를 중간에서 패러다임의 일치를 시켜주기 위한 기술이라고 한다.

개발자가 객체지향적으로 프로그래밍 할 수 있도록, SQL에 종속적인 개발을 하지 않도록 만들어 준다는 것이다.

 

 

 

 

JPA

인터페이스로서 자바 표준명세서.

인터페이스이기 때문에 사용하기 위해서는 구현체가 필요하다. 구현체로는 보통 Hibernate 등이 있다.

하지만 Spring에서는 직접 이 구현체를 다루지 않고 구현체를 좀 더 쉽게 사용하기 위해 추상화시킨 Spring Data JPA라는 모듈로 JPA 기술을 다룬다.

 

❓Spring Data JPA로 하나, Hibernate로 직접 다루나 큰 차이 없는데 왜 Spring에서는 Spring Data JPA 사용을 권장할까?

이에 대한 물음은 추상화라는 키워드를 떠올리면 된다. 이 키워드로 인해 뚜렷한 장점 2가지를 얘기할 수 있다.

1. 구현체 교체의 용이성: Hibernate 외의 다른 구현체로 쉽게 교체하기 위함

2. 저장소 교체의 용이성: 관계형 데이터베이스 외에 다른 저장소로 쉽게 교체하기 위함

ㄴSpring Data의 하위 프로젝트들은 기본적은 CRUD의 인터페이스가 같기 때문에 저장소가 교체되어도 기본적인 기능을 변경할 것이 없다.

cf. Spring Data는 save(), findAll(), findOne 등을 인터페이스로 갖고 있다.

 

 

 

이제 실습을 해보자~~~~!

요구사항 분석
게시판 기능
- 게시글 조회
- 게시글 등록
- 게시글 수정
- 게시글 삭제
회원 기능
- 구글 / 네이버 로그인
- 로그인한 사용자 글 작성 권한
- 본인 작성글에 대한 권한 관리

 

프로젝트에 Spring Data JPA 적용하기

p86 - build.gradle

implementation('org.springframework.boot:spring-boot-starter-data-jpa')
implementation("org.mariadb.jdbc:mariadb-java-client")
implementation('com.h2database:h2')

이는 1장 정리글(https://paradiseiswhereiam.tistory.com/131)에서 한번 썼지만, 책이 출판된 이후로 업데이트가 있었기 때문에 책 대로 코드를 쓰면 안 된다. complie이 아닌 implementation을 이용해 의존성을 주입하자.

 

spring-boot-starter-data-jpa

: 스프링 부트용 Spring Data Jpa 추상화 라이브러리. 스프링 부트 버전에 맞춰 자동으로 버전 관리 해줌.

h2

: 인메모리 관계형 데이터베이스. 의존성만으로 관리 가능.

메모리에서 실행 되기 때문에 애플리케이션을 재시작할 때마다 초기화된다는 점을 이용하여 테스트 용도로 많이 사용!

 

1. posts.java

package com.jojoldu.book.springboot.web.domain.posts;

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

import javax.persistence.*;

@Getter
@NoArgsConstructor
@Entity
public class Posts {
    
    @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;
    }
}

➡️noArgsConstructor

: 기본 생성자 자동 추가. public Posts(){}와 같은 효과.

➡️Builder

: 해당 클래스의 빌더 패턴 클래스를 생성. 생성자 상단에 선언 시 생성자에 포함된 필드만 빌더에 포함

 

2. PostsRepository.interface

package com.jojoldu.book.springboot.domain.posts;

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

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

참고로 인터페이스는 자바 만들기 클릭 후

밑에 초록색 동그라미의 Interface를 선택하면 된다

 

테스트 코드 작성

3. PostsRepositoryTest.java

package com.jojoldu.book.springboot.domain.posts;

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 java.util.List;

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

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

    @Autowired
    PostsRepository postsRepository;

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

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

        postsRepository.save(Posts.builder()
                .title(title)
                .content(content)
                .author("songhs0719@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);

    }
}

assertThat이 빨간 글자로 변한다면 import를 확인!

근데 테스트 통과가 안 돼서 새로운 오류를 만나게 되었다. 혹시 Unable to find a 블라블라 하는 에러가 뜬 사람은 아래를 참고하시라,,~

https://paradiseiswhereiam.tistory.com/133

 

[문제 해결] Unable to find a @SpringBootConfiguration, you need to use @ContextConfiguration or @SpringBootTest(classes=...)

이 오류는 @SpringBootApplication 어노테이션이 붙은 클래스가 존재하는 패키지의 하위 패키지에 둬야 한다는 원칙을 어겨서 나는 오류이다. 다시 보니 테스트 하고자 했던 클래스의 패키

paradiseiswhereiam.tistory.com

어이없는 실수지만 나도 조심하기 위해 작성

 

 

4. application.properties

: 실제로 실행된 쿼리는 어떤 형태인지 확인하기 위한 파일

이 한 줄만 덜렁 적는 게 맞는 건지 긴가민가해 하며 해당 파일 Run

책에서 얘기한 대로 create table 쿼리에 노란색 밑줄과 같은 옵션으로 생성이 되는 걸 볼 수 있다.

➡️H2의 쿼리 문법이 적용된 것

 

p100 이후 디버깅을 위하 MySQL 버전으로 변경해 보기 (책 내용 업데이트에 맞게 수정)

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

이 코드를 추가한 후 다시 Run 하면

 

이렇게 옵션이 적용됨을 확인할 수 있다.

 

 

 

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

API를 만들기 위해서는 3개의 클래스가 필요하다.

1. Request 데이터를 받을 Dto

2. API 요청을 받을 Controller

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

 

✔️Service에서 비지니스 로직을 처리해야 한다는 오해를 없애기 위한 Spring 웹 계층

Spring Web Layer

⚪Web Layer

- 흔히 사용하는 컨트롤러와 JSP/Freemarker 등의 뷰 탬플릿 영역

- 이외에도 필터, 인터셉터, 컨트롤러 어드바이스 등 외부 요청의 응답에 대한 전반적인 영역을 이야기함

⚪Service Layer

- @Service에 사용되는 서비스 영역

- 일반적으로 컨트롤러와 Dao 중간 영역에서 사용됨

- @Transactional이 사용돼야 하는 영역이기도 함

⚪Repository Layer

- 데이터베이스와 같이 데이터 저장소에 접근하는 영역

- 기존의 Dao 영역으로 이해하면 됨

⚪Dtos

- DTO는 Data Transfer Object의 약자로 계층 간에 데이터 교환을 위한 객체를 이야기하며, Dtos는 이들의 영역을 뜻함

ex. 뷰 탬플릿 엔진에서 사용될 객체 또는 Repository Layer에서 결과로 넘겨준 객체 등이 이들을 뜻함

⚪Domain Model

- 도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화 시킨 것

ex. 택시 앱에서 배차, 탑승, 요금 등이 모두 도메인이라 볼 수 있음

- 무조건 데이터베이스의 테이블과 관계가 있어야만 하는 것은 아님

ㄴ VO처럼 값 객체들도 이 영역에 해당하기 때문

 

❓이 5가지의 레이어에서 비지니스 처리를 담당해야 할 곳은 어디일까.

➡️ Domain

 

기존에 서비스로 처리하던 방식을 트랜잭션 스크립트라고 한다.

주문 취소 로직을 예로 들어 트랜잭션 스크립트와 도메인 모델에서 처리할 경우를 살펴 본다면,

//슈도 코드

@Transactional
public Order canelOrder(int orderId) {
	1) 데이터베이스로부터 주문정보(Orders), 결제정보(Billing), 배송정보(Delivery) 조회
    2) 배송 취소를 해야 하는지 확인
    3) if(배송 중이라면) {
    	배송 취소로 변경
    }
    4) 각 테이블에 취소 상태 update
}
//트랜잭션 스크립트로 구현한 실제 코드

@Transactional
public Order cancelOrder(int orderId) {
	
    //1) DB로부터 주문/결제/배송 정보 조회
    OrderDto order = ordersDao.selectOrders(orderId);
    BillingDto billing = billingDao.selectBilling(orderId);
    DeliveryDto delivery = delivery.selectDelivery(orderId);
    
    //2) 배송 취소를 해야 하는지 확인
    String deliveryStatus = delivery.getStatus();
    
    //3) 배송 중이라면 배송 취소로 변경
    if("IN_PROGRESS".equals(deliveryStatus)) {
    	delivery.setStatus("CANCEL");
        deliveryDao.update(delivery);
    }
    
    //4) 각 테이블에 취소 상태 업데이트
    order.setStatus("CANCEL");
    orderDao.update(order);
    
    billing.setStatus("CANCEL");
    deliveryDao.update(billing);
    
    return order;
}

➡️모든 로직이 서비스 클래스 내부에서 처리 -> 서비스 계층이 무의미 해짐, 객체란 단순한 데이터 덩어리 역할만 하게 됨

 

//도메인 모델을 다루어 코드 작성

@Transactional
public Order cancelOrder(int orderId) {
	//1)
    Orders order = ordersRepository.findById(orderId);
    Billing billing = billingRepository.findByOrderId(orderId);
    Delivery delivery = deliveryRepository.findByOrderId(orderId);

	//2, 3)
    delivery.cancel;
    
    //4)
    order.cancel();
    billing.cancel();
    
    return order;
}

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

 

 

 

등록 기능 만들기

5. PostsApiController

package com.jojoldu.book.springboot.web;

import com.jojoldu.book.springboot.service.posts.PostsService;
import com.jojoldu.book.springboot.web.dto.PostsSaveRequestDto;
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 PostApiController {

    private final PostsService postsService;

    @PostMapping("/api/v1/posts")
    public long save(@RequestBody PostsSaveRequestDto requestDto) {
        return postsService.save(requestDto);
    }
}

 

6. PostsService.java

package com.jojoldu.book.springboot.service.posts;

import com.jojoldu.book.springboot.domain.posts.PostsRepository;
import com.jojoldu.book.springboot.web.dto.PostsSaveRequestDto;
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, 생성자 이렇게 세 가지가 있고

 

내가 스프링을 썼을 때는 Controller와 Service에서 Bean을 주입하는 방식으로 @Autowired를 많이 썼었는데...

권장하지 않는 방법이라고 한다.

책에서는 생성자로 Bean 객체를 받도록 권장하고 있다.

위의 코드에서는 @RequiredArgsConstructor이 final이 선언된 모든 필드를 인자값으로 하는 생성자를 생성해준다.

 

❓왜 생성자를 직접 안 쓰고 롬복의 @RequiredArgsConstructor을 쓰는 걸까.

➡️ 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정하는 번거로움을 해결하기 위해서.

 

 

7. PostsSaveRequestDto

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 클래스는 DB와 맞닿은 핵심 클래스이다. 이 클래스를 기준으로 테이블 생성, 스키마 변경이 이루어진다.

Entity 클래스가 변경이 되면 여러 클래스에 영향을 끼친다는 뜻이다.

Request와 Response용 Dto는 View를 위한 클래스라 잦은 변경을 필요로 하므로 Entity와 Dto는 꼭 분리해서 사용하기.

 

8. PostsApiControllerTest

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 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.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.assertj.core.api.Assertions.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);

    }
}

❓Api Controller 테스트에 HelloController처럼 @WebMvcTest를 안 쓰는 이유는

➡️JPA 기능이 작동하지 않기 때문에.

컨트롤러와 컨트롤러어드바이스 등 외부 연동과 관련된 부분만 활성화돼서 JPA기능까지 한번에 테스트 할 때는

@SpringBootTest와 TestRestTemplate을 사용하면 된다.

 

WebEnvironment.RANDOM_PORT로 인한 랜덤 포트 실행과 insert 쿼리 실행된 것 확인 완료

 

수정 기능 만들기

5. PostsApiController

@RequiredArgsConstructor
@RestController
public class PostApiController {

	...
    
    //수정
    @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);
    }
}

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

PostsResponseDto는 Entity의 일부만 사용하므로 생성자로 Entity를 받아 필드에 값을 넣는다.

굳이 모든 필드를 가진 생성자가 필요하진 않으므로 Dto는 Entity를 받아 처리한다.

 

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

 

1. Posts.java

public class Posts {

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

 

6. PostsService

@RequiredArgsConstructor
@Service
public class 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;
        }

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

            return new PostsResponseDto(entity);
        }
    }
}

❓ update 기능에서 DB에 쿼리를 날리는 부분이 없다.

➡️ JPA의 영속성 컨텍스트 때문.

 

✔️영속성 컨텍스트란

- 엔티티를 영구 저장하는 환경.

- JPA의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함되어 있냐 아니냐로 갈린다.

 

JPA의 엔티티 매니저가 활성화된 상태로 (Spring Data JPA를 쓴다면 기본 옵션)

트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태이다.

이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영한다.

즉, Entity 객체의 값만 변경하면 별도로 Update 쿼리를 날릴 필요가 없다는 것이다.

이 개념을 더티 채킹이라고 한다.

 

8. PostsApiControllerTest

...

public class PostsApiControllerTest {

...

    @Test
    public void Posts_수정된다() throws Exception {
        //given
        Posts savePosts = postsRepository.save(Posts.builder()
                .title("title")
                .content("content")
                .author("author")
                .build());

        Long updateId = savePosts.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 쿼리가 실행된 걸 알 수 있다.

 

로컬 환경에서 실행하기 위해 데이터베이스로 H2를 사용.

메모리에서 실행하기 때문에 직접 접근하려면 웹 콘솔을 사용해야 한다.

웹 콘솔을 활성화 하기 위해 src/main/resources/application.properties에 다음과 같은 코드를 추가한다

(+ test에도 만들기!!)

 

 

4. application.properties

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

spring.datasource.url=jdbc:h2:mem:testdb

 

그리고 Application.java 메인 메소드 실행 후

http://localhost:8080/h2-console

에 접속

 

JDBC URL을 책과 같이 바꿔준 후 Test Connection을 누르면 아래에 Test successful이 뜬다 

그래서 Connect 버튼을 클릭해보았다

 

오른쪽에 쿼리문을 작성할 수 있다.

 

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

위의 쿼리 작성 후 실행하면

 

이런 화면을 볼 수 있다.

그럼 등록된 데이터를 확인하기 위해 API를 요청해 보겠다.

 

http://localhost:8080/api/v1/posts/1

 

이 주소로 들어가면 됨!

 

테스트 완료 :)

 

 

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

데이터가 언제 만들어졌는지, 언제 수정되었는지는 유지보수에 있어 굉장히 중요한 작업이기 때문에

엔티티에는 생성시간과 수정시간이 꼭 포함되어있다.

그래서 DB에 insert하기 전 update 하기 전에 날ㄹ짜 데이터를 등록/수정하는 코드가 여기저기 들어가게 된다.

 

//생성일 추가 코드 예제
public void savePosts() {
...
	posts.setCreateDate(new LocalDate());
    postsRepository.save(post);
...
}

예를 들자면 위의 코드!

를 모든 테이블과 서비스 메소드들에 반복해야 한다는 것. <- 누가 생각해도 반복을 줄여줘야 할 것 같다는 느낌이 옴

 

이 문제를 해결하기 위해서 사용하는 것이 JPA Auditing이다.

 

+ BaseTimeEntity.java

package com.jojoldu.book.springboot.domain.posts;

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 //1
@EntityListeners(AuditingEntityListener.class) //2
public class BaseTimeEntity {
    
    @CreatedDate
    private LocalDateTime createdDate;
    
    @LastModifiedDate
    private LocalDateTime modifiedDate;
}

➡️ @MappedSuperClass

: JPA Entity 클래스들이 BaseTimeEntity을 상속할 경우 필드들(createdDate, modifiedDate)도 칼럼으로 인식하도록 함.

➡️ @EntityListners(AuditingEntityListner.class)

: BaseTimeEntity 클래스에 Auditing 기능을 포함시킴.

➡️ @CreateDate

: Entity가 생성되어 저장될 때 시간이 자동 저장됨.

➡️ @LastModifiedDate

: 조회한 Entity의 값을 변경할 때 시간이 자동 저장됨.

 

그럼 @MappedSuperClass가 활성화 되기 위해 Posts 클래스가 BaseTimeEntity를 상속 받도록 변경하고,

Application 클래스에 JPA Auditing 어노테이션을 활성화 시키기 위해 @EnableJpaAuditing이라는 어노테이션을 추가한다.

 

Posts.java

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

Applicattion.java

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

 

다시 테스트 코드 작성!

 

PostsRepositoryTest.java

..
public class PostsRepositoryTest {
...
    @Test
    public void BaseTimeEntity_등록() {
        //given
        LocalDateTime now = LocalDateTime.of(2022, 8, 22, 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);
    }

그리고 이 테스트 코드를 실행하면

 

이렇게 실제 시간이 잘 저장된 것을 확인할 수 있다.