[JPA] JPA 구조, 특성

개요

 

- DB와 객체를 어떻게 설계하여 매핑(Mapping)할 것인가?

- 내부에서 JPA는 어떻게 동작할까?

 

 

어플리케이션의 개발시 하나의 EntityManagerFactory를 통해서 고객의 요청(트랜잭션)이 올때마다 EntityManager가 생성되고 커넥션을 통해서 DB와 상호작용이 이루어진다. 이때 EntityManager마다 1대1로 영속성 컨텍스트가 생성된다. 

 

 

영속성 컨텍스트란?

 

  • 엔티티를 영구 저장하는 환경이라는 의미
  • 애플리케이션과 데이터베이스 사이에서 객체를 임시 보관하는 가상의 캐시, 데이터베이스같은 역할을 한다.
  • 엔티티매니저를 통해서 엔티티를 저장/조회!!하면 엔티티매니저는 영속성 컨텍스트를 통해서 엔티티를 보관하고 관리한다. >> 저장 뿐 아니라 조회시에도 DB의 데이터는 영속성 컨텍스트의 1차 캐시에 영속화된다.
  • 영속성 컨텍스트 안에는 1차 캐시가 존재한다. 후술하겠지만 영속성 컨텍스트==1차캐시라고 이해하면 될 것 같다.

 

 

 

엔티티의 생명주기

 

  • 비영속 : 객체가 생성되어 있지만 영속 컨텍스트(EntityManager)와의 연결이 이루어지지 않은 상태(JPA와 관련이 없는 순수한 객체 상태)
  • 영속 : 객체를 생성하고 EntityManager를 통해 em.persist(member);와 같이 persist가 실행된 상태. 사실 영속상태라고 DB에 저장되는(쿼리 전달) 것은 아니다. 
  • 준영속 : em.detach(member)-특정 엔티티만 분리, em.clear()-영속성 콘텍스트 초기화, em.close()-영속성 콘텍스트 종료 // 엔티티를 영속성 컨텍스트에서 분리한 상태. 더이상 엔티티 매니저가 관리하지 않음
  • 삭제 : em.remove(member) // 객체를 삭제한 상태

 

 

 

트랜잭션

 

 만약 DB의 데이터를 수정하는 도중에 exception이 발생한다던지 하는 경우에, DB의 데이터들은 수정이 되기 전의 상태로 다시 되돌아가져야 하고, 다시 수정작업이 진행되어야 할 것이다. 트랜잭션은 더 이상 쪼갤 수 없는 최소 작업의 단위를 의미한다. 그렇기에 트랜잭션은 commit시 성공하거나, 실패하면 rollback된다. 이렇게 트랜잭션은 데이터의 무결점성을 보장하게 된다.

클래스나 메소드에 @Transactional 어노테이션을 붙여줄 경우, 범위 내 메소드가 트랜잭션이 되도록 보장할 수 있다.

 데이터베이스 트랜잭션이 시작될 때, JPA는 DB 커넥션 풀에서 HikariCP와 같은 라이브러리를 사용하여 EntityManager로 Database Connection을 가져온다. 그리고 트랜잭션이 종료되면, (JPA의 기본 OSIV 설정이 true로 되어있다는 가정 하에) API response가 나가기 전까지 영속성 컨텍스트의 커넥션을 유지한다.

 

