본문 바로가기

스프링부트와 AWS로 구현하는 웹서비스

[스프링부트/AWS] 3장 스프링 부트에서 JPA로 데이터베이스 다뤄보자(2/2) - Spring 웹계층, CRUD API, Auditing

- Spring 웹 계층

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

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

여기서 많은 분들이 오해하고 있는 것이, Service에서 비즈니스 로직을 처리해야 한다는 것입니다. 하지만, 전혀 그렇지 않다고 합니다. Service는 트랜잭션, 도메인 간 순서 보장의 역할만 합니다.

Spring 웹 계층

1) Web Layer

  • 흔히 사용하는 컨트롤러(@Controller)와 JSP/Freemarker 등의 뷰 템플릿 영역입니다.
  • 이외에도 필터(@Filter), 인터셉트, 컨트롤러 어드바이스(@ContollerAdvice) 등 외부 요청과 응답에 대한 전반적인 영역을 이야기 힙니다.

2) Service Layer

  • @Service에 사용되는 서비스 영역입니다.
  • 일반적으로 Controller와 Dao의 중간 영역에서 사용됩니다.
  • @Transactional이 사용되어야 하는 영역이기도 합니다.

3) Repository Layer

  • Database와 같이 데이터 저장소에 접근하는 영역입니다.
  • 기존에 개발했던 Dao(Data Access ObjecT)영역으로 이해하면 됩니다.

4) Dtos

  • Dto(Data Transfer Object)는 계층 간에 데이터 교환을 위한 객체를 이야기하며 Dtos를 이들의 영역을 얘기합니다.
  • 예를 들어 뷰 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체 등이 이들을 이야기합니다.

5) Domain Model

  • 도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화시킨 것을 도메인 모델이라고 합니다.
  • 이를테면 택시 앱이라고 하면 배차, 탑승, 요금 등이 모두 도메인이 될 수 있습니다.
  • @Entity를 사용해보신 분들은 @Entity가 사용된 영역 역시 도메인 모델이라고 이해해 주시면 됩니다.
  • 다만, 무조건 데이터베이스의 테이블과 관계가 있어야만 하는 것은 아닙니다.
  • VO처럼 값 객체들도 이 영역에 해당하기 때문입니다.

이 5가지 레이어에서 비즈니스 처리는 Domain에서 담당합니다. 기존에 서비스로 처리하던 방식을 트랜잭션 스크립트라고 합니다.

//슈도코드
@Transactional
public Order cancelOrder(int orderId){
	1) 데이터베이스로부터 주문정보(Orders), 결제정보(Billing), 배송정보(Delivery) 조회
    2) 배송 취소를 해야 하는지 확인
    3) if(배송 중이라면){
    	배송 취소로 변경
        }
    4) 각 테이블에 취소 상태 Update
  }
//실제코드
@Transactional
Public Order cancelOrder(int orderId){
	//1)
    OrderDto 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 = orderRepository.findById(orderId);
    Biling billing = billingRepository.findByOrderId(orderId);
    Delivery delivery = deliveryRepository.findByOrderId(orderId);
    
    //2-3)
    delivery.cancel();
    
    //4)
    order.cancel();
    billing.cancel();
    
    return order;
}

각자 본인의 취소 이벤트 처리를 하며, 서비스 메소드는 트랜잭션과 도메인 간의 순서만 보장해 줍니다. 이 책에서는 계속 이렇게 도메인 모델을 다루고 코드를 작성해 있습니다.

 

- 등록/수정/삭제 API 생성

패키지 및 클래스 생성

1) PostApiController

package com.jordy.books.springboot.web;

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

 

2) PostsService

package com.jordy.books.springboot.service.posts;

import com.jordy.books.springboot.domain.posts.PostsRepository;
import com.jordy.books.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();
    }
}

Spring에 있는 Controller와 Service에서 @Autowired가 없는 것을 볼 수 있습니다. 스프링에서는 Bean을 주입받는 방식들은 다음과 같습니다.

  • @Autowired
  • setter
  • 생성자

이중 가장 권장하는 방식이 생성자로 주입받는 방식입니다.(@Autowired는 권장하지 않습니다.) 즉 생성자로 Bean 객체를 받도록 하면 @Autowired와 동일한 효과를 볼 수 있습니다. 위의 소르를 보면 @RequiredArgsConstructor에서 해결해 주고 있습니다. final이 선언된 모든 필드를 인자갑으로 하는 생성자를 대신 생성해 줍니다.

