SpringBoot에서 JPA 사용하기(3) - API 만들기

dev_Shawn·2022년 3월 24일
0

SpringBoot

목록 보기
4/15
post-thumbnail

해당 내용은 이동욱님 저서 '스프링 부트와 AWS로 혼자 구현하는 웹 서비스'를 공부하며 정리한 내용입니다.

API를 만들기 위해 필요한 클래스

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

나는 지금껏 비즈니스 로직은 Service에서 처리한다고 배우고 그렇게 실천해왔는데 이 책에서는 나의 이 생각을 읽기라도 한듯 ‘그렇지 않다!'라고 한다. Service는 트랜잭션, 도메인 간 순서 보장의 역할만 한다고 한다.

그럼 비즈니스 로직은 누가 처리하지?

(이거 알면 최소 20대 후반)

그 전에 Spring의 각 웹 계층을 살펴보자!

Web Layer

  • Filter, Interceptor, Controller Advice, Controller, View Template 등 외부 요청과 응답에 대한 전반적인 영역

Service Layer

  • @Service에 사용 되는 영역
  • Controller와 DAO의 중간 영역에서 사용
  • @Transactional이 사용되어야 하는 영역

Persistence Layer (=Repository Layer)

  • DB와 같은 데이터 저장소에 접근하는 영역

DTOs

  • 계층 간 데이터 교환을 위한 객체인 DTO(Data Transfer Object)의 영역

Domain Model

  • 도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화 시킨 것
  • 택시 앱을 예로 들면 배차, 탑승, 요금 등이 모두 도메인
  • @Entity가 사용된 영역, VO(Value Object)
  • 비즈니스 로직을 처리해야 하는 곳

주문을 취소하는 상황에서

  1. Service Layer에서 비즈니스 로직을 처리할 경우

    @Transactional
    public Order cancelOrder(int orderId){
    	Orders order = orderRepository.findById(orderId);
    	Billing billing = billingRepository.findByOrderId(orderId);
    	Delivery delivery = deliveryRepository.findByOrderId(orderId);
    
    	String deliveryStatus = delivery.getStatus();
    
    	if("IN_PROGRESS".equals(deliveryStatus)){
    		delivery.setStatus("cancel");
    		deliveryDao.update(delivery);	
    	}
    
    	order.setStatus("cancel");
    	ordersDao.update(order);
    	
    	billing.setStatus("cancel");
    	billngDao.update(billing);
    	
    	return order;
    }

    필요한 데이터를 조회하여 취소가 가능하다면 각 테이블에 취소 상태를 update

    → 서비스 계층이 무의미하고, 객체란 단순히 데이터 덩어리 역할만 하게됨

  2. Domain Model에서 비즈니스 로직을 처리할 경우

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

    필요한 데이터를 조회한 뒤 각 객체가 각각의 취소 이벤트 처리를 하며, 서비스 메서드는 트랜잭션과 도메인 간의 순서만 보장


PostsApiController

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

    @DeleteMapping("/api/v1/posts/{id}")
    public Long delete(@PathVariable Long id){
        postsService.delete(id);
        return id;
    }
}                                                           

Injection

  • Bean을 주입 받는 방식들(@Autowired, Setter, 생성자) 중 으뜸은 생성자로 주입 받기.
  • final로 필드를 선언하고 @RequiredArgsConstructor로 생성자 생성.
  • 직접 생성자를 만드는 대신 롬복을 사용하는 이유 : 클래스의 의존성 관계가 변경될 때마다 코드를 계속 수정하는 번거로움을 해결하기 위해

PostsRepository

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

import java.util.List;

public interface PostsRepository extends JpaRepository<Posts, Long> {

    @Query("SELECT p FROM Posts p ORDER BY p.id DESC")
    List<Posts> findAllDesc();

}

