스프링 부트와 AWS로 혼자 구현하는 웹 서비스 를 공부하고 정리한 내용입니다.
🔔 API를 만들기 위해 총 3개의 클래스가 필요하다!
- Request 데이터를 받을
Dto
- API 요청을 받은
Controller
- 트랜잭션, 도메인 기능 간의 순서를 보장하는
Service
💡 참고
- Service에서 비즈니스 로직을 처리한다는 것은 잘못된 것이다.
Service
는 트랜잭션, 도메인 간 순서 보장의 역할만 한다.
✔ 비즈니스 로직은 누가 처리하냐?
Web Layer
@Controller
)와 JSP/Freemarker 등의 뷰 템플릿 영역@Filter
), 인터셉터, 컨트롤러 어드바이스(@ControllerAdvice
) 등 외부 요청과 응답에 대한 전반적인 영역을 이야기한다.Service Layer
@Service
에 사용되는 서비스 영역Controller
와 Dao
의 중간 영역에서 사용된다.@Transactional
이 사용되어야 하는 영역이기도 하다.Repository Layer
Dtos
Domian Model
✔ 트랜잭션 스크립트
기존에 서비스로 처리하던 방식
ex) 주문 취소 로직 작성
슈도 코드
@Transactional
public Order cancelOrder(int orderId){
1) 데이터베이스로부터 주문정보(Orders), 결제정보(Billing), 배송정보(Delivery) 조회
2) 배송 취소를 해야 하는지 확인
3) if (배송 중이라면){
배송 취소로 변경
}
4) 각 테이블에 취소 상태 Update
}
실제 코드
@Transactional
public Order cancelOrder(nt 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)
Order order = orderRepository.findById(orderId);
Billing billing = billingRepository.findByOrderId(orderId);
Delivery delivery = deliveryRepository.findByOrderId(orderId);
// 2-3)
delivery.cancel();
// 4)
order.cancel();
billing.cancel();
return order;
}
order, billing, delivery
가 각자 본인의 취소 이벤트 처리를 한다.
✔ 등록, 수정, 삭제 기능 소스
PostApiController
package springbootawsbook.springawsbook.web;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import springbootawsbook.springawsbook.service.PostsService;
import springbootawsbook.springawsbook.web.dto.PostsSaveRequestDto;
@RequiredArgsConstructor
@RestController
public class PostApiController {
private final PostsService postsService;
@PostMapping("/api/v1/posts")
public Long save(@RequestBody PostsSaveRequestDto requestDto){
return postsService.save(requestDto);
}
}
PostsService
package springbootawsbook.springawsbook.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import springbootawsbook.springawsbook.domain.posts.PostsRepository;
import springbootawsbook.springawsbook.web.dto.PostsSaveRequestDto;
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
@Transactional
public Long save(PostsSaveRequestDto requestDto) {
return postsRepository.save(requestDto.toEntity())
.getId();
}
}
🎁 스프링에서 Bean을 주입받는 방식
@Autowired
,setter
,생성자
- 이 중에서 가장 권장하는 방식은 생성자로 주입받는 방식
@Autowired
는 권장하지 않는다.- 생성자로 Bean 객체를 받도록 하면
@Autowired
와 동일한 효과를 볼 수 있다.
@RequiredArgsConstructor
: final이 선언된 모든 필드를 인자값으로 하는 생성자를 대신 생성해준다.
- 생성자를 직접 안쓰고 롬복 어노테이션을 사용하는 이유 : 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정하는 번거로움을 해결하기 위해서 사용한다.
- 롬복 어노테이션이 있으면 컨트롤러에 새로운 서비스를 추가하거나, 제거하거나 등 어떤 상황이 발생해도 생성자 코드는 전혀 손대지 않아도 된다.
PostsSaveRequestDto
Controller
와Service
에서 사용할 Dto 클래스 생성
package springbootawsbook.springawsbook.web.dto;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import springbootawsbook.springawsbook.domain.posts.Posts;
@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 클래스
- Entity 클래스는 데이터베이스와 맞닿은 핵심 클래스
- Entity 클래스를 기준으로 테이블이 생성되고, 스키마가 변경된다.
- 수많은 서비스 클래스나 비즈니스 로직들이 Entity 클래스를 기준으로 동작한다.
- Entity 클래스가 변경되면 여러 클래스에 영향을 끼치지만, Request와 Response 용 Dto는 View를 위한 클래스라 정말 자주 변경이 필요하다.
Controller
에서 결괏값으로 여러 테이블을 조인해서 줘야 할 경우, 빈번하므로 Entity
클래스만으로 표현하기가 어려운 경우가 많다.Entity
클래스와 Controller
에서 쓸 Dto는 분리해서 사용해야 한다!
PostsApiControllerTest - 테스트 코드
package springbootawsbook.springawsbook.web;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
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 springbootawsbook.springawsbook.domain.posts.Posts;
import springbootawsbook.springawsbook.domain.posts.PostsRepository;
import springbootawsbook.springawsbook.web.dto.PostsSaveRequestDto;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class PostApiControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private PostsRepository postsRepository;
@AfterEach
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
를 사용하지 않은 이유 : @WebMvcTest
의 경우 JPA 기능이 작동하지 않는다.Controller
와 ControllerAdvice
등 외부 연동과 관련된 부분만 활성화된다.@SpringBootTest
와 TestRestTemplate
을 사용하면 된다.
실행 결과
WebEnvironment.RANDOM_PORT
로 인한 랜덤 포트 실행insert
쿼리가 실행
PostsApiController
package springbootawsbook.springawsbook.web;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import springbootawsbook.springawsbook.service.PostsService;
import springbootawsbook.springawsbook.web.dto.PostsResponseDto;
import springbootawsbook.springawsbook.web.dto.PostsSaveRequestDto;
import springbootawsbook.springawsbook.web.dto.PostsUpdateRequestDto;
@RequiredArgsConstructor
@RestController
public class PostApiController {
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);
}
}
PostsResponseDto
package springbootawsbook.springawsbook.web.dto;
import lombok.Getter;
import springbootawsbook.springawsbook.domain.posts.Posts;
@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의 필드 중 일부만 사용한다.
PostsUpdateRequestDto
package springbootawsbook.springawsbook.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
package springbootawsbook.springawsbook.domain.posts;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@Getter
@NoArgsConstructor
@Entity
public class Posts {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 500, nullable = false)
private String title;
@Column(columnDefinition = "TEXT", nullable = false)
private String content;
private String author;
@Builder
public Posts(String title, String content, String author) {
this.title = title;
this.content = content;
this.author = author;
}
// 추가
public void update(String title, String content) {
this.title = title;
this.content = content;
}
}
PostsService
package springbootawsbook.springawsbook.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import springbootawsbook.springawsbook.domain.posts.Posts;
import springbootawsbook.springawsbook.domain.posts.PostsRepository;
import springbootawsbook.springawsbook.web.dto.PostsResponseDto;
import springbootawsbook.springawsbook.web.dto.PostsSaveRequestDto;
import springbootawsbook.springawsbook.web.dto.PostsUpdateRequestDto;
@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);
}
}
📝 영속성 컨텍스트
- JPA의 엔티티 매니저가 활성화된 상태로 트랙잰셕 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태이다.
- 이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영한다. (더티 체킹 : Entity 객체의 값만 변경하면 별도로 Update 쿼리를 날릴 필요가 없다.)
PostsApiControllerTest - 테스트 코드
package springbootawsbook.springawsbook.web;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
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 springbootawsbook.springawsbook.domain.posts.Posts;
import springbootawsbook.springawsbook.domain.posts.PostsRepository;
import springbootawsbook.springawsbook.web.dto.PostsSaveRequestDto;
import springbootawsbook.springawsbook.web.dto.PostsUpdateRequestDto;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class PostApiControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private PostsRepository postsRepository;
@AfterEach
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);
}
}
실행 결과
insert into posts (author, content, title) values ('author', 'content', 'title');
최종 실행 결과
기본적인 등록/수정/조회 기능을 모두 만들고 테스트해보았다.
등록/수정은 테스트 코드로 보호해 주고 있으니 이후 변경 사항이 있어도 안전하게 변경할 수 있다.
보통 엔티티에는 해당 데이터의 생성시간과 수정시간을 포함한다.
언제 만들어졌는지, 언제 수정되었는지 등은 차후 유지보수에 있어 굉장히 중요한 정보이다.
그러다보니, 매번 DB에 삽입하기 전, 갱신하기 전에 날짜 데이터를 등록/수정하는 코드가 여기저기 들어가게 된다.
✔ LocalData 사용
Java8부터
LocalDate
와LocalDateTime
이 등장했다.
BaseTimeEntity
package springbootawsbook.springawsbook.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 class BaseTimeEntity {
@CreatedDate
private LocalDateTime createdDate;
@LastModifiedDate
private LocalDateTime modifiedDate;
}
BaseTimeEntity클래스
: 모든 Entity의 상위 클래스가 되어 Entity들의 createdDate
, modifiedDate
를 자동으로 관리하는 역할
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@CreatedDate
@LastModifiedDate
Posts
public class Posts extends BaseTimeEntity{
~
}
Application
package springbootawsbook.springawsbook;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@EnableJpaAuditing // JPA Auditing 활성화
@SpringBootApplication
public class SpringawsbookApplication {
public static void main(String[] args) {
SpringApplication.run(SpringawsbookApplication.class, args);
}
}
PostsRepositoryTest - JPA Auditing 테스트 코드
@Test
public void BaseTimeEntity_등록 () throws Exception{
// given
LocalDateTime now = LocalDateTime.of(2022,5,17,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
만 상속받으면 자동으로 해결된다.