[스프링] 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - 스프링 부트에서 JPA로 데이터베이스 다뤄보자

June·2021년 3월 6일
0

JPA: 자바 표준 ORM (Object Relational Mapping)

참고
MyBatis, iBatis는 ORM이 아니다. SQL Mapper이다. ORM은 객체를 매핑하는 것이고, SQL Mapper는 쿼리를 매핑한다.

JPA 소개

현대의 웹 어플리케이션에서 관계형 데이터베이스는 필수적이다. 그러다 보니 객체를 관계형 데이터 베이스에서 관리하는 것이 중요하다.

각 테이블마다 기본적인 CRUD(Create, Read, Update, Delete) SQL을 매번 생성해야한다.

또 반복의 문제 외에도 패러다임의 불일치문제가 있다. 관계형 데이터베이스는 어떻게 데이터를 저장할지에 초점이 맞춰진 기술이다. 반대로 객체지향 프로그래밍 언어는 기능과 속성을 한 곳에서 관리하는 기술이다.

User user = findUser();
Group group = user.getGroup();

누구나 명확하게 User와 Group은 부모-자식 관계임을 알 수 있다.

User user = userDao.findUser();
Group group = groupDao.findGroup(user.getGroupId());

User따로 Group따로 조회한다. User와 Group이 어떤 관계인지 알 수 없다. 상속, 1:N등 다양한 객체 모델링을 데이터베이스로 구현할 수 없다. 그러다보니 웹 애플리케이션 개발은 점점 데이터 베이스 모델링에만 집중한다. JPA는 이러한 문제점을 해결하기 위해 등장한다.

서로 지향하는 바가 다른 2개 영역을 중간에서 패러다임 일치를 시켜주기 위한 기술이다. 즉, 개발자는 객체 지향적으로 프로그래밍을 하고 JPA가 이를 관계형 데이터 베이스에 맞게 SQL을 대신 생성해서 실행한다. 개발자는 항상 객체 지향적으로 코드를 표현할 수 있으니 더는 SQL에 종속적인 개발을 하지 않아도 된다.

Spring Data JPA

JPA는 인터페이스로서 자바 표준명세서이다. 인터페이스인 JPA를 사용하기 위해서는 구현체가 필요하다. 대표적으로 Hibernate, Eclipse Link 등이 있다. 구현체들을 좀 더 쉽게 사용하고자 추상화시킨 Spring Data JPA라는 모듈을 사용해서 JPA를 다룬다.

JPA <- Hibernate <- Spring Data JPA

Hibernate를 쓰는 것과 Spring Data JPA를 쓰는 것 사이에는 큰 차이가 없다. 그럼에도 스프링 진영에는 Spring Data JPA를 개발했다. 이유는

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

구현체 교체의 용이성이란 Hibernate외에 다른 구현체로 쉽게 교체하기 위함이다.
Hibernate가 언젠가 수명을 다해서 새로운 JPA 구현체가 대세로 떠오를 때, Spring Data JPA를 쓰는 중이라면 아주 쉽게 교체할 수 있다.

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

실무에서 JPA

실무에서 JPA를 사용하지 못하는 가장 큰 이유는 높은 러닝 커브이다. JPA를 잘 쓰려면 객체지향 프로그래밍과 관계형 데이터베이스 둘 다 이해해야 한다.

요구사항 분석

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

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

build.gradle에 의존성 등록하기

dependencies {
    compile('org.springframework.boot:spring-boot-starter-web')
    testCompile('org.springframework.boot:spring-boot-starter-test')
    //롬복 관련
    compile('org.projectlombok:lombok')
    testCompile "org.projectlombok:lombok"
    annotationProcessor('org.projectlombok:lombok')
    testAnnotationProcessor('org.projectlombok:lombok')
    // 추가된 dependency
    compile('org.springframework.boot:spring-boot-starter-data-jpa')
    compile('com.h2database:h2')
    testCompile('otg.springframework.boot:spring-boot-starter-test')
}
    1. spring-boot-starter-data-jpa
    • 스프링 부트용 Spring Data Jpa 추상화 라이브러리이다.
    • 스프링 부트 버전에 맞춰 자동으로 JPA 관련 라이브러리들의 버전을 관리해 준다.
    1. h2
    • 인메모리 관계형 데이터베이스이다.
    • 별도의 설치가 필요 없이 프로젝트 의존성만으로 관리할 수 있다.
    • 메모리에서 실행되기 때문에 애플리케이션을 재시작할 때마다 초기화된다는 점을 이용하여 테스트 용도로 많이 사용된다.

