DB/자바 ORM 표준 JPA 프로그래밍

[14장] 컬렉션과 부가 기능

꾸준함. 2025. 3. 19. 23:34

컬렉션

  • JPA는 자바에서 기본으로 제공하는 Collection, List, Set, Map 컬렉션을 지원하고 다음 경우에 해당 컬렉션을 사용할 수 있음
    • @OneToMany, @ManyToMany를 사용해서 일대다나 다대다 엔티티 관계를 매핑할 때
    • @ElementCollection을 사용해서 값 타입을 하나 이상 보관할 때

 

  • 자바 컬렉션 인터페이스의 특징은 다음과 같음
    • Collection: 자바가 제공하는 최상위 컬렉션, 하이버네이트는 중복을 허용하고 순서를 보장하지 않는다고 가정
    • Set: 중복을 허용하지 않는 컬렉션, 순서를 보장하지 않음
    • List: 순서가 있는 컬렉션이며 순서를 보장하고 중복을 허용
    • Map: Key, Value 구조로 되어 있는 특수한 컬렉션

 

https://ivory59.tistory.com/79

 

1. JPA와 컬렉션

  • 하이버네이트는 엔티티를 영속 상태로 만들 때 컬렉션 필드를 하이버네이트에서 준비한 컬렉션으로 감싸서 사용
    • ex) ArrayList 타입이었던 컬렉션이 엔티티를 영속 상태로 만든 직후 하이버네이트가 제공하는 PersistentBag으로 변경

 

 

  • 하이버네이트는 컬렉션을 효율적으로 관리하기 위해 엔티티를 영속 상태로 만들 때 원본 컬렉션을 감싸고 있는 내장 컬렉션 (Wrapper Collection)을 생성해서 해당 내장 컬렉션을 사용하도록 참조를 변경함
  • 하이버네이트는 위와 같은 특징 때문에 컬렉션을 사용할 때 다음처럼 즉시 초기화해서 사용하는 것을 권장
    • Collection<Member> members = new ArrayList<>();

 

2. Collection, List

  • Collection과 List 인터페이스는 중복을 허용하는 컬렉션이고 PersistentBag을 래퍼 컬렉션으로 사용
    • 해당 인터페이스는 ArrayList로 초기화하면 됨

 

  • Collection과 List는 중복을 허용한다고 가정하므로 객체를 추가하는 add() 메서드는 내부에서 어떤 비교도 하지 않고 항상 true를 반환
    • Collection과 List는 엔티티를 추가할 때 중복된 에티티가 있는지 비교하지 않고 단순히 저장만 하면 됨
    • 따라서 엔티티를 추가해도 지연 로딩된 컬렉션을 초기화하지 않음

 

  • 같은 엔티티가 있는지 조회하거나 삭제할 때는 equals() 메서드를 사용

 

3. Set

  • Set은 중복을 허용하지 않는 컬렉션이며 하이버네이트는 PersistentSet 컬렉션 래퍼로 사용
    • 해당 인터페이스는 HashSet으로 초기화하면 됨

 

  • HashSet은 중복을 허용하지 않으므로 add() 메서드로 객체를 추가할 때마다 equals() 메서드로 같은 객체가 있는지 비교함
    • 같은 객체가 없으면 객체를 추가하고 true를 반환하고, 같은 객체가 이미 있어서 추가에 실패하면 false를 반환
    • HashSet은 해시 알고리즘을 사용하므로 hashcode()도 함께 사용해서 비교
    • Set은 엔티티를 추가할 때마다 중복된 엔티티가 있는지 비교해야 하므로 엔티티를 추가할 때 지연 로딩된 컬렉션을 초기화함

 

4. List + @OrderColumn

  • List 인터페이스에 @OrderColumn을 추가하면 순서가 있는 특수한 컬렉션으로 인식함
    • 순서가 있다는 의미는 DB에 순서 값을 저장해서 조회할 때 사용한다는 의미
    • 하이버네이트는 내부 컬렉션인 PersistentList를 사용

 

 

부연 설명

  • Board.comments에 List 인터페이스를 사용하고 @OrderColumn을 추가했으므로 Board.comments는 순서가 있는 컬렉션으로 인식됨
  • 순서가 있는 컬렉션은 DB에 순서 값도 함께 관리함
    • 여기서는 @OrderColumn의 name 속성에 POSITION이라는 값을 부여했고 JPA는 List의 위치 값을 테이블의 POSITION 컬럼에 보관함
    • Board.comments 컬렉션은 Board 엔티티에 있지만 테이블의 일대다 관계의 특성상 위치 값은 다 (N) 쪽에 저장해야 함
    • 따라서 실제 POSITION 컬럼은 COMMENT 테이블에 매핑됨

 

