여는 글

안녕하세요. 오늘은 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를 사용할 수 없는 제약 환경에서도 충분히 효과적인 로컬 캐시를 구현할 수 있었습니다. 중요한 것은:

  1. 적절한 기반 기술 선택: ConcurrentHashMap의 동시성 특성 이해
  2. 점진적 개선: 기본 구현 → LRU 정책 → 성능 최적화 순서로 발전
  3. 실제 문제 해결: 메모리 누수, 동시성 이슈 등 실무에서 발생하는 문제들 대응
  4. 성능 측정: 도입 효과를 정량적으로 확인

제약사항이 있는 환경에서도 창의적인 해결책을 통해 요구사항을 충족할 수 있다는 것을 경험할 수 있었습니다. 이 과정에서 Java의 동시성 메커니즘에 대한 깊은 이해도 얻을 수 있었고요.