우아한테크코스 테코톡
릴리의 JPA 연관관계 최적화
카테고리 : 우아한테크코스 테코톡
https://youtu.be/HTK1pnZj9ko?si=Kte7Db-TLeF3J-8s
릴리의 JPA 연관관계 최적화
- 릴리의 JPA 연관관계 최적화
연관 관계란?
- JPA는 자바 애플리케이션에서 관계형 데이터베이스의 데이터를 관리하는 데 필요한 객체와 관계를 맵핑하는 API
- 핵심이 되는 개념 중 하나가 엔티티인데 엔티티란 데이터베이스 테이블에 대응되는 객체로 각 엔티티는 특정한 데이터 모델을 표현
- 연관관계란 JPA 엔티티 간의 관계를 정의 즉 한 엔티티가 다른 엔티티와 어떻게 연결되어 있는지를 설명하는 것
왜 연관 관계를 이해해야 하는가?
- 애플리케이션 내 데이터를 효율적으로 구성하기 위함
- 성능 향상, 데이터 무결성, 유지 보수 등의 기타 등등
연관 관계 별 작동 원리와 주요 고려사항
양방향 One To Many
항상 부모 측에서 자식 측으로 전이를 사용
- 회사가 사라지면 직원도 사라지지만 직원 한 명 사라진다고 회사가 사라지면 안된다
- 통상적으로 항상 부모 측에서 자식 측으로 전이를 사용
단방향 One To Many
부모-자식 연관관계를 관리하기 위한 연결 테이블이 생성
- 단방향 one-to-many에서는 부모-자식 연관관계를 관리하기 위한 연결 테이블이 생성이 되는데 자식 엔티티가 부모 엔티티를 직접 참조하지 않기 때문에 자식 엔티티의 부모의 외래키를 저장할 수 없다
- 이를 해결하기 위해 부모-자식 관계를 관리하는 별도의 연결 테이블을 형성하는 것이다
연결 테이블로 인한 비효율
- 연결 테이블로 인한 비효율이 발생하는데 여기서 말하는 비효율이란 메모리 사용 증가 및 성능 저하 가능성을 얘기한다
- 메모리 사용이 증가하는 이유는 연결 테이블에서 두 개의 외래키 열에 대한 인덱싱이 필요하고 연결 테이블 자체의 오버헤드와 나아가 데이터베이스 오버헤드까지 발생할 수 있기 때문이다
- 3개의 테이블로 인한 쿼리 복잡성이 증가하기 때문에 성능이 저하될 가능성이 있다는 것이다
INSERT나 DELETE가 작동되는 방법
- 회사에 새 직원를 추가하고 싶다고 가정
- 회사에 3명의 직원을 추가해주고 이 회사에 새로운 직원을 추가
- 기존 회사에 3명의 직원를 추가하는 코드에서는 연결 테이블을 통해 관리하고 있는 모습이 출력
- 그 이후 새로운 직원을 INSERT하는 쿼리를 살펴보면 INSERT 쿼리가 하나만 나타날 것이라고 예상하지만 DELETE 쿼리 하나와 INSERT 쿼리가 4개가 출력
- 연결 테이블에 있는 기존의 회사와 직원 간의 모든 관계를 삭제하고 이후 새로 추가된 직원을 포함하여 메모리에 있는 모든 직원 목록을 다시 연결 테이블에 삽입하고 있다
- 이렇게 불필요한 삭제와 삽입이 발생
- 불필요한 삭제와 삽입
- 단순히 새로운 데이터를 추가하기 위해 이미 존재하는 모든 관계를 삭제하고 다시 삽입하는 과정을 거친다
- 이는 부모 엔티티는 자식 엔티티와의 관계를 직접 관리하지 않기 때문에 자식 엔티티의 상태를 정확히 파악하기 위한 방법이 제한적이기 때문이다
- 결국 데이터 일관성을 유지하고 관계 변경을 정확히 감지하기 위해서이다
@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의 주요 단점이다
- 두 번째로 기본키를 공유하면 메모리 사용량이 줄어든다
- 왜냐하면 기본키와 외래키를 모두 인덱싱할 필요가 없기 때문이다