본문 바로가기

Spring/SpringBoot

SpringBoot- API

API

 Mapping 어노테이션

 API는 기본적으로 통신을 위한 것이고, 다양한 매핑 어노테이션을 사용한다. 기존에 봐왔던 @RequestMapping, @GetMapping등이 존재한다.

  • @RequestMapping
  • @GetMapping: 데이터를 가져오는 경우에 사용한다.
  • @PostMapping: 가입, 생성 시에 사용한다.
  • @PutMapping: POST와 유사하나 멱등성을 가진다. 전체 update가 필요한 경우에는 @PutMapping을 사용한다. 만일 부분 update가 필요한 경우에는 PUT 대신 POST를 사용한다.
  • @DeleteMapping: 삭제시에 사용한다.

 API 설계 (1) - GetMapping

API를 설계하는데 있어서 중요한 점은 절대로 엔티티 그 자체를 노출시키지 않는다는 것이다. 예를들어 엔티티를 노출하는 다음과 같은 API가 존재한다.

 엔티티 직접 노출

@RestController
// @RestController = @Controller + @ResponseBody
// @ResponseBody: 자바객체를 json, xml로 변환해서 보낼 때 사용
@RequiredArgsConstructor
public class MemberApiController {


    private final MemberService memberService;
    /**
     * 조회 V1: 응답 값으로 엔티티를 직접 외부에 노출한다.
     * 문제점
     * - 엔티티에 프레젠테이션 계층을 위한 로직이 추가된다.
     * - 기본적으로 엔티티의 모든 값이 노출된다.
     * - 응답 스펙을 맞추기 위해 로직이 추가된다. (@JsonIgnore, 별도의 뷰 로직 등등)
     * - 실무에서는 같은 엔티티에 대해 API가 용도에 따라 다양하게 만들어지는데, 한 엔티티에 각각의
     API를 위한 프레젠테이션 응답 로직을 담기는 어렵다.
     * - 엔티티가 변경되면 API 스펙이 변한다.
     * - 추가로 컬렉션을 직접 반환하면 항후 API 스펙을 변경하기 어렵다.(별도의 Result 클래스 생성
     으로 해결)
     * 결론
     * - API 응답 스펙에 맞추어 별도의 DTO를 반환한다.
     */
    // 조회 V1: 안 좋은 버전, 모든 엔티티가 노출

    @GetMapping("/api/v1/members")
    public List<Member> membersV1(){
        // 이 함수로 orders에 관한 내용없이 Member에 관한 내용만 반환하기 위해선
        // Member.orders에 @JsonIgnore를 붙여서 orders가 Json으로
        // 전달되지 못하게 강제하는 방법밖에 없다.
        // @JsonIgnore -> 모든 API에 영향을 주기 때문에 절대 사용해서는 안된다!!!
        return memberService.findMembers();
    }
}

 뭔가 주저리 많이 쓰여있긴 한데 메서드만 봐보자. membersV1( )은 memberService.findMembers()로 엔티티를 조회하고 이를 반환한다. 즉 엔티티를 직접 노출시키는 것인데 이처럼 엔티티를 직접 노출시키는 경우 몇가지 문제점이 존재한다.

  1. 엔티티의 모든 정보를 노출시킴
  2. 무한루프에 빠질 수 있음

1) 만일 Member 엔티티를 반환하는데, member의 필드로 name, address, orders가 있다고 하자. 나는 여기서 name만 api를 통해서 전달하고 싶다고 하자. 근데 위의 membersV1처럼 엔티티 자체를 통째로 반환한다면 name, address, orders가 모두 전달될 것이다.   따라서 불필요한 정보가 노출되는 위험성이 존재한다.

 

Member.class

package jpabook.jpashop.domain;

import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Getter;
import lombok.Setter;
import org.springframework.beans.factory.annotation.Autowired;

import javax.persistence.*;
import javax.validation.constraints.NotEmpty;
import java.util.ArrayList;
import java.util.List;

@Entity
@Getter @Setter
public class Member {

    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    @NotEmpty(message = "이름은 필수 입력 값입니다.")
    private String name;

    @Embedded
    private Address address;

    @JsonIgnore
    // 종속: member.orders
    @OneToMany(mappedBy = "member", cascade = CascadeType.ALL)
    private List<Order> orders = new ArrayList<>();

    public void setAddress(String city, String street, String zipcode) {
        this.address = new Address(city,street,zipcode);
    }
}

