JPA 활용2 배우는 내용 간단 요약

HwangBaco·2023년 5월 24일
1

요즘은 MSA로 구성하다보니, "서버사이드 렌더링"보다는 api 단위로 개발하게 된다.

JPA를 활용하여 api를 작성할 때에는 주의할 것이 많다.
-> JPA를 활용하여 api를 만들 때 어떤 방식이 올바른 방식인지 설명해주실 예정.

우선 서버에서 화면을 처리하는 것과, json을 처리하는 api와는 패키지를 구분하는게 좋다. (둘의 동작 방식이 다르기 때문)

@RestController


@Controller@ResponseBody 어노테이션을 클래스 레벨에 둘 다 선언해야 했던 것을 @RestController로 한 번에 처리할 수 있게 지원하고 있음.

@ResponseBody는 데이터를 json이나 xml 등으로 보낼 수 있도록 하는 어노테이션

@RestController
@RequiredArgsConstructor
public class MemberApiController {
    private final MemberService memberService;

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

    @Data
    static class CreateMemberResponse {
        private Long id;

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

}

근데 위 코드는 한 가지 주의할 점이 있다. Member 클래스를 그대로 post 방식의 requestBody 양식으로 지정하면서, Validation을 사용하기 위해서는 Member 클래스 내에 필수 값에 대하여 @NotEmpty를 추가해줘야 한다는 것이다.

// gradle.build 추가 필요
implementation 'org.springframework.boot:spring-boot-starter-validation'
@Entity
@Getter
@NoArgsConstructor(access = PROTECTED)
public class Member {
    @Id @GeneratedValue
    private Long id;

    @NotEmpty
    private String name;
...
}

만약 @NotEmpty를 설정하지 않는다면 @Valid가 붙어있는다 하더라도 아무 검증을 거칠 수 없고, 빈 요청을 보내고 Member가 등록되는 걸 확인할 수 있다.

문제 : 엔티티를 requestbody로 사용

근데, 여기서 문제가 있다.

Member는 엔티티다. 즉, DB에 저장되는 테이블을 저장하는 양식인데, 이를 controller에서 requestbody로 사용되면, 화면 구성을 위해 name필드에 @NotEmpty를 하게 되면 다른 api는 이 검증 로직이 필요하지 않은데도 이를 같이 가져가야 한다.(==프레젠테이션 로직) 또한 엔티티 필드 이름을 개발 편의상 살짝 변경하기라도 한다면, 이로 인해 api 스펙이 모두 바뀌게 된다. 즉, 프로그램의 확장성이 닫히고 변경에 열리게 되므로 OCP원칙을 준수하지 못하는 것이다.

프레젠테이션 계층이란 클라이언트의 요청/응답을 처리하는 계층으로 Controller가 이 계층에 속한다. 출처: velog

특히, 실무에서는 회원 가입도 간편가입, 소셜가입 등의 회원가입 케이스를 모두 다뤄야 하는데, 하나의 엔티티로 모든 경우에 대한 requestbody를 커버하겠다는건 프로그램의 유연성을 확보하지 못하는 판단일 수 있다.

또한 엔티티만 보면 api를 활용할 때 어떤 필드가 오갈지 예상이 어렵다. 그리고 모든 엔티티 필드가 외부에 노출된다는 치명적인 단점도 있다.

그리고 기존 entity 반환 방식으로 여러 엔티티를 반환하면, 엔티티 정보가 json화 되어 하나하나 담긴 array 형식으로 반환되는데, 응답 형식 중 count를 추가해달라고만 해도 해당 응답 형식이 깨져버리게 된다. 따라서 확장성을 닫아버리게 되는 결과를 초래한다. 이러한 점을 보완하기 위해서라도 Dto를 사용해야 한다.

기본 array 반환 형식 : 형태 변경 불가

