[Spring Boot] API를 만들어보자!

윤동환·2023년 2월 19일
0
post-thumbnail

API 만들기!

API를 만들기 위한 3가지의 Class

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

Service의 역할
Service로 비지니스 로직을 처리하지 않고 트랜젝션, 도메인간 순서 보장의 역할을 해야한다!!

계층형 아키텍쳐

  • Web Layer
    • 흔히 사용하는 컨트롤러(@Controller)와 JSP/Freemarker 등의 뷰 템플릿 영역입니다.
    • 이외에도 필터(@Filter), 인터셉터, 컨트롤러 어드바이스(@ControllerAdvice) 등 외부 요 청과 응답에 대한 전반적인 영역을 이야기합니다.

  • Service Layer
    • @Service에 사용되는 서비스 영역입니다.
    • 일반적으로 Controller와 Dao의 중간 영역에서 사용됩니다.
    • @Transactional이 사용되어야 하는 영역이기도 합니다.

  • Repository Layer
    • Database와 같이 데이터 저장소에 접근하는 영역입니다.
    -> Dao(Data Access Object) 영역

  • Dtos
    • Dto(Data Transfer Object)는 계층 간에 데이터 교환을 위한 객체를 이야기하며 Dtos는
    이들의 영역을 얘기합니다.
    • 예를 들어 뷰 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체 등이 이들을 이야기합니다.

  • Domain Model
    •도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할
    수 있도록 단순화시킨 것을 도메인 모델이라고 합니다.
    •이를테면택시앱이라고하면배차,탑승,요금등이모두도메인이될수있습니다.
    • @Entity를 사용해보신 분들은 @Entity가 사용된 영역 역시 도메인 모델이라고 이해 해주시면 됩니다.
    • 다만, 무조건 데이터베이스의 테이블과 관계가 있어야만 하는 것은 아닙니다 • VO처럼 값 객체들도 이 영역에 해당하기 때문입니다.

    VO(Value Object) 값 오브젝트로써 값을 위해 쓰입니다. read-Only 특징(사용하는 도중에 변경 불가능하며 오직 읽기만 가능)을 가집니다.

위의 계층 구조에서 비지니스 로직을 처리해야하는 부분은 Domain입니다.

Dto, Controller, Service Class 생성

이제 위에서 언급한 것처럼 도메인 모델아래 스크린샷에 보이는 파일 구조로 class를 생성합니다.

PostsApiController

package orm.example.springboot.web;

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

PostsService

package orm.example.springboot.service.posts;

////선언된 모든 final 필드가 포함된 생성자를 생성해 줍니다
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에 @Autowire가 없는 이유
스프링에서 Bean을 주입받는 방식

  • @Autowire
  • setter
  • 생성자

이중 생성자로 주입받는 것을 가장 권장합니다. 즉 생성자로 @Autowired를 대신 할 수 있습니다.
여기에선 @RequredArgsConstructor에서 생성자를 생성해주므로 Bean을 주입받을 수 있습니다.
이 어노테이션을 사용하는 이유는 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속 수정하는 수고를 해결하기 위함입니다.

PostsSaveRequestDto

package orm.example.springboot.web.dto;

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

entity class 로 선언해둔 Posts가 있음에도 Dto를 따로 생성한 이유는 entity class는 DB와 맞다은 핵심 클래스이고 Dto는 view를 위한 class여서 화면 변경시 자주 변경이 필요합니다. 때문에 entity와 dto는 분리를 하여 관리해야합니다.

코드 로직 정리

Controller에서 Dto 타입으로 요청 받음, 받은 요청을 service로 보냄
return postsService.save(requestDto);
Service에서 Dto타입으로 Entity class repository를 사용하여 처리
return postsRepository.save(requestDto.toEntity()).getId();
-> service에선 순서만 보장, 직접적 처리는 도메인에 있는 entity로 진행

repository는 JpaRepository를 상속받아서 따로 save 메소드를 만들어두지 않아도 사용할 수 있습니다.

Test 만들기

package orm.example.springboot;

import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowire;
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 orm.example.springboot.domain.posts.Posts;
import orm.example.springboot.domain.posts.PostsRepository;
import orm.example.springboot.web.dto.PostsSaveRequestDto;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.linesOf;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostApiControllerTest {
    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private PostsRepository postsRepository;

    @After
    public void tearDown() throws Exception {
        postsRepository.deleteAll();
    }

    @Test
    public void Posts_register() 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);

    }
}

HelloController와 달리 @WebMvcTest 를 사용하지 않은 이유
@WebMvcTest의 경우 JPA 기능이 작동하지 않기 때문!
Controller와 ControllerAdvice 등 외부 연동과 관련된 부분만 활성 화되어
지금처럼 JPA 기능까지 테스트하기위해선 @SpringBootTestTestRestTemplate을 사용하면 됩니다.

코드 로직 정리2

Controller에서 @PathVariable를 통해 url의 id를 받음, PostsUpdateDto 타입으로 요청 body 받음, 받은 요청을 service로 보냄
return postsService.update(id, requestDto);;
Service에서 Dto타입으로 Entity class repository를 사용하여 처리

Posts posts = postsRepository.findById(id).orElseThrow(() -> 
	new IllegalArgumentException(
    	"해당 게시글이 없습니다. id="+ id)
    );

findById() 메소드의 return 값은 postsRepository를 통해 post를 찾고 생성해둔 PostsResponseDto로 응답해준다.

Posts entity = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id="+ id));
        return new PostsResponseDto(entity);

Dto를 궂이 만들고 findById()에서 해당 타입으로 리턴하는 이유는 계층화구조를 통해 역할을 분리하고 entity로 받아온 것을 Dto 생성자를 통해 정해둔 새로운 객체로 만들기 위함

test코드 작성

import ...

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostApiControllerTest {
    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private PostsRepository postsRepository;

    @After
    public void tearDown() throws Exception {
        postsRepository.deleteAll();
    }

    @Test
    public void Posts_register() throws Exception {
		...
    }

    @Test
    public void Posts_updater() 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 = "contest2";

        PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
                .title(expectedTitle)
                .content(expectedContent)
                .build();

        String url = "http://localhost:" + port + "/api/v1/posts/" + updateId;
        HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto); //새로만든 객체로 http 요청 생성

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

테스트 결과

성공적이었고 터미널 출력을 보면 save -> select -> update순으로 작업하는 것을 볼 수 있습니다.

update 출력문

실제 톰캣을 실행하여 확인해보기

참고할 글

profile
모르면 공부하고 알게되면 공유하는 개발자

0개의 댓글