pooney
article thumbnail
Published 2022. 4. 24. 21:28
JPA - OneToOne관계 N+1문제 JPA

 

안녕하세요.  오늘은 JPA 작업을 하시면서 발생하는 양방향 OneToOne 관계에서 주인과 주인이아닌 LazyLoding(지연로딩)으로 설정 했지만 적용이 안되고 eagerLoding(즉시로딩)으로 조회해서 발생하는 흔히 말하는 N+1문제가 왜 발생하는지 알아 보려고 합니다.  이문제는 정확히는 주인관계에서는 LazyLoding이 되지만 주인이 아닌 Entity에서 조회를 할시 eagerLoding 발생하는 문제입니다.   그러면 이제 시작하겠습니다.

 

아래의 연관간계는 쉬운 설명을 위하여 편하게 관계를 맺은점을 참고 해주셨으면 합니다. 

 

 

 

 

 

 

 

연관관계 설정 및 TEST_CASE 작성 

 

 

 

 

다이어그램

 

 

 

product 테이블

 

 

product_desc 테이블

 

 

 

 

 

 

 

Entity 구성은 2개 Product(상품)과 Product_Desc(상품설명)으로 구성되며 연관관계의 주인은 product로 위의 다이어그램을 보시면 상품과 상품설명은 1:1 관계로 서로 양방향 관계를 볼 수 있습니다. 

 

 

 

 

 

 

Product(상품)

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long prdIdx;

    @Column(name = "prd_name")
    String prdName;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "dsec_idx")
    ProductDesc productDesc;
}

 

 

 

ProductDesc(상품설명)

@Entity
@Table(name = "product_desc")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ProductDesc {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long descIdx;

    @Column(name = "prd_desc")
    String prdDesc;

    @OneToOne(mappedBy = "productDesc",fetch = FetchType.LAZY)
    Product product ;
}

 

 

 

TEST_CASE

@SpringBootTest(properties = "spring.config.location=classpath:/application.yml")
class TestServiceTest {
    @Autowired
    EntityManager entityManager;
    @Autowired
    ProductRepo productRepo;
    @Autowired
    ProductDescRepo productDescRepo;
    @Autowired
    JPAQueryFactory jpaQueryFactory;



    @BeforeEach
    public void before_lazy() {
        ProductDesc productDesc = ProductDesc.builder()
                .prdDesc("상품1 DESC 입니다.")
                .build();
        ProductDesc product2Desc = ProductDesc.builder()
                .prdDesc("상품2 DESC 입니다.")
                .build();

        productDescRepo.save(productDesc);
        productDescRepo.save(product2Desc);

        Product product1 = Product.builder()
                .prdName("상품1")
                .productDesc(productDesc)
                .build();

        Product product2 = Product.builder()
                .prdName("상품2")
                .productDesc(product2Desc)
                .build();

        productRepo.save(product1);
        productRepo.save(product2);
    }
	
    @Test
    @Rollback(false)
    @Transactional
    public void OneToOne_양방향관계_LAZY_LODOING_주인() {
        System.out.println("============== 연관관계의 주인 LazyLoding start ================");
        List<Product> productList = jpaQueryFactory.selectFrom(product).fetch();
        productList.stream().forEach(item -> {
            System.out.println("[상품명]  : " + item.getPrdName());
        });
        System.out.println("============== 연관관계의 주인 LazyLoding end ================");
    }

    @Test
    @Rollback(false)
    @Transactional
    public void OneToOne_양방향관계_LAZY_LODOING_NO주인() {
        System.out.println("============== 연관관계의 NO 주인 LazyLoding start ================");
        List<ProductDesc> productDescList = jpaQueryFactory.selectFrom(productDesc).fetch();
        productDescList.stream().forEach(item -> {
            System.out.println("[상품설명] : " + item.getPrdDesc());
        });
        System.out.println("============== 연관관계의 NO 주인 LazyLoding end ================");
    }


}

 

 

 

 

 

 

 

그러면 연관관계의 주인에서 조회 할 시 지연로딩이 정상적으로 작동하는지 확인해 보겠습니다. 

 

 

 

 

 

@Test
@Rollback(false)
@Transactional
public void OneToOne_양방향관계_LAZY_LODOING_주인() {
    System.out.println("============== 연관관계의 주인 LazyLoding start ================");
    List<Product> productList = jpaQueryFactory.selectFrom(product).fetch();
    productList.stream().forEach(item -> {
        System.out.println("[상품명]  : " + item.getPrdName());
    });
    System.out.println("============== 연관관계의 주인 LazyLoding end ================");
}

 

 

 

 

 

 

 

 

실행결과는 연관관계의 주인 즉 Product(상품)에서 조회 할시에는 ProductDesc(상품설명)은 정상적으로 LazyLoding으로 가져오는 것을 확인 할 수 있습니다.  그러면 좀더 상세하게 확인 하기 위하여 디버깅 하여 확인 해 보겠습니다. 

 

 

 

 

 

 

 

 

보시면 조회한 productList에 담겨져 있는 product들을 확인해보면 첫번째 상품1과 상품2의 productDesc에는 정상적으로 지연로딩을 위한 proxy가 들어가 있는것을 확인 할 수 있습니다.  자 보시면 다음에 lazyLoding을 하기위하여 id에 product의 식별자 값이 들어가 있는것을 확인 할 수 있습니다. 나중에 해당 productDesc의 값을 가져오기 위하여 터치하는 순간 초기화 작업이 이루어져야 하는데 id에 담겨져 있는 값을 통하여 productDesc를 가져오기위한 쿼리문이 날라 갈 것입니다.  자! 그러면 확인해 보겠습니다. 

 

 

 

