관리 메뉴

나구리의 개발공부기록

API 개발 기본, 회원 등록 API, 회원 수정 API, 회원 조회 API, API 개발 고급 - 준비, API 개발 고급 소개, 조회용 샘플 데이터 입력 본문

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

API 개발 기본, 회원 등록 API, 회원 수정 API, 회원 조회 API, API 개발 고급 - 준비, API 개발 고급 소개, 조회용 샘플 데이터 입력

소소한나구리 2024. 10. 23. 16:53

출처 : 인프런 - 실전! 스프링부트와 JPA활용2 - API개발과 성능 최적화 (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용

 

** 스프링부트와 JPA활용1 - 웹 애플리케이션 개발에서 진행한 프로젝트를 가지고 이어서 진행


** API를 개발하기 때문에 Postman이 필수(그 외 비슷한 기능을 하는 프로그램이 있다면 준비)

1. 회원 등록 API

  • 화면을 템플릿 엔진으로 만드는 경우도 존재하지만 대부분은 클라이언트와 서버를 구분해서 개발하기에 API통신으로 개발하는 경우가 많음
  • 특히 추세(2019년 강의 기준)가 마이크로서비스 아키텍처로 바뀌어가면서 더 API로 통신해야 할 일이 늘어가고 있음

1) 패키지 작성

  • 코드 작성 전 패키지 작성을 위한 고민을 해야하는데, 김영한님은 주로 템플릿 엔진으로 렌더링하는 컨트롤러랑 API 스타일의 컨트롤러의 패키지를 분리하는 편이라고 함
  • 스프링으로 개발을 하다보면 공통으로 예외 처리등을 할 때 패키지나 구성 단위로 하게 되는데 화면을 렌더링하는 컨트롤러랑 API를 다루는 컨트롤러는 공통으로 처리해야되는 요소가 다르기 때문에 별도로 구성한다고 함

2) MemberApiController - saveMemberV1

(1) 코드 작성

  • API 응답을 위한 컨트롤러를 만들고 매개변수로 Entity를 직접 사용함
  • API 응답을 위한 클래스를 내부에 정의하여 id값을 반환하도록 설정
  • @PostMapping으로 지정한 url로 요청을 보내면 회원이 가입이 되고 응답값으로 id값이 반환됨
package jpabook.jpashop.api;

@RestController
@RequiredArgsConstructor
public class MemberApiController {

    private final MemberService memberService;

    /**
     * 요청 값으로 Member Entity를 직접 받음 - 문제 발생
     *  - 엔터티에 프레젠테이션 계층을 위한 로직이 추가됨
     *  - 엔터티에 API검증을 위한 로직이 들어감(@NotEmpty 등등)
     *  - 실무에서는 회원 엔터티를 위한 API가 다양하게 만들어지는데, 한 엔터티에 각각의 API를 위한 모든 요청 요구사항을 담기 어려움
     *  - 엔터티가 변경되면 API 스펙이 변함
     *  결론
     *   - API요청 스펙에 맞추어 별도의 DTO를 파라미터로 받아야 함
     */
    @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;
        }
    }
}

 

(2) Postman 실행

  • json으로 API요청을 보내면 정상적으로 응답이 돌아오는 것을 확인
  • 그러나 지금 구조에서는 API요청에 아무것도 없이 요청을 해도 정상적으로 DB에 null로 저장이 되어 회원이름이 없는 회원가입이 발생하였기에 Entity의 name 필드에 @NotEmpty 제약조건을 입력하여 name필드가 비어있지 않으면 에러가 발생하도록 변경

좌) 정상 요청 응답 / 우) 아무값도 없이 요청을 하여도 정상 응답이 된 모습

 

(3) Member Entity 수정 후 Postman 실행

  • Entity에 제약조건을 입력하여 name필드 없이 API를 요청하면 에러메세지가 응답으로 반환됨(스프링부트로 프로젝트를 빌드 했기 때문에 현재 에러메세지는 스프링 부트가 기본으로 제공하는 에러메세지임)
  • 그러나 여기에는 어마어마한 문제점이 숨어있는데, 바로 Entity를 API 컨트롤러의 파라미터로 직접 사용하고 있다는 것