domain 패키지 만들기

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

domain 패키지에 posts 패키지와 Posts 클래스를 만든다.

src/main/java/jojoldu/book/springboot/domain/posts/Posts

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

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;

@Getter // 6
@NoArgsConstructor // 5
@Entity // 1
public class Posts {

    @Id // 2
    @GeneratedValue(strategy = GenerationType.IDENTITY) // 3
    private Long id;

    @Column(length = 500, nullable = false) // 4
    private String title;

    @Column(columnDefinition = "TEST", nullable = false)
    private String content;

    private String author;

    @Builder // 7
    public Posts(String title, String content, String author) {
        this.title = title;
        this.content = content;
        this.author = author;
    }
}

이 책에서는 어노테이션 순서를 주요 어노테이션을 클래스에 가깝게둔다. @Entity는 JPA의 어노테이션이며, @Getter와 @NoArgsConstrucor는 롬복의 어노테이션이다. 롬복은 코드를 단순화시켜 주지만 필수 어노테이션은 아니다. 그래서 주요 어노테이션인 @Entity를 클래스에 가깝게 두고, 롬복 어노테이션을 그 위로 두었다. 이렇게 하면 이후에 코틀린 등의 새 언어 전환으로 롬복이 더이상 필요 없을 경우 쉽게 삭제할 수 있다.

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

    1. Entity
    • 테이블과 링크될 클래스임을 나타낸다.
    • 기본값으로 클래스의 카멜케이스 이름을 언더스코어 네이밍(_)으로 테이블 이름을 매칭한다.
    • ex) SalesManager.java -> sales_manager table
    1. @Id
    • 해당 테이블의 PK 필드를 나타낸다.
    1. @GeneratedValue
    • PK의 생성 규칙을 나타낸다.
    • 스프링 부트 2.0에서는 GenerationType.IDENTITY 옵션을 추가해야만 auto_increment가 된다.
    1. @Column
    • 테이블의 칼럼을 나타내며 굳이 선언하지 않더라도 해당 클래스의 필드는 모두 칼럼이 된다.
    • 사용하는 이유는, 기본값 외에 추가로 변경이 필요한 옵션이 있으면 사용한다.
    • 문자열의 경우 VARCHAR(255)가 기본값인데, 사이즈를 500으로 늘리고 싶거나(ex: title), 타입을 TEXT로 변경하고 싶거나(ex: content)등의 경우에 사용된다.

참고
웬만하면 Entity의 PK는 Long타입의 Auto_increment를 추천한다 (MySQL 기준으로 이렇게 하면 bigint 타입이 된다). 주민등록번호와 같이 비즈니스상 유니크 키나, 여러 키를 조합한 복합키로 PK를 잡을 경우 난감한 상황이 종종 발생한다.

  1. FK를 맺을 때 다른 테이블에서 복합키 전부를 갖고 있거나, 중간 테이블을 하나 더 둬야 하는 상황이 발생한다.
  2. 인덱스에 좋은 영향을 끼치지 못한다.
  3. 유니크한 조건이 변경될 경우 PK 전체를 수정해야 하는 일이 발생한다.
  1. @NoArgsConstructor
    • 기본 생성자 자동 추가
    • public Posts() {}와 같은 효과
  2. @Getter
    • 클래스 내 모든 필드의 Getter 메소드를 자동생성
  3. @Builder
    • 해당 클래스의 빌더 패턴 클래스를 생성
    • 생성자 상단에 선언시 생성자에 퐇람된 필드만 빌더에 포함

이 Posts 클래스에는 한 가지 특이점이 있는데 Setter 메소드가 없다는 점이다. setter를 무작정 생성하면 해당 클래스의 인스턴스 값들이 언제 어디서 변해야하는지 코드상으로 명확하게 구분할 수 없을 수 잇다.
그래서 Entity 클래스에서는 절대 Setter 메소드를 만들지 않는다. 대신, 해당 필드의 값 변경이 필요하면 명확히 그 목적과 의도를 나타낼 수 있는 메소드를 추가한다.

잘못된 예

public class Order {
    public void setStatus(boolean status) {
        this.status = status
    }
}

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

올바른 예

public class Order {
    public void cancelOrder() {
        this.status = false;
    }
}

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

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

