You are viewing an old version of this page. View the current version.

Compare with Current View Page History

« Previous Version 11 Next »

앞장에서 설명한 PersistentDurableStateActor 은 상태있는 서비스에서 분산을 했을때 AKKA를 이용한 분산상태 개발방식입니다.

상태없는 개발방식과 없는 방식의 차이를 먼저알아고보 AKKA의 액터모델이 아닌, 코틀린 순수액터모델을 이용해 유사하게 구현해보고

읽기성능이 얼마나 빨라질수 있는지 확인을 해보겠습니다.


상태없는 서비스 VS 상태 있는서비스 장단점 요약

특성상태없는 서비스상태 있는 서비스
병목 원인데이터베이스, 외부 API, 캐시 사용으로 부하 증가상태 동기화, 세션 관리, 중앙 상태 저장소로 부하 증가
확장성수평 확장이 용이하며 병목을 분산 처리 가능상태 동기화 필요로 인해 확장성이 제한됨
장애 복구장애 복구가 상대적으로 단순병목 지점의 상태 복구가 복잡
최적화 방향요청 간 상태 독립성 활용, 캐싱 및 병목 지점 분산상태 동기화 최적화, 병목 지점 클러스터링
적합한 사례대규모 웹 애플리케이션, REST API, 서버리스 설계게임 서버, 채팅, 실시간 세션 기반 시스템


일반적인 웹개발에서 상태없는 서비스로 작성하는것이 이득이 있지만 , 게임또는 채팅이 진행되는동안 상태업데이트가 지속 일어나는경우

상태있는 객체를 설계해야할수도 있습니다.


간단한 게임및 비교적 간단한 채팅의 경우 상태없는 서비스로도 충분히 설계가 가능할수 있으며 꼭 어느 한가지방식만 장점이 있을수 있다고 볼수 없으며

다음과 같이 트레이드 오프가 발생할수 있습니다. 

상태관리 객체를 만드는 가장 큰 단점은 구현의 난이도에 있습

니다.

성능 트레이드오프

장점

  • 로컬 상태 관리로 요청 처리 속도가 빨라짐.
  • 네트워크 비용 감소와 높은 동시성 처리.
  • 읽기와 쓰기의 독립적 최적화.
  • 데이터베이스 성능 의존을 줄일수 있음

단점

  • 상태 동기화와 장애 복구(예: 인스턴스 장애 시 상태 손실) 복잡성 증가.
  • 상태 저장소와 메모리 사용량 증가.
  • 서버 장애 시 상태 복구를 위한 이벤트 로그 또는 스냅샷 관리 필요.


액터모델은 기본적으로 상태를 관리하며 라이프 사이클이 웹(REST API)에서 요청하는 사이클보다 긴 사이클의 상태관리를 할때 활용할수 있습니다.

시도되는 코드

  • 조건에 따른 상태저장 : 특정 사용자가 Hello를 하면 사용자별 카운트 1증가 , 기분이 나쁜 상태일때는 Hello를 거부 카운트 증가없음
  • 상태읽기 : 특정 사용자 Click 카운트를 확인 하기 위해 1000번 조회후 성능측정
  • 추가 미션 : 마지막 상태값 저장뿐만 아니라 상태 로그를 저장해 시계열분석이 가능하도록 데이터 처리


성능표

  • Total time for 100 Hello commands for Write: 73 ms
  • Average time per Hello command for Write: 0.73 ms
  • Total time for 1000 HelloCount for Read commands: 75 ms
  • Average time per HelloCount command: 0.075 ms


성능표를 먼저 공유하면~  저장시 마지막 상태는 Redis에 저장을 하고, 로그성은 Kafka에 저장합니다.

해당 객체가 상태를 가지고 있기때문에 읽기시 Redis로부터 값을 읽을 필요없이 로컬에서의 상태값값을 반환합니다.