SpringDataJPA에서 제공하지 않는 메서드는 @Query와 함께 쿼리문으로 작성할 수 있다.
규모가 있는 프로젝트에서의 데이터 조회는 FK의 Join, 복잡한 조건 등으로 인해 Entity 클래스만으로 처리하기 어려워 조회용 프레임워크를 추가로 사용한다.
대표적으로 querydsl, jooq, myBatis가 있다.
프레임워크로 조회하고, 등록/수정/삭제는 SpringDataJpa를 사용한다.

PostsService

package com.shawn.springboot.service.posts;

import com.shawn.springboot.domain.posts.Posts;
import com.shawn.springboot.domain.posts.PostsRepository;
import com.shawn.springboot.web.dto.PostsListResponseDto;
import com.shawn.springboot.web.dto.PostsResponseDto;
import com.shawn.springboot.web.dto.PostsSaveRequestDto;
import com.shawn.springboot.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;

@RequiredArgsConstructor
@Service
public class PostsService {
    private final PostsRepository postsRepository;

    @Transactional
    public Long save(PostsSaveRequestDto requestDto){
        return postsRepository.save(requestDto.toEntity()).getId();
    }

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

    @Transactional(readOnly = true)
    public List<PostsListResponseDto> findAllDesc() {
        return postsRepository.findAllDesc().stream().map(PostsListResponseDto::new).collect(Collectors.toList());
        //map(PostsListResponseDto::new) == map(posts -> new PostsListResponseDto(posts))
    }

    @Transactional
    public void delete(Long id){
        Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
        postsRepository.delete(posts);
    }
}

update

  • update 기능에서는 JPA의 영속성 컨텍스트(엔티티를 영구 저장하는 환경) 덕분에 쿼리를 직접 날리지 않아도 된다.
  • JPA의 엔티티 매니저가 활성화된 상태(Spring Data JPA를 싸용하면 기본 옵션)에서는 트랜잭션 안에서 DB 데이터를 가져오면 해당 데이터는 영속성 컨텍스트가 유지되는 상태
  • 이 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경된 데이터를 반영한다.
  • Entity 객체의 값 변경 만으로 update. 더티 체킹이라고 표현.

delete

  • delete()는 엔티티를 파라미터로 삭제할 수 있고, deleteBuId()를 사용하면 id로 삭제할 수도 있다.
  • 여기서는 존재하는 Posts인지 확인을 위해 엔티티 조회 후 그대로 삭제

@Transactional(readOnly = true)

  • 트랜잭션 범위는 유지하되, 조회기능만 남겨두어 조회 속도 개선된다.

PostsSaveRequestDto

import com.shawn.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 클래스와 유사한 형태이지만 절대로 Entity 클래스를 Request/Response 클래스로 사용하면 안된다!
  • Entity 클래스는 DB와 맞닿은 핵심 클래스. 이를 기준으로 테이블이 생성되고 스키마가 변경된다.
  • Dto는 View를 위한 클래스이기 때문에 변경이 잦다.
  • View Layer와 DB Layer의 역할을 철저하게 분리하는 것이 좋다.

PostsResponseDto

import com.shawn.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를 받아 처리한다.

PostsUpdateRequestDto

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

PostsApiControllerTest

package com.shawn.springboot.web;

import com.shawn.springboot.domain.posts.Posts;
import com.shawn.springboot.domain.posts.PostsRepository;
import com.shawn.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_for_test";
        String content = "content_for_test";
        PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
                                                    .title(title)
                                                    .content(content)
                                                    .author("author_for_test")
                                                    .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__2")
                                            .content("content__2")
                                            .author("author__2")
                                            .build());
    }

		@Test
    public void Posts_수정된다() throws Exception{
        //given
        Posts savedPosts = postsRepository.save(Posts.builder()
                                            .title("title__2")
                                            .content("content__2")
                                            .author("author__2")
                                            .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);

    }
}

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)

  • JPA 기능까지 한번에 테스트 할 때는 @SpringBootTest와 TestRestTemplate을 활용한다.
profile
안주는 술 마실 때나

0개의 댓글