본문 바로가기

Next

mutateAsync 도입기

상황

프로젝트에는 A 탭과 B 탭, 두 개의 탭이 있다. 두 탭 모두 React Flow로 구성된 캔버스를 가지고 있고, 각 캔버스의 상태를 서버에 스냅샷 형태로 저장하고 불러오도록 설계했었다.

초기 구현은 단순했다. 컴포넌트가 mount될 때 서버에 GET 요청을 보내 스냅샷을 가져오고, 그 데이터를 노드와 엣지 상태로 복원한다. 그리고 컴포넌트가 unmount될 때는 현재 React Flow의 상태를 toObject()로 스냅샷 형태로 만든 뒤, 이를 POST로 서버에 저장한다.

코드는 대략 다음과 같은 형태였다.

useEffect(() => {
  // mount 시 GET
  refetch()
    .then((res) => {
      ...
    })
    .catch((err) => {
      ...
    })

  // unmount 시 POST
  return () => {
    const snapshot = reactFlowInstance.toObject()

    mutate(snapshot, {
      ...
    })
  }
}, [])

 

처음 구상은 명확했다. 탭이 열리면 자동으로 상태를 불러오고, 탭이 닫히면 자동으로 저장되도록 해서, 사용자는 탭 전환만 해도 자연스럽게 상태가 유지되는 경험을 제공하려고 했다.

 

문제

처음에는 “언마운트에 자동 저장, 마운트에 자동 로드”면 깔끔할 것이라고 생각했지만, 실제 동작을 지켜보니 여러 문제가 드러났다.

가장 먼저 드러난 것은 요청의 순서가 보장되지 않는다는 점이다. 예를 들어 A 탭에서 B 탭으로 이동하는 순간을 생각해보면, A 탭이 언마운트되면서 서버로 POST 요청을 보내고, 곧이어 B 탭이 마운트되면서 GET 요청을 보낸다. 의도한 흐름은 “A 상태 저장 → B 상태 로드”지만, 네트워크 환경에 따라 어떤 요청이 먼저 끝날지 전혀 예측할 수 없다.

 

이로 인해 B 탭이 옛날 상태를 불러오는 상황이 실제로 발생한다. A 탭에서 작업을 마치고 바로 B 탭으로 이동했을 때, A 탭의 최신 스냅샷이 아직 서버에 반영되기 전에 B 탭의 GET 요청이 먼저 처리되어 이전 시점의 스냅샷을 받아오는 경우가 생긴다. 저장이 진행 중인 시점과 로드 시점이 서로 엇갈리면서, A와 B 사이의 상태 일관성이 깨지는 전형적인 레이스 컨디션이 되는 것이다.

 

또 하나의 문제는 저장 실패를 UX적으로 처리하기 어렵다는 점이다. 기존에는 React Query의 mutate를 사용하고 있었는데, 이 함수는 콜백 기반으로 동작한다. 상위 레벨에서 “저장이 끝난 다음에만 탭을 전환하자”와 같은 흐름을 코드로 표현하기가 자연스럽지 않다. 저장이 실패해도 이미 탭은 넘어가 있고, 사용자는 저장이 되었는지 안 되었는지를 알 방법이 없다. “저장 실패 시 탭 전환을 막는다”, “실패 메시지를 보여준다” 같은 UX 정책을 구현하려 해도 구조 자체가 그것을 도와주지 않는다.

결국 정리해보면, “언마운트에서 mutate, 마운트에서 refetch”라는 구조만으로는 탭 전환 시점의 데이터 일관성을 보장할 수 없었다는 것이 문제였다.

 

원인 분석: mutate와 컴포넌트 라이프사이클의 한계

이 문제를 조금 더 구조적으로 바라보면 두 가지 축이 보인다. 하나는 mutate 자체의 특성이고, 다른 하나는 useEffect와 컴포넌트 라이프사이클의 특성이다.

먼저 mutate는 콜백 기반이다. React Query에서 제공하는 mutate는 호출 시점에 바로 반환되며, 반환값이 await의 대상이 되는 Promise가 아니다. 이 말은 상위 코드에서 다음과 같은 패턴을 쓴다고 해도, 실제로는 의도대로 동작하지 않는다는 뜻이다.