생성자를 직접 안 쓰고 롬복 어노테이션을 사용한 이유

룸복 어노테이션이 있으면 컨트롤러에 새로운 서비스를 추가하거나, 기존 컴포넌트를 제거하는 등의 상황이 발생해도 생성자 코드는 전혀 손대지 않아도 됩니다.

 

3) Controller와 Service에서 사용할 Dto 클래스 생성(PostsSaveRequestDto)

package com.jordy.books.springboot.web.dto;

import com.jordy.books.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클래스는 데이터베이스와 맞닿은 핵심 클래스입니다. 그래서 변경되면 여러 클래스에 영향을 끼치지만, Request와 Response용 Dto는 View를 위한 클래스라 정말 자주 변경이 필요합니다.

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

그래서 꼭 Entity클래스와 Controller에서 쓸 Dto는 분리해서 사용해야 합니다.

 

4) 등록기능 테스트 코드로 검증(PostsApiControllerTest)

패키지 및 파일 경로

package com.jordy.books.springboot.web;

import com.jordy.books.springboot.domain.posts.Posts;
import com.jordy.books.springboot.domain.posts.PostsRepository;
import com.jordy.books.springboot.web.dto.PostsSaveRequestDto;
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.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;

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

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

Posts 등록 API 테스트 결과

WebEnvironment.RANDOM_PORT로 인한 랜덤 포트 실행과 insert 쿼리가 모두 실행된 것을 확인 할 수 있습니다.\

 

5) 수정/조회 기능 생성

 

(1) PostApiController

package com.jordy.books.springboot.web;

import com.jordy.books.springboot.service.posts.PostsService;
import com.jordy.books.springboot.web.dto.PostsSaveRequestDto;
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);
    }
}
  • @PathVariable(경로를 변수화)
    • @~Mapping의 값 {id}를 변수화하여 @PathVariable Long id변수에 Mapping한다는 의미 입니다.

 

(2) PostsResponseDto

package com.jordy.books.springboot.web.dto;

import com.jordy.books.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();
    }

}

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

 

(3) PostUpdateRequestDto

package com.jordy.books.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;
    }
}

 

(4) Posts

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

 

(5) 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의 엔티티 매니저(Entity Manager)가 활성화된 상태로(Spring Data Jpa를 쓴다면 기본 옵션) 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태입니다.

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

  • 더티(Dirty) : 상태변화가 생긴정도
  • 더티 체킹(Dirty Checking) : 상태 변경 검사

이때 변화의 기준은 최초 조회 상태입니다.

JPA에서는 엔티티를 조회하면 해당 엔티티의 조회 상태 그대로 스냅샷을 만들어 놓습니다.

그리고 트랜잭션이 끝나는 시점에는 이 스냅샷과 비교해서 다른점이 있다면 Update Query를 데이터베이스로 전달합니다. 이런 상태 변경 검사의 대상은 영속성 컨텍스트가 관리하는 엔티티에만 적용 됩니다.

  • detach된 엔티티(준영속)
  • DB에 반영되기 전 처음 생성된 엔티티(비영속)

(6) PostsApiControllerTest(테스트 코드 작성)

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

Posts등록 API 테스트 결과

이렇게하면 좀 더 객체지향적으로 코딩할 수 있음을 느낄 수 있습니다. JPA와 테스트 코드에 대해 진행해 보았으니, 조회 기능은 실제로 톰켓을 실행해서 확인해 보겠습니다.

 

6) H2 웹콘솔 접근

H2 데이터베이스는 메모리에서 실행하기 때문에 직접 접근하려면 웹 콘솔을 사용해야만 합니다. 먼저 application.properties에 다음과 같이 옵션을 추가하여 웹 콘솔 옵션을 활성화합니다.

spring.h2.console.enabled=true

추가한 뒤 Applicationi 클래스의 main 메소드를 실행합니다. 8080포트로 톰켓이 실행됐다면, 웹 브라우저에서 http://localhost:8080/h2-console로 접속하면 다음과 같은 웹 콘솔 화면이 등장합니다.

JDBC URL 수정 후 Connect

H2 관리자 페이지 이동 후에는 다음과 같이 POSTS 테이블이 정상적으로 노출되어야 합니다.

Posts 테이블

간단한 쿼리를 실행해 봅시다.

SELECT * FROM POSTS

현재 등록된 데이터 없음

insert 쿼리를 실행 후 이를 API로 조회해 보겠습니다.

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