https://velog.io/@rosesua318/14%EC%9E%A5-%EC%BB%AC%EB%A0%89%EC%85%98%EA%B3%BC-%EB%B6%80%EA%B0%80-%EA%B8%B0%EB%8A%A5

 

4.1 @OrderColumn의 단점

@OrderColumn은 다음과 같은 단점들 때문에 실무에서 잘 사용하지 않습니다.

  • @OrderColumn을 Board 엔티티에서 매핑하므로 Comment는 POSITION의 값을 알 수 없기 때문에 Comment를 INSERT 할 때는 POSITION 값을 저장하지 않음
    • POSITION은 Board.comments의 위치 값이므로 해당 값을 사용해서 POSITION의 값을 UPDATE 하는 SQL이 추가로 발생함

 

  • List를 변경하면 연관된 많은 위치 값을 변경해야 함
    • i.g. 위 사진에서 댓글2를 삭제하면 댓글3과 댓글4의 POSITION 값을 각각 하나씩 줄이는 UPDATE SQL이 두 번 추가로 실행됨

 

  • 중간에 POSITION 값이 없을 경우 조회한 List에는 null이 보관됨
    • i.g. 댓글2를 데이터베이스에서 강제로 삭제하고 다른 댓글들의 POSITION 값을 수정하지 않을 경우 데이터베이스의 POSITION 값은 [0, 2, 3]이 되어서 중간에 1 값이 없음
    • 위와 같은 경우 List를 조회하면 1번 위치에 null 값이 보관되며 이에 따라 컬렉션을 순회할 때 NullPointerException이 발생함

 

5. @OrderBy

  • @OrderColumn이 DB에 순서용 컬럼을 매핑해서 관리했다면 @OrderBy는 DB의 ORDER BY 절을 사용해서 컬렉션을 정렬하므로 순서용 컬럼을 매핑하지 않아도 된다는 장점이 있음

 

 

부연 설명

  • Team.members에 @OrderBy를 적용했고 @OrderBy의 값으로 `username desc, id asc`를 사용해서 Member의 username 필드로 내림차순 정렬하고 id로 오름차순 정렬함
  • @OrderBy의 값은 JPQL의 `order by`절처럼 엔티티의 필드를 대상으로 함

 

@Converter

  • Converter를 사용하면 엔티티의 데이터를 변환해서 DB에 저장할 수 있음
    • i.g. 회원의 VIP 여부를 자바의 boolean 타입으로 사용하고 싶다고 가정했을 때 JPA를 사용하면 방언에 따라 자바의 boolean 타입이 0 또는 1인 숫자로 저장됨
    • 위 케이스에서 DB에 숫자 대신 문자 `Y` 또는 `N`으로 저장하고 싶을 경우 컨버터를 사용하면 됨

 

 

부연 설명

  • 회원 엔티티의 vip 필드는 boolean 타입
  • @Convert를 적용해서 DB에 저장되기 직전에 BooleanToYNConverter 컨버터가 동작
  • 컨버터 클래스는 @Converter 어노테이션을 사용하고 AttributeConverter 인터페이스를 구현해야 하며 제네릭에 현재 타입과 변환할 타입을 지정해야 함
    • 위 예제에서는 <Boolean, String>을 지정해서 Boolean 타입을 String 타입으로 변환해야 함

 

  • AttributeConverter 인터페이스에는 구현해야 할 다음 두 메서드가 있음
    • convertToDatabaseColumn(): 엔티티의 데이터를 DB 컬럼에 저장할 데이터로 변환
    • convertToEntityAttribute(): DB에서 조회한 컬럼 데이터를 엔티티의 데이터로 변환

 

1. 글로벌 설정

  • 모든 Boolean 타입에 컨버터를 적용하려면 @Converter(autoApply = true) 옵션을 적용하면 됨
  • 글로벌 설정을 하면 @Convert를 지정하지 않아도 모든 Boolean 타입에 대해 자동으로 컨버터가 적용됨

 

 

리스너

  • JPA 리스너 기능을 사용하면 엔티티의 생명주기에 따른 이벤트를 처리할 수 있음

 

