JPA로 데이터베이스 다루기

짱J·2022년 5월 9일
0
post-thumbnail

JPA 소개

  • 패러다임 불일치: 관계형 데이터베이스와 객체지향 프로그래밍 언어의 패러다임이 서로 다른데, 객체를 데이터베이스에 저장하려고 하며 발생하는 문제

JPA는 서로 지향하는 바가 다른 2개 영역을 중간에서 패러다임 일치를 시켜주기 위한 기술이다.
개발자는 JPA를 통해 객체지향적으로 프로그래밍을 하고, SQL에 종속적인 개발을 하지 않아도 된다.


Spring Data JPA

화난다 적은거 날라갓다 포항항!

인터페이스인 JPA를 사용하기 위해서는 구현체가 필요하다. ( Hibernate, Eclipse, Link 등 ... )
하지만 Spring에서 JPA를 사용할 때 이 구현체들을 직접 다루진 않고, 구현체들을 좀 더 쉽게 사용하고자 추상화시킨 Spring Data JPA 모듈을 사용한다.

Spring Data JPA → Hibernate → JPA

Hibernate를 사용하는 것과 Spring Data JPA를 쓰는 것에는 큰 차이가 없음에도 Spring Data JPA가 등장한 이유는 아래 두 가지이다.

  • 구현체 교체의 용이성
    • Hibernate 외에 다른 구현체로 쉽게 교체하기 위함
  • 저장소 교체의 용이성
    • 관계형 데이터베이스 외에 다른 저장소로 쉽게 교체하기 위함
    • 의존성만 교체하면 됨
    • Spring Data의 하위 프로젝트들은 기본적인 CRUD의 인터페이스가 같기 때문

요구사항 분석

책에서는 앞으로 하나의 게시판을 만들고 이를 AWS에 무중단 배포 하는 것까지 진행한다.
게시판의 요구사항은 아래와 같다.

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

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

build.gradle의 의존성 파트이다.
아래와 같이 spring-boot-starter-data-jpah2 의존성을 등록해주어야 한다.

🍄 spring-boot-starter-data-jpa

  • 스프링 부트용 Spring Data Jpa 추상화 라이브러리
  • 스프링 부트 버전에 맞춰 자동으로 JPA 관련 라이브러리들의 버전을 관리해줌

🍄 h2

  • 인메모리 관계형 데이터베이스
  • 별도의 설치 필요 없이 프로젝트 의존성만으로 관리
  • 메모리에서 실행되기 때문에 애플리케이션을 재시작할 때마다 초기화됨 → 테스트 용도로 많이 사용
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-mustache'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'junit:junit:4.13.2'
    implementation 'org.projectlombok:lombok'

    runtimeOnly 'com.h2database:h2'

    // JUnit 4
    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }

    // lombok
    annotationProcessor 'org.projectlombok:lombok'
    testAnnotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.projectlombok:lombok'
}


의존성이 등록되었다면, 새로고침 후 다음과 같이 domain 패키지를 만들다.
domain 패키지는 도메인을 담을 패키지이다.

🍄 도메인이란?

게시글, 댓글, 회원, 정산, 결제 등 소프트웨어에 대한 요구사항 혹은 문제 영역이라고 생각하면 된다.


도메인 패키지 안에 post 패키지와 Posts 클래스도 만들어주자.

package com.example.demo.domain.posts;

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

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

🍄 @Entity

  • 테이블과 링크될 클래스
  • 기본값으로 클래스의 카멜 케이스 이름을 언더스코어 네이밍(_)으로 테이블 이름을 매칭
    • ex) SalesManager.java → sales_manager table

🍄 @Id

  • 해당 테이블의 PK 필드

🍄 @GeneratedValue

  • PK의 생성 규칙을 나타냄
  • 스프링 부트 2.0에서는 GenerationType.IDENTITY 옵션을 추가해야만 auto_increment가 된다.

🍄 @Column

  • 테이블의 컬럼을 나타내며 굳이 선언을 하지 않아도 해당 클래스의 필드는 모두 컬럼이 됨
  • 기본값 외에 추가로 변경이 필요한 옵션이 있으면 사용
    • 문자열의 경우 VARCHAR(255)가 기본값인데 사이즈를 500으로 늘리고 싶거나,
    • 타입을 TEXT로 변경하고 싶을 때 등에 사용

