По мере увеличения масштаба проекта становится все более важным эффективно обрабатывать управление состоянием и асинхронные запросы, чем просто реализовать функции. Особенно в приложениях на базе React несколько компонентов разделяют одни и те же данные и вызывают API в зависимости от различных изменений состояния, и по мере усложнения такой структуры могут возникнуть неожиданные проблемы с дублирующимися запросами.
В этом проекте после входа в систему информация о пользователе, информация о правах, доступные рабочие данные и т. д. хранились в глобальном контексте для общего использования на нескольких экранах. Изначально это работало нормально, но по мере добавления новых функций один и тот же API начал вызываться несколько раз, или ответы предыдущих запросов перезаписывали самые свежие данные.
Сначала я думал, что проблема в скорости ответа сервера. Однако, проанализировав фактическую причину, я обнаружил, что проблема заключалась в структуре внутреннего управления состоянием фронтенда и способах обработки асинхронных запросов.
В этой статье я хочу поделиться опытом о том, как возникла проблема с дублирующимися API запросами, процессе анализа причин и о том, как я решил проблему, используя AbortController и идентификатор запроса (runId).
1. Проблемная ситуация
В проекте был процесс запроса данных, связанных с пользователем, после входа в систему.
При первоначальной загрузке также запрашивались следующие данные.
1. Основная информация о пользователе
2. Информация о правах пользователя
3. Данные о рабочих процессах, доступных пользователю
4. Связанная информация о проекте
Запрашиваемая информация сохранялась в глобальном контексте и использовалась на нескольких экранах.
Общий поток выглядел следующим образом.
Вход в систему Завершено -> Пользователь Информация Запрос -> Разрешение Информация Запрос -> Проект Информация Запрос -> Сохранение контекста -> Экран Отрисовка
Сначала не было обнаружено никаких проблем.
Однако по мере роста проекта объем данных, сохраняемых в контексте, увеличивался, и количество компонентов, использующих их, также возросло.
В частности, в процессе изменения состояния аутентификации, состояния разрешений и состояния контекста возникла проблема с повторным выполнением одной и той же логики запроса.
Например, сразу после входа пользователя в систему может произойти следующий поток.
Аутентификация Статус Изменение -> Пользователь Информация Запрос -> Обновление контекста -> Подписка на контекст Компонент Переотрисовка -> Добавить Запрос Запуск
На самом деле, проверив вкладку Network, я увидел, что один и тот же API вызывается несколько раз за короткое время.
В среде разработки это особо не ощущалось, но в рабочей среде это могло привести к увеличению нагрузки на сервер и неэффективному использованию сетевых ресурсов.
2. Анализ причин
Чтобы решить проблему, сначала я отслеживал поток запросов.
В результате анализа с использованием React DevTools и вкладки Network проблема оказалась внутри фронтенда.
Проблема заключалась в сложной зависимости между состояниями.
Аутентификация Состояние Изменение -> Пользователь Информация Запрос -> Сохранение контекста -> Значение контекста Изменение -> Ререндеринг потребителя -> Другие вызов useEffect -> добавить вызов API
изменение одного состояния может вызвать изменение другого состояния, в результате чего несколько useEffect могут быть исполнены последовательно.
проблема заключается в том, что такой поток становится более сложным по мере увеличения масштабов проекта.
на практике мы могли наблюдать, что один и тот же API вызывался несколько раз с очень небольшими изменениями.
это дало нам понять, что просто работоспособность функции и её эффективная работа в реальной операционной среде - это совершенно разные проблемы.
3. Первоначальный обзор: возможность применения Debounce
первым рассмотренным методом после выявления проблемы дублирования вызовов был Debounce.
Debounce - это техника, которая выполняет функцию только если в течение определённого времени не произошло дополнительных событий.
обычно используется для оптимизации строки поиска.
const debouncedValue = useDebounce(value, 300);
первоначально мы рассматривали возможность реализации вызова API с задержкой после изменения состояния.
на практике Debounce оказывается очень эффективным методом для случаев, когда события ввода пользователя происходят повторно.
но в данном случае проблема возникла не из событий ввода, а в процессе инициализации контекста и изменении состояния аутентификации.
Мы пришли к выводу, что более важно безопасно отменить уже выполняющиеся запросы, чем просто задержать количество запросов.
В результате было решено применить AbortController.
4. Применение AbortController
AbortController — это API браузера, предоставляющий возможность отменять выполняющиеся запросы.
AbortController предоставляет объект signal для выполнения асинхронных задач,
вызывая controller.abort(), все запросы, отслеживающие этот signal, будут немедленно отменены.
Передавая один и тот же signal нескольким запросам, можно одновременно отменить несколько запросов.
С помощью AbortController была реализована отмена существующих запросов, если поступает новый запрос.
const controllerRef = useRef<AbortController | null>( null );
useEffect(()=>{
. . .
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
. . .
// 언마운트/재실행 시 abort
return () => {
controller.abort();
};
},[ctxData, auth])
При реальных API-запросах signal также передавался.
const response = await axios.get(url, { signal:
controller.signal, });
Преимущество этого подхода в том, что можно немедленно прекратить запросы, которые больше не нужны.
Например, если пользователь быстро перемещается по экрану или состояние изменяется непрерывно, предыдущие запросы в сущности становятся бессмысленными.
После применения AbortController мы смогли устранить подобные ненужные запросы, что также снизило потребление сети.
5. Проблемы, которые не решались только с помощью AbortController
После применения AbortController большинство проблем с дублирующими запросами было решено.
Однако в процессе тестирования была обнаружена другая проблема.
Рассмотрим ситуацию, подобную следующей.
Запрос Запуск A -> Запрос Запуск B -> Запрос Завершение B -> Последние Данные Показать -> Запрос A завершено -> предыдущий данные отображение
Пользователь думает, что отменил запрос A, но на самом деле может существовать пересечение времени возврата ответа и времени отмены.
В этом случае старый ответ будет перезаписывать последний статус.
Это проблема гонки, которая часто возникает в процессе асинхронной обработки.
Одного только AbortController было недостаточно для полной защиты от таких ситуаций.
Поэтому было решено, что нужны дополнительные меры безопасности.
6. Управление идентификатором запроса (runId)
Для решения проблемы гонки мы также управляли идентификатором запроса (runId).
Каждый раз, когда возникает новый запрос, номер увеличивается, и во время обработки ответа проверяется, является ли текущий запрос самым последним.
const runIdRef = useRef(0);
//요청이 바뀔 때 마다 번호 증가
const currentRunId = ++runIdRef.current;
Перед обработкой ответа мы проверяли следующее.
if ( currentRunId !== runIdRef.current ) {
//최신 요청이 아닐 시 return
return;
}
Поток выполнения выглядит следующим образом.
Запрос A runId = 1 -> Запрос B runId = 2 -> Запрос A Ответ 1 !== 2 Игнорировать -> Запрос B Ответ 2 === 2 Отразить
Таким образом, мы смогли игнорировать все ответы на устаревшие запросы и отразить только последний запрос на экране.
Лично для меня самым важным моментом в этой работе был именно этот процесс.
Сначала целью было уменьшение количества вызовов API, но на самом деле более важной задачей было управление тем, каким ответам можно доверять.
7. Параллельная обработка с использованием Promise.all
Дополнительно улучшена структура вызовов API.
Сначала запросы обрабатывались последовательно следующим образом.
const userInfo = await findUserInfo();
const projectInfo = await findProjectInfo();
В этом случае первый запрос должен быть завершен, прежде чем начнется второй запрос.
Если каждый из них занимает 500 мс, то потребуется более 1 секунды.
Однако эти два API не зависели друг от друга.
Поэтому была внесена изменениe, чтобы использовать Promise.all для параллельной обработки.
const [ userInfo, projectInfo ] = await Promise.all([
findUserInfo(),
findProjectInfo(),
]);
Это позволило сократить общее время начальной загрузки и обеспечить более быстрый опыт входа в систему для пользователей.
8. Результаты улучшения
После улучшений мы снова проанализировали поток запросов.
Ранее очень часто возникала ситуация, когда один и тот же API вызывался многократно, но после улучшений большинство запросов выполнялось только один раз.
Кроме того, не наблюдалось ситуации, когда предыдущий ответ перезаписывал последние данные, и экран несколько раз не мигал.
Сводя результаты улучшений, можно выделить следующее.
- Снижение дублирующих вызовов одного и того же API
- Снижение сетевого трафика
- Снижение ненужных серверных запросов
- Решение проблемы гонки
- Увеличение скорости начальной загрузки
- Улучшение согласованности данных
- Улучшение поддерживаемости
Я считаю, что главное достижение заключается в том, что мы смогли заранее устранить проблемы с несоответствием данных, которые могут возникнуть в операционной среде.
9. Ретроспектива
В ходе выполнения этой работы я еще раз ощутил, что функциональность и эффективность работы — это совершенно разные задачи.
Сначала мне казалось, что нет больших проблем, так как информация о пользователе корректно отображается на экране.
Но по мере увеличения масштаба проекта и усложнения управления состоянием возникли проблемы с повторными вызовами одного и того же API или старый ответ перезаписывал последние данные, и в процессе анализа я смог глубже понять структуру рендеринга React и его асинхронную обработку.
Особенно на этом этапе я смог усвоить важность управления жизненным циклом запросов, а не просто сокращения количества вызовов API.
Используя AbortController, я смог отменить ненужные запросы и реализовать обработку так, чтобы отражались только новые запросы через идентификатор запроса (runId), таким образом, можно было обеспечить стабильную структуру обработки данных.
Кроме того, в процессе решения проблемы я осознал, что на самом деле неосознанные дублированные запросы и такие вещи, как конкурентные состояния (Race Condition), могут привести к снижению производительности и несоответствию данных в реальной сервисной среде.
Этот опыт еще раз подтвердил, что управление состоянием и асинхронная обработка в React-приложениях — это не просто область реализации, а важные элементы, напрямую влияющие на качество обслуживания. В будущем я намерен не только сосредоточиться на реализации функций, но и постоянно размышлять и учиться, чтобы разрабатывать более стабильные и эффективные сервисы, учитывая и потоки запросов, и изменения состояния.
[Ссылки]
https://developer.mozilla.org/ko/docs/Web/API/AbortController
https://okayoon.tistory.com/entry/AbortController#google_vignette
Kancho