발단

마이데이터에서는 기본적으로 외부 기관에 Dependency 가 걸려있는 수많은 API 거래가 발생하기 때문에, 발생하는 API 요청들을 처리하기 위해서는 다양한 시스템적인 고민이 필요했다. 그 중에 하나가 DB 부하와 API 호출 속도 개선을 위한 JVM 내부 Snapshot 캐시 개발이다.

아래 그림을 보면 마이데이터 시스템 아키텍처를 매우 단순히 구조화 시켜놓은 그림이다. Client 에서 요청이 발생하면 여러 서버와 채널계 호출을 거쳐 결국에는 마이데이터 플랫폼의 서버를 호출하게 된다. 이때 App 에서는 (고객, 인증, 기관, API) 와 관련된 굉장히 다양한 메타데이터가 발생하게 된다. 고객마다의 인증정보, 호출하려는 기관, 호출하는 API 마다의 명세가 다르기 때문이다.

이런 상황에서, 매 호출 마다 DB 에서 정보를 가져오는 것은 매우 비효율적이다. 그렇다고 모든 명세와 메타정보를 하드코딩 하기에는 빈번히 변경되는 API 규격과 실시간 인증처리에 대응이 불가능 하다. 따라서 자주 조회하는 정보는 캐시로 들고 있고, 변경점이 발생 할 경우 이를 주기적으로 갱신하는 방식이 효율적이라고 판단했다.

어떤 캐시를 사용할까?

가장 일반적이고 간단한(?) 방법은 In-memory 기반의 Redis 같은 캐시 클러스터를 구축해 사용하는 것이였다. 이는 Multi-Node 로 구성된 서버들과 주기적으로 업데이트 되는 정보를 공유하기 유리 하다. 이미 안정성과 사용성도 검증되었기 때문에 운영하는 데에 큰 이슈도 없어보였다. 하지만 결국은 Redis Cluster 를 별도로 관리해야 하는 어려움과, 현재 WebtoB-Jeus 솔루션에 Redis의 연동을 정상적으로 진행하기 위해서는 필요한 인프라 레벨의 버저닝을 일치시키는 작업이 수반되었고, 무엇보다 굳이 각 jvm 간에 메타데이터 Sync를 일치시킬 필요성은 없었다.

다음 생각한 것은 Memcached(멤캐시디) 와 같이  RAM에 데이터를 캐싱하여 웹 애플리케이션 속도를 향상시키는 메모리 객체 캐싱 시스템이였다. 초기에는 실제로 Memcached 도입을 검토하다가 프레임워크 레벨에 이미 안정성이 검증된 노드레벨 캐시가 있다는 것을 확인했고, PoC를 바로 진행했다. 하지만 진행중에 해당 방식이 지원하는 것은 단순 (key-value) 형태였고 내가 필요한 것은 고객/인증/기관/API 각 정보를 유기적으로 담을 (key-multimap) 형태가 필요했다. 단순 key-value 형태도 key 모양을 어떻게 잡는지에 따라 처리가 불가능하진 않겠으나 추후 관리와 문자열 처리에 대한 성능저하는 불가피해보였다.

개선방안 - SnapShot Cache

여러 방안을 고민해보다가 JVM 내부에서 관리되는 SnapShot 형태의 캐시를 개발하게 되었다. 별도의 인프라 변경 없이 소스코드 만으로 개발이 가능하기 떄문에 적은 공수로도 원하는 목적이 달성 가능했기 때문이다. 여러 시행착오를 거쳐 최종적으로는 최초 트랜젝션 발생 시점에 DB에서 API 메타정보를 한번에 읽어와서 불변 스탭샷 객체 로 만들고, TTL 이 만료되는 경우 그 스냅샷을 통째로 교체하는 방식의 캐시로 만들었다.

구조

전체 코드의 Class Diagram은 다음과 같다.

  1. ApiCaches.java : 바깥에서 호출하는 진입점으로 Singleton 관리자이다.
  2. ApiCommonMetaCache.java : 실제 캐시 엔진이다.
  3. ApiCommonMetaLoader.java : 데이터를 어디서 어떻게 읽을지를 감춰둔 인터페이스 이다.
  4. DefaultApiCommonMetaLoader.java : 실제 DB에서 row 목록을 읽어 스냅샷 객체로 바꾸는 구현체
  5. ApiCommonMeta.java : 캐시에 들어가는 최상위 스냅샷 객체
  6. ApiEndpointMetadataSnapshot.java : 스냅샷에 들어가는 메타 정보 (캐싱하려는 정보 1)
  7. ApiEndpointRoutingSnapshot.java : 스냅샷에 들어가는 메타 정보 (캐싱하려는 정보 2 )
  8. FreezeUtils.java : Map 를 불변화 하는 유틸리티
  9. CacheReloadLogSupport, CacheReloadLogPublisher : 캐시 init, reload 로그를 kafka 로 발행하는 용도

