1. 데이터베이스 캐싱
1.1 캐싱의 계층 구조
- 캐싱은 데이터베이스 성능 최적화의 핵심
- DB → OS → 애플리케이션 → (ORM) → 2차 캐시 → 1차 캐시 등 여러 계층에 걸쳐 존재
- 디스크 접근을 최소화하고, DB/OS/애플리케이션 메모리에서 최대한 데이터를 제공하는 것이 핵심
1.2 캐시 동기화 전략
가. Cache-aside
- 애플리케이션이 캐시에서 먼저 데이터를 찾고, 없으면 DB에서 조회 후 캐시에 넣음
- 쓰기는 데이터베이스와 캐시를 모두 갱신해야 함 (동기화 주의 필요)
나. Read-through
- 캐시에 데이터가 없을 때, 캐시 시스템이 DB에서 자동으로 데이터 조회 및 캐시 삽입
- 애플리케이션에서는 항상 캐시만 조회
다. Write-through
- 쓰기 작업이 캐시와 DB에 동시에 적용됨
- 캐시 일관성 유지가 쉬움
라. Write-invalidate
- 쓰기 시 캐시에서 해당 데이터를 삭제만 하고, 다음 읽기 때만 최신 데이터로 채움
마. Write-behind
- 애플리케이션은 캐시에만 쓰고, 일정 주기/조건에 따라 DB에 배치로 반영 (flush)
- 쓰기 성능 극대화, 데이터 일관성 보장에 추가 로직 필요
1.3 데이터베이스와 OS 캐싱
가. 데이터베이스 캐시
- 대부분의 DBMS는 메모리 내 버퍼 풀 (Buffer Pool), 실행 계획 캐시 (Shared Pool), Redo Log Buffer 등 다양한 캐시 구조 제공
- 실제 데이터 페이지, 인덱스, 쿼리 결과, 실행 계획, 임시 작업 결과 등 다양한 객체가 캐싱됨
나. OS 캐시
- OS 레벨의 페이지 캐시, 파일 시스템 캐시 등
- 일부 DB는 Double Buffering (같은 페이지가 OS와 DB buffer pool 모두에 중복 저장)
- Direct I/O 지원 DB는 OS 캐시를 우회하여 중복 캐싱을 피함
1.4 주요 DBMS별 캐시 구조
가. Oracle
- SGA (System Global Area): Buffer Pool (디스크 페이지 캐시, 8KB 등), Shared Pool (실행 계획, SQL, 메타데이터), Redo Log Buffer, Large Pool 등
- PGA (Program Global Area): 각 세션별 작업 메모리, ORDER BY/JOINS/커서 실행 시 사용
- 자동 메모리 관리 (AMM): SGA, PGA 크기 자동 조절 지원 (메모리 제한 가능)
- Buffer Pool 사이즈 결정: V$DB_CACHE_ADVICE 뷰로 최적 크기 추정 (캐시 미스율, IO 감소 효과 등)
나. SQL Server
- Buffer Pool: 8KB 페이지 단위, 테이블/인덱스 데이터를 RAM에 적재
- 메모리 자동 관리: 시스템의 여유 메모리를 최대한 사용, min/max server memory 설정으로 제한 가능
- 메모리 사용량/버퍼 풀 크기 조회 쿼리 제공
다. PostgreSQL
- Shared Buffers: DBMS 내 캐시, 8KB 페이지, 전체 RAM의 15~25% 권장
- OS 캐시 의존: Direct I/O 미지원, OS 파일 시스템 캐시와 Double Buffering 발생
- Vacuum 프로세스: 오래된/삭제된 버전 페이지 정리, GC와 유사
- effective_cache_size: OS 캐시 크기를 PostgreSQL 옵티마이저에 알려줌(쿼리 플랜 최적화)
라. MySQL(InnoDB)
- Buffer Pool: 16KB 페이지, 전체 RAM의 80~90%까지도 할당 권장 (특히 대용량 시스템)
- Direct I/O 지원: OS 캐시 우회 가능, Double Buffering 방지
- SHOW ENGINE INNODB STATUS로 상세 메모리/버퍼 풀 통계 확인 가능
1.5 캐시 크기, 정책과 성능의 상관관계
- Buffer Pool/Shared Buffers 크기가 작으면 캐시 미스율 증가 → 더 많은 디스크 IO → 성능 저하
- 반면, 너무 크게 설정하면 GC, vacuum 등 유지 보수 작업이 느려지고, OS/DB 메모리 경쟁 심화
- 따라서 적정 크기로 설정하는 것이 중요
- Oracle/SQL Server/MySQL: RAM의 70~90%
- PostgreSQL: RAM의 15~25% (나머지는 OS 캐시)
1.6 캐시 히트율과 최적화
- 캐시 히트율이 높을수록 즉, 캐시 미스가 적을수록 DB나 디스크 접근 없이 메모리에서 데이터 제공 → 대폭적인 성능 향상
- 캐시 미스 시에는 데이터베이스 또는 디스크에서 데이터를 읽어야 하므로 응답시간이 급격히 증가
- 따라서 히트율을 정기적으로 모니터링하고, 빈번히 사용되는 쿼리/테이블/인덱스가 캐시에 적재되도록 테이블 구조, 인덱스, 쿼리, 메모리 크기 등 조정하는 것을 권장
1.7 기타 운영체제 캐시, Direct I/O 등 주의사항
- Direct I/O 적용 시 OS 캐시를 우회, DB가 자체적으로 페이지 캐시 관리 (Oracle, MySQL 등)
- Double Buffering 방지, RAM 중복 사용 최소화하는 것을 권장
- PostgreSQL은 OS 캐시와 Shared Buffers 모두 사용
- shared_buffers는 15~25%로 제한, 나머지는 OS 페이지 캐시 할당하는 것을 권장
2. 애플리케이션 레벨 캐싱
2.1 애플리케이션 레벨 캐시의 필요성
- DB 캐시 (버퍼 풀, 인메모리 캐시)는 I/O 최적화에 탁월함
- 디스크 읽기/쓰기 횟수 최소화, 읽기 성능 극대화
- DB 내부에서만 관리되므로 일관성이 매우 강력 (변경 즉시 반영, 캐시 미스 = DB 최신)
- 하지만 DB 캐시도 한계가 있음
- 대용량 JOIN, 정렬, 윈도우 함수, JSON/ARRAY 처리, Recursive 쿼리 등 메모리/CPU 집약 연산은 DB 캐시만으로 커버 불가
- 실행계획이 아무리 최적이어도 대량 데이터/연산은 느릴 수밖에 없음
- 애플리케이션 레벨 캐시는 복잡한 다중 JOIN, 집계, 분석 쿼리 결과를 한 번 계산해서 애플리케이션 레벨 (분산 캐시 등)에 저장한 뒤 O(1)로 결과를 제공
- 조회 성능 극대화, DB 부하 감소
- 데이터베이스와 분리된 확장성, 장애 격리, 트래픽 버퍼 역할 수행
- DB 장애/점검 시에도 읽기만 제공된다면 서비스 연속성 확보 가능
- i.g. StackOverflow가 DB 정기점검 중에도 캐시 기반 읽기 서비스 유지
2.2 대표적 애플리케이션 레벨 캐시 솔루션
- Redis: 분산 인메모리 키-값 저장소, 데이터 구조 지원, pub/sub, TTL 등
- Memcached: 단순 분산 메모리 캐시, 키-값, TTL
- Hazelcast, Ehcache: JVM 내장/분산 캐시 (자바 친화적)
- Aerospike, Etcd: 분산 캐시/NoSQL DB, 고가용성/확장성
- Kafka, Debezium 등: CDC (Change Data Capture) 이벤트 기반 캐시 동기화에 사용
2.3 캐싱 계층과 동기화 전략
- 일반적인 구조는 다음과 같음
- DB 캐시: 버퍼 풀/OS 캐시
- 애플리케이션 캐시: 분산 캐시 i.g. Redis/Memcached
- ORM 2차 캐시: Hibernate, JPA 2nd-level cache
- 1차 캐시: Persistence Context, 세션 단위
- 일반적으로 적용하는 전략은 다음과 같음
- Cache-aside (Write/Read-through/Write-behind 등)
- 애플리케이션이 DB와 캐시를 모두 직접 갱신
- 캐시 미스시 DB에서 읽고, 수정/삭제시 캐시 무효화 또는 비동기 업데이트
- Change Data Capture (CDC)
- 트리거나 Redo Log, Binlog, WAL 등 DB 변경 이벤트를 추출하여 캐시 동기화
- 대표적인 오픈소스는 Debezium (다수 DB 지원)
- Cache-aside (Write/Read-through/Write-behind 등)
2.4 캐시 동기화 어려움과 일관성 이슈
- DB가 단일 진실의 원본(Single Source of Truth): 캐시와 DB가 반드시 일관되게 동기화되어야 함
- XA 트랜잭션 미지원: 분산 캐시는 XA/XID 트랜잭션을 지원하지 않는 것이 일반적이므로 동기화 실패 혹은 롤백 시 데이터 불일치 가능성 높음
- 동기화를 위해 다음 방식을 채택하는 것을 권장
- Invalidate: 롤백되더라도 캐시 미스만 발생하도록 DB 변경 후 캐시 항목 삭제 (가장 안전하지만 캐시를 자주 무효화하기 때문에 효율은 떨어짐)
- Async Update: DB 커밋 후 캐시 비동기 업데이트 (성능↑, 일관성↓), 여러 트랜잭션이 짧은 시간에 구버전 캐시 데이터를 읽을 수 있음
2.5 CDC (Change Data Capture)와 트리거/로그 활용
데이터 변경을 추적하기 위해 다음 옵션을 채택할 수 있습니다.
- 트리거 기반 감사 로그 테이블: INSERT/UPDATE/DELETE 시 트리거로 감사 로그에 기록하고 해당 로그를 기반으로 캐시 업데이트
- i.g. book_audit_log 테이블에 old/new row, DML 유형, 타임스탬프 기록
- Redo log/Binlog/WAL 기반 CDC: DB 트랜잭션의 커밋된 변경을 로그에서 추출 (비동기)
- Debezium, Kafka 등으로 실시간 이벤트 스트림 생성, 캐시 동기화
2.6 대규모/대용량 데이터 캐시 관리의 어려움
- 계층적 (aggregate) 데이터 전체를 캐시에 저장할 경우 부모 엔티티 (i.g. 회사명, 태그명 등) 변경 시, 모든 관련 캐시 데이터를 일일이 업데이트/무효화해야 함 (갱신 ripple effect)
- 캐시 설계상 aggregate를 분해하여, 각 주요 파트 (게시물, 태그, 투표 등)를 키 단위로 별도로 캐싱하면 ripple effect를 완화할 수 있지만 키 수가 많아지고, 여러 번의 조회 및 저장이 필요함
2.7 실무 적용 팁
- 읽기 부하가 크거나, DB 장애 시 가용성 보장이 필요하다면 반드시 애플리케이션 레벨 캐시 (분산 인메모리 캐시) 적극 도입하는 것을 권장
- 캐시 동기화는 비즈니스 상황에 맞게 적용 필요
- 동기(Invalidate), 비동기(CDC, 이벤트), TTL, 버전 관리 등 상황별로 조합
- 캐시 일관성과 효율성 트레이드오프를 항상 인지하고 설계해야 함
- CDC (Debezium 등) 활용 시 DB 트랜잭션과 캐시 동기화 파이프라인의 장애/지연/누락에 주의
- 대규모 aggregate 캐시 설계 시 ripple effect를 고려해 분해 캐시, TTL, 대량 무효화 전략 등 다각적으로 대비하는 것을 권장
3. Hibernate 2차 캐시
3.1 Hibernate 2차 캐시란?
- 1차 캐시
- Hibernate의 Persistence Context (Session, EntityManager) 단위
- 트랜잭션/세션 범위 내에서만 엔티티 객체를 메모리에 저장 (즉, DB 한 번만 로드)
- 2차 캐시 (Second-level Cache):
- 애플리케이션 전체 또는 EntityManagerFactory/SessionFactory 단위의 공유 캐시
- 같은 엔티티 (또는 컬렉션, 쿼리 결과 등)를 여러 세션/트랜잭션에서 재사용 가능
- DB 접근 (읽기/쓰기) 부하를 효과적으로 감소시킴
3.2 2차 캐시의 필요성
- Primary/Replica 구조에서 읽기 트래픽 (scale-out)은 Replica 노드 추가로 쉽게 확장 가능
- 쓰기 트래픽 (scale-up)은 Primary 노드 한 대에 집중하기 때문에 수직 확장 (scale-up)만 가능
- 쓰기 부하를 분산하거나, Primary 부하를 줄이기 위해서는
- Hibernate 2차 캐시를 활용하여 읽기 요청의 일부를 DB가 아닌 캐시에서 처리
- 읽기-쓰기 트랜잭션에서 읽기 부하의 상당 부분을 캐시에 오프로딩
3.3 2차 캐시의 종류 및 옵션
- Entity Cache: 엔티티 단위 캐싱
- Collection Cache: 컬렉션 (List/Set) 관계 식별자 캐싱
- Query Cache: 쿼리 결과 (식별자/프로젝션) 캐싱
- Natural Id Cache: 비즈니스 키 기반 엔티티 식별자 캐싱
3.4 2차 캐시 동작 순서
- 엔티티 조회 시 1차 캐시 (Persistence Context) → 2차 캐시 → DB 쿼리
- 2차 캐시에 없는 경우, DB에서 로드 후 2차 캐시에 저장
가. 1차 캐시 (PersistenceContext, Session/EntityManager)
- 영속성 컨텍스트는 현재 트랜잭션/세션 범위 내에서만 유효한 엔티티 캐시
- JPA의 EntityManager와 Hibernate의 Session이 1차 캐시 역할을 수행
- 엔티티를 조회하면 가장 먼저 1차 캐시에서 해당 엔티티의 인스턴스를 찾음
- 1차 캐시에 이미 관리되고 있는 엔티티가 있다면, 별도의 DB 쿼리나 2차 캐시 접근 없이 즉시 반환됨
- 동일 트랜잭션/세션에서 여러 번 조회해도 DB 쿼리는 1번만 발생
나. 2차 캐시(Second-Level Cache)
- 1차 캐시에 엔티티가 없고 2차 캐시가 활성화된 경우, 2차 캐시에서 해당 엔티티를 찾음
- 2차 캐시는 SessionFactory/EntityManagerFactory 단위로 여러 세션/트랜잭션에서 공유됨
- 2차 캐시에서 엔티티를 찾으면(캐시 히트), DB 접근 없이 캐시 된 ‘로드 상태’로 엔티티를 복원해서 반환
- 2차 캐시에 엔티티가 없다면(캐시 미스), 다음 단계로 넘어감
다. DB 쿼리
- 1차/2차 캐시 모두에 엔티티가 없다면, Hibernate가 실제로 DB에 SELECT 쿼리를 실행하여 엔티티 데이터를 조회
- DB에서 엔티티를 가져온 뒤 1차 캐시에 엔티티를 등록하고 2차 캐시가 활성화되어 있다면, 엔티티의 ‘로드 상태’를 2차 캐시에 저장 (다음 세션/트랜잭션에서 재사용)
3.5 Hibernate 2차 캐시 설정 방법
3.6 쿼리 캐시 설정 방법
3.7 Natural Id (비즈니스 키) 캐시
- Natural Id (비즈니스 키)는 데이터베이스에서 엔티티를 고유하게 식별할 수 있는 속성으로, 보통 비즈니스적으로도 유일성이 보장되는 값
- Primary Key와는 별도로, 비즈니스 로직에서 자주 사용하는 자연키 기반 조회가 필요할 때가 많음
- i.g. 책 테이블의 ISBN, 상품의 SKU 등
- @NaturalId + @NaturalIdCache를 통해 비즈니스 키 기반 엔티티 캐싱
- Hibernate API를 사용하면 Natural Id로 엔티티를 직접 조회 가능
- DB round-trip을 최소화하여 매우 빠름
- 엔티티가 수정/삭제/추가되면 자연키 캐시와 엔티티 캐시가 함께 무효화되어 최신 상태 유지
- JPA 표준에는 없고 Hibernate 고유 API
- 단, 변경이 자주 발생하면 캐시 무효화와 동기화 비용이 증가하므로 Natural Id 값은 실제로는 거의 변하지 않아야 함
- 또한, 캐시 제공자 (RegionFactory)에 따라 설정과 성능이 달라질 수 있으므로 운영 환경에서 캐시 동작/효율을 반드시 모니터링 필요
- 동작 방식은 다음과 같음
- slug 값으로 Natural Id 쿼리를 실행하면
- Hibernate는 먼저 Natural Id 캐시에서 해당 slug → id 매핑을 찾음
- 없으면 DB에서 SELECT id FROM post WHERE slug = 'slug' 실행 후 결과를 캐시에 저장
- id 값을 찾으면, 곧바로 엔티티 캐시 (2차 캐시)에서 id로 엔티티를 찾음
- 엔티티 캐시에도 없으면 DB에서 SELECT * FROM post WHERE id = ? 실행
4. Hibernate 2차 캐시 동시성 전략
- Hibernate 2차 캐시는 데이터 일관성과 동시성 요구에 따라 네 가지 캐시 동시성 전략을 제공
- READ_ONLY: 읽기 전용/불변 데이터에 적합 (immutable)
- NONSTRICT_READ_WRITE: 데이터 변경이 드물고, 약간의 일관성 손실을 허용할 수 있을 때 채택하는 전략
- READ_WRITE: 강력한 일관성이 필요할 때 적용 (동시성 제어, 소프트락 사용)
- TRANSACTIONAL: JTA 트랜잭션 환경, XA 트랜잭션에 참여하는 경우
가. READ_ONLY 전략
- 변경 불가 능(immutable)한 엔티티 및 컬렉션 캐시에만 사용
- i.g. 코드 테이블, 읽기 전용 참조 데이터)
- 해당 전략의 특징은 다음과 같음
- 불변 엔티티/컬렉션에만 설정 가능 (@Immutable 강제)
- 캐시 동기화 필요 없음, 캐시에 저장된 값이 영원히 유효
- 모든 CRUD 작업에서 데이터베이스-캐시 일관성 유지가 쉬움
- 해당 전략 채택 시 다음과 같이 동작
- persist 시 write-through로 캐시에 저장
- 읽기 시 캐시 hit/miss 카운트로 통계 확인 가능
- 업데이트/삭제 시 예외 발생 또는 무시
나. NONSTRICT_READ_WRITE 전략
- 데이터 변경이 드물고, 복제된 환경이나 분산 캐시에서 '최종적 일관성 (eventual consistency)'만 보장해도 되는 경우 적용
- 변경이 자주 일어나지 않는 데이터, 완벽한 일관성이 필요 없는 환경
- 해당 전략의 특징은 다음과 같음
- 업데이트/삭제 시 캐시에 있는 항목 무효화
- 캐시 무효화와 DB 업데이트 사이에 시간 차이 발생 가능 (동시성 문제 가능성)
- 분산 환경 (여러 웹 노드, 복제본 등)에서 락 없이 작동 가능
- 해당 전략 채택 시 다음과 같이 동작
- persist 시에는 캐시에 저장되지 않음, 최초 조회 시에만 캐시 저장 (read-through)
- 업데이트/삭제 시 캐시 즉시 무효화, 이후 최초 조회 시에 다시 캐시 됨
다. READ_WRITE 전략
- 데이터 일관성이 매우 중요한 환경에서 적용
- i.g. OLTP, 단일 노드/인스턴스와 같이 강력한 동시성 보장 필요한 환경
- 해당 전략의 특징은 다음과 같음
- 캐시에 소프트락 객체를 사용, 트랜잭션 커밋 시까지 락 보유
- 동시 수정 충돌 방지, 커밋 전까지 다른 트랜잭션이 캐시 된 값을 볼 수 없음
- 강력한 일관성 보장, 단일 노드 (혹은 분산 캐시에서 락 동기화 지원) 환경에서 적합
- 해당 전략 채택 시 다음과 같이 동작
- persist/update/delete 시 캐시와 DB 모두 write-through
- update/delete 시 캐시에 소프트락이 생성, 커밋 후에만 실제 값으로 대체
- 다른 트랜잭션이 락 상태를 만나면 DB에서 직접 로드
라. TRANSACTIONAL 전략
- XA 트랜잭션 (JTA) 환경, 다수의 DB/캐시 시스템이 2단계 커밋(two-phase commit)에 참여할 때 적용
- @Cache(usage = CacheConcurrencyStrategy.TRANSACTIONAL) 어노테이션 지정 필요
- XA 지원 DataSource, 트랜잭션 매니저, 캐시 프로바이더 필요
- 해당 전략의 특징은 다음과 같음
- JTA/XA 트랜잭션 매니저 필요 (Bitronix, Atomikos, Java EE JTA 등)
- 캐시와 데이터베이스 커밋/롤백이 완전히 동기화됨 (모두 성공/모두 실패)
4.1 실무 적용 팁
- 쿼리 캐시는 별도 활성화 필요
- 쿼리 실행 결과 (식별자/프로젝션)만 캐시
- 파라미터, 쿼리 문자열, 필터 등 모두가 캐시 키에 포함
- 엔티티/컬렉션이 변경되면 관련 쿼리 캐시도 무효화하는 것을 권장
- 쿼리 캐시가 조회될 때, 결과에 저장된 타임스탬프 < 현재 테이블 타임스탬프이면 해당 쿼리 결과가 더 이상 유효하지 않은 것으로 간주하고 무효화
- Native SQL을 사용할 때는 .addSynchronizedEntityClass(Entity.class) 또는 .addSynchronizedQuerySpace("table_name") 메서드로 해당 쿼리가 어느 테이블/엔티티에 영향을 미치는지 Hibernate에 명시적으로 알려줌
- 이렇게 적용하면 정확히 관련된 쿼리 캐시만 무효화되어, 나머지 쿼리 캐시는 계속 재사용 가능
- 컬렉션 캐시는 항상 read-through
- 컬렉션은 데이터베이스에서 처음 읽을 때만 캐시에 저장
- 자식 엔티티들도 반드시 캐시 되어야 N + 1 문제 회피
참고
인프런 - 고성능 JPA & Hibernate (High-Performance Java Persistence)
'DB > JPA' 카테고리의 다른 글
[Hibernate/JPA] 트랜잭션과 동시성 제어 패턴 (0) | 2025.06.09 |
---|---|
[Hibernate/JPA] Fetching (0) | 2025.06.09 |
[Hibernate/JPA] Batching (0) | 2025.06.04 |
[Hibernate/JPA] Statement (0) | 2025.06.04 |
[Hibernate/JPA] 영속성 컨텍스트 (0) | 2025.06.04 |