Redis가 아무리 고성능 장치라고 하지만 1000회조회에 75ms 이내로 수행하기는 어렵습니다. 다음과 같은 결정적인 이유가 있기때문입니다.

  • Redis 를 1000번 호출한다는것은 인메모리에서 1000번 호출할것같지만  네트워크 호출 1000회가 포함되어 있습니다.
    • 상태없는 프로그래밍에서 최적화 지점은 Redis가 고성능이라고 믿고 있지만 네트워크 호출 횟수조차 줄여~ 단일지점 저장소의 부하를 어플리케이션에 분산하는것에 있습니다. 
    • Redis에서 저장용량을 줄이기위해 Json을 더 작은 바이너리 또는 Bit연산가능한 데이터로 저장할수도 있습니다.  이것은 저장공간을 줄일수 있지만 네트워크 호출 비용(Access) 을 결정적으로 줄이지는 못합니다.


이것을 모델 다이어그램으로 정리하면 다음과 같이 동작하게 됩니다.


  • Hello Count 를 질의하기위해 영속장치를 접근할 필요없이 , 인메모리(상태프로그래밍) 에서 정확한 값을 바로 응답할수 있습니다.
  • Redis에 마지막 값을 항상 유지함으로 ~ 업데이트 또는 장애복구시 Actor가 초기화될시 마지막값으로 상태를 복원해 시작할수 있습니다.
    • 마지막 상태값 유지를 위해, 꼭 Redis일 필요없으며, 인메모리 기능을 이미 가지고 있기때문에 RDB에 단지 마지막 값을 유지할수도 있습니다.  
    • 이 모델은 Read를 위해 매번 RDB 조회할 필요가 없으며, Read의 책임있는 DB를 인메모리가 아닌 다른곳에 위임할수도 있습니다. 
  • 이벤트의 변화를 Kafka에 기록해둠으로 누군가는 이것을 소비해 시계열 기반 분석 기능을 작성할수도 있습니다. ( 이벤트 소싱패턴 활용한 다양한 기능을 구현 )



위 방식이 전통적인 CRUD 보다 분명 복잡하고 고려해야할 사항들은 더 있을수 있으며 위 방식을 단지 CRUD방식으로 풀어서 비교해보겠습니다.

전통적인 RDB를 이용한 CRUD 방식

CREATE TABLE user_state (
    user_id VARCHAR(255) PRIMARY KEY,
    state ENUM('HAPPY', 'ANGRY') NOT NULL,
    hello_count BIGINT NOT NULL,
    hello_total_count BIGINT NOT NULL
);

DELIMITER //

CREATE PROCEDURE increment_hello_count(
    IN p_user_id VARCHAR(255),
    IN p_amount BIGINT
)
BEGIN
    UPDATE user_state
    SET hello_count = hello_count + p_amount
    WHERE user_id = p_user_id AND state = 'HAPPY';
END //

DELIMITER ;

DELIMITER //

CREATE PROCEDURE get_user_state(
    IN p_user_id VARCHAR(255)
)
BEGIN
    SELECT state, hello_count, hello_total_count
    FROM user_state
    WHERE user_id = p_user_id;
END //

DELIMITER ;


전통적인 DB에서 쓰기와 읽기를 분리한다고 했을때 이것이 CQRS라고 생각하면 큰 착각이다. 우선 DB의 Read성능을 높이기위해 확장하는것은 DB1개를 더 두는것이기때문에 아주 값비싼 확장방식이다.

더욱이 사용자의 1카운트를 증가하기위해 Update또는 Create만 발생하는것이 아니라~  기존 값을 확인(Read)한후 증가하기때문에 Read와 Write(Update)비용이 증가함과 동시에 동시성 처리를 위해

사용자단위로 LockFree하지 않은 방식이 사용되었습니다.

CRUD가 항상 단점이 있는것은 아니며 다음과 CQRS대비 장단점이 존재합니다.

