스프링 부트와 AWS로 혼자 구현하는 웹 서비스
: 인텔리제이, JPA, JUnit 테스트, 그레이들, 소셜 로그인, AWS 인프라로 무중단 배포까지
이동욱 저
C,R,U API 만들기
Table of Content
API를 만들기 위한 클래스
•
Request 데이터를 받을 Dto 클래스
•
API 요청을 받을 Controller
•
트랜잭션, 도메인 기능간의 순서를 보장하는 Service
Service의 역할?
교재에서는 내가 평상시 알고있던 내용과 조금 다른 방식의 문구가 적혀있었다.
많은 분들이 오해하고 계신 것이, Service에서 비지니스 로직을 처리해야 한다는 것으로 알고있지만, Service는 트랜잭션, 도메인간 순서 보장의 역할만 한다.
그럼 비지니스 로직은 누가 처리하는가?에 대한 질문이 나타난다. 항상 Service계층이 비지니스 로직 계층이라고만 배워왔기 때문에 무슨 의미인지 알고싶어졌다.
계층에 대한 설명
•
Web Layer
◦
흔히 사용하는 컨트롤러(@Controller)와 JSP/Freemaker 등 뷰 템플릿의 영역
◦
이외에도 필터(@Filter), 인터셉터, 컨트롤러 어드바이스(@ControllerAdvice) 등 외부 요청과 응답에 대한 전반적인 영역
•
Service Layer
◦
@Service에 사용되는 서비스 영역
◦
일반적으로 Controller와 Dao의 중간 영역에서 사용
◦
@Transactional이 사용되어야 하는 영역(트랜잭션)
•
Repository Layer
◦
데이터베이스와 같이 데이터 저장소에 접근하는 영역
◦
기존(과거) Dao(Data Access Object) 영역으로 이해해도 됨
•
Dtos
◦
Data Transfer Object는 계층간에 데이터 교환을 위한 객체 계층으로 보면 됨
◦
ex) 뷰 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨줄 객체 등이 이들을 이야기함
•
Domain Model
◦
도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해 할 수 있고 공유할 수 있도록 단순화 시킨것을 도메인 모델이라고 한다.
◦
예로 택시앱이라 생각하면 배차, 탑승, 요금 등 모두 도메인이 된다.
◦
@Entity를 사용해본 분들은 @Entity가 사용된 영역 역시 도메인 모델이라고 이해하면 됨
◦
다만, 무조건 데이터베이스 테이블에 관계가 있어야 하는 것은 아니다.
▪
VO처럼 값 객체들도 이 영역에 해당하기 때문
위 계층에서 비지니스 처리를 담당해야할곳은 Service가 아니라 실제로는 Domain Model 계층이다. 코드로 예를 들면
@Transactional
public Order cancelOrder(int orderId){
Orders order = ordersRepository.findById(orderId);
Billing billing = billingRepository.findByOrderId(orderId);
Delivery delivery = deliveryRepository.findByOrderId(orderId);
delivery.cancel(); //1
order.cancel(); //2
billing.cancel(); //3
return order;
}
Java
복사
위 처럼 delivery, order, billing 각자 취소 이벤트 처리를 하며 서비스 메소드는 트랜잭션과 도메인간의 순서만 보장해주고 있다. 이처럼 이 교재의 코드는 도메인 모델을 다루고 코드를 작성하는 방법으로 진행된다.
게시글 등록 기능 API
PostsApiController.java
package com.citefred.ldwspring.web;
import com.citefred.ldwspring.service.PostsService;
import com.citefred.ldwspring.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);
}
}
Java
복사
•
스프링에서는 Bean을 주입 받는 방법이 @Autowired, setter, 생성자 3가지가 있는데 가장 권장하는 방식은 생성자로 주입받는 방식이다. (@Autowired는 권장하지 않음)
◦
그 이유는 다음과 같다.
생성자 주입을 권고하는 이유
1. 순환참조 방지
생성자 주입방식은 먼저 생성자의 인자에 사용되는 Bean을 찾거나 Bean Factory에서 만든다. 그 후에 찾은 인자 Bean으로 주입하려는 Bean의 생성자를 호출한다. ⇒ 먼저 Bean을 생성하지 않고 주입하려는 Bean을 먼저 찾는다. setter와 @Autowired 방식은 먼저 Bean을 생성한 후, 주입하려는 Bean을 찾아 주입
2. final 선언
생성자 주입의 경우 필드를 final로 선언할 수 있다. ⇒ 런타임에 객체 불변성을 보장받는다.
3. 테스트코드 작성의 용이
생성자 주입의 경우 단순히 원하는 객체를 생성한 후 생성자에 넣어줌 ⇒ Mocking없이 테스트코드를 작성
•
Lombok의 @RequiredArgsConstructor 은 그 번거로움을 해결해준다. final이 선언된 모든 필드를 인자값으로 하는 생성자를 롬복의 @RequiredArgsConstructor가 대신 생성해주기 때문에 위 와 같은 상황을 나도 모르게 방지 할 수 있다.
•
@AllArgsConstructor 는 클래스 내 모든 필드의 생성자를 생성해주는데 이 어노테이션을 사용하면 final이 없이도 생성자 주입이 가능하다. 하지만 위에서 언급한 내용과 같이 객체의 불변성을 보장받을수 없고 불필요한 의존성이 추가되어 코드의 복잡도가 높아질 수 있으므로 되도록이면 final을 곁들인 @RequiredArgsConstructor를 사용하는것이 좋아보인다.
PostsService.java
package com.citefred.ldwspring.service;
import com.citefred.ldwspring.domain.posts.PostsRepository;
import com.citefred.ldwspring.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();
}
}
Java
복사
PostsSaveRequestDto.java
컨트롤러와 서비스에서 사용할 Dto클래스 생성
package com.citefred.ldwspring.web.dto;
import com.citefred.ldwspring.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();
}
}
Java
복사
•
엔티티 클래스와 거의 유사한 형태임에도 Dto클래스를 추가로 생성하는 이유?
◦
Entity클래스는 절대로 Request/Response 클래스로 사용하면 안된다.
◦
엔티티 클래스는 데이터베이스와 맞닿은 핵심 클래스이다.
◦
이것을 기준으로 테이블이 생성되고 스키마(구조)가 변경됩니다.
◦
수많은 서비스 클래스나 비지니스 로직들이 엔티티 클래스를 기준으로 동작하는데 이것에 불변성이 유지되지 않으면 데이터베이스에 알수없는 값들, 오류가 발생 할 수 있는 원인이 될 수 있다.
◦
RequestDto, ResponseDto는 특정 View를 위한 클래스라 자주 변경되거나 목적에 맞게 변경된다. 따라서 View레이어와 DB 레이어의 역할 분리를 철저하게 하는 것이 좋다.
save 등록 API 가 작성되었으니 테스트 코드
해당 컨트롤러에서 Option+Enter를 통해서 테스트 코드 클래스를 생성 할 수도 있다.
PostsApiControllerTest.java
package com.citefred.ldwspring.web;
import com.citefred.ldwspring.domain.posts.Posts;
import com.citefred.ldwspring.domain.posts.PostsRepository;
import com.citefred.ldwspring.web.dto.PostsSaveRequestDto;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
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.test.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class PostsApiControllerTest {
@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> postsLists =postsRepository.findAll();
assertThat(postsLists.get(0).getTitle()).isEqualTo(title);
assertThat(postsLists.get(0).getContent()).isEqualTo(content);
}
}
Java
복사
API 컨트롤러 테스트
•
HelloController 테스트와 달리 @WebMvcTest를 사용하지 않았다.
•
그 이유는 @WebMvcTest는 JPA 기능이 작동하지 않고 Controller, ControllerAdvice등 외부 연동과 관련된 부분만 활성화되기 때문.
•
지금 같이 JPA 기능까지 한번에 테스트 할때는 @SpringBootTest, TestRestTemplate를 사용하면 된다.
RANDOM_PORT
•
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) 를 통해서 랜덤 포트 실행
게시글 수정 및 조회 기능 API
PostsApiController.java
package com.citefred.ldwspring.web;
import com.citefred.ldwspring.service.PostsService;
import com.citefred.ldwspring.web.dto.PostsResponseDto;
import com.citefred.ldwspring.web.dto.PostsSaveRequestDto;
import com.citefred.ldwspring.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
@RestController
public class PostsApiController {
private final PostsService postsService;
...
@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);
}
}
Java
복사
PostsResponseDto.java
package com.citefred.ldwspring.web.dto;
import com.citefred.ldwspring.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();
}
}
Java
복사
•
PostsResponseDto는 엔티티의 필드 중 일부만 사용하므로 생성자로 Entity를 받아 필드에 값을 넣는다.
•
필요한 필드(조회하고자 하는 필드)만 필요하므로 Entity를 받아 처리
PostsUpdateRequestDto.java
package com.citefred.ldwspring.web.dto;
import lombok.Builder;
import lombok.Getter;
@Getter
public class PostsUpdateRequestDto {
private String title;
private String content;
@Builder
public PostsUpdateRequestDto(String title, String content){
this.title = title;
this.content = content;
}
}
Java
복사
Posts.java
package com.citefred.ldwspring.domain.posts;
import jakarta.persistence.*;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
@Entity
public class Posts {
...
public void update(String title, String content){
this.title = title;
this.content = content;
}
}
Java
복사
PostsService.java
package com.citefred.ldwspring.service;
import com.citefred.ldwspring.domain.posts.Posts;
import com.citefred.ldwspring.domain.posts.PostsRepository;
import com.citefred.ldwspring.web.dto.PostsResponseDto;
import com.citefred.ldwspring.web.dto.PostsSaveRequestDto;
import com.citefred.ldwspring.web.dto.PostsUpdateRequestDto;
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 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);
}
}
Java
복사
•
여기서 신기한 부분은 update 기능에서 데이터베이스에 쿼리를 보내는 부분이 없다.(save는 save를 했는데?)
•
이게 가능한 이유는 JPA의 영속성 컨텍스트 때문
◦
영속성 컨텍스트(Persistence Context)는 엔티티를 영구 저장하는 환경 일종의 논리적 개념
◦
JPA의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함되어있냐(영속성 컨텍스트에 MANAGED) 아니냐로 구분됩니다.
◦
JPA가 엔티티 매니저(EM)이 활성화된 상태(SpringDataJPA를 쓴다면 기본 옵션)에서 트랜잭션(Transactional)안에서 DB로부터 데이터를 가져오면 이 데이터는 영속성 컨텍스트 환경 위(Managed) 상태가 됩니다.
◦
이 상태에서 값을 변경하면 해당 데이터를 테이블에 반영하는데 이것을 더티 체킹이라고 합니다.
update API 가 작성되었으니 테스트 코드
실제로 update관련 쿼리 실행 메소드가 없이도 더티 체킹으로도 update 쿼리가 나타나는지와 API가 작동하는지 테스트하기 위해 테스트 코드를 작성합니다.
package com.citefred.ldwspring.web;
import com.citefred.ldwspring.domain.posts.Posts;
import com.citefred.ldwspring.domain.posts.PostsRepository;
import com.citefred.ldwspring.web.dto.PostsSaveRequestDto;
import com.citefred.ldwspring.web.dto.PostsUpdateRequestDto;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
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.test.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class PostsApiControllerTest {
@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
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> postsLists =postsRepository.findAll();
assertThat(postsLists.get(0).getTitle()).isEqualTo(expectedTitle);
assertThat(postsLists.get(0).getContent()).isEqualTo(expectedContent);
}
}
Java
복사
•
테스트 결과를 보면 update ~~ set 으로 업데이트 쿼리가 날라가는 것을 확인 할 수 있다.
read API는 톰캣으로 URL 요청으로 직접 확인 테스트 해보기
•
application.properties에 다음과 같은 코드를 작성
•
spring.h2.console.enabled=true
•
이후 Application.java(메인) 클래스의 메인 메소드를 실행하여 톰캣 실행
•
•
jdbc URL 부분을 다음처럼 수정 jdbc:h2:mem:testdb
•
이후 Connect를 누르면 데이터베이스 대쉬보드로 들어 올 수 있음
•
Posts라는 테이블의 스키마가 다음처럼 설계 된 것을 볼 수 있으며, 아직 삽입된 데이터는 없는 것을 볼 수 있다.
•
간단한 insert 쿼리를 통해 이를 API로 조회해본다.
◦
insert into posts (author, content, title) values(’author’, ‘content’, ‘title’);
•
브라우저를 통해 작성했던 조회 api(@GetMapping) 부분을 통해 posts Id를 통해 조회해보면 다음처럼 정상적으로 데이터가 나타나는것을 확인 할 수 있다.
•
이는 ResponseDto를 통해 반환된 결과이며 해당 API 요청(브라우저에서 URL에서 엔터를 누르는 순간)하면 실행중인 톰캣 서버에서도 조회에 대한 쿼리가 날라가는 것을 확인 할 수 있다.
Related Posts
Search