서버 상태 변경 반영하기: Polling, Long Polling, React Query

김채은·2023년 11월 15일
17
post-thumbnail

들어가며

지난 포스트에 이어 당근 윈터테크 인턴십 두 번째 사전 질문에도 답해보려고한다. 질문을 읽자마자 React Query로 개발했던 경험이 떠올랐는데, 만약 React Query가 없다면 어떻게 구현할 수 있지? 라는 고민을 시작으로 공부한 내용을 담았다. 이후 React Query 라이브러리 코드를 간단하게 분석해보고, 어떻게 "잘" 사용할 수 있을지 의견을 제시하며 마무리한다.

      1. 추가
        폴링, 롱 폴링으로의 구현은 내가 아닌 다른 사람이 서버 상태를 변경했을 때도 반영 가능하고, React Query는 내가 변경했을 때 반영할 수 있는 방법이다.

게시글 상세에서 좋아요를 눌렀을 때 피드에도 반영되게 하려면 무엇을 고려해야 하고 어떻게 개발할 수 있을까요?

서버 데이터가 변경되었을 때 해당 데이터를 사용하는 클라이언트 상태가 변경돼야 한다.

먼저, 특정 라이브러리 없이 자바스크립트로 이를 구현할 수 있는 방법을 조사했다. 다양한 방법이 있었지만 일반적으로 이 두 가지가 언급되었다.

폴링(Polling)

클라이언트가 특정 주기를 가지고 서버에 HTTP 요청을 한다.

롱 폴링(Long Polling)

  1. 클라이언트에서 서버로 HTTP 요청을 보낸다.
  2. 서버 이벤트가 없는 경우 연결을 유지한다.
  3. 이벤트가 있거나, 타임아웃이 발생하면 클라이언트로 응답하고 연결을 종료한다.
  4. 응답을 수신한 클라이언트가 곧바로 HTTP 요청을 보낸다.

JavaScript로 폴링 구현하기

흐름을 이해하는 데에 좋은 코드가 있어 사용 허락을 받고 가져왔다.

1. setInterval API 활용


function startPolling(callApiFn, testFn, doFn, time) {
  let intervalId = setInterval(() => {                        // [1]
    callApiFn()                                               // [2]
      .then((data) => {
        if (intervalId && testFn(data)) {                     // [3]
          stopPolling();
          doFn(data);
        }
      })
      .catch((e) => {                                         // [4]
        stopPolling();
        throw new Error("Polling cancelled due to API error");
      });
  }, time);

  function stopPolling() {                                    // [5]
    if (intervalId) {
      console.log(new Date(), "Stopping polling...");
      clearInterval(intervalId);
      intervalId = null;
    } else {
      console.log(new Date(), "Polling was already stopped...");
    }
  }

  return stopPolling;                                         // [6]
}
  1. setInterval()으로 주기적으로 API를 호출하고 결과를 체크하는 루프를 생성한다. 폴링이 취소되면 intervalIdnull이 된다.
  2. API를 호출한다.
  3. 결과가 도착했을 때 폴링이 유지되는 중이고, 테스트를 통과했다면 폴링을 중단하고 doFn()을 수행한다.
  4. API 에러가 있다면 폴링을 중단하고 예외를 throw한다.
  5. 폴링을 중단시키려면 clearInterval()을 사용한다. 1번에서 말했듯 intervalIdnull로 만든다.
  6. stopPolling 함수를 리턴한다.

해당 코드는 폴링 함수 안에 doFn이 묶여있기 때문에 유연성이 떨어진다고 생각했다.

2. Promise와 setTimeout API 활용

