EH cache
웹 애플리케이션에서 성능을 확보하기 위해 가장 먼저, 쉽게 도입할 수 있는 기술 중 하나가 캐싱(Cache)이다. 캐시는 반복적으로 조회되는 데이터를 메모리에 보관해 DB 부하를 줄이고, 응답속도를 빠르게 만드는 도구이다. 보통 Redis부터 떠오르는데, Caffeine, EhCache와 같은 로컬 캐싱 방법도 있다. 그 중 EhCache는 안정적이고, JVM에 최적화된 로컬 캐시로 널리 사용된다.
외부 인프라(Redis 등)을 도입하지 않고, 간단하게 Spring Boot 설정만으로 캐싱을 도입할 수 있다는 장점이 있다.
EhCache란?
- JVM 힙/오프힙 메모리 기반 캐싱
- 디스크 저장 가능(옵션)
- JSR-108(JCache) 표준 지원
- Spring Cache 추상화(@Cacheable)와 통합되어 있음
- 빠른 속도, 낮은 지연
- 네트워크 비용이 없는 순수 JVM 내부 캐시
EhCache vs Redis
| 항목 | EhCache | Redis |
| 캐시 종류 | 로컬 캐시 | 분산 캐시 |
| 저장 위치 | JVM 메모리 / 디스크 | 외부 서버 메모리 |
| 네트워크 비용 | 인스턴스 내 캐시로 네트워크 비용 없음 | 외부 서버로 네트워크 비용발생 |
| 장애 시 | 서버 재시작 시 캐시 유실 | Redis 장애 시 Redis에 의존하는 기능 장애 가능성 |
| 멀티 인스턴스 | 캐시 동기화 불가 | 멀티 서버간 공유 가능 |
EhCache 장단점
장점
1. 매우 빠른 속도
네트워크 왕복이 없기 때문에 Redis보다도 빠르다.
2. 성능 안정성
기업 환경에서 오랫동안 사용된 만큼 안정적이고, 메모리 관리 정책도 성숙하다.
3. 디스크 저장 옵션
휘발성이 아니라, 스냅샷처럼 디스크 저장 기능도 지원한다.
단점
1. 클러스터 환경에서 동기화 불가
단일 인스턴스가 아니라 서버가 여러 대일 경우, 인스턴스 간 캐시 공유, 동기화가 안된다. 여러 인스턴스간 공유하는 캐시가 필요한 경우 사용할 수 없다. 반면에 Redis는 별도 서버에서 동작하기 때문에 멀티 인스턴스간 캐시 공유가 가능하다. 자주 조회되는 작은 단위의 데이터, 일회성 데이터, 동기화가 필요 없는 데이터를 캐싱할 때에 유리하다.
2. 운영 관리 어려움
Redis와 달리 모니터링/백업 등은 애플리케이션 레벨에서 직접 구현해야 한다.
3. 애플리케이션 재기동 시 캐시 초기화
디스크 옵션을 쓰지 않는 이상 애플리케이션 종료 시 캐시는 리셋된다.
EhCache 적용 방법 (Code)
웹에서 찾은 대부분의 소스는 아래 캡쳐본처럼 xml 기반 설정을 사용한다. 이 글에서는 Programmatic configuration 방법을 다룬다.

