[spring] 게시글 API 만들기(1) -글 등록

Kaite.Kang·2022년 12월 13일
0
post-thumbnail

* 목표

지난번에 포스팅했던 Posts 엔티티 클래스와 JpaRepository를 이용하여 사용자가 게시글을 등록할 수 있도록 API를 만들어보자.
우선 API를 만들기 전에 API 클래스는 어떻게 구성할 수 있는지, 사용자가 API를 통해 DB에 데이터를 넣을 때 어떤 흐름으로 데이터가 이동하는지 살펴보자.
마지막으로 테스트 코드를 작성하여 API가 잘 동작하는지 확인해보자.

1. API 클래스는 어떻게 구성할까?

1) API 클래스 구성

API를 만들기 위해 총 3개의 클래스가 필요하다.

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

2) Spring 웹 계층 영역 소개

spring 웹 계층의 영역을 간단히 소개하자면 다음과 같다.


그림[1] Spring 웹 계층

  • Web Layer

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

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

    • Datebase와 같이 데이터 저장소에 접근하는 영역이다.
    • Dao(Data Access Object) 영역과 같은 개념이다.
  • Dtos

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

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

💡 VO (Value Object) 란?
- 변하지 않는 값을 저장하기 위해 사용하는 오브젝트
- 예를 들어, Color 클래스로 Red를 표현 하기 위해서는 Color.RED등 과 같이 값을 표현하기 위해 getter 기능만이 존재한다.
- VO vs DTO 비교 : https://mommoo.tistory.com/61

3) 스프링 웹 계층 간 데이터 흐름 이해하기


그림[2] 스프링 계층 간 데이터 흐름도

계층(Layer)는 3가지 계층인 Web Layer(Controller), Service Layer, Repository Layer 로 구분할 수 있고, 계층 간 통신하는 데이터 단위는 DTO, Domain 이 있다.
DTO는 View Layer(화면 단)을 위한 클래스이며, 도메인(Domain)은 DB Layer(데이터베이스)와 직접 연관되어 있는 클래스이다. DTO와 Domain을 구분하는 것이 궁금할 수도 있다. 뒤에서 자세히 설명하겠다.

클라이언트(웹에서는 브라우저)의 요청을 컨트롤러가 받아 서비스로 넘긴다. 서비스 계층에서는 정의된 순서대로 트랜젝션이나 도메인을 실행한다. 데이터에 접근(데이터의 CRUD가 필요할때)할 때는 리포지토리를 통해 접근하여, 데이터 처리는 도메인(= 엔티티 클래스)에서 처리한다. 도메인에서 처리된 데이터는 최종적으로 데이터베이스에 반영된다.

4) 예시 코드를 통해 Service 영역 이해하기

Web(Controller), Service, Repository, Dto, Domain, 이 5가지 레이어에서 비즈니스 처리를 담당해야 할 곳은 Domain 영역이다.

많은 사람들은 Service 영역에서 비즈니스 로직을 처리 해야한다고 오해하지만 전혀 그렇지 않다.

Service 는 트랜젝션, 도메인 간 순서 보장을 하는 역할만 한다.

주문 취소 로직을 통해 Service 영역의 권장하는 예시와 권장하지 않는 예시를 비교해보자.

A. 슈도 코드

@Transactional
public Order cancelOrder(int orderId){

	1) 데이터베이스로부터 주문정보(Orders), 결제정보(Billing), 배송정보(Delivery) 조회
	2) 배송 취소를 해야 하는지 확인
	3) if(배송 중이라면) {
				배송 취소로 변경
		}
	4) 각 테이블에 취소 상태 Update

}

B. 권장하지 않는 예시 코드

모든 로직이 서비스 클래스 내부에서 처리된다. 그러다 보니 서비스 계층이 무의미하며, 객체란 단순히 데이터 덩어리 역할만 하게 된다.

