우아한테크코스 테코톡

릴리의 JPA 연관관계 최적화

https://youtu.be/HTK1pnZj9ko?si=Kte7Db-TLeF3J-8s

릴리의 JPA 연관관계 최적화

연관 관계란?

  • JPA는 자바 애플리케이션에서 관계형 데이터베이스의 데이터를 관리하는 데 필요한 객체와 관계를 맵핑하는 API
  • 핵심이 되는 개념 중 하나가 엔티티인데 엔티티란 데이터베이스 테이블에 대응되는 객체로 각 엔티티는 특정한 데이터 모델을 표현
  • 연관관계란 JPA 엔티티 간의 관계를 정의 즉 한 엔티티가 다른 엔티티와 어떻게 연결되어 있는지를 설명하는 것

왜 연관 관계를 이해해야 하는가?

  • 애플리케이션 내 데이터를 효율적으로 구성하기 위함
  • 성능 향상, 데이터 무결성, 유지 보수 등의 기타 등등

연관 관계 별 작동 원리와 주요 고려사항

양방향 One To Many

항상 부모 측에서 자식 측으로 전이를 사용

  • 회사가 사라지면 직원도 사라지지만 직원 한 명 사라진다고 회사가 사라지면 안된다
  • 통상적으로 항상 부모 측에서 자식 측으로 전이를 사용

단방향 One To Many

부모-자식 연관관계를 관리하기 위한 연결 테이블이 생성

  • 단방향 one-to-many에서는 부모-자식 연관관계를 관리하기 위한 연결 테이블이 생성이 되는데 자식 엔티티가 부모 엔티티를 직접 참조하지 않기 때문에 자식 엔티티의 부모의 외래키를 저장할 수 없다
  • 이를 해결하기 위해 부모-자식 관계를 관리하는 별도의 연결 테이블을 형성하는 것이다

연결 테이블로 인한 비효율

  • 연결 테이블로 인한 비효율이 발생하는데 여기서 말하는 비효율이란 메모리 사용 증가 및 성능 저하 가능성을 얘기한다
  • 메모리 사용이 증가하는 이유는 연결 테이블에서 두 개의 외래키 열에 대한 인덱싱이 필요하고 연결 테이블 자체의 오버헤드와 나아가 데이터베이스 오버헤드까지 발생할 수 있기 때문이다
  • 3개의 테이블로 인한 쿼리 복잡성이 증가하기 때문에 성능이 저하될 가능성이 있다는 것이다

INSERT나 DELETE가 작동되는 방법

  • 회사에 새 직원를 추가하고 싶다고 가정
    1. 회사에 3명의 직원을 추가해주고 이 회사에 새로운 직원을 추가
    2. 기존 회사에 3명의 직원를 추가하는 코드에서는 연결 테이블을 통해 관리하고 있는 모습이 출력
    3. 그 이후 새로운 직원을 INSERT하는 쿼리를 살펴보면 INSERT 쿼리가 하나만 나타날 것이라고 예상하지만 DELETE 쿼리 하나와 INSERT 쿼리가 4개가 출력
    4. 연결 테이블에 있는 기존의 회사와 직원 간의 모든 관계를 삭제하고 이후 새로 추가된 직원을 포함하여 메모리에 있는 모든 직원 목록을 다시 연결 테이블에 삽입하고 있다
    5. 이렇게 불필요한 삭제와 삽입이 발생
  • 불필요한 삭제와 삽입
    • 단순히 새로운 데이터를 추가하기 위해 이미 존재하는 모든 관계를 삭제하고 다시 삽입하는 과정을 거친다
    • 이는 부모 엔티티는 자식 엔티티와의 관계를 직접 관리하지 않기 때문에 자식 엔티티의 상태를 정확히 파악하기 위한 방법이 제한적이기 때문이다
    • 결국 데이터 일관성을 유지하고 관계 변경을 정확히 감지하기 위해서이다

@JoinColumn 사용

  • @JoinColumn을 지정하면 one-to-many 연관관계가 자식 테이블 외래키를 제어할 수 있음을 하이버네트에 지시하는 것이다
  • 연결 테이블로 인한 쿼리는 사라지고 업데이트를 진행한다
  • @JoinColumn을 추가하면 일반 단방향 one-to-many보다는 이점을 제공할 수 있지만 추가 업데이트문은 여전히 성능 저하를 가져오고 있다