기본적인 구조는 생성자를 통해 최종값을 채운 후 DB에 삽입하는 것이며, 값 변경이 필요한 겨웅 해당 이벤트에 맞는 public 메소드를 호출하여 변경하는 것을 전제로 한다. 이 책에서는 생성자 대신에 @Builder를 통해 제공되는 빌더 클래스를 사용한다. 생성자나 빌더나 생성 시점에 값을 채워주는 역할은 똑같으나, 생성자의 경우 ㄱ지금 채워야 할 필드가 무엇인지 명확히 지정할 수 없다.

예를 들어, 다음과 같은 생성자가 있담녀 개발자가 new Example(b, a)처럼 a와 b의 위치를 변경해도 코드를 실행하기 전까지는 문제를 찾을 수가 없다.

public Example(String a, String b) {
    this.a = a;
    this.b = b;
}

하지만 빌더를 사용하게 되면 다음과 같이 어느 필드에 어떤 값을 채워야할지 명화갛게 인지할 수 있다.

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

Posts 클래스 생성이 끝났다면, Posts 클래스로 Database를 접근하게 해줄 JpaRepository를 생성한다.

src/main/java/com/jojoldu/book/book/springboot/domain/posts/PostsRepository

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

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

public interface PostsRepository extends JpaRepository<Posts,Long> {

}

보통 ibatis나 MyBatis 등에서 Dao라고 불리는 DB Layer 접근자이다. JPA에선 Repository라고 부르며 인터페이스로 생성한다. 단순히 인터페이스를 생성 후, JpaRepository<Entity 클래스, PK 타입>를 상속하면 기본적인 CRUD 메소드가 자동으로 생성된다.

@Repository를 추가할 필요도 없다. 여기서 주의할 점은 Entity 클래스와 기본 Entity Repository는 함께 위치해야 한다는 점이다. Entity 클래스는 기본 Repository 없이는 제대로 역할을 할 수가 없다.

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

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

test 디렉토리에 domain.posts 패키지를 생성하고, 테스트 클래스는 PostsRepositoryTest란 이름으로 생성한다.

src/test/java/jojoldu/book/springboot/domain/posts/PostsRepositoryTest

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

import java.util.List;
import org.assertj.core.api.Assertions;
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;

@RunWith(SpringRunner.class)
@SpringBootTest
public class PostsRepositoryTest {

    @Autowired
    PostsRepository postsRepository;
    
    @After // 1
    public void cleanup() {
        postsRepository.deleteAll();
    }
    
    @Test
    public void 게시글저장_불러오기() {
        //given
        String title = "테스트 게시글";
        String content = "테스트 본문";
        
        postsRepository.save(Posts.builder() // 2
            .title(title)
            .content(content)
            .author("jojoldu@gmail.com")
            .build());
        
        // when
        List<Posts> postsList = postsRepository.findAll();
        
        // then
        Posts posts = postsList.get(0);
        Assertions.assertThat(posts.getTitle()).isEqualTo(title);
        Assertions.assertThat(posts.getContent()).isEqualTo(content);
    }
}
  1. @After
    • JUnit에서 단위 테스트가 끝날 때마다 수행되는 메소드를 지정
    • 보통은 배포 전 전체 테스트를 수행할 때 테스트간 데이터 침범을 막기 위해 사용한다.
    • 여러 테스트가 동시에 수행되면 테스트용 데이터베이스인 H2에 데이터가 그대로 남아 있어 다음 테스트 실행 시 테스트가 실패할 수 있습니다.
  2. postsRepository.save
    • 테이블 posts에 insert/update 쿼리를 실행한다.
    • id 값이 있다면 update가, 없다면 insert 쿼리가 실행된다.
  3. postsRepository.findAll
    • 테이블 posts에 있는 모든 데이터를 조회해오는 메소드입니다.

별다른 설정 없이 @SpringBootTest를 사용할 경우 H2 데이터베이스를 자동으로 실행한다.

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

import java.util.List;
import org.assertj.core.api.Assertions;
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;

@RunWith(SpringRunner.class)
@SpringBootTest
public class PostsRepositoryTest {

    @Autowired
    PostsRepository postsRepository;

    @After // 1
    public void cleanup() {
        postsRepository.deleteAll();
    }

    @Test
    public void empty() {

    }

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

