본문 바로가기

FrontEnd

[번역] 서버에서 Redux 사용하기

반응형

원문 : https://webbylab.com/blog/redux-on-backend/

 

How We Used Redux on Backend and Got Offline-First Mobile App as a Result | Webbylab

we want to share our experience of building offline-first React-Native application, using Redux-like approach on our NodeJS backend server

webbylab.com

서문

원문 저자는 React Native 앱을 개발하며 NodeJS 백엔드에서 Redux를 사용한 경험을 공유한다.

저자는 kafka나 activieMQ와 같은 도구 없이 event-sourcing architecture를 도입하기 위해 Redux를 사용했다고 한다.

코어 개념을 이해하면 아키텍처의 큰 변화 없이 프로젝트에서 흔히 사용하는 Redux 만으로도 이벤트 소싱 장점을 누릴 수 있다.


구현 대상 애플리케이션

저자가 구현한 애플리케이션은 3가지 부분으로 구성된 시스템이다.

  • 웹 클라이언트 (React + Redux)
  • 백엔드 (NodeJS + MongoDB + Redux)
  • 네이티브 앱(RN + Redux)

앱의 요구사항은 다음과 같다.

  • 모바일 오프라인 모드
    • 유저는 오프라인 상태에서 모든 작업을 수행할 수 있으며, 작업 결과는 앱에 저장된다.
  • 모바일 앱과 웹 앱은 다를 수 있다.
    • 웹에서는 사용자 그룹 통계를 표시하고 모바일에서는 개인의 정보만 표시할 수 있다.
  • 요구 사항은 끊임없이 진화한다.
    • 즉, 언제든지 상사가 이전 요구사항을 뒤엎고 새로운 피처를 추가할 수 있다.
  • 감사 추적(Audit trail) 지원
    • 프로젝트는 법적 활동 분야와 밀접하게 연결되어 있다.
요구사항 구현 전에 우리가 데이터에 대해 생각하는 방법을 돌아보자.

데이터에 대해 생각하기

  • OS 연산의 결과는 메모리 변환이다.
    • 응용 프로그램의 메모리는 일반적으로 상태로 표현한다.
    • 상태는 복잡한 DB, 일반적인 파일 또는 일반 자바스크립트 객체로 표현할 수 있다.
  • 즉, 애플리케이션의 모든 연산 결과는 상태 변환이다.
    • 상태를 어떻게 표현하는가 보다 상태를 어떻게 바꾸느냐가 더 중요하다.
상태 변환 연산을 구현하는 일반적인 접근 방식은
현재 작업에 관련된 상태만 메모리에 유지하고, 사용자가 연산을 수행하면 해당 상태를 변경하는 것이다.
일반적인 CRUD 모델의 흐름은 (특정 방식으로) 스토라지에서 데이터를 가져와 수정하고 다시 스토리지에 넣는 것이다.
추가적으로 동시 업데이트를 제어해야 한다. 
순차적으로 적용되어야 할 사항이 동시에 적용되거나, 순서가 뒤바뀌면 문제가 된다.
일반적인 접근 방식은 지금 작업에 관련된 상태만 유지하고, 사용자가 연산을 수행하면 해당 상태를 변경하는 것이다

 

소프트웨어 엔지니어링 분야가 빠르게 발전하면서,
개발자들은 이러한 전통적인 사고 방식이 일부 유형의 애플리케이션(예: 엔터프라이즈)에서는 그리 효과적이지 않다는 것을 알게되었다.
그 결과 새로운 아키텍처 패턴이 나타나기 시작했다.
DDD, CQRS 및 Event Sourcing 패턴이 그것이다.
 
우리의 주돤 관심사는 이벤트 소싱이 데이터에 대한 우리의 사고 방식을 어떻게 바꾸는가이다.
날씨에 관련된 상태에 독립적인 연산을 연속해서 적용하는 대신,
연산을 위한 정보를 기록하고 나중에 배치 처리하면 안될까? 그리고 처리 방법은 나중에 결정하자
 
일단 이러한 액션을 어딘가에 계속 기록하고 나중에 처리 방법이 생각나면 그때 처리하는 것이다.
이를 통해 이벤트의 발생과 처리 시점, 처리 방법을 분리할 수 있다.
심지어 이벤트의 발생을 기록하는 앱과 처리하는 앱도 분리할 수 있다.
카프카와 서버 / 웹 애플리케아션이 그것이다. (물론 합칠 수도 있다)