function startPolling(callApiFn, testFn, time) {
  let polling = false;                                        // [1]
  let rejectThis = null;

  const stopPolling = () => {                                 // [2]
    if (!polling) {
      console.log(new Date(), "Polling was already stopped...");
    } else {
      console.log(new Date(), "Stopping polling...");         // [3]
      polling = false;
      rejectThis(new Error("Polling cancelled"));
    }
  };
  
  const promise = new Promise((resolve, reject) => {          
    polling = true;                                           // [4]
    rejectThis = reject;         							  // [5]
    
    const executePoll = async () => {                         // [6]
      try {
        const result = await callApiFn();                     // [7]
        if (polling && testFn(result)) {                      // [8]
          polling = false;
          resolve(result);
        } else {                                              // [9]
          setTimeout(executePoll, time);
        }
      } catch (error) {                                       // [10]
        polling = false;
        reject(new Error("Polling cancelled due to API error"));
      }
    };
    
    setTimeout(executePoll, time);                            // [11]
  });

  return { promise, stopPolling };                            // [12]
}
  1. Promise를 반환할 것이기 때문에, doFn()을 인자로 넘기지 않는다. polling 변수는 폴링이 유지될 때 true를 가지고, rejectThis 변수는 Promise로 부터 reject 함수를 저장한다(4, 5번 참고).
  2. stopPolling() 함수는 폴링이 실행 중이면 중단한다.
  3. 폴링을 중단하기 위해, pollingfalse로 하고, 저장된 rejectThis 함수로 Promise를 즉시 reject한다.
  4. 새 Promise를 생성한다. pollingtrue로 세팅된다.
  5. stopPolling에서 Promise를 강제로 reject할 수 있도록, reject 파라미터를 rejectThis에 담는다(3번 참고).
  6. excutePoll은 폴링과 테스팅을 한다.
  7. API를 호출한다.
  8. 폴링이 유지되고 테스트가 통과되면, 폴링을 중단하고 Promise를 resolve한다.
  9. 테스트가 통과하지 못하면, time의 지연 이후 새로운 폴링을 세팅한다.
  10. 에러가 있다면, 폴링을 중단하고 Promise를 reject한다.
  11. 처음 폴링은 time의 지연 이후 executePoll로 인해 시작된다.
  12. 해당 함수는 promisestopPolling을 리턴한다.

위 코드는 Promise를 리턴하기 때문에 API 응답 성공 이후 행위에 대해 보다 자유로운 핸들링이 가능하다.

아래는 사용하는 코드이다.

console.log(new Date(), "Starting polling");
const { promise, stopPolling } = startPolling(callFakeApi, testCondition, 1000);
promise.then(doSomething).catch(() => { /* do something on error */ });
await timeout(6300);
console.log(new Date(), "Canceling polling");
stopPolling();

폴링의 단점

  • 인터벌이 너무 짧으면 서버 부하가 발생할 수 있다.
  • 인터벌이 너무 길면 최신 데이터를 유지할 수 없다.

이러한 단점을 극복하기 위해 롱 폴링 기법이 생긴 것이라고 한다.

롱 폴링 구현

폴링 함수를 참고하여 롱 폴링을 구현해보았다. 롱 폴링은 서버 측에서 이벤트 또는 타임아웃이 발생할 때까지 연결을 유지해주어야 하기 때문에, 서버 측 구현이 더 복잡하다.

사실 나는 서버 개발을 해본 적이 거의 없다. 하지만 Observer 개념을 이용해서 간단히 코드를 작성해보았다.

먼저 register, unregister, notify 기능을 가진 Observer 클래스를 작성했다.

Observer 객체를 통한 서버 구현

class DataObserver {
  constructor() {
    this.subscribers = [];
  }

  register(subscriber) {
    this.subscribers.push(subscriber);
  }

  unregister(unsubscriber) {
    this.subscribers = this.subscribers.filter(subscriber => subscriber !== unsubscriber);
  }

  notify(data) {
    this.subscribers.forEach(subscriber => subscriber(data));
  }
}

다음은 이 객체를 이용해서 서버 이벤트가 발생했을 때, 또는 타임아웃이 발생했을 때 응답을 주는 서버를 구현해보았다.

const express = require('express');
const app = express();
const PORT = 3000;