🍄 @NoArgsConstructor

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

🍄 @Getter

  • 클래스 내 모든 필드의 Getter 메소드를 자동생성

🍄 @Builder

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

💁‍♀️ 참고
Entity의 PK는 auto_increment를 추천한다!
주민등록번호와 같은 비즈니스상 유니크 키나, 여러 키를 조합한 복합키로 PK를 잡으면 난감한 상황이 종종 발생될 수 있다!

@Entity, @Id, @GeneratedValue, @Column은 JPA에서 제공하는 어노테이션이고, @NoArgsConstructor, @Getter, @Builder은 롬복에서 제공하는 어노테이션이다.

롬복의 어노테이션들은 코드 변경량을 최소화시켜주기 때문에 적극적으로 사용하자!

위의 Posts 클래스를 보면 Setter 메소드가 없다는 것을 알 수 있다.
getter/setter을 무작정으로 생성하면 해당 클래스의 인스턴스 값들이 언제 어디서 변해야 하는지 코드상으로 명확하게 구분할 수가 없어, 차후 기능 변경시 복잡해진다.

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

 // 1. 잘못된 사용 예
 public class Order{
	public void setStatus(boolean status){
    	this.status = status;
    }
}

public void 주문서비스의_취소이벤트(){
	order.setStatus(false);
}

// 2. 올바른 사용 예
 public class Order{
	public void cancelOrder(){
    	this.status = false;
    }
}

public void 주문서비스의_취소이벤트(){
	order.cancelOrder(false);
}

그러면 Setter가 없는 이 상황에서 어떻게 값을 채워 DB에 삽입할까? 🧐

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

해당 책에서는 생성자 대신 @Builder를 통해 제공되는 빌더 클래스를 사용한다.

빌더를 사용하게 되면 어느 필드에 어떤 값을 채워야 할지 명확하게 인지할 수 있다.

 Example.builder()
 	.a(a)
    .b(b)
    .build();

이제 Posts 클래스로 Database를 접근하게 해줄 JpaRepository를 생성하자.

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

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

DAO라고 불리는 DB Layer 접근자이다.
JPA에선 Repository라고 부르며 인터페이스로 생성한다.

인터페이스를 생성 후, JpaRepository<Entity 클래스, PK 타입>를 상속하면 기본적인 CRUD 메소드를 자동으로 생성해준다.
(@Repository를 추가할 필요 없음!)

여기서 주의할 점 !!
Entity 클래스와 기본 Entity Repository는 함께 위치해야 한다.

나중에 프로젝트 규모가 커져 도메인별로 프로젝트를 분리해야 한다면 이때 Entity 클래스와 기본 Repository는 함께 움직여야 하므로 도메인 패키지에서 함께 관리한다.


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