설명

최초에 캐시를 로드하는 형식은 아래와 같다. 원래는 spring 이 기동되는 시점에 static 객체를 생성하듯 캐시 클래스를 init 시키고자 했지만 사내 프레임워크팀의 관리 방침에 어긋나서 대안으로 최초 거래가 시작되는 시점에 캐시를 생성하고 이후에 재활용하게 된다. 즉, 최초 시작 거래 1개 트랜젝션은 운이 나쁘게도 DB에서 메타정보를 불러와 캐시를 생성하는 시간이 추가되므로 부득이 거래가 느려질 수 있다.

  • 예시로 시작은 아래와 같이 진행된다. 코드들에서 개념을 설명하는 이외 부분들은 생략해두었다.
    public static void main(String[] args) {
	    // dbio 목업 객체
        ApiCaches.DMdcCacheMng dMdcCacheMng = new FakeDMdcCacheMng();
        ApiCaches.Input input = new ApiCaches.Input("BA01");
 
		// **실제 호출 방식**
		// 캐시를 전역적으로 하나만 유지.
        ApiCommonMetaCache cache = ApiCaches.apiEndpointCache(dMdcCacheMng, input);  // 최초 호출이면 초기화
 
		// 사용 예시)
        System.out.println("apiUrl=" + cache.getApiUrlOrNull("BA01")); // 이후 발급된 캐시에서 TTL Refresh 고려한 데이터 호출
        // System.out.println("httpMethod=" + cache.getHttpMethodOrNull("BA01"));
    }
  • ApiCaches 는 전역적으로 1개만 만들고 이를 여러 스레드가 참조할 수 있는 형태로 만든다. apiEndpointCache 가 호출되어 최초로 캐시를 생성하는 경우 아래 로직으로 움직인다.
    1. 이미 만들어진 캐시가 있는 지 확인한다.
    2. 없다면 synchronized 로 락을 잡는다.
    3. 락 안에서 다시 확인해서 없다면 DB Loader 를 통해 데이터를 가져온다.
    4. static reference에 저장한다.
  • 이 과정에서 가장 중요한 부분은 현재 시점의 전체 데이터를 “스냅샷 한 장”처럼 들고 있다가 한번에 교체하는 부분이다. 이렇게 하게 되면 1) 읽는 쪽은 락 없이 빠르게 사용 가능하고, 2) 중간 상태를 볼 일이 없으며 3) 동시성 버기가 줄어들게 되는 장점이 있다.
// main(또는 프레임워크 진입점)에서 캐시를 직접 생성/선언하기 어렵다면 별도의 정적 클래스에서 "부팅 시점"에 캐시를 만들어두고
// 이후에는 어디서든 동일 인스턴스를 꺼내 쓰는 패턴을 쓴다.
public final class ApiCaches {
 
    // 싱글톤 인스턴스는 static 으로 유지하되, 내부 내용물(cache)은 최초 거래 시점에 늦게(lazy) 만든다.
    private static final AtomicReference<ApiCommonMetaCache> API_ENDPOINT_CACHE_REF = new AtomicReference<ApiCommonMetaCache>(); // 실제 캐시 인스턴스는 여기에 들어간다.
    private static final Object API_ENDPOINT_INIT_LOCK = new Object(); // 최초 1회 초기화를 동기화하기 위한 락 객체다.
 
    private ApiCaches() { // 다른 인스턴스 생성을 막는다.
    }
    