    // 엔티티가 직접 반환되는(노출되는) 위험성 있는 api 버전
    @GetMapping("/api/v1/members")
    public List<Member> membersV1() {
        return memberService.findAll();
    }
{
  	{
		"id" : 1,
		"name" : "kim"
	},
	{
      	"id" : 2,
      	"name" : "hwang"
    }
}

dto 사용 버전

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

@GetMapping("/api/v2/members")
public Result memberListV2() {
	List<MemberDto> collect = memberService.findAll()
    .stream().map(m -> new MemberDto(m.getName()))
    .collect(Collectors.toList());
    return new Result(collect);
}
결과 json
{
  {
  	"count" : 2,
  	"data" : [
  		{
			"id" : 1,
			"name" : "kim"
		},
		{
      		"id" : 2,
      		"name" : "hwang"
    	}
	}
}

해결 : 엔티티 대신 dto를 사용

따라서 dto를 생성해야 하는 것이다. 이는 사실 선택이 아닌 필수이다.

여기서 dto란 data transfer object의 줄임말로, 보통 json으로 변환될 자바 객체인 response 객체 뿐만 아니라 request 객체를 모두 작성하여 사용한다. 뿐만 아니라 도메인 또는 서비스 로직에서도 이를 활용하고, 대개 DB에 입력할 때에만 엔티티를 조작하게 된다.

앞서 자연스레 설정한 CreateMemberResponse 또한 dto이다.

정 만들기 싫다면 Map을 이용하여 가볍게 사용하는 방법이 있는데, 이는 api 스펙이 명확하게 드러난다고 이해하기 어려우므로 가급적 혼자 토이프로젝트를 하는게 아닌 이상 지양하는게 좋다.

이때, dto의 값을 변경한다고 해서 그게 무조건 entity의 값을 변경하는 건 아니므로, entity에 비해 dto는 상대적으로 Lombok 어노테이션을 사용하는 것에 비교적 자유롭다.

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


    @Data
    static class CreateMemberResponse {
        private Long id;

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

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

회원 수정 API (PUT)


put은 멱등해야 한다. 즉, 해당 메서드를 여러 번 호출해도 input이 같다면 항상 같은 결과를 얻어야 한다.

이에 비해 유사한 수정 메서드인 patch는 멱등하지 않다고 한다.

아울러 일반적으로 post와 put은 그 request가 다르다. 수정은 대개 제한적으로만 이루어지기 때문이다. 따라서 requestDto를 별도로 작성해주면 좋다.

또한 수정 로직은 merge를 사용하게 두지 말고(merge는 수정을 위한 녀석이 아니라 준영속을 영속으로 바꾸는 것에 불과) 더티체킹을 이용해야 한다.

Q. 강의안에서 수정을 구현할 때, merge는 좋지 못한 전략이라고 해주셨는데 , 만약에 링크 남겨주신대로 save는 사용하지 않고 제가 임의대로 구현을해서 persist를 해서 id를 반환하는 메서드를 짜는게 좋을까요?

--
A. 정확히 표현하면 merge는 좋지 못한 전략이라기 보다는, merge의 원래 설계 목적은, update가 아니라 준영속 상태의 엔티티를 영속 상태를 바꾸는 것이 목적입니다. 그 목적에 맞게 사용되어야 하는데, 주변에 보면 남용되고 있는 사례를 많이 보았습니다. update는 merge 대신에 변경감지(drity checking)을 사용하는 것이 맞습니다.

커맨드와 쿼리를 철저히 분리하자.

서비스 등의 비즈니스 로직을 구성할 때, 생성과 조회, 수정은 모두 그 목적이 명확하고, 반환값을 위해 해당 커맨드와 관련이 없는 서비스 로직을 하나에 담는 것은 권장하지 않는다.

즉, 조회 서비스는 조회만, 수정 서비스는 수정 로직만 구성하고, controller단에서 dto 작성을 위한 엔티티 조회를 조회 메서드로, 그리고 수정은 수정 메서드를 이용하여서만 하는 것이 맞다는 이야기이다.

이는 결과적으로 메서드를 호출 했을 때, 내부에서 변경(사이드 이펙트)가 일어나는 메서드인지, 아니면 내부에서 변경이 전혀 일어나지 않는 메서드인지 명확히 분리하는 것이지요.

강사님께서 일반적으로 권장하는 방법은 insert는 id만 반환하고(아무것도 없으면 조회가 안되니), update는 아무것도 반환하지 않고, 조회는 내부의 변경이 없는 메서드로 설계하면 좋습니다^^

참고 : 인프런 답변

회원 조회 API


아래처럼 dto 없이 사용하면 불필요한 데이터까지 전부 노출되는 문제가 있다.

    // 엔티티가 직접 반환되는(노출되는) 위험성 있는 api 버전
    @GetMapping("/api/v1/members")
    public List<Member> membersV1() {
        return memberService.findAll();
    }
{
  	{
		"id" : 1,
		"name" : "kim"
	},
	{
      	"id" : 2,
      	"name" : "hwang"
    }
}

dto 사용 버전

이를 dto를 통해 바꾸면 다음과 같다. 위에서도 다룬 내용이다.

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

@GetMapping("/api/v2/members")
public Result memberListV2() {
	List<MemberDto> collect = memberService.findAll()
    .stream().map(m -> new MemberDto(m.getName()))
    .collect(Collectors.toList());
    return new Result(collect);
}
결과 json
{
  {
  	//"count" : 2,
  	"data" : [
  		{
			"id" : 1,
			"name" : "kim"
		},
		{
      		"id" : 2,
      		"name" : "hwang"
    	}
	}
}

JPA 성능최적화


X To One 성능 최적화

만약 문자열을 받아서 검색어로 활용되는 역할을 하는 OrderSearch라는 객체가 존재하고, 이를 활용하여 repository에서 관련되는 이름을 갖는 Order를 모두 불러온다고 해 봅시다.

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
    private final OrderRepository orderRepository;

    @GetMapping("/api/v1/simple-orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        return all;
    }
}

즉, 반환값이 Order 엔티티 그 자체인 api인 거죠. JPA는 이러한 작업을 수행하기 위해 아래 코드를 돌게 됩니다.

@Entity
@Table(name = "orders") // 이거 안하면 에러
@Getter
@NoArgsConstructor(access = PROTECTED)
public class Order {
    @Id
    @GeneratedValue
    private Long id;
    
    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
   	...
}
@Entity
@Getter
@NoArgsConstructor(access = PROTECTED)
public class Member {
    @Id @GeneratedValue
    private Long id;

    @NotEmpty
    private String name;

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

위 코드를 보시면 아시겠지만, order 객체 정보를 알아내기 위해선 member 객체를 알아야 하며, member 객체를 알려고 하니 order 객체를 알아야 하는 구조입니다. 즉, 양방향 참조로 구현되어 있어서 조회를 할 때 무한루프에 빠지게 되는 것이죠.

첫 번째 장벽 : 무한루프

그렇다면 엔티티를 반환하는 걸 하면 안된다 정도로 정리해야 하는 걸까요? 그 외에 무한루프를 해결할 순 없을까요? 역시 방법은 있습니다.
양방향이 걸리는 모든 엔티티 간 한 쪽 필드에 @JsonIgnore를 추가해줘야 합니다. 이렇게 하면 "반대 쪽으로는 정보를 안 불러와도 되는구나" 하고 인식해서 json 에는 반영이 안되겠지만 루프에 빠질 위험은 없게 됩니다.

이 때, 주인-종속 관계와 관계 없이 정보를 꼭 보여줘야 하는 엔티티의 필드를 제외한 상대 필드에 입력해주면 됩니다.

그러면 두 번째 장벽이 옵니다.

두 번째 장벽 : Type Definition Error

우리는 엔티티 조회 성능을 위해 모든 연관관계 주인이 갖는 종속 객체 fetchType을 LAZY로 설정합니다. 즉, Order를 불러올 때 DB에서 Member 데이터를 가져오지 않는 거죠. 대신 LAZY 설정으로 인해 프록시 객체를 넣어두게 됩니다. 최근에는 bytebuddy라는 라이브러리의 byteBuddyInterceptor 객체를 이용하여 hibernate에서 이 프록시 객체를 구현하고 있습니다.

하지만 엔티티를 Json으로 반환해야 하는 경우에는 해당 객체의 정보가 필요해서 조회를 하는 건데, 이 때에도 Jackson 라이브러리는 LAZY 설정으로 인하여 해당 객체를 상속받아 생성되는 프록시 객체를 json으로 어떻게 생성할 지 알지 못합니다. 자신이 모르고 있는 타입이라는 거죠.

이렇게 LAZY로 인해 문제가 발생한다고 판단하여 즉시 로딩(EAGER)으로 설정하면 안됩니다. 한 엔티티를 조회할 때 항상 연관관계의 객체 정보들을 조회하게 로직을 구성하게 되면 성능 문제가 강하게 발생할 수 있고, 이러한 조건에서는 성능 개선이 매우 어렵습니다. 언제나 LAZY를 사용하고, 여기서 더 성능을 개선할 때 fetch join을 사용하면 됩니다.

그래서 이 타입을 직접 빈에 등록해줘야 합니다. 이는 hibernate5JakartaModule에 있습니다.

implementation 'com.fastersml.jackson.datatype:jackson-datatype-hibernate5-jakarta'
@Bean
Hibernate5JakartaModule hibernate5JakartaModule() {
	return new Hibernate5JakartaModule();
}

직접 빈에 등록하면, 일단 객체 조회시에 이게 뭔 지는 알아 듣게 되는데, 이를 위 모듈이 null로 처리하라고 하기 때문에 조회할 때 null이 보이게 됩니다. 만약 보이게 하고 싶다면 위 모듈의 configure메서드를 통해 수정할 순 있습니다.

@Bean
Hibernate5JakartaModule hibernate5JakartaModule() {
	Hibernate5JakartaModule hibernate5JakartaModule = new Hinernate5JakartaModule();

	hibernate5JakartaModule.configure(Hibernate5JakarteModule.Feature.FORCE_LAZY_LOADING, true);
	return hibernate5JakartaModule;
	}

일단 이렇게 어거지로 구현할 순 있는데, 필요한 스펙 이외에 초과적으로 모든 엔티티 필드를 외부에 노출하게 되는 것과, 필드 조금만 수정해도 api 스펙이 달라질 수 있는 것이 문제이다.

또한 LAZY를 기껏 먹여놓고 이를 풀기 위해 적용을 하는 경우에는 성능 이슈도 존재하게 됩니다.

물론 force lazy loading 외에 for 문과 조건문 등을 이용해서 해당 객체를 조회하려고 하면 이를 DB에 접근해서 불러오기 때문에 이를 활용할 "수는" 있다. 하지만 dto를 무조건 권장한다.

@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {
	List<Order> all = orderRepository.findAllByString(new OrderSearch());
	for (Order order : all) {
    	order.getMember().getName(); // LAZY 강제 초기화
        order.getDelivery().getAddress(); // LAZY 강제 초기화
            /**
             * 위와 같이 코딩함으로써 프록시 대신 DB로부터 실제 객체를 불러오게끔 함.
             */
        } 
    return all;
}

따라서 이러한 모든 단점을 해결하도록 dto를 사용해야 합니다.

API를 기획할 때 필요한 정보만 정해서 DTO를 기획하는 것이 좋다.

v2 : 엔티티 대신 DTO 로 변환 + fetch join ~ X

    /**
     * V2. 엔티티를 조회해서 dto로 변환 (fetch join 사용x)
     * 단점 : 지연로딩으로 인해 쿼리가 N번 호출되어야 함
     */
    @GetMapping("/api/v2/simple-orders")
    public List<SimpleOrderDto> orderListV2() {
        List<Order> orderList = orderRepository.findAll();
        List<SimpleOrderDto> result = orderList.stream()
                .map(SimpleOrderDto::new)
//                .map(o -> new SimpleOrderDto(o))
                .collect(Collectors.toList());
        return result;
    }

    @Data
    static class SimpleOrderDto {
        private Long orderId;
        private String name;
        private LocalDateTime orderDate; // 주문 시간
        private OrderStatus orderStatus;
        private Address address;

        public SimpleOrderDto(Order order) {
            orderId = order.getId();
            name = order.getMember().getName();
            orderDate = order.getOrderDate();
            orderStatus = order.getOrderStatus();
            address = order.getDelivery().getAddress();
        }
    }

위 방법은 dto로 변환하는 가장 일반적인 방법이다. 우선, dto로 변환하게끔 구현하면 엔티티를 반환할때의 문제는 해결된다. 하지만 위처럼 로직을 구성할 경우에는 쿼리가 총 1+N+N번 실행된다.(== v1과 쿼리 수 동일 ; 이를 일반적으로 N+1 문제라고 한다. 여기서는 연관관계가 2개나 묶여있으니 1+N+N이 된 것이다.).

  • order 조회 1번 (N개의 객체 가져옴)
  • order -> member 지연 로딩 조회 N번
  • order -> delivery 지연 로딩 조회 N번
    그렇다면, order를 조회한 결과 객체가 4개라면, 최악의 경우 1+4+4번 실행되는 것입니다.

여기서, 지연 로딩은 영속성 컨텍스트에서 조회하기 때문에, 이미 조회된 경우에는 쿼리가 나가지 않는다. 즉, 만약 각 주문을 모두 같은 member가 했을 경우 처음 order를 조회하고 쿼리를 쏴서 member를 영속성 컨텍스트로 불러오면, 다음 주문 때에도 이 member가 주문했다는 상황이므로 굳이 DB에 쿼리를 날리지 않는다.

하지만 개발자는 항상 최악의 경우는 가정해야 하므로, 모든 주문에 모든 주문자가 다르고 모든 배송이 다른 경우에는 1+N+N번의 쿼리를 날려야 하는 것이다.

v3 : 엔티티 대신 DTO 로 변환 + fetch join ~ YES

    /**
     * V3. 엔티티를 조회하여 dto로 변환(fetch join 사용o)
     * fetch join으로 쿼리를 1번 호출
     * 참고 : fetch join에 대한 자세한 내용은 JPA 기본편에 있음
     */
    @GetMapping("/api/v3/simple-orders")
    public List<SimpleOrderDto> ordersV3() {
        List<Order> orderList = orderRepository.findAllWithMemberDelivery();
        List<SimpleOrderDto> result = orderList.stream()
                .map(o -> new SimpleOrderDto(o))
                .collect(Collectors.toList());
        return result;
    }
public interface OrderRepository extends JpaRepository<Order, Long> {
    @Query("SELECT o from Order o JOIN FETCH o.member m JOIN FETCH o.delivery d")
    public List<Order> findAllWithMemberDelivery();
}

위처럼 join fetch쿼리를 사용하면 쿼리 1번만 날려서 조회가 가능하다. fetch join으로 order -> member, order -> delivery는 이미 조회된 상태이므로 지연 로딩으로 인한 문제는 발생하지 않는 것입니다.

리포지토리에서 DTO 직접 조회

컨트롤러

/**
* V4. JPA에서 DTO로 바로 조회
* - 쿼리 1번 호출
* - select 절에서 원하는 데이터만 선택해서 조회
*/
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
	return orderRepository.findOrderDtoList();
}

레포지토리

@Query("SELECT NEW likelion.springbootBaco.domain.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.orderStatus, d.address) FROM Order o JOIN o.member m JOIN o.delivery d")
public List<OrderSimpleQueryDto> findOrderDtoList();

dto

@Data
public class OrderSimpleQueryDto {
    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;

    public OrderSimpleQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
    }
}

위 방식은 JPQL 문법을 이용하여 애초부터 DB에서 조회한 뒤에 불러오는 값을 DTO로 즉시 변환하게 하는 방법입니다. 이는 select 절에서 데이터를 직접 선택해서 반환하지 때문에 네트워크 용량을 (생각보다 미미하지만) 최적화할 수 있습니다.

하지만 이는 명확한 단점이 존재합니다. 바로, API 스펙에 맞춘 쿼리가 직접 레포지토리에 박힌다는 점 입니다. 이는 재사용성이나 확장성 측면에서 제한적입니다. 이렇게 강하게 결속되는 코드는 별도로 관리해주는 것이 좋다고 합니다. 기본 리포지토리는 순수 엔티티를 조회하는 용도로 사용하고, 지금 다루는 "dto로 바로 변환하는 등 강하게 결속된 쿼리를 보내는 레포지토리"는 별도로 관리하는 것이 직관적입니다.

따라서 JOIN FETCH로도 성능이 아쉬울 경우 시도해볼만한 방법이고, 만약 이 방법으로도 성능이 안나올 경우 JDBC Template 등을 사용해서 SQL을 직접 사용하는 방식으로 풀어내야 한다.

쿼리 방식 선택 순서 (권장)

  1. 일단 DTO 변환 방식으로 구현
  2. 필요시 JOIN FETCH 방식을 사용하여 성능 최적화
  3. 그래도 부족하다면 JPQL 문법을 활용하여 DB 조회시 바로 DTO 변환 사용
  4. 성능을 극한으로 올리기 위해서는 JPA가 제공하는 네이티브 SQL 또는 JDBC Template을 사용하여 SQL을 직접 사용해야 함
profile
알고리즘 풀이 아카이브

0개의 댓글