SharedMap과 로컬 캐시 구현 실전 가이드
Redis 없는 환경에서 고성능 로컬 캐시 구축하기
여는 글
안녕하세요. 오늘은 Java의 Map 구현체들 중에서도 멀티스레드 환경에서 안전하게 사용할 수 있는 SharedMap에 대해 이야기해보겠습니다. 특히 Redis나 외부 캐시 솔루션을 사용할 수 없는 제약 환경에서 로컬 캐시를 구현할 때 마주치는 문제들과 해결 방안에 대해 실무 경험을 바탕으로 공유하겠습니다.
SharedMap<K,V>이란?
SharedMap<K,V>
는 Java에서 프로세스 간 메모리 공유를 위해 설계된 특별한 Map 구현체입니다. Chronicle Map이나 Java의 Memory-Mapped Files를 기반으로 한 구현체들이 대표적입니다.
SharedMap의 특징
// SharedMap의 기본 사용법 (Chronicle Map 예시)
SharedMap<String, UserSession> sessionMap = ChronicleMap
.of(String.class, UserSession.class)
.entries(100_000)
.create();
// 일반 Map처럼 사용 가능
sessionMap.put("user123", new UserSession("user123", LocalDateTime.now()));
UserSession session = sessionMap.get("user123");
일반적인 Map과의 차이점:
- 프로세스 간 공유: 여러 JVM 프로세스가 동일한 메모리 영역 공유
- 영속성: 프로세스 재시작 후에도 데이터 유지 가능
- 메모리 매핑: OS 레벨의 메모리 매핑 기술 활용
- Zero-Copy: 직렬화 없이 데이터 접근 가능
실무에서의 활용 사례
Payment Gateway 프로젝트에서 여러 인스턴스 간 세션 정보를 공유할 때 SharedMap을 고려해봤습니다:
@Component
public class SharedSessionStore {
private final SharedMap<String, PaymentSession> sessionMap;
public SharedSessionStore() {
this.sessionMap = ChronicleMap
.of(String.class, PaymentSession.class)
.entries(50_000)
.averageKeySize(32)
.averageValueSize(1024)
.createPersistedTo(new File("session-store.dat"));
}
public void storeSession(String sessionId, PaymentSession session) {
sessionMap.put(sessionId, session);
}
public PaymentSession getSession(String sessionId) {
return sessionMap.get(sessionId);
}
}
하지만 실제로는 다음과 같은 제약사항으로 인해 사용하지 못했습니다:
- 외부 라이브러리 사용 제한
- 파일 시스템 접근 권한 문제
- 복잡한 설정과 관리 오버헤드
그래서 일반적인 ConcurrentHashMap
기반의 로컬 캐시로 대안을 구현했습니다. TTL(Time To Live), LRU(Least Recently Used) 정책, 메모리 사용량 제한 등 추가적인 기능이 필요한 경우가 대부분이었기 때문입니다.
기존 Map 구현체들과의 차이점
HashMap vs ConcurrentHashMap vs Collections.synchronizedMap
// 1. HashMap - 스레드 안전하지 않음
Map<String, String> hashMap = new HashMap<>();
// 멀티스레드 환경에서 데이터 손실이나 무한루프 발생 가능
// 2. Collections.synchronizedMap - 전체 메서드를 동기화
Map<String, String> syncMap = Collections.synchronizedMap(new HashMap<>());
// 성능이 좋지 않음, 복합 연산 시 별도 동기화 필요
// 3. ConcurrentHashMap - 세그먼트 기반 동기화
ConcurrentHashMap<String, String> concurrentMap = new ConcurrentHashMap<>();
// 높은 성능과 스레드 안전성 보장
SharedMap vs ConcurrentHashMap vs 일반 Map
구분 | HashMap | ConcurrentHashMap | SharedMap (Chronicle Map) |
---|---|---|---|
스레드 안전성 | ❌ | ✅ | ✅ |
프로세스 간 공유 | ❌ | ❌ | ✅ |
영속성 | ❌ | ❌ | ✅ |
메모리 효율성 | 높음 | 높음 | 매우 높음 |
설정 복잡도 | 낮음 | 낮음 | 높음 |
외부 의존성 | 없음 | 없음 | 필요 |
ConcurrentHashMap 내부 동작 원리
세그먼트 기반 락킹 (Java 7 이전)
// Java 7 이전 방식 (개념적 설명)
public class ConceptualConcurrentHashMap<K, V> {
private final Segment<K, V>[] segments;
static class Segment<K, V> extends ReentrantLock {
private volatile HashEntry<K, V>[] table;
// 각 세그먼트가 독립적인 락을 가짐
}
}
CAS 기반 최적화 (Java 8 이후)
Java 8부터는 CAS(Compare-And-Swap) 연산과 synchronized 블록을 조합하여 더 효율적인 동시성 제어를 구현합니다.
// Java 8+ 방식의 핵심 아이디어
public V putVal(K key, V value, boolean onlyIfAbsent) {
Node<K,V>[] tab; Node<K,V> p; int n, i, fh;
// CAS로 빈 버킷에 노드 삽입 시도
if ((p = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break; // 성공
}
else {
// 충돌 발생 시에만 synchronized 사용
synchronized (p) {
// 체인이나 트리 구조 처리
}
}
}
실무에서의 로컬 캐시 구현
프로젝트 배경
Payment Gateway 프로젝트에서 다음과 같은 요구사항이 있었습니다:
- 가맹점 정보를 빠르게 조회해야 함 (응답시간 < 50ms)
- Redis 사용 불가 (보안 정책상 외부 의존성 금지)
- 메모리 사용량 제한 (최대 512MB)
- 데이터 변경 시 즉시 반영 필요
1단계: 기본 캐시 구현
@Component
public class LocalCache<K, V> {
private final ConcurrentHashMap<K, CacheEntry<V>> cache = new ConcurrentHashMap<>();
public static class CacheEntry<V> {
private final V value;
private final long createTime;
private volatile long lastAccessTime;
public CacheEntry(V value) {
this.value = value;
this.createTime = System.currentTimeMillis();
this.lastAccessTime = createTime;
}
public boolean isExpired(long ttl) {
return System.currentTimeMillis() - createTime > ttl;
}
}
public V get(K key) {
CacheEntry<V> entry = cache.get(key);
if (entry != null && !entry.isExpired(300000)) { // 5분 TTL
entry.lastAccessTime = System.currentTimeMillis();
return entry.value;
}
cache.remove(key); // 만료된 엔트리 제거
return null;
}
public void put(K key, V value) {
cache.put(key, new CacheEntry<>(value));
}
}
2단계: LRU 정책 추가
메모리 사용량 제한을 위해 LRU 정책을 구현했습니다:
@Component
public class LRULocalCache<K, V> {
private final ConcurrentHashMap<K, CacheEntry<V>> cache = new ConcurrentHashMap<>();
private final ConcurrentLinkedQueue<K> accessOrder = new ConcurrentLinkedQueue<>();
private final int maxSize;
private final AtomicInteger currentSize = new AtomicInteger(0);
public LRULocalCache(int maxSize) {
this.maxSize = maxSize;
}
public V get(K key) {
CacheEntry<V> entry = cache.get(key);
if (entry != null && !entry.isExpired()) {
// LRU 순서 업데이트
updateAccessOrder(key);
return entry.value;
}
return null;
}
public void put(K key, V value) {
if (currentSize.get() >= maxSize) {
evictLRU();
}
if (cache.put(key, new CacheEntry<>(value)) == null) {
currentSize.incrementAndGet();
}
updateAccessOrder(key);
}
private void evictLRU() {
K oldestKey = accessOrder.poll();
if (oldestKey != null && cache.remove(oldestKey) != null) {
currentSize.decrementAndGet();
}
}
private void updateAccessOrder(K key) {
accessOrder.remove(key); // O(n) 연산이지만 실용적
accessOrder.offer(key);
}
}
3단계: 성능 최적화
실제 운영하면서 발견한 성능 병목점들을 해결했습니다:
@Component
public class OptimizedLocalCache<K, V> {
private final ConcurrentHashMap<K, CacheEntry<V>> cache = new ConcurrentHashMap<>();
private final ScheduledExecutorService cleanupExecutor =
Executors.newSingleThreadScheduledExecutor();
@PostConstruct
public void startCleanupTask() {
// 주기적으로 만료된 엔트리 정리 (백그라운드)
cleanupExecutor.scheduleAtFixedRate(this::cleanup, 1, 1, TimeUnit.MINUTES);
}
private void cleanup() {
long now = System.currentTimeMillis();
cache.entrySet().removeIf(entry -> entry.getValue().isExpired(now));
}
// 조회 시마다 만료 체크를 하지 않아 성능 향상
public V get(K key) {
CacheEntry<V> entry = cache.get(key);
return entry != null ? entry.value : null;
}
@PreDestroy
public void shutdown() {
cleanupExecutor.shutdown();
}
}
실무에서 마주친 문제들
1. 메모리 누수 문제
초기 구현에서는 만료된 엔트리가 자동으로 제거되지 않아 메모리 누수가 발생했습니다.
해결책: 백그라운드 정리 작업과 WeakReference 활용
// WeakReference를 활용한 자동 정리
private final ConcurrentHashMap<K, WeakReference<CacheEntry<V>>> cache =
new ConcurrentHashMap<>();
2. 동시성 이슈
여러 스레드가 동시에 같은 키에 대해 데이터를 로드할 때 중복 작업이 발생했습니다.
해결책: Double-Checked Locking 패턴 적용
public V getOrLoad(K key, Function<K, V> loader) {
CacheEntry<V> entry = cache.get(key);
if (entry == null) {
synchronized (this) {
entry = cache.get(key); // 다시 확인
if (entry == null) {
V value = loader.apply(key);
entry = new CacheEntry<>(value);
cache.put(key, entry);
}
}
}
return entry.value;
}
3. 캐시 워밍업
애플리케이션 시작 시 캐시가 비어있어 초기 응답시간이 느렸습니다.
해결책: 애플리케이션 시작 시 주요 데이터 미리 로드
@EventListener(ApplicationReadyEvent.class)
public void warmupCache() {
log.info("Starting cache warmup...");
// 주요 가맹점 정보 미리 로드
List<String> popularMerchants = merchantService.getPopularMerchantIds();
popularMerchants.parallelStream()
.forEach(id -> merchantCache.getOrLoad(id, merchantService::findById));
log.info("Cache warmup completed. Loaded {} entries", popularMerchants.size());
}
성능 측정 결과
로컬 캐시 도입 전후 비교:
항목 | 캐시 도입 전 | 캐시 도입 후 |
---|---|---|
평균 응답시간 | 150ms | 15ms |
DB 조회 횟수 | 100만/일 | 10만/일 |
캐시 히트율 | - | 92% |
메모리 사용량 | 기준점 | +300MB |
대안 기술과 비교
SharedMap (Chronicle Map) 사용 시나리오
// Chronicle Map 기반 SharedMap 구현 (제약으로 인해 사용하지 못한 이상적 방안)
SharedMap<String, MerchantInfo> merchantMap = ChronicleMap
.of(String.class, MerchantInfo.class)
.entries(100_000)
.averageKeySize(20)
.averageValueSize(500)
.createPersistedTo(new File("merchant-cache.dat"));
// 여러 프로세스가 동일한 캐시 공유
merchantMap.put("merchant123", merchantInfo);
SharedMap의 장점:
- 프로세스 간 메모리 공유로 중복 데이터 제거
- 영속성 보장으로 재시작 시 캐시 유지
- Zero-copy 접근으로 높은 성능
제약사항으로 인한 한계:
- 외부 라이브러리 사용 불가
- 파일 시스템 접근 권한 필요
- 복잡한 설정 및 관리
만약 Redis를 사용할 수 있다면?
// Redis 기반 캐시 (사용할 수 없었던 이상적인 방안)
@Cacheable(value = "merchants", key = "#merchantId")
public Merchant findMerchant(String merchantId) {
return merchantRepository.findById(merchantId);
}
Redis의 장점:
- 분산 환경에서 캐시 공유 가능
- 영속성 보장
- 다양한 데이터 구조 지원
로컬 캐시의 장점:
- 네트워크 지연 없음
- 외부 의존성 없음
- 구현 복잡도 낮음
Caffeine Cache 활용
제약이 풀린다면 검토해볼 만한 라이브러리:
Cache<String, Merchant> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats()
.build();
결론
구분 | HashMap | ConcurrentHashMap | SharedMap | 직접 구현 로컬 캐시 |
---|---|---|---|---|
스레드 안전성 | ❌ | ✅ | ✅ | ✅ |
프로세스 간 공유 | ❌ | ❌ | ✅ | ❌ |
영속성 | ❌ | ❌ | ✅ | ❌ |
TTL 지원 | ❌ | ❌ | ✅ | ✅ |
LRU 정책 | ❌ | ❌ | ✅ | ✅ |
메모리 관리 | ❌ | ❌ | ✅ | ✅ |
외부 의존성 | 없음 | 없음 | 필요 | 없음 |
맺는글
Redis를 사용할 수 없는 제약 환경에서도 충분히 효과적인 로컬 캐시를 구현할 수 있었습니다. 중요한 것은:
- 적절한 기반 기술 선택: ConcurrentHashMap의 동시성 특성 이해
- 점진적 개선: 기본 구현 → LRU 정책 → 성능 최적화 순서로 발전
- 실제 문제 해결: 메모리 누수, 동시성 이슈 등 실무에서 발생하는 문제들 대응
- 성능 측정: 도입 효과를 정량적으로 확인
제약사항이 있는 환경에서도 창의적인 해결책을 통해 요구사항을 충족할 수 있다는 것을 경험할 수 있었습니다. 이 과정에서 Java의 동시성 메커니즘에 대한 깊은 이해도 얻을 수 있었고요.