[Race Condition - 1] 당신의 핸들러 함수는 위험할 수 있다.

이인·2023년 11월 5일
0

1. 서론

API 호출과 그 응답을 활용한 사이드 이펙트 점화는 의외로 간단하지 않다. 개발자가 고려해야 할 부분이 너무 많다. 간단히 추려보자면 다음과 같은 항목들이 있다.

  1. 에러핸들링
  2. 사이드 이펙트의 발생 시점
  3. 렌더링 주기 제어
  4. 중복 호출 등 비정상적인 액션 방지
  5. 이벤트 버블링, 캡쳐링 제어
  6. 핸들러 함수의 책임 분리

언급된 항목 외에도 프로젝트의 도메인이나 스택, 아키텍쳐에 따라 고려해야 할 부분이 줄어들기도 하고 늘어나기도 한다.

해당 글에서는 이중 3,4번에 대해 깊이있게 다루어 볼 예정이다.

2. 핸들러 함수의 일반적인 형태

리액트를 활용하여 컴포넌트에 특정 이벤트가 발생하면 API를 호출하는 코드를 작성해 본 적이 있을 것이다. 그리고 이런 코드들은 일반적으로 다음과 같은 형상을 띈다.

import {useState} from "react";

const ExampleButton = ()=> {

    const [count, setCount]=useState<number>(0)

    const handleClick = async  () => {
        const res = await fetch('ExampleURL')
        setCount(res?.count)
    }

    return <button onClick={handleClick}>{count}+</button>;
}

버튼을 클릭하면 API Request가 날아가는 핸들러가 작성되어있다. 허나 사용자가 해당 버튼을 짧은 시간에 반복적으로 클릭할 경우 어떻게 될까?

API Client의 종류에 따라 다르겠지만, 일반적으로 별도의 Configuration이 없을 경우, 누른 횟수만큼 서버에게 요청을 전송하게 된다.

이는 개발자가 의도한 동작은 아니다. 어떤 개발자도 쓸데없는 API 호출을 허용하려는 생각을 가지고 코드를 작성하지는 않을 것이다.

그렇다면 이런 핸들러 함수가 가지는 문제점이 단순히 중복된 Request Message 전송 뿐일까?

3. API 중복 호출 제어가 없을경우 발생하는 문제점

앞서 살펴봤다시피 기본적으로 불필요한 중복 요청으로 서버측 리소스를 낭비하는게 가장 큰 문제점이다. 해당 API의 세부적인 구현에 따라 다르겠지만, I/O가 중복해서 발생하여 생각보다 큰 리소스 낭비가 발생할 수 있는 여지도 생긴다.

나아가, 클라이언트의 관점과 사용자의 관점에서 어떤 문제가 발생할 수 있을까?

바로 Race Condition 문제이다.

3-1. RaceCondition

운영체제에서 말하는 Race Condition이란 다공유 자원에 대해 여러 프로세스가 동시에 접근을 시도할 때, 타이밍이나 순서 등이 결과값에 영향을 줄 수 있는 상태를 말한다.

공유 자원에 여러 프로세스가 동시에 접근할 때 자료의 일관성을 해치는 결과가 나타날 수 있다.

API 중복 호출도 마찬가지 이다.

여기서 말하는 공유자원이란 클라이언트 측의 'State' 라고 볼 수 있다. 또한, 공유자원이라는 State에 접근할 수 있는 주체는 핸들러 함수이다.

여기서 말하는 핸들러 함수란, API Request Message를 전송하고 그에 따른 응답값을 바탕으로 State를 업데이트 해주는 함수이다.

즉 중복된 핸들러 함수 호출이 상태값의 일관성을 해쳐 실제 사용자가 발생시킨 최종적인 액션의 기댓값과 달리, 상태값이 정합성이나 일관성이 깨진 상태로 최종 화면이 사용자에게 렌더링 될 수 있다는 것이다.

왜 위와 같은 현상이 나타나는 지 구체적으로 살펴보자.

4. Batch Update

Race Condition을 이해하기 위해 리액트의 렌더링이 비동기적으로 이루어지는 특성과 관련한 기본적인 배경 지식이 필요하다. 만약 배운적이 없다면 Beta Docs를 참고하자.

리액트는 내부적으로 다음과 같은 과정을 통해 렌더링을 진행한다.

  1. 훅을 통해 컴포넌트 상태를 업데이트한다(Batch).
  2. 업데이트를 반영할 Work를 scheduler에게 전달하고 scheduler는 스케줄링된 Task를 적절한 시기에 실행한다.
  3. Work을 통해 VDOM 재조정 작업을 진행한다.
  4. Work를 진행하며 발생한 변경점을 적용한다.
  5. 사용자의 상호작용으로 이벤트가 발생하고 등록된 핸들러가 실행되면서 다시 1번으로 되돌아간다.

