JPA 컬렉션

JPA 와 컬렉션에 대해서 알아보자

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

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

JPA 명세에는 자바 컬렉션 인터페이스에 대한 특별한 언급이 없다. 따라서 JPA구현체에 따라서 제공하는 기능이 조금씩 다를 수 있는데 여기서는 하이버네이트 기준으로 설명한다.

JPA와 컬렉션

@Entity
@Data
public class Team {
  @Id
  private String id;

  @OneToMany
  @JoinColumn
  private Collection<Member> members = new ArrayList<Member>();
}

Team 은 Members 컬렉션을 필드로 가지고 있다. 다음 코드로 Team을 영속 상태로 만들어보자.

Team team = new Team();
team.setId("test");
System.out.println(team.getMembers().getClass());
entityManager.persist(team);
System.out.println(team.getMembers().getClass());

출력 결과는 다음과 같다.

class java.util.ArrayList
class org.hibernate.collection.internal.PersistentBag

출력 결과를 보면 원래 ArrayList 타입이었던 컬렉션이 엔티티를 영속 상태로 만든 직후에 하이버네이트가 제공하는 PersistentBag 타입으로 변경 되었다. 하이버네이트가 제공하는 내장 컬렉션은 원본 컬렉션을 감싸고 있어서 래퍼 컬렉션으로도 부른다.
하이버네이트는 이런 특징 때문에 컬렉션을 사용할 때 다음 처럼 즉시 초기화해서 사용하는 것을 권장한다.

Collection<Member> members = new ArrayList<Member>();

Collection, List

Collection, List 인터페이스는 중복을 허용하는 컬렉션이고 PersistentBag을 래퍼 컬렉션으로 사용한다.

public class Parent {
  @Id @GeneratedValue
  private Long id;

  @OneToMany
  @JoinColumn
  private Collection<CollectionChild> collection = new ArrayList<CollectionChild>();

  @OneToMany
  @JoinColumn
  private List<ListChild> list = new ArrayList<ListChild>();
}

중복을 허용한다고 가정하므로 객체를 추가하는 add() 메소드는 내부에서 어떠한 비교도 하지 않고 항상 true를 반환한다. 같은 엔티티가 있는지 찾거나 삭제할 때는 equals() 메서드를 사용한다.

List<Comment> commentList = new ArrayList<Comment>();
boolean result = commentList.add(data); //항상 true

commentList.contains(comment); //equals 비교
commentList.remove(comment); //equals 비교

Collection, List는 엔티티를 추가 할 때 중복된 엔티티가 있는지 비교하지 않고 단순히 저장만 하면 된다. 따라서 엔티티를 추가해도 지연 로딩된 컬렉션을 초기화 하지 않는다.

Set

Set은 중복을 허용하지 않는 컬렉션이다. 하이버네이트는 PersistentSet을 컬렉션 래퍼로 사용한다. 이 인터페이스는 HashSet으로 초기화 하면 된다.

@OneToMany
@JoinColumn
private Set<SetChild> set = new HashSet<SetChild>();

HashSet은 중복을 허용하지 않으므로 add() 메서드로 객체를 추가할 때 마다 equals() 메서드로 같은 객체가 있는지 비교한다. 같은 객체가 없으면 추가하고 true로 반환, 이미 있어서 추가에 실패하면 false 로 반환한다. HashSet은 해시 알고리즘을 사용하므로 hashCode() 도 함께 사용한다.

Set<Comment> commentList = new HashSet<Comment>();
boolean result = commentList.add(data); //hashCode + equals() 비교

commentList.contains(comment); //hashCode + equals() 비교
commentList.remove(comment); //hashCode + equals() 비교

Set은 엔티티를 추가할 때 중복된 엔티티가 있는지 비교해야 한다. 따라서 엔티티를 추가할 때 지연 로딩된 컬렉션을 초기화 한다.

List + @OrderColumn

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

@Entity
@Data
public class Board {
  @Id @GeneratedValue
  private Long id;

