JPA 시작과 영속성 관리

이경환·2023년 7월 17일
0

JPA

목록 보기
1/5

JPA는 무엇인가?

JPA는 자바 ORM 기술에 대한 API 표준 명세이며, 쉽게 말하자면 인터페이스입니다. JPA를 구현한 다양한 ORM 프레임워크가 있는데, 주로 하이버네이트를 사용합니다.

JPA를 왜 사용하는가?

반복적인 SQL을 개발자가 직접 작성하지 않아도 되므로 생산적인 측면에서 장점이 있습니다.
또 한 mybatis나 jdbc template와(sql 매퍼) 와는 다르게 sql에 의존적이지 않습니다.

개발자가 설정한 데이터 베이스 벤더에 따라 JPA가 SQL을 만들어주기 때문에 JPA는 특정 기술에 종속되지 않습니다.

영속성 관리

엔티티 매니저 팩토리와 엔티티 매니저

엔티티 매니저는 엔티티를 CRUD하는 엔티티에 관련된 일을 처리하는엔티티를 저장하는 가상의 데이터베이스라고 생각하자.

엔티티 매니저 팩토리는 엔티티 매니저를 만드는 공장인데, 만드는 비용이 상당히 크므로 애플리케이션 전체에서 1개만 만들어 공유합니다.

  • 엔티티 매니저 팩토리는 여러 스레드가 동시에 접근해도 안전합니다. 그러므로 다른 스레드 간에 공유가 가능합니다. 하지만 엔티티 매니저는 여러 스레드가 동시에 접근하면 동시성 문제가 발생하므로 스레드 간에 절대 공유하면 안됩니다.

영속성 컨텍스트란

엔티티를 영구 저장하는 환경 엔티티 매니저를 사용해서 영속성 컨텍스트에 저장합니다.

엔티티의 생명주기

영속 상태

em.persist(member);

영속성 컨텍스트가 관리하는 엔티티를 영속 상태라 합니다. 즉 영속상태라는 것은 영속성 컨텍스트에 의해 관리된다는 뜻입니다.

영속성 컨텍스트 특징

영속성 컨텍스트에 엔티티가 저장된다고 해서 데이터베이스에 저장되는 것은 아닙니다. Commit 되는 시점에 데이터베이스에 반영됩니다. 이것을 flush 라고합니다.

영속성 컨텍스트의 장점

  • 1차캐시
  • 동일성(identity) 보장
  • 트랜잭션을 지원하는 쓰기 지연 (transactional write-behind)
  • 변경 감지(Dirty Checking)
  • 지연 로딩(Lazy Loading)

엔티티 조회

영속성 컨텍스트는 내부에 캐시를 가지고 있는데 이것을 1차 캐시라 합니다. 영속 상태의 엔티티는 모두 이곳에 저장됩니다.

em.find()를 호출하면 우선 1차 캐시에서 식별자 값으로 엔티티를 찾습니다.
찾고자 하는 엔티티가 있으면 데이터베이스를 조회하지 않고 메모리에 있는 1차캐시에서 엔티티를 찾습니다. 만약 em.find()를 호출했는데 엔티티가 1차 캐시에 없으면 앤티티 매니저는 데이터베이스를 조회해 엔티티를 생성합니다. 그리고 1차 캐시에 저장한 후에 영속 상태의 엔티티를 반환합니다.

엔티티 등록

엔티티 매니저는 트랜잭션을 커밋하기 직전까지 데이터베이스에 엔티티를 저장하지 않고 내부 쿼리 저장소에 insert sql 문을 모아둡니다. 그리고 트랜잭션이 커밋 될 때. 데이터베이스 쿼리를 보냅니다 이것을 쓰기 지연이라고 합니다.

플러시

플러시flush()는 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영합니다.

JPA는 기본적으로 데이터를 맞추거나 동시성에 관련된 것들은 데이터베이스 트랜잭션(작업의 단위)에 위임합니다.

플러시를 하는 방법

  • em.flush() 를 직접 호출한다.
  • 트랜잭션 커밋 시 플러시가 자동 호출된다.
    데이터베이스에 변경 내용을 SQL 로 전달하지 않고 트랜잭션만 커밋하면 어떤 데이터도 데이터베이스에 반영되지 않는다. 따라서 트랜잭션을 커밋하기 전에 꼭 플러시를 호출해서 영속성 컨텍스트의 변경 내용을 데이터 베이스에 반영해야한다.
  • JPQL 쿼리 실행 시 플러시가 자동 호출된다.

직접 호출

다른 두 가지 방법은 밑에서 설명하겠습니다.

Member member = new Member(200L, "A");
entityManager.persist(member);

entityManager.flush();

tx.commit();