양방향 Many To Many

  • 양방향 many-to-many에서는 양측 모두에서 탐색을 할 수 있기 때문에 양측 모두가 부모가 되고 별도의 연결 테이블을 통해 두 개의 외래 키 열을 관리하고 있다

항상 List가 아닌 Set사용

  • List일 경우 데이터를 삭제하면 DELETE 쿼리와 INSERT 쿼리가 두 개가 나간다
    • 이것은 연결 테이블에서 모든 연결 항목을 삭제하는 것으로 시작해서 삭제 대상이 아닌 연결 항목들을 다시 삽입해 메모리 내의 내용을 반영하는 것이다
  • Set을 사용하면 하나의 Delete 문만 사용한다 이렇기 때문에 양방향 Many-to-Many에서 항상 리스트가 아닌 Set을 사용하라는 것이다

CascadeType.ALL 및 CascadeType.REMOVE 사용하지 않기

  • 대부분의 경우 제거에 대한 전이는 좋은 생각이 아니다
    • 예를 들어서 회사 엔티티를 삭제해도 다른 회사에 의해 사용자가 참조될 수 있기 때문에 사용자 제거가 호출되지 않아야 하는데 이거는 곧 의도치 않은 전이가 발생할 수도 있다 그렇기 때문에 CascadeType의 ALL 및 REMOVE 사용을 피하고 명시적으로 PERSIST와 MERGE를 사용해야한다

연관관계 양측에서 지연 로딩을 사용

  • 기본적으로 many-to-many 연관관계는 지연 처리되고이 방식을 그대로 사용해야한다 이 말은 곧 many-to-many에서 즉시 로딩을 사용하지 말라는 말이다
  • 왜냐하면 즉시 로딩이 성능 저하와 비효율적인 데이터 로딩을 초래할 수 있기 때문이다
  • 곧 N+1 문제가 발생할수록 할 수 있기 때문인데 N+1 문제 발생은 다른 연관관계에서도 마찬가지 아닌가 라고 생각할 수 있지만 Many-to-many 관계에서 다수대 다수로 양방향성이 높아 필요 이상의 연관 엔티티가 많이 로딩되기 쉬운 구조이기 때문에 즉시 로딩시 더 주의가 필요하다

단방향 One To One

  • 단방향 one-to-one은 자식 측에 어노테이션이 붙는다

부모 엔티티가 자식 엔티티의 식별자를 알 수 없다

  • 자식 엔티티를 조회하기 위해 부모 정보를 조건으로 하여 JPQL 쿼리를 실행
  • 부모에 관련된 자식 정보를 가져올 때마다 추가 쿼리가 발생
  • 이렇게 부모 측이 지속적으로 또는 매번 자식 측을 필요로 한다면 새로운 쿼리가 추가되면서 성능이 저하될 수 있다는 뜻이다

양방향 One To One

  • 양방향이니까 부모 측도 @MappedBy가 들어가 @OneToOne 어노테이션을 추가한다

부모 엔티티 조회 시 자식 엔티티까지 로딩

  • 양방향 one-to-one에서는 부모 엔티티 조회 시 자식 엔티티까지 로딩이 되는 문제가 있는데 부모를 조회하는 코드 한 줄을 실행시켰는데 부모뿐만 아니라 자식까지 LAZY 연관 관계임에도 SELECT가 된다
  • 이렇게 애플리케이션의 부모만 필요한 경우에도 자식을 가져오는 것은 리소스 낭비이자 성능 저하를 초래한다
  • one-to-one 관계 특성상 자식 엔티티가 없는 경우와 있는 경우의 참조 상태가 중요하기 때문인데 자식 엔티티를 가져오지 않으면 하이버네이트는 자식 참조를 null로 할지 Object로 할지 알 수 없기 때문이다

@MapsId

  • 자식 ID에는 Strategy가 없다
  • 자식 측에 @MapsId를 추가한다
  • 반대로 부모 엔티티는 양방향 one-to-one이 필요하지 않기 때문에 간단해진다
  • @MapsId는 자식 테이블이 부모 테이블과 기본 키를 공유하는 것이다
@MapsId를 사용 이점
  • 첫 번째로 부모를 가져오는 것은 크루를 추가적으로 가져오는 불필요한 부가 쿼리를 자동으로 호출하지 않는다
    • 이는 양방향 one-to-one의 주요 단점이다
  • 두 번째로 기본키를 공유하면 메모리 사용량이 줄어든다
    • 왜냐하면 기본키와 외래키를 모두 인덱싱할 필요가 없기 때문이다

© 2020. All rights reserved.

SIKSIK