public class Member {
    // ... 기존코드 생략

    @NotEmpty   // 꼭 값이 있어야하도록 설정
    private String name;
}

Entity에 @NotEmpty 제약조건을 걸어서 빈 값으로 요청을 보내면 에러응답이 옴, 즉 API스펙이 바뀜

 

(4) Entity를 Request Body에 직접 매핑했을 때의 문제점

  • 엔터티에 @NotEmpty제약조건을 적용한 것 자체가 나쁜 것은 아니지만, 해당 엔터티는 API 컨트롤러에서 뿐만아니라 화면을 렌더링하기위한 컨트롤러에서까지 함께사용하고 있으며 두 컨트롤러의 검증 로직이 해당 엔터티에 들어가있음
  • 즉, 어떤 컨트롤러에서는 @NotEmpty 제약조건이 필요 없을 수도 있고 어떤 컨트롤러에서는 필요할 수도 있고, API 컨트롤러끼리라고 하더라도 각각 다른 컨트롤러는 필요한 제약조건이 다를 수도 있는데, Entity를 직접 사용하면 동시에 적용되는 문제가 있음
  • 그리고 동시에 개발을 한다면 Entity는 여러팀에서 사용할 수 있기 때문에 언제든 변경될 수 있는데, Entity가 변경되는 즉시 API 스펙이 바뀌어버려서 API를 호출하는 클라이언트쪽에서는 갑자기 데이터를 받지 못하는 엄청난 장애가 발생될 수 있음
  • 결국 API스펙을 위한 별도의 DTO(Data Transfer Obejct)를 만들어서 컨트롤러에서는 만들어둔 DTO를 파라미터로 받도록 개발해야함
  • 실무에서는 정말 많은 회원 가입 케이스가 등장할텐데 Entity하나만으로 이것을 감당하기에는 절대 불가능하며 엔터티를 외부에 노출해서 개발한다는 것 자체가 조금만 애플리케이션이 복잡해지면 문제가 발생할 수밖에 없음

2) MemberApiController - saveMemberV2

  • API컨트롤러에 Entity를 직접 전달하는 것이 아니라 별도의 DTO 클래스를 만들어서 파라미터로 전달하고, 컨트롤러 메서드 안에서 Member를 생성하여 파라미터로 받은 값을 저장하도록 변경
  • 저장 후 반환값은 기존과 동일하게 id값을 반환함
  • 해당 컨트롤러를 변경을 하고 Postman으로 API요청을 보내보면 정상적으로 응답값이 잘 반환되는것을 확인할 수 있음
public class MemberApiController {
    // ... 기존코드 생략
    
    /**
     * 요청 값으로 Member Entity대신 별도의 DTO를 받음 - 문제 해결
     */
    @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;
        // 주소 생략...
    }
}

 

(1) DTO를 파라미터로 받는 API 컨트롤러의 장점

  • 만약 누군가 엔터티를 변경하게 되면 IDE에서 컴파일 오류가 발생하기 때문에 확인이 가능함 즉, 개발단계에서 이미 오류를 확인하고 대처할 수 있게 되어 API에 영향을 주지 않음
  • Entity로 직접 받게되면 API의 스펙 문서를 열어보지 않으면 어떤 로직을 거쳐서 어떤 값이 파라미터로 넘어오는지 확인하기가 번거로우나 DTO를 사용하게되면 해당 DTO만 확인하면 해당 컨트롤러에서 요구하는 API의 스펙과 제약사항을 바로 확인할 수 있기 때문에 유지보수하기가 좋음

(2) API 개발의 정석

  • API스펙을 명확하게 분리할 수 있고 Entity가 변경이 되어도 API스펙이 변하지 않는 장점이 있는 Entity가 아닌 DTO를 요청값으로 받도록 개발하는 것이 정석임
  • 실무에서는 엔터티를 외부에 노출하거나 엔터티를 파라미터로 직접 받아서 개발하는 행위는 사이드 이펙트가 어떻게 발생될지 모르기 때문에 절대 권장하지 않으며 이러한 방식때문에 사고가 굉장히 많이 났었음
  • API는 요청 및 응답을 절대 Entity를 사용하지 말 것