액션을 로그에 계속 기록하고 나중에 처리 방법이 생각나면 그때 처리한다.


왜 이렇게 해야 할까?

어차피 우리가 원하는게 상태에 연산을 적용하는 것이라면 굳이 연산을 따로 기록하고 나중에 처리할 필요가 없다.
시스템이 더 복잡해질 뿐만 아니라 일도 더 늘어난다.

하지만, 우리가 병원에 갈 때를 생각해보자.
병원에는 EMR(진료 차트)라는게 있다.
해당 기록에는 환자의 진료 기록이 들어있다.
따라서 의사는 항상 해당 기록을 통해 우리의 질병의 현재 상태를 유추할 수 있다.

또한 기록을 통해 현재 상테에 어떻게 도달하게 되었나가 분명 중요한 진단 기준이 될 수도 있다.

또한 이전 기록을 통해 현재 상태가 정상인지 아닌지도 쉽게 유추가 가능하다.

즉, 히스토리가 중요한 비즈니스일 경우 이벤트 소싱은 꽤나 도움이 될 것이다.

이벤트 자체가 기록의 대상이 될 것이기 때문이다.


이벤트 소싱을 사용하지 말아야 할 때

동시 업데이트의 위험이 없고 실시간 업데이트의 요구사항이 없는 경우 이벤트 소싱은 일만 늘린다.


리덕스와 이벤트 소싱

  • 상태는 앱의 단일 진실 원천이다.
  • 는 해당 상태의 표현이다.
  • 상태를 변경하려면 뷰 혹은 어딘가에서 이벤트(액션)을 디스패치(전송)해야 한다.
  • 리듀서는 이벤트 핸들러다.
  • 리듀서는 액션의 배열을 이전 상태에 적용해 모든 액션이 적용된 이후의 상태로 축소(reduce)한다.
  • 리듀서는 상태 변경 방법의 단일 진실 원천이다.

이벤트가 리듀서에서 배치 처리되는 모양은 이벤트 소싱과 매우 유사하다.

Redux와 이벤트 소싱은 트랜잭션 상태를 관리하는 동일한 목적을 수행한다.
Redux는 액션(이벤트)에 대한 리듀서가 애플리케이션 상태 변경 방법(how) 대한 단일 진실 원천이 될 수 있음을 보여준다.

Redux의 단일 진실 원천은 store(& reducer)지만, event sourcing 에서 단일 진실 원천은 이벤트라는 것이 다르다.

즉, 같은 이벤트여도 다른 리듀서를 사용한다면 다른 상태가 된다.

 

두 개념의 공통점을 파악하면 다음과 같다.

  • 상태는 이벤트에서 기원한다.
  • 이벤트는 변경할 수 없다. (불변)

이벤트는 불변이라는 것이 매우 중요하다.

연산 도중 예측 불가능한 이벤트의 변경이 발생하면, 입력만으로 출력을 투명하게 예측할 수 없다.

즉 상태는 결정적이어야 한다.

리덕스와 이벤트 소싱의 공통점

 


구현

다음은 애플리케이션에 어떤 상황이 있는지에 대한 개요다.
사용자는 오프라인 모드에서 시험에 통과할 수 있다.

이 경우 두 가지 요구사항이 존재한다.

  1. 모바일에서 통과 결과를 기반으로 사용자 정보를 다시 집계한다. (오프라인이건 아니건)
  2. 어떻게든 백엔드의 DB에서 동일한 사용자 정보를 얻어, 애플리케이션과 완전히 분리된 웹 파트에 표시할 수 있다. (온라인)
첫 번째 부분은 그다지 문제가 되지 않는다. 낙관적 업데이트를 사용하면 된다.
오프라인 모드 모바일 앱에서 기능을 실행하고 사용자 정보를 다시 계산할 수 있다.
인터넷 연결은 Redux가 작동하기 위한 요구 사항이 아니기 때문이다.
 
하지만 두 번째 문제는 어떻게 처리해야 할까?
액션을 어딘가에 기록하여 오프라인일 때 사용자가 수행한 작업을 놓치지 않도록 해야 한다.
 