플러시가 일어난다고해서 1차캐시가 모두 사라지는 것은 아닙니다. 플러시는 결국
쓰기 지연 SQL 저장소에 있는 Query들이 DB에 전송되는 동기화 과정입니다.

트랜젝션 종료시 1차캐시 비워집니다.

중요한것은 영속성 컨텍스트의 내용을 비우거나 지우는 것이 아닌 플러시는 영속성 컨텍스트의 변경 내용을 DB에 동기화한다고 보면됩니다.

플러시 동작과정

엔티티 수정할 떄.

JPA로 엔티티를 수정할 때 엔티티의 변경 사항을 데이터베이스에 자동으로 반영하는 기능을 변경감지라 말합니다.(이 떄 변화의 기준은 기준은 최초 조회 상태입니다.) 변경 감지는 영속성 컨텍스트가 관리하는 영속 상태의 엔티티에만 적용됩니다.

엔티티를 영속성 컨텍스트에 보관할 때, 최초 상태를 복사해서 저장해 두는데 이것을 스냅샷이라 합니다. 그리고 플러시 시점에 스냅샷과 엔티티를 비교해서 변경된 엔티티를 찾습니다.

엔티티가 수정 됐을 때를 요약하자면
1. 트랜잭션을 커밋하면 앤티티 매니저 내부에서 먼저 플러시가 호출된다.
자세한 플러시을 동작과정은 밑과 같습니다.
2. 앤티티와 스냅샷을 비교해서 변경된 앤티티를 찾는다.
3. 변경된 엔티티가 있으면 수정 쿼리를 생성해서 쓰기 지연 SQL저장소에 보낸다.
4. 쓰기 지연 저장소의 SQL을 데이터베이스에 보낸다.
5. 데이터베이스 트랜잭션을 커밋한다.

플러시의 동작과정

  1. 변경을 감지한다. (Dirty Checking , 변화의 기준은 기준은 최초 조회 상태)
  2. 변경된 엔티티가 있으면 수정 쿼리를 생성해서 쓰기 지연 SQL저장소에 보낸다.
  3. 쓰기 지연 SQL 저장소의 Query를 DB에 전송한다. (insert, update, delete)
    flush가 발생한다고 해서 commit이 이루어지는 것이 아니고 flush 다음에 실제 commit이 일어납니다.
    플러시가 동작할 수 있는 이유는 데이터베이스 트랜잭션(작업 단위)이라는 개념이 있기 때문입니다.
    트랜잭션이 시작되고 해당 트랜잭션이 commit 되는 시점 직전에만 동기화 해주면 되기 때문에, 그 사이에서 플러시 동작이 가능합니다.

이러한 과정 때문에 플러시 작업은 정상적인 조건에서 트랜잭션이 커밋되면 백그라운드에서 자동으로 실행됩니다. 일반적으로 PersistenceContext에서 수동 플러시를 실행할 필요가 없습니다.

기억해야 할 것은 변경 사항은 트랜잭션이 커밋된 경우에만 영구적이 될 수 있습니다.

플러시의 모드

Jpa는

  • AUTO
    기본적으로 FlushMode는 AUTO 입니다. 이 경우 플러시 작업은 PersistenceContext에서 변경된 엔터티와 관련하여 ORM 쿼리(JPQL)가 실행되기 직전과 트랜잭션 커밋 중에 자동으로 실행됩니다.
em.persist(memberA);
em.persist(memberB);
em.persist(memberC);

//중간에 JPQL 실행
query = em.createQuery("select m from Member m", Member.class);
List<Member> members= query.getResultList();

쿼리를 실행시 플러시가 되지않는다면 commit 데이터베이스에 없으므로 쿼리 결과로 조회되지 않을것입니다. member들이 모두 영속 상태라면 JPA는 이런 문제를 예방하기 위해 JPQL을 실행할 때도 플러시를 자동 호출합니다.

  • COMMIT
    커밋 할때만 플러시 하는 모드 입니다. 성능 최적화를 위해 사용할 수도 있습니다.

JPQL은 기본적으로 영속성 컨텍스트에 있는 데이터를 고려하지 않고 데이터베이스에서 데이터를 조회합니다. 따라서 JPQL을 실행하기 전에 영속성 컨텍스트의 내용을 데이터베이스에 반영해야합니다.

//1
product.setPrice(2000);

//2
Product prodct = em.createQuery("select p from Product p where p.price = 2000",Product,class).getStringleResult();