await mutate(snapshot) // 이렇게 쓴다고 해서 저장이 끝날 때까지 기다려주지 않는다

 

mutate는 내부적으로 요청을 날리고, 성공과 실패를 각각 onSuccess, onError 콜백으로 알려줄 뿐이다. 따라서 “저장이 끝난 후에만 탭 전환을 허용한다”는 비즈니스 규칙을 탭 전환 핸들러 내부에서 async/await 형태로 자연스럽게 표현하기가 어렵다.

 

두 번째로, useEffect의 cleanup 함수는 비동기 완료를 보장해 주지 않는다. useEffect에서 반환하는 함수는 컴포넌트가 언마운트되거나 의존성이 변경될 때 호출되지만, 이 함수 안에서 async를 사용한다고 해서 React가 해당 비동기 작업이 끝날 때까지 기다려 준다는 보장은 없다. A 탭이 언마운트된 직후 B 탭이 바로 마운트될 수 있고, 이 사이에는 아직 끝나지 않은 비동기 저장이 떠 있을 뿐이다. 결과적으로 A 탭 unmount → B 탭 mount 흐름 사이에 “저장 완료 후 로드”라는 순서를 기술적으로 보장할 수 없다는 한계에 부딪힌다.

 

이 두 가지를 종합해 보면 데이터 일관성을 제대로 보장하려면 컴포넌트의 라이프사이클에 네트워크 로직을 묶어두지 말아야 한다는 것이다. 그리고 저장과 로드의 순서를 개발자가 명시적으로 제어하려면, 어딘가에서 async/await를 사용할 수 있는 구조가 필요하다.

 

해결 전략: 명시적 저장 + mutateAsync 도입

이번 단계에서 선택한 전략은, 구조를 전부 갈아엎기보다는 자동 저장에만 의존하지 않고 명시적인 저장 행위를 도입하는 것이었다.

사용자가 캔버스에서 작업을 마친 뒤 스스로 “저장” 버튼을 누르게 하고, 그 시점에 현재 React Flow 상태를 스냅샷으로 만든 다음 서버에 저장하도록 했다. 이때 저장 API 호출은 기존의 mutate 대신 mutateAsync로 교체했다. 이렇게 하면 저장 요청이 실제로 끝날 때까지 await할 수 있고, 그 결과를 기준으로 UI 피드백을 명확하게 줄 수 있다. 사용자는 “저장 완료” 메시지를 직접 확인한 뒤에 탭을 이동할 수 있기 때문에, 이전처럼 “언제 저장됐는지 모르는 상태에서 탭을 바꾸는” 불안한 경험을 줄일 수 있다.

자동 저장 구조는 그대로 유지할 수도 있지만, 핵심은 “상태가 서버에 반영되는 순간”을 사용자와 코드가 함께 인지할 수 있게 만드는 데 있었다. 명시적인 저장 버튼과 mutateAsync를 결합하면서, 이 흐름을 훨씬 통제 가능한 형태로 바꿀 수 있었다.

 

mutateAsync로 “저장 완료 시점”을 정확히 알 수 있다

mutateAsync의 가장 큰 장점은 Promise를 반환한다는 점이다. 덕분에 다음과 같은 코드는 실제로 의미를 가진다.

await mutateAsync(cleanedSnapshot)

 

이 줄은 말 그대로 저장 요청이 끝날 때까지 대기한다는 뜻이 된다. 저장이 정상적으로 끝나면 try 블록의 다음 줄로 자연스럽게 흐름이 이어지고, 에러가 발생하면 catch 블록으로 제어가 넘어간다. 이 구조 덕분에 저장 성공 시에만 “저장이 완료되었습니다.”라는 메시지를 확실히 띄울 수 있고, 실패했을 때는 “저장 중 오류가 발생했습니다.”라는 메시지를 보여주면서 뒤이어 일어날 사용자 행동, 예를 들어 탭 이동이나 추가 작업에 대해 주의를 줄 수 있다.

 

이번 작업을 통해, 단순히 “언마운트에서 자동으로 저장되겠지”라고 믿는 구조보다, 사용자와 코드가 함께 저장 시점을 인지하고 통제할 수 있는 구조가 훨씬 안정적이라는 것을 다시 한 번 느끼게 되었다.