  private String title;

  @OneToMany(mappedBy = "board")
  @OrderColumn(name = "POSITION")
  private List<Comment> comments = new ArrayList<Comment>();
}

@Entity
@Data
public class Comment {

  @Id @GeneratedValue
  private Long id;

  private String comment;

  @ManyToOne
  @JoinColumn(name = "BOARD_ID")
  private Board board;
}

Board.comments에 List 인터페이스를 사용하고 @OrderColumn을 추가했다. 따라서 Board.comments는 순서가 있는 컬렉션으로 인식된다. 자바가 제공하는 List 컬렉션은 내부에 위치 값을 가지고 있다. 따라서 다음 코드처럼 List의 위치 값을 활용할 수 있다.

list.add(1, data); //1번위치에 data를 저장 
list.get(10); //10 번 위치에 있는 값 조회

순서가 있는 컬렉션은 데이터베이스에 순서 값도 함께 관리해 준다. 여기서는 @OrderColumn의 name속성에 POSITION이라는 값을 주었다. JPA는 List의 위치 값을 테이블의 POSITiON 컬럼에 보관한다. 그런데 Board.comments 컬렉션은 Board 엔티티에 있지만 테이블의 일대다 관계의 특성상 위치 값은 다(N) 쪽에 저장해야 한다.

@OrderColumn을 사용해서 List의 위치 값을 보관하면 편리할 것 같지만 실무에서 사용하기에는 단점이 너무 많다. 따라서 @OrderColumn을 매핑하지 말고 개발자가 직접 POSITION 값을 관리 하거나 @OrderBy를 사용하길 권장한다.

출처 : 자바 ORM 표준 JPA 프로그래밍 (김영한)

JPA 트랜잭션과 락

트랜잭션은 원자성, 일관성, 격리성, 지속성을 보장해야 한다.
원자성 : 트랜잭션내에서는 실행한 작업들은 마치 하나의 작업인 것 처럼 모두 성공하든가 모두 실패해야 한다.
일관성 : 모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 한다. 예를 들어 데이터베이스에서 정한 무결성 제약 조건을 항상 만족해야 한다.
격리성 : 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리한다. 예를들어 동시에 같은 데이터를 수정하지 못하도록해야 한다. 격리성과 관련된 성능 이슈로 인해 격리 수준을 선택할 수 있다.
지속성 : 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야 한다. 중간에 시스템에 문제가 발생해도 데이터베이스 로그 등을 사용해서 성공한 트랜잭션 내용을 복구해야 한다.

트랜잭션은 원자성, 일관성, 지속성을 보장한다. 문제를 격리성인데 트랜잭션 간에 격리성을 완벽히 보장하려면 트랜잭션을 거의 차례대로 실행해야 한다. 이렇게 하면 동시성 처리 성능이 매우 나빠진다. 트랜잭션 격리 수준은 다음과 같다.

  • READ UNCOMMITTED (커밋되지 않는 읽기)
  • READ COMMITTED (커밋된 읽기)
  • REPEATABLE READ (반복 가능한 읽기)
  • SERIALIZABLE (직렬화 기능)