외부 처리가 필요한 모든 액션에 대해 이 기록을 수행해야 한다.
따라서 기록 대상 액션의 모든 액션 생성자를 포함한 파일을 만든다.
부수효과도 있고 비동기 로직을 실행할 수 도 있어야 하기 때문에 미들웨어에서 실행하는 thunk로 만든다.
/* We rely on thunk-middleware to deal with asyncronous actions. 
If you are not familiar with the concept, go check it out, 
it may help you to undestand our action creator's structure better */
export function createQuestionAnswerEvent(payload) {
    return (dispatch) => {
        const formattedEvent = {
            type: ActionTypes.ANSWER_QUESTION, 
            // It's crucial to have timestamp on every event
            timestamp: new Date().toISOString(),
            payload
        };

        // Dispatch the event itself, so our regular reducers that are responsible for analytics can process it and recalculate the statistics
        dispatch(formattedEvent); 
        // Dispatch the action that declares that we want to save this particular event
        dispatch({ type: ActionTypes.SAVE_EVENT, payload: formattedEvent }); 
        // At some point in time we gonna send all saved events to the backend, but let's get to it later
        dispatch(postEventsIfLimitReached()); 
    };
}
 
 
다음은 SAVE_EVENT 타입 액션의 이벤트 핸들러 역할을 하는 리듀서다.
상태를 이용해 액션을 저장한다.
const initialState = [];

export default (state = initialState, action) => {
    switch (action.type) {
        // Append event to our events log
        case ActionTypes.SAVE_EVENT:
            return state.concat(action.payload);

        // After sending events to the backend we can clear them
        case ActionTypes.POST_EVENTS_SUCCESS: 
            return [];

        /* If user wants to clear the client cache and make sure
        that all analytics is backend based, he can press a button,
        which will fire CLEAR_STORAGE action */
        case ActionTypes.CLEAR_STORAGE: 
            return [];

        default:
            return state;
    }
};
 
사용자가 오프라인 상태에서 수행한 모든 액션(이벤트)을 저장했다.
다음 논리적 단계는 이러한 이벤트를 백엔드로 보내는 것이며
이전에 보았던 postEventsIfLimitReached 함수가 이를 수행한다.
 
이벤트를 post하기 전 사용자가 온라인 상태인지 확인해야 한다.
또한 이벤트를 하나씩 보내는 것이 아니라 묶어 보내는 것이 좋다.
HTTP 요청을 자주 실행하지않기 위해서, DB 적재를 자주 실행하지 않기 위해서이다.
export function postEventsIfLimitReached() {
    return async (dispatch, getState) => {
        const events = getState().events;

        /* If user is online perform batch events submission */ 
        if (events.length > config.EVENTS_LIMIT && getState().connection.isConnected) {
            try {
                await api.events.post(events);

                dispatch({ type: ActionTypes.POST_EVENTS_SUCCESS });
            } catch (e) {
                dispatch({ type: ActionTypes.POST_EVENTS_FAIL });
            }
        }
    };
}
 
우리는 모바일 앱에서 낙관적인 업데이트를 사용하고, 사용자의 모든 액션을 백엔드로 전달할 수 있었다.

백엔드 구현

백엔드의 NodeJs 앱에는 이벤트 묶음을 수신하고 MongoDB에 저장하는 POST 경로가 있다.
다음은 몽고디비 스키마다 (몽구스 사용)

const EventSchema = new Schema({
    employeeId : { type: 'ObjectId', required: true },
    type       : {
        type     : String,
        required : true,
        enum     : [
            'TRAINING_HEARTBEAT',
            'ANSWER_QUESTION',
            'START_DISCUSSION',
            'MARK_TOPIC_AS_READ',
            'MARK_COMMENT_AS_READ',
            'LEAVE_COMMENT',
            'START_READING_MODE',
            'FINISH_READING_MODE',
            'START_QUIZ_MODE',
            'FINISH_QUIZ_MODE',
            'OPEN_TRAINING',
            'CLOSE_APP'
        ]
    },
    timestamp   : { type: Date, required: true },
    isProcessed : { type: Boolean, required: true, default: false },
    payload     : { type: Object, default: {} }
});

이제 DB에 모든 이벤트가 있으며 이 이벤트를 어떤 방식으로건 간에 처리해야 한다.
이를 위해 애플리케이션의 시작 파일에서 임포트하여 사용하는 클래스를 만든다.