@Test
@Rollback(false)
@Transactional
public void OneToOne_양방향관계_LAZY_LODOING_주인() {
    System.out.println("============== 연관관계의 주인 LazyLoding start ================");
    List<Product> productList = jpaQueryFactory.selectFrom(product).fetch();
    productList.stream().forEach(item -> {
        System.out.println("[상품명]  : " + item.getPrdName());
        //ProductDesc 초기화 작업
        System.out.println("[상품명]  : " + item.getProductDesc().getPrdDesc());
    });
    System.out.println("============== 연관관계의 주인 LazyLoding end ================");
}

 

 

 

 

 

 

 

                               

 

 

 

자 결과를 보시면 product를 한번 조회하는 쿼리가 발생하고 이후에 productDesc를 가져오기 위한 쿼리문 2개가 발생하는 것을 확인 할 수 있습니다. 또한 binding parameter를 보시면 디버깅하면서 보았던 proxy를 초기화 하기 위한 id값이 binding 되서 쿼리문을 날리는 것을 확인 할 수 있습니다.  자! 그러면 이번에는 연관관계의 주인이 아닌 쪽에서 조회를 해보 겠습니다. 

 

 

 

@Test
@Rollback(false)
@Transactional
public void OneToOne_양방향관계_LAZY_LODOING_NO주인() {
    System.out.println("============== 연관관계의 NO 주인 LazyLoding start ================");
    List<ProductDesc> productDescList = jpaQueryFactory.selectFrom(productDesc).fetch();
    productDescList.stream().forEach(item -> {
        System.out.println("[상품설명] : " + item.getPrdDesc());
    });
    System.out.println("============== 연관관계의 NO 주인 LazyLoding end ================");
}

 

 

 

 

 

 

확인보시면 지연로딩을 기대했지만 결과는 product를 한번 조회하고 productDesc를 n+1만큼 조회 하는 쿼리가 발생 한 것 확인 할 수 있습니니다.  그러면 좀 더 상세하게 확인 하기 위하여 디버깅을 하여 확인해 보겠습니다.

 

 

 

 

 

 

 

 

 

 

자 보시면 product에는 proxy가 아닌 정상적인 product가 들어가 있는것을 확인 해 볼 수 있습니다. 

그렇다면 왜 이러한 현상이 발생 했을까요?  ERD를 확인해 보면 이해하기가 쉽습니다. 

 

 

 

 

 





PRODUCT 테이블은 product_desc 필드를 가지고 있고 PRODUCT_DESC 테이블은 product 필드를 가지고 있지 않습니다.




 

이처럼 연관관계의 주인에서 조회할 때 지연로딩이 가능 했던 이유는 product입장에서는 연관관계 주인으로 productDesc 외래키를 가지고 있고 관리를 하기 떄문에 주인인 PRODUCT는 productDesc의 값이 null 인지 , 혹은 값이 있는지를 알 수 있어 proxy를 넣어 줄 수 가 있습니다. 즉 PRODUCT를 조회하고 product_desc의 값이 null 이면 null을 넣어주고 값이 있으면 proxy객체에 id에 조회한 product_desc 값(desc_idx)를 넣고 이렇게 만들어진 proxy를 product.productDesc에  넣어 줄 수 있습니다.  하지만 반대입장인 PRODUCT_DESC 테이블에서는 prd_idx 필드를 가지고 있지 않음으로 무조건 product 테이블을 조회해서 null인지 또는 값이 있는지를 확인 해야 하기 때문에 지연로딩의 의미가 없어짐으로 즉시로딩으로 가져 오는 것입니다.  때문에 위처럼 n+1 문제가 생기는 것을 확인 할 수 있습니다.

 

 

 

 

 

이러한 문제를 해결 하기 위한 방법은 아래와 같습니다. 

  1. 양방향 관계를 제거한다. 
  2. OneToOne 관계를 다른 Many등 다른 관계로 변환한다. 

 

 

 

 

제가 생각하는 방법은 양방향 관계는 사용하지 않고 단방향으로 해결 하는 것이 가장 좋은 방법이라고 생각합니다.  흔히 단순하게 연관관계를 생각하고 사용하면 이처럼 예상치 못한 문제가 많이 발생하는 것 같습니다. 저도 실제 업무를 수행하면서 발생한 문제였기 때문에 이유를 찾아보고 했는데 단순하게 OneToOne은 지연로딩이 안된다 라고만 하지 왜그런지 자세하게 설명이 없길래 열심히 찾고 TEST하고 디버깅해서 이유를 찾고 다음에는 이러한 실수를 하지 않기 위하여 이렇게 작성했습니다. 여러분도 이러한 실수 하지 않기를 .....  지금까지 글을 읽어 주셔서 감사합니다. 

 

 

 

 

 

 

 

 

 

 

 

 

 

'JPA' 카테고리의 다른 글

JPA - 1차캐시 동작 조건  (0) 2024.07.20
JPA - find by 외래키(FK) 조회 방법  (9) 2021.08.16
profile

pooney

@pooney

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!