Java/JPA

[API 개발] 회원 등록, 수정, 조회 API

Lea Hwang 2022. 5. 24. 15:57

목표

기능 구현을 넘어서 기술 문제 해결 - 유지보수 고려

좋은 설계는 유지보수 단계까지 고려해야 합니다.

 

1. API 개발

  • 등록, 수정, 조회 REST API 개발
  • API 설계, 사용 시 주의점

 

2. JPA성능 최적화

 

 

API 개발 기본

 

요즘에는 화면을 템플릿 엔진(1편) → 싱글 페이지 애플리케이션

점점MSA 도입하면서 일이 많습니다.

 

api호출은
"클라이언트단에서 서버 쪽으로 또는 서버에서 서버로 원하는 데이터를 받아오기 위해서 하는 것

 

postman 설치 - REST API툴

https://www.getpostman.com

 

Postman API Platform | Sign Up for Free

Postman is an API platform for building and using APIs. Postman simplifies each step of the API lifecycle and streamlines collaboration so you can create better APIs—faster.

www.postman.com

 

회원 API 개발

고민 : 패키지 어떻게 만들까?

템플릿 엔진 Controller와 API Contorller 분리

왜? 공통으로 예외 처리할 때 패키지 단위로 하므로 차이가 있어서 패키지 분리함

 

MemberApiController 

  APIController의 경우 이름 명시해줌

 

 

@Controller와 @ResponseBody 합친 게 @RestContoller ← REST API 스타일로 만들 때 사용
  @ResponseBody : Json 형태로 데이터를 반환


전통적인 Spring MVC의 컨트롤러인 @Controller는 주로 View를 반환하기 위해 사용

컨트롤러에서는 데이터를 반환하기 위해 @ResponseBody 어노테이션을 활용해주어야 합니다.
이를 통해 Controller도 Json 형태로 데이터를 반환할 수 있습니다.

@RestController는 @Controller에 @ResponseBody가 추가된 것입니다.
RestController의 주용도는 Json 형태로 객체 데이터를 반환하는 것입니다.

출처: https://mangkyu.tistory.com/49

 

 

회원 등록 API (v1)

@RequestBody
클라이언트가 전송하는 Json(application/json) 형태의 HTTP Body 내용을 Java Object로 변환시켜주는 역할을 한다.

  여기서는 Json데이터를 Member로 바꿔주는 역할
Json 형태로 받은 HTTP Body 데이터를 MessageConverter를 통해 변환시킴

출처:
https://mangkyu.tistory.com/72
@Data
롬복 어노테이션 @Data는
 = @toString + @getter + @setter + @RequiredArgsConstructor + @EqualsAndHashCode

 

MemberApiController

v1, v2 코드

package jpabook.jpashop.api;

@RestController
@RequiredArgsConstructor
public class MemberApiController {

    private final MemberService memberService;

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

    }

    @PostMapping("/api/v2/members")
    public CreateMemberResponse savaMemberV2(@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;
    }

    @Data
    static class CreateMemberResponse{
        private Long id;

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

 

Q.
여기서 드는 의문은 CreateMemberResponse에 @Data를 사용했을 때 생성자, getter, setter... 를 따로 안 적어주어도 되는 걸로 알고 있는데 생성자를 따로 생성해 준 이유

A.
@RequiredArgsConstructor는 final 키워드나 @NotNull이 붙은 필드를 포함해 생성자를 자동으로 만들어주는 애노테이션입니다. 
CreateMemberResponse의 필드는 final 키워드, @NotNull이 모두 없으므로 id를 받는 생성자를 새로 생성해줘야 합니다.


Q.
v1에서 CreateMemberResponse는 final 키워드, @NotNull이 모두 없으므로 id를 받는 생성자를 새로 생성해주었습니다.
v2인 CreateMemberRequest에서도  final 키워드, @NotNull이 모두 없으므로 id를 받는 생성자를 만들어 주어야 할 것 같은데 이 경우 생성 안해준 이유

A.
1. CreatMemberResponse를 생성할 때 id를 전달해서 id를 가진 상태로 해당 객체를 반환하기 위해서 생성자를 만들었습니다. 
2. CreateMemberRequest는 name을 파라미터로 전달받는 생성자가 필요 없기 때문에 안 만든 것입니다.

 

 

postman 확인

 

Send전에 Application.java 구동시켜야 함!

 

DB에 잘 들어감

insert into member (city, street, zipcode, name, member_id) values (?, ?, ?, ?, ?)
insert into member (city, street, zipcode, name, member_id) values (NULL, NULL, NULL, 'hello', 1);

 

이 V1은 심각한 문제가 있습니다.

다시 코드를 살펴보자면,

MemberApiController v1

package jpabook.jpashop.api;

import jpabook.jpashop.domain.Member;
import jpabook.jpashop.service.MemberService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.Valid;

@RestController
@RequiredArgsConstructor
public class MemberApiController {

    private final MemberService memberService;

    /**
     * 회원 등록 API
     */
    @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;
        }
    }
}

 