각 모듈의 구체적인 동작방식과 아키텍쳐는 제쳐두고, 중요한 두 가지의 사실을 기억하자.

  1. 일반적으로 setState함수가 호출이 된다고 해서 즉시 상태값이 업데이트 되어 화면에 반영되는 것이 아니라는 점이다.

  2. 또한 핸들러 함수의 Call stack Frame이 종료되기 전 까지, 싱글 스레드의 특성 때문에 리액트는 제어권을 가지고 있지 않으므로, 업데이트 스케쥴링을 수행할 수 없다.

완전한 이해를 위해 이벤트 루프에 대해 이해해보자.

5.이벤트 루프 관점에서 핸들러 함수의 동작 과정

브라우저 환경이던, Node.js이던 그 이면에는 동일한 엔진인 v8과 이벤트루프를 통한 비동기 처리라는 특성을 가진다.

즉 각 환경의 구현에 세부적인 차이가 있을지라도, 싱글스레드라는 기조는 동일하다.

이벤트가 발생하여 핸들러 함수가 실행되고, 이를 통해 상태 업데이트가 이루어지는 과정을 앞서 작성된 예시 코드를 기반으로 정리하면 다음과 같다.

import {useState} from "react";

const ExampleButton = ()=> {

    const [count, setCount]=useState<number>(0)

    const handleClick = async  () => {
        const res = await fetch('ExampleURL')
        setCount(res?.count)
    }

    return <button onClick={handleClick}>{count}+</button>;
}

1. 핸들러 함수가 바인딩된 버튼에 클릭 이벤트가 발생한다.
2. handleClick함수가 호출되고 Call stack에 실행 컨텍스트가 적재된다.
3. 핸들러 함수 내부에서 fetch 함수가 실행 실행 컨텍스트가 적재되고, Web API 로 이관된다.
4. await 키워드로 인해, WebAPI로 이관된 프라미스가 이행되기 이전까지 handleClick이 중단되며, API 호출이 완료되었을 때 fetch에 할당된 콜백함수가 있다면, 이는 마이크로 태스크 큐에 적재되고 즉시 실행된다.
5. 현재는 콜백 함수가 존재하지 않으므로, 이행된 이후로 handleClick내부의 나머지 코드들이 순차적으로 콜스택에 적재되고, 이때 setCount 가 호출되며 상태 업데이트를 예약한다.
5. 핸들러 함수의 호출이 끝나며, handleClick 실행 컨텍스트 콜스택에서 제거된다.
6. 리액트가 업데이트를 위한 스레드 제어권을 얻는다.
7. batch, schedule,reconcile 등의 개념을 바탕으로 상태 업데이트를 수행하여 가상 DOM을 업데이트 한다
8. 가상 DOM의 변경이력을 실제 DOM에 커밋하며, 스레드 제어권을 브라우저에게 넘긴다.
9. 브라우저는 리페인트를 실시하며 화면에 반영된다.

간단한 핸들러 함수 호출의 이면에는 생각보다 복잡한 과정들이 뒤따르고 있다. 그렇다면 이에 대한 이해가 Race Condition과 어떤 상관관계가 있을까?

6. Race Condition과 핸들러 함수

앞 장에서 살펴본 핸들러 함수의 실행 과정을 바탕으로, 문제상황이었던 여러번 핸들러 함수가 호출되는 상황에서 Race Condition 이 발생하는 시나리오를 정리해보면 다음과 같다.

사용자가 버튼을 클릭합니다 (click 1).

  1. handleClick 함수의 첫 번째 인스턴스가 콜 스택에 푸시되고 실행됩니다(handleClick1).
  2. handleClick1 내부에서 fetch 함수가 호출되어, 네트워크 요청이 시작됩니다. await은 fetch의 결과를 기다리는 동안 handleClick1의 실행을 일시 중단시킵니다.

click1으로 인한 handleClick 프레임이 제거되기 이전, 사용자가 다시 버튼을 클릭 (click 2).

  1. handleClick 함수의 두 번째 인스턴스가 콜 스택에 푸시되고 실행됩니다 (handleClick2).
  2. handleClick2 내부에서 또 다른 fetch 함수가 호출되어, 두 번째 네트워크 요청이 시작됩니다.
  3. handleClick2 역시 await에 의해 중단됩니다.

이때, 네트워크 응답의 도착 순서가 요청의 순서와 일치하지 않을 수 있습니다.
만약 fetch 요청 2의 결과가 fetch 요청 1의 결과보다 먼저 도착한다고 가정하면,

  1. setCount가 호출되어 count 상태를 업데이트합니다.
  2. 그 다음에 fetch 요청 1의 결과가 도착하고, setCount가 다시 호출되어 count 상태를 업데이트합니다.

이 경우, 두 번째 클릭에 의해 시작된 handleClick2의 결과가 첫 번째 클릭에 의해 시작된 handleClick1의 결과보다 먼저 반영됩니다.

그 후 handleClick1의 결과가 나중에 도착하면서 더 최근의 handleClick2의 상태 업데이트를 덮어쓰게 되는데, 이것이 바로 race condition입니다.

Solution

0개의 댓글