장점: CRUD

  • 데이터 영속성: RDB는 내구성 있는 저장소를 제공하여 애플리케이션이 충돌하더라도 데이터가 손실되지 않습니다.
  • ACID 트랜잭션: RDB는 원자성, 일관성, 고립성, 내구성을 지원하여 신뢰할 수 있는 트랜잭션을 보장합니다.
  • 유연한 쿼리: SQL을 사용하여 복잡한 쿼리와 조인을 수행할 수 있어 데이터 검색 및 조작이 용이합니다.
  • 확장성: RDB는 대용량 데이터셋을 처리할 수 있으며 샤딩과 복제를 통해 수평 확장을 지원합니다.
  • 백업 및 복구: RDB는 데이터 백업 및 복구를 위한 내장 메커니즘을 가지고 있습니다.

단점: CQRS

  • 지연 시간: RDB 작업은 디스크 I/O 및 네트워크 지연을 수반하므로 메모리 내 작업에 비해 느릴 수 있습니다.
  • 동시성: 높은 동시성을 처리하는 것은 도전적일 수 있으며, 병목 현상을 피하기 위해 신중한 트랜잭션 관리가 필요합니다.
  • 복잡성: 스키마 관리, 인덱스 최적화 및 쿼리 최적화는 애플리케이션에 복잡성을 추가할 수 있습니다.
  • 오버헤드: RDB는 ACID 속성을 유지하고 데이터 무결성을 보장하기 위해 오버헤드를 도입합니다.
  • 확장성: RDB는 확장 가능하지만, 분산 메모리 내 액터 시스템의 선형 확장성을 따라가지 못할 수 있습니다.



액터를 이용해 CQRS 패턴으로 구현하기

RedisService구현

implementation("org.springframework.boot:spring-boot-starter-data-redis-reactive")


@Service
class RedisService(private val reactiveRedisTemplate: ReactiveRedisTemplate<String, String>) {

    fun setValue(category: String, key: String, value: String): Mono<Boolean> {
        val compositeKey = "$category:$key"
        return reactiveRedisTemplate.opsForValue().set(compositeKey, value)
    }

    fun getValue(category: String, key: String): Mono<String?> {
        val compositeKey = "$category:$key"
        return reactiveRedisTemplate.opsForValue().get(compositeKey)
    }
}
  • 상태값을 가져오고 저장하는 코드는 심플하며, 복잡한 관계형 DB필요없이 공통으로 Value 객체를 이용하게 됩니다.


액터구현

코틀린 언어가 지원하는 순수 액터가 이용되었으며 

