1차 캐시의 특징

  • 1차 캐시의 실체는 JPA의 PersistenceContext 이며, 하이버네이트 구현체로는 Session 이다.
  • 1차 캐시가 작동하기 위해서는 PersistenceContext(Session) 인스턴스가 필요하다. 이 인스턴스는 트랜잭션이 시작될 때 팩토리로부터 생성된다. 즉 1차 캐시가 작동하기 위해서는 트랜잭션이 필요하다.
  • 1차 캐시는 특정 "Session(세션)" 인스턴스에 바인딩된다. 세션은 사실상 하나의 런타임 트랜잭션을 의미하며, 한 트랜잭션에 저장된 캐시는 다른 트랜잭션으로부터 격리된다. 즉 '볼 수 없다.'
  • 캐시된 인스턴스의 범위는 '세션' 까지이다. 인스턴스를 캐싱한 세션이 닫히면, 즉 트랜잭션이 종료되면 그 안에서 저장된 캐시도 모두 소멸된다.
  • 하이버네이트에서 1차 캐시는 기본적으로 활성화(사용) 상태이며, 비활성화 할 수 없다.
  • 한 세션(트랜잭션)에서 한 엔티티를 처음 쿼리할 때, 이 엔티티는 쿼리를 실행하여 DB로부터 읽어들이고 이를 세션에 바인딩된 1차 캐시에 저장한다.
  • 이미 세션의 1차 캐시에 저장된 엔티티를 다시 쿼리하면 이 엔티티는 DB가 아닌 캐시로부터 읽어들인다. 즉 쿼리를 실행하지 않는다.
  • 세션의 1차 캐시에 존재하는 인스턴스는 EntityManager.evict() 메서드를 통해 삭제할 수 있다. 삭제 후 같은 엔티티를 쿼리하면 이는 DB로부터 읽어들인다.
  • 세션의 1차 캐시의 데이터를 모두 삭제하려면 EntityManager.clear() 를 호출한다.
  • 읽기 전용 트랜잭션(@Transactional(readOnly = true)) 에서도 조회된 엔티티는 1차 캐시에 저장된다. 대신, 이 때 저장된 엔티티는 readonly 플래그를 지닌다.

 

엔티티 조회 시 쿼리를 생략하고 1차 캐시를 반환하는 조건 (이 조건은 기본적으로 2차 캐시에도 동일하게 적용)

  • EntityManager.find (by PK)를 통해 조회될 경우에만 1차 캐시를 반환한다. Spring-data-jpa 사용 시 이는 repository.findById 가 된다. 하이버네이트는 1차 캐시에 데이터 저장 시 primary key가 되는 프로퍼티(with @Id)를 키로 사용하기 때문에 다른 컬럼을 사용하여 엔티티 조회 시 1차 캐시에서 이를 찾을 수 없다.
  • 위의 내용에 이어서, 커스텀 쿼리는 1차 캐시를 사용할 수 없다. (ex: Member @Id id: Long, name: String 일 때, memberRepository.findByName 은 1차 캐시에 엔티티가 존재해도 쿼리를 호출한다.) 
  • 위의 내용에 이어서, PK 를 통해 조회하더라도 그것이 커스텀 쿼리라면 1차 캐시를 사용할 수 없다. 예를 들어, findOneById(Long id) 라는 커스텀 쿼리 메서드는 PK 로 조회하지만 1차 캐시가 존재해도 무조건 쿼리를 호출한다.
  • 위의 이유로, JPQL 은 무조건 쿼리를 호출한다. JPQL 은 EntityManager.createQuery 를 통해 쿼리를 직접 생성해 호출하며, EntityManager.find 와는 다르다.
  • 이 조건들은 '1차 캐시를 반환하는' 조건이지, '엔티티를 1차 캐시에 저장하는' 조건이 아니다. 기본적으로 1차 캐시에는 primary key 데이터를 키로 하여 조회된 엔티티를 모두 저장한다. 즉 JPQL로 조회된 엔티티는 1차 캐시에 저장되어 findById 호출 시 1차 캐시로부터 반환된다.

 

캐시의 작동 과정

