비동기 이벤트 핸들러를 여러 번 호출할 때 처리 방법

박기완·2021년 7월 28일
0
post-thumbnail

쉽게 쓰여진 form

작성한 데이터를 서버로 보내는 form이 있다.

function MyForm() {
  const handleSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
    e.preventDefault()

    await fetch('/api/my-awesome-api', { method: 'POST' })
  }

  return (
    <form onSubmit={handleSubmit}>
      {/* 입력 UI */}
      <button type="submit">전송</button>
    </form>
  )
}

이 form에서 버튼을 여러 번 누르면 handleSubmit 함수를 여러 번 호출할 것이다. 하지만 새로운 데이터를 DB에 저장하는 form이라면 API를 여러 번 호출하는 것을 막아야 한다. 물론 서버 쪽에서도 대응을 하겠지만, 클라이언트에 뭔지 모를 API 에러가 쌓이는 것보다 깔끔하게 한 번 요청하는 것이 좋을 것 같다.

???: "잠시 기다려주세요..."

가장 먼저 비동기 요청이 진행중일 때 핸들러가 작동하지 않도록 막았다. submitting 상태를 정의하여 비동기 함수가 전후로 상태를 변경해준다. 그리고 상태 값이 true이면 핸들러를 막는다.

function MyForm() {
  const [submitting, setSubmitting] = useState(false)

  const buttonDisabled = submitting

  const handleSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
    e.preventDefault()

    if (submitting) {
      return
    }

    setSubmitting(true)
    
    await fetch('/api/my-awesome-api', { method: 'POST' })

    setSubmitting(false)
  }

  return (
    <form onSubmit={handleSubmit}>
      {/* 입력 UI */}
      <button type="submit" disabled={buttonDisabled}>전송</button>
    </form>
  )
}

setState의 함정

리액트의 setState 함수는 동기적으로 작동하지 않는다. setSubmitting(true)를 호출했다고 해서 그 즉시 submitting의 값이 true가 되는 것이 아니다. Reconciliation이 일어나고 상태가 반영되어 새로운 handleSubmit 함수가 만들어지기 전까진 클로저에 이전 submitting를 가진 handleSubmit 함수가 호출된다. 이 순간은 아주 짧겠지만, 그래도 버튼을 충분히 빠르게 누르면 방어 코드를 통과할 수 있다.

debounce...?

이것은 debounce를 통해 막을 수 있다. 이벤트가 발생하면 일정 시간동안 기다렸다가 핸들러를 호출하는 방법인데, 기다리는 동안 새로운 이벤트가 발생하면 이전 이벤트 처리는 취소하고 새로운 이벤트로 다시 기다린다. 기다리는 시간동안 이벤트가 발생하지 않으면 핸들러를 호출한다.

이것을 handleSubmit 함수에 적용하려고 했다.

const handleSubmit = debounce((e) => {
  e.preventDefault()

  if (submitted) {
      return
  }
  
  setSubmitted(true)
  
  await fetch('/api/my-awesome-api', { method: 'POST' })
}, 500)

그런데 이렇게 하니 문제가 있었다. 디바운스 되어 이전에 누른 이벤트를 처리하진 않으니 preventDefault()를 호출하지 않아서 form의 기본 동작이 일어났다. 기본 동작을 무시하고 자체 이벤트 핸들러만 실행하고 싶었기 때문에 handleSubmit 함수는 그대로 두고, 내용 부분을 디바운스해야 했다.

필요한 기능 정리

지금까지 필요한 기능을 정리해봤다. 디바운스를 통해 여러번의 이벤트가 발생해도 핸들러는 한 번만 호출되어야 하며, 핸들러가 작동하는 동안은 새로운 핸들러가 작동하지 말아야 한다. 이를 그림으로 그리면 다음과 같다.

초록색 체크 표시가 된 이벤트만 처리되고, 나머지 이벤트는 모두 무시하는 것이다.

makeAsyncFunctionTransactional

최대한 작은 단위로 기능을 개발해야 버그도 없고 재사용성도 높아진다고 생각한다. 이런 생각으로 비동기 함수를 변환하는 Higher Order Function을 상상했다. makeAsyncFunctionTransactional이라고 이름지은 이 함수를 대상 함수가 통과하면 여러 번 호출해도 한 번만 작동하고, 비동기 작업이 끝나기 전까진 호출해도 작동하지 않게 변한다. 이런 함수를 구현하기로 했다.

디바운스부터