(OSIV: 트랜잭션이 끝나도 영속성 컨텍스트를 유지하는 JPA 전략이다. 기본값은 true.

https://dodeon.gitbook.io/study/kimyounghan-spring-boot-and-jpa-optimization/04-osiv 참고하면 될 것 같다)

 

 

 

 

============================================================================

 

 

영속성 컨텍스트 장점

 

- 1차 캐시

ㆍ 영속성 컨텍스트 내부에는 엔티티를 보관하는 저장소가 있는데, 이를 1차 캐시라고 한다. (사실 영속성 컨텍스트 자체가 사실상 1차 캐시다.)

ㆍ EntityManager로 조회하거나 변경하는 모든 엔티티는 먼저 1차 캐시에 저장되고, flush를 호출하거나 commit(자동 flush)하면 1차 캐시의 내역이 DB로 동기화된다. (단, 동기화가 1차캐시에서의 삭제를 의미하는것은 아니다!)

같은 트랜잭션 내에서 캐시값을 조회할때 db조회 전에 key를 id로, value를 엔티티로 하는 캐시에 있는 값을 반환받아 조회할 수 있다. (DB로 쿼리를 날리지 않고 1차캐시를 조회하기 때문에 여러번 호출되지 않는다). 만약 1차 캐시에 없다면 DB에서 조회해서 1차캐시에 저장후 반환한다. 캐시메모리나 TLB와 유사하게 이해하자

ㆍ 1차 캐시에 있는 값은 EntityManager가 트랜잭션 단위이기 때문에 트랜잭션이 끝나면 1차 캐시도 지워진다.

 

 

 

 

- 영속 엔티티의 동일성 보장

: 1차 캐시로 반복가능한 읽기등급의 트랜잭션 격리 수준을 DB가 아닌 애플리케이션 차원에서 제공한다. 쉽게 말해, 1차캐시의 존재로 인해서 같은 트랜잭션 내에서 같은 데이터에 대해 ==비교가 가능하게끔 한다.

Member a = em.find(Member.class, "member1");
Member b = em.find(Member.class, "member1");
System.out.println(a==b); // true returned

 

 

 

- 트랜잭션을 지원하는 쓰기 지연 (buffering)

EntityTransaction tx = em.getTransaction();
tx.begin();

em.persist(memberA);
em.persist(memberB);
//여기까지 쿼리문은 전달되지 않는다.

transaction.commit(); //트랜잭션을 커밋하는 순간 쿼리문을 DB에 전달

: persist만 하면 엔티티를 일단 1차 캐시에 저장하고, 엔티티를 분석해서 SQL문을 만들어 저장소에 저장해준다. 이후 commit()이 이루어져야만 저장소에서 flush되어 DB에 SQL문이 전송된다. 왜 쿼리문을 모아서 전송할까? 만약 persist문을 바로바로 sql을 작성하여 전송하게 되면, hibernate에서 네트워크를 여러번 거쳐 전송해야 하고 최적화 또한 불가능하게 된다.

 

 

 

 

- 변경 감지(Dirty Checking)

 JPA 첫 포스트의 마지막에서 이야기했던 'setter정도만 호출해주어도 알아서 db를 업데이트해주어 마치 컬렉션을 다루듯 객체와 DB를 다루게 해주는' 기능에 관한 내용이다. 

transaction.begin();
Member memberA = em.find(Member.class, "MemberA"); //DB에서 1차캐시로 영속화

memberA.setUsername("hi");
memberA.setAge("10");

//em.update(memberA); or em.persist(memberA)

transaction.commit(); //commit 직전시점에 이전의 entity와 커밋 entity를 비교하여 변경감지(Dirty checking)

 엔티티를 조회하고 setter로 값을 변경했으면, commit하기 이전에 update나 persist를 통해 갱신해주어야 하지 않을까? 라는 생각을 자연스럽게 하게 된다. 하지만 jpa는 마치 컬렉션에서 list.add나 list.set의 호출만을 통해서 값을 추가하고 업데이트하듯 setter의 호출만을 통해서 자연스럽게 DB의 정보까지도 바뀌도록 해준다(자동으로 업데이트 쿼리를 추가해준다!). 이것은 em.find로 EntityManager를 통해서 DB에서 데이터를 가져올 때 그 데이터가 1차 캐시에 영속화된 이후, JPA가 트랜잭션을 커밋하기 직전 시점에 1차캐시에서 엔티티의 snapshot과 커밋 이후의 snapshot을 비교해서 변경된 부분이 있다면 UPDATE sql을 생성해서 DB로 전송해주기 때문에 가능한 것이다.

 

더티 체킹은 Transaction 안에서 엔티티의 변경이 일어나면, 변경 내용을 자동으로 DB에 반영해준다. 예를 들어, 고객의 비밀번호를 변경하는 로직이 있다고 할 때,

 

//UserService.java
userRepository.save(new UserInfo(user.getId(), user.getEmail(), bCryptPasswordEncoder.encode(newPw), user.getUsername(), RoleType.USER, null, user.getPhoneNumber(), user.getIsGuardian()));

 

서비스 로직에서 이런 식으로 레포지토리에 직접 save함으로서 쿼리문을 날리게 해줄수도 있다. 하지만,

 

//UserInfo.java
public void updatePassword(String newPassword) {
    this.password = newPassword;
}

엔티티에 이러한 메소드를 만들고, 서비스 로직에서 이 로직을 호출하게 하면 자동으로 변경 감지가 이루어져서 업데이트 쿼리가 날아가게 된다.

 

 

 

============================================================================

 

플러시

 

트랜잭션의 커밋시 영속성 컨텍스트의 변경내용을 데이터베이스에 반영하는 것. jpa는 커밋시 수정된 엔티티를 쓰기 지연하여 SQL저장소에 전달하도록 등록한다. 이때 프로그래밍 언어의 flush같이 영속성 컨텍스트가 내보내고 비워지는 것이 아니라, 영속성 컨텍스트의 변경내용을 DB에 동기화해주는 것이라는 것에 유념하자. 또한, 플러시가 발생한다고 해서 트랜잭션이 커밋되는 것은 아니고, 커밋하는 경우에 등록된 쿼리문이 전달이 되는 것이라는 점도 헷갈리기 쉬운 부분이다.

em.flush(); //직접호출

tx.commit(); //커밋시 자동호출

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

1. em.flush();를 이용하면 커밋이 이루어지기 전 강제로 플러시가 호출되어 쿼리문이 DB로 반영된다.

(엔티티와 스냅샷 비교 후 변경된 것에 대한 SQL 생성 > 생성된 SQL을 쓰기 지연 SQL 저장소에 등록 > 쓰기 지연 SQL 저장소에 등록된 쿼리를 DB로 전송) // em.flush(); 실행시 이 모든 단계가 이루어지며, 마지막 단계를 flush라고 한다

2. commit시점에 자동호출된다.

3. jpql쿼리 실행시에 문제를 방지하기 위해서 자동으로 쿼리를 실행하기 전에 플러시가 호출된다.

4. jpa를 이용하는 것이 아니라 DB에 바로 접근시에는 flush();를 수동으로 호출해주어야 한다.

@Transactional 어노테이션의 범위 안의 메소드가 종료될 때 자동으로 commit(), flush()가 일어난다.

 

 

준영속 상태

 

em.persist나 em.find를 통해 영속 상태(1차 캐시에 올라간상태, JPA가 관리하는 상태)가 된 엔티티가 영속성 컨텍스트에서 분리된 상태. 직접 사용하는 경우는 흔하지 않다