2. 회원 수정 API

** 강의 오류 정정

  • 강의에서 HTTP 메서드를 PUT방식을 사용했는데, PUT은 전체 업데이트를 할 때 사용하는 것이기에 부분 업데이트를 하려면 PATCH를 사용하거나 POST를 사용하는 것이 REST 스타일에 맞음
  • https://nagul2.tistory.com/137 강의 참고

1) updateMemberV2 컨트롤러 추가 및 MemberService 수정

(1) updateMemberV2 추가

  • API 컨트롤러를 정의한 클래스에 update를 위한 컨트롤러를 추가하고 매핑은 @PutMapping을 사용(강의 오류 정정 내용을 참고하여 부분 업데이트는 PATCH나 POST를 사용하는 것이 좋음)
  • 등록에 비해 수정은 매우 제한적이기 때문에 보통 API 스펙이 다르므로 별도의 DTO를 사용함
  • DTO에 생성자 관련 롬복 애노테이션을 사용하였는데, Entity는 조심스럽게 사용하지만 DTO는 데이터만 넘기는 것이기 때문에 롬복 애노테이션을 자주 사용함

** API를 위한 DTO 위치

  • 해당 예제는 API를 위한 DTO클래스들을 API Controller안에 Inner class(내부클래스)로 정의하였는데 inner class는 내부 클래스를 포함하는 클래스 안에서만 한정적으로 접근할 때 사용함
  • 개발자들이 신경써야하는 외부 클래스가 줄어드는 효과가 있고 응집력을 높이는 것과 비슷한 효과가 있음
  • 만약 해당 DTO가 여러 클래스에서 접근해야 하면 외부 클래스로 사용하는 것이 맞음

** 커맨드와 쿼리를 분리(CQS)

  • 개발 스타일 중 하나이며, 커맨드(명령)과 쿼리(조회)를 분리하여 불필요한 사이드 이펙트를 줄이기 위한 개발 설계 방법론
  • 만약 update쿼리에서 Member를 반환하게 되면, update쿼리에서 Member를 조회하게 되는 현상이 발생되는데 이것을 구분하여 update가 반환하는 값은 없거나 최소한의 id값만 반환하는것으로 설계하는 것
  • 자세한 내용은 CQS와 CQRS(조회와 쿼리를 분리하여 설계하는 디자인 패턴)를 검색하여 원문과 정리된 글을 참고
package jpabook.jpashop.api;

@RestController
@RequiredArgsConstructor
public class MemberApiController {
    // ... 기존 코드 생략

    /**
     * 회원 수정 API
     * 수정과 등록은 API 스펙이 거의 다름(수정은 제한적임)
     * 응답과 요청을 각각 별도로 생성(응답은 경우에따라 공용으로 사용할 수 있음)
     */
    @PutMapping("/api/v2/members/{id}")
    public UpdateMemberResponse updateMemberV2(@PathVariable("id") Long id,
                                               @RequestBody @Valid UpdateMemberRequest request) {

        // 수정할 때는 JPA 변경감지를 이용
        memberService.update(id, request.getName());

        /**
         * 위에서 작성한 memberService.update()의 메서드를 Member를 반환하도록 해도 되지만,
         * 커맨드와 쿼리를 분리한다는 정책을 따라서 조회를 별도로 한 뒤에 반환된 값을 가지고 응답을 생성
         * - 정답은 아니지만 이렇게 개발하면 유지보수가 용이해짐
         */
        Member findMember = memberService.findOne(id);
        return new UpdateMemberResponse(findMember.getId(), findMember.getName());
    }
	
    // 응답, 요청을 위한 inner 클래스들
    
    @Data
    @AllArgsConstructor
    static class UpdateMemberResponse {
        private Long id;
        private String name;
    }

    @Data
    static class UpdateMemberRequest {
        private String name;
    }
}

 