첫 번째 setPrice()를 호출시 영속성 컨텍스트에 1000 -> 2000원 반영이되지만 commit을 하지않아 DB에는 반영이 되지앟는다. 두 번째 JPQL쿼리를 실행시 2000원인 상품을 조회시 플러시 모드를 따로 설정하지 않으면 자동으로 AUTO일 것입니다. 이 말은 즉 쿼리 실행 직전에 영속성 컨텍스트가 플러시됩니다. 2000원 상품을 조회 할 수 있습니다. 만약 이상황에서 flush 모드를 Auto 가아닌 commit으로 설정한다면 JPQL쿼리 실행시 자동 플러시가 되지않으므로 방금 수정한 데이터를 조회할 수 없습니다. 이때는 직접 em.flush()로 수동 플러시를 해줘야 합니다.

그럼에도 Commit 모드를 쓰는 이유는 성능상에 이점이 있기 때문입니다.

만약 Auto모드 일시

등록() -> 쿼리() 자동플러시 -> 등록() -> 쿼리() 자동플러시 ......
이런 비지니스 로직에서 플러시를 여러번 호출 하는것이 아닌 한번만 호출해 성능상의 이점을 가져갈수 있습니다.
Hibernate는

  • AUTO
  • COMMIT
  • MANUAL
    개발자가 데이터베이스에 대한 변경 사항을 동기화하기위해 flush() 메서드를 명시적으로 호출해야 합니다.
  • ALWAYS
    트랜잭션이 진행 중인지 여부에 관계없이 쿼리 실행 전에 데이터베이스에 대한 변경 사항을 자동으로 플러시합니다.
  Person person = new Person();
 	Person.setName("회원1");
    em.persist(person);

    em.createQuery("SELECT t FROM PersonTeam t").getResultList();
    
    
    Query q = em.createQuery("SELECT p FROM Person p WHERE p.name = :name");
    q.setParameter("name", "감자");
    
    q.getResultList();
 em.getTransaction().commit();

Auto 일경우 PersonTeam 엔티티가 Person엔티티를 참조하지 않기 때문에
insert 쿼리가 ("SELECT t FROM PersonTeam t").getResultList(); 쿼리를 실행하여도 나가지않습니다.

commit시점에야 비로소 insert 쿼리가 데이터베이스로 날라가게 됩니다.

하지만 ALWAYS 모드의 경우

 EntityManager em = emf.createEntityManager();
    Session session = em.unwrap(Session.class);
    session.setHibernateFlushMode(FlushMode.ALWAYS);
    
    

PersonTEam Person의 존재를 모름에도 불구하고 플러시 모드를 ALWAYS로 세팅하였기 때문에 select 쿼리가 날라가기 전에 insert 쿼리가 수행됩니다.

플러시 query 순서

쓰기 지연 저장소에 모아진 SQL이 Flush 될 때도 순서가 정해져 있습니다.

flush() 의 잠재적인 문제

  • 데이터베이스 경합: flush()를 사용할 때 잠재적으로 데이터베이스 경합에 직면할 수 있습니다. 이는 여러 트랜잭션이 동일한 데이터에 액세스하려고 시도하는 경우 가장 가능성이 높습니다. 이로 인해 잠금, 교착 상태 및 기타 데이터베이스 관련 문제가 발생할 수 있습니다.
  • 메모리 사용량 증가: 너무 자주 호출하면 메모리 사용량이 증가할 수 있습니다.
    너무 자주 호출되는 경우 flush()는 데이터베이스 연결을 비효율적으로 사용할 수 있습니다. flush()를 호출할 때마다 데이터베이스에 대한 새로운 왕복이 필요하므로 연결 자원의 소모, 연결 관련 문제가 발생할 수 있습니다.
  • 데이터 무결성 문제: flush()는 데이터 무결성 문제를 초래할 수 있습니다. 변경 순서에 주의하지 않으면 더욱 그렇습니다. 이로 인해 데이터베이스에 저장된 데이터의 불일치 및 오류가 발생할 수 있습니다.

준영속 상태

영속 상태였다가 더는 영속성 컨텍스트가 관리하지 않는 상태를 준영속 상태라 합니다.
당연히 1차 캐시, 쓰기 지연, 변경 감지, 지연 로딩을 포함한 영속성 컨텍스트가 제공하는 어떤 기능도 동작하지 않습니다.

준영속 상태를 만드는 방법

  1. em.detach(entity) : 특정 엔티티만 준영속 상태로 전환

  2. em.clear() : 영속성 컨텍스트를 완전히 초기화

  3. em.close() : 영속성 컨텍스트를 종료

엔티티를 준영속 상태로 전환 : detach()
em.detach(memberA)를 호출하는 순간 1차 캐시부터 쓰기 지연 SQL 저장소까지 해당 엔티티를 관리하기 위한 모든 정보가 제거됩니다.

