티스토리 뷰

개요

  • 마이크로서비스의 유행과 함께 NoSQL 저장소의 사용이 필수인 시대가 되었다. 이제는 전통적인 RDBMS 저장소 만으로는 복잡한 비즈니스 로직과 병렬적인 빠른 동시성 처리 요구를 수용하기 어려워졌다. 한편, NoSQL 중에서도 로컬 캐시 및 분산 캐시는 가장 핫한 주제이다. 캐시를 잘 활용하면 극단적인 동시적 요청 상황에서도 데이터베이스의 부하를 최소화하고 1ms 이하로 빠르게 응답할 수 있다. 현재 분산 캐시는 다양한 오픈 소스 솔루션의 경쟁 구도가 형성되고 있는데 이번 글에서는 오랜 기간 검증된 분산 캐시 솔루션인 JBoss Infinispan를 애플리케이션의 라이브러리 형태인 Embedded 모드로 사용하는 방법을 소개하고자 한다.

배경지식

  • Java 플랫폼에서 MapKey-Value로 데이터를 저장할 수 있는 인터페이스이다. 그리고 이를 구현한 HashMap 클래스를 가장 많이 사용하는데 문제는 단일 쓰레드 환경에서는 상관이 없지만 멀티 쓰레드 환경에서는 동기화가 보장되지 않는 문제가 발생한다. Hashtable 클래스가 동기화 문제를 해결했지만 성능 문제가 존재했고 동기화와 성능 이슈를 같이 해결한 것이 바로 ConcurrentMap 인터페이스이다. Infinispan이 제공하는 Cache 인터페이스 또한 ConcurrentMap 인터페이스를 확장한 형태이다. 애플리케이션에서의 다중 요청에 대한 공통의 캐시 역할을 하기에 안성맞춤이다.
  • 그렇다면 왜 써드 파티 캐시가 필요할까? ConcurrentMap이 제공하지 않는 유용한 기능들 때문이다. 첫째는 데이터의 만료 시기를 정할 수 있다. 캐시에 담긴 데이터가 특정 시간이 지나면 자동으로 삭제되는 것이다. 영원하지 않은 캐시라는 임시적 성격에 걸맞는 기능이다. 두번째는 캐시가 지정된 크기를 초과할 경우 미리 설정된 만료 정책에 따라 불필요하고 우선순위가 낮은 데이터를 자동으로 삭제할 수 있다. 자연스럽게 캐시는 가장 빈번히 읽히는 데이터에 집중할 수 있게 된다.
  • 여기까지 생각했다면 가볍고 성능 좋은 캐시 라이브러리들이 많다. Google Guava Cache, cache2k가 추천할만하다. 문제는 이러한 캐시들의 생명주기가 로컬 캐시로서의 용도로 국한된다는 점이다. 현대적인 마이크로서비스 클러스터 환경에서는 애플리케이션 노드 또한 n개로 수평 확장되기 때문에 각 노드가 공통으로 공유하는 분산 캐시가 필요하게 되었다. 플랫폼을 잠시 벗어나서 이야기하면 요즘 이런 요구사항을 가장 잘 수용할 수 있는 저장소가 바로 세계에서 가장 빠른 저장소인 Redis이다. 하지만 Java 생태계에서는 JBoss Infinispan, Hazelcast, Apache Ignite 등 이미 뛰어난 분산 캐시 솔루션들이 뛰어난 성능과 다양한 기능을 무기로 경쟁하고 있다. 그 중에서도 JBoss Infinispan은 2009년부터 시작된 전통 있고 안정된 분산 캐시 솔루션으로 이미 Red HatJBoss 제품군에 널리 쓰이고 있다. (대표적으로 세션 클러스터링에 쓰인다.)
  • 내 경우 프로젝트에서 Redis 저장소를 RDBMS 저장소를 보조하는 분산 캐시 용도로 사용하다가 순간적인 집중 부하로 인한 I/O 병목을 경험하고 cache2k 로컬 캐시를 적용하여 하드웨어 업그레이드 없이 부하량을 2/3로 감소시킬 수 있었다. 그 뒤 계속 욕심을 내보니 고민에 봉착했다. 어떤 오브젝트를 로컬 캐시에 담았을 때 A라는 노드에서 변경점이 발생한 오브젝트가 B, C라는 노드에서 인지하지 못하면 나머지 노드에서는 계속 잘못된 과거의 데이터가 응답되는 문제이다. 이 고민을 해결해주는 것이 바로 Near Cache 개념이었고 이 것을 보장하는 JBoss Infinispan을 프로젝트에 적용하게 되었다. Near Cache의 쓰임새 때문인지 Redis 기반의 Redisson 같은 솔루션은 Near Cache 기능을 따로 유료 라이센스에 넣어 판매하기도 한다.