순서대로 READ UNCOMMITTED가 격리 수준이 가장 낮고 SERIALIZABLE의 격리 수준이 가장 높다.

  1. READ UNCOMMITTED : 커밋하지 않은 데이터를 읽을 수 있다. 예를 들어 트랜잭션1이 데이터를 수정하고 있는데 커밋하지 않아도 트랜잭션 2가 수정 중인 데이터를 조회 할 수 있다. 이것을 DIRTY READ라 한다. 트랜잭션 2가 DIRTY READ한 데이터를 사용하는데 트랜잭션 1을 롤백하면 데이터 정합성에 심각한 문제가 발생할 수 있다. DIRTY READ를 허용하는 격리 수준을 READ UNCOMMITTED라 한다.
  2. READ COMMITTED : 커밋한 데이터만 읽을 수 있다. 따라서 DIRTY READ가 발생하지 않는다. 하자만 NON-REPEATABLE READ가 발생할 수 있다. 예를들어 트랜잭션 1이 회원 A를 조회 중인데 갑자기 트랜잭션 2가 회원 A를 수정하고 커밋하면 트랜잭션 1이 다시 회원 A를 조회했을 때 수정된 데이터가 조회된다. 이처럼 반복해서 같은 데이터를 읽을 수 없는 상태를 NON-REPEATABLE READ 라 한다. DIRTY READ는 허용하지 않지만 NON-REPEATABLE READ는 허용하는 격리 수준을 READ COMMITTED라 한다.
  3. REPEATABLE READ : 한번 조회한 데이터를 반복해서 조회해도 같은 데이터가 조회된다. 하지만 REPEATABLE READ는 발생할 수 있다. 예를 들어 트랜잭션1이 10살 이하의 회원을 조회했는데 트랜잭션 2가 5살 회원을 추가하고 커밋하면 트랜잭션1이 다시 10살 이하의 회원을 조회했을 때 회원 하나가 추가된 상태로 조회된다. 이처럼 반복 조회시 결과 집합이 달라지는 것을 REPEATABLE READ라 한다. NON-REPEATABLE READ는 허용하지 않지만 REPEATABLE READ는 허용하는 격리 수준을 REPEATABLE READ라 한다.
  4. SERIALIZABLE : 가장 엄격한 트랜잭션 격리 수준이다. 여기서는 REPEATABLE READ가 발생하지 않는다. 하지만 동시성 처리 성능이 급격히 떨어 질 수 있다.

애플리케이션 대부분은 동시성 처리가 중요하므로 데이터베이스들은 보통 READ COMMITTED 격리 수준을 기본으로 사용한다.

낙관적 락과 비관적 락

JPA의 영속성 컨텍스트를 적절히 활용하면 데이터베이스 트랜잭션이 READ COMMITTED 격리 수준이어도 애플리케이션 레벨에서 반복 가능한 읽기(REPEATABLE READ)가 가능하다. 물론 엔티티가 아닌 스칼라 값을 직접 조회하면 영속성 컨텍스트의 관리를 받지 못하므로 반복 가능한 읽기를 할 수 없다.

낙관적 락은 이름 그대로 트랜잭션 대부분은 충돌이 발생하지 않는다고 낙관적으로 가정하는 방법이다. 이것은 데이터베이스가 제공하는 락 기능을 사용하는 것이 아니라 JPA가 제공하는 버전 관리 기능을 사용한다. 쉽게 이야기해서 애플리케이션이 제공하는 락이다. 낙관적 락은 트랜잭션을 커밋하기 전까지 트랜잭션의 충돌을 알 수 없다는 특징이다.

비관적 락은 이름 그대로 트랜잭션의 충돌이 발생한다고 가정하고 우선 락을 걸고 보는 방법이다. 이것은 데이터베이스가 제공하는 락 기능을 사용한다. 대표적으로 select for update 구문이 있다.

@Version

낙관적 락과 비관적 락을 설명하기 전에 먼저 @Version을 알아보자. JPA가 제공하는 낙관적 락을 사용하려면 @Version 어노테이션을 사용해서 버전 관리 기능을 추가해야 한다.

@Version 적용 가능 타입은 다음과 같다.
– Long (long)
– Integer (int)
– Short (short)
– TimeStamp

@Entity
@Data
public class Board {

  @Id
  private Long id;

  private String title;

  @Version
  private Integer version;
}

@Version 어노테이션을 붙이면 엔티티가 수정될때 자동으로 버전이 하나씩 증가하게 된다. 엔티티를 수정할 때 조회 시점의 버전과 수정 시점의 버전이 다르면 예외가 발생한다. 예를 들어 트랜잭션 1이 조회한 엔티티를 수정하고 있는데 트랜잭션 2에서 같은 엔티티를 수정하고 커밋해서 버전이 증가해버리면 트랜잭션 1이 커밋할 때 버전 정보가 다르므로 예외가 발생한다.

