03 스프링 부트에서 JPA로 데이터베이스를 다뤄보자(2)

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

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

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

Spring 웹 계층

Web Layer

컨트롤러(@Controller)와 JSP/Freemarker 등의 뷰 템플릿 영역

필터(@Filter), 인터셉터, 컨트롤러 어드바이스(@ControllerAdvice) 등 외부 요청과 응답에 대한 전반적인 영역

Service Layer

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

Controller와 Dao의 중간 영역에서 사용

@Transactional이 사용되어야 하는 영역

Service에서 비즈니스 로직을 처리? No! → 트랜잭션, 도메인 간 순서 보장

Repository Layer

데이터 저장소에 접근하는 영역(Dao)

Dtos

Dto(Data Transfer Object)는 계층 간에 데이터 교환을 위한 객체이며 Dtos는 이들의 영역

뷰 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체 등

Domain Model

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

택시 앱이라면 배차, 탑승, 요금 등

@Entity가 사용된 영역

무조건 데이터베이스의 테이블과 관계가 있어야만 하는 것은 아님 → VO처럼 값 객체들도 이 영역에 해당

비즈니스 로직 처리

트랜잭션 스크립트

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

기존에 서비스로 처리하던 방식

모든 로직이 서비스 클래스 내부에서 처리되어 서비스 계층이 무의미하며, 객체란 단순히 데이터 덩어리 역할만 하게 됨

Model에서 비즈니스 로직 처리

@Transactional
public Order cancelOrder(int orderId) {
    // 1) 데이터베이스로부터 주문정보(Orders), 결제정보(Billing), 배송정보(Delivery) 조회
    Orders order = ordersRepository.findById(orderId);
    Billing billing = billingRepository.findById(orderId);
    Delivery delivery = deliveryRepository.findById(orderId);

    // 2) 배송 취소를 해야 하는지 확인
    // 3) if(배송 중이라면) 배송 취소로 변경
    delivery.cancel();

    // 4) 각 테이블에 취소 상태 Update
    order.cancel();
    billing.cancel();
    
    return order;
}

order, billing, delivery가 각자 본인의 취소 이벤트 처리

서비스 메소드는 트랜잭션과 도메인 간의 순서만 보장

등록 기능

PostsApiController

package com.vencott.dev.springboot.web;

import com.vencott.dev.springboot.service.posts.PostsService;
import com.vencott.dev.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);
    }
}

PostsService

package com.vencott.dev.springboot.service.posts;

import com.vencott.dev.springboot.domain.posts.PostsRepository;
import com.vencott.dev.springboot.web.dto.PostsSaveRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import javax.transaction.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, 생성자의 3가지 방법을 사용하며 이중 가장 권장하는 방식은 생성자를 통한 주입이다

여기선 롬복의 @RequiredArgsConstructor가 final이 선언된 모든 필드를 인자값으로 하는 생성자를 대신 생성해준다

생성자를 직접 쓰지 않고 롬복을 사용하는 이유는 해당 클래스의 의존성 관계가 변경될 떄마다 생성자를 수정하지 않기 위해서다

PostsSaveRequestDto

package com.vencott.dev.springboot.web.dto;

import com.vencott.dev.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();
    }
}

위 Dto 클래스는 Entity 클래스와 거의 유사한 형태임에도 굳이 따로 만들어줬다

절대로 Entity 클래스를 Request/Response 클래스로 사용하면 안된다

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

수많은 서비스 클래스나 비즈니스 로직들이 Entity 클래스를 기준으로 동작하므로 이를 수정하는 것은 매우 리스크가 크다

Response/Request Dto은 화면의 수정을 위해 수시로 변경되므로 View Layer와 DB Layer의 역할 분리를 철저히 하여 Entity 클래스와 Controller에서 쓸 Dto는 분리해서 사용하자

PostsApiControllerTest(테스트)

package com.vencott.dev.springboot.web;

import com.vencott.dev.springboot.domain.posts.Posts;
import com.vencott.dev.springboot.domain.posts.PostsRepository;
import com.vencott.dev.springboot.web.dto.PostsSaveRequestDto;
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 PostsApiControllerTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private PostsRepository postsRepository;

    @After
    public void tearDown() throws Exception {
        postsRepository.deleteAll();
    }

    @Test
    public void Posts_등록된다() throws Exception {
        //given
        String title = "title";
        String content = "content";
        PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
                .title(title)
                .content(content)
                .author("author")
                .build();

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

        //when
        ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, Long.class);

        //then
        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(responseEntity.getBody()).isGreaterThan(0L);

        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(title);
        assertThat(all.get(0).getContent()).isEqualTo(content);
    }
}