class HelloKTableActor(
        private val persistenceId:String ,
        private val producer: KafkaProducer<String, HelloKTableState>,
        private val redisService: RedisService
    ) {

    private val channel = Channel<HelloKTableActorCommand>()
    private var curState: HelloKTableState

    init {

        // Read initial state from Redis
        curState = redisService.getValue("hello-state-store", persistenceId)
            .map { stateJson ->
                // Deserialize stateJson to HelloKTableState
                // Assuming you have a method to deserialize JSON to HelloKTableState
                stateJson?.let { deserializeState(it) }
            }
            .block() ?: HelloKTableState(HelloKState.HAPPY, 0, 0) // Default state if not found

  • 액터는 고유 시별ID(논리적구분 여기서는 사용자별) 를 가지며 , 초기화시 Redis로부터 마지막 상태값을 읽어옵니다.
  • producer 는 이벤트 로그를 kafka에 전송하기위해 이용되었으며 이 장치는 없어도 동작에 영향을 끼치지 않습니다. - 시계열데이터가 있기때문에 이벤트 소싱에서 이용가능


이벤트 처리기

    private fun handleHello(command: HelloKtable) {
        if (curState.state == HelloKState.HAPPY && command.message == "Hello") {
            val newState = curState.copy(helloCount = curState.helloCount + 1, helloTotalCount = curState.helloTotalCount + 1)

            curState = newState

            // Save state to Redis
            redisService.setValue("hello-state-store", persistenceId, serializeState(curState)).subscribe()

            // Update KTable with new state
            //stateStore.put(persistenceId, newState)
            producer.send(org.apache.kafka.clients.producer.ProducerRecord("hello-log-store", persistenceId, curState))

            command.replyTo.complete(HelloKStateResponse("Kotlin"))

        } else if (curState.state == HelloKState.ANGRY) {
            command.replyTo.complete(HelloKStateResponse("Don't talk to me!"))
        }
    }
  • 나의 상태가 Happy 일때만 반응하며 아닌경우 거부합니다.
  • Redis를 통해 새로운 상태를 저장합니다.
  • Kafka를 통해 로그성 데이터를 생산합니다.


헬로우 카운트 조회

    private fun handleGetHelloCount(command: GetHelloKtableCount) {
        command.replyTo.complete(HelloKStateCountResponse(curState.helloCount))
    }
  • 액터를 통한 상태프로그래밍에 의해 HelloCount 가 동기화가 되었기때문에~ Redis를 별도로 호출할 필요없이 이미 알고 있는 상태를 반환합니다.



Redis와 Kafka 유실없음 확인



AKKA 가 CQRS를 위해 액터모델에 지원하는 장치는 다음과같습니다.

Journal

이벤트 소싱(Event Sourcing) 방식을 사용합니다1.
액터의 상태 변경을 나타내는 이벤트들을 순차적으로 저장합니다1.
추가 전용(append-only) 로그 형태로 이벤트를 저장합니다1.
액터의 전체 상태 변경 이력을 보존합니다.
액터 복구 시 저장된 이벤트들을 재생하여 상태를 복원합니다3.

Snapshot

액터의 전체 상태를 특정 시점에 저장합니다1.
복구 시간을 최적화하기 위한 용도로 사용됩니다4.
전체 이벤트 이력을 재생하지 않고도 빠르게 상태를 복원할 수 있게 해줍니다1.
Journal과 함께 사용되며, 가장 최근 스냅샷 이후의 이벤트만 재생하면 됩니다1.

Durable State

액터의 최신 상태만을 저장합니다

이벤트 이력을 저장하지 않고 현재 상태만 유지합니다.
CRUD 기반 애플리케이션과 유사한 방식으로 동작합니다
상태 변경 시마다 전체 상태를 덮어씁니다.

주요 차이점:

  • 저장 방식: Journal은 이벤트 로그, Snapshot은 전체 상태의 특정 시점 복사본, Durable State는 최신 상태만 저장합니다.
  • 복구 프로세스: Journal은 모든 이벤트 재생, Snapshot은 최근 스냅샷 + 이후 이벤트 재생, Durable State는 최신 상태만 로드합니다.
  • 데이터 보존: Journal은 전체 이력 보존, Snapshot과 Durable State는 특정 시점/최신 상태만 보존합니다.
  • 사용 사례: Journal은 감사와 시간 기반 쿼리에 유용, Snapshot은 복구 최적화, Durable State는 단순한 상태 관리에 적합합니다.


코틀린 순수 액터모델을 사용해  Journal + durable State 컨셉을 직접 구현해 적용되었습니다.

모델의 Value 변화에따른 이벤트 버전관리는 제외 되었으며 완전한 컨셉은 PersistentDurableStateActor 를 통해 확인할수 있습니다.


전체 코드및 테스트코드

여기서 설명하는 전체코드를 확인할수 있으며 유닛테스트를 통해 기능확인및 성능테스트를 시도해볼수 있습니다.



이러한 상태관리 프로그래밍의 방식이 꼭 액터모델을 통해서 할수 있는것은 아니며 카프카의 Stream의 KTable을 통해서도 이러한 개념을 대체해 적용할수 있습니다.

상태관리 프로그래밍 방식이 왜 카프카에도 도입되고 활용하고 있는지?


카프카에서 도입된 스트림을 통한 상태관리 프로그래밍 예

- https://velog.io/@ehdrms2034/%EC%B9%B4%ED%94%84%EC%B9%B4-%EC%8A%A4%ED%8A%B8%EB%A6%BC%EC%A6%88-DSL-%EA%B0%9C%EB%85%90
- https://breezymind.com/kafka-streams-basic/










  • No labels