본문 바로가기
Backend/JPA & QueryDSL

[JPA] 영속성 컨텍스트

by 제이동 개발자 2023. 7. 9.
728x90

영속성 컨텍스트

 영속성 컨텍스트는 EntityManagerFactory에 의해 하나의 요청(Thread)마다 EntityManager가 생성되고 EntityManager 안에서 사용하는 저장 공간이 바로 영속성 컨텍스트 입니다. JPA에서 영속성 컨텍스트는 한 Transaction 내에서 DB에 저장한 Entity와 DB에서 조회한 Entity를 영속성 컨텍스트에 저장하여 사용하기 때문에 성능을 이끌어주는 중요한 역할을 합니다. 

 

 

1. 영속성 컨텍스트의 특징

1-1. 1차 캐시

 영속성 컨텍스트는 저장 공간으로 한 Transaction 내에서 DB에 저장한 Entity와 DB에서 조회한 Entity를 Transaction이 끝날 때까지 1차 캐시에 저장합니다. 1차 캐시에 저장된 Entity들은 find 메서드, 즉 조회쿼리를 실행하게 되면 DB에서 조회하는 것이 아닌 1차 캐시에 저장되어 있는 Entity를 가져오기 때문에 성능을 최적화시켜 주는 특징이 있습니다.

Member memberEntity = new Memeber("홍길동");
// 영속성 컨텍스트 1차 캐시에 Member Entity가 저장된다.
Member findMember1 = memberRepository.save(memberEntity);

// findMember1 다시 조회
// 영속성 컨텍스트 1차 캐시에 저장된 식별자가 1인 Member Entity를 조회한다.
Member findMember2 = memberRepository.findById(findMember1.getId());

 

1-2. 동일성 보장(identity)

 한 Transcation 내에 1차 캐시에 저장된 Entity들은 각 Entity의 식별자 값(PK)으로 구분이 되며 같은 식별자를 가진 Entity들은 동일성, 즉 같은 객체임을 보장합니다.

// 식별자 값이 1인 member 조회
// 영속성 컨텍스트 1차 캐시에 DB에서 조회한 Member Entity가 저장된다.
Member findMember1 = memberRepository.findById(1L);

// 식별자 값이 1인 member 다시 조회
// 영속성 컨텍스트 1차 캐시에 저장된 식별자가 1인 Member Entity를 조회한다.
Member findMember2 = memberRepository.findById(1L);

System.out.print(findMember1 == findMember2) // true

 

1-3. 쓰기 지연 & 변경 감지(Dirty Checking)

 Entity를 수정할 경우 쓰기 지연이 적용됩니다. 쓰기 지연이란 한 Transcation 내에서 1차 캐시에 저장되어 있는 Entity를 수정했을 경우 Transcation 끝나는 시점에 수정된 내용들을 자동으로 Update 쿼리를 DB에 전달하는 것을 말합니다. 따라서 수정 쿼리를 DB에 직접 전달하지 않아도 조회한 Entity를 수정만 하면 Transcation이 끝나는 시점에 Update 쿼리가 실행되기 때문에 코드의 생산성이 증가한다는 장점이 있습니다. 또한 Entity가 수정이 되었을 때마다 쿼리가 전달되는 것이 아닌 Transcation이 끝나는 시점에 한 번에 전달되기 때문에 성능이 향상된다는 장점도 있습니다.

@Test
@Rollback(value = false)
@Transactional
public void 쓰기지연_테스트() {
    log.info("save 1 이전");
    TestEntity testEntity1 = testEntityRepository.save(
            TestEntity.builder()
                    .name("홍길동")
                    .age(10).build()
    );
    log.info("save 1 이후");

    log.info("save 2 이전");
    TestEntity testEntity2 = testEntityRepository.save(
            TestEntity.builder()
                    .name("이몽룡")
                    .age(10).build()
    );
    log.info("save 2 이후");

    log.info("testEntity 1 값 변경 이전");
    testEntity1.setAge(20);
    log.info("testEntity 1 값 변경 이후");

    log.info("testEntity 2 값 변경 이전");
    testEntity2.setAge(30);
    log.info("testEntity 2 값 변경 이후");
}

더보기

이상한 점이 있지 않나요?

 