2) Member와 Order는 서로 양방향 연관관계로 매핑 되어있다. 따라서 Member 엔티티를 반환할 때 Member.orders가 반환되고, 또 Member.orders는 양방향 매핑된 Order.member를 반환해서 서로가 서로를 계속해서 반환하게 되는 불상사가 발생하게 된다. 이를 막기위해선 @JsonIgnore 어노테이션을 이용해서 하나의 엔티티가 반환될 때 강제로 다른 엔티티는 Json 형태로 반환되는 것을 막을 수 있긴 하지만, 이처럼 API 스펙을 위해서 기존의 엔티티의 변화를 가하는 것은 설계상 좋지 못하고 유지 보수성을 낮춘다.  사실 api를 하나만 사용하는 것이 아니기 때문에 하나의 api를 위해서 엔티티의 수정을 가하게 되면 다른 api의 사용에서도 문제점이 발생할 수 있기 때문에 이는 좋지 못한 설계이다.

 

따라서 이러한 문제를 해결하기 위해서 "DTO"를 사용한다.

DTO를 사용해서 문제 해결하기

package jpabook.jpashop.api;

import jpabook.jpashop.domain.Member;
import jpabook.jpashop.service.MemberService;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import javax.validation.constraints.NotEmpty;
import java.util.List;
import java.util.stream.Collectors;

@RestController
// @RestController = @Controller + @ResponseBody
// @ResponseBody: 자바객체를 json, xml로 변환해서 보낼 때 사용
@RequiredArgsConstructor
public class MemberApiController {

    private final MemberService memberService;

    @GetMapping("/api/v2/members")
    public Result memberV2(){
        List<Member> findMembers = memberService.findMembers();

        List<MemberDTO> collect = findMembers.stream()
                .map(m -> new MemberDTO(m.getName()))
                .collect(Collectors.toList());

        return new Result<>(collect);
    }
    @Data
    @AllArgsConstructor
    private class Result<T> {

        private T data;
    }
    @Data
    @AllArgsConstructor
    static class MemberDTO{
        private String name;
    }
}

 보면 @Data 어노테이션이 붙은 Result, MemberDTO 클래스를 정의, 선언하였다. 보면 Member 엔티티를 조회한 후, List로 조회된 Member 엔티티들을 하나씩 꺼내서, member.name을 MemberDTO에 넣은 후, MemberDTO를 Result로 감싸서 반환하고 있다.  

 Json으로 반환된 결과는 다음과 같다.

MemberV2() API로 반환된 JSON

 보면 MemberDTO로 따로 name만 꺼내서 반환하기 이전에는 양방향 매핑으로 인한 무한 루프 이슈 뿐만 아니라, 내가 반환하고 싶지 않은 별도의 필드값도 반환되는 문제가 존재했다. 하지만 이렇게 DTO로 감싸니 무한 루프 문제도 해결되고, 또한 내가 반환하고자 한 name만 따로 반환하는 것도 가능했다. 

 그리고 마지막에 DTO를 Result라는 클래스로 한번 더 감싸서 반환을 했다. 감싼 경우와 감싸지 않고 그냥 MemberDTO를 반환한 경우 차이는 다음과 같다.

result 클래스로 감싸서 반환한 경우: return Result<MemberDTO>(collect); (좌)          /          감싸지 않고 그냥 반환한 경우: return collect;(우)

감싸서 반환한 경우 data라는 리스트로 묶여서 반환된다. 이렇게 묶어서 반환하는 경우 한 눈에 보기에 더 좋다.

 

 API 설계 (2) - PostMapping

 방금 전에는 @GetMapping으로 예시를 들었으니 이번에는 @PostMapping으로 DTO를 이용한 설계 예시를 보여보자.

 엔티티 직접 노출

package jpabook.jpashop.api;

import jpabook.jpashop.domain.Member;
import jpabook.jpashop.service.MemberService;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import javax.validation.constraints.NotEmpty;
import java.util.List;
import java.util.stream.Collectors;

@RestController
// @RestController = @Controller + @ResponseBody
// @ResponseBody: 자바객체를 json, xml로 변환해서 보낼 때 사용
@RequiredArgsConstructor
public class MemberApiController {

    private final MemberService memberService;

    @PostMapping("/api/v1/members")
    // @Valid: member를 검증할 때 사용, @RequestBody: json으로 온 내용을 member에 매핑
    public CreateMemberResponse saveMemberV1(@RequestBody @Valid Member member) {
        // api 통신에 이런식으로 엔티티를 파라미터로 직접 받는 경우
        // 엔티티의 필드가 바뀌는 경우 더 이상 통신이 불가능한 문제등이 발생 가능.
        // 엔티티의 변화가 api 스펙에 영향을 주는 식으로 설계하면 안된다.
        // 이는 잘못된 설계임.
        Long id = memberService.join(member);
        return new CreateMemberResponse(id);
    }