@Transactional
public Order cancelOrder(int orderId) {

	//1)
	OrderDto order = orderDao.selectOrders(orderId);
	BillingDto billing = billingDao.selectBilling(orderId);
	DeliveryDto delivery = deliveryDao.selectDelivery(orderId);
	
	//2)
	String deliveryStatus = delivery.getStatus();
	
	//3)
	if("IN_PROGRESS".equals(deliveryStatus)) {
		delivery.setStatus("CANCEL");
		deliveryDao.update(delivery);
	}
	
	//4)
	order.setStatus("CANCEL");
	orderDao.update(order);
	
	billing.setStatus("CANCEL");
	deliveryDao.update(billing);
	
	return order;

}

반면 도메인 모델에서 처리할 경우 다음과 같은 코드가 될 수 있다.

C. 권장하는 예시 코드

order, billing, delivery 가 각자 본인의 취소 이벤트를 처리하며, 서비스 메소드는 트랜잭션과 도메인 간의 순서만 보장해 준다.

@Transactional
piblic Order cancelOrder(int orderId) {

	//1)
	Orders order = orderRepository.findById(orderId);
	Billing billing = billing Repository.findByOrdderId(orderId);
	Delivery delivery = deliveryRepository.findByOrderId(orderId);
	
	//2-3)
	delivery.cancel();
	
	//4)
	order.cancel();
	billing.cancel();
	
	return order;

}

2. 게시글 등록 기능 만들기

1) 파일 구성

클래스 파일은 각각 아래 패키지에 생성한다.

  • web 패키지 → PostsApiController 클래스
  • web.dto 패키지 → PostsSaveRequestDto 클래스
  • service 패키지 → PostsService 클래스

2) 구현

A. PostsApiController: 외부 요청에 응답할 컨트롤러 레벨

  • src/main/java/com/spring/book/web/PostsApiController.java
package com.spring.book.web;

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

PostsApiController 클래스는 @RestController 어노테이션을 지정하면서 컨트롤러 역할을 수행하게 되었다.
save 메소드는 /api/v1/posts 요청을 처리하며, 서비스 단을 호출하여 얻은 데이터를 반환한다.

위 코드에서는 @RequiredArgsConstructorfinal이 선언된 모든 필드를 인자값으로 하는 생성자를 대신 생성해준다. 이 어노테이션은 롬복 라이브러리에서 제공하는 어노테이션이다.

A) 스프링에서 Bean을 주입받는 방식

스프링에서 Bean 객체를 주입받는 방식은 다음과 같다.

  • @Autowured → 권장(X)
  • setter
  • 생성자 → 가장 권장(O)

이 중에서 가장 권장하는 방식이 생성자로 주입받는 방식이다. (@Autowired는 권장하지 않는다.)

B) 생성자 대신 롬복 어노테이션을 사용하는 이유

해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속 수정해야하는 번거로움을 해결하기 위함이다.

(롬복 어노테이션이 있으면 해당 컨트롤러에 새로운 서비스를 추가하거나, 기존 컴포넌트를 제거하는 등의 상황이 발생해도 생성자 코드는 전혀 손대지 않아도 된다.)

C) @RequestBody / @ResponseBody 어노테이션

@RequestBody 어노테이션이 붙은 파라미터는 HTTP 요청의 본문(Body)내용을 통째로 자바 객체로 변환해서 매핑된 메소드 파라미터로 전달해준다.
일반적인 GET/POST의 요청 파라미터라면 @RequestBody를 사용할 일이 없을 것이다.

반면에 xml이나 json기반의 메시지를 사용하는 요청의 경우에 이 방법이 매우 유용하다.

비교해서 알아두면 좋을 어노테이션으로 @ResponseBody가 있다.

@RequestBody / @ResponseBody 정리.
클라이언트에서 서버로 필요한 데이터를 요청하기 위해 JSON 데이터를 요청 본문에 담아서 서버로 보내면, 서버에서는 @RequestBody 어노테이션을 사용하여 HTTP 요청 본문에 담긴 값들을 자바객체로 변환시켜, 객체에 저장한다.
서버에서 클라이언트로 응답 데이터를 전송하기 위해 @ResponseBody 어노테이션을 사용하여 자바 객체를 HTTP 응답 본문의 객체로 변환하여 클라이언트로 전송한다.