위 조건을 만족하는 경우, 하이버네이트의 LoadEntityEvent 가 작동한다. 이 인터페이스의 구현체로는 DefaultLoadEventListener가 사용된다. 이 클래스의 인스턴스는 다음 과정으로 엔티티의 로딩을 실행한다.

  1. loadFromSessionCache(): 해당 키를 가진 엔티티가 1차 캐시에 존재하는지 확인한다. 존재한다면 그 엔티티를 반환한다.
  2. loadFromSecondLevelCache(): 1차 캐시에 엔티티가 존재하지 않는다면, 2차 캐시에 같은 방식으로 엔티티 존재 여부를 확인하고, 존재한다면 그 엔티티를 반환한다.
  3. loadFromDataSource(): 2차 캐시에 엔티티가 존재하지 않는다면, 데이터소스(DB)에 SQL을 실행하여 ResultSet을 얻고, 여기서 데이터를 읽어 Object[] 를 생성한다. 여기까지가 엔티티가 '읽혀진' 상태가 된다.
  4. 읽어온 Object[] 를 1차 캐시와 2차 캐시에 저장한다. 그리고 이 Object[ ] 를 엔티티 인스턴스로 변환하고, 이를 1차 캐시에 저장한다. 즉 dirty checking을 위한 데이터는 사실 엔티티로 저장되지 않는다. (이 저장된 데이터가 후에 데이터 변경 여부 확인(dirty checking)에 그대로 사용된다.)

정리하면, 1차 캐시 - 2차 캐시 - DB 순으로 조회하며, DB 조회 시 2차 캐시에는 ResultSet 으로부터 데이터를 추출한 Object[] 를, 1차 캐시에는 Object[], 엔티티를 저장한다. 때문에 cache hit이 발생하여 1차 캐시의 데이터를 반환할 때는 엔티티를 반환하지만, 2차 캐시의 엔티티를 반환할 때는 Object[] 를 반환된다. 그리고 2차 캐시로부터 반환된 Object[] 는 엔티티로 변환되고, 또다시(DB 조회에서와 같이) 1차 캐시에 Object[], 엔티티가 모두 저장된다.

 

레퍼런스:

https://howtodoinjava.com/hibernate/understanding-hibernate-first-level-cache-with-example/

https://stackoverflow.com/questions/64190242/in-spring-data-jpa-a-derived-find-method-doesnt-use-first-level-cache-while-ca

https://vladmihalcea.com/jpa-hibernate-second-level-cache/

'Java' 카테고리의 다른 글

JPA] @OneToOne lazy loading  (0) 2022.08.22
test] @Testcontainers, @Container  (0) 2022.08.03

스프링 버전 5.3.18 기준으로 작성됨

 

이 글에서는 용어를 다음과 같이 줄여서 표현한다.

OpenEntityManagerInViewFilter => 필터

WebApplicationContext => wac

TransactionSynchronizationManager => tsm

EntityManagerFactory => emf

EntityManager => em

EntityManagerHolder => emh

AsyncRequestInterceptor => ari

 

Spring-orm 에서 OSIV 처리를 담당하는 주요 클래스는 org.springframework.orm.jpa.support.OpenEntityManagerInViewFilter 이다. 이 클래스는 다음과 같이 작동한다.

 

이 클래스는 OncePerRequestFilter 타입이다. request가 들어오면 doFilterInternal 에서 다음을 수행한다.

 

step 1. 현재 필터 자신의 인스턴스에 emf 인스턴스가 존재하는지(초기화되어 있는지) 확인한다. 없으면 wac 에서 emf를 찾아서 세팅한다.

- 이 동작은 필터가 받는 첫 요청 처리에서 이루어질 것이다.

 

step 2. tsm에 emf를 키로 리소스 존재 여부를 확인해서, 존재할 경우 participate = true 를 실행한다.

- tsm에 이 리소스가 존재한다는 것은 이미 이 요청에 대해 생성된 em이 존재한다는 뜻이다. 때문에 글자 그대로 기존 요청에 '참여' 함을 표시한다.

 

step 3. 이 요청이 async request이면서 AsyncManager가 ari 를 가지고 있다면, ari 에 em 을 바인딩한다. (bindEntityManager) 그리고 step 6 으로 넘어간다.

 

