개요

 

우리는 JPA를 사용하며, 어떠한 이점을 누릴 수 있는지에 대한 질문을 받는다면, 1차 캐시를 빼고 말하기 어렵습니다. "1차 캐시가 어떻게 동작하는데??"라고 질문받는다면, "영속성 콘텍스트에 보관되고, 사용하면 돼!"라고만 답하는 사람이 있을 겁니다.. (저 역시도 얼마 전까지는..) 하지만 일을 하다 보니 느끼는 것이 어떻게 사용하는 것이 아닌 왜 사용하고, 어떻게 적용 돼 사이드 이펙트가 있을 것인지에 대해서 집중하게 됐습니다. 그래서 1차 캐시가 어떻게 동작하는지에 대해서 알아보겠습니다.

(1차 캐시를 날리는 @Modfiying clearAutomatically를 사용하며, 부족함을 느끼게 돼  정리하게 됐습니다.)

 

1차 캐시란?

 

우선 동작을 알아보기 전에 1차 캐시가 무엇인지 알아보겠습니다.

 

1차 캐시란?

영속성을 이용해서, DB에서 엔티티를 조회하고, 바로 버리는 것이 아닌 영속성 컨텍스트라는 곳에 저장해 같은 엔티티를 조회한다면, 영속성 콘텍스트에서 찾아 DB에서 조회하는 횟수를 줄여, 성능상 이점을 가져올 수 있습니다. 간단한 코드로 보겠습니다. 

 

@Test
void cacheTest(){
    Person person = Person.createPerson("person1", 10, "region1");

    personRepository.save(person); // 1

    em.clear(); // 2

    Person result1 = personRepository.findById(person.getId()).get(); // 3
    Person result2 = personRepository.findById(person.getId()).get(); // 4

}

 

1. Person 객체를 저장합니다. 객체를 저장하며, 영속석 컨텍스트에 저장됩니다.

2. 영속성 컨텍스트의 내용을 초기화합니다.

3. Person 객체를 찾습니다.

4. 3와 동일

 

이때 날라간 쿼리를 살펴보겠습니다.

 

 

1은 저장 쿼리이며, 2는 엔티티 조회 쿼리입니다. 3, 4 모두 조회 쿼리이지만, 3에서 엔티티를 조회하며, 영속성 콘텍스트에 저장해 4의 쿼리는 날아가지 않은 모습을 볼 수 있습니다. 1차 캐시가 동작하는 것을 알아봤고, 그렇다면 동작하는 과정을 알아보겠습니다.

 

1차 캐시 동작 과정

 

1. JpaRepository를 구현하고 있는 SimpleJpaRepository의 findById

 

public Optional<T> findById(ID id) {
    Assert.notNull(id, "The given id must not be null!");
    Class<T> domainType = this.getDomainClass();
    if (this.metadata == null) { // 1
        return Optional.ofNullable(this.em.find(domainType, id));
    } else { // 2
        LockModeType type = this.metadata.getLockModeType();
        Map<String, Object> hints = new HashMap();
        this.getQueryHints().withFetchGraphs(this.em).forEach(hints::put);
        return Optional.ofNullable(type == null ? this.em.find(domainType, id, hints) : this.em.find(domainType, id, type, hints));
    }
}

 

1. metadata는 CrudMetadata로서 QueryHint, LockMode, EntityGraph를 가지고 있는 인터페이스입니다. 정보가 없다면 바로 EntityManager(이하 em)으로 엔티티를 찾아 반환합니다.

 

2. metadata의 정보를 넣어 em으로 엔티티를 찾아 반환하는 것을 알 수 있습니다. 

 

2. em.find를 구현하고 있는 SessionImpl

 

sessionImpl에는 멤버 변수로 PersistenceContext를 가지고 있습니다. 구현체는 StatefulPersistenceContex이고 말 그대로 영속성 컨텍스트로서, 1차 캐시를 구현하게 해 줍니다. 

 