detach가 하나의 엔티티라면 clear는 모든 엔티티를 준영속 상태로 만듭니다.
em.clear()를 호출하는 순간 영속성 컨텍스트에 있는 모든 것이 초기화됩니다. 영속성 컨텍스트에 관리되던 모든 엔티티들이 준영속 상태가 됩니다.

영속성 컨텍스트 종료 : close()

준영속 상태와 비영속 상태의 차이점

가장 큰차이로는 식별자가 있느냐 없느냐 입니다. 비영속 상태는 식별자 값이 없을 수도 있지만 준영속 상태는 이미 한 번 영속 상태였기 때문에 반드시 식별자 값을 가지고 있습니다.

병합 merge()

준영속 상태의 엔티티를 다시 영속 상태로 변경하려면 merge()를 사용합니다.
준영속 상태의 엔티티를 받아서 그 정보로 새로운 영속 상태의 엔티티를 반환합니다.

//준영속 상태에서 회원의 상태를 변경할 경우
Member member - createMember("memberA", "회원1"); //준영속 상태 엔티티 반환.
memeber.setUsername("회원명 변경)";

Member mergeMember = em2.merge(member);
tx2.commit // 엔티티 트렌젝션 커밋
  1. createMember가 준영속 상태의 엔티티를 반환할 경우
  2. setUsername()가 준영속 상태의 엔티티를 수정했기 때문에 영속성 컨텍스트에는 이 준영속 상태의 엔티티를 관리하지 않기때문에 수정사항을 데이터 베이스에 반영할수 없다.
  3. 준영속 상태의 엔티티를 수정하기 위해서는 다시 영속 상태로 변경해야한다 이때
    merge()를 사용한다. 이때
    mergeMember는 새로운 영속성 컨텍스트의 관리를 받고 트랜잭션을 커밋 할 때 수정했던 회원 명이 데이터베이스에 반영된다.(정확히는 member 엔티티가 준영속 상태에서 영속상태가 되는것은 아니고 mergeMember라는 새로운 영속 상태의 엔티티가 반환)

따라서 merge 연산 후에 원본 엔티티의 동일성은 보장되지 않습니다. 즉, merge된 엔티티와 원본 엔티티는 같은 객체로 간주되지 않습니다. 이는 merge된 엔티티가 새로운 객체로 생성되어 원본 엔티티와 다른 동일성을 가지기 때문입니다.

  • 순서
  1. 머지 실행
  2. 파라라미터의 준영속 엔티티의 식별자 값(준영속 상태기 때문에 식별자 값이 존재합니다.) 1차 캐시에서 엔티티를 조회.
    2-1. 만약 1차 캐시에 엔티티가 없으면 데이터 베이스에서 엔티티를 조회 한 후 1차 캐시에 저장.
  3. 조회한 영속 엔티티(mergeMember)에 member 엔티티의 값을 채워 넣는다.
    (member 엔티티의 모든 값을 mergeMember에 밀어넣는다. 이때 mergeMember의 setUsername 값이 변경됩니다.)
  4. mergeMember를 반환한다.

이 때 파라미터로 넘어온 member의 참조 변수는 여전히 준영속 상태입니다. mergeMember와는 주소값이 다른 변수로 서로 다른 인스턴스입니다.
더이상 member 변수는 쓸모없기 떄문에 안전하게 하기 위해서는

Member mergeMember = em2.merge(member); 보다는
member= em2.merge(member); 가 더 안전합니다.

  • SimpleJpaRepository의 save() 메서드
  @Transactional
    public <S extends T> S save(S entity) {
        if (this.entityInformation.isNew(entity)) {
            this.em.persist(entity);
            return entity;
        } else {
            return this.em.merge(entity);
        }
    }

entityInformation에서 새로운 entity이면 persist() 를 그게 아니면 merge()를 호출합니다.

persist -> detached -> persit = merge(새로운 상태)

  • 참고로 병합은 비영속 상태에서도 가능합니다.

    Member member = new Mmeber();
    Member newMember = em.merge(member);
    tx.commit;

하지만!

변경 감지와는 달리, 준영속 엔티티의 merge는 값을 모두 덮어씌우는 방식입니다.
병합 시 값이 없으면, null로 업데이트 할 위험이 있습니다. 변경할 필요가 없는 필드도 모두 변경합니다. 결론적으로는 변경 감지를 사용하는 것이 좋습니다.

참고자료: https://umanking.github.io/2019/04/12/jpa-persist-merge/

profile
개선하는 개발자, 이경환입니다

2개의 댓글

comment-user-thumbnail
2023년 7월 17일

잘봤습니다. 좋은 글 감사합니다.

답글 달기
comment-user-thumbnail
2023년 7월 17일

저도 개발자인데 같이 교류 많이 해봐요 ㅎㅎ! 서로 화이팅합시다!

답글 달기