인프런/실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화

1) API 개발 기본 [ 회원 등록,수정,조회 API ]

backend dev 2024. 6. 10.

1편의 프로젝트를 가지고 API 개발을 진행한다.

 

회원 등록 API V1

@RestController
@RequiredArgsConstructor
public class MemberApiController {

    private final MemberService memberService;


    @PostMapping("/api/v1/members")
    public CreateMemberResponse saveMemberV1(@RequestBody @Valid Member member) {
        Long id = memberService.join(member);
        return new CreateMemberResponse(id);

    }
    @Data
    static class CreateMemberResponse{
        private Long id;

        public CreateMemberResponse(Long id){
            this.id = id;
        }
    }


}

 

 

@Entity
@Getter @Setter
public class Member {

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

    private String name;

    @Embedded
    private Address address;

    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();

}

멤버 엔티티에 제약조건을 넣지않았기에

name만 넣어도 동작한다. [ id는 알아서 생성이니까 안넣어도 되고,address, orders는 null에 대한 제약조건이 없으므로 아아무값을 안넣으면 null로 들어간다.

 

name값이 확실히 들어가야한다면 Bean Validation의 어노테이션을 이용하여 

@NotEmpty
private String name;

설정해준다. 

 

하지만 엔티티의 필드에 해당 검증어노테이션을 추가하고, api에서 데이터를 받아올때 엔티티를 사용한다면

다른 로직에서는 name이 필수가 아닐경우가 존재할때 문제가 발생한다.

또는 엔티티의 필드명이 바뀔때 해당 엔티티를 사용하는 api가 동작하지않게 된다.

[ 엔티티에 변화가있을때 api스펙이 변경된다는 문제점이 존재한다.]

그러므로 api에서는 엔티티를 직접사용하지말고

api 스펙에 맞는 DTO를 생성해서 사용해야한다.

/**
 * 등록 V1: 요청 값으로 Member 엔티티를 직접 받는다.
 * 문제점
 * - 엔티티에 프레젠테이션 계층을 위한 로직이 추가된다.
 * - 엔티티에 API 검증을 위한 로직이 들어간다. (@NotEmpty 등등)
 * - 실무에서는 회원 엔티티를 위한 API가 다양하게 만들어지는데, 한 엔티티에 각각의 API를 위한
모든 요청 요구사항을 담기는 어렵다.
 * - 엔티티가 변경되면 API 스펙이 변한다.
 * 결론
 * - API 요청 스펙에 맞추어 별도의 DTO를 파라미터로 받는다.
 */

 

 

 

회원 등록 API V2

@PostMapping("/api/v2/members")
public CreateMemberResponse saveMemberV2(@RequestBody @Valid CreateMemberRequest request) {
    Member member = new Member();
    member.setName(request.getName());
    Long id = memberService.join(member);
    return new CreateMemberResponse(id);
}

@Data
static class CreateMemberRequest{
	@NotEmpty
    private String name;
}

 

등록 V2: 요청 값으로 Member 엔티티 대신에 별도의 DTO를 받는다.

 

CreateMemberRequest 를 Member 엔티티 대신에 RequestBody와 매핑한다.

엔티티와 프레젠테이션 계층을 위한 로직을 분리할 수 있다.

엔티티와 API 스펙을 명확하게 분리할 수 있다.

엔티티가 변해도 API 스펙이 변하지 않는다.

 

참고: 실무에서는 엔티티를 API 스펙에 노출하면 안된다!

 

[ 엔티티만 봐서는 어떤값을 넘겨야하는지 감이 오지않는다. DTO를 만들면 어떤값을 전달해줘야하는지

API 스펙을 파악하기 쉽다.]

 

api의 request,response에서 엔티티를 절대 사용하지않는다. DTO를 사용해야한다.

 

 

회원 수정 AP

@Transactional
public void update(Long id, String name) {
    Member member = memberRepository.findOne(id);
    member.setName(name);
}

변경은 변경감지를 사용해야한다.

@Transactional
public Member update(Long id, String name) {
    Member member = memberRepository.findOne(id);
    member.setName(name);
}

이런식으로 구성하면 Member를 반환하므로 수정된 멤버를 다시 조회안해도되지만

하나의 메소드에서 커맨드(변경)과 쿼리(조회)가 같이있는게 된다.

 

CQS (Command Query Separation)

이번 인프콘의 마지막 강연에서 CQS라는 개념을 알게 되었다. 집에 가서 크롬에 CQS라는 단어를 검색해보니 '컴퓨터 프로그래밍에서 반드시 지켜야 할 원칙'이라는 설명이 나왔다. 오늘은 이 기본

velog.io

id 정도는 조회를 위해 반환해줄수 있다.

@PutMapping("/api/v2/members/{id}")
public UpdateMemberResponse updateMemberV2(@PathVariable Long id, @RequestBody @Valid UpdateMemberRequest request) {
    memberService.update(id, request.getName());
    Member findMember = memberService.findOne(id); // pk를 이용해서 조회하는걸 한다고해서 성능상 큰 문제는 없다. 이렇게 쿼리랑 커맨드를 나누면 유지보수성이 향상된다.
    return new UpdateMemberResponse(findMember.getId(), findMember.getName());
}

 

PUT은 전체 업데이트를 할 때 사용하는 것이 맞다.

부분 업데이트를 하려면 PATCH를 사용하거나 POST를 사용하는 것이 REST 스타일에 맞다.

[여기서는 부분업데이트지만 put을 사용했는데, 원래는 patch나 post를 사용해야한다. ]

 

 

 

 

post,patch,put 어떤 방식으로해도

기존의 있는 데이터도 가져와서 파라미터로 사용한다. [ 데이터는 name만 보냈지만 ]

 

회원 조회 API

회원조회 V1: 응답 값으로 엔티티를 직접 외부에 노출

 

@Entity
@Getter @Setter
public class Member {

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

    @NotEmpty
    private String name;

    @Embedded
    private Address address;

//    @JsonIgnore
    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();

}
@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

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

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

두 엔티티는 양방향연관관계이므로 한 엔티티를 조회하려고하면 순환참조가 발생해서

stackoverflow 에러가 발생한다.

멤버를 조회할때 해당 멤버에 order값이 존재하므로 순환참조 발생, 만약 해당멤버에 order값이 존재한다면 순환참조가 발생하지는 않을것이다.

 

해결방법중 하나는

한쪽 엔티티에

@JsonIgnore

를 붙여준다.

@JsonIgnore
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();

 

@GetMapping("/api/v1/members")
public List<Member> membersV1() {
    return memberService.findMembers();
}

@JsonIgnore로 인해주문 정보 자체가 결과에 보이지않는다.

 

 

하지만 이렇게 엔티티에 @JsonIgnore와 같은 프레젠테이션 계층 로직이 추가되기 시작한다.

등록에서 말했던것처럼 api에서 엔티티를 사용하면 여러 문제가 발생한다.

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

@JsonIgnore를 이용하여 해당 필드가 노출되는것을 막을 수 있지만, 해당 설정이 모든 api의 스펙을 맞출 수 없다.

 

 

참고

엔티티를 외부에 노출하지 마세요! 실무에서는 member 엔티티의 데이터가 필요한 API가 계속 증가하게 된다.

어떤 API는 name 필드가 필요하지만, 어떤 API는 name 필드가 필요없을 수 있다.

결론적으로 엔티티 대신에 API 스펙에 맞는 별도의 DTO를 노출해야 한다.

 

 

회원조회 V2: 응답 값으로 엔티티가 아닌 별도의 DTO 사용

@GetMapping("/api/v2/members")
public Result membersV2() {
    List<Member> findMembers = memberService.findMembers();
    List<MemberDto> collect = findMembers.stream().map(m -> new MemberDto(m.getName())).collect(Collectors.toList());
    return new Result(collect); // 한번 감싸지않고 리스트를 반환한다면 json 배열으로 반환되기 때문에 유연성이 떨어진다. 이렇게 한번 감싸고 반환해줘야한다.
}

@Data
@AllArgsConstructor
static class Result<T>{
    private T data;
}
@Data
@AllArgsConstructor
static class MemberDto{
    private String name;
}

json안에 하나의 키안의 값으로 json배열이 들어간모습-> 이 모습으로 반환해줘야한다.

 

위의 v1은 바로 리스트를 반환해서

다음과 같이 바로 json배열이 보인다.

 

Result같이 결과 객체를 감싸는 객체가 필요한 이유

1. 추가적으로 반환해야하는 필드가 있다면?

예를 들어 상태코드를 반환해줘야한다면 멤버DTO에 상태코드라는 필드를 넣을 순없다.

멤버DTO가 상태코드를 가지기에는 해당 객체의 정체성과 맞지않기 때문이다.

그럴때 Result객체에 상태코드를 추가하면된다.

@Data
@AllArgsConstructor
static class Result<T>{
    private T data;
    private int httpStatus;
}

이렇게 result객체로 감싸게되면 , 추가된 필드에 접근도 쉬워진다. [ json의 키로 접근하면되니까 ]

2. 어떤 데이터도 받을수 있게 설계되었다.

제너릭을 이용하여 멤버DTO 뿐만아니라 다른 DTO도 받을수있다.

반환에 필요한 필드또한 해당 객체가 가지고있다면 반환에 사용되는 공용객체로 사용될 수 있다.

 

Result 클래스 관련 질문입니다. - 인프런

Result 클래스를 만들어서 response 데이터를 보냈습니다. 이때 제너릭으로 설정하신 이유가 있나요? 현재 아래 코드가 이런식입니다. static class Result<T> { private T data; } 그런데 제너릭을 쓰지 않는 반

www.inflearn.com

 

조회 V2

엔티티를 DTO로 변환해서 반환한다.

엔티티가 변해도 API 스펙이 변경되지 않는다.

추가로 Result 클래스로 컬렉션을 감싸서 향후 필요한 필드를 추가할 수 있다.

댓글