Infinispan의 아키텍쳐 및 동작 원리

  • JBoss InfinispanEmbeddedServer-Client 2개 모드로 작동 방법이 구분된다. Embedded 모드는 각 노드마다 애플리케이션의 라이브러리 형태로 작동하여 동일한 JVM 안에서 애플리케이션과 전체 생명주기를 같이 한다. 같은 JVM 영역의 메모리에 데이터를 저장하므로 Redis같은 외부 저장소를 캐시로 이용할 때보다 당연히 읽기, 쓰기 속도가 굉장히 빠르다는 장점을 가진다.
  • Embedded 모드는 각 노드의 JVM의 통제를 받지만 클러스터 설정을 통해 여러 개의 노드가 하나의 클러스터로 기능할 수 있다. 각 노드는 JGroupsUDP 프로토콜을 통해 멀티캐스트 메시지를 전파하여 지속적으로 서로를 식별하고 동기화를 수행한다. (특정 노드가 마스터가 되는 것이 아니라 각 노드가 독립적으로 서로를 발견하고 통신하기에 P2P 방식이라고도 부른다.) 클러스터의 조건은 각 노드가 같은 LAN 안에 존재해야 하며 같은 클러스터 이름을 공유해야 한다.
  • Server-Client 모드는 Redis 저장소와 같이 Infinispan을 아예 독립된 애플리케이션으로 실행하는 형태이다. Java가 아닌 다른 플랫폼에서도 원격으로 접속이 가능하며 본래 애플리케이션의 배포 행위에 영향을 받지 않는다는 장점이 있다. Hot Rod라 불리는 전용 바이너리 프로토콜로 통신할 수 있으며 전용 클라이언트 라이브러리를 다양한 언어 별로 제공하고 있다.
  • InfinispanSpring Cache를 지원하기 때문에 Spring Boot와 결합하여 애플리케이션 분산 캐시로서 우아하게 작동할 수 있다.

JGroups를 통한 클러스터 노드 간의 통신

  • 앞서 언급했듯이 JBoss InfinispanJGroups 메시징 라이브러리를 사용하여 클러스터 노드 간의 상호 발견과 전송을 수행하며 상세 설정이 가능하다. 따라서 프로토콜 선택이 미치는 영향에 대한 간략한 이해가 필요하다.
  • 가장 성능과 확장성이 뛰어난 것은 노드 발견에 UDP 멀티캐스트, 노드 간 전송에 UDP를 사용하는 것이다. 100개 노드 이상의 분산 모드(Distribution Mode) 또는 10개 노드 이하의 복제 모드(Replicated Mode)에 적합하다.
  • 다음은 노드 발견에 UDP 멀티캐스트, 노드 간 전송에 TCP를 사용하는 것이다. 100개 노드 이하의 분산 모드(Distribution Mode)에 적합하다. 제작사에서는 특별히 네트워크 환경에서 UDP를 차단할 경우에만 TCP를 사용하라고 권장하고 있다.
  • 한편, 각 클러스터 노드는 46655 고정 포트로 UDP 멀티캐스트를 전송하여 서로를 발견한다. 이후 서로의 특정 포트(애플리케이션 시작시 랜덤하게 결정)를 향해 UDP 유니캐스트를 전송한다. 결국 방화벽 설정은 각 요청 주소가 애플리케이션 노드일 경우에 한하여 UDP 포트를 모두 개방하도록 설정해야 한다. CentOS 환경에서는 아래와 같이 방화벽 포트를 개방하면 된다.
### 같은 클러스터 노드로 오고 가는 UDP 포트를 개방
$ iptables -I INPUT -p udp -s XXX.XXX.XXX.XXX -j ACCEPT $ iptables -I OUTPUT -p udp -d XXX.XXX.XXX.XXX -j ACCEPT

### 방화벽 재시작
$ service iptables restart 
  • AWS 클라우드 환경에서는 노드 간의 통신은 TCP, 각 노드의 디스커버리는 NATIVE S3 PING만 가능하므로, 보안 그룹에서 내부 VPCTCP 7800, 7801 2개 포트만 개방하면 된다.

캐시 모드의 종류

  • Simple 캐시 모드는 가장 간단한 로컬 캐시이다. 전체 모드를 통틀어 읽기 부하, 쓰기 부하 모두 가장 빠르다. 만약 이 모드 만으로 만족한다면 더욱 가볍고 빠른 cache2k 라이브러리를 쓸 것을 추천한다.
  • Local 캐시 모드는 Simple 모드에 트랜잭션, 인덱싱, 영구 저장 기능을 추가한 버전이다. 클러스터를 제외한 Infinispan의 모든 기능을 사용할 수 있는 모드이다.
  • Invalidation 캐시 모드부터 클러스터가 적용된다. 기본적으로 각 노드는 로컬 캐시처럼 작동한다. 단, 한가지가 다른 것은 하나의 노드에서 특정 키에 대한 remove(), put() 메써드가 실행되면 다른 클러스터 노드에 해당 키를 삭제하는 메시지를 전파한다.(evict() 메써드는 현재 노드의 키만 삭제할 뿐 다른 노드에 전파하지 않는다.) 엄격한 일관성이 요구되고 빈번한 읽기 부하와 드문 쓰기 부하에 적합한 방식이다. 보다 일반적인 의미로는 Near Cache라고도 부른다.
  • Replicated 캐시 모드는 각 노드에서 발생하는 모든 이벤트를 다른 모든 노드로 동일하게 전파한다. 즉, 모든 노드는 정확히 동일한 복제된 캐시를 가지게 된다. 노드 개수가 늘어날수록 쓰기 부하가 증가하여 느려진다. 대신, 특정 노드가 셧다운되도 다른 노드가 캐시를 완전히 보존하는 효과가 있다.
  • Distributed 캐시 모드는 Replicated의 단점을 보완하여 모든 노드로 복제하는 것이 아니라 자체적인 해쉬 알고리즘에 의해 1개 노드에만 원본 데이터를 저장하고 정해진 설정 만큼 일부 노드에 복제(백업) 데이터를 저장한다. 상대적으로 쓰기 부하가 줄어들어 스케일 아웃에 강점을 가지는 반면 모든 노드에 데이터를 가지지 않기 때문에 최대 1번의 네트워크 통신이 필요할 수 있어 읽기는 느려질 수 있다.(L1 Cache 옵션을 활성화하면 다른 노드에서 조회된 데이터를 짧은 시간 로컬에 보관하여 네트워크 통신을 줄일 수 있다. 이 경우 Near Cache로 작동하게 된다.) 클러스터의 크기가 아주 클 경우 유용하다.