1. 이벤트 종류

  • 이벤트의 종류와 발생 시점은 다음과 같음
    • PostLoad: 엔티티가 영속성 컨텍스트에 조회된 직후 또는 refresh를 호출한 직후
    • PrePersist: persist() 메서드를 호출해서 엔티티를 영속성 컨텍스트에 관리하기 직전에 호출되며 식별자 생성 전략을 사용한 경우 엔티티에 식별자는 아직 존재하지 않음, 새로운 인스턴스를 merge 할 때도 수행됨
    • PreUpdate: flush나 commit을 호출해서 엔티티를 DB에 수정하기 직전에 호출됨
    • PreRemove: remove() 메서드를 호출해서 엔티티를 영속성 컨텍스트에서 삭제하기 직전에 호출되고 삭제 명령어로 영속성 전이가 일어날 때도 호출됨
    • PostPersist: flush나 commit을 호출해서 엔티티를 DB에 저장한 직후에 호출되며 식별자가 항상 존재함, 식별자 생성 전략이 IDENTITY면 식별자를 생성하기 위해 persist()를 호출하면서 DB에 해당 엔티티를 저장하므로 이때는 persist()를 호출한 직후에 바로 PostPersist가 호출됨
    • PostUpdate: flush나 commit을 호출해서 엔티티를 DB에 수정한 직후에 호출됨
    • PostRemove: flush나 commit을 호출해서 엔티티를 DB에 삭제한 직후에 호출됨

 

https://milenote.tistory.com/150

 

2. 이벤트 적용 위치

  • 이벤트는 엔티티에서 직접 받거나 별도의 리스너를 등록해서 받을 수 있음
    • 엔티티에 직접 적용
    • 별도의 리스너 등록
    • 기본 리스너 사용

 

가. 엔티티에 직접 적용

  • 아래 예제의 경우 엔티티에 이벤트가 발성할 때마다 어노테이션으로 지정한 메서드가 실행됨

 

 

나. 별도의 리스너 등록

  • 리스너는 대상 엔티티를 파라미터로 받을 수 있으며 반환 타입은 void로 설정해야 함

 

 

다. 기본 리스너 사용

  • 모든 엔티티의 이벤트를 처리하려면 META-INF/orm.xml에 기본 (default) 리스너로 등록하면 됨
  • 여러 리스너를 등록했을 때 이벤트 호출 순서는 다음과 같음
    • 기본 리스너
    • 부모 클래스 리스너
    • 리스너
    • 엔티티

 

 

라. 더 세밀한 설정

  • 더 세밀한 설정을 위한 어노테이션도 있음
    • javax.persistence.ExcludeDefaultListeners: 기본 리스너 무시
    • javax.persistence.ExcludeSuperclassListeners: 상위 클래스 이벤트 리스너 무시

 

  • 이벤트를 잘 활용하면 대부분의 엔티티에 공통적으로 적용하는 등록 일자, 수정 일자 처리와 해당 엔티티를 누가 등록하고 수정했는지에 대한 기록을 리스너 하나로 처리 가능

 

 

엔티티 그래프

  • 글로벌 fetch 옵션은 애플리케이션 전체에 영향을 끼치고 변경할 수 없는 단점이 있음
  • 이에 따라 일반적으로 글로벌 fetch 옵션은 FetchType.LAZY를 사용하고, 엔티티를 조회할 때 연관된 엔티티를 함께 조회할 필요가 있으면 JPQL의 페치 조인을 사용
  • 그런데 페치 조인을 사용하면 같은 JPQL을 중복해서 작성하는 경우가 많음
    • ex) 주문 상태를 검색 조건으로 주문 엔티티를 조회하는 JPQL을 작성하면 `SELECT o FROM Order o WHERE o.status = ?`
    • 주문과 회원을 함께 조회할 필요가 있을 경우 다음과 같이 JPQL을 새로 추가 `SELECT o FROM Order o JOIN FETCH o.member WHERE o.status = ?`
    • 그리고 주문과 주문상품을 함께 조회하는 기능이 필요해서 다음과 같이 JPQL을 새로 추가 `SELECT o FROM Order o JOIN FETCH o.orderItems WHERE o.status = ?`
    • 세 가지 JQP 모두 주문을 조회하는 같은 JPQL이지만 함께 조회할 엔티티에 따라서 다른 JPQl을 사용해야 함
    • 이는 JQPL이 데이터를 조회하는 기능뿐만 아니라 연관된 엔티티를 함께 조회하는 기능도 제공하기 때문인데, 결국 JPQL이 두 가지 역할을 모두 수행해서 발생하는 문제

 

  • 위와 같은 문제를 해결하기 위해 JPA 2.1에 추가된 엔티티 그래프 기능을 사용하면 엔티티를 조회하는 시점에 함께 조회할 연관된 엔티티를 선택할 수 있기 때문에 JPQL은 데이터를 조회하는 기능만 수행하면 되고 연관된 엔티티를 함께 조회하는 기능은 엔티티 그래프를 사용하면 됨
    • 엔티티 그래프 기능은 엔티티 조회시점에 연관된 엔티티들을 함께 조회하는 기능
    • 엔티티 그래프는 정적으로 정의하는 Named 엔티티 그래프와 동적으로 정의하는 엔티티 그래프가 있음

 

