Versions Compared

Key

  • This line was added.
  • This line was removed.
  • Formatting was changed.

...

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

니다.

성능 트레이드오프

장점

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

...

  • 조건에 따른 상태저장 : 특정 사용자가 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) 을 결정적으로 줄이지는 못합니다.


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


draw.io Board Diagram
bordertrue
diagramNamehelloredis
simpleViewerfalse
width
linksauto
tbstyletop
lboxtrue
diagramWidth1441
revision3

  • Hello Count 를 질의하기위해 영속장치를 접근할 필요없이 , 인메모리(상태프로그래밍) 에서 정확한 값을 바로 응답할수 있습니다.
  • Redis에 마지막 값을 항상 유지함으로 ~ 업데이트 또는 장애복구시 Actor가 초기화될시 마지막값으로 상태를 복원해 시작할수 있습니다.
    • 마지막 상태값 유지를 위해, 꼭 Redis일 필요없으며, 인메모리 기능을 이미 가지고 있기때문에 RDB에 단지 마지막 값을 유지할수도 있습니다.  
    • 이 모델은 Read를 위해 매번 RDB 조회할 필요가 없으며 Read의 책임있는 DB를 인메모리가 아닌 다른곳에 위임할수도 있습니다. 
    • 상태있는 서비스를 통해 RDB만으로 Redis의 분산처리 캐시효과 줄수 있으며 영속성장치는 Redis보다는 RDB가 더 적합합니다. 이 샘플은 Redis의 Read속도보다 더 빠른장치를 만들고 비교하기위한 샘플입니다.
    • 액터자체를 분산처리하고 관리 하는방식은 여기서 제외되었습니다. ( Akka에서 지원하는 기능 )
  • 이벤트의 변화를 Kafka에 기록해둠으로 누군가는 이것을 소비해 시계열 기반 분석 기능을 작성할수도 있습니다. ( 이벤트 소싱패턴 활용한 다양한 기능을 구현 )



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

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

Code Block
themeEmacs
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구현

Code Block
themeEmacs
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 객체를 이용하게 됩니다.


액터구현

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

Code Block
themeEmacs
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에 전송하기위해 이용되었으며 이 장치는 없어도 동작에 영향을 끼치지 않습니다. - 시계열데이터가 있기때문에 이벤트 소싱에서 이용가능


이벤트 처리기

Code Block
themeEmacs
    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를 통해 로그성 데이터를 생산합니다.


헬로우 카운트 조회

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



Redis와 Kafka 유실없음 확인

Image Added


Image Added



상태있는 서비스는 기본적으로 CQRS기법을 사용하며, 상태없는 서비스여도 CQRS기법을 사용할수 있습니다. 이둘의 컨셉은 각각입니다.

CQRS

CQRS는 **쓰기(Command)**와 **읽기(Query)**를 서로 분리하여 독립적으로 설계 및 구현하는 패턴입니다.

장점

  1. 성능 최적화:

    • 읽기/쓰기 작업이 분리되므로, 각 작업에 특화된 데이터 저장소나 데이터 모델을 사용할 수 있어 성능이 향상됩니다.
    • 읽기 작업은 캐시, 읽기 전용 복제본 등을 활용하여 대규모 트래픽을 처리할 수 있습니다.
  2. 확장성:

    • 읽기와 쓰기가 독립적이므로, 시스템을 개별적으로 확장 가능. 예를 들어, 읽기 요청이 많으면 읽기 쪽만 확장(수평 확장) 가능.
  3. 복잡한 도메인 로직 지원:

    • 쓰기 모델에서 복잡한 비즈니스 로직을 구현할 수 있고, 읽기 모델은 단순히 데이터를 클라이언트가 원하는 형태로 제공하도록 설계 가능.
  4. 유연한 데이터 모델링:

    • 읽기와 쓰기에 각기 다른 데이터베이스나 데이터 구조를 사용할 수 있어, 읽기 모델을 최적화하거나 도메인 이벤트를 활용하는 등 다양한 설계를 적용 가능.
  5. 도메인 이벤트 활용:

    • Event Sourcing과 결합하면 시스템에서 발생한 모든 상태 변화를 이벤트로 기록하여 시스템 상태의 과거를 재구성하거나 추적할 수 있음.

...

단점

  1. 복잡성 증가:

    • 읽기와 쓰기 모델을 별도로 설계하고 구현해야 하므로 개발 및 유지보수 비용이 증가함.
    • 데이터 동기화가 까다로울 수 있음(Eventual Consistency).
  2. 개발 및 테스트 비용 증가:

    • 두 가지 모델과 관련 로직을 모두 테스트해야 하므로 더 많은 시간과 리소스가 필요함.
  3. 지연된 일관성:

    • 쓰기 작업 후 읽기 모델에 반영되기까지 지연(Latency)이 있을 수 있음(Eventual Consistency 모델 적용 시).


여기서는 상태있는 서비스개발에 CQRS의 개념을 일부 채택하였습니다. 

우리의 대부분 도메인모델을 CRUD로 해결할수 있지만~ 그 한계로인해 CQRS로 해결해야는 부분이 있을수 있습니다.


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 변화에따른 이벤트 버전관리는 제외 되었으며 CQRS 완전한 컨셉은 PersistentDurableStateActor 를 통해 확인할수 있습니다.


전체 코드및 테스트코드

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


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

상태관리 프로그래밍 방식이 왜 카프카에도 도입되고 CRUD에서 해결하기 어려운 성능문제 해결에 이용되고 있는지 학습해볼 필요는 있습니다.