import EventsHandler from './lib/EventsHandler';

const eventsHandler = new EventsHandler();

eventsHandler.start();
이 클래스의 구현을 살펴보자.
export default class EventsHandler {
    // Initialize an interval
    start() {
        this.planNewRound();
    }

    stop() {
        clearTimeout(this.timeout);
    }

    // 주기적으로 이벤트를 배치 처리한다
    planNewRound() {
        this.timeout = setTimeout(async () => {
            await this.main();

            this.planNewRound();
        }, config.eventsProcessingInterval);
    }

    async main() {
        const events = await this.fetchEvents();

        await this.processEvents(events);
    }

    async processEvents(events) {
        const metrics = await this.fetchMetricsForEvents(events);

        /* Here we should process events somehow. 
        But HOW??? 
        We'll get back to it later
        */

        /* It's critical to mark events as read after processing, 
        so we don't fetch and apply the same events every time */
        await Promise.all(events.map(this.markEventAsProcessed));
    }

    async markEventAsProcessed(event) {
        event.set({ isProcessed: true });

        return event.save();
    }

    async fetchMetricsForEvents(events) {
      /* I removed a lot of domain-related code from this method, for the sake
      of simplicity. What we are doing here is accumulating ids of Metrics related to every event from argument events. 
      That's how we got metricsIds  */

      return Metric.find({ _id: { $in: metricsIds } });
    }

    async fetchEvents() {
        return Event.find({ isProcessed: false }).limit(config.eventsFetchingLimit);
    }
}

 

이벤트 처리에만 집중할 수 있도록 많은 로직을 제거하였다.

이 클래스로 수행하는 핵심 작업은 다음과 같다.

  1. 처리되지 않은 이벤트를 DB에서 전부 불러온다.
  2. 처리에 필요한 이벤트 데이터와 관련된 모든 항목을 불러온다.
    1. 개인(Session Metric) 및 그룹(Training Metric) 통계를 나타내는 대부분의 Metrics 도큐먼트다
  3. 어떤 방식으로 이벤트를 처리하고 메트릭을 변경한다. (processEvent 의 주석 영역)
  4. 변경된 데이터를 저장한다.
  5. 이벤트를 처리 완료 상태로 표시한다.

보다 강력한 솔루션을 원하는 경우 다음 사항을 고려한다.

  • 주기적 배치 처리는 특정 시점에는 일부 이벤트를 전달하였어도, 아직 처리되지 않았을 수 있음을 의미한다.
  • MongoDB대신 카프카 사용도 가능하다.
  • 이벤트 간 우선순위 지정이 필요할 수도 있다.
  • 이벤트는 표준 Mongo의 ObjectId를 사용하지만, 단순히 증분 식별자를 사용하는 것도 괜찮다.
    • 무슨 일이 일어나고 있는지, 이벤트의 올바른 순서가 무엇인지 더 쉽게 파악할 수 있다.

이제 이벤트 처리 방법에 대해 알아보자.


이벤트 처리의 어려움

이제 이벤트의 구조는 준비되었다.

하지만 이제 가장 복잡한 질문인 이벤트 처리 방법이 남아있다.

가장 큰 문제는 클라이언트의 낙관적 업데이트와 서버에서 계산한 결과와의 차이다.
 
사용자가 오프라인 모드에서 모든 테스트의 81%를 완료하였다.
온라인에 접속하니 갑자기 진행률이 78%가 되고 남은 시간이 줄어든다.
이것은 UX 관점에서 매우 고약한 버그다.
 
클라이언트와 서버에서 데이터를 동기화하는 것은 중요한 문제다.
보통 QA가 버그를 발견하면, 클라이언트와 백엔드에서 개별적으로 버그를 수정하고
수정 결과가 두 플랫폼에서 동일한 결과를 생성하는지 확인해야 한다.
 
훨씬 더 일반적인 경우는, 비즈니스 요구 사항이 변경되어,
두 플랫폼 모두에서 이벤트 처리 로직을 다시 작성해야 하는 것이다.
다시 한번 수학적인 결과를 얻지 못할 가능성이 있다.
 
이벤트 처리의 어려움

코드 재사용이 해법이다

리덕스의 리듀서를 재사용하면 어떨까?

처음에는 이상하게 들릴 수 있다.

Redux는 클라이언트에서나 쓰는 것이기 떄문이다.

 