B. PostsService: 트랜젝션, 도메인의 순서를 처리하는 서비스 레벨

  • src/main/java/com/spring/book/service/posts/PostsService.java
package com.spring.book.service.posts;

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

DTO의 데이터를 리포지토리를 통해 엔티티에 저장한다.
엔티티의 id값을 컨트롤러로 전달한다.

C. PostsSaveRequestDto: 게시글 등록시 사용할 DTO

  • src/main/java/com/spring/book/web/dto/PostsSaveRequestDto.java
package com.spring.book.web.dto;

import com.spring.book.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();
    }
}

A) Entity 클래스(Domain)와 DTO 클래스를 분리해서 사용하는 이유

Dto 클래스인 PostsSaveRequestDto는 Entity 클래스인 Posts와 거의 유사한 형태이다.

그럼에도 Dto 클래스를 따로 분리해야 하는 이유는 다음과 같다.

  • View Layer(Dto)와 DB Layer(Entity)의 역할을 분리하여 Request와 Response 동작 변경시 다른 클래스에 영향을 최소화하기 위함이다.
    • Entity 클래스는 데이터베이스와 맞닿은 핵심 클래스로,
      Entity 클래스를 기준으로 테이블이 생성되고 스키마가 변경된다.
    • 화면 변경(View Layer)은 아주 사소한 기능 변경인데, 이를 위해 테이블과 연결된 Entity 클래스(DB Layer)를 변경하는 것은 너무 큰 변경이다.
    • 수많은 서비스 클래스나 비즈니스 로직들이 Entity 클래스를 기준으로 동작한다. Entity 클래스가 변경되면 여러 클래스에 영향을 끼치지만, Request와 Response용 Dto는 View를 위한 클래스라 정말 자주 변경이 필요하다.
  • Entity 클래스만으로 표현하기 어려운 부분을 구현하기 위함이다.
    • Controller에서 결과값으로 여러 테이블을 조인해서 줘야 할 경우가 빈번하므로 Entity 클래스만으로 표현하기가 어려운 경우가 많다.

3. 테스트 코드로 Post API 검증하기

1) PostsApiControllerTest

  • src/test/java/com/spring/book/web/PostsApiControllerTest.java
package com.spring.book.web;

import com.spring.book.domain.posts.Posts;
import com.spring.book.domain.posts.PostsRepository;
import com.spring.book.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 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);
    }
}

A. Api Controller를 테이스하는 데 @WebMvcTest 대신 @SpringBootTest를 사용한 이유

@WebMvcTest는 JPA 기능이 작동하지 않으며, Controller와 ControllerAdvice 등 외부 연동과 관련된 부분만 활성화된다.

JPA 기능까지 한번에 테스트할 때에는 @SpringBootTest와 TestRestTemplate를 사용하면 된다.

B. 테스트 결과

WebEnvironment.RANDOM_PORT로 인한 랜덤 포트 실행과 insert 쿼리가 실행된 것을 모두 확인할 수 있다.

* 정리

  • 코드 로직
    • 게시글을 포스팅하기 위해 등록 API를 생성하였다. /api/v1/posts 를 호출하면 컨트롤러에 요청을 받아서 서비스로 포스팅 할 데이터를 넘겨준다. 서비스는 리포지토리를 통해 도메인에 데이터를 저장하고, 도메인에 데이터를 저장하면 데이터베이스에도 반영된다.
  • 개념
    • 스프링 웹 계층 영역은 Controller, Service, Repository 로 구성되어 있다.
    • 계층 간 통신시 데이터 교환 단위로는 도메인(Domain)과 DTO이 있으며 두 클래스는 분리해서 사용해야 한다.
    • 비즈니스 로직을 처리하는 영역은 도메인이다.

* 추후 공부할 것

  • @Controller 와 @RestController의 차이

참고

도서 - 스프링 부트와 AWS로 혼자 구현하는 웹 서비스

0개의 댓글