JPA 영속성 컨텍스트

Hyun·2024년 1월 23일
0

Spring

목록 보기
31/38
post-thumbnail

JPA의 영속성 컨텍스트에 대해 정리할 때가 온 것 같다. 확실히 이걸 알고나니 JPA의 동작 방식을 논리적으로 이해할 수 있게된 것 같다. 강의를 보지 않고 여러 블로그들을 참고하여 공부하였기 때문에 타 블로그의 내용이 많이 들어가게 되었다.

영속성 컨텍스트

영속성 컨텍스트란 인스턴스로 존재하는 엔티티를 관리하고 영속화시키는 논리적 영역이다. 영속화의 사전적 의미는 '사라지지 않고 지속되게 한다'이다. 쉽게 말하자면 DB에 저장된다는 의미이다.

그러나 '영속'이라는 말은 단지 영속성 컨텍스트에서 관리된다는 의미이지, '영속화'되어 DB에 저장된 것은 아니다. 다만 영속화 될 수 있는 가능성이 있을 뿐이다.


영속성 컨텍스트는 Server 와 Database 사이에 위치한다.

영속성 컨텍스트에서 엔티티를 관리하고 필요에 따라 DB의 데이터를 저장, 조회, 수정, 삭제할 수 있다. 이러한 작업을 담당하는 객체를 '엔티티 매니저(Entity Manager)' 라고 한다. Entity Manager 는 JPA 에서 제공하는 interface로 Entity Manager 가 구현체로 spring bean 으로 등록되어 있어 Autowired로 사용할 수 있다. 아래 사진에서 Entity Manager 가 영속성 컨텍스트와 DB사이에 위치한다고 보면 된다.

영속성 컨텍스트는 크게 2가지 영역으로 나뉜다.

1차 캐시 저장소
영속성 컨텍스트가 관리하는 엔티티 정보를 보관한다. 이 상태를 '영속 상태'라고 한다. 다시 한번 강조하지만 '영속 상태'는 아직 DB에 저장된 상태가 아니다. 단순히 영속성 컨텍스트에서 관리(managed)하는 상태일 뿐이다.

1차 캐시 저장소는 Map 형태로 만들어지는데, Map의 key는 id값, value는 해당 entity 값이 들어있다. 후에 기술하겠지만 엔티티 타입이 다른 경우에 동일한 식별자값이 사용될 수 있기 때문에 key는 엔티티의 식별자와 타입을 함께 고려하여 다음과 같은 형태로 구성된다.

1차 캐시 저장소

Map<EntityKey, Object> firstLevelCache = new HashMap<>();

EntityKey

public class EntityKey {
    private Class<?> entityType;
    private Object identifier;
}

쿼리문 저장소 (SQL 저장소)
JPA는 필요한 쿼리문(SQL)을 보관해둔다. 최대한 여러 쿼리문을 모아두고, DB에 접근하는 횟수를 최소화하게 되면 성능상에 이점을 얻을 수 있기 때문이다. 저장해둔 쿼리문으로 DB에게 접근하는 행위는 엔티티 매니저의 플러시 flush() 로 진행한다.

엔티티의 생명주기

JPA(영속성 컨텍스트)의 입장에서 엔티티의 생명주기를 4가지로 나눌 수 있다. (엔티티는 쉽게 말해 하나의 인스턴스, DB입장에서는 한건의 레코드 정도로 이해하면 된다.)

1. 비영속(new, transient)상태
엔티티가 영속성 컨텍스트와 전혀 관련이 없는 상태이다.

2. 영속(managed) 상태
엔티티가 영속성 컨텍스트에서 관리되고 있는 상태이다. 다시 한번 강조하지만, 이는 아직 DB에 저장된 상태가 아니다. 엔티티 매니저의 'persist()' 를 사용하면 비영속 상태의 엔티티를 영속상태로 만들 수 있다.

위의 이미지는 persist() 를 실행한 후, 영속성 컨텍스트를 도식화 한 것이다. 엔티티를 저장하는 INSERT 쿼리문이 생성되었지만, 아직 DB에게 전달되지 않고 쿼리문 저장소에 보관되었다. 이 부분이 영속성 컨텍스트를 이해하는 핵심이다. '플러시 flush()'가 실행되기 전에는 실제 DB에게 접근하지 않는다.

여러개의 엔티티를 persist() 하게 되더라도, 해당하는 INSERT 쿼리문은 계속 보관하게 된다.