Reducer는 액션과 초기 상태를 파라미터로 받아 새로운 상태를 반환하는 순수 함수다.

프론트엔드 개발자를 위해서는 보통 Redux의 스토어가 Reducer의 호출을 대신해 준다는 점이 특별할 뿐이다.

이 Reducer를 직접 가져다 쓰면 Redux가 없어도 된다.

 

하지만 아래 두 가지 사항을 명심한다.

  • 리듀서에는 부작용이 없어야 한다.
    • 부작용은 별도로 보관한다.
  • 상태와 이벤트는 불변이다.
이전에 본 processEvents 함수의 정식 버전은 다음과 같다. (이벤트 드리븐 아키텍처)

1. 먼저 events 배열의 각 이벤트의 관심 대상인 metric을 id를 이용해 어디선가 가져온다.

2. 각 metric에 모든 이벤트를 reducer를 이용해 적용한다.

  • 해당 이벤트 중 해당 metric에 영향을 미치는 이벤트만 상태를 변경할 것이다.
  • 이벤트는 순서대로 줄세워져 있고, 여러 동일한 타입의 이벤트가 여러번 같은 metric에 영향을 미칠 수 있다.
async processEvents(events) {
    const metrics = await this.fetchMetricsForEvents(events);

    await Promise.all(metrics.map(async (metric) => {
        /* sessionMetricReducer and trainingMetricReducer are just functions that are imported from a separate repository,
        and are reused on the client */
        const reducer = metric.type === 'SESSION_METRIC' ? sessionMetricReducer : trainingMetricReducer;

        try {
            /* Beatiful, huh? :)
            Just a line which reduces hundreds or thousands of events
            to a one aggregate Metric (say analytical report)
            */
            const newPayload = events.reduce(reducer, metric.payload);
        
            /* If an event should not reflect the metric in any ways
            we just return an initial state in our reducers, so we can
            have this convenient comparison by link here  */
            if (newPayload !== metric.payload) {
                metric.set({ payload: newPayload });
        
                return metric.save();
            }
        } catch (e) {
            console.error('Error during events reducing', e);
        }

        return Promise.resolve();
    }));

    await Promise.all(events.map(this.markEventAsProcessed));
}
  • sessionMetricReducer는 개별 사용자의 통계를 계산하는 함수다.
  • trainingMetricReducer는 그룹 통계를 계산하는 함수다.

둘다 순수함수다.

우리는 그 둘 함수를 별도의 레포지토리에 저장한 뒤 머리부터 발끝까지 단위 테스트를 적용한다.

뿐만 아니라 해당 리듀서를 클라이언트에서도 사용한다.

핵심은 const newPayload = events.reduce(reducer, metric.payload) 부분이다.

metric.payload는 초기 상태를 의미한다.

초기 상태에 events 배열의  event를 적용한다.

즉, 앱의 데이터를 redux의 state처럼 모델링 할 수 있으면, event 배열을 이용해 직접 reducer를 호출하는 것이 가능하다.

 

개략적인 앱의 구조

 

 

이벤트가 유효하지 않을 경우 문제가 발생할 수도 있다.
이 경우 이벤트를 한건 한건 처리하여 이벤트가 유효한 경우마다 저장을 수행할 수 있다. (for of loop)
for (const event of events) {
  try {
    // Pay attention to the params order.
    // This way we should first pass the state and after that the event itself
    const newPayload = reducer(metric.payload, event);

    if (newPayload !== metric.payload) {
      metric.set({ payload: newPayload });

      await metric.save();
    }
  } catch (e) {
    console.error('Error during events reducing', e);
  }
}
 

리듀서 구현 구경하기

가장 중요한 문제는 Metric.payload를 백엔드에 유지하고
클라이언트와 비슷한 구조로 사용자 통계를 나타내는 state를 사용하는 것이다.
코드를 재사용하려면 이것이 유일한 방법이다.
이벤트는 이미 동일하다.
프론트엔드에서 이벤트를 생성하고 클라이언트 리듀서를 통해 통해 전달한 다음 서버로 보낸다.
 
이벤트가 동일하고, 서버에서 클라이언트와 유사한 상태 구조를 유지하는 한 리듀서를 자유롭게 공유할 수 있다.
import moment from 'moment';