영속성을 위한 이벤트 스토어 DDL for Postgres 용

상태프로그래밍 영속성 이벤트 스토어용으로 RDB는 R2DBC(Reactive Drive)를 통해 여전히 높은 동시성 처리 저장장치로 활용할수 있습니다.

샘플은 Akka Actor에 연결되는 영속장치의 공식 DDL문입니다. - akka 2.7 기준 호환됨을 확인

Code Block
themeEmacs
linenumberstrue
CREATE TABLE IF NOT EXISTS event_journal(
                                            slice INT NOT NULL,
                                            entity_type VARCHAR(255) NOT NULL,
    persistence_id VARCHAR(255) NOT NULL,
    seq_nr BIGINT NOT NULL,
    db_timestamp timestamp with time zone NOT NULL,

                               event_ser_id INTEGER NOT NULL,
                               event_ser_manifest VARCHAR(255) NOT NULL,
    event_payload BYTEA NOT NULL,

    deleted BOOLEAN DEFAULT FALSE NOT NULL,
    writer VARCHAR(255) NOT NULL,
    adapter_manifest VARCHAR(255),
    tags TEXT ARRAY,

    meta_ser_id INTEGER,
    meta_ser_manifest VARCHAR(255),
    meta_payload BYTEA,

    PRIMARY KEY(persistence_id, seq_nr)
    );

-- `event_journal_slice_idx` is only needed if the slice based queries are used
CREATE INDEX IF NOT EXISTS event_journal_slice_idx ON event_journal(slice, entity_type, db_timestamp, seq_nr);

CREATE TABLE IF NOT EXISTS snapshot(
                                       slice INT NOT NULL,
                                       entity_type VARCHAR(255) NOT NULL,
    persistence_id VARCHAR(255) NOT NULL,
    seq_nr BIGINT NOT NULL,
    write_timestamp BIGINT NOT NULL,
    ser_id INTEGER NOT NULL,
    ser_manifest VARCHAR(255) NOT NULL,
    snapshot BYTEA NOT NULL,
    meta_ser_id INTEGER,
    meta_ser_manifest VARCHAR(255),
    meta_payload BYTEA,

    PRIMARY KEY(persistence_id)
    );

CREATE TABLE IF NOT EXISTS durable_state (
    slice INT NOT NULL,
    entity_type VARCHAR(255) NOT NULL,
    persistence_id VARCHAR(255) NOT NULL,
    revision BIGINT NOT NULL,
    db_timestamp timestamp with time zone NOT NULL,
    state_ser_id INTEGER NOT NULL,
    state_ser_manifest VARCHAR(255),
    state_payload BYTEA NOT NULL,
    tags TEXT ARRAY,

PRIMARY KEY(persistence_id, revision)
);

-- `durable_state_slice_idx` is only needed if the slice based queries are used
CREATE INDEX IF NOT EXISTS durable_state_slice_idx ON durable_state(slice, entity_type, db_timestamp, revision);

-- Primitive offset types are stored in this table.
-- If only timestamp based offsets are used this table is optional.
-- Configure pekko.projection.r2dbc.offset-store.offset-table="" if the table is not created.
CREATE TABLE IF NOT EXISTS projection_offset_store (
                                                       projection_name VARCHAR(255) NOT NULL,
    projection_key VARCHAR(255) NOT NULL,
    current_offset VARCHAR(255) NOT NULL,
    manifest VARCHAR(32) NOT NULL,
    mergeable BOOLEAN NOT NULL,
    last_updated BIGINT NOT NULL,
    PRIMARY KEY(projection_name, projection_key)
    );

-- Timestamp based offsets are stored in this table.
CREATE TABLE IF NOT EXISTS projection_timestamp_offset_store (
                                                                 projection_name VARCHAR(255) NOT NULL,
    projection_key VARCHAR(255) NOT NULL,
    slice INT NOT NULL,
    persistence_id VARCHAR(255) NOT NULL,
    seq_nr BIGINT NOT NULL,
    -- timestamp_offset is the db_timestamp of the original event
    timestamp_offset timestamp with time zone NOT NULL,
    -- timestamp_consumed is when the offset was stored
    -- the consumer lag is timestamp_consumed - timestamp_offset
    timestamp_consumed timestamp with time zone NOT NULL,
                                     PRIMARY KEY(slice, projection_name, timestamp_offset, persistence_id, seq_nr)
    );

CREATE TABLE IF NOT EXISTS projection_management (
                                                     projection_name VARCHAR(255) NOT NULL,
    projection_key VARCHAR(255) NOT NULL,
    paused BOOLEAN NOT NULL,
    last_updated BIGINT NOT NULL,
    PRIMARY KEY(projection_name, projection_key)
    );


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

대량데이터 배치처리가 아닌 준실시간성 이벤트 처리를 위한 액터 상태머신 샘플


상태프로그래밍 / CQRS /  영속성  각각 다른 컨셉으로  복잡한 도메인을 처리하기위한 CQRS의 기법설명은 여기서 제외되었으며 정밀기법은 다음을 참고합니다.


Next

이와같이 구현된 컨셉을, 액터모델없이 Kafka제공 순수 스트림프로그래밍을 통해 유사하게 구현해보겠습니다.

액터모델의 경우 OOP베이스에 함수형이 필요하면 혼합할수 있는 하이브리드 방식이라고 하면 스트리밍 프로그래밍은 함수형을 주로이용합니다.