let data = 'Initial data';						// [1]

app.use(express.json());

const dataObserver = new DataObserver();

app.get('/posts', (req, res) => {
  const onDataUpdate = (data) => {				// [2]
    res.json({ data });
    dataObserver.unsubscribe(onDataUpdate);
  };
  
  const timeoutId = setTimeout(()=>{			// [3]
    res.json({ data });
    dataObserver.unsubscribe(onDataUpdate);
  }, 30000);

  dataObserver.subscribe(onDataUpdate);			// [4]
});

app.post('/posts/:id/data', (req, res) => {
  data = req.body.data;
  dataObserver.notify(data);					// [5]
  res.json({ success: true });
});

app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});
  1. 데이터는 간단하게 변수 하나로 두었다.
  2. 클라이언트에 데이터를 응답해주고 observerunsubscribe하는 함수이다.
  3. 조회 요청이 들어왔을 때 timeout을 세팅해주는 함수이다.
  4. 조회 요청이 들어왔을 때 observersubscribe하는 함수이다.
  5. 수정 요청이 들어왔을 때 데이터를 수정하고 observernotify하는 함수이다.

재귀 함수를 통한 클라이언트 구현

function startLongPolling(callApiFn, testFn, doFn) {
  let polling = false;              

  const stopLongPolling = () => {                         
    if (!polling) {
      console.log(new Date(), "Polling was already stopped...");
    } else {
      console.log(new Date(), "Stopping polling...");        
      polling = false;
    }
  };
  
 const executeLongPolling = async () => {
 	try {
    	const result = await callApiFn();

		if (polling){
			if (testFn(result)){
				const data = await result.json();
              	doFn(data);									// [1]
        	}
          
        	executeLongPolling();							// [2]
        }
    } catch (error){
		console.log("Long Polling Error");

      	executeLongPolling();								// [3]
    }
 }
 
  executeLongPolling();

  return { stopLongPolling };                            
}
  1. 새로운 데이터가 응답되면 doFn()을 실행한다.
  2. 폴링이 강제 중단되지 않으면 롱 폴링을 재시작한다.
  3. 에러가 감지된 경우 롱 폴링을 재시작한다.

정리하자면 이러한 플로우의 코드이다.

롱 폴링 단점

  • 한 번에 대량의 요청이 발생하는 경우 서버 부하가 발생할 수 있다.

유의할 점

import React, { useEffect } from 'react';

function Component() {
  useEffect(() => {
    const { stopPolling } = startPolling(
      callApiFn, 
      testFn, 
      doFn
    );

    return () => {
      stopPolling();
    };
  }, []);

}

폴링, 롱 폴링을 위와 같은 방식으로 구현한다면 반드시 언마운트 시 폴링을 멈춰줘야 한다. 아니면 setInterval(), setTimeout(), 재귀 함수 등이 해제되지 않아 메모리 문제가 발생할 수 있다.

갱신해야 되는 상태를 클라이언트에서 알려주자

폴링, 롱 폴링에서 발생하는 부하 문제를 해결하려면 갱신해야 되는 데이터를 클라이언트에서 알려주어, 해당 데이터만 리패치하는 방법을 사용할 수 있다.

React Query의 invalidateQueries나, SWC의 mutate가 사용하는 방식이다. 나에게는 React Query가 조금 더 익숙해서, 이 라이브러리가 어떻게 리패치할 데이터를 알려주는지 간단히 알아보았다.

나는 invalidateQuries 함수에 대해서만 분석할 것인데, React Query의 전체적인 동작원리에 대해 자세히 분석해둔 좋은 아티클이 있어 첨부한다.

invalidateQueries 사용법

// [1]
queryClient.invalidateQueries()

// [2]
queryClient.invalidateQueries({ queryKey: ['todos'] })