퇴거 정책

  • 캐시가 고성능일 수 있는 이유는 비싸고 한정적인 시스템 메모리에 데이터를 저장하기 때문이다. 캐시에 디스크처럼 무한정으로 데이터를 저장할 수는 없다. 특히 JBoss Infinispan은 애플리케이션이 기동되는 JVM의 힙 영역을 공유하므로 애플리케이션에 java.lang.OutOfMemoryError 장애를 유발할수도 있다. 따라서 캐시의 최대 용량에 제한이 필요하다.
  • 문제는 캐시가 지정된 최대 용량에 도달했을 경우이다. 캐시가 최대 용량에 도달하면 기존 저장된 데이터 중에 어떤 데이터를 삭제하여 용량을 확보할지 결정해야 한다. 이러한 행위를 퇴거(Eviction)라고 하며 행위를 결정하는 방법을 퇴거 정책(Eviction Policy)이라고 부른다. 사용 빈도가 높은 데이터를 삭제하면 캐시 입장에서는 손해다. JBoos Infinispan은 재사용 가능성을 효과적으로 예측할 수 있는 TinyLFU 퇴거 정책을 기본 정책으로 사용한다.
  • 퇴거는 각 노드의 인메모리 영역에서만 발생한다. 만약 해당 캐시 오브젝트에 RocksDB와 같은 온디스크 기반의 Cache Store를 지정했다면 퇴거된 데이터가 캐시 스토어에 영구적으로 보관되므로(TTL을 지정하지 않았다면) 재사용이 가능하다.

캐시 데이터의 영구 저장

  • JBoss Infinispan은 기본적으로 고속의 인메모리 KVS(Key-Value Store)이기 때문에 애플리케이션이 종료되거나 인스턴스가 셧다운되면 모든 캐시 데이터가 증발한다. 하지만 별도로 Cache Store라고 불리우는 영구적인 캐시 저장소를 설정하는 것도 가능하다. 개별 캐시 단위로 서로 다른 저장소 설정이 가능하다. 기본으로 제공되는 저장소는 아래와 같다.
1. Single File Cache Store
2. JDBC Cache Store
3. Soft-Index Cache Store
4. JPA Cache Store
5. RocksDB Cache Store
6. Remote Cache Store
7. REST Cache Store
  • 외부 저장소와의 연동은 캐시 성능에 심각한 성능 저하를 일으키므로 제작사는 기본적으로는 외부 저장소를 사용하지 않을 것을, 꼭 필요할 경우 비동기 옵션을 활성화할 것을 권장하고 있다. JBoss InfinispanIMDG(In-Memory Data Grid) 솔루션으로 분류하기도 하지만 기본적으로 캐시 솔루션에 가깝기 때문에 데이터의 백업이나 영구 저장 문제는 상대적으로 다른 NoSQL 저장소에 비해 부족한 측면이 있다.

build.gradle.kts 작성

  • Spring Boot 프로젝트에 Infinispan을 적용해볼 차례이다. https://start.spring.io/에 방문하여 바로 Gradle, Java 기반의 Spring Boot 2.6.x 프로젝트를 생성한 상태를 가정하고 진행한다. InfinispanEmbedded 모드로 사용하려면 프로젝트 루트의 build.gradle에 아래 내용을 추가한다.
ext {
    infinispanVersion = '13.0.6.Final'
}

dependencies {
    implementation("org.infinispan:infinispan-core:$infinispanVersion")
    implementation("org.infinispan:infinispan-commons:$infinispanVersion")
    implementation("org.infinispan:infinispan-spring5-embedded:$infinispanVersion")
    implementation("org.infinispan:infinispan-jcache:$infinispanVersion")
    implementation("org.infinispan:native-s3-ping:1.0.0.Final")
}
  • spring-boot-starter-cacheinfinispan-spring5-embedded 아티팩트는 Spring Cache를 가능하게 해준다. 다음으로 원하는 버전의 infinispan-core, infinispan-commons 아티팩트를 추가한다.

application.yml 작성

  • 아래는 프로젝트에 Hibernate L2 캐시를 활성화하는 예이다. (Hibernate L2 캐시를 사용하지 않을 경우 아래 챕터로 건너뛰어도 된다.)
spring:
  jpa:
    properties:
      # Hibernate L2 캐시를 활성화
      hibernate.cache.use_second_level_cache: true
      # Hibernate L2 캐시 매니저로 Infinispan을 지정
      hibernate.cache.region.factory_class: org.infinispan.hibernate.cache.v53.InfinispanRegionFactory
      hibernate.cache.default_cache_concurrency_strategy: nonstrict-read-write
      hibernate.cache.infinisapn.statistics: false
      # Hibernate L2 캐시 매니저가 정의된 환경 설정 파일 지정
      hibernate.cache.infinispan.cfg: config/infinispan/infinispan-configs-prod.xml
      # Hibernate 쿼리 캐시를 활성화
      hibernate.cache.use_query_cache: false
      hibernate.generate_statistics: false

@Configuration 클래스 작성

import org.infinispan.commons.marshall.JavaSerializationMarshaller
import org.infinispan.configuration.global.GlobalConfigurationBuilder
import org.infinispan.configuration.parsing.ConfigurationBuilderHolder
import org.infinispan.manager.DefaultCacheManager
import org.infinispan.manager.EmbeddedCacheManager
import org.infinispan.spring.embedded.provider.SpringEmbeddedCacheManager
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.cache.annotation.EnableCaching
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
@EnableCaching
class InfinispanCacheConfig {