/* All are pure function, that responsible for a separate parts
of Metric indicators (say separate branches of state) */
import { spentTimeReducer, calculateTimeToFinish } from './shared';
import {
    questionsPassingRatesReducer,
    calculateAdoptionLevel,
    adoptionBurndownReducer,
    visitsReducer
} from './session';

const initialState = {
    timestampOfLastAppliedEvent : '',
    requiredTimeToFinish        : 0,
    adoptionLevel               : 0,
    adoptionBurndown            : [],
    visits                      : [],
    spentTime                   : [],
    questionsPassingRates       : []
};

export default function sessionMetricReducer(state = initialState, event) {
    const currentEventDate = moment(event.timestamp);
    const lastAppliedEventDate = moment(state.timestampOfLastAppliedEvent);

    // Pay attention here, we'll discuss this line below
    if (currentEventDate.isBefore(lastAppliedEventDate)) return state;

    switch (event.type) {
        case 'ANSWER_QUESTION': {
            const questionsPassingRates = questionsPassingRatesReducer(
                state.questionsPassingRates,
                event
            );

            const adoptionLevel = calculateAdoptionLevel(questionsPassingRates);
            const adoptionBurndown = adoptionBurndownReducer(state.adoptionBurndown, event, adoptionLevel);
            const requiredTimeToFinish = calculateTimeToFinish(state.spentTime, adoptionLevel);

            return {
                ...state,
                adoptionLevel,
                adoptionBurndown,
                requiredTimeToFinish,
                questionsPassingRates,
                timestampOfLastAppliedEvent : event.timestamp
            };
        }

        case 'TRAINING_HEARTBEAT': {
            const spentTime = spentTimeReducer(state.spentTime, event);

            return {
                ...state,
                spentTime,
                timestampOfLastAppliedEvent : event.timestamp
            };
        }

        case 'OPEN_TRAINING': {
            const visits = visitsReducer(state.visits, event);

            return {
                ...state,
                visits,
                timestampOfLastAppliedEvent : event.timestamp
            };
        }

        default: {
            return state;
        }
    }
}​
  • 함수 시작 부분에서 다음 이벤트가 적용되었을 때, 이전 이벤트가 적용되지 않도록 한다.
  • 이벤트를 불변의 단일 진실 원천으로 사용하기 위해, 추가 정보가 필요하면 상태를 이용한다.

최종 개요

이것이 최종 프로젝트 구조이다.

공통 리듀서를 별도의 레포지토리에 보관하고 서버와 클라이언트에서 재사용한다.

이를 통해 얻은 효과는 다음과 같다.

  • 모바일 오프라인 모드
  • 웹과 모바일 분석 정보 동기화
  • 리듀서는 유지보수 및 확장이 쉽다.
    • 테스트와 개발은 한곳에서만 하면 된다.
  • 계산에서 버그를 발견해도 이벤트를 이용해 처음부터 다시 계산할 수 있다.
  • 타임 트레블링
  • 비즈니스 요구 사항이 변경될 때마다 새로운 메트릭을 쉽게 만들고 계산할 수 있다.
  • 이벤트 처리가 사용자의 주요 앱 작업 ​​흐름과 독립적으로 백그라운드에서 실행될 수 있으므로 애플리케이션의 성능과 응답성을 크게 향상시킬 수 있다.
  • 모든 이벤트를 활용 가능하다는 것은, 시스템을 테스트하고 디버그하는 데 크게 도움이 된다.
    • 언제든지 이벤트를 쉽게 재생하고 시스템이 어떻게 반응하는지 관찰할 수 있다.
  • 분석 정보 계산에 대한 더 많은 제어권.
    • 모바일에서는 실시간 개별 통계
    • 웹에서는 통합 집계
  • 이벤트는 사용자 활동 분석의 매우 좋은 원천이다. 유용한 비즈니스 정보를 많이 얻을 수 있다.
  • 즉시 사용 가능한 완전한 로그(히스토리) 및 감사 추적(audit trail)

사실 이러한 접근 방식은 스트림을 선호한다.
스트림은 애플리케이션의 데이터를 흐름으로 생각하는 방식이다.
이벤트의 무한한 대기열에 애플리케이션이 어떻게 반응하는가를 생각한다. (기존의 요청, 응답 모델과 다르게)