💡 v1의 심각한 문제점과 대안 v2 💡
검증 로직이 엔티티에 들어가 있습니다.(@NotEmpty) 있는데 문제는 어떤 Api에서는 @NotEmpty가 필요할 수도 있고, 어떤 api에서는 @NotEmpty가 필요 없을 수도 있습니다.

그리고
엔티티의 스펙을 name에서 username으로 바꾼다면 클라이언트에서 api 요청했을 때
동작을 안 할 수 있습니다. 

즉, 엔티티는 굉장히 여러 곳에서 쓰이는데, v1에서는 엔티티를 수정해서 api스펙 자체가 바뀐다는 
게 가장 큰 문제입니다. 지금 이 상황은 엔티티와 api스펙이 1:1 매핑된 상태입니다.


결론적으로 api 요청 스펙을 위한 별도의 DTO를 만들고 파라미터로 받아야 합니다.(v2)
  DTO : CreateMemberRequest


📢 "항상" api 개발할 때 엔티티를 파라미터로 받지 말고 엔티티 외부 노출 절대 하지 말기,
api스펙에 맞는 DTO객체 만들어서 파라미터에 넣어주기


v2장점은 엔티티를 수정해서 api스펙 자체가 바뀌지 않습니다.
 누군가가 엔티티 스펙을 바꾸었을 때, Controller단에서 컴파일 오류가 나기 때문에 그 부분만
 수정해주면 됩니다.
 예) Member name필드를 username으로 바꿈 → Controller → saveMemberV2 →
      member.setName 부분이 빨간 줄이 뜨면서 컴파일 오류 → 이 부분을 member.setUsername으로 바꾸어
      주면 됩니다.


v2의 또 다른 장점은
v1의 경우 Member의 어떤 파라미터가 넘겨오는지 모르므로
개발자 입장에서 api스펙 문서를 까 보기 전에는 어디까지 넘어오는지 모릅니다.
근데 v2처럼 dto객체를 만들면, 이것만 보면 아 name만 넘겨오는구나를 바로 알 수 있으며,
만약 @Valid가 필요하다면 필요한 api스펙에 추가하면 됩니다. 


이는 유지 보수할 때 큰 장점입니다.


정리)
엔티티와 API 명확히 분리
엔티티를 외부에 노출하거나 엔티티 그대로 파라미터에 넣는 거 X

 

HTTP API vs REST API (Representational State Transfer)

◽ API(Application Programming Interface)란?
API는 클라이언트나 서버 같은 다른 프로그램끼리 데이터를 주고받는 방법, 규격

HTTP API와 REST API는 사실 거의 같은 의미로 사용됩니다.
그런데 디테일하게 들어가면 차이가 있습니다.
HTTP API는 HTTP를 사용해서 서로 정해둔 스펙으로 데이터를 주고받으며 통신하는 것으로 이해하시면 됩니다.
그래서 상당히 넓은 의미로 사용됩니다.
반면에 REST API는 HTTP API에 여러 가지 제약 조건이 추가됩니다.