// [3]
queryClient.invalidateQueries({
  predicate: (query) => 
  	query.queryKey[0] === 'todos' && query.queryKey[1]?.version >= 10
})
  1. 캐시에 있는 모든 쿼리를 무효화한다.
  2. 'todos'로 시작하는 쿼리 키를 가진 모든 쿼리를 무효화한다.
  3. 첫 번째 쿼리 키로 'todos', 두 번째 쿼리 키의 version 프로퍼티가 10 이상인 모든 쿼리를 무효화한다.

invalidateQueries()

쿼리 캐시에서 일치하는 키를 가진 모든 캐시를 무효화하고, refetch 한다.

invalidateQueries(
    filters: InvalidateQueryFilters = {},
    options: InvalidateOptions = {},
  ): Promise<void> {
    return notifyManager.batch(() => {
      this.#queryCache.findAll(filters).forEach((query) => { // [1]
        query.invalidate() 									 // [2]
      })

      if (filters.refetchType === 'none') { 				 // [3]
        return Promise.resolve()
      }
      const refetchFilters: RefetchQueryFilters = {
        ...filters,
        type: filters.refetchType ?? filters.type ?? 'active',
      }
      return this.refetchQueries(refetchFilters, options) // [4]
    })
  }
  1. findAll()으로 queryClient에서 인자로 넣어준 필터와 일치하는 쿼리 캐시를 찾는다.
  2. 가져온 쿼리를 무효화한다.
  3. refetchType'none'이면 그대로 Promise를 resolve한다.
  4. 그렇지 않다면 refetchQueries()를 수행한다.

findAll()

쿼리 캐시에서 일치하는 쿼리를 모두 반환하는 함수이다.

export class QueryCache extends Subscribable<QueryCacheListener> {
  #queries: QueryStore