step 4. 이 요청이 async request 가 아니거나, async 라도 AsyncManager가 ari 를 가지고 있지 않다면, 다음 과정을 수행한다. (일반적인 mvc요청은 이 방식으로 이루어진다.)

1. emf 로 em을 생성한다.

2. 생성한 em 을 가지는 emh 를 생성한다.

3. tsm 에 bindResource(emf, emh) 를 호출한다.

- tsm의 bindResource 는 내부 쓰레드로컬에 맵을 세팅하여 그곳에 리소스를 저장해둔다. 때문에 이미 request에 대해 생성된 em이 존재하는 경우, tsm를 통해 이를 확인할 수 있도록 한다. (step 2)

4. emf, emh 를 가지는 ari를 생성하고, AsyncManager에 ari를 등록한다.

- 이렇게 등록된 ari 가 다음번 async 요청에서 바로 ari 에 em을 바인딩할 수 있게 된다. (step 3)

 

step 5. filterChain.doFilter

 

step 6. finally 에서, participate = false 인 경우 tsm에서 em을 제거하고(unbindResource(emf)), async요청이 아닐 경우 EntityManagerFactoryUtils.closeEntityManager 로 em을 완전히 닫는다.

- '참여' 요청이라면 기존 요청이 존재하므로 여기서 em을 unbind 해서는 안 된다. 때문에 participate를 체크한다.

 

카프카는 왜 빠른가

프로그램 구현 레벨까지 가면 여러가지 이유를 댈 수 있겠지만, 카프카를 고성능으로 만드는 결정적인 이유를 말하라면 네 가지를 들 수 있겠다.

 

1. 시퀀셜 IO

일반적으로 RAM은 랜덤 엑세스를 지원하지만, 디스크는 그렇지 않다. 디스크는 원하는 데이터의 위치한 블록을 찾기 위한 시간(seek time), 블록을 메모리에 카피하는 시간 등의 오버헤드가 존재한다. 데이터가 캐시나 램에 이미 존재하면 이 과정은 생략될 수 있지만, 그렇지 않을 경우(page fault) 이러한 동작을 반복적으로 필요로 한다. 이런 점으로 인해 디스크는 일반적으로 느리다고 인식되곤 한다.

 

용량을 기준으로 볼 때 RAM은 디스크보다 훨씬 비싸다. 대용량 데이터를 핸들링하는 카프카가 그 모든 데이터를 RAM에 올리기는 현실적으로 어려운 일이다. 때문에 카프카는 디스크를 저장소로 사용하여 작동하는데, 대신 시퀀셜 디스크 엑세스를 통해 seek time의 최소화를 도모한다.

 

이는 카프카가 데이터를 로그(segments)로 저장하기 때문에 가능한 일이다. 로그는 한 번 쓰여지면 변하지 않는 성질을 지닌다(immutable). 특히 카프카의 데이터는 컨슈머가 읽어도 지워지지 않는다. 데이터는 오로지 맨 끝에 추가될 뿐이다(append-only). 이로 인해 카프카의 데이터는 디스크에 조각(fragments)으로 나뉘어 저장되지 않고, 가능한 연속적인 블록에 저장된다. 때문에 카프카는 원하는 데이터에 순차적으로 접근할 수 있다. 즉 디스크 seek time이 최소화된다.

 

이 시퀀셜 엑세스가 항상 보장되는 것은 아니다. 카프카의 데이터가 위치하는 파일 시스템을 다른 어플리케이션이 함께 사용하는 경우, 다른 어플리케이션으로 인해 디스크 단편화가 발생할 소지는 존재한다. 카프카의 데이터를 가급적 독립된 파일시스템에 유지할 것을 권장하는 이유가 여기에 있다. 하나의 파일 시스템을 카프카가 온전히 홀로 사용하도록 하여 카프카의 시퀀셜 엑세스를 보장해야 한다.

 

또, 디스크 엑세스를 더 빠르게 하기 위해 reading and writing은 OS 페이지 캐시를 최대한 활용하도록 해야 한다. 그래서 카프카가 구동되는 머신은 커널 swapiness를 최대한 작게 설정하여 OS 페이지 캐시가 swap out됨을 최대한 방지하는 것이 좋다.

 

 

2. Zero-Copy

