Spring/Spring Boot

[Spring Boot] 간단한 API 제작

ozofweird 2020. 9. 16. 01:50

1. 기본 개념

1) API에 필요한 클래스

Request 데이터를 받을 Dto, API 요청 받을 Controller, 트랜잭션 및 도메인 기능 간의 순서를 보장하는 Service가 필요하다.

2) Spring 웹 계층

모든 계층 중에서 비즈니스 처리를 담당하는 장소는 Domain이며 기존에 서비스로 처리하는 방식을 트랜잭션 스크립트라고 한다. 

웹 계층 설명
Web Layer Controller와 JSP/Freemarker 등의 뷰 템플릿 영역이다. 이외에도 필터, 인터셉터, Controller 어드바이스 등 외부 요청과 응답에 대한 전반적인 영역이다.
Service Layer @Service 어노테이션이 사용되는 계층으로, Controller와 Dao 중간 영역에 해당한다. 또한 @Transactional이 사용되어야 하는 영역이다.
Repository Layer 데이터베이스에 접근하는 영역으로 Dao 영역이다.
Dtos 계층 간의 데이터 교환을 위한 객체 영역이다.
Domain Model Domain Model은 개발 대상을 모든 사람이 동일한 관점으로 이해할 수 있고 공유할 수 있도록 단순화한 모델이다. Entity 영역이며, 데이터베이스의 테이블과 관계가 반드시 있을 필요는 없다.

 

 

 

2. 등록 API 제작

1) Controller

PostsApiController 클래스를 web 패키지에 생성한다.

package com.springbook.biz.web;

import com.springbook.biz.service.posts.PostsService;
import com.springbook.biz.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);
    }
}

2) Service

PostsService 클래스를 service.posts 패키지에 생성한다. 많은 스프링 유저는 @Autowired 어노테이션을 이용한 Bean 주입 방식에 익숙하지만, 실제로는 생성자로 주입받는 방식을 더 권장한다.

package com.springbook.biz.service.posts;

import com.springbook.biz.domain.posts.PostsRepository;
import com.springbook.biz.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();
    }
}

3) PostSaveRequestDto

저장할 때의 데이터 전달 객체인 PostsSaveRequestDto 클래스를 web.dto 패키지에 생성한다. Entity 클래스와 유사한 형태이지만 Dto 클래스를 추가로 생성했다. Entity 클래스는 데이터베이스와 관계되는 핵심 클래스이기에 Entity 클래스를 반드시 Request/Response 클래스로 사용해서는 안된다. 사소한 화면 변경으로 인한 Entity 클래스 변경은 큰 부담이 되기 때문이다.

 

대부분의 서비스 및 클래스는 Entity 클래스 기준으로 동작하기에 Entity 클래스가 변경되면 다른 클래스에도 영향을 주지만, Request/Response용 Dto는 View를 위한 클래스이기에 자주 변경이 된다. 결국, View와 데이터베이스의 역할 분리가 중요하며, Controller에서 결과값으로 여러 테이블을 조인해서 줘야할 경우가 빈번하기에 Entity 클래스만으로 표현하기 어렵다.

package com.springbook.biz.web.dto;

import com.springbook.biz.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();
    }
}

4) 테스트 코드

어노테이션 및 코드 설명
@SpringBootTest @WebMvcTest의 경우 JPA 기능이 동작하지 않으며 Controller, ControllerAdvice 등 외부 연동과 관련된 부분만 활성화되니 JPA 기능까지 한번에 테스트할 경우에 사용한다.
TestRestTemplate
package com.springbook.biz.web;

import com.springbook.biz.domain.posts.Posts;
import com.springbook.biz.domain.posts.PostsRepository;
import com.springbook.biz.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 postSave() throws Exception {
        // given
        String title = "제목1";
        String content = "내용1";

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

 

 

 

3. 수정/조회 API 제작

1) Controller

기존 PostsApiController 클래스 코드에 수정과 조회 기능을 추가한다.

package com.springbook.biz.web;

import com.springbook.biz.service.posts.PostsService;
import com.springbook.biz.web.dto.PostsResponseDto;
import com.springbook.biz.web.dto.PostsSaveRequestDto;
import com.springbook.biz.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);
    }
}

2) PostsResponseDto

게시글 데이터 전달 객체인 PostsResponseDto는 Entity의 필드 중 일부만 사용하기에 생성자로 Entity를 받아 필드에 값을 넣는다. 모든 필드를 가진 생성자가 필요하지 않기에 Dto는 Entity를 받아 처리한다.

package com.springbook.biz.web.dto;

import com.springbook.biz.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();
    }
}

3) PostsUpdateRequestDto

수정할 데이터를 전달하는 객체인 PostsUpdateRequestDto를 생성한다.

package com.springbook.biz.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;
    }
}

4) Entity

수정할 때 사용할 메서드를 Entity 클래스내에 생성해준다.

package com.springbook.biz.domain.posts;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Getter
@NoArgsConstructor
@Entity
public class Posts {

    ... // 추가될 내용
    