엔티티 그래프를 설명하기 위한 예제에 사용할 엔티티 모델은 다음과 같습니다.

 

https://devbksheen.tistory.com/entry/%EC%97%94%ED%8B%B0%ED%8B%B0-%EA%B7%B8%EB%9E%98%ED%94%84%EB%A1%9C-%EC%97%94%ED%8B%B0%ED%8B%B0-%EC%97%B0%EA%B4%80%EA%B4%80%EA%B3%84-%EC%A1%B0%ED%9A%8C-%ED%95%98%EA%B8%B0

 

1. Named 엔티티 그래프

  • 아래 코드는 주문을 조회할 때 연관된 회원도 함께 조회하는 엔티티 그래프를 사용하는 예제


 

부연 설명

  • Named 엔티티 그래프는 @NamedEntityGraph로 정의
    • name: 엔티티 그래프명 정의
    • attributeNodes: 함께 조회할 속성 선택, 이때 @NamedAttributeNode를 사용하고 해당 값으로 함께 조회할 속성 선택

 

  • Order.member가 지연 로딩으로 설정되어 있지만, 엔티티 그래프에서 함께 조회할 속성으로 member를 선택했으므로 해당 엔티티 그래프를 사용하면 Order를 조회할 때 연관된 member도 함께 조회할 수 있음
  • 둘 이상 정의하려면 @NamedEntityGraphs를 사용하면 됨

 

2. em.find()에서 엔티티 그래프 사용


 

부연 설명

  • Named 엔티티 그래프를 사용하려면 정의한 엔티티 그래프를 `em.getEntityGraph("Order.withMember")`를 통해서 찾아오면 됨
  • 엔티티 그래프는 JPA의 힌트 기능을 사용해서 동작하는데 힌트의 키로 javax.persistence.fetchgraph를 사용하고 힌트의 값으로 찾아온 엔티티 그래프를 사용하면 됨
  • em.find(Order.class, orderId, hints)로 Order 엔티티를 조회할 때 힌트 정보도 포함했음

 

실행된 SQL을 보면 적용한 Order.withMember 엔티티 그래프를 사용해서 Order와 Member를 함께 조회한 것을 확인할 수 있습니다.


 

3. subgraph

  • Order -> OrderItem -> Item까지 함께 조회한다고 가정했을 때 Order -> OrderItem은 Order가 관리하는 필드지만 OrderItem -> Item은 Order가 관리하는 필드가 아님
    • 이때 subgraph 속성을 사용하면 됨


 

부연 설명

  • 정의한 Named 엔티티 그래프는 Order -> Member, Order -> OrderItem, OrderItem -> Item의 객체 그래프를 함께 조회함
    • 이때 OrderItem -> Item은 Order의 객체 그래프가 아니므로 subgraphs 속성으로 정의해야 함
    • 해당 속성은 @NamedSubgraph를 사용해서 서브 그래프를 정의

 

Order.withAll이라는 Named  엔티티 그래프를 사용해서 Order 엔티티를 조회하는 코드와 실행되는 SQL은 다음과 같습니다.


 

4. JPQL에서 엔티티 그래프 사용

  • em.find()와 동일하게 힌트만 추가하면 JPQL에서 엔티티 그래프를 사용할 수 있음


 

5. 동적 엔티티 그래프

  • 엔티티 그래프를 동적으로 구성하려면 createEntityGraph() 메서드를 사용하면 됨


 

6. 엔티티 그래프 정리

 

6.1 ROOT에서 시작

  • 엔티티 그래프는 항상 조회하는 엔티티의 ROOT에서 시작해야 함
  • 당연한 이야기지만 Order 엔티티를 조회하는데 Member부터 시작하는 엔티티 그래프를 사용하면 안 됨

 

6.2 이미 로딩된 엔티티

  • 영속성 컨텍스트에 해당 엔티티가 이미 로딩되어 있을 경우 엔티티 그래프가 적용되지 않음
  • 아직 초기화되지 않은 프록시에는 엔티티 그래프가 적용됨


 

6.3 fetchgraph, loadgraph 차이

  • 예제에서는 javax.persistence.fetchgraph 힌트를 사용해서 엔티티 그래프를 조회했고 이것은 엔티티 그래프에 선택한 속성만 함께 조회함
  • 반면 javax.persistence.loadgraph 속성은 엔티티 그래프에 선택한 속성뿐만 아니라 글로벌 fetch 모드가 FetchType.EAGER로 설정된 연관관계도 포함해서 함께 조회함

 

참고

자바 ORM 표준 JPA 프로그래밍 - 김영한 저

반응형