Boot TIL - 5. API 만들기

Create, Read, Update API 만들기

API를 만들기 위해 필요한 3가지가 있다.

  1. Request 데이터를 받을 Dto
  2. API 요청을 받을 Controller
  3. 트랜젝션, 도메인 기능 간의 순서를 보장하는 Service

Dto와 Controller는 이전 포스트에서 Hello 예제로 다뤄본 적이 있었다. Service는 그럼 무엇일까?

Service의 역할은 DB접근자가 Database에서 받아온 데이터를 받아 가공하는 역할을 한다. 많은 사람이 Service에서 비즈니스 로직을 처리해야 한다고 하지만, 그렇지 않고 트랜잭션, 도메인 간의 순서 보장의 역할만 한다고 한다.

Spring의 웹 계층은 다음과 같이 구성되어 있다.

  1. Web layer, 컨트롤러와 jsp 등 뷰 템플릿 영역으로 외부 요청과 응답에 대한 전반적인 영역
  2. Service Layer, @Service, @Transaction에 사용되는 영역으로 Controller와 Dao(DB접근자)의 중간에서 사용된다.
  3. Repository Layer, Database와 같이 데이터 저장소에 접근하는 영역으로, DB 접근자 영역으로 생각하면 될 것이다.

이 웹 계층은 또 두가지로 나뉠 수도 있는데,

  1. Dtos, 계층 간의 데이터 교환을 위한 객체를 이야기한다. 뷰 템플릿 엔진에 사용될 객체나 Reposiroty layer에서 결과로 넘겨준 객체 등을 이야기한다.
  2. Domatin Model, 도메인이라고 불리는 개발 대상을 동일한 관점에서 이해할 수 있게 단순화 시킨 것. @Entity가 사용된 영역이 대표적인 도메인이지만 무조건 테이블과 관계가 있어야만 하는 것은 아니다.

위의 두 계층은 각각 Web layer ~ Service Layer의 절반, Service Layer의 절반 ~ Repository Layer로 표현된다. 즉 비즈니스 로직을 위한 처리는 무조건 Service Layer가 담당해야 하는 것이 아니라, Domain 레이어에서 담당해야 한다는 것이다.

결론적으로 서비스에서 담당하던 트랜잭션 과정 중 도메인 객체 자체가 method를 가지고 작업을 수행할 수 있게 구현한다면 Service 트랜잭션 스크립트 자체가 굉장히 간결해지고 직관성이 높아지는 이점을 받을 수 있다.

PostsApiControllerweb 패키지에, PostsSaveRequestDtoweb.dto에, 마지막으로 PostsService를 service.posts에 생성했다.

package com.mySpringProject.main.web;

import com.mySpringProject.main.service.posts.PostsService;
import com.mySpringProject.main.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);
    }
}
package com.mySpringProject.main.service.posts;

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

보통 스프링에서는 Controller와 Service에서 @Autowired를 사용해 Bean을 주입받는데, 위에서는 lombok을 사용해 생성자를 통해 bean을 주입받는다. 위의 PostsServicePostsRepository는 다른 객체에 의존성이 존재하게 되는 부분인데, 만약 @Autowired를 사용하여 bean을 주입받게 되면 너무 쉽게 bean을 주입받을 수 있어 의존성이 늘어날 여지가 있고, final을 사용할 수 없기 때문에 객체의 일관성이 깨질 수 있다. 따라서 Spring에서는 이런 의존 관계를 필드에 직접@Autowired로 사용하기 보다는 생성자를 통해 사용하는 것을 권장한다.


private final BookService bookService

@Autowired
public PostsService(BookService bookService) {
	this.bookService = bookService;
}

위와 같은 방식으로 field에 직접 bean을 주입하지 않고 @Autowired를 생성자에 붙여 사용하는 것인데, 위 코드에서는 Lombok을 사용했기 때문에 @RequiredArgsConstructor로 한번에 해결한 예시이다.