위 사진처럼 test 디렉토리에 domain.posts 패키지를 생성하고, PostsRepositoryTest라는 테스트 클래스를 생성하자.

 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.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("leeeee_yeon@naver.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/update 쿼리를 실행
  • id 값이 있다면 update, 없다면 insert 쿼리가 실행된다.

🍄 postsRepository.findAll

  • 테이블 posts에 있는 모든 데이터를 조회해오는 메소드

@SpringBootTest를 사용할 경우 H2 데이터베이스를 자동으로 실행해 준다.


테스트가 성공했다!
여기서 드는 궁금증

실제로 실행된 쿼리는 어떤 형태일까? 🧐

스프링부트에서는 application.properties, application.yml 등의 파일로 한 줄의 코드로 설정할 수 있도록 지원 및 권장한다.

src/main/resources 디렉토리 아래에 application.properties 파일을 생성하고 아래 문구를 추가하자.

spring.jpa.show-sql=true


옵션을 적용하고 다시 테스트를 실행하면 쿼리 로그를 확인할 수 있다!

여기서 create table 쿼리를 보면 id bigint generated as identity라는 옵션으로 생성되는 것을 볼 수 있다.
이는 H2 쿼리 문법이 적용되었기 때문이다.
H2는 MySQL의 쿼리를 수행해도 정상적으로 작동하기 때문에 이후 디버깅을 위해서 출력되는 쿼리 로그를 MySQL 버전으로 변경하자.
application.properties에서 아래 코드를 추가해주자.

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect

하지만 이대로만 하니 테이블이 제대로 생성되지 않았다!

GenerationTarget encountered exception accepting command : Error executing DDL "create table posts (id bigint not null auto_increment, author varchar(255), content TEXT not null, title varchar(500) not null, primary key (id)) engine=InnoDB" via JDBC Statement

Error executing DDL "create table posts (id bigint not null auto_increment, author varchar(255), content TEXT not null, title varchar(500) not null, primary key (id)) engine=InnoDB" via JDBC Statement

https://github.com/jojoldu/freelec-springboot2-webservice/issues/67
위의 링크를 참고하여, application.properties를 아래와 같이 수정해주니 테스트가 통과하고, 쿼리 로그도 MySQL 버전으로 잘 출력되는 것을 확인할 수 있다.

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://localhost/~/testdb;MODE=MYSQL


굿이애옹 ~


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

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

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

Service에서는 비지니스 로직을 처리해야 하는 것이 아니다!
Service는 트랜잭션, 도메인 간 순서 보장의 역할만 한다.

이에 대해 알아보기 위해 Spring 웹 계층에 대해 알아보자

🍄 Spring 웹 계층

  • Web Layer
    • 컨트롤러(@Controller)와 JSP/Freemarker 등의 뷰 템플릿 영역
    • 필터(@Filter), 인터셉터, 컨트롤러 어드바이스(@ControllerAdvice) 등 외부 요청과 응답에 대한 전반적인 영역
  • Service Layer
    • @Service에 사용되는 서비스 영역
    • 일반적으로 Controller와 DAO의 중간 영역에서 사용됨
    • @Transactional이 사용되어야 하는 영역
  • Repository Layer
    • Database와 같이 데이터 저장소에 접근하는 영역
    • DAO(Data Access Object) 영역으로 이해하면 쉽다!
  • DTOs
    • DTO(Data Transfer Object): 계층 간에 데이터 교환을 위한 객체
    • ex) 뷰 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체
  • Domain Model
    • 도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있도록 단순화시킨 것
    • @Entity가 사용되는 영역
    • 무조건 데이터베이스의 테이블과 관계가 있어야만 하는 것은 아님!
    • VO처럼 값 객체들도 해당

아까 Service에서는 비즈니스 로직을 처리하지 않는다고 했는데, 그럼 비즈니스 처리를 담당하는 곳은 어디일까? 🧐
정답은 Domain이다.

주문 취소 로직을 예시로 들어보자.

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

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

이제 진짜로 API를 만들어보좌!

게시글 등록 API

  • web 패키지에 PostsController
  • web.dto 패키지에 PostsSaveReq
  • service.post 패키지에 PostsService
    를 생성한다.

🍄 PostsController

import com.example.demo.service.posts.PostsService;
import com.example.demo.web.dto.PostsSaveReqDto;
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 PostsController {
    private final PostsService postsService;

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

🍄 PostsService

import com.example.demo.domain.posts.PostsRepository;
import com.example.demo.web.dto.PostsSaveReqDto;
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(PostsSaveReqDto reqDto) {
        return postsRepository.save(reqDto.toEntity()).getId();
    }
}

스프링에서 Bean을 주입 받는 방식에는 @Autowired, setter, 생성자 3가지 방법이 있다.
저자는 이 중 생성자로 주입받는 방식을 권장한다. (Autowired 비추!)
롬복의 @RequiredArgsConstructor을 사용하면, final로 선언된 모든 필드를 인자값으로 하는 생성자를 생성해준다.

클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정하는 번거로움을 해결하기 위해 롬복 어노테이션 사용!

🍄 PostsSaveReqDto