    public static ApiCommonMetaCache apiEndpointCache(
	    DMdcCacheMng dMdcCacheMng, 
	    Input input, 
	    CacheReloadLogPublisher reloadLogPublisher
    ) {
        ApiCommonMetaCache existing = API_ENDPOINT_CACHE_REF.get(); // 이미 초기화된 캐시가 있는지 확인한다.
        if (existing != null) { // 이미 있으면
            return existing; // 그대로 반환한다(추가 초기화 없음).
        }
 
        synchronized (API_ENDPOINT_INIT_LOCK) { // 초기화가 필요하면 여러 스레드가 접근하더라도 1회만 수행되도록 동기화한다.
            ApiCommonMetaCache again = API_ENDPOINT_CACHE_REF.get(); // 락 안에서 다시 확인한다(경쟁 상태 방지).
            if (again != null) {
                return again;
            }
            
            ...
 
            ApiCommonMetaLoader loader = new DefaultApiCommonMetaLoader(); // 무상태 기본 로더.
            ApiCommonMetaCache cache = new ApiCommonMetaCache(loader, (reloadLogPublisher == null ? NoopCacheReloadLogPublisher.INSTANCE : reloadLogPublisher));
            cache.reloadOrThrow(dMdcCacheMng, input); // 최초 1회 로딩(스냅샷 생성).
 
            API_ENDPOINT_CACHE_REF.set(cache); // static 싱글톤 내용물(진짜 캐시)을 채워 넣는다.
            return cache; // 초기화된 캐시를 반환한다.
        }
    }
    
    public static ApiCommonMetaCache apiEndpointCache(DMdcCacheMng dMdcCacheMng, Input input) { // 최초 거래 시점에 주입받은 dbio로 캐시를 초기화한다.
        return apiEndpointCache(dMdcCacheMng, input, null); // reload 로그 퍼블리셔가 없으면 null로 둔다
    }
    
	...
}
 
  • 이어서 AtomicReference<ApiCommonMetaCache> 를 살펴보자.
    • reloadOrThrow(…) : 최초 캐시 생성 직후 초기 적재용으로 호출되는 함수
    • tryReloadIfExpired(…) : TTL이 만료됐을 때 조회 경로에서 자동 리로드를 시도하는 함수
/**
 * 읽기(조회)가 매우 잦고 변경(리로드)이 드문 상황에 최적화된 "스냅샷" 캐시다.
 *
 * 핵심 아이디어:
 * - 읽기: AtomicReference로 가리키는 불변 ApiCommonMeta(내부 다수 Map)를 그대로 조회(락 없음, O(1))
 * - 변경: DB에서 전체를 다시 읽어 새 ApiCommonMeta를 만든 뒤, 참조를 한 번에 교체(스왑)
 */
public final class ApiCommonMetaCache { // 상속을 막아 동작을 고정하고 예측 가능하게 만든다.
 
    private static final long TTL_MILLIS = 5L * 60L * 1000L; // TTL을 5분으로 고정한다(스케줄러 없이 "조회 시" 갱신 트리거).
 
    private final ApiCommonMetaLoader loader; // DB 스냅샷을 가져오는 로더(무상태 구현; 호출 시 dbio/input 전달).
    private final AtomicReference<ApiCommonMeta> snapshotRef; // 현재 스냅샷(ApiCommonMeta)을 원자적으로 보관한다.
    private final ReentrantLock reloadLock; // 동시에 여러 스레드가 리로드하지 않도록 막는다.	
	
	...
 
	// 초기화 인 경우
	public void reloadOrThrow(ApiCaches.DMdcCacheMng dMdcCacheMng, ApiCaches.Input input) { // DB에서 스냅샷을 다시 읽어 캐시를 교체한다(실패 시 예외).
        reloadLock.lock(); // 리로드 중에는 다른 리로드를 막기 위해 락을 건다.
        String logMessage = null;
        long startedAtEpochMillis = 0L;
        long startNanos = 0L;
        try { // 락을 해제하기 위해 try/finally를 쓴다.
            startedAtEpochMillis = System.currentTimeMillis();
            startNanos = System.nanoTime();
            
            ApiCommonMeta prev = snapshotRef.get();
            ApiCommonMeta loaded = loader.loadSnapshot(dMdcCacheMng, input); // 호출 시점 파라미터로 전체 스냅샷을 읽는다.
            ApiCommonMeta next = sanitizeAndFreeze(loaded); // 널/중복 등을 정리하고 불변 스냅샷으로 만든다.
            snapshotRef.set(next); // 참조를 한 번에 교체하여 "스냅샷" 업데이트를 끝낸다.
            
            lastSuccessfulReloadEpochMillis = System.currentTimeMillis(); // 성공 시각을 기록한다.
            long durationMillis = (System.nanoTime() - startNanos) / 1000000L;
            ...
        } catch (RuntimeException e) { // 로더가 런타임 예외를 던지면
	        ...   
        } finally { // 어떤 경우든
            reloadLock.unlock(); // 락을 반드시 해제한다.
            CacheReloadLogSupport.publishSafely(reloadLogPublisher, logMessage);
        }
    }
    