public static void main(String[] args) {
  //엔티티 매니저 생성 (비용이 많이 안든다.)
  EntityManager entityManager = entityManagerFactory.createEntityManager();

  //트랜잭션 획득
  EntityTransaction transaction = entityManager.getTransaction();

  try {
    transaction.begin(); 
    //트랜잭션1 조회
    Board board = entityManager.find(Board.class, 1L);
    //트랜잭션 2에서 해당 게시물을 수정
    board.setTitle("aaa");
    //예외 발생 데이터베이스에는 version=2 엔티티의 version=1
    transaction.commit(); 
  } catch (Exception e) {
    System.out.println(e);
    transaction.rollback();
  } finally {
    entityManager.close();
  }

  entityManagerFactory.close();
}

제목이 A이고 버전이 1인 게시물이 있다고 가정하자. 트랜잭션 1은 이것을 제목 B로 변경하려고 조회했다. 이때 트랜잭션 2가 해당 데이터를 조회해서 제목을 C로 수정하고 커밋해서 버전정보가 2로 증가했다. 이후에 트랜잭션 1이 데이터를 제목 B로 변경하고 트랜잭션을 커밋하는 순간 엔티티를 조회할 때 버전과 데이터베이스의 버전이 다르므로 예외가 발생한다. 버전 정보를 사용하면 최초 커밋만 인정된다.

JPA 락

락은 다음 위치에 적용할 수 있다.
1. EntityManager.lock(), EntityManager.find(), EntityManager.refresh()
2. Query.setLockMode() (TypeQuery 포함)
3. NamedQuery

Board board = em.find(Board.class, id, LockModeType.OPTIMISTIC);
위에 처럼 조회하면서 즉시 락을 걸 수도 있고

Board board = em.find(Board.class, id);

em.lock(LockModeType.OPTIMISTIC);
이처럼 필요 할 때 락을 걸 수도 있다.

JPA가 제공하는 락 옵션은 javax.persistence.LockModeType에 정의되어있다.

락 모드 타입 설명
낙관적 락 OPTIMISTIC 낙관적 락을 사용한다.
낙관적 락 OPTIMISTIC_FORCE_INCREMENT 낙관적락 + 버전정보를 강제로 증가한다.
비관적 락 PESSIMISTIC_READ 비관적 락, 읽기 락을 사용한다.
비관적 락 PESSIMISTIC_WRITE 비관적 락, 쓰기 락을 사용한다.
비관적 락 PESSIMISTIC_FORCE_INCREMENT 비관적락 + 버전정보를 강제로 증가한다.
기타 NONE 락을 걸지 않는다.
기타 READ JPA 1.0 호환기능이다. OPTIMISTIC 같다
기타 WRITE JPA 1.0 호환기능이다. OPTIMISTIC_FORCE_INCREMENT 같다

이것으로 JPA의 락에 대해서 알아 봤다.
나중에는 락 타입에 대해서 상세하게 알아보자.

출처 : 자바 ORM 표준 JPA 프로그래밍 (김영한)

JPA n+1을 해결하는 방법

이번에는 JPA의 n+1을 해결하는 방법을 한번 살펴보자.
n+1의 예를 한번들어보자.

어떤 Account 라는 엔티티와 Order라는 엔티티가 있다고 가정하자.

@Entity
public class Account {

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

  private String name;

  private String password;

  private String email;

  @OneToMany(mappedBy = "account", fetch = FetchType.LAZY)
  private List<Order> orders;
}
//getter setter etc..

@Entity
public class Order {

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

  @ManyToOne
  @JoinColumn(name = "ACCOUNT_ID")
  private Account account;

  @Temporal(TemporalType.TIMESTAMP)
  private Date orderDate;

//getter setter etc..
}

