[JPA #4] JPA vs TypeORM - JPA 1차 캐시

6 분 소요

태그: ,

카테고리:

업데이트:

1. 들어가며

지금까지 JPA 시리즈에서는 EntityManager, N+1 문제, 트랜잭션 관리, Lock 전략을 다뤘다. 이번 포스팅에서는 JPA의 또 하나 중요한 특징인 1차 캐시(Persistence Context Cache)를 정리 해보려 한다.

최근 TypeORM을 사용하다가 데이터 업데이트 이후에도 변경 전 값이 조회되는 문제를 겪은 적이 있다. 이 경험을 통해 “ORM이 내부적으로 어떤 캐싱을 제공하는가?”에 대한 궁금증이 생겼고, 자연스럽게 JPA의 1차캐시 개념을 다시 살펴보게 되었다.

즉, 이번 글은 JPA 1차 캐시의 동작 방식을 중심으로 설명하되, TypeORM의 캐싱 동작과 비교해 보면서 차이를 살펴보려고 한다.

2. JPA 1차 캐시

2.1. 1차 캐시란?

JPA 는 영속성 컨텍스트(Persistence Context)를 통해 엔티티의 생명주기를 관리한다. 이 영속성 컨텍스트 안에는 1차 캐시(1st-level cache)가 존재하는데, 이는 트랜잭션 단위로 유지되는 임시 저장소이다.

  • 트랜잭션 범위 안에서 동일한 엔티티를 여러 번 조회하면, DB를 다시 조회하지 않고 1차 캐시에 있는 엔티티를 반환한다.
  • 엔티티 변경 시에도 즉시 DB에 UPDATE 쿼리를 보내지 않고, 1차캐시에 기록해 두었다가 flush 시점에 한번에 반영한다.
  • 트랜잭션이 종료되면 1차 캐시도 함께 사라진다. 즉, JPA 1차 캐시는 성능 최적화와 동시에 데이터 일관성을 유지하는 데 중요한 역할을 한다.

다음은 JPA 1차 캐시의 동작 방식을 보여주는 간단한 예시이다.

@Transactional
public void testFirstLevelCache() {
    // 첫 번째 조회 - DB 에서 SELECT 발생
    Member member1 = entityManager.find(Member.class, 1L);

    // 두번째 조회 - DB SELECT 없음, 1차 캐시에서 반환
    Member member2 = entityManager.find(Member.class, 1L);

    System.out.println(member1 == member2); // true
}

2.1.1. Hibernate 실행로그

Hibernate: 
    select
        member0_.id as id1_0_0_,
        member0_.name as name2_0_0_ 
    from
        member member0_ 
    where
        member0_.id=?

첫번째 조회(member1) 때만 SELECT 로그가 발생하고, 두번째 조회(member2)에서는 SELECT 로그가 발생하지 않는다. 이는 DB가 아닌 1차 캐시에서 엔티티를 반환했기 때문이다. 따라서 member1member2는 동일한 객체 참조를 가진다.

2.2. Dirty Checking

JPA 1차 캐시는 단순히 엔티티를 캐싱하는것에 그치지 않고, 초기상태(스냅샷)을 함께 보관한다. 트랜잭션이 끝나는 시점(=flush 시점)에 1차 캐시에 있는 엔티티와 스냅샷을 비교하여 변경된 부분이 있으면 자동으로 UPDATE 쿼리를 생성해 DB에 반영한다. 이를 Dirty Checking이라고 한다.

@Transactional
public void updateMemberName(Long id, String newName) {
    Member member = memberRepository.findById(id).orElseThrow();
    member.setName(newName); // 엔티티 속성만 변경

    // 별도의 update() 호출 필요 없음
    // 트랜잭션 종료 시 flush -> Dirty Checking - DB UPDATE 실행
}

2.2.1. Hibernate 실행로그

Hibernate:
    select
        member0_.id as id1_0_0_,
        member0_.name as name2_0_0_,
        member0_.version as version3_0_0_
    from
        member member0_ 
    where
        member0_.id=?

Hibernate:
    update
        member 
    set
        name=? 
    where
        id=? and version=?

flush 발생 시점

  • 트랜잭션이 정상적으로 커밋되기 직전
  • JPQL/Criteria 쿼리 실행 직전
  • entityManager.flush() 명시적 호출

2.2.2. 동작 과정

  1. findById() 호출 시점에 DB에서 엔티티를 조회하고, 1차 캐시에 저장한다. 이때 엔티티의 초기 상태(스냅샷)도 함께 보관한다.
  2. setName(newName) 호출로 엔티티의 속성을 변경한다.
  3. 트랜잭션이 종료되는 시점에 flush가 호출된다.
  4. flush 시점에 1차 캐시에 있는 엔티티와 스냅샷을 비교하여 변경된 부분이 있는지 확인한다. 변경된 부분이 있으면 자동으로 UPDATE 쿼리를 생성해 DB에 반영한다.
  5. 별도의 update() 호출이 필요 없다.

2.2.3. 실무 주의사항

  • 작은 단위의 트랜잭션 로직에서는 Dirty Checking을 활용하는 것이 간결하고 편리하다.
    • ex) 주문 상태 변경, 회원 이름 수정 등
  • 대량 업데이트나 성능이 중요한 구간에서는 flush 시점에 캐시 비교 비용이 커질 수 있어 적합하지 않다. 이 경우 JPQL update/delete 같은 벌크 연산을 사용하는 것이 좋다.
  • 정합성이 중요한 도메인(핀테크, 결제, 재고 관리 등)에서는 Dirty Checking 남발을 지양하고, 변경 쿼리를 명시적으로 작성하여 의도를 분명히 하는 것이 좋다.