package com.mySpringProject.main.web.dto;

import com.mySpringProject.main.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();
    }
}

위의 Dto는 이전에 만들었던 Domain 클래스와 거의 같은 모습이지만, Entity를 직접 Request, Response로 사용하면 안된다. Entity는 데이터베이스와 직접 소통하는 클래스이기 때문에 Entity를 중심으로 데이터베이스가 조작된다. 따라서 수많은 서비스 클래스와 로직들이 Entity를 기준으로 동작하기 때문에 Entity를 직접 사용하며 조작할 경우 프로젝트에 큰 영향을 주게 된다. 대신 Dto를 만들어 Request, Response를 위해 사용하는 것이 더 적절하다.

이후 PostsApiController에 적절한 테스트인 PostsApiControllerTest를 만들어 주었다.

package com.mySpringProject.main.web;

import com.mySpringProject.main.domain.posts.Posts;
import com.mySpringProject.main.domain.posts.PostsRepository;
import com.mySpringProject.main.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.AssertionsForClassTypes.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 Posts_is_created() throws Exception {
        String title = "title";
        String content = "content";

        PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
                .title(title)
                .content(content)
                .author("kjh")
                .build();

        String url = "http://localhost:" + port + "/api/v1/posts";

        ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, Long.class);

        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);
    }
}
  • @SpringBootTest를 사용하여 테스트를 진행했다. HelloControllerTest에서 @WebMvcTest를 사용한 것과 다른데, @WebMvcTest를 사용하여 테스트하면 JPA 기능이 작동하지 않기 때문이다.

위와 마찬가지로 Put과 Get API를 추가하고 테스트 코드를 작성했다. 책에 있는 코드를 너무 많이 포스팅하는 것 같아서 중복된 내용은 혼자 공부하고 최대한 빼는 것이 좋을 것 같다.

Django나 express와 다른점은 Dto의 존재이다. 원래같으면 Serializer를 통해 검증한 값을 통해 값을 다뤘다면, update, save 등 api마다 적절한 Dto를 만들고 간접적으로 Entity를 다루는 것이 꽤나 생소한데, 객체 지향 특성상 이렇게 하는 것이 프로젝트의 결합도를 낮추는 좋은 방법이라고 생각한다. 다만, API가 커질수록 Dto 관리에 신경을 쓰는 것이 좋을 것 같다. 간단히 찾아보니 Dto의 수가 많아지면, inner class의 형태로 다루는 방법도 있었다 참고

Update의 Test 코드는 다음과 같은 순서로 동작한다.

  1. Post를 만든다.
  2. 만들어진 Posts의 id를 가지고 update 요청을 보낸다.
  3. update된 post가 의도대로 업데이트 되었는지 확인한다.

2번 과정은 다음과 같이 나눠진다.

  1. PostsUpdateRequestDto 작성
  2. HttpEntity 작성
  3. restTemplate로 exchange 메서드 사용, url에 put 요청과 HttpEntity 전송

RestTemplate에서 exchange를 사용하려면 parameter로 HttpEntity를 넘겨주어야 하기 때문에 Dto를 사용해 HttpEntity를 작성해야 한다.

테스트 코드 작성 이후 h2 database에 연결하여 쿼리를 날려보는 항목이 있는데,

// application.properties

spring.h2.console.enabled = true

application.properties에 위 라인을 추가해준 뒤 http://localhost:8080/h2-console 에 접속하고, jdbc url를 jdbc:h2:mem:testdb로 변경한 뒤 연결하면 된다.

쿼리를 날려보던 중 이상한 점을 발견했는데, id 필드가 자동으로 생성되지 않고 있었다. @GeneratedValue가 hibernate 버전이 올라가며 책과 다르게 작동하던 것이다.

따라서 만들어 주었던 Posts의 Entity class를 다음과 같이 수정하면 된다.

...

@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;

...

좋은 웹페이지 즐겨찾기