    // [필수] 캐시 매니저 빈 생성
    @Bean("infinispanCacheManager")
    fun infinispanCacheManager(): EmbeddedCacheManager {

        val global = GlobalConfigurationBuilder()
            .transport().defaultTransport()
            // 사전 정의된 JGroups 프리셋 사용
            .addProperty("configurationFile", "default-configs/default-jgroups-udp.xml")
            // 클러스터의 이름을 설정
            .clusterName("some-cache-cluster")
            // 기본 캐시의 이름을 설정
            .defaultCacheName("default-cache")

        // 마샬러를 설정
        global.serialization()
            .marshaller(JavaSerializationMarshaller())
            // 캐시 대상 클래스를 모두 명시
            .allowList().addClasses<Any>(
                ArrayList::class.java,
                LinkedHashMap::class.java,
                HashMap::class.java,
                LinkedHashSet::class.java,
                HashSet::class.java,
                FooDTO::class.java,
                BarDTO::class.java
            )

        return DefaultCacheManager(ConfigurationBuilderHolder(Thread.currentThread().contextClassLoader, global), true)
    }

    // [옵션] Spring Cache 활성화
    @Bean
    fun springEmbeddedCacheManager(@Qualifier("infinispanCacheManager") cacheManager: EmbeddedCacheManager?): SpringEmbeddedCacheManager {

        return SpringEmbeddedCacheManager(cacheManager)
    }
}
  • EmbeddedCacheManager 오브젝트는 애플리케이션에서 유일한 캐시 매니저 오브젝트로 Infinispan의 총 지휘자 역할을 수행한다. 상당히 무거운 오브젝트이기 때문에 애플리케이션의 기동 단계에 스프링의 싱글턴 빈으로 초기화하였다.
  • 캐시 매니저 생성 시점에 클러스터의 이름, 기본 캐시의 이름, 기본 캐시의 상세 설정을 할 수 있다. 기본 캐시를 설정해두면 후에 cacheManager.getCache()로 바로 기본 캐시를 획득하여 사용할 수 있다.
  • 기본 캐시와 별개로 목적에 맞게 커스텀 캐시를 아래와 같이 생성할 수 있다.
import org.infinispan.Cache
import org.infinispan.configuration.cache.CacheMode
import org.infinispan.configuration.cache.ConfigurationBuilder
import org.infinispan.manager.EmbeddedCacheManager
import org.infinispan.transaction.LockingMode
import org.infinispan.transaction.TransactionMode
import org.infinispan.util.concurrent.IsolationLevel
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.util.concurrent.TimeUnit

@Configuration
class InfinispanCustomCacheConfig {

    // Foo 클래스를 저장할 수 있는 Local 캐시 빈 생성
    @Bean("fooLocalCache")
    fun fooLocalCache(@Qualifier("infinispanCacheManager") cacheManager: EmbeddedCacheManager): Cache<String, Foo> {

        val cacheName = "foo-cache"
        val config = ConfigurationBuilder()
        config.apply {
            this.expiration().lifespan(60, TimeUnit.MINUTES)
            this.memory().maxCount(2048L)
            // 캐시 모드 지정
            // LOCAL, REPL_ASYNC, INVALIDATION_ASYNC, DIST_ASYNC
            this.clustering().cacheMode(CacheMode.LOCAL)
            this.locking().isolationLevel(IsolationLevel.READ_COMMITTED).useLockStriping(false)
                .lockAcquisitionTimeout(10, TimeUnit.SECONDS)
            this.transaction().lockingMode(LockingMode.OPTIMISTIC).transactionMode(TransactionMode.NON_TRANSACTIONAL)
            this.encoding().key().mediaType("application/x-protostream")
        }
        cacheManager.defineConfiguration(cacheName, config.build())

        return cacheManager.getCache(cacheName)
    }
}
  • ConfigurationBuilderexpiration().lifespan()를 통해 캐시의 만료 시간을 설정할 수 있다. 위 예제의 경우 2시간으로 설정했다.
  • ConfigurationBuilderclustering().cacheMode()를 통해 각 노드 간의 클러스터 방식을 설정할 수 있다.

인덱스 생성을 통한 필드 단위 조회

  • JBoss Infinispan은 기본적으로 KVS(Key-Value Store) 구조로 POJO 오브젝트를 저장하고 조회할 수 있는데 추가적으로 Hibernate Search(내부적으로는 Apache Lucene) 기반의 인덱스 생성을 활성화하면 단순 키 조회 이상의 오브젝트 필드 단위의 상세 조회가 가능해진다. 전제 조건은 개별 캐시 생성시 인덱스 활성화 옵션이 명시되어야 하며 인덱싱 대상 오브젝트에는 클래스 레벨에 @Indexed, 필드 레벨에서는 @Field가 명시되어야 한다. 한편, 인덱스 사용은 양날의 검으로 클러스터의 쓰기 성능이 저하될 수 있음에 유의해야 한다.
  • JBoss Infinispan의 인덱스는 로컬과 클러스터 방식 2가지를 지원한다. 로컬 방식의 인덱스의 장점은 노드 간의 동기화 작업이 필요 없어 연산 속도가 빠르다는 것이다. 캐시를 복제 모드로 사용할 경우 모든 노드의 캐시가 같은 데이터를 가지므로 자연스럽게 로컬 방식의 인덱스를 사용할 수 있다. 가장 성능이 뛰어나 기본적으로 추천하는 방식이다. 아래는 로컬 방식의 인덱스를 생성하는 예이다.