(2) MemberService - update() 추가

  • JPA에서의 변경은 merge()보다는 변경감지를 사용하는 것을 권장함
  • DB에서 값을 조회한 값은 영속성 컨텍스트에 저장되어 영속화 되어있는데, 해당값을 변경하면 해당 트랜잭션이 커밋되는 시점에 JPA가 변경감지를 확인하여 변경된 값을 DB에 반영함
public class MemberService {
    // ... 기존 코드 생략

    // update 기능 추가
    @Transactional
    public void update(Long id, String name) {
        Member member = memberRepository.findOne(id); // id값으로 DB에서 회원을 찾아서 반환 -> 영속상태
        member.setName(name); // 영속상태의 멤버를 setName으로 바꿔주고 트랜잭션이 커밋되면 변경감지를 확인하여 바뀐값을 DB에 반영됨
    }
}

 

(3) 실행 결과

  • 해당 컨트롤러 적용 후 Postman으로 실행을 해보면 정상적으로 등록된 값이 수정되는 것을 확인할 수 있음

좌) 회원 입력 / 우) 회원 수정(PUT)


3. 회원 조회 API

** 설정 변경

  • application.yml 설정의 ddl-auto를 none으로 변경 후 데이터를 입력한 뒤에 실습

1) 좋지 않은 버전 - 응답 값으로 엔터티를 직접 외부에 노출

(1) membersV1 추가

  • @GetMapping으로 매우 간단하게 조회된 멤버들을 List로 반환하는 컨트롤러
  • Member Entity를 직점 반환하기 때문에 많은 문제들이 노출됨
public class MemberApiController {
    // ... 기존코드 생략

    /**
     * 조회 V1 : 응답 값으로 엔터티를 직접 외부에 노출 - 문제발생
     *  - 엔터티에 프레젠테이션 계층을 위한 로직이 추가됨
     *  - 엔터티의 모든 값이 노출 됨(비밀번호 등 중요 정보들이 그대로 노출)
     *  - 응답 스펙을 맞추기 위해 엔터티에 로직이 추가됨(@JsonIgnore, 별도의 뷰 로직 등)
     *  - 실무에서는 같은 엔터티에 대해 API가 용도에 따라 다양하게 만들어지는데, 한 엔터티에 각각의 API를 위한 프리젠테이션 응답 로직을 담기는 어려움
     *  - 엔터티가 변경되면 API 스펙이 변함
     *  - 컬렉션을 직접 반환하게 되면 향후 API 스펙을 변경(확장)하기가 어려워짐 -> 별도의 반환타입 클래스를 생성하여 해결 
     * 결론은 회원가입 API와 동일
     *  - API 응답 스펙에 맞추어 별도의 DTO를 반환해야함
     *  - 화면에 종속적이게 설계하지 말것(@JsonIgnore 사용하여 해결하는 것은 최악의 방법)
     */
    @GetMapping("/api/v1/members")
    public List<Member> membersV1() {
        return memberService.findMembers();
    }
}

 

(2) Postman 결과 - 문제점 정리

  • 정상적으로 Member 정보들이 전송되지만 회원 정보를 요청했는데 회원 Entity의 정보들이 전부 반환되어버려서 Orders의 정보까지도 함께 반환되어 Entity의 전체 정보가 외부로 노출 되어버림
  • 이를 방지하기 위해 MemberEntity에 JSON으로 반환하고 싶지않은 필드에 @JsonIgnore라는 애노테이션을 붙인 후 다시 API 호출을 하면 정상적으로 Orders가 제거되고 호출되어 해결된 듯 하지만, 만약 다른 API에서 Order는 포함하고 Address는 빼달라고하면 해결한 방법이 없음
  • 즉, 제대로된 해결방법이 아니며 오히려 Entity에 화면과 API를 위한 제약조건이 추가되기 시작하여 엔터티로 의존관계가 쭉 들어오는게 아니라 엔터티에서 의존관계가 나가버리게되어 애플리케이션을 유지보수하고 수정하기가 매우 어려워짐
  • 회원등록 문제가 발생했던 API와 마찬가지로 Entity가 변경되면 API 스펙이 변경되어 오류가 발생되기도 하고 Entity의 정보가 직접 외부로 노출되어있기 때문에 비밀번호 등의 민감한 정보도 모두 그대로 노출 될 수 있으며 @JsonIgnore로 막는다고해도 모든 용도의 API요청을 위한 제약조건을 Entity하나로 대응하는 것은 절대 불가능함
  • 추가적으로 이렇게 컬렉션이나 배열로 직접 반환하게 되면 해당 API스펙을 변경하기가 어려워지기 때문에 컬렉션으로 응답값중의 하나로 설계되도록 해야함
  • 결론적으로 실무에서는 member엔터티의 데이터가 필요한 API가 계속 증가하게되어 API마다 요구 스펙이 달라질 수 있기 때문에 Entity가 아니라 각 API스펙에 맞는 DTO를 노출하도록 해야함