    @Data
    static class CreateMemberResponse {
        // 클라이언트에게 반환 값. Json의 형태로 클라이언트에게 반환됨
        private Long id;
        public CreateMemberResponse(Long id) {
            this.id = id;
        }
    }
}

 saveMember() 메서드는 localhost:8080/api/v1/members 주소에 Json으로 Member 엔티티에 대한 구성정보(필드 값)을 받아서 새로운 Member 객체를 생성한 후, 이를 회원으로 등록하는 메서드이다. 그리고 반환으로는 CreateMemberResponse 객체를 Json의 형태로 반환하는데, 결과적으로 새로 가입된 Member의 id를  반환하는 형태이다.

 지금 보면 Json 데이터를 @RequestBody 어노테이션을 이용하여 Member 엔티티로 직접 받고 있다.

 

 Json으로 전달할 수 있는 데이터는 Member의 필드 변수들인 name, adderss, orders에 대한 값들인데 이들 중 name에 대한 정보만 필수이고 나머지는 받지 않아도 되게 설계 해 놓았다.

 

 따라서 PostMan을 이용해서 POST request를 날리면 다음과 같이 동작한다.

member1 이라는 name 데이터 POST 전송. 15의 id를 갖는 회원이 생성되었고 회원의 id가 반환됨

근데 만약에 내가 Member 엔티티를 재 설계 하다가 "name" 이라는 필드명이 맘에 들지 않아서 이름을 "userName"으로 고쳤다고 하자. 

@Entity
@Getter @Setter
class Member{
	
    @Id @GenerateValue
    @Column(name = "member_id")
    private Long Id;
    
    @NotEmpty(message = "이름은 필수 입력 값입니다")
    private String userName;  // name -> userName
    	.
    	.
    	.
}

 이때 만약에 내가 위와 똑같은 POST 메세지를 보낸다면 결과가 어떻겠는가?

String name -> String userName

 당연히 에러가 발생한다. 더 이상 "name"이라는 이름을 갖는 필드 값이 없기 때문이다. 즉, 엔티티를 API 설계 시 외부에 노출시키게 되면, 엔티티의 변화가 생긴 경우 이처럼 기존과 같은 request 메시지를 전달하는 경우에 바뀐 필드값에 의해서 에러가 발생할 수 있다.

 

이 또한 DTO를 사용해서 해결할 수 있다.

DTO를 사용해서 문제 (또) 해결하기

package jpabook.jpashop.api;

@RestController
// @RestController = @Controller + @ResponseBody
// @ResponseBody: 자바객체를 json, xml로 변환해서 보낼 때 사용
@RequiredArgsConstructor
public class MemberApiController {

    private final MemberService memberService;

    /**
     * 조회 V2: 응답 값으로 엔티티가 아닌 별도의 DTO를 반환한다.
     * - 엔티티를 DTO로 변환해서 반환한다.
     * - 엔티티가 변해도 API 스펙이 변경되지 않는다.
     * - 추가로 Result 클래스로 컬렉션을 감싸서 향후 필요한 필드를 추가할 수 있다
     */

    @PostMapping("/api/v2/members")  // @RequestBody: 이 주소로 json, xml 데이터가 전달되면 이를 자바객체로 변환해서 매핑해줌
    public CreateMemberResponse saveMemberV2(@RequestBody @Valid CreateMemberRequest request) {
        //@Valid 어노테이션 안 붙이면 에러메시지 안뜸
        Member member = new Member();
        member.setName(request.getName());
        Long id = memberService.join(member);

        return new CreateMemberResponse(id);
    }

    @Data
    static class CreateMemberRequest {
        // 엔티티의 변화에도 대응할 수 있도록 값을 받는

        // Request 파라미터를 별도로 생성
        // 클라이언트 요청이 여기로 들어옴
        @NotEmpty(message = "이름은 필수 값 입니다")
        private String name;
    }
    @Data
    @AllArgsConstructor
    static class CreateMemberResponse {
        // 클라이언트에게 반환 값. Json의 형태로 클라이언트에게 반환됨
        private Long id;
    }
}

이제 Json 데이터를 Member 엔티티로 직접 받는 것이 아닌 "CreateMemberRequest request"라는 별도의 DTO 객체 매개변수를 이용해서 받는다. (편의상 데이터로 name만 받는다고 가정하였다). 기존에는 엔티티의 name 필드의 이름이 바뀐다면 위의 경우 처럼 에러가 발생했다. 하지만 이제는 DTO로 값을 받기 때문에 DTO의 name 필드 명을 다른 이름으로 바꾸는 경우가 아니라면, 위와 같이 기존과 동일한 request message를 전달했을 때도 에러가 발생하지 않는다.