@Bean("indexedCache") public Cache<String, Something> indexedCache(@Qualifier("cacheManager") EmbeddedCacheManager cacheManager) {      ConfigurationBuilder config = new ConfigurationBuilder();     config.memory().evictionType(EvictionType.COUNT).size(65536);     config.clustering().cacheMode(CacheMode.REPL_SYNC);     config.indexing().index(Index.ALL)              // 인덱스 반영을 비동기로 진행하여 성능 향상, 하지만 조회 결과에 즉각 반영되지 않음, 동기를 원할 경우 directory-based를 명시              .addProperty("default.indexmanager", "near-real-time");              // 인덱스 정보를 JVM의 힙 영역에 저장하여 성능 향상, 단점은 노드 셧다운시 인덱스 정보가 증발, 생략할 경우 파일 시스템에 저장              .addProperty("default.directory_provider", "local-heap");      cacheManager.defineConfiguration("indexed-cache", config.build());     Cache<String, Something> indexedtCache = cacheManager.getCache("indexed-cache");      return indexedCache; } 
  • 한편, 클러스터 방식의 인덱스의 장점은 모든 노드가 같은 인덱스 정보를 공유하여 한 노드가 셧다운 후 재기동해도 일관성을 유지할 수 있다는 것이다. 클러스터 방식의 인덱스 정보는 내부적으로 3개의 캐시에 저장된다. Data Cache, Metadata Cache, Locking Cache인데 이 3개의 캐시 모드가 분산 캐시로 작동함으로서 자연스럽게 인덱스를 클러스터 방식으로 구성할 수 있다. 또한, 인덱스 정보의 양이 임계치에 도달해도 Cache Store를 지정할 경우 영구적으로 저장할 수 있다. 아래는 클러스터 방식의 인덱스를 생성하는 예이다.
@Bean("indexedCache") public Cache<String, Something> indexedCache(@Qualifier("cacheManager") EmbeddedCacheManager cacheManager) {      ConfigurationBuilder config = new ConfigurationBuilder();     config.expiration().lifespan(2L, TimeUnit.HOURS);     config.memory().evictionType(EvictionType.COUNT).size(65536);     config.clustering().cacheMode(CacheMode.DIST_SYNC).sync()             .l1().lifespan(2L, TimeUnit.HOURS)              .hash().numOwners(2);     config.indexing().index(Index.PRIMARY_OWNER)              .addProperty("default.directory_provider", "infinispan")              .addProperty("default.exclusive_index_use", "true")              .addProperty("default.indexmanager", InfinispanIndexManager.class.getName())              .addProperty("default.reader.strategy", "shared");      cacheManager.defineConfiguration("indexed-cache", config.build());     Cache<String, Something> indexedtCache = cacheManager.getCache("indexed-cache");      return indexedCache; } 
  • 인덱스 설정이 완료되었으면 인덱스의 대상 클래스에 어노테이션 식별자를 명시해야 한다. 클래스 레벨에 @Indexded를 필드 레벨에 @Field(store = Store.YES)를 명시하면 기본적인 작업은 완료된다.

캐시 매니저를 통한 클러스터 상태 확인

  • 클러스터를 사용하는 캐시 오브젝트의 경우 네트워크 환경에 의존적이므로 주기적인 상태 확인이 필수적이다. 1분 마다 캐시 매니저를 통해 클러스터의 환경을 로그에 남기면 추후 문제 분석이 수월해진다.
cacheManager.getClusterName(); 
// some-cache-cluster

cacheManager.getStatus();
// RUNNING

cacheManager.getAddress();
// node-1

cacheManager.getMembers();
// [node-1, node-2, node-3]

cacheManager.getHealth().getClusterHealth().getNumberOfNodes()
// 3 

캐시 오브젝트 획득

  • 캐시 오브젝트의 획득은 앞서의 캐시 매니저 오브젝트의 생성 만큼 무거운 작업이다. 왜냐하면 Simple, Local 모드와 같은 로컬 캐시에서는 코드가 실행된 노드에서만 캐시를 생성하거나 이미 존재하는 캐시를 반환하지만 Replicated, Distributed 모드는 같은 클러스터 내의 다른 노드에도 동일한 캐시를 생성하고 상황에 따라 캐시 데이터의 동기화까지 수행하기 때문이다. 따라서 애플리케이션이 기동하는 시점에 해당 작업을 미리 해주는 것이 좋다. (Spring Boot 환경에서는 획득한 캐시를 미리 Bean으로 등록해두면 된다.)
// 기본 캐시 오브젝트의 획득 Cache<String, String> someCache = cacheManager.getCache();  // 특정 캐시 오브젝트의 획득 Cache<String, Something> someCache = cacheManager.getCache("some-cache"); 

캐시 오브젝트의 사용

  • 획득한 캐시 오브젝트의 사용법은 아래와 같이 단순하다.
// 캐시 데이터 삽입 
someCache.put("some-key", "some-value");  

// 캐시 데이터 삽입, Invalidation 모드에서 캐시 무효 이벤트가 다른 노드로 전파되지 않음 
someCache.putForExternalRead("some-key", "some-value");  

// 캐시 데이터 존재 여부 반환 
boolean isExist = someCache.containsKey("some-key");  

// 캐시 데이터 삭제, 모든 노드로 전파되지 않음 
someCache.evict("some-key");  

// 캐시 데이터 삭제, 모든 노드로 전파 
someCache.remove("some-key");

POJO 클래스 작성

  • 캐시에 저장할 타입이 기본형이 아닌 커스텀 타입일 경우 아래와 같이 POJO 클래스를 작성해야 한다.
@Data
@AllArgsConstructor
public class Something implements Serializable {      

    private String key;
    private String value; 
} 
  • 예제에 사용할 목적이라 아주 간단한 POJO 오브젝트를 작성했다. Lombok의 힘을 빌어 @Data, @AllArgsConstructor 2개의 클래스 레벨 어노테이션 만으로 작성을 끝냈다.
  • 로컬 캐시로서만 사용할 때는 상관이 없지만 클러스터를 통한 분산 캐시로서 사용할 때는 네트워크를 통한 마샬링이 필수적이기 때문에 반드시 Serializable 인터페이스를 명시해주어야 한다. InfinispanSerializable의 대안으로 많은 써드파티 마샬링 기법을 제공하고 있지만 본 예제에서는 따로 소개하지 않는다.

Spring Cache 적용

  • 이제 Spring BootSpring Cache의 힘을 빌어 직접적인 캐시 오브젝트 코드의 제어 없이 우아하게 비즈니스 로직에 적용해볼 차례이다.
@Repository
@CacheConfig(cacheNames = "some-cache") public class SomeRepository {      @Cacheable(key = "#key")     public Something getSomething(String key) {          // 실제 영구적 저장소에 대한 오브젝트 로드 로직 작성          return something;     }      @CachePut(key = "#something.key")     public Something createSomething(Something something) {          // 실제 영구적 저장소에 대한 오브젝트 저장 로직 작성          return something;     } } 
  • 클래스 레벨에 @CacheConfig를 명시하여 모든 메써드가 공통으로 사용할 전역 캐시 설정을 지정할 수 있다. 위 예제의 경우 전역 CRUD를 수행할 some-cache라는 캐시 인스턴스를 지정했다. 실제 메써드 레벨의 @Cacheable에 개별적으로 따로 사용할 캐시를 다르게 지정할 수 있다.
  • 메써드 레벨에 @Cacheable를 명시하여 각 메써드가 캐시를 저장하고 불러올 수 있는 키 정보를 지정했다. 이를 통해 AOP의 기법으로 우아하게 캐시가 작동할 수 있다.
  • Spring CacheSpring AOP를 통한 간결하고 직관적인 캐시 제어를 가능하게 해주지만 무조건 답은 아니다. 경우에 따라 직접적인 코드 제어가 더 나은 경우도 있다.

@SpringBootApplication 클래스 작성

  • 마지막으로 애플리케이션 클래스를 작성할 차례이다.
@SpringBootApplication @EnableCaching public class InfinispanEmbeddedDemoApplication {      public static void main(String[] args) {          ApplicationContext applicationContext = SpringApplication.run(InfinispanEmbeddedDemoApplication.class, args);     } } 
  • @EnableCaching를 명시하면 앞서 작성한 어노테이션 기반의 캐시가 작동하게 된다.

작동 로그 확인

  • 아래는 최초 애플리케이션 시작시 Infinispan을 초기화하는 상황의 로그이다.
2018-09-01 14:13:22.374  INFO 12244 --- [           main] o.i.r.t.jgroups.JGroupsTransport         : ISPN000078: Starting JGroups channel some-cluster 2018-09-01 14:13:27.801  INFO 12244 --- [           main] org.infinispan.CLUSTER                   : ISPN000094: Received new cluster view for channel some-cluster: [DESKTOP-6LS8SV6-41316|0] (1) [DESKTOP-6LS8SV6-41316] 2018-09-01 14:13:27.804  INFO 12244 --- [           main] o.i.r.t.jgroups.JGroupsTransport         : ISPN000079: Channel some-cluster local address is DESKTOP-6LS8SV6-57510, physical addresses are [fa20:0:0:0:b88a:a11d:75d6:80a9%4:62120] 2018-09-01 14:13:27.806  INFO 12244 --- [           main] o.i.factories.GlobalComponentRegistry    : ISPN000128: Infinispan version: Infinispan 'Bastille' 9.1.7.Final 
  • 아래는 새로운 노드를 발견하고 자신의 클러스터 구성 정보에 추가하는 상황의 로그이다.
2018-09-01 14:07:43.880  INFO 6012 --- [P-6LS8SV6-41316] org.infinispan.CLUSTER                   : ISPN000094: Received new cluster view for channel some-cluster: [DESKTOP-6LS8SV6-41316|1] (2) [DESKTOP-6LS8SV6-41316, DESKTOP-6LS8SV6-56189] 2018-09-01 14:07:43.884  INFO 6012 --- [P-6LS8SV6-41316] org.infinispan.CLUSTER                   : ISPN100000: Node DESKTOP-6LS8SV6-56189 joined the cluster 
  • 아래는 기존 작동하던 노드가 제거됨을 발견하고 자신의 클러스터 구성 정보를 재설정하는 상황의 로그이다.
2018-09-01 14:11:08.209  INFO 4992 --- [P-6LS8SV6-56189] org.infinispan.CLUSTER                   : ISPN000094: Received new cluster view for channel some-cluster: [DESKTOP-6LS8SV6-56189|2] (1) [DESKTOP-6LS8SV6-56189] 2018-09-01 14:11:08.212  INFO 4992 --- [P-6LS8SV6-56189] org.infinispan.CLUSTER                   : ISPN100001: Node DESKTOP-6LS8SV6-41316 left the cluster 

분산 캐시 이벤트 리스너 구현

  • 분산 캐시의 생성, 변경, 무효화와 같은 생명주기에 걸쳐 발생하는 이벤트에 대한 리스너를 생성할 수 있다. 이를 통해 클러스터 내의 특정 노드에서 발생한 이벤트를 나머지 모든 노드에서도 인지할 수 있다. 먼저, 아래와 같이 @Listener 클래스를 제작한다.
@Listener(clustered = true) class CacheListener {      companion object : KLogging()      // 분산 캐시 생성 이벤트 리스너     @CacheEntryCreated     fun cacheEntryCreated(event: CacheEntryCreatedEvent<String, Any?>) {          logger.info("분산 캐시 생성 이벤트 수신 완료 [evnet=$event]")         ...     } } 
  • 다음으로, 앞서 작성한 @Configuration 클래스의 작성 부분에 아래 내용을 추가하면 이벤트 리스너가 활성화된다.
val cache: Cache<String, Any> = cacheManager.getCache("some-cache") cache.addListener(CacheListener()) ... 

분산 카운터 구현

  • 만약 모든 노드가 숫자를 증가시키고 감소시킬 수 있는, 원자성을 보장하는 시퀀스가 필요하다면? 분산 카운터를 구현하면 된다. JBoss infinispan은 모듈 추가를 통해 분산 카운터 기능을 제공한다. 프로젝트 루트의 /build.gradle에 아래 내용을 추가한다.
dependencies {     compile group: 'org.infinispan', name: 'infinispan-clustered-counter', version: '9.4.4.Final' } 
  • 앞서 @Configuration 클래스의 작성 부분에 아래 내용을 추가한다.
global.addModule(CounterManagerConfigurationBuilder::class.java).apply {     
    numOwner(2).reliability(Reliability.AVAILABLE)     
    .addStrongCounter()         
    .name("some-counter")         
    .initialValue(0).lowerBound(0).upperBound(999)        
    .storage(Storage.VOLATILE)
}
  • initialValue()를 통해 분산 카운터의 초기값을 지정할 수 있다. 또한, lowerBound()upperBound()를 통해 분산 카운터의 최소값과 최대값을 지정할 수 있다. 분산 카운터의 증가, 감소 시점에 지정된 최소값, 최대값에 도달하면 CounterOutOfBoundsException 예외가 발생한다.
  • storage()를 통해 분산 카운터 값의 저장 방식을 지정할 수 있다. Storage.VOLATILE로 지정하면 모든 노드가 클러스터에서 이탈할 경우 분산 카운터 값이 보존되지 않고 증발한다. 단, 1대 노드라도 생존한다면 분산 카운터 값은 계속 유지되어 보존된다.

JGroups 트러블슈팅

  • JBoss InfinispanJGroups에 의존하여 클러스터 노드 간의 발견과 통신을 수행한다. 그리고 이 구간에서 가장 많은 예상치 못한 문제가 발생한다. (특히 프로덕션 환경)
  • 운영체제의 방화벽 설정이 정상적으로 완료되었다면 아래와 같이 tcpdump 명령으로 실제 노드 간에 오고 가는 UDP 패킷을 확인할 수 있다.
$ tcpdump udp
  • 가장 확실한 통신 구간 테스트는 JGroups가 제공하는 통신 테스트를 실행하는 것이다. 아래와 같이 테스트 소스 코드를 다운로드하여 실행 후 송신측에서 키보드를 타이핑하면 수신측으로 UDP 멀티캐스트가 정상적으로 전파되는지 여부를 확인할 수 있다.
### 송신측
$ wget http://central.maven.org/maven2/org/jgroups/jgroups/4.0.13.Final/jgroups-4.0.13.Final.jar
$ java -cp jgroups-4.0.13.Final.jar org.jgroups.tests.McastSenderTest -mcast_addr 228.6.7.8 -port 46655
> 1 > 2 > 3

### 수신측
$ wget http://central.maven.org/maven2/org/jgroups/jgroups/4.0.13.Final/jgroups-4.0.13.Final.jar
$ java -cp jgroups-4.0.13.Final.jar org.jgroups.tests.McastReceiverTest -mcast_addr 228.6.7.8 -port 46655
1 2 3 
  • 한편, 애플리케이션 실행시 JVM 옵션으로 아래 파라메터를 전달하면 설정을 변경해가며 테스트가 가능하다.
### JGroups의 기본 Logger를 SLF4J로 전환, Spring Boot에서 제어가 가능해짐 (ex: TRACE로 상세 로그 확인 가능)
-Djgroups.log_class=org.jgroups.logging.Slf4jLogImpl

### JVM은 기본적으로 IPv6로 작동하는데 이를 IPv4로 전환
-Djava.net.preferIPv4Stack=true  

### UDP 멀티캐스트 목적지 주소
-Djgroups.udp.mcast_addr=228.6.7.8

### UDP 멀티캐스트 목적지 포트
-Djgroups.udp.mcast_port=46655 

TimeoutException: ISPN000299 트러블슈팅

  • 분산 캐시 환경에서 하나의 캐시 오브젝트에 대한 쓰기(또는 만료) 요청이 각 노드에서 동시에 극단적으로 증가할 경우 org.infinispan.util.concurrent.TimeoutException: ISPN000299 예외가 발생할 확률이 높다. 기본 트랜잭션 정책 때문인데 개별 쓰기 요청이 서로 충돌하면서 데드락 상황에 빠지는 것이 원인이다. 이 경우 기본 트랜잭션 및 잠금 수준을 완화하여 해결할 수 있다. 아래와 같이 설정할 수 있다.
ConfigurationBuilder config = new ConfigurationBuilder();
config.transaction().lockingMode(LockingMode.OPTIMISTIC).transactionMode(TransactionMode.NON_TRANSACTIONAL); // 기본값은 LockingMode.PESSIMISTIC, TransactionMode.TRANSACTIONAL 
config.locking().isolationLevel(IsolationLevel.READ_COMMITTED); // 기본값은 IsolationLevel.REPEATABLE_READ
  • 위 조치로도 동일한 예외가 계속 발생할 경우에는 내부적으로 사용되는 쓰레드풀의 최대값을 증가시켜 해결한 사례도 있다. [관련 링크1] [관련 링크2]

JGRP000015 트러블슈팅

  • 리눅스 기반 운영체제의 경우 소켓마다 송수신되는 패킷을 임시 보관하는 버퍼의 최대 크기가 작을 경우 아래 경고가 로그에 출력된다.
2018-12-18 17:28:57.932  WARN 25137 --- [           main] org.jgroups.protocols.UDP                : JGRP000015: the send buffer of socket MulticastSocket was set to 1.00MB, but the OS only allocated 212.99KB. This might lead to performance problems. Please set your max send buffer in the OS correctly (e.g. net.core.wmem_max on Linux) 2018-12-18 17:28:57.932  WARN 25137 --- [           main] org.jgroups.protocols.UDP                : JGRP000015: the receive buffer of socket MulticastSocket was set to 25.00MB, but the OS only allocated 212.99KB. This mi ght lead to performance problems. Please set your max receive buffer in the OS correctly (e.g. net.core.rmem_max on Linux) 
  • 위와 같은 경고 로그를 방치할 경우 노드 간의 통신 성능이 저하된 채로 클러스터를 유지하게 된다. 아래와 같이 수정하면 된다. 시스템 리부트 없이 바로 적용할 수 있다.
### 송수신 패킷에 대한 최대 버퍼 크기 상향 설정
$ sudo vi /etc/sysctl.conf
net.core.wmem_max = 1048576
net.core.rmem_max = 26214400

### 설정한 내용을 실시간 적용
$ sudo sysctl --system

### 적용된 내용 확인
$ sysctl net.core.wmem_max net.core.wmem_max = 1048576
$ sysctl net.core.rmem_max net.core.rmem_max = 26214400

Amazon EC2 트러블슈팅: NATIVE_S3_PING 을 사용한 멀티캐스트

  • NATIVE_S3_PING 통신 방식은 AWS 이용시 제작사가 권장하는 방식으로 EC2ECS on Fargate 모두 작동한다.
  • 프로젝트에 NATIVE_S3_PING을 적용하려면 라이브러리에 포함된 /default-configs/default-jgroups-ec2.xml 파일을 /src/main/resources/default-jgroups-ec2.xml 이름으로 복사하고 org.jgroups.aws.s3.NATIVE_S3_PING 블록을 아래 내용을 추가한다.
<org.jgroups.aws.s3.NATIVE_S3_PING
    region_name="ap-northeast-2"
    bucket_name="some-jgroups"
    num_discovery_runs="3"
/>
  • NATIVE_S3_PING은 노드 간의 발견을 S3 저장소 버킷에 업로드한 파일로 확인한다. 따라서 region_name에는 S3 버킷이 생성되고 유지될 리전 이름(위 예제는 서울 리전)을, bucket_name에는 버킷 이름을 지정한다. (지정하지 않으면 자동으로 생성한다. 글로벌 단위로 유일하게 식별되는 버킷 이름을 지정해야함에 유의한다.)
  • 마지막으로 앞서 작성했던 CacheConfig에서 configurationFile 옵션 값을 생성한 파일로 교체하면 된다.
GlobalConfigurationBuilder global = new GlobalConfigurationBuilder()
    .transport().defaultTransport()
    .addProperty("configurationFile", "default-jgroups-ec2.xml")
    ...
  • NATIVE_S3_PING 통신 방식은 ECS 환경에서 노드가 증가할수록 초기 기동시 노드 디스커버리 속도가 느려지는 경향이 있다. 이는 헬스 체크 실패를 유발할 수 있으므로 서비스 속성에서 상태 검사 유예 기간을 기본값 0초에서 보다 여유 있게 120초(2분)~300초(5분) 정도로 설정해두면 오토 스케일링 상황에서도 문제없이 대응할 수 있다.

프로덕션 도입 경험

  • 현재 운영 환경의 서비스에 JBoss InfinispanEmbedded 모드로 도입하여 얻은 경험은 다음과 같다. 읽기 요청이 극단적으로 많고 쓰기 요청은 매우 드문 오브젝트에 한정하여 Invalidation 캐시 모드를 적용했다. 그 결과 요청 처리 시간이 1ms 미만으로 기록되었다. (APM에는 0ms로 기록되었다.) JVM의 인메모리에 오브젝트를 캐시하므로 어찌 보면 당연한 결과인데 APM의 그래프를 보면 잔디를 깎아서 바짝 엎드려 평평해진 모양새라 눈이 즐거워진다. 원격지에 위치한 Redis 저장소에만 의존할 때 보다 확실히 빠르다.
  • 가장 궁금하고 기대했던 쓰기 요청 발생시 오브젝트 캐시의 전체 노드에 대한 Invalidate 전파 시간은 1~3ms를 기록했다. (전체 3개 노드 기준으로 모두 같은 사설망에 위치하며 UDP 프로토콜만 사용했다. 노드를 증설할수록 전파 시간도 느려짐을 감안해야 한다.) 클러스터의 관점에서 충분히 수용할만한 성능이다.

참고 글

댓글
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/03   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함