좌) orders 정보가 포함된 응답값 / 우) @JsonIgnore로 Entity에 제약을 걸은 응답값

2) 회원조회 V2 - 별도의 DTO 사용

(1) membersV2()

  • API 요구 스펙이 회원의 이름만 반환하는 것이라고 가정
  • 조회된 Member Entity들을 직접 반환하지 않고 별도로 생성한 DTO로 변환하여 List에 담고, API 응답을 담아서 반환할 클래스를 생성하여 객체를 생성 후 DTO로 변환한 값을 입력하여 최종 반환
  • 실무에서 stream 문법을 자주 사용하므로 숙지하는 것을 권장
public class MemberApiController {
    // ... 기존코드 생략  
    
    /**
     * Entity를 직접반환하지 않고 DTO로 회원을 변환하여 List에 담은 뒤,
     * 별도로 API를 반환하는 클래스를 정의하여 해당 객체에 DTO의 결과를 담아서 반환
     */
    @GetMapping("/api/v2/members")
    public Result membersV2() {
        // stream을 사용하여 전체조회한 회원을 MemberDto타입 List로 변환하여 반환
        List<MemberDTO> collect = memberService.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;    // API 요구 스펙이 이름정보만 반환하는 것으로 가정
    }
}

 

(2) Postman 결과 및 API 스펙 추가 후 결과

  • 변경된 컨트롤러로 변경 뒤 Postman으로 요청을 보내보면 API 요구 스펙대로 회원의 이름 정보만 반환되고, 향후 API 스펙이 변경되어도 쉽게 대응할 수 있도록 List가 data라는 필드의 값으로 출력됨
  • 회원가입 API때와 마찬가지로 Entity가 변경되면 IDE에서 컴파일오류가 발생하여 사전에 에러를 방지할 수 있음
  • 만약 여기에 count정보를 추가를 한다면 매우 간단하게 결과를 반환하는 Object타입 Result 클래스를 수정하여 컨트롤러에서 생성자의 값만 추가해주면 매우 간편하게 API 스펙을 변경하여 응답해줄 수 있음
  • 예를 들어 응답한 개수를 API 스펙에 추가한다고하면 아래처럼 간단한 수정으로 요구사항을 반영할 수 있음
  • 정리하자면 API를 개발할 때에는 조회든, 입력이든 DTO로 각 API의 스펙에 맞게 정의하여 개발해야만 함(강제)
@GetMapping("/api/v2/members")
public Result membersV2() {
    // stream 코드 동일 

    return new Result(collect.size(), collect);  // 변경된 API 스펙으로 반환
}

@Data
@AllArgsConstructor
static class Result<T> {
    private int count;  // count 추가
    private T data;
}

좌) 기존의 API 요청 응답 결과 / 우) API 스펙을 확장하여 응답한 결과


4. API 개발 고급 - 준비 / 소개 / 조회용 샘플 데이터 입력

** 설정 변경

  • application.yml 설정의 ddl-auto를 다시 create로 변경하여 애플리케이션 로딩때마다 샘플데이터가 입력되도록 설정