2.3. 동일성 보장 (Identity Guarantee)

JPA 1차 캐시는 동일 트랜잭션 범위 내에서 같은 엔티티를 항상 동일한 객체로 반환한다. 이를 동일성 보장(Identity Guarantee)라고 한다. 즉, PK를 가진 엔티티를 조회해도 항상 DB에서 새로 조회하는 것이 아니라, 1차 캐시에 있는 객체를 반환하기 때문에, 항상 같은 인스턴스를 참조한다.

@Transactional
public void testIdentityGuarantee() {
    Member member1 = entityManager.find(Member.class, 1L);
    Member member2 = entityManager.find(Member.class, 1L);

    System.out.println(member1 == member2); // true
}

이 특성 덕분에, 트랜잭션 내에서 엔티티의 상태가 일관되게 유지된다. 예를 들어, 회원 엔티티를 여러 번 조회해도 항상 같은 객체를 참조하므로, 변경 사항이 누락되거나 충돌하는 일이 없다.

2.3.1. 장점

  • 데이터 일관성: 동일한 트랜잭션 내에서 엔티티 상태가 일관되게 유지된다.
  • 성능 최적화: 동일한 엔티티를 반복 조회할 때 DB 접근을 줄여 성능을 향상시킨다.
  • 엔티티 동일성 보장: equals()가 아니라 객체 참조비교(==)도 성립한다.

2.3.2.실무 주의사항

  • 트랜잭션 범위: 동일성 보장은 트랜잭션 범위 내에서만 유효하다. 트랜잭션이 종료되면 1차 캐시도 사라지므로, 다음 트랜잭션에서는 동일한 엔티티를 다시 조회하면 새로운 객체가 생성된다.
  • 영속성 컨텍스트 분리: 서로 다른 영속성 컨텍스트에서는 동일한 PK를 가진 엔티티라도 다른 객체로 취급된다. 예를 들어, 서로 다른 트랜잭션에서 같은 회원을 조회하면 각각 다른 객체가 생성된다.
  • 캐시 무효화: 1차 캐시는 트랜잭션이 끝나면 사라지므로, 장기적으로 데이터를 캐싱하려면 2차 캐시(예: Hibernate 2nd-level cache)를 사용해야 한다.

3. TypeORM 의 캐시

TypeORM의 캐시는 JPA의 1차 캐시와는 성격이 완전히 다르다.

  • JPA 1차 캐시는 영속성 컨텍스트 단위에서 엔티티 동일성을 보장하고, Dirty Checking으로 변경사항을 추적한다.
  • 반면, TypeORM 캐시는 쿼리 결과를 메모리나 Redis 등에 저장하고, 같은 쿼리를 실행할 때 캐시된 결과를 반환하는 단순한 “쿼리결과 캐시(Query Result Cache)”이다.

즉, JPA의 캐시는 트랜잭션 일관성을 위한 것이고, TypeORM 캐시는 쿼리 성능 최적화를 위한 것이다.

3.1. 기본동작

TypeORM은 QueryBuilder 단계에서 .cache(true) 혹은 전역 설정(cache: true)을 통해 캐시를 활성화 할 수 있다.

const users = await dataSource
    .getRepository(User)
    .createQueryBuilder("u")
    .where('u.isActive = :active', { active: true })
    .cache(60000) // 60초 동안 캐싱
    .getMany();
  • 최초 실행 시 DB에서 데이터를 조회하고, 결과를 캐시에 저장한다.
  • 이후 동일 쿼리를 실행하면 DB가 아니라 캐시에서 결과를 가져온다.

3.2. 실무 사례: 캐시로 인한 정합성 문제

실무에서 TypeORM 캐시 때문에 데이터가 갱신되었음에도 불구하고, 이전 상태가 반환되는 문제를 겪은 적이 있다. 기존 코드는 Repository 기반 QueryBuilder를 사용했다.

