SpringBoot에서 JPA 사용하기(3) - API 만들기
해당 내용은 이동욱님 저서 '스프링 부트와 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)
- 비즈니스 로직을 처리해야 하는 곳
주문을 취소하는 상황에서
-
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
→ 서비스 계층이 무의미하고, 객체란 단순히 데이터 덩어리 역할만 하게됨
-
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을 활용한다.
Author And Source
이 문제에 관하여(SpringBoot에서 JPA 사용하기(3) - API 만들기), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@shawnhansh/SpringBoot에서-JPA-사용하기3-API-만들기저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)