예로부터 데이터를 디스크에서 읽어 네트워크로 전송하는 작업은 아래와 같이 4번의 카피와 4번의 컨텍스트 스위칭을 요구한다:

- 디스크에서 read buffer로 (DMA)

  -> read buffer 에서 application buffer로 (CPU)

    -> application buffer 에서 socket buffer로 (CPU)

      -> socket buffer에서 nic buffer로 (DMA)

 

Zero copy는 이 과정을 대폭 축소한다. 위 과정에서 application 영역은 디스크(read buffer)에서 읽은 데이터를 그대로 socket buffer로 보내기만 할 뿐, 다른 하는 일이 없다. 즉 데이터를 read buffer에서 socket buffer로 곧바로 보낼 수 있으면 좋을 것이다. zero copy는 그대로 탄생했다. 그리고 다음과 같이 동작한다:

- 디스크에서 read buffer로 (DMA)

  -> read buffer에서 socket buffer로 (CPU)

    -> socket buffer에서 nic로 (DMA)

카피 횟수도 줄었지만, 컨텍스트 스위칭이 처음 call에서 한 번, 리턴에서 한 번, 총 2번으로 줄었다.

 

Zero copy는 OS의 sendfile() syscall를 통해 이루어지는데, 이는 OS에서 zero copy를 지원해야 함을 의미한다. 유닉스나 요즘의 웬만한 리눅스 버전은 대부분 지원한다고 한다.

 

그런데 여기서 한 번 더 나아간다. 위 과정에서, read buffer에서 nic로 바로 데이터를 보낼 수 있다면 더욱 좋을 것이다. 이 기능은 nic라 gather operation을 지원해야 한다. 리눅스는 커널 2.4 이후로 이 기능을 지원하기 위해 socker buffer descriptor에 변경이 있었다. 이렇게 하여 최종적으로 zero copy는 다음과 같이 동작한다:

- 디스크에서 read buffer로 (DMA)

  -> read buffer에서 파일 디스크립터 정보(파일 위치, 크기 등)만 socket buffer로 (CPU)

    -> read buffer에서 nic로 (DMA)

Socket buffer는 파일 메타데이터만 가지며, 실제 데이터 카피는 발생하지 않는다. Read buffer에서 nic로의 데이터 카피는 DMA를 통해서만 이루어진다.

 

 

3. Data Batch & Compression

카프카는 데이터 batching과 compression을 지원하여 네트워킹 횟수를 대폭 줄여 성능을 향상한다. 추가로 로그 데이터 디스크 사용량도 크게 감소한다. 이 기능은 카프카 설정에 따라 작동 여부와 그 정도가 달라지지만, 대부분의 경우 사용할 것을 권장한다.

 

 

4. Topic Partitioning

카프카의 데이터는 로그 세그먼트로 저장되는데, 이 데이터는 순차적이기 때문에 다수의 쓰레드가 동시에 접근하는 일은 의미가 없다. 데이터는 최근의 것만 한 번 읽으면 되기 때문에, 아무리 많은 쓰레드가 있어도 경합만 발생할 뿐, 이득이 없다고 할 수 있다. 즉 병렬 처리가 불가능하다.

 

카프카는 로그 파티셔닝을 통해 이를 개선했다. 같은 토픽이라도 데이터를 나누어(sharding) 저장하고, 각 파티션에는 독립된 쓰레드가 붙어 작업할 수 있도록 함으로써 결과적으로 하나의 토픽을 여러 쓰레드가 나누어 처리할 수 있도록 병렬성을 구현했다.

 

 

@개인적으로 1번과 4번이 카프카의 특이점이라고 생각된다. 나머지는 다른 메시징 어플리케이션에서도 얼마든지 구현할 수 있을 것 같은 느낌이다. 카프카가 결정적으로 차별화를 두는 점은 4번이 결정적이고, 다음이 1번이라고 할 수 있지 않을까. 이 두 특성으로 인해 카프카가 메시징 어플리케이션을 넘어 데이터 스트리밍 플랫폼으로 군림할 수 있게 되었다고 생각된다.

'Kafka' 카테고리의 다른 글

Kafka 설정 listeners vs. advertised.listeners  (1) 2020.09.08

+ Recent posts