import com.example.demo.domain.posts.Posts;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

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

    @Builder
    public PostsSaveReqDto(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 클래스를 추가로 생성하였다.
But, 절대로 Entity 클래스를 Request/Response 클래스로 사용하면 안된다!

Entity 클래스는 데이터베이스와 맞닿은 핵심 클래스이다.
Entity 클래스를 기준으로 테이블이 생성되고, 스키마가 변경된다.

Entity 클래스가 변경되면 여러 클래스에 영향을 끼치지만 Request와 Response용 Dto는 View를 위한 클래스라 자주 변경이 필요하다.

그러므로 View Layer와 DB Layer의 역할 분리를 철저하게 하는 것이 좋다.

꼭 Entity 클래스와 Controller에서 사용할 DTO를 분리하자.

등록 API가 만들어졌으니, 테스트 코드로 검증해 보자.
test 패키지 안의 web 패키지에 PostsControllerTest를 만들어준다.

import com.example.demo.domain.posts.Posts;
import com.example.demo.domain.posts.PostsRepository;
import com.example.demo.web.dto.PostsSaveReqDto;
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.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 PostsControllerTest {
    @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";
        PostsSaveReqDto reqDto = PostsSaveReqDto.builder()
                .title(title)
                .content(content)
                .author("author")
                .build();
        
        String url = "http://localhost:" + port + "/api/v1/posts";
        
        // when
        ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, reqDto, 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);
    }
}

이번에는 HelloController와 달리 @WebMvcTest를 사용하지 않았다.
@WebMvcTest의 경우 JPA 기능이 작동하지 않기 때문이다.

JPA 기능까지 한번에 테스트할 때는 @SpringBootTestTestRestTemplate을 사용한다.

WebEnvironment.RANDOM_PORT로 인해 랜덤 포트가 실행되었고,
insert 쿼리가 실행된 것도 확인할 수 있다.

등록 기능 clear ✨
수정, 조회 기능도 쇼쇼쇽 만들자

게시글 조회 + 수정 API

🍄 PostsController

    @PutMapping("/api/v1/posts/{id}")
    public Long update(@PathVariable Long id, @RequestBody PostsUpdateReqDto reqDto) {
        return postsService.update(id, reqDto);
    }
    
    @GetMapping("/api/v1/posts/{id}")
    public PostsResDto findById (@PathVariable Long id) {
        return postsService.findById(id);
    }

🍄 PostsResDto

import com.example.demo.domain.posts.Posts;
import lombok.Getter;

@Getter
public class PostsResDto {
    private Long id;
    private String title;
    private String content;
    private String author;

    public PostsResDto(Posts entity) {
        this.id = entity.getId();
        this.title = entity.getTitle();
        this.content = entity.getContent();
        this.author = entity.getAuthor();
    }
}

🍄 PostsUpdateReqDto

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

@Getter
@NoArgsConstructor
public class PostsUpdateReqDto {
    private String title;
    private String content;
    
    @Builder
    public PostsUpdateReqDto(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

🍄 Posts

아래 함수를 추가

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

🍄 Posts

아래 함수를 추가

    @Transactional
    public Long save(PostsSaveReqDto reqDto) {
        return postsRepository.save(reqDto.toEntity()).getId();
    }

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

        return id;
    }

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

        return new PostsResDto(entity);
    } 

update 기능에는 데이터베이스에 쿼리를 날리는 부분이 없다.
이게 가능한 이유는 JPA의 영속성 컨텍스트 때문이다.

영속성 컨텍스트? 엔티티를 영구 저장하는 환경

  • JPA의 엔티티 매니저가 활성화된 상태(=Spring Data Jpa의 기본 옵션)로 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태

이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영한다.
그러므로 Entity 객체의 값만 변경하면 별도로 Update 쿼리를 날릴 필요가 없는 것이다!
이를 더티 체킹(dirty checking)이라고 한다.


이제 수정 API에 대한 테스트 코드를 작성해 보자.

    @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";

        PostsUpdateReqDto reqDto = PostsUpdateReqDto.builder()
                .title(expectedTitle)
                .content(expectedContent)
                .build();

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

        HttpEntity<PostsUpdateReqDto> requestEntity = new HttpEntity<>(reqDto);

        // 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를 사용하고, 메모리에서 실행하기 때문에 직접 접근하려면 웹 콘솔을 사용해야만 한다.