애플리케이션 버전
- Java 21
- Spring Boot 3.1.12
build.gradle.kts
implementation("org.springframework.boot:spring-boot-starter-cache")
implementation("org.ehcache:ehcache:3.10.8:jakarta") // Jakarta EE 버전
Config
config에서 모든 캐시 정보를 미리 등록해야 합니다.
import java.time.Duration;
import javax.cache.Caching;
import javax.cache.spi.CachingProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.ehcache.config.CacheConfiguration;
import org.ehcache.config.builders.CacheConfigurationBuilder;
import org.ehcache.config.builders.CacheEventListenerConfigurationBuilder;
import org.ehcache.config.builders.ExpiryPolicyBuilder;
import org.ehcache.config.builders.ResourcePoolsBuilder;
import org.ehcache.config.units.EntryUnit;
import org.ehcache.config.units.MemoryUnit;
import org.ehcache.event.EventType;
import org.ehcache.jsr107.Eh107Configuration;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.jcache.JCacheCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Slf4j
@RequiredArgsConstructor
@EnableCaching
@Configuration
public class EhCacheConfig {
private static final String EH_CACHE_PROVIDER_CLASS_NAME = "org.ehcache.jsr107.EhcacheCachingProvider";
@Bean
public CacheManager cacheManager() {
// Cache Provider 설정
CachingProvider provider = Caching.getCachingProvider(EH_CACHE_PROVIDER_CLASS_NAME);
javax.cache.CacheManager jCacheManager = provider.getCacheManager();
// 캐시 1
CacheConfiguration<Object, Object> ehCacheConfig1 =
CacheConfigurationBuilder.newCacheConfigurationBuilder(
Object.class,
Object.class,
ResourcePoolsBuilder.newResourcePoolsBuilder()
.heap(100_000, EntryUnit.ENTRIES) // heap 사이즈 설정
.offheap(10, MemoryUnit.MB) // offheap 사이즈 설정
//.disk(100, MemoryUnit.MB, true)
)
.withExpiry(ExpiryPolicyBuilder.timeToLiveExpiration(Duration.ofSeconds(10L))) // 만료 설정
.withService(// 리스너 등록(EhCacheEventLogger)
CacheEventListenerConfigurationBuilder
.newEventListenerConfiguration(
new EhCacheEventLogger(),
EventType.CREATED, EventType.UPDATED, EventType.EXPIRED, EventType.REMOVED,
EventType.EVICTED
)
.unordered()
.asynchronous()
)
.build();
javax.cache.configuration.Configuration<Object, Object> config1
= Eh107Configuration.fromEhcacheCacheConfiguration(ehCacheConfig1);
jCacheManager.createCache("cache1", config1); // 캐시 생성
// 캐시 2
// 캐시 3 ...
return new JCacheCacheManager(jCacheManager);
}
}
Event Logger
import lombok.extern.slf4j.Slf4j;
import org.ehcache.event.CacheEvent;
import org.ehcache.event.CacheEventListener;
@Slf4j
public class EhCacheEventLogger implements CacheEventListener<Object, Object> {
@Override
public void onEvent(CacheEvent<?, ?> event) {
log.info("[EHCache Event] Type={}, Key={}, Old={}, New={}",
event.getType(), event.getKey(), event.getOldValue(), event.getNewValue());
}
}
테스트, 캐시 활용 방법
Controller
@GetMapping("/default")
public SampleCacheService.User getCacheData(@RequestParam String id) {
System.out.println("### [Controller] getCacheData 호출 id = " + id);
return sampleCacheService.getTestUser(id);
}
Service
@Cacheable(cacheNames = "cache1", key = "#id")
public User getTestUser(String id) {
System.out.println("### [Service] no-cache => getTestUser 실행 id = " + id);
return new User(id, "User-" + id);
}
로그 확인
가독성을 위해 로그 라인은 앞쪽을 생략 후 [LOG] 로 표현합니다.
1. 캐시 없는 상태 - 최초 호출
API 요청 로그, Controller에서 찍은 print, Service에서 찍은 print, 캐시 생성 로그를 확인할 수 있다.
[LOG] GET /api/sample/cache/default?id=test-user1
### [Controller] getCacheData 호출 id = test-user1
### [Service] no-cache => getTestUser 실행 id = test-user1
[LOG] EhCacheEventLogger : [EHCache Event] Type=CREATED, Key=test-user1, Old=null, New=User[id=test-user1, name=User-test-user1]
2. 캐시 있는 상태에서 재호출
API 요청 로그와 Controller에서 찍힌 print는 확인할 수 있지만 캐시 관련 로그와 Service에서 찍는 sout은 없는 걸 볼 수 있다.
[LOG] GET /api/sample/cache/default?id=test-user1
### getCacheData 호출 id = test-user1
3. 캐시 만료 후 호출
Controller 호출 로그 이후에 캐시가 만료된 걸 확인하고, Service 코드 호출된 것을 확인할 수 있다.
이후 캐시를 다시 생성하는 로그도 보인다.
[LOG]GET /api/sample/cache/default?id=test-user1
### [Controller] getCacheData 호출 id = test-user1
[LOG] EhCacheEventLogger : [EHCache Event] Type=EXPIRED, Key=test-user1, Old=User[id=test-user1, name=User-test-user1], New=null
### [Service] no-cache => getTestUser 실행 id = test-user1
[LOG] EhCacheEventLogger : [EHCache Event] Type=CREATED, Key=test-user1, Old=null, New=User[id=test-user1, name=User-test-user1]
전체 코드 샘플
Config
EhCacheType enum을 순회하며 EhCacheConfigurationBuilder를 통해 cache 등록
import javax.cache.Caching;
import javax.cache.spi.CachingProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.jcache.JCacheCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Slf4j
@RequiredArgsConstructor
@EnableCaching
@Configuration
public class EhCacheConfig {
private static final String EH_CACHE_PROVIDER_CLASS_NAME = "org.ehcache.jsr107.EhcacheCachingProvider";
@Bean
public CacheManager cacheManager() {
CachingProvider provider = Caching.getCachingProvider(EH_CACHE_PROVIDER_CLASS_NAME);
javax.cache.CacheManager jCacheManager = provider.getCacheManager();
for (EhCacheType cacheType : EhCacheType.values()) {
log.info("[EhCache] Cache configured name: {}", cacheType.name());
jCacheManager.createCache(cacheType.name(), EhCacheConfigurationBuilder.build(cacheType));
}
return new JCacheCacheManager(jCacheManager);
}
}
EhCacheType
heapEntrySize, offHeapSize, cahceExpireTime으로 구성
@Getter
@RequiredArgsConstructor
public enum EhCacheType {
DEFAULT_CACHE(100_000, 10, Duration.ofMinutes(10L)),
REFERENCE_DATA(10_000, 50, Duration.ofHours(1L));
private final long heapEntrySize;
private final long offHeapSize;
private final Duration duration;
}
EhCacheConfigurationBuilder
CacheType에 등록된 변수들을 기반으로 EhCahceConfiguration 생성
import java.time.Duration;
import javax.cache.configuration.Configuration;
import org.ehcache.config.CacheConfiguration;
import org.ehcache.config.builders.CacheConfigurationBuilder;
import org.ehcache.config.builders.CacheEventListenerConfigurationBuilder;
import org.ehcache.config.builders.ExpiryPolicyBuilder;
import org.ehcache.config.builders.ResourcePoolsBuilder;
import org.ehcache.config.units.EntryUnit;
import org.ehcache.config.units.MemoryUnit;
import org.ehcache.event.EventType;
import org.ehcache.jsr107.Eh107Configuration;
import org.springframework.stereotype.Component;
@Component
public class EhCacheConfigurationBuilder {
public static CacheEventListenerConfigurationBuilder defaultListenerBuilder =
CacheEventListenerConfigurationBuilder
.newEventListenerConfiguration(
new EhCacheEventLogger(),
EventType.CREATED, EventType.UPDATED, EventType.EXPIRED, EventType.REMOVED,
EventType.EVICTED
)
.unordered()
.asynchronous();
/**
*
* @param heapEntrySize ex: 100_000
* @param offHeapSize ex) 10MB
* @param duration ex) Duration.ofMinutes(1L)
*/
public static Configuration build(long heapEntrySize,
long offHeapSize, Duration duration) {
CacheConfiguration<Object, Object> ehCacheConfig =
CacheConfigurationBuilder.newCacheConfigurationBuilder(
Object.class,
Object.class,
ResourcePoolsBuilder.newResourcePoolsBuilder()
.heap(heapEntrySize, EntryUnit.ENTRIES)
.offheap(offHeapSize, MemoryUnit.MB)
//.disk(100, MemoryUnit.MB, true)
)
.withExpiry(ExpiryPolicyBuilder.timeToLiveExpiration(duration))
.withService(defaultListenerBuilder) // 리스너 등록
.build();
// JCache 변환 (Eh107Configuration)
return Eh107Configuration.fromEhcacheCacheConfiguration(ehCacheConfig);
}
public static Configuration build(EhCacheType ehCacheType) {
return build(ehCacheType.getHeapEntrySize(), ehCacheType.getOffHeapSize(),
ehCacheType.getDuration());
}
}
EhCacheEventLogger
캐시 관련 로깅을 위한 클래스
import lombok.extern.slf4j.Slf4j;
import org.ehcache.event.CacheEvent;
import org.ehcache.event.CacheEventListener;
@Slf4j
public class EhCacheEventLogger implements CacheEventListener<Object, Object> {
@Override
public void onEvent(CacheEvent<?, ?> event) {
log.info("[EHCache Event] Type={}, Key={}, Old={}, New={}",
event.getType(), event.getKey(), event.getOldValue(), event.getNewValue());
}
}
EhCacheManager
캐시를 직접 컨트롤하기 위한 클래스
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Component;
@RequiredArgsConstructor
@Component
public class EhCacheManager {
private final CacheManager cacheManager;
/**
* 캐시 매니저 반환
*
* @return CacheManager
*/
public CacheManager getCacheManager() {
return cacheManager;
}
/**
* 캐시 반환
*
* @param cacheName 캐시명 (분류) EhCacheType.name()
* @return cache 반환
*/
public Cache getCache(String cacheName) {
return cacheManager.getCache(cacheName);
}
/**
* 캐시 수동 조회
*
* @param cacheName 캐시명 (분류) EhCacheType.name()
* @param id 캐시 id
* @param clazz value type
* @param <T> cache Value type
* @return value
*/
public <T> Optional<T> getCache(String cacheName, Object id, Class<T> clazz) {
Cache cache = cacheManager.getCache(cacheName);
if (cache == null) {
return Optional.empty();
}
T value = cache.get(id, clazz);
return Optional.ofNullable(value);
}
/**
* 캐시 전체 삭제 (모든 entry 삭제)
*
* @param cacheName 캐시명 (분류) EhCacheType.name()
*/
public void clearCache(String cacheName) {
Cache cache = cacheManager.getCache(cacheName);
if (cache != null) {
cache.clear();
}
}
/**
* 캐시 삭제 by id
*
* @param cacheName 캐시명 (분류) EhCacheType.name()
* @param id 캐시 id
*/
public void evictCache(String cacheName, Object id) {
Cache cache = cacheManager.getCache(cacheName);
if (cache != null) {
cache.evict(id);
}
}
/**
* 캐시 업데이트
*
* @param cacheName 캐시명 (분류) EhCacheType.name()
* @param id 캐시 id
* @param value 값
*/
public void upsertCache(String cacheName, Object id, Object value) {
Cache cache = cacheManager.getCache(cacheName);
if (cache != null) {
cache.put(id, value);
}
}
}
'Spring' 카테고리의 다른 글
| 카카오 로그인 API 사용 방법 - 2025 ver (Java, Spring) (0) | 2025.12.10 |
|---|---|
| [Spring] 메모리 Queue + @Scheduling 배치 처리 - 부하 분산 (0) | 2025.09.21 |
| [Spring] @Transactional 전파수준 정리 - Propagation 예제코드로 테스트해보기 (0) | 2025.06.19 |
| [Spring] @RequestBody JSON 바인딩 원리와 Jackson 직렬화/역직렬화 과정 (MappingJackson2HttpMessageConverter) (1) | 2025.06.08 |
| [JPA] fetchJoin시 MultipleBagFetchException 발생(카르테시안 곱) (0) | 2025.04.06 |