@WebMvcTest는 JPA 기능이 작동하지 않고 Controller와 ControllerAdvice등 외부 연동과 관련된 부분만 활성화해 테스트하므로 여기선 사용 불가하다

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

테스트 결과, 다음과 같이 랜덤 포트에서 insert 쿼리가 모두 수행된 것을 볼 수 있다

수정/조회 기능

PostsApiController

@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

package com.vencott.dev.springboot.web.dto;

import com.vencott.dev.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

package com.vencott.dev.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;
    }
}

Posts

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

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를 쓴다면 기본 옵션) 트랜잭션 안에서 DB로부터 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태이다
  • 이 상태에서 데이터 값을 변경하면 트랜잭션이 끝나는 시점에서 해당 테이블에 변경분을 반영 → Entity 객체의 값만 변경하면 Update 쿼리를 날릴 필요가 없다!
  • 이를 더티 체킹이라고 한다

PostsApiControllerTest(테스트)

package com.vencott.dev.springboot.web;

import com.vencott.dev.springboot.domain.posts.Posts;
import com.vencott.dev.springboot.domain.posts.PostsRepository;
import com.vencott.dev.springboot.web.dto.PostsSaveRequestDto;
import com.vencott.dev.springboot.web.dto.PostsUpdateRequestDto;
import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.List;

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

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private PostsRepository postsRepository;

    @After
    public void tearDown() throws Exception {
        postsRepository.deleteAll();
    }

    @Test
    public void Posts_등록된다() throws Exception {
        //given
        String title = "title";
        String content = "content";
        PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
                .title(title)
                .content(content)
                .author("author")
                .build();

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

        //when
        ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, Long.class);

        //then
        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(responseEntity.getBody()).isGreaterThan(0L);

        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(title);
        assertThat(all.get(0).getContent()).isEqualTo(content);
    }

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

        Long updateId = savedPosts.getId();
        String expectedTitle = "title2";
        String expectedContent = "content2";

        PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
                .title(expectedTitle)
                .content(expectedContent)
                .build();

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

        HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto);

        //when
        ResponseEntity<Long> responseEntity = restTemplate.exchange(url, HttpMethod.PUT, requestEntity, Long.class);

        //then
        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(responseEntity.getBody()).isGreaterThan(0L);

        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
        assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
    }
}

MyBatis를 쓰는 것보다 JPA를 씀으로 조금 더 객체지향적으로 코딩할 수 있다

조회 테스트

조회 기능은 실제로 톰캣을 실행해서 확인해보자

로컬 환경의 H2 데이터베이스는 메모리에서 실행하기 때문에 직접 접근하려면 웹 콘솔을 사용하기 위해 application.properties에 다음 옵션을 추가한다

spring.h2.console.enabled=true

다음으로 Application 클래스의 main 메소드를 실행한 뒤 http://localhost:8080/h2-console로 접근하여 다음과 같이 세팅한다

다음과 같이 임의의 데이터를 넣고 테스트해본다

마지막으로, 주소창에 다음과 같이 GET 요청을 보내 데이터를 확인해본다

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

보통 엔티티에는 해당 데이터의 생성/수정 시간을 포함한다

언제 만들어졌고 수정되었는지 등은 차후 유지보수에 있어 매우 중요하기 때문이다

매번 DB에 삽입/갱신 하기 전에 날짜 데이터를 등록/수정하는 코드가 여기저기 들어가게 된다면 코드가 지저분해진다

JPA Auditing을 통해 이를 자동화할 수 있다

LocalDate 사용

Java8 부턴 기존 Date의 문제점을 제대로 고친 LocalDate, LocalDateTime을 사용한다

BaseTimeEntity

package com.vencott.dev.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 // JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우 필드들(createdDate, modifiedDate)도 컬럼으로 인식
@EntityListeners(AuditingEntityListener.class) // BaseTimeEntity 클래스에 Auditing 기능을 포함
public abstract class BaseTimeEntity {

    @CreatedDate // 생성
    private LocalDateTime createdDate;

    @LastModifiedDate // 수정
    private LocalDateTime modifiedDate;

}

Posts에서 상속

public class Posts extends BaseTimeEntity

Application

JPA Auditing 어노테이션들을 모두 활성화할 수 있도록 어노테이션 추가

@EnableJpaAuditing
@SpringBootApplication
public class Application {

JPA Auditing 테스트 코드 작성하기

    @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());
        assertThat(posts.getCreatedDate()).isAfter(now);
        assertThat(posts.getModifiedDate()).isAfter(now);
    }

출처: 이동욱 저, 『스프링 부트와 AWS로 혼자 구현하는 웹 서비스』, 프리렉(2019)


좋은 웹페이지 즐겨찾기