springboot

[springboot] 실전! 스프링부트와 JPA 활용2 - API 개발과 성능 최적화 - 1. 회원 API 개발

힝뿌 2024. 2. 28. 22:07
반응형

실전! 스프링부트와 JPA 활용1을 공부하고 블로그에 내용을 정리한 지..

벌써 몇 개월이 후딱 지나가버렸다.

그 사이, 많은 일들이 있었지만 서둘러 두 번째 내용을 정리하고 "자바 ORM 표준 JPA 프로그래밍" 책을 읽으며 공부하고자 한다.

 

 

회원 등록 API

"등록 CASE 1: 요청 값으로 Member Entity를 직접 받는다." ▶️ Request Body에 직접 매핑한다.

 

문제점)

- Entity에 프로젠테이션 계층을 위한 로직이 추가된다.

- Entity에 API 검증을 위한 로직이 들어간다. (@NotEmpty 등)

- 실무에서는 회원 entity를 위한 API가 다양하게 만들어지는데, 한 entity에 각각의 API를 위한 모든 요청 요구사항을 담기는 어렵다.

- Entity가 변경되면 API 스펙이 변한다.

 

결론) API 요청 스펙에 맞추어 별도의 DTO 파라미터로 받는다!

 

@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 CreateMemberRequest {
        private String name;
    }

    @Data
    static class CreateMemberResponse {
        private Long id;

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

 

 

"등록 CASE 2: 요청 값으로 Member entity 대신에 별도의 DTO를 받는다." ► RequestBody에 DTO를 매핑한다.

- CreateMemberRequest를 Member entity 대신에 RequestBody와 매핑한다.

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

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

- Entity가 변해도 API 스펙이 변하지 않는다.

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

 

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

 

 

 

 

회원 수정 API

회원 수정도 DTO를 요청 parameter에 매핑

/**
     * 수정 API
     */
    @PatchMapping("/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;
    }

 

 

변경 감지를 사용해서 데이터를 수정

public class MemberService {

    private final MemberRepository memberRepository;

    /**
     * 회원 수정
     */
    @Transactional
    public void update(Long id, String name) {
        Member member = memberRepository.findOne(id);
        member.setName(name);
    }
}

 

 

 

회원 조회 API

"회원 조회 CASE 1: 응답 값으로 Entity를 직접 외부에 노출" 

 

문제점)

- Entity에 프로젠테이션 계층을 위한 로직이 추가된다.

- 기본적으로 entity의 모든 값이 노출된다.

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

- 실무에서는 같은 entity에 대해 API가 용도에 따라 다양하게 만들어지는데, 한 entity에 각각의 API를 위한 프레젠테이션 응답 로직을 담기는 어렵다.

- Entity가 변경되면 API 스펙이 변한다.

- 추가로 컬렉션을 직접 반환하면 향후 API 스펙을 변경하기 어렵다. (별도의 Result 클래스 생성으로 해결)

 

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

 

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

 

public class MemberApiController {

    private final MemberService memberService;

    // 조회 CASE1: 안 좋은 버전, 모든 entity가 노출, @JsonIgnore -> 이건 정말 최악, api가 이거 하나인가! 화면에 종속적이지 마라!
    @GetMapping("/api/v1/members")
    public List<Member> membersV1() {
        return memberService.findMembers();
    }
}

 

 

"회원 조회 CASE2: 응답 값으로 Entity가 아닌 별도의 DTO 사용"

 

- Entity를 DTO로 변환해서 반환한다.

- Entity가 변해도 API 스펙이 변경되지 않는다.

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

 /**
     * 조회 CASE2: 응답 값으로 엔티티가 아닌 별도의 DTO를 반환한다.
     */
    @GetMapping("/api/v2/members")
    public Result membersV2() {
        List<Member> findMembers = memberService.findMembers();
        // Entity -> 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;
    }

 

 

 

그동안 내가 springboot와 JPA를 이용해서 개발을 해온 것을 생각해 보면....

나는 정말 좋지 않은 CASE의 방법을 사용하고 있었다....

요청 파라미터에는 DTO를 만드는 방식으로 잘해왔지만, 응답 값으로는 Entity를 직접 외부에 노출하고 있었던 것이다....!!

물론, Return type을 Response DTO를 만들어서 반환하는 로직도 있었지만 대부분은 외부에 직접 노출....

특히 좋지 않은 CASE를 내가 그대로 사용하고 있었던 것에 충-격😳

 

나의 코드를 살펴보면 Entity에 @JsonIgnore이 덕지덕지.. 이 강의를 보고 깨달았다.. 잘못된 방식임을...

또한, 제네릭을 이용해서도 응답을 해줄 수 있다는 사실을 새롭게 알게 되었다. 왜 저런 생각을 못했을까?

 

지난 1탄보다 정말.. 많은 것을 깨닫게 되는 시간이었다.

현재 진행하고 있는 프로젝트에서도 이와 같은 방법을 사용할 수 있도록 리펙토링을 진행해야겠다.

반응형