REST는 다음 4가지 제약조건을 만족해야 합니다.

- 자원(Resource)의 식별 : 예) 웹 기반의 REST 시스템에서의 URI 
- 메시지를 통한 리소스 조작
- 자기 서술적 메시지 :각 메시지는 자신을 어떻게 처리해야 하는지에 대한 충분한 정보를 포함해야 한다.
                                  HTTP 프로토콜의 Method로 행위를 표현
- 애플리케이션의 상태에 대한 엔진으로써 하이퍼미디어 :만약에 클라이언트가 관련된 리소스에 접근하기를 원한다면,                                                                                             리턴되는 지시자에서 구별될 수 있어야 한다.

여러 가지가 있지만 대표적으로 구현하기 어려운 부분이 마지막에 있는 부분인데요. 이것은 HTML처럼 하이퍼링크가 추가되어서 다음에 어떤 API를 호출해야 하는지를 해당 링크를 통해서 받을 수 있어야 합니다.
즉, REST API는 웹을 위한 네트워크 기반 아키텍처로 REST의 특징을 기반으로 서비스 API를 구현한 것입니다.
존재하는 많은 자원들 중 URI를 부여하여 자원을 명시하고 HTTP Method(GET, POST, PUT, DELETE)를 통해 해당 자원에 대한 CRUD를 이용하여 데이터를 처리한다고 정리할 수 있습니다.
REST API의 가장 큰 특징은 각 요청이 어떤 동작이나 정보를 위한 것인지를 그 요청의 모습 자체로 추론이 가능합니다,

그리고 이런 부분을 완벽하게 지키면서 개발하는 것을 RESTful API라고 하는데요. 
이미 많은 사람들이 해당 조건을 지키지 않아도 REST API라고 하기 때문에, HTTP API나 REST API를 거의 같은 의미로 사용하고 있지만 엄격하게는 다른 개념입니다.

