📖본 포스팅은 '스프링부트와 aws로 혼자 구현하는 웹서비스 - 이동욱 저자'를 보고 포스팅 되었습니다.
1. 등록, 수정, 조회 API 만들기
API를 만들기 위해서 총 3개의 클래스가 필요합니다.
- Request 데이터를 받은 Dto
- API 요청을 받을 Controller
- 트랜잭션, 도메인 기능 간의 순서를 보장하는 Service
[Service에서는 비지니스 로직을 처리하지 않는다.]
이때 서비스에서 비지니스 로직을 처리해야 한다고 생각하지만, Service는 트랜잭션, 도메인 간 순서 보장의 역할만 한다. 그렇다면 비지니스 로직은 누가 처리할까??? 아래를 통해서 이를 알아보도록 하자
2. 스프링 웹 계층에 대해서
[비지니스 로직은 누가 처리할까?]
- web Layer
- 흔히 사용하는 컨트롤러 등의 뷰 템플릿 영역
- 이외에도 필터, 인터셉터, 컨트롤러 등 외부 요청과 응답에 대한 전반적인 영역을 이야기 함.
- Service Layer
- @Service에 사용되는 서비스 영역
- 일반적으로 Controller와 Dao의 중간 영역에서 사용
- @Transactional이 사용되어야 하는 영역
- Repository Layer
- Database와 같이 데이터 저장소에 접근하는 영역
- DTOs
- 계층 간에 데이터 교환을 위한 객체를 이야기하며 DTOs는 이들의 영역을 이야기 한다.
- Domain Model
- 도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화시킨 것을 도메인 모델이라고 한다.
- @Entity가 사용된 영역 역시 도메인 모델이라고 생각하면된다.
- 그러나 데이터베이스의 테이블과 무조건 연관이 있어야 하는 것은 아니다.
[보충 정보]
1. DTOs란?
DTO(Data Transfer Object)란 계층간 데이터 교환을 위해 사용하는 객체(Java Beans)입니다.
2. MVC란?
MVC 패턴은 어플리케이션을 개발할 때 그 구성 요소를 Model과 View 및 Controller 등 세 가지 역할로 구분하는 디자인 패턴입니다. 비즈니스 처리 로직(Model)과 UI 영역(View)은 서로의 존재를 인지하지 못하고, Controller가 중간에서 Model과 View의 연결을 담당합니다.
📌출처: https://tecoble.techcourse.co.kr/post/2021-04-25-dto-layer-scope/
[web, service, repository, Dto, Domain 5가지 레이아웃 비지니스 처리를 담당하는 곳은?]
Domain으로 기존에 서비스를 처리하던 방식을 트랜잭션 스크립트라고 한다.
3. 도메인 모델
서비스 메소드는 트랜잭션과 도메인 간의 순서만 보장해주어야 한다. 이를 유의해서 우리는 도메인 모델을 다루고 코드를 작성할 것이다.
[디렉토리 현황]
[등록]
[Controller class]
package com.yeomyaloo.book.springboot.web;
import com.yeomyaloo.book.springboot.service.posts.PostsService;
import com.yeomyaloo.book.springboot.web.dto.PostsUpdateRequestDto;
import com.yeomyaloo.book.springboot.web.dto.PostsResponseDto;
import com.yeomyaloo.book.springboot.web.dto.PostsSaveRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
@RestController
public class PostsApiController {
//final 키워드가 붙어서 꼭 값을 돌려주어야 한다!
private final PostsService postsService;
@PostMapping("/api/v1/posts")
public Long save(@RequestBody PostsSaveRequestDto requestDto){
return postsService.save(requestDto);
}
[Service class]
package com.yeomyaloo.book.springboot.service.posts;
import com.yeomyaloo.book.springboot.web.dto.PostsUpdateRequestDto;
import com.yeomyaloo.book.springboot.web.dto.PostsResponseDto;
import com.yeomyaloo.book.springboot.web.dto.PostsSaveRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import com.yeomyaloo.book.springboot.domain.posts.Posts;
import com.yeomyaloo.book.springboot.domain.posts.PostsRepository;
import javax.transaction.Transactional;
// @Autowired 없이 생성자 주입이 되는 이유는??
// final이 선언된 모든 필드를 인자값으로 하는 생성자를 롬복의 @RequiredArgsConstructor가 대신 생성해준 것.
//그렇다면 왜 이렇게 생성자 주입을 직접 쓰지 않고 어노테이션을 사용해서 진행하는가?? -> 의존성 관계 변경때마다 생성자 코드를 계속해서 수정하는 번거로움을 피하기 위해서다.
@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);
}
}
[서비스 클래스와 컨트롤러 클래스에서 사용할 Dto class]
package com.yeomyaloo.book.springboot.web.dto;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import com.yeomyaloo.book.springboot.domain.posts.Posts;
// Controller와 Service의 중간에서 사용할 Dto 클래스
@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)
.author(author)
.content(content)
.build();
}
}
[Entity 클래스를 Response / Request 클래스로 만들면 안 되는 이유]
- Entity 클래스는 데이터베이스와 맞닿은 핵심 클래스로 이를 기준으로 테이블 생성, 스키마의 변경이 일어난다. 이때 화면 변경은 아주 사소한 일인데 이를 위해 테이블과 연결된 Entity 클래스를 변경하는 것은 매우 큰 변경으로 효율적이지 않기 때문입니다.
- 수많은 비지니스 로직은 Entity 클래스를 기준으로 동작한다. 그렇기에 이 클래스가 변경되면 여러 클래스의 영향을 끼치게 되기 때문에 자주 변경되는 Response/Request 클래스로 Entity 클래스를 구성하게 되면 매우 위험해질 수 있습니다.
- Veiw Layer(컨트롤러 뷰등..)와 DB Layer(데이터베이스를 다루는 ..)의 역할 분리는 철저하게 해주어야 합니다.
- 이를 위해서 Entity 클래스와 Controller에서 쓸 Dto는 분리해서 사용해야 한다.
[수정]
[Controller class]
package com.yeomyaloo.book.springboot.web;
import com.yeomyaloo.book.springboot.service.posts.PostsService;
import com.yeomyaloo.book.springboot.web.dto.PostsUpdateRequestDto;
import com.yeomyaloo.book.springboot.web.dto.PostsResponseDto;
import com.yeomyaloo.book.springboot.web.dto.PostsSaveRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
@RestController
public class PostsApiController {
//final 키워드가 붙어서 꼭 값을 돌려주어야 한다!
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);
}
}
[Response Dto class]
package com.yeomyaloo.book.springboot.web.dto;
import lombok.Getter;
import com.yeomyaloo.book.springboot.domain.posts.Posts;
@Getter
public class PostsResponseDto {
private Long id;
private String content;
private String title;
private String author;
public PostsResponseDto(Posts entity){
this.id = entity.getId();
this.title= entity.getTitle();
this.content = entity.getContent();
this.author = entity.getAuthor();
}
}
[UpdateRequest Dto class]
package com.yeomyaloo.book.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;
}
}
- update 기능에서 데이터베이스에 쿼리를 날리는 부분이 없다. 이게 가능한 이유는 JPA의 영속성 컨텍스트 때문이다.
- 영속성 컨텍스트란?
- 엔티티를 영구 저장하는 환경으로 일종의 논리적인 개념으로 JPA의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함되어 있냐 아니냐로 갈리게 된다.
- JPA 엔티티 매니저가 활성화된 상태로 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태이다.
- 이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영한다. 즉, Entity 객체의 값만 변경하면 별도로 update 쿼리를 날릴 필요가 없다는 것이다. 이를 더티 체킹이라고 한다.
[Service class(추가)]
package com.yeomyaloo.book.springboot.service.posts;
import com.yeomyaloo.book.springboot.web.dto.PostsUpdateRequestDto;
import com.yeomyaloo.book.springboot.web.dto.PostsResponseDto;
import com.yeomyaloo.book.springboot.web.dto.PostsSaveRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import com.yeomyaloo.book.springboot.domain.posts.Posts;
import com.yeomyaloo.book.springboot.domain.posts.PostsRepository;
import javax.transaction.Transactional;
// @Autowired 없이 생성자 주입이 되는 이유는??
// final이 선언된 모든 필드를 인자값으로 하는 생성자를 롬복의 @RequiredArgsConstructor가 대신 생성해준 것.
//그렇다면 왜 이렇게 생성자 주입을 직접 쓰지 않고 어노테이션을 사용해서 진행하는가?? -> 의존성 관계 변경때마다 생성자 코드를 계속해서 수정하는 번거로움을 피하기 위해서다.
@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);
}
}
[로컬환경에서 데이터베이스를 직접 접근하는 방법(H2 콘솔로 들어가자)]
1. 웹 콘솔
- http://localhost:8080/h2-console로 들어가면 위의 이미지와 같이 웹 콘솔 화면이 등장합니다.
- 이때 JDBC URL이 앞 화면과 같이 설정되었나를 확인하고 저렇게 치고 들어가주세요 .
2. 관리 화면
- POSTS 테이블이 정상적으로 노출되어야 합니다.
- 간단한 쿼리를 실행합니다 (SELECT * FROM POSTS )
- 등록된 데이터가 현재까지는 없기 때문에 간단한게 insert 쿼리를 실행해보고 이를 API로 조회해 보도록 합시다.
- insert into posts(author,content,title) values ('author','content','title');
- API를 조회 기능을 테스트 했습니다.
- 이때 정렬된 JSON 형태가 보고 싶다면 크롬에서 JSON Viewer라는 플러그인을 설치해주시면 됩니다. (본인은 설치하지 않음)
[삭제]
뒤의 포스팅에서 나올 예정
4. 테스트 코드
package com.yeomyaloo.book.springboot.web;
import com.yeomyaloo.book.springboot.web.dto.PostsSaveRequestDto;
import com.yeomyaloo.book.springboot.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.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 com.yeomyaloo.book.springboot.domain.posts.Posts;
import com.yeomyaloo.book.springboot.domain.posts.PostsRepository;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public 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> 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 savePosts = postsRepository.save(Posts.builder()
.title("title")
.content("content")
.author("author")
.build());
Long updateId = savePosts.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);
}
}
- 위의 코드들로 기본적인 등록/수정/조회 기능을 모두 만들고 테스트를 진행해보았다.
- 특히 등록과 수정은 테스트 코드로 보호해 주고 있으니 이후 변경 사항이 있어도 안전하게 변경이 가능하다.
'Back-End > 스프링부트와 AWS로 구현하는 웹서비스' 카테고리의 다른 글
[스프링][Spring] - 7. 서버 템플릿 엔진과 기본 화면 구현 (0) | 2022.02.03 |
---|---|
[스프링][Spring] - 6. JPA Auditing으로 생성시간/수정시간 자동화하기 (0) | 2022.02.02 |
[스프링][Spring] - 4. 스프링부트와 JPA (0) | 2022.01.30 |
[스프링][Spring] 3. 스프링 부트에서 JPA로 데이터베이스 다루기 (0) | 2022.01.10 |
[스프링][Spring] 2. 테스트 코드 작성하기 (0) | 2022.01.10 |