앞서 말했듯, 모아둔 쿼리문은 '플러시 flush()'를 실행하게 될 때 DB에 반영된다. 플러시를 하더라도 1차 캐시 저장소에서 관리중인 엔티티들이 사라지는 것은 아니다. 플러시는 영속성 컨텍스트와 DB를 동기화(Synchronize) 할 뿐이다.

생성한 엔티티를 입력할 때 이외에도 엔티티 매니저가 DB에서 조회해온 데이터도 '영속 상태'인 엔티티가 된다. 조회해온 데이터는 1차 캐시 저장소에 먼저 저장되고, 저장된 엔티티 정보를 반환한다. 조회를 하기 위해서는 엔티티 매니저의 find()를 사용한다.

만약 같은 엔티티를 여러 번 조회하게 되면 어떻게 될까?. JPA는 첫번째 조회때는 DB에 접근하겠지만 두번째 조회부터는 1차 캐시 저장소에 있는 엔티티를 반환하고 실제로 DB에게 접근은 하지 않는다. JPA가 조회와 관련한 성능상의 큰 이점을 취할 수 있는 이유이다. 또한 같은 인스턴스의 참조값을 반환하기 때문에, ==(주소값 비교) 로 동일성을 비교한다면 같은 인스턴스임을 확인할 수 있따. *값 비교는 equals()를 사용한다.

3. 준영속(detached) 상태
영속성 컨텍스트에서 관리되던 엔티티가 영속성 컨텍스트에서 관리되지 않게 되면, 이 엔티티를 준영속 상태라고 한다.

엔티티를 준영속 상태로 만드는 방법은 3가지가 있다.

1. 특정 엔티티를 준영속 상태로 만들기 위해서는 엔티티 매니저의 detach()를 사용한다.

detach 메서드는 특정 엔티티를 영속성 컨텍스트에서 분리하여 1차 캐시에서 제거한다. 그러나 이는 해당 엔티티와 관련된 쿼리문을 영속성 컨텍스트 내부의 쿼리문 저장소에서 제거하지는 않는다. 쿼리문 저장소에 있는 쿼리문들은 여전히 남아 있다.

따라서 detach를 호출하면 1차 캐시에서 해당 엔티티가 제거되지만, 쿼리문 저장소에 있는 쿼리문들은 그대로 남아 있다. 만약 해당 엔티티와 관련된 쿼리문도 함께 제거하고자 한다면, detach 대신 clear를 사용하여 영속성 컨텍스트를 초기화해야 한다.

2. 영속성 컨텍스트 전체를 초기화 시키는 clear() 를 사용할 수 있다. 이 때 쿼리문 저장소의 보관해둔 쿼리들도 모두 초기화된다.

영속성 컨텍스트를 초기화하면 영속상태의 entity가 모두 준영속 상태가 된다.

3. 영속성 컨텍스트를 닫아버리는 close() 를 사용한다면, 영속성 컨텍스트 자체가 사라지게 되니, 관리되던 엔티티들은 모두 준영속 상태가 된다. (close()는 엄밀히 말하면 엔티티 매니저가 닫히는 것이다. 상황에 다르지만 일단은 하나의 엔티티 매니저가 하나의 영속성 컨텍스트에 속한다고 보면 된다.)

영속성 컨텍스트가 사라진다면, 당연하게 영속상태이던 엔티티들이 모두 준영속 상태로 변환된다.

준영속 상태의 엔티티는 엔티티 매니저의 merge()를 사용하면 다시 영속성 컨텍스트에서 관리되는 '영속 상태'로 변환할 수 있다.

4. 삭제(removed) 상태
삭제 상태는 엔티티를 영속성 컨텍스트에서 관리하지 않게 되고, 해당 엔티티를 DB에서 삭제하는 DELETE 쿼리문을 보관하게 된다. persist() 와 마찬가지로 '플러시 flush()'가 호출되기 전까지는 실제 DB에 접근되지 않는다.

1차 캐시 저장소에 보관된 정보는 바로 삭제되고, DELETE 쿼리문만 남는다.

변경 감지(Dirty Checking)

지금까지 엔티티의 생명주기를 설명하면서 INSERT, SELECT, DELETE 를 어떤 식으로 진행하게 되는지 설명하였다. 하지만 UPDATE와 관련한 엔티티 매니저의 메소드는 정의되어 있지 않다. 엔티티에 변경사항이 생긴 경우, 이에 대한 '변경을 감지' 할 뿐이다.

사실 1차 캐시 저장소에는 본래 엔티티가 아니라, 엔티티에 대한 참조와 이 엔티티를 처음 영속 상태로 만들었을 때의 복사본(Snapshot)을 가지고 있다.(참고: 1차 캐시 저장소에서 영속성 컨텍스트는 엔티티의 타입과 @id 값으로 구별된다. 따라서 @id 가 같아도 타입이 다른 경우 영속성 컨텍스트에 함께 포함될 수 있다)