위와 같은 도메인이 있다고 가정할때 우리는 n+1 만날 수 있다. 예를 들어 Account를 찾고 그 안에 주문정보를 보고 싶다면 Account를 조회 후 해당하는 주문정보를 또 조회해야 한다. 예를들어 계정 정보가 10개가 있다면 주문 정보도 10번 조회를 해야 한다. 또한 주문정보에는 주문한 item들이 있기도 하기에 이게 계속 되다보면 성능에 영향을 미칠 수 도 있다.
이것을 해결 하기 위해 우리는 몇가지 방법을 알아볼 예정이다.
Account를 찾아서 그 아래에 있는 리스트를 가져오려면 아래와 같은 쿼리문이 n번 날라간다.

Hibernate: 
    select
        account0_.account_id as account_1_0_,
        account0_.email as email2_0_,
        account0_.name as name3_0_,
        account0_.password as password4_0_ 
    from
        account account0_
Hibernate: 
    select
        orders0_.account_id as account_3_3_0_,
        orders0_.order_id as order_id1_3_0_,
        orders0_.order_id as order_id1_3_1_,
        orders0_.account_id as account_3_3_1_,
        orders0_.order_date as order_da2_3_1_ 
    from
        orders orders0_ 
    where
        orders0_.account_id=?
Hibernate: 
    select
        orders0_.account_id as account_3_3_0_,
        orders0_.order_id as order_id1_3_0_,
        orders0_.order_id as order_id1_3_1_,
        orders0_.account_id as account_3_3_1_,
        orders0_.order_date as order_da2_3_1_ 
    from
        orders orders0_ 
    where
        orders0_.account_id=?
  ...
  ...
//n개

이것을 해결하려면 JPA의 패치 조인을 사용하면 해결 할 수 있다. jpql를 직접 써도 되지만 필자는 querydsl을 사용하였다.

public List<Account> findByleftJoinOrders() {
  QAccount account = QAccount.account;
  QOrder order = QOrder.order;
  return from(account)
    .leftJoin(account.orders, order).fetchJoin()
    .fetch();
}

로그를 살펴보면 쿼리가 한번 출력 되는 것을 확인 할 수 있다.

Hibernate: 
    select
        account0_.account_id as account_1_0_0_,
        orders1_.order_id as order_id1_3_1_,
        account0_.email as email2_0_0_,
        account0_.name as name3_0_0_,
        account0_.password as password4_0_0_,
        orders1_.account_id as account_3_3_1_,
        orders1_.order_date as order_da2_3_1_,
        orders1_.account_id as account_3_3_0__,
        orders1_.order_id as order_id1_3_0__ 
    from
        account account0_ 
    left outer join
        orders orders1_ 
            on account0_.account_id=orders1_.account_id

하지만 여기의 예제에서는 문제가 있다 중복으로 데이터들을 가져온다. 그걸 방지하고자 distinct를 사용해서 중복을 제거하자.

public List<Account> findByleftJoinOrders() {
  QAccount account = QAccount.account;
  QOrder order = QOrder.order;
  return from(account)
    .distinct()
    .leftJoin(account.orders, order).fetchJoin()
    .fetch();
}

다시 테스트를 해보면 중복이 제거된 상태로 출력이 된다. 근데 순서가 맞지 않게 정렬 되어 나온다. account도 그렇지만 그 안에 주문 정보도 마찬가지다.
아래와 같이 orderby를 넣어주면 끝이다.

public List<Account> findByleftJoinOrders() {
  QAccount account = QAccount.account;
  QOrder order = QOrder.order;
  return from(account)
    .distinct()
    .leftJoin(account.orders, order).fetchJoin()
    .orderBy(account.id.asc(), order.id.asc())
    .fetch();
}

이렇게 fetch 조인을 써서 n+1을 막을 수가 있다. fetch 조인 말고도 방법이 몇가지 있는데 이것은 하이버네이트에서 제공해주는 방법이 있다. 하이버네이트에 의존적이긴 하지만 거의 대부분이 하이버네이트를 쓰기에 그렇게 많은 문제는 되지 않을 듯하다.

