들어가기 전에
하기 포스팅은 스프링 부트와 AWS로 혼자 구현하는 웹 서비스(이동욱 저)를 공부하여 작성한 내용입니다.
등록/수정/조회 API 만들기
API를 만들기 위해서는 총 3개의 클래스가 필요합니다.
- Request 데이터를 받을 DTO
- API 요청을 받을 Controller
- 트랜잭션, 도메인 기능 간의 순서를 보장하는 Service
Service에서 비즈니스 로직을 처리해야 한다고 생각할 수 있으나, Service는 트랜잭션, 도메인 간 순서 보장의 역할만 합니다.
Spring 웹 계층
- Web Layer
- 흔히 사용하는 컨트롤러(@Controller)와 JSP/Freemarker 등의 뷰 템플릿 영역입니다.
- 이외에도 필터, 인터셉터, 컨트롤러 어드바이스 등 외부 요청과 응답에 대한 전반적인 영역입니다.
- Service Layer
- @Service에 사용되는 서비스 영역입니다.
- 일반적으로 Controller와 Dao의 중간 영역에서 사용됩니다.
- @Transactional이 사용되어야 하는 영역이기도 합니다.
- Repository Layer
- Databased와 같은 데이터 저장소에 접근하는 영역입니다.
- DTOs
- DTO(Data Transfer Object)는 계층 간 데이터 교환을 위한 객체이며, DTOS는 이들의 영역을 이야기합니다.
- 예를 들어 뷰 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체 등이 이들을 이야기합니다.
- Domain Model
- 도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화시킨 것을 도메인 모델이라고 합니다.
- 예를 들어 쇼핑 앱이라고 하면 주문, 검색 등이 모두 도메인이 될 수 있습니다.
- @Entity가 사용된 영역을 도메인 모델이라고 볼 수 있습니다.
- 무조건 데이터베이스의 테이블과 관계있어야 하는 것은 아니며, VO처럼 값 객체들도 이 영역에 해당합니다.
비즈니스 처리를 담당해야 할 곳은 어디일까?
위 Spring 웹 계층에서 볼 수 있듯 비즈니스 처리를 담당해야 할 곳은 Domain입니다.
서비스 영역에서 비즈니스 처리를 진행한다면 아래와 같은 방식으로 진행됩니다(트랜잭션 스크립트 방식).
// Pseudo code
@Transactional
public Order cancelOrder(int orderId) {
1) db로부터 주문정보(Order), 결제정보(Billing), 배송정보(Delivery) 조회
2) 배송 취소를 해야 하는지 확인
3) if (배송 중이라면) {
배송 취소로 변경
}
4) 각 테이블에 취소 상태 update
}
// 실제 코드
@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();
biling.cancel();
return order;
}
- order, billing, delivery가 각자 본인의 취소 이벤트를 처리하며, 서비스 메소드는 트랜잭션과 도메인 간 순서만 보장해줍니다.
위 내용을 생각하면서 도메인 모델을 다루면서 등록, 수정, 삭제 기능을 하는 API를 만들어보겠습니다.
등록 API 코드 작성하기
먼저 PostApiController를 web 패키지에, PostsSaveRequestDto를 web.dto 패키지에, PostsService를 service.posts 패키지에 생성합니다.
아래 코드를 살펴보면, Controller와 Service에서 @Autowired가 없는 것을 확인할 수 있습니다. 스프링에서 Bean을 주입받는 방식들은 다음과 같습니다.
스프링 Bean 주입법
- @Autowired
- setter
- 생성자 (가장 권장)
위 3가지 방법 중 가장 권장하는 Bean 주입법은 생성자로 주입받는 방식으로 @Autowired는 권장하지 않는 방식입니다. 즉, 생성자로 Bean 객체를 받도록 하면 @Autowired와 동일한 효과를 볼 수 있다는 의미입니다. 그래서, @RequiredArgsConstructor 어노테이션(롬복 어노테이션)을 이용해 final이 선언된 모든 필드를 인자값으로 하는 생성자를 생성한 것입니다.
- 생성자를 직접 사용하지 않고 롬복 어노테이션을 이용한 이유는, 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속 수정하지 않기 위함입니다.
PostsApiController
package com.earth.study.springboot.web;
import com.earth.study.springboot.service.posts.PostsService;
import com.earth.study.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.earth.study.springboot.service.posts;
import com.earth.study.springboot.domain.posts.PostsRepository;
import com.earth.study.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();
}
}
PostsSaveRequestDto
package com.earth.study.springboot.web.dto;
import com.earth.study.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();
}
}
- Controller와 Service에서 사용할 Dto 클래스입니다.
- Entity 클래스(domain.posts.Posts.java)와 거의 유사한 형태임에도 DTO 클래스를 추가 생성했습니다.
- Entity 클래스를 Request/Response 클래스로 사용하면 안됩니다.
- Entity 클래스는 데이터베이스와 맞닿는 핵심 클래스로 Entity 클래스를 기준으로 테이블이 생성되고 스키마가 변경됩니다.
- 화면 변경은 아주 사소한 기능 변경이므로, 이를 위해 테이블과 연결된 Entity 클래스를 변경하는 것은 너무 큰 변경입니다.
- 수많은 서비스 클래스나 비즈니스 로직이 Entity 클래스를 기준으로 동작하므로, Entity 클래스가 변경되면 여러 클래스에 영향을 끼칩니다.
- Request와 Response용 DTO는 View를 위한 클래스라 자주 변경이 필요하기 때문에, View Layer와 DB Layer의 역할 분리를 위해 위와 같이 DTO 클래스를 따로 사용하는 것이 좋습니다.
- Entity 클래스와 Controller에서 사용할 DTO는 꼭 분리해서 사용해야 합니다.
등록 기능의 코드를 검증하기 위해 test 패키지 중 web 패키지에 PostsApiControllerTest를 생성해보겠습니다.
package com.earth.study.springboot.web;
import com.earth.study.springboot.domain.posts.Posts;
import com.earth.study.springboot.domain.posts.PostsRepository;
import com.earth.study.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);
}
}
- Api Controller를 테스트할 때에는 HelloController와 달리 @WebMvcTest를 사용하지 않았습니다.
- @WebMvcTest의 경우, JPA 기능이 작동하지 않기 때문에 해당 어노테이션을 사용하지 않았습니다.
- JPA 기능까지 한번에 테스트할 때에는 @SpringBootTest와 TestRestTemplate을 사용하면 됩니다.
- WebEnvironment.RANDOM_PORT로 인해 랜덤 포트 실행된 것을 해당 테스트 코드 실행 시 확인할 수 있습니다.
수정/조회 API 코드 작성하기
아래 코드들을 모두 작성하면, 프로젝트 내 클래스목록은 아래와 같아집니다.
PostsApiController
package com.earth.study.springboot.web;
import com.earth.study.springboot.service.posts.PostsService;
import com.earth.study.springboot.web.dto.PostsSaveRequestDto;
import com.earth.study.springboot.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
@RestController
public class 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.earth.study.springboot.web.dto;
import com.earth.study.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.earth.study.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 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의 영속성 컨텍스트 때문입니다.
영속성 컨텍스트
영속성 컨텍스트란, entity를 영구 저장하는 환경입니다. 일종의 논리적 개념이라고 볼 수 있으며, JPA의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함되어 있냐 아니냐로 갈립니다.
JPA의 EntityManager가 활성화된 상태로(Spring Data JPA 기본 옵션) 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지되는 상태입니다.
이 상태에서 해당 데이터 값을 변경하면, 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영합니다. 즉, Entity 객체의 값만 변경하면 별도로 Update 쿼리를 날릴 필요가 없는 것입니다.
이 개념을 더티 체킹(dirty checking)이라고 합니다.
등록 기능 때와 마찬가지로, 수정 기능의 코드를 검증하기 위해 test 패키지 중 web 패키지에 PostsApiControllerTest 내에 아래와 같이 코드를 추가해보겠습니다.
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class 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
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);
}
}
테스트 결과를 보면, update 쿼리가 수정되는 것을 확인할 수 있습니다.
등록 API와 수정 API는 JPA와 테스트 코드를 이용해 확인해보았으니 조회 기능은 실제로 톰캣을 실행해서 확인해 보겠습니다.
로컬 환경에서는 데이터베이스로 H2를 사용합니다. 메모리에서 실행하기 때문에, 직접 접근하려면 웹 콘솔을 사용해야 합니다. 먼저, 웹 콘솔 옵션을 활성화하기 위해 application.properties에 아래 내용을 추가합니다.
spring.h2.console.enabled=true
위 옵션을 추가한 후 Application 클래스의 main 메소드를 실행합니다. 실행 시 로그를 보면 8080 포트로 톰캣이 실행되었음을 확인할 수 있습니다. 이때, http://localhost:8080/h2-console로 접속하면 다음과 같은 웹 콘솔 화면을 확인할 수 있습니다.
이때, h2-console 화면에서 JDBC URL이 jdbc:h2:mem:testdb로 뜨지 않는다면, 해당 내용을 기입한 후 Connect을 클릭합니다.
connect한 후 나오는 화면은 아래 왼쪽 상단과 같습니다. 이때, POSTS 테이블이 정상적으로 보여야 합니다. 먼저, posts 테이블 내 항목들을 조회해 본 후, 데이터를 insert하고, insert한 데이터를 API로 조회해보겠습니다.
브라우저에서 http://localhost:8080/api/v1/posts/1을 입력해 조회 기능을 테스트하면 위와 같이 json 형식으로 확인이 가능합니다. 위와 같은 json 형식이 불편한 분들은 아래 chrome 웹 스토어에서 JSON Viewer와 같은 확장 프로그램을 설치하여 정렬된 JSON 형식으로 볼 수 있습니다. 참고로, 확장 프로그램을 추가해도 시크릿 모드에서는 정렬된 JSON 형식으로 보이지 않습니다.
chrome.google.com/webstore/category/extensions?hl=ko
JPA Auditing으로 생성시간/수정시간 자동화하기
보통 Entity에는 해당 데이터의 생성시간과 수정시간을 포함합니다. 언제 만들어졌는지, 언제 수정되었는지 등은 추후 유지보수에 있어 중요한 정보이기 때문입니다. 따라서, 매번 DB에 삽입(insert) 또는 갱신(update) 전 날짜 데이터를 등록/수정하는 코드가 반드시 필요하게 됩니다.
// 생성일 추가 코드 예
public void savePosts() {
...
posts.setCreateDate(new LocalDate());
postsRepository.save(post);
...
}
이런 단순하고 반복적인 코드가 모든 테이블과 서비스 메소드에 포함되면 코드가 지저분해질 수 있습니다. 이를 해결하기 위해 JPA Auditing을 사용해보도록 하겠습니다.
LocalDate 사용하기
domain 패키지에 BaseTimeEntity 클래스를 생성합니다.
package com.earth.study.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
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {
@CreatedDate
private LocalDateTime createdDate;
@LastModifiedDate
private LocalDateTime modifiedDate;
}
- BaseTimeEntity 클래스는 모든 Entity의 상위 클래스가 되어 Entity들의 createDate, modifiedDate를 자동으로 관리하는 역할입니다.
- @MappedSuperclass
- JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우 필드들(createDate, modifiedDate)도 컬럼으로 인식하도록 합니다.
- @EntityListeners(AuditingEntityListener.class)
- BaseTimeEntity 클래스에 Auditing 기능을 포함시킵니다.
- @CreatedDate
- Entity가 생성되어 저장될 때 시간이 자동 저장됩니다.
- @LastModifiedDate
- 조회한 Entity의 값을 변경할 때 시간이 자동 저장됩니다.
BaseTimeEntity 클래스를 생성했다면, 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 코드가 완성된 것입니다. 이제는 생성시간/수정시간 자동화를 테스트해보기 위해 JPA Auditing 테스트 코드를 작성해보겠습니다. 해당 테스트는 PostsRepositoryTest 클래스에 테스트 메소드를 추가하여 진행합니다.
...
@Test
public void BaseTimeEntity_등록() {
// given
LocalDateTime now = LocalDateTime.of(2021,04,24,0,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);
}
}
테스트 코드를 수행해보면 아래와 같이 실제 시간이 저장되는 것을 확인할 수 있습니다.
앞으로 추가될 엔티티들은 따로 등록일/수정일을 위한 코드 작성을 하지 않아도, BaseTimeEntity만 상속받으면 자동으로 시간이 등록됩니다.
'FRAMEWORK > Spring' 카테고리의 다른 글
[Springboot, IntelliJ] Re-run Spring Boot Configuration Annotation Processor to update generated metadata (0) | 2021.10.04 |
---|---|
[SpringBoot] 게시판 만들기 v2.2 (머스테치로 화면 구성하기) (1) | 2021.04.25 |
[SpringBoot] JPA로 데이터베이스 다루기 (0) | 2021.04.24 |
[SpringBoot] springboot에서 테스트 코드 사용하기 (0) | 2021.04.20 |
[SpringBoot, PostgreSQL] myBatis ?(물음표) 문자 사용하기 (0) | 2021.02.02 |