1차 캐시 저장소에는 엔티티의 참조값과 복사본(Snapshot)이 담긴다.

'플러시 flush()'가 호출되고 실행하기 직전에, 엔티티 매니저는 복사본(Snapshot)과 실제 엔티티를 비교한다. 만약 저장해둔 복사본과 참조값을 이용한 실제 엔티티를 대조했을 때, 내용이 다르다면(필드값이 다르다면) 엔티티 매니저는 '변경을 감지'할 수 있다. 이 경우 적절한 UDPATE 문을 자동으로 생성하고 플러시와 함께 쿼리문을 던져준다. 아래 예시를 살펴보자.

EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();
transaction.begin(); // [트랜잭션] 시작

// 영속 엔티티 조회
Member memberA = em.find(Member.class, "memberA");

// 영속 엔티티 데이터 수정
memberA.setUsername("hi");
memberA.setAge(10);

// em.update(member); 데이터 수정 후 이런 코드를 넣어야하지 않을까?

transaction.commit(); // [트랜잭션] 커밋

이렇게 영속성 컨텍스트 내의 엔티티를 수정한 뒤, 플러시 flush()가 호출되면, 별도의 db에 update를 알리는 문장없이, 플러시 flush()가 실행되기 직전 적절한 '변경을 감지'하여 update 쿼리문이 자동으로 생성되어 플러시와 함께 쿼리문이 날아간다.
*commit() 메서드가 호출될 때, 내부적으로 flush()도 함께 수행된다.

당연하게도 변경감지는 영속성 컨텍스트에서 관리되는 엔티티만을 대상으로 진행된다. 준영속 상태인 엔티티가 변경된다고 하더라도 변경감지는 발생하지 않는다.

주의 사항

1. 영속 상태인 객체는 식별자로만 조회가 가능하다.
영속성 컨텍스트는 내부적으로 식별자로 Entity 를 관리하기 때문에 영속상태인 객체는 식별자로만 조회가 가능하다.(식별자가 반드시 있어야 한다.)

@Transactional
public Team cashTest(Long id) {
    Team team = teamRepository.findById(id)
            .orElseThrow(RuntimeException::new);    // (1)

    Team team2 = teamRepository.findById(id)
            .orElseThrow(RuntimeException::new);    // (2)

    return team;
}

위 예제에선 식별자를 이용해 영속성 컨텍스트에서 엔티티를 조회하였고, 이미 조회가 되었기 때문에 두번째 조회는 영속성 컨텍스트 내부의 캐시에서 찾을 수 있어 결과적으로 SELECT 문이 1번만 사용되었다.

@Transactional
public Team cashTest3(Long id) {
    Team team1 = teamRepository.findById(id)
            .orElseThrow(RuntimeException::new);    // (1)

    Team team2 = teamRepository.findByName("test"); // (2)

    Team team3 = teamRepository.findByName("test"); // (2)

    return team1;
}

그러나 위의 경우, 식별자로 조회가 이미 되었으나 2,3번째 조회는 식별자가 아닌 방법으로 조회하고 있기 때문에 1차 캐시에서 조회할 수 없어 각각에 대해 DB에 SELECT 문이 날라가 총 3번의 SELECT 문이 사용되었다.

2. 엔티티 매니저, 영속성 컨텍스트, 트랜잭션의 관계
(스프링 컨테이너 기준)
스프링 컨테이너는 트랜젝션 범위의 영속성 컨텍스트 전략을 기본적으로 사용한다. 즉 트랜잭션의 범위와 영속성 컨텍스트의 생존 범위가 같다. 트랜잭션을 시작할 때 영속성 컨텍스트를 생성하고, 트랜잭션이 끝날 때 영속성 컨텍스트를 종료한다.

따라서 같은 트랜잭션 내에서는 여러 위치(레포지토리)의 엔티티 매니저를 사용해도 같은 영속성 컨텍스트에 접근하게 된다.

예를 들어, Transaction 범위의 한 클래스에서 repo1 의 어떤 em.~~() 을 수행하는 것과, repo2 의 어떤 em.~~()을 수행하는 것은 하나의 동일한 영속성 컨텍스트를 사용하고 있는 것이다.

실제 코드 예시를 살펴보자.