  constructor(public config: QueryCacheConfig = {}) {
    super()
    this.#queries = new Map<string, Query>()
  }
  getAll(): Array<Query> {
    return [...this.#queries.values()]
  }

  findAll(filters: QueryFilters = {}): Array<Query> {
    const queries = this.getAll()
    return Object.keys(filters).length > 0
      ? queries.filter((query) => matchQuery(filters, query))
      : queries
  }
}

filters가 있다면, matchQuery()를 통해 일치하는 쿼리를 가져온다. 없다면, 모든 쿼리를 가져온다.

matchQuery()

쿼리 키 혹은 다른 인자값을 통해 쿼리의 일치 여부를 반환하는 함수이다.

export function matchQuery(
  filters: QueryFilters,
  query: Query<any, any, any, any>,
): boolean {
  const {
    exact,
    predicate,
    queryKey,
  } = filters

  // [1]
  if (queryKey) {
    if (exact) {
      if (query.queryHash !== hashQueryKeyByOptions(queryKey, query.options)) {
        return false
      }
    } else if (!partialMatchKey(query.queryKey, queryKey)) {
      return false
    }
  }

  // [2]
  if (predicate && !predicate(query)) {
    return false
  }

  return true
}
  1. 쿼리 키를 사용한다면
  • 정확히 일치하는 값을 찾는다면
    - hashQueryKeyByOptions()를 통해 해시값이 일치하는지 확인한다.
  • 일부만 일치하는 값을 찾는다면
    - partialMatchKey()를 통해 일부가 일치하는지 확인한다.
  1. predicate를 사용한다면
  • 콜백 함수를 통해 일치하는지 확인한다.

refetch 함수까지 알아보기에는 시간적 제약이 있어서, 이 정도로 간단히 분석을 마쳤다.
React Query의 구현은 복잡하지만, invalidateQueries 함수에서 복잡한 패턴을 사용하지는 않는다. 넣어준 인자를 통해 일치하는 쿼리를 찾아서 무효화해준 뒤, refetch 하여 새로운 데이터를 불러오는 구조이다.

어떻게 잘 쓸까?

invalidateQueries()는 주로 useMutation과 함께 쓴다. Mutation이 성공했을 때 여러 쿼리가 refetch 돼야 하는 경우를 생각해보자.

const {...} = useMutation("...", {
	onSuccess: () =>{
    	queryClient.invalidateQueries("Query Key 1");
      	queryClient.invalidateQueries("Query Key 2");
      	queryClient.invalidateQueries("Query Key 3");
    }
})

무효화 시켜야 할 쿼리들이 해당 뮤테이션에 의존하게 되고, 비즈니스 요구사항이 변경되면 관련된 컴포넌트를 일일히 찾아서 관리해주어야 한다.

그럼 이제 쿼리 키를 어떻게 관리할 수 있을까? 라는 고민을 하게 된다.

쿼리 키 구조화

위 아티클을 참고하여 scope-entity-id라는 구조로 쿼리 키를 관리해보았다. 게시글이라는 도메인에서는 다음과 같이 작성해볼 수 있다.


* 그림에서 게시글 댓글 조회의 Unique Id가 누락되었습니다. 참고해주세요.

import { useQueryClient } from "@tanstack/react-query";

interface QueryKey{
    scope: string;
    entity?: string;
    id?: number;
}

const queryClient = useQueryClient();

const postKeys = {
  base: { scope: "posts" },
  getPosts: () => [{ ...postKeys.base }],
  getPost: (id: number) => [{ ...postKeys.base, id }],
  getComments: (id: number) => [{ ...postKeys.base, entity: "getComments", id }],
};

Refetch Dependency

그리고 데이터 수정에 따라 리패치하고 싶은 쿼리를 관리하기 위해 Dependencies라는 이름의 객체를 도입해보았다.
수정 요청에 따라 리패치하고 싶은 쿼리의 키를 배열로 넣어준 단순한 객체이다.

const postDependencies = {
  base: [postKeys.getPosts()],
  like: (id: number) =>
    [...postDependencies.base, postKeys.getPost(id)],
  post: () => [...postDependencies.base],
  comment: (id: number) => [...postDependencies.base, postKeys.getComments(id)]
};

const invalidate = (dependencies: Array<Array<QueryKey>>) => {
  dependencies.forEach((queryKey) => {
    queryClient.invalidateQueries({ queryKey, exact: true });
  });
};

다음과 같이 사용할 수 있다.

import { postDependencies } from '...';

const {...} = useMutation("...", {
	onSuccess: () =>{
		invalidate(postDependencies.like(id))
    }
})

쿼리 키 구조화와 쿼리 무효화 dependency 관리 대해서 고민을 충분히 하지 못해서 퀄리티가 떨어진다. 이 부분에 대해서는 지속적으로 개선점을 찾아가야 할 것 같다.

마치며

폴링과 롱 폴링을 코드로 구현하면서 이해하니까 이론으로만 접하는 것보다 훨씬 와닿았다. JavaScript로 A to Z까지 프레임워크를 구축하고 해당 코드를 직접 붙여보면 재밌겠다는 생각이 들었다.

이 아티클을 작성하면서 오랜만에 다른 사람이 작성한 코드를 심도 있게 읽고 분석했는데, 정말 만만치 않은 일이라는 것을 다시금 느꼈다. 아직도 React Query의 전반적인 동작원리에 대해서는 아리송하다. 참고했던 아티클을 몇 번 더 읽어봐야겠다.

그리고 나는 두 가지 사전 질문에 대해 이렇게 답해보았는데, 다른 사람들은 어떤 답변을 했는지 궁금하다. 정답이 있는 문제가 아니기 때문에 생각을 공유하면서 디벨롭해나가면 좋을 것 같다.

해당 포스트에 대한 의견이나 조언이 있으시면 댓글로 나누어주세요! 감사합니다.

profile
배워서 남주는 개발자 김채은입니다 ( •̀ .̫ •́ )✧

2개의 댓글

comment-user-thumbnail
2023년 11월 23일

좋은 글 잘 읽었습니다!
저는 abortController를 이용해서 구현했었는데, 관심있으시면 알아보세요 :)

1개의 답글