그리고 이 접근 방식은 미래의 일부 문제를 해결하기 더 쉽게 만들 수 있다.
예를 들어 웹 앱의 분석을 실시간으로 업데이트하기 위해
  • 클라이언트에서 데이터 변경 사항을 구독하고 (ex 웹소켓),
  • 백엔드에서 데이터가 변경될 때마다 클라이언트에 특정 타입의 메세지를 보내는 것이다.

결론

이는 일반적인 이벤트 소싱 구현과 상당히 다르다.
또한 Redux를 NodeJS 애플리케이션과 통합하지도 않았다.
 
주요 목적은 유연하고 확장된 비즈니스 논리와 공유 가능한 분석 정보를 사용하여,
안정적인 오프라인 퍼스트 모바일 애플리케이션을 만드는 것이었다.
그리고 이 목적은 위의 두 가지 없이도 reducer를 활용해 달성할 수 있었다.

부록 : 오프라인 모드의 문제 해결하기

문제 1. 클라이언트가 백엔드에서 계산된 최신 메트릭을 사용하도록 강제하는 방법은 무엇인가요?

각 메트릭(일반적인 캐시 무효화 전략에 익숙한 사용자를 위한 일종의 E-TAG)에 버전 속성을 추가한다.
클라이언트가 서버에서 메트릭을 가져오면
클라이언트 버전과 서버 버전을 비교하여 더 높은 버전을 사용한다.
버그 수정 후 우리가 해야 할 일은 버전 번호를 높이는 것이다.
클라이언트는 버전 정보를 비교하고 최산 데이터를 사용한다.

 

문제 2. 오프라인에서 엔터티를 만들고 해당 ID를 추가로 사용해야 합니다. 

uuid를 사용하여 클라이언트에서 id를 수동으로 생성하는 간단하지만 완벽하지는 않은 솔루션을 사용할 수 있다.
이 id를 사용하여 엔티티를 DB에 저장한다.
그러나 DB를 변경하거나 uuidV4에서 uuidV5로 마이그레이션하는 경우를 대비하여
백엔드에서 이러한 종류의 데이터를 제어하는 ​​것이 낫다.
클라이언트 ID를 임시 ID로 사용하고 백엔드에서 다시 만들 수 있다.
 

문제 3. RN에서 데이터 영속을 위해 무엇을 사용하나요?

암호화를 통해 데이터 보안을 제공한다면, 직접 구현하는게 편하다.
초기 앱 상태의 비동기 로딩을 위해 redux-async-initial-state를 사용한다.
import { createStore, applyMiddleware, combineReducers, compose } from 'redux';
import thunkMiddleware from 'redux-thunk';
import * as asyncInitialState from 'redux-async-initial-state';

import { setAutoPersistingOfState, getPersistedState } from '../utils/offlineWorkUtils';
import rootReducer from '../reducers';

const reducer = asyncInitialState.outerReducer(combineReducers({
    ...rootReducer,
    asyncInitialState: asyncInitialState.innerReducer
}));

const store = createStore(
    reducer,
    compose(applyMiddleware(thunkMiddleware, asyncInitialState.middleware(getPersistedState)))
);

setTimeout(() => {
    setAutoPersistingOfState(store);
}, config.stateRehydrationTime);

export default store;

offlineWorkUtils의 모듈은 다음과 같다.

export function getPersistedState() {
    return new Promise(async (resolve) => {
        // A wrapping function around AsyncStorage.getItem()
        const encodedState = await getFromAsyncStorage(config.persistedStateKey);

        if (!encodedState) resolve(undefined);

        // A wrapping function around CryptoJS.AES.decrypt()
        const decodedState = decryptAesData(encodedState);

        resolve(decodedState);
    });
}

export async function setAutoPersistingOfState(store) {
    setInterval(async () => {
        const state = store.getState();

        if (!state || !Object.keys(state).length) return;

        try {
            // A wrapping function around CryptoJS.AES.encrypt()
            const encryptedStateInfo = encryptDataWithAes(state).toString();

            // A wrapping function around AsyncStorage.setItem()
            await saveToAsyncStorage(config.persistedStateKey, encryptedStateInfo);
        } catch (e) {
            console.error('Error during state encryption', e);
        }
    }, config.statePersistingDebounceTime);
}
큰 blob(이미지, 오디오 등)의 경우 react-native-fs를 사용한다.




반응형