JPA 성능 튜닝 #1 ~ToOne

PEPPERMINT100·3일 전
0
post-thumbnail

서론

JPA와 같은 ORM을 사용하다보면 개발자의 의도와 다른 쿼리가 생성될 수 있다. 이유라고 하면 Java는 객체지향 언어이고 SQL은 Structured Query Language로 어떤 데이터를 바라보는 방식, 패러다임이 다르기 때문이다.

또 이 패러다임을 JPA만의 방식, 영속성 컨텍스트가 자동으로 영속화된 데이터를 적당한 타이밍에 질의하기 때문에 공부를 잘 해두지 않는다면 예상치 못한 쿼리가 성능을 망가뜨릴 수도 있다.

일반적으로 Insert 쿼리라면 JPA 사용으로 큰 성능저하가 일어나지 않을 수 있지만 Select, 조회 쿼리가 문제가 되는 경우가 많다.

김영한님의 JPA 고급 강의의 커리큘럼을 토대로 실제로 현업에서 겪은 문제를 조금 녹여서 공부한 내용을 정리해보려고 한다.

~ToOne에서 생기는 문제

~ToOne은 JPA의 엔티티에서 다른 엔티티와의 다대일 혹은 일대일 관계를 표시해주는 어노테이션이다.

@Entity
@Getter @Setter
public class Member {

    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;
    
    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();

}

이런 형태의 엔티티가 있다고 하자. 멤버는 Order라는 객체를 프로퍼티로 갖는다.


@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
}

Order는 또다시 어떤 사람이 주문을 넣었는지에 대한 Member 레퍼런스를 갖는다.

이렇게 엔티티가 설계된 상태에서 member 객체에 대고 findAll() 의 실행결과를 API로 만들어서 포스트맨으로 요청하면 아래와 같은 결과가 나온다.