아래와 같이 API를 요청해 보겠습니다. 브라우저에 http://localhost:8080/api/v1/posts/1을 입력해 API 조회기능을 테스트해 봅니다.

책에 나온데로 Chrome에 JSON Viewer, JSON Fomatter 플러그인을 설치했습니다. 

브라우저에 API 조회

chrome.google.com/webstore/detail/jsonview/chklaanhfefbnpoihckbnefhakgolnmc?hl=ko

 

JSONView

Validate and view JSON documents

chrome.google.com

 

chrome.google.com/webstore/detail/json-formatter/mhimpmpmffogbmmkmajibklelopddmjf

 

JSON Formatter

Makes JSON easy to read. Open source.

chrome.google.com

 

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

보통 엔티티(Entity)에는 해당 데이터의 생성시간과 수정시간을 포함합니다. 매번  DB에 삽입(Insert)하기 전, 갱신(Update)하기 전에 날짜 데이터를 등록/수정하는 코드가 여기저기 들어가게 되면, 귀찮고 소스가 지저분해 집니다. 그래서 이 문제를 해결하고자 JPA Auditing를 사용합니다.

 

1) LocalDate 사용

Java 8부터 LocalDate와 LocalDateTime이 등장했습니다. 그 동안 Java의 기본 날짜 타입인 Date의 문제점을 제대로 고친 타입이라 Java 8일 경우 무조건 써야 한다고 합니다.

Java 8이 나오기 전까지 사용되었던 Date와 Calendar클래스의 문제점
  • 불변(변경이 불가능한) 객체가 아닙니다.
    • 멀티 쓰레드 환경에서 언제든 문제가 발생할 수 있습니다.
  • Calendar는 월(Month) 값 설계가 잘못되어있었습니다.
    • 10월을 나타내는 Calendar.OCTOBER의 숫자 값은 '9'입니다.
    • 당연히 '10'으로 생각했던 개발자들에게는 큰 혼란이 왔습니다.

LocalDate와 LocalDateTime이 데이터베이스에 제대로 매핑되지 않는 이슈가 Hibernate 5.2.10 버전에서 해결되었습니다.

스프링 부트 1.x를 쓴다면 별도로 Hibernate 5.2.10 버전 이상을 사용하도록 설정이 필요하지만, 스프링 부트 2.x 버전을 사용하면 기본적으로 해당 버전을 사용 중이라 별다른 설정 없이 바로 적용하면 됩니다.

 

2) BaseTimeEntity 클래스 생성(domain패키지)

BaseTimeEntity 클래스

package com.jordy.books.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 class BaseTimeEntity {

    @CreatedDate // 3)
    private LocalDateTime createdDate;

    @LastModifiedDate // 4)
    private LocalDateTime modifiedDate;
}

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

(1) @MappedSuperclass

  • JPA Entity 클래스들이 BaseTimeEntity을 상속할 경우 필드들(createdDate, moditiedDate)도 컬럼으로 인식하도록 합니다.

(2) @EntityListeners(AuditingEntityListener.class)

  • BaseTimeEntity 클래스에 Auditing 기능을 포함시킵니다.

(3) @CreatedDate

  • Entity가 생성되어 저장될 때 시간이 자동 저장됩니다.

(4) @LastModifiedDate

  • 조회한 Entity의 값을 변경할 때 시간이 자동 저장됩니다.

BaseTimeEntity 클래스 생성 후 Posts 클래스에 상속을 해줍니다.

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

3) JPA Auditing 테스트 코드 작성하기

기존 PostsRepositoryTest 클래스에 테스트 메소드를 추가하겠습니다.

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

테스트 코드를 수행해보면 다음과 같이 실제 시간이 잘 저장된 것을 확인할 수 있습니다.

JPA Auditing 테스트 코드 결과

앞으로 추가될 엔티티들은 더이상 등록일/수정일로 고민할 필요가 없습니다. BaseEntity만 상속받으면 자동으로 해결되기 때문입니다.

 

 

출처 : 

jojoldu.tistory.com/463

 

[스프링 부트와 AWS로 혼자 구현하는 웹 서비스] 출간 후기

(출판사: 프리렉, 쪽수: 416, 정가: 22,000원) 서적 링크 오프라인 서점에는 2019.12.04 (수) 부터 올라갈 예정입니다. 강남 교보문고나 광화문 교보문고는 주말에도 올라올 순 있겠지만, 혹시 모르니

jojoldu.tistory.com