        postsRepository.save(Posts.builder() // 2
            .title(title)
            .content(content)
            .author("jojoldu@gmail.com")
            .build());

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

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

테스트를 실행했는데 Error creating bean with name 'entityManagerFactory'이 발생했다. 이유는

import org.springframework.data.annotation.Id; // 잘못된 import
import javax.persistence.Id; //올바른 import

실행된 쿼리를 로그로 보자. 스프링 부트에서는 application.properties, application.yml 등의 파일로 한 주르이 코드로 설정할 수 있다.
src/main/resources 디렉토리 아래에 application.properties 파일을 생성한다.

spring.jpa.show_sql = true


로그가 나온다. 하지만 H2 쿼리 문법이 적용되고 있다. MySQL 버전으로 변경해보자.

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

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

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

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

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

  • Web Layer
    • 흔히 사용하는 컨트롤러(@Controller)와 JSP / Freemarker 드으이 뷰 템플릿 영역이다.
    • 이외에도 필터(@Filter), 인터셉터, 컨트롤러 어드바이스(@ControllerAdvice)등 외부 요청과 응답에 대한 전반적인 영역을 이야기한다.
  • Service Layer
    • @Service에 사용되는 서비스 영역이다.
    • 일반적으로 Controller와 Dao의 중간 영역에서 사용된다.
    • @Transactional이 사용되어야 하는 영역이기도 하다.
  • Repository Layer
    • Database와 같이 데이터 저장소에 접근하는 영역이다.
    • Dao(Data Access Object) 영역이라고 보면 된다.
  • Dtos
    • Dto(Data Tansfer Object)는 계층 간에 데이터 교환을 위한 객체를 이야기하며 Dtos는 이들의 영역을 얘기한다.
    • 예를 들어 뷰 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체 등이 이들을 이야기한다.
  • Domain Model
    • 도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화시킨 것을 도메인 모델이라 한다.
    • 이를테면 택시 앱이라고 하면 배차, 탑승, 요금 등이 모두 도메인이 될 수 있다.
    • @Entity를 사용해본 사람이라면 @Entity가 사용된 영역 역시 도메인 모델이라 생각하면 된다.
    • 다만, 무조건 데이터베이스의 테이블과 관계가 있어야하는 것은 아니다.
    • VO처럼 값 객체들도 이 영역에 해당하기 때문이다.

비지니스 처리를 담당해야할 곳은 Domain이다. 기존에 서비스로 처리하던 방식을 트랜잭션 스크립트라고 한다.

아래 두 주문 취소 코드의 차이점을 고려해보자.

@Transactional
public Order cancelOrder(int orderId) {
    
    //1)
    OrdersDto order = ordersDao.selectOrders(orderId);
    BillingDto billing =  billingDao.selectBilling(orderId);
    DeliveryDto delivery = deliveryDao.selectDelivery(orderId);
    
    // 2)
    String deliveryStatus = delivery.getStatus();
    
    // 3)
    if ("IN_PROGRESS'.equals(deliveryStatus)) {
        delivery.setStatus("CANCEL");
        deliveryDao.update(delivery);
    }
    
    
    //4)
    order.setStatus("CANCEL");
    ordersDao.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가 각자 본인의 취소 이벤트를 처리하며, 서비스 메소드는 트랜잭션과 도메인 간의 순서만 보장해준다.

그럼 등록, 수정, 삭제 기능을 만들어 보자.
PostsApiController를 web 패키지에, PostsSaveRequestDao를 web.dto 패키지에, PostsService를 service.posts 패키지에 생성한다.

src/main/java/com/jojoldu/books/springboot/web/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 PostsApiController {
    
    private final PostsService postsService;
    
    @PostMapping("api/v1/posts")
    public Long save(@RequestBody PostsSaveRequestDto requestDto) {
        return postsService.save(requestDto);
    }
}

src/main/java/com/jojoldu/book/springboot/service/PostsService

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

스프링을 어느 정도 써본 사람이면 Controller와 Service에서 @Autowired가 없는 것이 어색하게 느껴진다. 스프링에서는 Bean을 주입받는 방식이 다음과 같다.

  • @Autowired
  • setter
  • 생성자

이 중 가장 권장하는 방식이 생성자로 주입받는 방식이다. (@Autowired는 권장하지 않는다). 즉 생성자로 Bean 객체를 받도록하면 되는데, @RequiredArgsConstructor에서 해결해준다. final이 선언된 모든 필드ㄹㄹ 인자값으로 하는 생성자를 롬복의 @RequiredArgsConstructor가 대신 생성해준 것이다.

생성자를 직접 안쓰고 롬복 어노테이션을 사용한 유는, 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정하는 번거로움을 해결하기 위함이다.

src/main/java/com/jojoldu/springboot/web/dto/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 클래스를 Request/Response 클래스로 사용해서는 안된다.

Entity 클래스는 데이터베이스와 맞닿은 핵심 클래스이다. Entity 클래스를 기준으로 테이블이 생성되고, 스키마가 변경된다. 화면 변경은 아주 사소한 기능 변경인데, 이를 위해 테이블과 연결된 Entity 클래스를 변경하는 것은 너무 큰 변경이다.

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

src/test/java/com/jojoldu/book/springboot/web/PostsApiControllerTest

package com.jojoldu.book.springboot.web;

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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
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 findById (@PathVariable Long id) {
        return postsService.findById(id);
    }
}

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 com.jojoldu.book.springboot.web.dto.PostsUpdateRequestDto;
import java.util.List;
import org.assertj.core.api.Assertions;
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.context.SpringBootTest.WebEnvironment;
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;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = 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
        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);
    }
}

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

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.GetMapping;
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;
	
    ...
    
    @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