출처 :(https://ko.wikipedia.org/wiki/REST)

 

 

회원 수정 API - PUT(전체 업데이트)

  수정 시에는 변경 감지 기능 사용!

 

롬복 어노테이션 사용
엔티티에서는 @Getter정도만 쓰고
DTO에서는 막 쓰는 편이다.
  DTO의 경우 비즈니스 로직이 있다기 보단, 데이터 왔다 갔다 하는 경우이므로

 

 

MemberApiController

package jpabook.jpashop.api;

@RestController
@RequiredArgsConstructor
public class MemberApiController {

    private final MemberService memberService;

    /**
     * 회원 수정 API
     *   수정시 변경감지 기능 사용
     *   PUT은 전체 업데이트를 할 때 사용, 부분 업데이트를 하려면 PATCH를 사용하는 것이 REST 스타일에 맞다.
     */
    @PatchMapping("/api/v2/members/{id}")
    public UpdateMemberResponse updateMemberV2(
            @PathVariable("id") Long id,
            @RequestBody @Valid UpdateMemberRequest request) {

        memberService.update(id, request.getName());
        // Command Query Separation 원칙(단순 조회여서 쿼리 짠 것)
        Member findMember = memberService.findOne(id);
        return new UpdateMemberResponse(findMember.getId(), findMember.getName());

    }

    @Data
    static class UpdateMemberRequest{
        private String name;
    }

    @Data
    @AllArgsConstructor
    static class UpdateMemberResponse{
        private Long id;
        private String name;
    }

   
}

 

"MemberService의 update 메서드에서 Member를 그대로 반환하지 않은 이유는 커맨드와 쿼리를 분리했기 때문입니다." 문장의 의미를 알아봅시다.

"Command Query Separation 원칙 = 명령(데이터 변경)과 조회를 분리한다."

Query
결과값을 반환하고, 시스템의 상태를 변화시키지 않는다.
따라서 부작용에서 자유롭다.(free of side effects)

Command
결과를 반환하지 않고, 대신 시스템의 상태를 변화시킨다.


추가적 의문에 대한 답변 정리 :
Q.
1. Member를 update 메서드에서 그대로 반환하면 왜 영속 상태가 끊긴 Member가 반환이 되나요?
2. Member를 그대로 반환하면 updateMemberV2 메서드에서
    Member member = memberService.update(id, request.getName());
    return new UpdateMemberResponse(member.getId(), member.getName());
이런 식으로 코드를 짜면 오히려  Member를 찾는 Select 쿼리 안 날아가니까 결과적으로 
커맨드와 쿼리를 분리할 필요가 없지 않나요?


A.
1. JPA는 기본으로 트랜잭션 범위를 넘어가면 영속성 컨텍스트도 종료됩니다. 그래서 member의 영속 상태가 유지될 수 없습니다.

2. 커멘드와 쿼리를 분리하는 것은 단순히 효율성을 넘어, 코드를유지보수하기 쉽게 만드는 방법 중 하나입니다.
변경을 하는 메서드와 단순히 조회를 하는 메서드를 아주 명확하게 분리해버리면, 변경 코드는 변경에만 집중하고, 조회 코드는 조회에만 집중할 수 있습니다.
개발하는 코드 분량이 많고, 복잡할수록, 이런 식으로 나누어 설계하면 관심사가 분리되어서 코드를 유지 보수하기 쉽습니다.
하지만 이것이 딱 정답은 아니고, 생각하신 것처럼 조회를 한번 더 해야 하기 때문에 상황에 따라 트레이드오프가 있습니다.

 

 

postman으로 테스트해보자

 

 

순서 :

postman에서 send 하면

@PatchMapping("/api/v2/members/{id}")

여기로 옵니다.

 

 

쿼리 나감(변경 감지 사용)

update member set city=NULL, street=NULL, zipcode=NULL, name='new-helloooo' where member_id=1;

이렇게 쿼리 나가고 트랜잭션이 완전히 끝나고

 

//Query(단순 조회)
Member findMember = memberService.findOne(id);

 

ResponseDTO 통해 반환해서

postman에서 이렇게 보이는 것입니다.

{
    "id"1,
    "name""new-helloooo"
}

 

 

회원 조회 API - GET

 

단순 조회이므로 데이터베이스의 데이터를 계속 쓰기 위해

 

application.properties > create를 none으로 수정

spring.jpa.hibernate.ddl-auto=none

 

빌드하고 구동후

회원가입 여러 개 하기

조회해보자

 

MemberApiController > v1

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

 

 

문제점

api만 보면 간단하다고 생각할 수 있다.

하지만 

조회 V1: 응답 값으로 엔티티를 직접 외부에 노출
문제점
1. 엔티티에 프레젠테이션 계층을 위한 로직이 추가된다.
2. 기본적으로 엔티티의 모든 값이 노출된다.
3. 응답 스펙을 맞추기 위해 로직이 추가된다. (@JsonIgnore, 별도의 뷰 로직 등등)
4. 실무에서는 같은 엔티티에 대해 API가 용도에 따라 다양하게 만들어지는데, 한 엔티티에 각각의 API를 위한 프레젠테이션 응답 로직을 담기는 어렵다.
5. 엔티티가 변경되면 API 스펙이 변한다.
6. 추가로 컬렉션을 직접 반환하면 향후 API 스펙을 변경하기 어렵다.


결론
API 응답 스펙에 맞추어 별도의 DTO를 반환한다.

 

참고: 엔티티를 외부에 노출하지 마세요!
실무에서는 member 엔티티의 데이터가 필요한 API가 계속 증가하게 된다. 어떤 API는 name 필드가 필요하지만, 어떤 API는 name 필드가 필요 없을 수 있다.
결론적으로 엔티티 대신에 API 스펙에 맞는 별도의 DTO를 노출해야 한다

 

 

그래서 엔티티를 DTO로 변환해서 반환하는 v2코드를 짜 보겠습니다.

 

MemberApiController

@GetMapping("/api/v2/members")
    public Result memberV2() {
        List<Member> findMembers = memberService.findMembers();
        //findMembers엔티티 그대로 반환하면 안되고 DTO로 변환 후 반환
        List<MemberDto> collect = findMembers.stream()
                .map(m -> new MemberDto(m.getName()))
                .collect(Collectors.toList());
        return new Result(collect);
    }

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

    @Data
    @AllArgsConstructor
    static class MemberDto {
        private String name;
    }

 

+ map은 A를 B로 바꾸는 것

 

+ 코드 설명

static class Result<T> {
        private T data;
    }
Result 객체가 생기기 전에는 MemberDto, OrderDto 등 각각 서로 다른 타입으로 반환하고 있습니다.
MemberDto는 멤버 정보를 담고 있는 객체이고, OrderDto는 주문 정보를 담고 있는 객체입니다.

[추가 요구사항 발생 시]
예를 들어, 응답 데이터로 특정 도메인의 정보(Member, Order 등)뿐만 아니라 응답 상태 코드를 추가적으로 나타내라는 요구사항이 추가되었습니다.

그러면 MemberDto, OrderDto는 응답 상태 코드를 가지기에는 객체의 정체성과 맞지 않습니다.
응답 상태 코드는 멤버 정보도 아니고, 주문 정보도 아니기 때문입니다.
따라서 API 컨트롤러의 응답을 추상화한 Result라는 클래스를 도출합니다
Result는 응답을 추상화했기 때문에 사용자가 요청한 데이터(MemberDto, OrderDto)도 담아야 하고, 응답 그 자체에 대한 데이터(StatusCode)도 담을 수 있어야 합니다.

이런 맥락에서 Result 객체의 핵심인 사용자가 요청한 데이터를 받는 data 필드가 생기게 되고, 추가 요구사항이었던 statusCode도 추가될 수 있습니다.
이때 사용자가 요청한 데이터가 어떤 타입이든 Result 객체에서 받을 수 있으려면 제네릭을 사용하여 명시한 타입을 data의 타입으로 사용할 수 있게 하는 것입니다.

 

 

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

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

  DTO와 API 스펙이 1:1 매칭

  엔티티에서 어떤 걸 노출할지 정해서 DTO생성

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

 

 

 


 

 

자주 쓰이는 어노테이션 정리

@ResponseBody 
Json 형태로 데이터를 반환

@RequestBody 
클라이언트가 전송하는 Json(application/json) 형태의 HTTP Body 내용을 Java Object로 변환시켜주는 역할

 

생성자 자동 생성해주는 롬복 어노테이션
@NoArgsConstructor
파라미터가 없는 기본 생성자를 생성

@RequiredArgsConstructor
final이나 @NonNull인 필드 값만 파라미터로 받는 생성자 만듦

@AllArgsConstructor
모든 필드 값을 파라미터로 받는 생성자를 만듦
@NoArgsConstructor
@RequiredArgsConstructor
@AllArgsConstructor
public class User {
  private Long id;
  
  @NonNull
  private String username;
  
  @NonNull
  private String password;
  
  private int[] scores;
}
User user1 = new User();
User user2 = new User("dale", "1234");
User user3 = new User(1L, "dale", "1234", null);

 

 

https://www.daleseo.com/lombok-popular-annotations/

참고 :

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-JPA-API%EA%B0%9C%EB%B0%9C-%EC%84%B1%EB%8A%A5%EC%B5%9C%EC%A0%81%ED%99%94/dashboard

 

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

스프링 부트와 JPA를 활용해서 API를 개발합니다. 그리고 JPA 극한의 성능 최적화 방법을 학습할 수 있습니다., - 강의 소개 | 인프런...

www.inflearn.com