// Repository 기반 QueryBuilder
const row = await this.dataSource
    .getRepository(User)
    .createQueryBuilder("u")
    .where('u.id = :id', { id: userId })
    .orderBy('u.id', 'DESC')
    .cache(false) // cache(false)를 줬지만, 여전히 캐시된 결과가 반환됨
    .getOne();

여기서 cache(false)를 명시했음에도 불구하고 전역 캐시 설정의 영향으로 과거 결과가 반환되는 사례가 발생했다. 즉, TypeORM의 캐시는 트랜잭션 단위로 완전히 격리되지 않으며, Repository 레벨에서는 제어가 제한적일 수 있다.

3.3. 해결방법: QueryRunner 사용

이 문제를 해결하기 위해 QueryRunner를 직접 사용하여 세션을 분리하고, cache(false)를 강제 적용했다.

const qr = this.dataSource.createQueryRunner();
await qr.connect();
try {
    await qr.query('SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED');
    const row = await qr.manager
        .createQueryBuilder(User, 'u')
        .where('u.id = :id', { id: userId })
        .orderBy('u.id', 'DESC')
        .cache(false) // 최신 데이터 강제 조회
        .getOne();
    return row ?? null;
} finally {
    await qr.release();
}
  • 독립된 QueryRunner를 통해 전역 캐시의 영향을 차단
  • cache(false)를 명시하여 캐시된 결과가 아닌 최신 데이터를 강제 조회
  • 동시에 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED를 적용해 읽기 정합성을 강화

3.4. 정리

  • JPA의 1차 캐시: 트랜잭션 범위 내에서 엔티티 동일성과 Dirty Checking을 보장하여 데이터 정합성을 유지한다.
  • TypeORM의 캐시: 쿼리 결과를 캐싱하여 성능을 최적화하지만, 트랜잭션 단위로 완전히 격리되지 않아 정합성 문제가 발생할 수 있다.
  • 실무에서는 캐시된 오래된 데이터로 인해 로직이 꼬일 수 있으므로, 최신 데이터가 필요한 경우 캐시를 비활성화하거나, 필요 시 QueryRunner로 독립된 세션을 사용하는 것이 좋다.

4. JPA 1차 캐시 vs TypeORM 캐시

JPA의 1차 캐시는 트랜잭션 단위에서 엔티티의 동일성을 보장하고 Dirty Checking을 통해 정합성을 유지하는 장치다. 반면 TypeORM 의 캐시는 단순히 쿼리 결과를 캐싱하여 성능을 최적화하는 기능이다.

특징 JPA 1차 캐시 TypeORM 캐시
캐시 범위 트랜잭션 단위 전역 또는 쿼리 단위
동일성 보장 동일 트랜잭션 내에서 동일한 엔티티는 동일한 객체 참조 동일성 보장 없음
변경 추적 (Dirty Checking) 엔티티 변경 시 자동으로 추적 변경 추적 없음
캐시 무효화 트랜잭션 종료 시 자동 무효화 TTL 또는 수동 무효화 필요
성능 최적화 DB 접근 최소화 및 일관성 유지 쿼리 성능 최적화
정합성 보장 높음 낮음
  • JPA 1차 캐시는 “데이터 일관성”을 위해 존재한다.
  • TypeORM 캐시는 “쿼리 성능 최적화”를 위해 존재한다.

이번 포스팅에서는 실무에서 TypeORM 캐시로 인해 경험했던 정합성 문제를 계기로, JPA의 1차 캐시가 어떤 원리로 일관성을 보장하는지 다시 살펴보았다. 이를 통해 ORM마다 제공하는 캐시 메커니즘의 차이를 이해하고, 상황에 맞게 다른 접근이 필요하다는 점을 명확히 할 수 있었다.

5. JPA 시리즈를 마치며

이번 JPA 시리즈는 여기서 마무리하려 한다. 지금까지 JPA를 공부하면서 개념을 정리하고, 직접 실습해 본 내용을 기록하는 과정이었다.

  • EntityManager와 영속성 컨텍스트
  • N+1 문제와 성능 최적화
  • 트랜잭션 경계와 전파/격리수준
  • Lock 전략 (Optimistic, Pessimistic)
  • 1차 캐시

이 과정을 통해 JPA의 내부 매커니즘이 단순한 ORM 기술을 넘어, 트랜잭션과 일관성을 지탱하는 핵심 도구라는 것을 확인할 수 있었다.

한편, TypeORM과의 비교를 통해 같은 “ORM”이라 하더라도 각기 다른 매커니즘과 특성을 가지고 있음을 체감했고, 실무에서 정합성 문제를 어떻게 다뤄야 하는지에 대한 인사이트를 얻을 수 있었다.

JPA 시리즈는 여기서 마무리 하지만, 앞으로는 JPA를 넘어, Spring Data JPA 고급기능, 분산락/메시징 환경의 트랜잭션 처리, 그리고 실무에서 마주하는 데이터 정합성 이슈에 대한 고민을 이어가려 한다.

댓글남기기