src/main/java/com/jojoldu/book/springbook/web/dto/PostsResponseDto

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를 받아 필드에 값을 넣는다.

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

public class Posts {

   ...

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

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 기능에서 데이터베이스에 쿼리를 날리는 부분이 없다. 이게 가능한 이유는 JPA의 영속성 컨텍스트 때문이다.

영속성 컨텍스트란, 엔티티 영구 저장하는 환경이다. 일종의 논리적 개념이며, JPA의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함되어 있냐 아니냐로 갈린다.

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

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

이 코드가 정상적으로 Update 쿼리를 수행하는지 테스트 코드로 확인해보자.

PostApiControllerTest

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 com.jojoldu.book.springboot.web.dto.PostsUpdateRequestDto;
import java.util.List;
import org.assertj.core.api.Assertions;
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.context.SpringBootTest.WebEnvironment;
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;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = 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
        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
        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(expectedTitle);
        Assertions.assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
    }
}

MyBatis를 쓰던 것과 달리 JPA를 씀으로 좀 더 객체지향적인 코딩을 했다. 조회 기능은 실제로 톰캣을 실행해서 확인해보자.

먼저 웹 콘솔 옵션을 활성화해야 한다. application.properties에 다음과 같이 옵션을 추가한다.

spring.h2.console.enabled = true

그 다음 Application 클래스의 main 메소드를 실행한다. 웹 브라우저에서 http://localhost:8080/h2-console 로 접속하면 웹 콘솔화면이 등장한다.

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

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

보통 엔티티에는 해당 데이터의 생성시간과 수정시간을 포함한다. 그러다보니 매번 DB에 삽입하기 전, 갱신하기 전에 날짜 데이터를 등록하는 코드가 여기저기 들어가게 된다. 이 문제를 해결하고자 JPA Auditing을 사용한다.

LocalDate 사용

Java8부터 LocalDate와 LocalDateTime이 등장했다. 그간 Java의 기본 날짜 타입인 Date의 문제점을 제대로 고친 타입이라 Java8일 경우 필수라 생각하면 된다.

src/main/java/com/jojoldu/book/springboot/domain/BaseTimeEntity

package com.jojoldu.book.springboot.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 // 1
@EntityListeners(AuditingEntityListener.class) // 2
public abstract class BaseTimeEntity {

    @CreatedDate // 3
    private LocalDateTime createdDate;

    @LastModifiedDate // 4
    private LocalDateTime modifiedDate;
}

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

  • 1.@MappedSuperclass

    • JPA Entity 클래스들이 BaseTimeEntity을 상속할 경우 필드들(createdDate, modifiedDate)도 칼럼으로 인식하도록 한다.
  • 2.@EntityListeners(AuditingEntityListener.class)

    • BaseTimeEntity 클래스에 Auditing 기능을 포함시킨다.
    1. @CreatedDate
    • Entity가 생성되어 저장될 때 시간이 동 저장된다.
    1. @LastModifedDate
    • 조회환 Entity 값을 변경할 때 시간이 자동 저장된다.

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

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

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

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

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

JPA Auditing 테스트 코드 작성하기

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

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

        Assertions.assertThat(posts.getCreatedDate()).isAfter(now);
        Assertions.assertThat(posts.getModifiedDate()).isAfter(now);
    }

0개의 댓글