Spring Boot + Ehcache로 데이터베이스 부하 줄이기
들어가며
애플리케이션을 운영하다 보면 동일한 데이터를 반복적으로 조회하는 경우가 많다.
특히 다음과 같은 상황에서 문제가 발생한다:
- 변경이 거의 없는 데이터를 매번 DB 조회
- 짧은 주기로 반복 조회되는 대시보드
- 높은 트래픽으로 인한 DB 부하
이 글에서는 Ehcache를 활용하여 데이터베이스 부하를 줄이고 성능을 개선한 실전 사례를 공유한다.
Ehcache란?
Ehcache 공식 사이트에서는 다음과 같이 소개하고 있다:
Ehcache는 가장 널리 사용되는 Java 기반 캐시로, 성능을 향상시키고 데이터베이스 부하를 줄이며 확장성을 간소화하는 데 사용되는 오픈 소스 및 표준 기반의 캐시이다.
동작 방식
Before (캐시 없음):
사용자 요청 → 애플리케이션 → 데이터베이스
↑
매번 DB 조회
After (캐시 적용):
사용자 요청 → 애플리케이션 (캐시 확인)
↓ (캐시 Hit)
캐시된 데이터 반환
↓ (캐시 Miss)
데이터베이스 → 캐시 저장 → 반환
주요 특징
1. 메모리 캐싱
- 데이터를 메모리에 캐시하여 성능 향상
- DB나 외부 서비스 조회 시간 단축
2. 다양한 캐시 설정
- 캐시 크기, 만료 정책, 메모리 사용량 조절 가능
- TTL(Time To Live) 설정
3. 다양한 저장소 지원
- Heap: JVM 힙 메모리
- Off-Heap: JVM 외부 메모리
- Disk: 디스크 저장
Ehcache 적용 케이스
다음과 같은 경우에 Ehcache 적용을 고려할 수 있다:
- 요청 빈도가 높은 경우
- 변경 빈도가 낮은 데이터
- 부하가 큰 요청 처리
적용 사례 1: 공통코드 캐싱
문제 상황
차량 관제 시스템을 운영할 당시, 다음과 같은 문제가 있었다:
// 화면 로딩 시마다 공통코드 조회
@GetMapping("/common-codes")
public List<CommonCode> getCommonCodes() {
return commonCodeRepository.findAll(); // 매번 DB 조회!
}
문제점:
- 화면이 열릴 때마다 공통코드 조회 API 호출
- 공통코드는 변경이 거의 없는데도 매번 DB 조회
- 하루 화면 오픈 횟수: 약 1,300회
해결 방법
Ehcache 적용:
@Service
@RequiredArgsConstructor
public class CommonCodeService {
private final CommonCodeRepository commonCodeRepository;
// 캐시 적용
@Cacheable(cacheNames = "commonCodeCache", key = "'all'")
public List<CommonCode> getAllCommonCodes() {
return commonCodeRepository.findAll();
}
}
캐시 설정:
private CacheConfiguration<String, List<CommonCode>> createCommonCodeCache() {
return CacheConfigurationBuilder
.newCacheConfigurationBuilder(
String.class,
List.class,
ResourcePoolsBuilder.heap(10L))
.withExpiry(ExpiryPolicyBuilder.timeToLiveExpiration(Duration.ofMinutes(30)))
.build();
}
성과
정량적 성과 (연간 기준):
화면 오픈: 1,300회/일
쿼리 성능: 1ms
캐시 적용 후: 첫 조회만 DB 접근
연간 절감:
- DB 조회 횟수: 1,300회/일 × 365일 = 474,500회 → 365회
- 절감 시간: 474,500ms = 7.9분
부수적 효과:
- DB 커넥션 풀 여유 증가
- DB 부하 감소로 다른 쿼리 성능 개선
- 네트워크 트래픽 감소
적용 사례 2: 대시보드 캐싱
문제 상황
물류센터의 도크(Dock) 예약 관리 시스템을 운영할 당시:
// 대시보드: 5분마다 자동 조회
setInterval(() => {
fetch('/api/dashboard/dock-status')
.then(response => response.json())
.then(data => updateDashboard(data));
}, 300000); // 5분 = 300,000ms
문제점:
- 사업 확장으로 물류센터 증가
- 각 센터마다 대시보드 디바이스 운영
- 100개 디바이스 × 288회/일 = 28,800회/일
- DB 부하 급증
도크(Dock)란?
- 물류센터에서 차량 적재를 위한 수평 이동 시설
- 창고 안에서 차량으로 직접 물품 적재 가능
해결 방법
서비스 레이어에 캐시 적용:
@Service
@RequiredArgsConstructor
public class DashboardService {
private final DockReservationRepository dockReservationRepository;
// 5분 TTL 캐시
@Cacheable(cacheNames = "dashboardCache", key = "'dockStatus'")
public DockStatusResponse getDockStatus() {
// 복잡한 조회 쿼리 (Join, 집계 등)
return dockReservationRepository.findDockStatusWithReservations();
}
}
캐시 설정:
private CacheConfiguration<String, DockStatusResponse> createDashboardCache() {
return CacheConfigurationBuilder
.newCacheConfigurationBuilder(
String.class,
DockStatusResponse.class,
ResourcePoolsBuilder.heap(1L)) // 1개만 유지
.withExpiry(ExpiryPolicyBuilder.timeToLiveExpiration(Duration.ofMinutes(5)))
.withDispatcherConcurrency(2)
.build();
}
성과
정량적 성과 (월간 기준):
디바이스 수: 100개
호출 주기: 5분 (하루 288회)
월간 호출: 100 × 288 × 30 = 864,000회
캐시 적용 후:
- DB 조회: 8,640회 (5분마다 1회만 DB 조회)
- 절감률: 99% 감소
부수적 효과:
- DB 커넥션 타임아웃 해소
- 대시보드 응답 속도 개선 (평균 500ms → 10ms)
- 사업 확장에도 안정적 운영 가능
Spring Boot에서 Ehcache 적용하기
실제 코드로 Ehcache를 적용하는 방법을 알아보자.
전체 코드는 GitHub에서 확인 가능하다.
개발 환경:
- Java 17
- Spring Boot 3.2.4
- Gradle
1단계: 의존성 추가
build.gradle:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
// Ehcache 의존성
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'org.ehcache:ehcache:3.10.0'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
}
spring-boot-starter-cache:
- Spring Framework의 캐시 추상화 제공
@Cacheable,@CachePut,@CacheEvict등 어노테이션 사용 가능
2단계: Ehcache 설정
EhcacheConfig.java:
@EnableCaching // 캐싱 활성화
@Configuration
public class EhcacheConfig {
@Bean
public CacheManager cacheManager() {
// Ehcache 제공자 생성
EhcacheCachingProvider provider = (EhcacheCachingProvider) Caching.getCachingProvider();
// 캐시 설정 맵 생성
Map<String, CacheConfiguration<?, ?>> cacheConfigMap = getCacheConfigurationMap();
// Ehcache 기본 설정
DefaultConfiguration config = new DefaultConfiguration(
cacheConfigMap,
provider.getDefaultClassLoader()
);
// JCache Manager 생성
javax.cache.CacheManager cacheManager = provider.getCacheManager(
provider.getDefaultURI(),
config
);
// Spring CacheManager로 변환
return new JCacheCacheManager(cacheManager);
}
private Map<String, CacheConfiguration<?, ?>> getCacheConfigurationMap() {
Map<String, CacheConfiguration<?, ?>> cacheMap = new HashMap<>();
// Person 캐시 등록
cacheMap.put("personCache", createPersonCache());
return cacheMap;
}
private CacheConfiguration<String, Person> createPersonCache() {
return CacheConfigurationBuilder
.newCacheConfigurationBuilder(
String.class,
Person.class,
ResourcePoolsBuilder.heap(10L)) // 최대 10개 엔트리
.withExpiry(new DefaultExpiry()) // 5분 TTL
.withDispatcherConcurrency(2) // 동시 처리
.withDefaultEventListenersThreadPool()
.withService(getCacheEventListenerConfig())
.build();
}
private CacheEventListenerConfigurationBuilder getCacheEventListenerConfig() {
return CacheEventListenerConfigurationBuilder
.newEventListenerConfiguration(
new EhcacheEventListener(),
EventType.CREATED,
EventType.UPDATED,
EventType.EXPIRED,
EventType.REMOVED
)
.unordered()
.asynchronous();
}
}
@EnableCaching의 역할:
- Spring 캐싱 기능 활성화
- 캐시 관련 어노테이션 사용 가능
- 캐시 관련 빈 자동 구성
3단계: 만료 정책 설정
DefaultExpiry.java:
public class DefaultExpiry implements ExpiryPolicy<String, Person> {
private static final Duration DEFAULT_DURATION = Duration.ofMinutes(5);
@Override
public Duration getExpiryForCreation(String key, Person value) {
return DEFAULT_DURATION; // 생성 시 5분
}
@Override
public Duration getExpiryForAccess(String key, Supplier<? extends Person> value) {
return DEFAULT_DURATION; // 조회 시 5분 연장
}
@Override
public Duration getExpiryForUpdate(String key, Supplier<? extends Person> oldValue, Person newValue) {
return DEFAULT_DURATION; // 수정 시 5분 연장
}
}
만료 정책 메서드:
getExpiryForCreation: 캐시 생성 시 만료 시간getExpiryForAccess: 캐시 조회 시 만료 시간 (연장 가능)getExpiryForUpdate: 캐시 수정 시 만료 시간
주의사항:
- 기본 만료 설정이 없으면 영구 보관
- 메모리 부족 위험 → 반드시 만료 정책 설정
4단계: 이벤트 리스너 설정
EhcacheEventListener.java:
public class EhcacheEventListener implements CacheEventListener<Object, Object> {
@Override
public void onEvent(CacheEvent<? extends Object, ? extends Object> event) {
System.out.println("Cache Event: " + event.getType());
System.out.println(" Key: " + event.getKey());
System.out.println(" Value: " + event.getNewValue());
}
}
이벤트 타입:
CREATED: 캐시 생성UPDATED: 캐시 수정EXPIRED: 캐시 만료REMOVED: 캐시 삭제
실전 예제
Entity 및 Repository
Person.java:
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Getter
@EqualsAndHashCode
public class Person implements Serializable {
private String id;
private String name;
public static Person create(String id, String name) {
return new Person(id, name);
}
public void updateName(String newName) {
this.name = newName;
}
}
Persons.java (DB 역할):
public class Persons {
private static final List<Person> persons = new ArrayList<>();
static {
// 초기 데이터
persons.add(Person.create("aks", "악스"));
}
public static Person getPerson(String id) {
return persons.stream()
.filter(person -> person.getId().equals(id))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Person not found"));
}
public static void deletePerson(String id) {
persons.removeIf(person -> person.getId().equals(id));
}
}
PersonRepository.java:
@Repository
public class PersonRepository {
public Person selectPerson(String id) {
return Persons.getPerson(id);
}
public void deletePerson(String id) {
Persons.deletePerson(id);
}
}
Service (캐시 적용)
PersonService.java:
@RequiredArgsConstructor
@Service
public class PersonService {
private final PersonRepository personRepository;
// 캐시 없이 조회
public Person selectPerson(String id) {
return personRepository.selectPerson(id);
}
// 캐시에서 조회
@Cacheable(cacheNames = "personCache", key = "#id")
public Person selectPersonFromCache(String id) {
System.out.println("DB 조회: " + id); // 캐시 미스 시에만 출력
return personRepository.selectPerson(id);
}
// 캐시 업데이트
@CachePut(cacheNames = "personCache", key = "#id")
public Person updatePersonInCache(String id, String newName) {
Person person = this.selectPerson(id);
person.updateName(newName);
return person;
}
// 캐시 삭제
@CacheEvict(cacheNames = "personCache", key = "#id")
public void deletePersonFromCache(String id) {
personRepository.deletePerson(id);
}
}
캐시 어노테이션:
@Cacheable: 캐시 조회, 없으면 메서드 실행 후 캐시 저장@CachePut: 메서드 항상 실행 후 결과를 캐시에 저장@CacheEvict: 캐시에서 삭제
주의사항: Self-Invocation
// 잘못된 예
@Cacheable(cacheNames = "personCache", key = "#id")
public Person selectPersonFromCache(String id) {
return personRepository.selectPerson(id);
}
@CachePut(cacheNames = "personCache", key = "#id")
public Person updatePersonInCache(String id, String newName) {
// this로 호출하면 AOP가 동작하지 않음!
Person person = this.selectPersonFromCache(id); // ✗
person.updateName(newName);
return person;
}
// 올바른 예
@CachePut(cacheNames = "personCache", key = "#id")
public Person updatePersonInCache(String id, String newName) {
// 캐시 없이 직접 조회
Person person = this.selectPerson(id); // ✓
person.updateName(newName);
return person;
}
테스트 코드
PersonServiceTest.java:
@SpringBootTest
public class PersonServiceTest {
@Autowired
private PersonService personService;
@Autowired
private CacheManager cacheManager;
@AfterEach
void afterEach() {
// 각 테스트 후 캐시 초기화
cacheManager.getCache("personCache").clear();
}
@Test
public void selectPerson_호출시_캐싱되지않는다() {
// given
String id = "aks";
// when
personService.selectPerson(id);
// then
Cache.ValueWrapper cache = cacheManager.getCache("personCache").get(id);
Assertions.assertNull(cache);
}
@Test
public void selectPersonFromCache_호출시_캐싱된다() {
// given
String id = "aks";
// when
personService.selectPersonFromCache(id);
// then
Cache.ValueWrapper cache = cacheManager.getCache("personCache").get(id);
Assertions.assertNotNull(cache);
}
@Test
public void selectPersonFromCache_2회_호출시_1회만_DB조회() {
// given
String id = "aks";
// when
personService.selectPersonFromCache(id); // DB 조회
personService.selectPersonFromCache(id); // 캐시 조회
personService.selectPersonFromCache(id); // 캐시 조회
// then
// 콘솔 출력으로 "DB 조회: aks"가 1번만 출력되는지 확인
Cache.ValueWrapper cache = cacheManager.getCache("personCache").get(id);
Assertions.assertNotNull(cache);
}
@Test
public void updatePersonInCache_호출시_캐시가_변경된다() {
// given
String id = "aks";
String expected = "스악";
personService.selectPersonFromCache(id); // 캐시 생성
// when
personService.updatePersonInCache(id, expected); // 캐시 수정
// then
Cache.ValueWrapper wrapper = cacheManager.getCache("personCache").get(id);
String actual = ((Person) wrapper.get()).getName();
Assertions.assertEquals(expected, actual);
}
@Test
public void deletePersonFromCache_호출시_캐시가_삭제된다() {
// given
String id = "aks";
personService.selectPersonFromCache(id); // 캐시 생성
// when
personService.deletePersonFromCache(id); // 캐시 삭제
// then
Cache.ValueWrapper cache = cacheManager.getCache("personCache").get(id);
Assertions.assertNull(cache);
}
}
테스트 결과:
✓ selectPerson_호출시_캐싱되지않는다
✓ selectPersonFromCache_호출시_캐싱된다
✓ selectPersonFromCache_2회_호출시_1회만_DB조회
✓ updatePersonInCache_호출시_캐시가_변경된다
✓ deletePersonFromCache_호출시_캐시가_삭제된다
Cache Event: CREATED
Key: aks
Value: Person(id=aks, name=악스)
Cache Event: UPDATED
Key: aks
Value: Person(id=aks, name=스악)
Cache Event: REMOVED
Key: aks
정리
핵심 요약
문제:
- 반복적인 DB 조회로 인한 부하
- 변경이 적은 데이터도 매번 조회
- 대시보드 등 주기적 조회로 인한 커넥션 부족
해결:
- Ehcache 도입
- 메모리 캐싱으로 DB 조회 최소화
- TTL 설정으로 데이터 신선도 유지
- 적용 사례
- 공통코드: 연 47만 회 → 365회 (99.9% 감소)
- 대시보드: 월 86만 회 → 8,600회 (99% 감소)
- 성과
- DB 부하 대폭 감소
- 응답 속도 개선
- 사업 확장에도 안정적 운영
Ehcache 적용 가이드
적용 검토:
- 요청 빈도가 높은가?
- 변경 빈도가 낮은가?
- DB 부하가 문제인가?
설정 체크리스트:
@EnableCaching설정- 캐시 설정 (크기, TTL)
- 만료 정책 설정 (필수!)
- 이벤트 리스너 (선택)
- 테스트 코드 작성
주의사항:
- 만료 정책 미설정 시 메모리 부족
- Self-invocation 문제 주의
- 캐시 일관성 관리 (수정/삭제 시 evict)
마지막으로
Ehcache는 간단한 설정만으로 데이터베이스 부하를 크게 줄일 수 있는 효과적인 도구다.
특히 변경이 적고 조회가 잦은 데이터에 적용하면 즉시 효과를 볼 수 있다.
“반복되는 조회, 캐시로 해결하자.”
댓글남기기