@OneToMany(mappedBy = "account", fetch = FetchType.LAZY)
@BatchSize(size = 5)
private List<Order> orders;

하이버네이트에서 제공해주는 BatchSize 어노테이션을 넣어주면 끝이다. 저 사이즈는 한번에 가져오는 갯수를 말하는것이다. 예를들어 Account가 10개 있으면 Order도 10번의 쿼리를 날려서 가져오는데 5개씩 2번의 쿼리만 날려서 가져오게 된다.

Hibernate: 
    select
        account0_.account_id as account_1_0_,
        account0_.email as email2_0_,
        account0_.name as name3_0_,
        account0_.password as password4_0_ 
    from
        account account0_
Hibernate: 
    select
        orders0_.account_id as account_3_3_1_,
        orders0_.order_id as order_id1_3_1_,
        orders0_.order_id as order_id1_3_0_,
        orders0_.account_id as account_3_3_0_,
        orders0_.order_date as order_da2_3_0_ 
    from
        orders orders0_ 
    where
        orders0_.account_id in (
            ?, ?, ?, ?, ?
        )
Hibernate: 
    select
        orders0_.account_id as account_3_3_1_,
        orders0_.order_id as order_id1_3_1_,
        orders0_.order_id as order_id1_3_0_,
        orders0_.account_id as account_3_3_0_,
        orders0_.order_date as order_da2_3_0_ 
    from
        orders orders0_ 
    where
        orders0_.account_id in (
            ?, ?
        )

위와 같은 쿼리를 날린다. in을 사용하여 데이터들을 가져오게된다. 만약 글로벌하게 설정하고 싶다면 아래와 같이 하면 된다. 필자는 Spring boot를 자주 사용해서 boot기준이다. 만약 boot를 사용하지 않느다면 아래와 같은 설정을 xml에 해주면 될 것으로 판단된다.

spring.jpa.properties.hibernate.default_batch_fetch_size=5

n+1의 마지막 방법으로 @Fetch(FetchMode.SUBSELECT)을 사용하면 된다. 이것 또한 하이버네이트에서 제공해주는 어노테이션이다.
방법 역시 간단하다.

@OneToMany(mappedBy = "account", fetch = FetchType.LAZY)
@Fetch(FetchMode.SUBSELECT)
private List<Order> orders;

위와 같이 어노테이션만 넣어 주면 끝난다.

Hibernate: 
    select
        account0_.account_id as account_1_0_,
        account0_.email as email2_0_,
        account0_.name as name3_0_,
        account0_.password as password4_0_ 
    from
        account account0_
Hibernate: 
    select
        orders0_.account_id as account_3_3_1_,
        orders0_.order_id as order_id1_3_1_,
        orders0_.order_id as order_id1_3_0_,
        orders0_.account_id as account_3_3_0_,
        orders0_.order_date as order_da2_3_0_ 
    from
        orders orders0_ 
    where
        orders0_.account_id in (
            select
                account0_.account_id 
            from
                account account0_
        )

그럼 위와 같이 서브 쿼리를 작성해서 날라간다.

필자가 말한 위의 3가지 방법 모두 사용가능하지만 @Fetch와 @BatchSize 는 너무 정적이다. 굳이 필요 없을 때에도 쿼리를 날려야만 한다. 그게 그렇게 많은 상관이 없다면 해도 되지만 정적인것을 역시 맘에 들지 않는다. 그래서 필자는 fetch조인을 그때그때 사용하는 것이 낫다고 판단한다. (물론 필자생각)
이것저것 다 해보고 자신에게 맞는 것만 한다면 아무거나 해도 상관은 없다.
이상으로 n+1에 대한 해결 방법을 알아봤다.

출처 : 자바 ORM 표준 JPA 프로그래밍 (김영한)