백엔드/JPA

[JPA 활용2] [1] API 개발 기본

RE-Heat 2023. 8. 30. 20:58

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

인프런 김영한 님의 강의를 듣고 작성한 글입니다.

 

[1] 회원 등록 API

MemberApiController - 회원등록 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
    @AllArgsConstructor
    static class CreateMemberResponse {
        private Long id;

    }
  • @RestController = @Controller + @ResponseBody => JSON 반환 API용
  • @RequestBody : 클라이언트에서 json 형식으로 데이터가 들어온다고 가정한다.
  • @Valid : 요청 데이터를 받을 때 발생되는 오류 처리
  • CreateMemberResponse 리턴용 객체 => Spring이 알아서 JSON화 해서 리턴해준다.

 

 

실행화면 - @NotEmpty 적용 이전

  • 값을 넣어주지 않아도 id값이 부여되는 것을 확인할 수 있다.
  • 이를 방지하기 위해선 반환한 Member의 name필드에 @NotEmpty 애노테이션을 붙여주면 된다.

 

참고] @NotBlank vs @NotEmpty vs @NotNull

  • @NotBlank : null, "", " " 모두 허용하지 않는다.
  • @NotEmpty : null과 "" 허용하지 않는다. 하지만 " "는 허용한다.
  • @NotNull : null은 허용하지 않지만, "", " "는 허용한다.

 

실행화면 - @NotEmpty 적용 후

스프링 에러 기본값으로 반환.

API 예외처리 관련 내용은 스프링 MVC 2편 - [9] API 예외 처리 참조 

 

■ V1 : 엔티티를 RequestBody에 직접 매핑하면 생기는 문제점

  • 엔티티에 프레젠테이션 계층을 위한 로직이 추가된다.
  • 엔티티에 API 검증을 위 한 로직이 들어간다. (@NotEmpty 등등)
  • 실무에서는 회원 엔티티를 위한 API가 다양하게 만들어지는데, 한 엔티티에 각각의 API를 위한 모 
    든 요청·요구사항을 담기는 어렵다.
  • 엔티티가 변경되면 API 스펙이 변한다.
    • 예를 들어 String name이 String username으로 변경되면 클라이언트 측에서 보내는 API 요청 데이터를 전부 바꿔야 하는 번거로움이 발생한다.

 

결론 : API 요청 스펙에 맞춰 별도의 DTO를 파라미터로 받는다.

 

 

MemberApiController - 회원등록 V2(엔티티 대신 DTO를 RequestBody에 매핑)

    @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 {
        private String name;
    }
  • Member 엔티티 대신 CreateMemberRequest를 RequestBody와 매핑한다. 
  • 엔티티와 프레젠테이션 계층을 위한 로직을 분리할 수 있다.
    • ex) @NotEmpty를 부담 가지지 않고 Dto에 붙일 수 있다.
  • 엔티티와 API 스펙을 명확하게 분리할 수 있다.
    • 클라이언트에 요구하는 다양한 스펙도 전용 DTO로 맞춰줄 수 있다. 
  • 엔티티가 변해도 API 스펙이 변하지 않는다.
    • DTO에서 엔티티를 스펙에 맞게 변환해 주는 과정이 있어 엔티티에 변경점이 생겨도 문제가 없다.

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

 

[2] 회원 수정 API

MemberApiController - 회원 수정

//회원 수정
@PutMapping("/api/v2/members/{id}")
public UpdateMemberResponse updateMemberV2(
        @PathVariable("id") Long id,
        @RequestBody @Valid UpdateMemberRequest request) {
    memberService.update(id, request.getName());
    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;
}
  • 요청·응답용 DTO 추가
    • 영한님 : 엔티티는 @Getter 정도만 사용하나, DTO는 @Data 등 롬복 애노테이션 자유롭게 사용한다.
  • 수정 목적이므로 @PutMapping 사용
  • MemberService에 update 메소드 추가

참고] void가 아닌 Member객체로 반환하면 커맨드(update는 커맨드 메서드)와 쿼리(조회)가 같이 있는 꼴이다. 따라서 update 메소드는 변경감지로 update만 시켜주고 Controller에선 바뀐 값을 조회하는 식으로 확실하게 분리해 준다. 

 

 

[3] 회원 조회 API

MemberApiController - 회원 조회 V1

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

조회 V1 : 응답 값으로 엔티티(Member)를 직접 외부에 노출한 버전이다.

 

■ 문제점

  • 엔티티에 프레젠테이션 계층을 위한 로직이 추가된다. 
  • 기본적으로 엔티티의 모든 값이 노출된다.

원하지 않는 orders도 노출된다. 또 패스워드까지 노출되는 케이스를 생각해 보자.

  • 응답 스펙을 맞추기 위해 로직이 추가된다. (@JsonIgnore, 별도의 뷰 로직 등등)

  • 실무에서는 같은 엔티티에 대해 API가 용도에 따라 다양하게 만들어지는데, 한 엔티티에 각각의 
    API를 위한 프레젠테이션 응답 로직을 담기는 어렵다.
    • 어떤 클라이언트는 orders를 노출하고 싶다고 하면 일이 복잡해진다.
  • 엔티티가 변경되면 API 스펙이 변한다.
    • 앞서 말한 String name => String username으로 바뀌는 케이스를 떠올려 보자.
  • 컬렉션을 직접 반환하면 향후 API 스펙을 변경하기 어렵다.
    • List<Member>를 그대로 반환하면 [] 배열 형태로 반환돼 더는 확장할 수 없다. 예를 들어 컬렉션 크기인 count 데이터를 추가하고 싶다고 해도 이런 상황에선 할 수 없다.

 

 

해결책 : 별도의 DTO를 사용하자!

 

MemberApiController - 회원 조회 API V2

 

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

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

@Data
@AllArgsConstructor
static class MemberDTO {
    private String name;
}
  • MemberDTO로 반환 (List<Member> => List<MemberDTO>)
  • Result<T> : 컬렉션을 직접 반환하면 확장하기 어려우므로 추가로 Result 클래스로 컬렉션을 감싸서 반환한다.

 

실행결과

 

■ []로 닫힌 형태가 아니기 때문에 count값을 추가할 수 있다.

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.size(), collect);
}

@Data
@AllArgsConstructor
static class Result<T> {
	private int count;
	private T data;
}
  • Result에 int count 추가 후 그 값에 컬렉션 size를 넣어줌

 

실행결과

 

최종결론 : 엔티티를 외부에 노출하지 마라!!(추천이 아닌 필수사항)