쓰기 지연의 특징은 Transcation이 끝나는 시점에 한 번에 쿼리가 실행된다고 했는데 insert 쿼리는 중간에 실행이 되었습니다. 그 이유는 JpaRepository의 구현체인 SimpleJpaRepository.save 메서드에서 확인할 수 있습니다. save 메서드에는 @Transactional이 있는 것을 볼 수 있는데 save 메서드를 호출하게 되면 하나의 트랜젝션이 새로 생성되고, 생성된 트랜젝션이 끝나면서 insert 쿼리를 실행하기 때문입니다.

@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
	...
	@Transactional // 새로운 트랜잭션이 생성이 되고 save 메서드가 끝나면 트랜잭션 종료
	@Override
	public <S extends T> S save(S entity) {

		Assert.notNull(entity, "Entity must not be null.");

		if (entityInformation.isNew(entity)) {
			em.persist(entity);
			return entity;
		} else {
			return em.merge(entity);
		}
	}
	...
}

 

 위 결과로 알 수 있듯이 testEntity1.setAge(20), testEntity2.setAge(30)으로 엔티티 값이 수정되면 update 쿼리는 Transcation이 끝나는 시점에 한 번에 실행되는 것을 볼 수 있습니다. 그렇다면 왜 수정된 시점에 update 쿼리를 전달하지 않고, Transcation이 끝나는 시점에 한 번에 실행이 될까요? 

  • 변경 작업을 일괄 처리하여 DB와의 통신 횟수를 줄일 수 있다.
  • 네트워크 비용 및 I/O 작업을 최소화하여 성능을 향상할 수 있다.
  • 쓰기 지연을 통해 동일 데이터에 대한 중복 업데이트를 방지하고 일관성을 유지할 수 있다.
더보기

쓰기 지연 사용 X, 10000개의 Entity 수정 시간 = 24.011초

쓰기 지연 사용 O, 10000개의 Entity 수정 시간 = 2.362초

@Test
@Rollback(value = false)
@Transactional
public void 쓰기지연_X_테스트_2() {
    List<TestEntity> testEntityList = new ArrayList<>();
    for (int i = 1; i <= 10000; i++) {
        testEntityList.add(testEntityRepository.save(
                TestEntity.builder()
                        .name("테스트" + i)
                        .age(1 * i)
                        .build()
        ));
    }

    long startTime = System.currentTimeMillis();
    log.info("startTime {}", startTime);
    for (int i = 0; i < 10000; i++) {
        TestEntity testEntity = testEntityList.get(i);
        testEntity.setAge(0);
        entityManager.merge(testEntity);
        entityManager.flush();
    }
    long stopTime = System.currentTimeMillis();
}

@Test
@Rollback(value = false)
@Transactional
public void 쓰기지연_O_테스트_3() {
    List<TestEntity> testEntityList = new ArrayList<>();
    for (int i = 1; i <= 10000; i++) {
        testEntityList.add(testEntityRepository.save(
                TestEntity.builder()
                        .name("테스트" + i)
                        .age(1 * i)
                        .build()
        ));
    }

    long startTime = System.currentTimeMillis();
    log.info("startTime {}", startTime);
    for (int i = 0; i < 10000; i++) {
        TestEntity testEntity = testEntityList.get(i);
        testEntity.setAge(0);
    }
    long stopTime = System.currentTimeMillis();
}

 

아래 이미지를 보면 어떠한 순서, 방법으로 쓰기 지연이 실행되는지 알 수 있습니다.

  1. 최초 1차 캐시에 저장될 때 Entity의 현재 상태를 복사하여 따로 저장한다.(스냅샷)
  2. Transcation 내에서 1차 캐시에 저장된 Entity들의 값이 변경이 이루어지면
  3. Transcation이 끝나는 시점(Commit)에 flush가 호출되어 1차 캐시에 저장되어 있는 Entity와 Entity의 스냅샷을 비교한다.
  4. 비교한 후 변경된 Entity들이 있으면 쿼리를 쓰기 지연 SQL 저장소에 보관한다.
  5. 1차 캐시에 있는 Entity들의 변경 내용들을 모두 확인하게 되면 쓰기 지연 SQL 저장소의 쿼리들을 DB에 전달한다.
728x90