[Next.js] AbortController 사용하여 중복 API 요청 최적화

Su Min·2026년 2월 4일

AbortController로 Race Condition 해결하기

프로젝트를 진행하다 보면 사용자 경험을 위해 실시간 저장 기능을 구현해야 할 때가 많다. 이런 과정에서 '네트워크 응답 속도의 차이'로 인해 데이터 무결성이 깨지는 Race Condition 문제를 마주하게 되었다.

🔗 문제 발생

Debounce만으로는 부족했던 이유

처음에는 텍스트 입력 시마다 API가 호출되는 것을 방지하기 위해 Debounce를 적용했다. 사용자가 입력을 멈추고 1000ms가 지나면 PATCH 요청을 보내도록 설계했다.

여기서 문제 발생...🚨🚨

  1. 사용자가 "안녕" 입력 -> 1초 뒤 첫 번째 요청(A) 발송
  2. 사용자가 바로 "안녕하세요" 입력 -> 1초 뒤 두 번째 요청(B) 발송
  3. 네트워크 상황에 따라 B가 A보다 먼저 처리되어 서버에 저장됨
  4. 뒤늦게 A의 응답이 도착하여 서버의 데이터를 "안녕"으로 덮어버림

결과적으로 사용자는 "안녕하세요"를 입력했지만, 최종 저장된 데이터는 "안녕"이 되어버리는 버그가 발생한 것이다. 요청 순서가 보장되지 않고, 마지막 요청의 응답이 반드시 최종 상태임을 확신할 수 없는 상황이었다.

🔗 해결책: AbortController란?

이 문제를 해결하기 위해 AbortController를 도입했다.

AbortController는 하나 이상의 웹 요청을 취소할 수 있게 해주는 DOM 인터페이스로 이를 사용하면 새로운 요청이 발생했을 때, 아직 완료되지 않은 이전 요청을 '중단(Abort)' 시킬 수 있다.

📌 AbortController.signal: DOM 요청과 통신하고 취소하는 데 사용되는 AbortSignal 객체를 반환
📌 AbortController.abort(): 비동기 작업이 완료되기 전에 취소

🔗 코드 적용: 커스텀 훅으로 공통 로직 분리하기

매번 컴포넌트에서 취소 로직을 작성하는 대신, useDebouncedFieldUpdate라는 커스텀 훅을 만들어 관리 효율성을 높였다.

useDebouncedFieldUpdate

useRef를 사용하여 이전의 AbortController 인스턴스를 유지하고, 새로운 요청이 들어올 때마다 abort()를 호출하는 것이 핵심이다.

interface UseDebouncedFieldUpdateProps<T = unknown> {
    fieldName: string
    debounceTime?: number
    mutationFn: (value: T, signal?: AbortSignal) => void
    enabled?: boolean
}

export const useDebouncedFieldUpdate = <T = unknown>({
    fieldName,
    debounceTime = 1000,
    mutationFn,
    enabled = true,
}: UseDebouncedFieldUpdateProps<T>) => {
    const { formState } = useFormContext()
    const abortRef = useRef<AbortController | null>(null)
    const mutationFnRef = useRef(mutationFn)

    const fieldValue = useWatch({ name: fieldName })
    const debouncedValue = useDebounce(fieldValue, debounceTime)

    // react-hook-form의 dirtyFields를 체크하여 실제 변경이 있을 때만 동작하도록 최적화
    const isDirty = useMemo(() => {
        const fieldParts = fieldName.split('.')
        let dirtyFields = formState.dirtyFields

        for (const part of fieldParts) {
            if (dirtyFields?.[part] === undefined) return false
            dirtyFields = dirtyFields[part]
        }

        return (dirtyFields as unknown as boolean) === true || typeof dirtyFields === 'object'
    }, [fieldName, formState.dirtyFields, debouncedValue])

    useEffect(() => {
        mutationFnRef.current = mutationFn
    })

    useEffect(() => {
        if (!enabled || !isDirty) return

        // 1. 새로운 요청 전, 이전 요청이 살아있다면 취소!
        abortRef.current?.abort()

        // 2. 새로운 AbortController 생성
        const controller = new AbortController()
        abortRef.current = controller

        // 3. API 함수에 signal 전달
        mutationFnRef.current(debouncedValue as T, controller.signal)

        return () => {
            // 컴포넌트 언마운트 시 요청 취소
            controller.abort()
        }
    }, [debouncedValue, isDirty, enabled])

    return {
        value: fieldValue,
        debouncedValue,
        isDirty,
    }
}

API 요청부

Axios의 config 객체에 signal을 넘겨주면, Axios가 해당 신호를 감지하여 요청을 중단한다.

export const requestEditContent = ({
    contentId,
    data,
    signal,
}: {
    contentId: string
    data: Partial<RequestCreateContentModel>
    signal?: AbortSignal
}) => {
    return axiosHandler<Partial<RequestCreateContentModel>, undefined>({
        httpMethod: 'patch',
        url: `/contents/${contentId}`,
        data,
        config: {
            signal, // AbortSignal 전달
        },
    })
}

실제 사용 예시

컴포넌트 단에서는 매우 선언적으로 사용할 수 있다.

useDebouncedFieldUpdate({
    fieldName: `${contentPath}.title`,
    mutationFn: (title: string, signal?: AbortSignal) =>
        editContent({
            contentId: contentId!,
            data: { title: title.trim() === '' ? '' : title },
            signal,
        }),
})

🔗 결과 및 얻은 점

AbortController를 적용한 결과, 브라우저 네트워크 탭에서 이전 요청들이 (canceled) 처리되는 것을 확인할 수 있었다.

  • 불필요한 리소스 절약: 서버는 마지막 요청에 대해서만 응답을 집중하면 되므로 불필요한 연산을 줄일 수 있다.
  • 데이터 일관성 보장: 늦게 도착한 이전 응답이 현재의 최신 데이터를 덮어쓰는 버그가 완벽히 해결되었다.
  • 사용자 경험 향상: 네트워크 지연과 상관없이 사용자가 마지막으로 입력한 값이 항상 안전하게 저장된다.

단순히 Debounce로 요청 횟수를 줄이는 것에 그치지 않고, 비동기 요청의 생명 주기(Life Cycle) 를 제어하는 것이 얼마나 중요한지 깨닫게 된 경험이었다.

profile
성장하는 과정에서 성취감을 통해 희열을 느낍니다⚡️

0개의 댓글