    public void update(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

5) Service

수정 기능의 경우 데이터베이스에 쿼리는 전달하는 부분이(postsRepository.update()) 없다. 이것이 가능한 이유는 Entity를 영구 저장하는 환경인 JPA의 영속성 컨텍스트의 덕분이다. 트랜잭션이 끝나는 시점에 변경이 되며, update 쿼리문을 전달할 필요가 없어진다. (더티 체킹)

package com.springbook.biz.service.posts;

import com.springbook.biz.domain.posts.Posts;
import com.springbook.biz.domain.posts.PostsRepository;
import com.springbook.biz.web.dto.PostsResponseDto;
import com.springbook.biz.web.dto.PostsSaveRequestDto;
import com.springbook.biz.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@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);
    }
}

6) 테스트 코드

package com.springbook.biz.web;

import com.springbook.biz.domain.posts.Posts;
import com.springbook.biz.domain.posts.PostsRepository;
import com.springbook.biz.web.dto.PostsSaveRequestDto;
import com.springbook.biz.web.dto.PostsUpdateRequestDto;
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.HttpEntity;
import org.springframework.http.HttpMethod;
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 {

    ... // 추가될 내용
    
    @Test
    public void postsUpdate() 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);
    }
}

7) H2 데이터베이스

메모리에서 실행하는 만큼 직접 접근하기 위해서 웹 콘솔을 사용해야하기에 application.properties에 옵션을 추가해준다. 추가가 완료되면 Application 메인 메서드를 실행하고 'localhost:8080/h2-console 로 접속하여 JDBC URL이 'jdbc:h2:mem:testdb'인 상태에서 접근한다. 이 후, 콘솔에서 직접 데이터를 넣고 확인해본다.

spring.h2.console.enabled=true

H2 데이터베이스

 

 

 

3. JPA 영속성 컨텍스트

1) JPA 영속성 컨텍스트

JPA의 영속성 컨텍스트란 사전적인 의미로는 오래도록 계속 유지되는 성질들을 저장하고 관리라고 정의된다. 즉, Entity를 저장하고 관리한다는 의미다. 영속성 컨텍스트는 내부에 캐시를 지니고 있는데, 이를 1차 캐시라고 지칭하고, 영속 상태의 Entity는 모두 이곳에 저장이 된다.

JPA 영속성 컨텍스트 도식화

※ 1차 캐시 영속성 컨텍스트 내부에 Map이 존재하며, 키는 @Id, 값은 Entity 인스턴스이다.

2) Entity 조회

1. 1차 캐시

// Entity 생성 (비영속)
Member member = new Member();
member.setId("member1");
member.setUsername("멤버");

// Entity 영속
em.persist(member);

Entity 영속 시의 1차 캐시

2. 1차 캐시에서 조회

Entity가 1차 캐시에 존재할 경우, 영속 상태의 Entity를 반환한다.

Member member = em.find(Member.class, "member1");

Entity가 1차 캐시에 존재하지 않을 경우, EntityManager는 데이터베이스를 조회하여 Entity를 생성하고, 1차 캐시에 저장하여 영속 상태의 Entity를 반환한다.

 

Member member = em.find(Member.class, "member2");

1차 캐시에 Entity가 존재할 경우와 존재하지 않을 경우

3) Entity 등록

EntityManager는 트랜잭션을 커밋하기 전가지 내부 쿼리 저장소에 INSERT 쿼리문을 모아두고, 커밋 시점에 데이터베이스에 전달(FLUSH)한다. 이 과정을 '쓰기 지연'이라고 지칭한다.

EntityManager em = emf.createEntityManager();

EntityTransaction transaction = em.getTransaction();
transaction.begin();

// 영속 상태
em.persist(memberA);
em.persist(memberB); 

// 쓰기 지연 저장소에서 데이터베이스에 FLUSH
transaction.commit();

FLUSH 하기 전 / 후

4) Entity 수정

JPA로 Entity를 수정할 때에는 Entity를 조회한 후 데이터만 변경해주면 된다. JPA에는 update 메서드가 존재하지 않은 대신, 자동 변경 감지 기능이 있다. 단, 영속 상태의 Entity에서만 적용이 된다.

 

JPA는 Entity를 영속성 컨텍스트에 보관할 때, 최초 상태를 복사하여 저장하는 스냅샷 기능이 있다. 트랜잭션 커밋(FLUSH)이 동작할 때, Entity와 스냅샷을 비교하여 변경된 Entity를 찾는다. 만약 변경된 Entity가 있을 경우 수정 쿼리를 생성하여 쓰기 지연 SQL 저장소에 전달하고, 쓰기 지연 저장소에서 데이터베이스에 쿼리를 전달하여 수정하는 과정을 거친다.

EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();
transaction.begin();

// 영속 엔티티 조회
Member memberA = em.find(Member.class, "memberA");

// 영속 엔티티 데이터 수정
memberA.setUsername("hi");
memberA.setAge(10);

transaction.commit();

Entity 수정 과정

5) Entity 삭제

Entity 등록 과정과 유사하게 쓰기 지연 SQL 저장소에 등록하고 트랜잭션 커밋 과정을 거쳐 데이터베이스에 삭제 쿼리가 전달된다.

Member memberA = em.find(Member.class, "memberA");
em.remove(memberA);

[참고] 스프링 부트와 AWS로 혼자 구현하는 웹 서비스

[참고] cornswrold.tistory.com/339

728x90