먼저 웹 콘솔 옵션을 활성화하기 위해 application.properties에 아래 옵션을 추가하고, Application 클래스의 메인 메소드를 실행한다.

spring.h2.console.enabled=true

정상적으로 실행되면, 톰캣은 8080 포트로 실행된다.
http://localhost:8080/h2-console 로 접속하자.

JDBC URL을 위와 같이 바꿔주자.
그 후,Connect 버튼을 클릭하면 현재 프로젝트의 H2를 관리할 수 있는 관리 페이지로 이동한다.

책에 나온대로 했지만 나는 되지 않았다 :(
구글링을 하며 properties 파일도 수정해보고 ~ gradle 파일도 수정해봤지만 ! 되지 않았다 ...

🔨 h2 콘솔 해결 방법

그러다 아래 링크를 발견하고 문제를 해결했다 !
맥북에서 다른 방법으로 접근해야 하는 것이 원인이였다.
https://goateedev.tistory.com/248

brew install h2
h2

h2 명령어를 입력하면 위와 같이 웹 콘솔로 바로 연결되고, 연결 버튼만 누르면 현재 프로젝트의 H2를 관리할 수 있는 관리 페이지로 이동할 수 있다 ! 야호 !

그런데 !! 아래 사진처럼 POSTS 테이블이 떠야 하는데 뜨지 않는 것이다 🥺

나는 그래서 다음과 같은 해결 방법을 썼다.


1️⃣ 포트 번호 변경
h2 명령어를 통해 웹 콘솔 화면으로 들어갔을 때 포트 번호가 8082였다.
이것이 해결에 영향을 미쳤는지 모르겠지만, 우선 application.properties에 아래 코드를 추가하여 포트 번호를 익숙한 8080으로 변경해주었다.

server.port = 8080

2️⃣ 접속 주소, JDBC URL 변경
톰캣을 실행하면 아래와 같이 접속 주소와 JDBC URL을 알려준다.
책이나 다른 블로그말고 아래 주소와 URL을 참고하여 접속하자.


그러면 위에 있는 사진처럼 POSTS 테이블도 정상적으로 뜨는 것을 볼 수 있다.

이제 간단한 insert 쿼리를 날리고, 이를 API로 확인해보자.

쿼리를 날리는 모습이고,

브라우저를 통해 API가 정상적으로 조회되는 것을 확인할 수 있다 !

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

보통 엔티티에는 해당 데이터의 생성시간과 수정시간이 포함되고, 그렇기 때문에 매번 DB에 삽입/갱신하기 전에 날짜 데이터를 등록/수정하는 코드가 여기저기 들어가게 된다.

JPA Auditing 어노테이션을 사용해서 생성시간/수정시간을 자동화 해보자!
책에서는 날짜 타입으로 LocalDate를 사용한다.

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

package com.example.demo.domain;

import lombok.Getter;
import org.apache.tomcat.jni.Local;
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 class BaseTimeEntity {
    
    @CreatedDate
    private LocalDateTime createdDate;
    
    @LastModifiedDate
    private LocalDateTime modifiedDate;
}

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

🍄 @MappedSuperclass

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

🍄 @EntityListeners(AuditingEntityListener.class)

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

🍄 @CreatedDate

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

🍄 @LastModifiedDate

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

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

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

이제 기능 구현은 완성되었다.

기능이 잘 작동하는지 테스트 코드를 작성해 보자.

🍄 PostsRepositoryTest

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

실제 시간이 잘 저장된 것을 확인할 수 없다.
앞으로 추가될 엔티티들은 더 이상 등록일/수정일을 고민할 필요 없이, BaseTimeEntity만 상속 받으면 된다 !!

profile
[~2023.04] 블로그 이전했습니다 ㅎㅎ https://leeeeeyeon-dev.tistory.com/

4개의 댓글

comment-user-thumbnail
2022년 5월 19일

우왕! 이걸루 공부할게여!!!!!!!!!!!!!!!

1개의 답글
comment-user-thumbnail
2022년 5월 21일

맨 마지막 사진의 JDBC URL이랑 그 위 사진의 JDBC URL이 달라요...!!

1개의 답글