@Controller
class HelloController {
    @Autowired HelloService helloService;
    public void hello(){
        Member member = helloService.logic(); // 반환된 Member 엔티티는 준영속 상태
    }
}
@Service
class HelloService {
    @PersistenceContext
    EntityManager em;
    @Autowired Repository1 repository1;
    @Autowired Repository2 repository2;
    // 메소드를 호출할 때 트랜잭션을 먼저 시작
    @Transactional
    public void logic() {
        repository1.hello();
        // member는 영속상태 : 현재 트랜잭션 범위 안에 있으므로 영속성 컨텍스트의 관리를 받는다.
        Member member = repository2.findMember();
        return member;
    }
    // 트랜잭션 종료 : 트랜잭션 커밋, 영속성 컨텍스트 종료, 조회한 member는 이제부터 준영속 상태
}
@Repository
class Repository1 {
    @PersistenceContext
    EntityManager em;
    public void hello(){
        em.xxx(); // A 영속성 컨텍스트 접근
    }
}
@Repository
class Repository2 {
    @PersistenceContext
    EntityManager em;
    public void findMember(){
      return em.find(Member.class, "id1"); // B 영속성 컨텍스트 접근
    }
}

HelloService 클래스 내에서 하나의 트랜잭션으로 처리하는 logic()함수를 수행할때 repo1의 em.~~() 과 repo2의 em.~~()이 호출된다. 이들의 EntityManager 는 각기 다르지만 각각의 entitiyManager는 동일한 영속성 컨텍스트를 사용한다.

반면 트랜잭션이 다르면 동일한 엔티티 매니저를 사용해도 다른 영속성 컨텍스트를 사용한다.

Transaction 범위의 A클래스, Transaction 범위의 B 클래스가 동일한 repo의 em.~~() 을 동시에 사용해도 트랜잭션에 따라 접근하는 영속성 컨텍스트가 다르다. 즉, 스프링 컨테이너는 스레드마다 각각 다른 트랜잭션을 할당한다. 이를 통해 멀티 스레드 상황에서 안전성을 보장할 수 있다.

정리

  • 스프링 컨테이너에서 영속성 컨텍스트는 트랜잭션의 생존 범위와 같다.
  • 하나의 트랜잭션에서는 여러 엔티티 매니저가 하나의 영속성 컨텍스트를 사용한다.
  • 서로 다른 트랜잭션에서는 여러 엔티티 매니저가 서로 다른 영속성 컨텍스트를 사용한다(당연히 동일한 repo여도 해당된다.)

3. flush*()와 commit()
gpt는 다음과 같이 설명한다
flush()의 기능:

  • flush() 메서드는 영속성 컨텍스트의 변경 내용을 데이터베이스에 동기화하는 역할을 합니다.
  • 영속성 컨텍스트 내에서 수행된 모든 변경(추가, 수정, 삭제)을 데이터베이스에 반영합니다.
  • 영속성 컨텍스트의 1차 캐시에 있는 엔티티의 상태를 데이터베이스에 적용하며, 이때 SQL 쿼리가 생성되고 실행될 수 있습니다.
  • flush()를 호출하면 영속성 컨텍스트의 변경 내용이 데이터베이스에 반영되지만, 트랜잭션은 아직 커밋되지 않습니다.

commit()의 기능:

  • commit() 메서드는 트랜잭션을 커밋하는 역할을 합니다.
  • 트랜잭션이 커밋되면 데이터베이스에 영속성 컨텍스트의 변경 내용이 확정되고, 데이터베이스에 영구적으로 반영됩니다.
  • commit()이 호출되면 flush()도 내부적으로 수행되어 변경 내용을 데이터베이스에 동기화합니다.
  • 트랜잭션이 커밋되면 해당 트랜잭션 내에서 수행된 모든 데이터베이스 변경 작업이 영구적으로 적용됩니다.

JPA에서는 commit()을 호출할때 내부적으로 flush()가 호출되기 때문에, 일반적인 사용 패턴에서는 commit() 만 호출해도 영속성 컨텍스트의 변경 내용이 데이터베이스에 반영된다. 따라서 명시적으로 flush() 를 호출하지 않아도 된다.

자료 출처
https://devoong2.tistory.com/entry/JPA-%EC%98%81%EC%86%8D%EC%84%B1-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8Persistence-Context%EC%9D%98-5%EA%B0%80%EC%A7%80-%ED%8A%B9%EC%A7%95
https://ssdragon.tistory.com/59
https://siyoon210.tistory.com/138
https://www.nowwatersblog.com/jpa/ch13/13-1
https://www.youtube.com/watch?v=XlL0eq9Phws

profile
better than yesterday

0개의 댓글