1) API 개발 고급편 내용 

  • 대부분의 현업에서 발생하는 문제는 쿼리가 너무 많이나가서 최적화가 되어있지 않거나, 레이지로딩 익셉션이 발생하거나 하는 문제인데 이러한 부분을 단계별로 해결하는 과정을 거처 학습을 하기 위함
  • 즉, 대부분의 성능문제는 최적화가 되어있지 않기 때문에 발생하므로 JPA를 사용할 때 발생하는 성능문제를 해결하는 방법의 모음을 학습함

2) 조회용 샘플 데이터 입력

  • 강의에 사용하기위한 샘플 데이터를 입력
  • 총 2건의 주문과, 4개의 상품을 입력
  • 예제이기때문에 단순하고 간편한 방법으로 샘플 데이터를 초기화
  • 초기화를 담당할 클래스를 만든 뒤 @PostConstruct를 메서드에 붙혀서 애플리케이션 실행 시 작성해둔 데이터가 초기화되도록 입력
  • 주문1, 2가 중복되는 코드가 많아 회원생성, 아이템생성, 주소생성은 별도의 메서드로 만들어 인자값으로 값들이 저장되도록 작성
package jpabook.jpashop;

/**
 * 총 주문 2개
 *  - userA (주문1)
 *    - JPA1 BOOK
 *    - JPA2 BOOK
 *  - userB (주문2)
 *    - SPRING1 BOOK
 *    - SPRING2 BOOK
 */
@Component  // 컴포넌트 대상으로 지정
@RequiredArgsConstructor
public class InitDb {

    private final InitService initService;

    @PostConstruct  // 애플리케이션 로딩시점에 메서드 호출
    public void init() {
        initService.dbInit1();
        initService.dbInit2();
    }

    @Component
    @Transactional
    @RequiredArgsConstructor
    static class InitService {
        private final EntityManager em;

        // 주문 1개 생성 메서드 - 회원과 책을 생성
        public void dbInit1() {
            Member member = createMember("userA", new Address("서울", "1", "1111"));
            em.persist(member);

            Book book1 = createBook("JPA1 BOOK", 10000, 100);
            em.persist(book1);

            Book book2 = createBook("JPA2 BOOK", 20000, 100);
            em.persist(book2);

            // 주문생성
            OrderItem orderItem1 = OrderItem.createOrderItem(book1, 10000, 1);
            OrderItem orderItem2 = OrderItem.createOrderItem(book2, 20000, 2);

            Delivery delivery = createDelivery(member);
            // ...가변인자 문법을 사용한 이유
            Order order = Order.createOrder(member, delivery, orderItem1, orderItem2);
            em.persist(order);
        }

        // 주문 1개 생성 메서드 - 회원과 책을 생성
        public void dbInit2() {
            Member member = createMember("userB", new Address("성남", "2", "2222"));
            em.persist(member);

            Book book1 = createBook("SPRING1 BOOK", 20000, 200);
            em.persist(book1);

            Book book2 = createBook("SPRING2 BOOK", 40000, 300);
            em.persist(book2);

            // 주문생성
            OrderItem orderItem1 = OrderItem.createOrderItem(book1, 20000, 3);
            OrderItem orderItem2 = OrderItem.createOrderItem(book2, 40000, 4);

            Delivery delivery = createDelivery(member);
            // ...가변인자 문법을 사용한 이유
            Order order = Order.createOrder(member, delivery, orderItem1, orderItem2);
            em.persist(order);
        }

        // Member 생성 메서드
        private Member createMember(String name, Address address) {
            Member member = new Member();
            member.setName(name);
            member.setAddress(address);
            return member;
        }

        // Book 생성 메서드
        private Book createBook(String name, int price, int quantity) {
            Book book = new Book();
            book.setName(name);
            book.setPrice(price);
            book.setStockQuantity(quantity);
            return book;
        }

        // 주소 생성 메서드
        private Delivery createDelivery(Member member) {
            Delivery delivery = new Delivery();
            delivery.setAddress(member.getAddress());   // 실제론 이렇게 안하겠지만 예제이므로 고객의 집주소를 배송주소로 지정
            return delivery;
        }
    }
}