Back-End/스프링부트와 AWS로 구현하는 웹서비스

[스프링][Spring] - 5. JPA와 등록, 수정, 조회 API 만들기

얄루몬 2022. 2. 2. 23:16

📖본 포스팅은 '스프링부트와 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의 연결을 담당합니다.
MVC 패턴
📌출처: 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 쿼리 실행

  • insert into posts(author,content,title) values ('author','content','title');

브라우저로 API 조회

  • 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);

    }
}

 

  • 위의 코드들로 기본적인 등록/수정/조회 기능을 모두 만들고 테스트를 진행해보았다. 
  • 특히 등록과 수정은 테스트 코드로 보호해 주고 있으니 이후 변경 사항이 있어도 안전하게 변경이 가능하다.