public class SessionImpl extends AbstractSessionImpl implements EventSource, SessionImplementor, HibernateEntityManagerImplementor {
    private static final EntityManagerMessageLogger log = HEMLogging.messageLogger(SessionImpl.class);
    private Map<String, Object> properties;
    private transient ActionQueue actionQueue = this.createActionQueue();
    private transient StatefulPersistenceContext persistenceContext = this.createPersistenceContext();
    private transient LoadQueryInfluencers loadQueryInfluencers;
    private LockOptions lockOptions;
    private boolean autoClear;
    private boolean autoClose;
    private boolean queryParametersValidationEnabled;
    private transient int dontFlushFromFind;
    private transient LoadEvent loadEvent;
    private transient TransactionObserver transactionObserver;
    private transient boolean isEnforcingFetchGraph;
    private transient SessionImpl.LobHelperImpl lobHelper;
	// 중략
}


3. StateFullPersistenceContext

 

많은 멤버 변수들이 존재하고, 눈여결 볼 변수들은 아래와 같습니다.

 

private HashMap<EntityKey, Object> entitiesByKey;
private HashMap<EntityUniqueKey, Object> entitiesByUniqueKey;
private ConcurrentReferenceHashMap<EntityKey, Object> proxiesByKey;
private HashMap<EntityKey, Object> entitySnapshotsByKey;

 

3-1. entitedByKey: Entity 들을 보관하는 HashMap입니다. key를 이용해 엔티티를 찾을 수 있습니다.

 

3-2. entitiesByUniqueKey: 엔티티의 키가 유티크 할 때의 HashMap입니다.

 

3-3. proxiesByKey: 지연로딩일 때 사용되는 Map이고, 실제 엔티티의 키를 가지고 있습니다. 

 

3-4. entitySnapshotsByKey: 엔티티가 수정될 때 엔티티에 대한 값을 스냅숏으로 저장해 더티 채킹과 같은 기능을 수행할 수 있게 해 줍니다.

 

메서드를 실행하는 위치는 sessionImpl에서 readObject 메서드를 통해서 persistenceContext가 객체를 조회하고 위에서 살펴본 Map에 키와 객체를 저장합니다. 

private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException, SQLException {
    if (log.isTraceEnabled()) {
        log.tracef("Deserializing Session [%s]", this.getSessionIdentifier());
    }

    ois.defaultReadObject();
    this.persistenceContext = StatefulPersistenceContext.deserialize(ois, this);
    this.actionQueue = ActionQueue.deserialize(ois, this);
    this.loadQueryInfluencers = (LoadQueryInfluencers)ois.readObject();
    Iterator var2 = this.loadQueryInfluencers.getEnabledFilterNames().iterator();

    while(var2.hasNext()) {
        String filterName = (String)var2.next();
        ((FilterImpl)this.loadQueryInfluencers.getEnabledFilter(filterName)).afterDeserialize(this.getFactory());
    }

}

 

4. StatefulPersistenceContext.deserialize()

 

public static StatefulPersistenceContext deserialize(ObjectInputStream ois, SessionImplementor session) throws IOException, ClassNotFoundException {
    LOG.trace("Deserializing persistence-context");
    StatefulPersistenceContext rtn = new StatefulPersistenceContext(session);
    SessionFactoryImplementor sfi = session.getFactory();

    try {
        rtn.defaultReadOnly = ois.readBoolean();
        rtn.hasNonReadOnlyEntities = ois.readBoolean();
        int count = ois.readInt();
        if (LOG.isTraceEnabled()) {
            LOG.trace("Starting deserialization of [" + count + "] entitiesByKey entries");
        }

        rtn.entitiesByKey = new HashMap(count < 8 ? 8 : count);

        for(i = 0; i < count; ++i) {
            rtn.entitiesByKey.put(EntityKey.deserialize(ois, sfi), ois.readObject());
        }
        // 중략
    }
}

 

엔티티를 해쉬 맵에 저장하는 것을 볼 수 있습니다. 이때 Session과 PersistenceContext는 1:1 맵핑이 됩니다. EntityManager는 ThreadLocal로 개별 스레드로 보관되기 때문에 Transaction이 시작하면, 1차 캐시가 생선 되고, 끝나면 1차 캐시도 사라지는 것을 알 수 있습니다.

 

정리

1차 캐시란? 영속성 콘텍스트에 보관되며, 엔티티 정보를 키 벨류 값으로 가지고 있어 데이터 베이스에 접근하는 횟수를 줄여 성능상의 이점을 가져옵니다. 영속성 콘텍스트의 구현체는 StatefulPersistenceContext이며, Session과 1:1로 맵핑되기 때문에 1차 캐시는 각 스레드의 Transaction의 시작과 끝과 같은 라이프 사이클일 가질 수 있습니다.