먼저 가장 복잡한 디바운스부터 구현했다. 디바운스를 적용하더라도 원래 함수의 응답을 반환해야 한다. 그래서 Promise를 반환하는 함수를 작성했다. Promise 안에서 setTimeout을 실행하고, 그 안에서 파라미터로 받은 함수를 실행한다. then, catch 체인에 각각 resolve, reject 넣어서 원본 함수의 응답을 변환 함수로 전달할 수 있다.
변환한 함수를 사용할 때 디바운스로 취소되면 예외로 취급하고 싶었다. 그래서 timeout을 취소할 때 에러를 냈다. 그리고 이 에러를 판단할 수 있는 메서드를 변환 함수에 같이 제공했다. 변환된 함수를 사용할 때 try/catch 문과 메서드를 이용해 이 함수가 낸 에러만 특정하여 조용히 넘어갈 수 있다.

function makeAsyncFunctionTransactional<
  Params extends any[],
  Response extends any,
>(
  asyncFunction: (...params: Params) => Promise<Response>,
): { (...params: Params): Promise<Response>; isOwnError(error: any): boolean } {
  const REJECTED_CALL_ERROR = 'REJECTED_CALL_ERROR'

  // 이전 호출의 타임아웃을 제거하는 함수를 외부 함수에서 관리하여 상태를 유지할 수 있다.
  let clearPreviousTimeout: (() => void) | undefined

  const transactionFunction: {
    (...params: Params): Promise<Response>
    isOwnError(error: any): boolean
  } = (...params) => {
    if (clearPreviousTimeout !== undefined) {
      clearPreviousTimeout()
    }

    // Promise를 이용해 원본 함수의 응답을 변환 함수가 사용할 수 있도록 할 수 있다.
    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        clearPreviousTimeout = undefined

        asyncFunction(...params)
          .then(resolve)
          .catch(reject)
      }, 500)

      clearPreviousTimeout = () => {
        clearTimeout(timeout)
        reject(new Error(REJECTED_CALL_ERROR))
      }
    })
  }

  // 이 함수에서 일으킨 에러인지 아닌지 검증할 수 있다.
  transactionFunction.isOwnError = (error: any): boolean => {
    return error?.message === REJECTED_CALL_ERROR
  }

  return transactionFunction
}
const transactionFetchForm = makeAsyncFunctionTransactional(fetchForm)

const handleSubmit = () => {
  
  try {
    // handleSubmit이 빠르게 여러 번 실행되어도 transactionFetchForm은 디바운스 된다.
    const response = await transactionFetchForm(params)
    return response
  } catch (error) {
    if (transactionFetchForm.isOwnError(error)) {
      // 디바운스 되었을 때 에러가 발생하고, 여기서 조용히 넘어간다.
      return
    }

    throw error
  }
}

실행중이니 호출 무시

다음은 비동기 함수가 실행 중일 때 발생하는 요청을 무시하는 코드를 추가했다. asyncFunction 실행 전후에 클로저로 접근할 수 있는 "실행중" 상태 값을 변경해주고, transactionalFunction을 실행했을 때 값을 보고 더 진행할지 말지 결정한다. 그리고 위와 마찬가지로 비동기 함수가 실행 중일 때 에러를 낸다.

function makeAsyncFunctionTransactional<
  Params extends any[],
  Response extends any,
>(
  asyncFunction: (...params: Params) => Promise<Response>,
): { (...params: Params): Promise<Response>; isOwnError(error: any): boolean } {
  const REJECTED_CALL_ERROR = 'REJECTED_CALL_ERROR'
  const FUNCTION_EXECUTING_ERROR = 'FUNCTION_EXECUTING_ERROR'

  let clearPreviousTimeout: (() => void) | undefined
  let isExecuting = false

  const transactionFunction: {
    (...params: Params): Promise<Response>
    isOwnError(error: any): boolean
  } = (...params) => {
    // 이미 실행중이라면 예외 처리한다.
    if (isExecuting) {
      throw new Error(FUNCTION_EXECUTING_ERROR)
    }

    if (clearPreviousTimeout !== undefined) {
      clearPreviousTimeout()
    }

    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        clearPreviousTimeout = undefined

        isExecuting = true
        asyncFunction(...params)
          .then(resolve)
          .catch(reject)
          .finally(() => {
            // 원본 함수가 성공했든, 실패했든 실행중 상태값이 변해야 한다.
            isExecuting = false
          })
      }, 500)

      clearPreviousTimeout = () => {
        clearTimeout(timeout)
        reject(new Error(REJECTED_CALL_ERROR))
      }
    })
  }

  transactionFunction.isOwnError = (error: any): boolean => {
    return (
      error?.message === REJECTED_CALL_ERROR ||
      error?.message === FUNCTION_EXECUTING_ERROR
    )
  }

  return transactionFunction
}

마무리

이렇게 여러번 호출해도 한 번 작동하는 것을 보장하게 만들어주는 makeAsyncFunctionTransactional 함수를 구현했다. 클로저를 이용해 현재 상태를 저장하는 것이 가장 중요했다.

완성된 함수는 여기서 확인할 수 있다.

0개의 댓글