[
    {
        "id": 1,
        "name": "userA",
        "address": {
            "city": "서울",
            "street": "1",
            "zipcode": "1111"
        },
        "orders": [
            {
                "id": 4,
                "member": {
                    "id": 1,
                    "name": "userA",
                    "address": {
                        "city": "서울",
                        "street": "1",
                        "zipcode": "1111"
                    },
                    "orders": [
                        {
                            "id": 4,
                            "member": {
                                "id": 1,
                                "name": "userA",
                                "address": {
                                    "city": "서울",
                                    "street": "1",
                                    "zipcode": "1111"
                                },
                                "orders": [
                                    {
                                        "id": 4,
                                        "member": {
                                            "id": 1,
                                            "name": "userA",
                                            "address": {
                                                "city": "서울",
                                                "street": "1",
                                                "zipcode": "1111"
                                            },
                                            "orders": [
                                                {
                                                    "id": 4,
                                                    "member": {
                                                        "id": 1,
                                                        "name": "userA",
                                                        "address": {
                                                            "city": "서울",
                                                            "street": "1",
                                                            "zipcode": "1111"
                                                        },
                                                        "orders": [
                                                            {
                                                                "id": 4,
                                                                "member": {
                                                                    "id": 1,
                                                                    "name": "userA",
                                                                    "address": {
                                                                        "city": "서울",
                                                                        "street": "1",
                                                                        "zipcode": "1111"
                                                                    },
                                                                    "orders": [
                                                                        {
                                                                            "id": 4,
                                                                            "member": {
                                                                                "id": 1,
                                                                                "name": "userA",
                                                                                "address": {
                                                                                    "city": "서울",
                                                                                    "street": "1",
                                                                                    "zipcode": "1111"
                                                                                },
                                                                                "orders": [
                                                                                    {
                                                                                        "id": 4,
                                                                                        "member": {
                                                                                            "id": 1,
                                                                                            "name": "userA",
                                                                                            "address": {
                                                                                                "city": "서울",
                                                                                                "street": "1",
                                                                                                "zipcode": "1111"
                                                                                            },
                                                                                            "orders": [
                                                                                                {
                                                                                                    "id": 4,
                                                                                                    "member": {
                                                                                                        "id": 1,
                                                                                                        "name": "userA",
                                                                                                        "address": {
                                                                                                            "city": "서울",
                                                                                                            "street": "1",
                                                                                                            "zipcode": "1111"
                                                                                                        },
                                                                                                        "orders": [
                                                                                                            {
                                                                                                                "id": 4,
                                                                                                                "member": {
                                                                                                                    "id": 1,
                                                                                                                    "name": "userA",
                                                                                                                    "address": {
                                                                                                                        "city": "서울",
                                                                                                                        "street": "1",
                                                                                                                        "zipcode": "1111"
                                                                                                                    },
                                                                                                                    "orders": [
                                                                                                                        {
                                                                                                                            "id": 4,
                                                                                                                            "member": {
                                                                                                                                "id": 1,
                                                                                                                                "name": "userA",
                                                                                                                                "address": {
                                                                                                                                    "city": "서울",
                                                                                                                                    "street": "1",
                                                                                                                                    "zipcode": "1111"
                                                                                                                                },
                                                                                                                                "orders": [
                                                                                                                                    {
                                                                                                                                        "id": 4,
                                                                                                                                        "member": {
                                                                                                                                            "id": 1,
                                                                                                                                            "name": "userA",
                                                                                                                                            "address": {
                                                                                                                                                "city": "서울",
                                                                                                                                                "street": "1",
                                                                                                                                                "zipcode": "1111"
                                                                                                                                            },
                                                                                                                                            "orders": [
                                                                                                                             

굉장히 긴데, 실제로는 계속해서 무한정으로 늘어나고 엄청 길어진다. 이렇게 API를 만들면 사용할 수 없다.

이유는 Member와 Order의 관계에 있다. Member는 List<Order> 를 프로퍼티로 갖고 Order는 다시 Member 를 프로퍼티로 갖는다.

이를 양방향 연관관계라고 하는데, 이런 경우에서 쿼리를 날리면 JPA는 최초에 Member를 조회하고 그 안에 Order에 대해서 쿼리를 날리고, Order안의 Member를 조회하기 위해 쿼리를 날리고 또 다시 그 Member의 Order를 조회하기 위해서 쿼리를 날리고… 계속 반복하는 무한 루프가 생성된다.

이러한 문제점을 해결하는 방법을 알아보자.

JsonIgnore

가장 심플한 방법은 @JsonIgnore 를 양방향 연관관계를 만드는 프로퍼티에 붙이는 것이다.

Spring에서는 Jackson이라는 라이브러리가 Java 객체를 Json으로 만들어주고, Json을 자바 객체로 매핑해주는 일을 해주는데, 그 Jackson의 @JsonIgnore 를 사용하면 해당 프로퍼티를 Json으로 변환하지 않는다.

@Entity
@Getter @Setter
public class Member {

    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;
    
    @JsonIgnore
    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();

}

이렇게하면 쿼리를 날릴 때 Order를 건들지 않기 때문에 Member를 가져올 때 더이상 Order를 가져오려고 하지 않기 때문에 루프가 끊긴다.

하지만 이 방법도 완벽하지는 않다. 모든 엔티티에서 양방향 연관관계를 만드는 프로퍼티마다 해당 어노테이션을 붙여줘야하고, 또 만약 Member를 가져오는게 아니라 Order를 가져오는 JPA 메소드를 만들면 프록시 관련 에러가 나온다.

@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
}

JPA에서 LAZY 방식으로 객체를 가져오면 그 객체에 실제로 접근할 때 Member에 대한 쿼리가 나가도록 하는데, 그 전까지는 프록시를 가지고 있게 된다. 만약 이 프록시를 API에 반환하려고 하면 역시 에러가 발생하게 된다.

이를 예방하려면

	@Bean
	Hibernate5Module hibernate5Module() {
		Hibernate5Module hibernate5Module = new Hibernate5Module();
		hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
		return hibernate5Module;
	}

이러한 빈을 등록해도 된다. 이렇게 하면 API 응답을 위해 Json으로 변환할 때 LAZY 로딩을 강제로 해버릴 수 있다. 다만 이 빈을 사용하면 꼭 필요하지 않은 LAZY 로딩 프로퍼티도 전부 가져오려고 하기 때문에 많은 select 쿼리가 발생할 수 있다.

이렇게 한번에 모든 프로퍼티를 가져오기 위해 Fetch 전략을 Lazy가 아닌 Eager로 설정할 수도 있는데, 이 역시 성능의 관점에서 건드릴 수 있는 부분이 더 적어지기 때문에, 이 엔티티는 모든 시점에서 자식 엔티티를 필요로 하는 경우가 아니면 Lazy 전략을 디폴트로 해주는게 좋다.

Lazy 강제 로딩

위처럼 Hibernate 모듈을 전체적으로 커스텀해버리는것은 좋지 않다. 따라서 필요한 쿼리만 날리기 위해서는 모든 프로퍼티를 Lazy 로딩해버리지 않고 필요한 부분만 로딩하는 방법이 있다.

List<Order> all = orderRepository.findAll();
for (Order order : all) {
    order.getMember().getName(); //Lazy 강제 초기화
}
return all;

이런식으로 Lazy 처리가 된 member에 접근해서 강제로 Lazy를 초기화해버리면 필요한 부분만 그때 그때 쿼리를 날려서 가져올 수도 있다.

DTO의 사용

위의 Lazy 강제 로딩을 활용해서 DTO에 주입하는 방법도 있다.

  @Data
  static class OrderDto {
      private Long orderId;
      private String name;

      public SimpleOrderDto(Order order) {
          orderId = order.getId();
          name = order.getMember().getName();
      }
  }

이렇게 DTO를 만들어서 Entity의 값들을 안에 주입해준다. 이렇게 하면 외부(API, DB 등..)와 엔티티 간의 연관관계를 DTO로 끊어내면서 Lazy에 해당하는 프로퍼티를 강제로 로드할 수 있다.

다만 이 방식으로 생성 되는 쿼리는

select
    order0_.order_id as order_id1_6_,
    order0_.delivery_id as delivery4_6_,
    order0_.member_id as member_i5_6_,
    order0_.order_date as order_da2_6_,
    order0_.status as status3_6_ 
from
    orders order0_
                   : 
select
    member0_.member_id as member_i1_4_0_,
    member0_.city as city2_4_0_,
    member0_.street as street3_4_0_,
    member0_.zipcode as zipcode4_4_0_,
    member0_.name as name5_4_0_ 
from
    member member0_ 
where
    member0_.member_id in (
        ?, ?
    )

이런식으로 생성된다. Order를 조회하고 Order의 프로퍼티인 Member를 또 쿼리한다. 즉 두 개의 쿼리가 나간다.

만약 Order에 Member 뿐만 아니라 다양한 프로퍼티가 더 있다고하면 더 많은 쿼리가 나갈 것이다.

즉 Order 1개를 가져오기 위해 프로퍼티 개수 만큼 더 쿼리가 날아가고 이 프로퍼티의 개수가 N이라고 하면 총 N + 1개의 쿼리가 나가고 이게 JPA에서 유명한 N + 1 문제이다.

Fetch join

Fetch Join은 JPA의 SQL언어인 JPQL에서 지원하는 특별한 예약 Join 명령어이다.

조금 더 효율적인 쿼리를 보기 위해 Order 객체에 새로운 프로퍼티를 추가하였다.

@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id @GeneratedValue
    @Column(name = "order_id")
    private Long id;
    
    @OneToOne(fetch = LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
}

Delivery는 주문에 대한 정보를 나타내고 Order 마다 하나씩 존재한다. 즉 ~ToOne에 해당된다. Order를 JPA로 조회하고, delivery, member를 Lazy 강제 로딩을 하면 Order 이외에 Delivery, Member를 위해 두 개의 쿼리가 더 나가게 된다.(N=2)

이제 Fetch Join을 사용한 코드를 실제로 작성하면

    public List<Order> findAllWithItem() {
        return em.createQuery(
			            "select distinct o from Order o" +
                  " join fetch o.member m" +
                  " join fetch o.delivery d" +
                  , Order.class)
                .getResultList();
    }

위처럼 작성할 수 있다. fetch를 통해서 member, delivery를 통해서 미리 가져올 수 있다.

    select
        order0_.order_id as order_id1_6_0_,
        member1_.member_id as member_i1_4_1_,
        delivery2_.delivery_id as delivery1_2_2_,
        order0_.delivery_id as delivery4_6_0_,
        order0_.member_id as member_i5_6_0_,
        order0_.order_date as order_da2_6_0_,
        order0_.status as status3_6_0_,
        delivery2_.city as city2_2_2_,
        delivery2_.street as street3_2_2_,
        delivery2_.zipcode as zipcode4_2_2_,
        delivery2_.status as status5_2_2_ 
    from
        orders order0_ 
    inner join
        member member1_ 
            on order0_.member_id=member1_.member_id 
    inner join
        delivery delivery2_ 
            on order0_.delivery_id=delivery2_.delivery_id

그러면 결과적으로 위처럼 Inner join을 사용하여 한 번의 쿼리로 데이터를 잘 가져오는 것을 볼 수 있다.

이렇게 하니 SQL을 직접 작성해서 Java 객체에 하나하나 매핑하는 수고를 JPA가 성능까지 챙기면서 간단하게 만들어주는 것을 확인할 수 있다.

DTO에 직접 매핑

fetch join이 아니라 실제로 JPQL로 쿼리한 값들을 DTO에 바로 넣어버릴 수도 있다.

    private List<OrderQueryDto> findOrders() {
        return em.createQuery(
                "select new jpabook.jpashop.repository.order.query.OrderQueryDto(o.id, m.name, o.orderDate)" +
                        " from Order o" +
                        " join o.member m" +
                        " join o.delivery d", OrderQueryDto.class)
                .getResultList();
    }

이 방식을 사용해도 자연스럽게 inner join을 사용해서 한 번의 쿼리로 결과를 가져올 수 있다. 또 필요한 정보만 DTO에 매핑할 수 있지만, 실제로 이 방식을 현업에서 많이 사용해봤는데 해당 DTO를 만드는 생성자를 여러개 작업해주어야 한다는 단점이 있었다.

수 많은 생성자가 생기다보니 또 해당 DTO에 대한 신뢰성이 깨지는 느낌이 많이 들었다.

또 사용된 DTO가 변경되면 해당 쿼리를 수정해줘야 하는 경우가 생겼고 심지어 문자열이라 조금 조심스럽게 작성해야 하는 점도 있었다.

즉 필요한 컬럼만 가져와서 성능면에서 Fetch Join보다 유리하지만 재사용성, 유지보수성이 확실히 떨어진다.

강의를 보니 Repository에서 영속성 계층이 깨지는게 불안정하기도 하고 요즘의 네트워크 bandwidth, DB의 성능이 괜찮기 때문에 이 정도 성능 차이는 미미하다고 한다.

일할 때도 같은 고민을 많이 했는데, 답이 없다고 느꼈었다. 김영한님도 이런 고민을 했고, 결국 트레이드오프라는 결론을 내주셨다. 그때 내가 했던 고민들이 헛된 시간이 아니고, 갈팡질팡하는게 맞았구나! 라는 생각에 기분이 미묘하게 좋다.

코드를 입력하세요
profile
기억하기 위해 혹은 잊어버리기 위해 글을 씁니다.
post-custom-banner

0개의 댓글