    ...
    
    // TTL인 경우
    private void tryReloadIfExpired(ApiCaches.DMdcCacheMng dMdcCacheMng, ApiCaches.Input input) { // TTL 만료 시 "자동"으로 리로드를 트리거한다.
 
        long last = lastSuccessfulReloadEpochMillis; // 마지막 성공 리로드 시각을 읽는다.
        long now = System.currentTimeMillis(); // 현재 시각을 구한다.
 
        // 아직 한 번도 로드하지 않았거나, TTL이 지나지 않았다면 아무 것도 하지 않는다.
        if (last != 0L && (now - last) < TTL_MILLIS) { // TTL이 아직 유효하면
            return; // 리로드할 필요가 없다.
        }
 
        // 동시에 여러 스레드가 DB를 두드리지 않도록, 오직 1개의 스레드만 리로드를 실행하게 한다.
        if (!reloadLock.tryLock()) { // 락을 못 잡으면(다른 스레드가 리로드 중이면)
            return; // 이 스레드는 기존 스냅샷을 그대로 사용한다(대기하지 않음).
        }
        
        ...
 
        try { // 락을 잡았으니, 여기서만 실제 리로드를 시도한다.
 
            // 락 획득 전후로 다른 스레드가 이미 갱신했을 수 있으니 TTL을 한 번 더 확인한다.
            long lastAfterLock = lastSuccessfulReloadEpochMillis; // 락 획득 후의 마지막 성공 리로드 시각을 다시 읽는다.
            long nowAfterLock = System.currentTimeMillis(); // 락 획득 후 현재 시각을 다시 구한다.
            if (lastAfterLock != 0L && (nowAfterLock - lastAfterLock) < TTL_MILLIS) { // 이미 최신이면
                return; // 중복 리로드를 하지 않는다.
            }
 
            // 여기부터는 DB 접근이 발생한다(전체 스냅샷 1회 로드).
            startedAtEpochMillis = System.currentTimeMillis();
            startNanos = System.nanoTime();
            ApiCommonMeta prev = snapshotRef.get();
            ApiCommonMeta loaded = loader.loadSnapshot(dMdcCacheMng, input); // 로더로부터 전체 스냅샷을 읽는다.
            ApiCommonMeta next = sanitizeAndFreeze(loaded); // 널/중복 등을 정리하고 불변 스냅샷으로 만든다.
            nextForSize = next;
            snapshotRef.set(next); // 참조를 한 번에 교체하여 스냅샷을 갱신한다.
            lastSuccessfulReloadEpochMillis = nowAfterLock; // 성공 시각을 기록한다.
 
			...
        } catch (Exception e) {
            ...
        } finally { // 어떤 경우든
            reloadLock.unlock(); // 락을 반드시 해제한다.
            CacheReloadLogSupport.publishSafely(reloadLogPublisher, logMessage);
        }
    }
 
	... 
}
 

정리하며

마지막으로 해당 구조의 장/단점을 정리해보았다. 다음 포스팅에는 조금 더 자세한 코드 개념과 synchronized & ReentrantLock 동기화에 대해 정리해보겠다. 👋

장점

    1. 읽기가 빠르다 스냅샷 읽기만 하면 되니 거의 O(1)
    1. 동시성 구조가 비교적 단순하다 읽는 쪽은 락이 거의 필요없다.
    1. 중간 상태가 없다 새 스냅샷이 완성되기 전까진 기존 스냅샷을 계속 쓰고, 완성되면 한 번에 교체한다.
    1. 캐시 오염 위험이 낮다 불변 객체라서 외부 수정이 어렵다.

단점(trade-off)

    1. 리로드 때 전체를 다시 만든다 부분 갱신이 아니라 통째로 재생성이므로 데이터가 아주 커지면 메모리/CPU 비용이 늘 수 있다.
    1. TTL 지난 첫 요청이 느릴 수 있다 조회 시점 갱신이므로 첫 요청이 리로드 비용을 부담할 수 있다.