이 응답은 누구의 데이터인가: 계정·자녀 전환에서 비동기와 캐시 경계 지키기

하승진·2일 전

Level Up 개발자

목록 보기
20/30

계정 A와 B의 응답이 각각 자기 캐시에 도착하는 데이터 경계 개념 썸네일

자녀 A의 학습 정보를 요청한 뒤 바로 자녀 B로 전환한다. B의 응답이 먼저 도착하면 화면은 B의 정보로 채워진다. 그런데 잠시 후 A의 응답이 도착하면서 다시 A의 데이터가 보인다.

요청도 성공했고 서버가 잘못된 데이터를 준 것도 아니다. 문제는 응답을 받는 쪽에서 그 데이터가 지금 화면의 대상인지 구분하지 않았다는 데 있었다.

이 문제를 수정한 뒤에는 계정 전환에서도 같은 질문을 하게 됐다. 이 데이터는 누구의 것인가, 그리고 지금도 그 사용자를 위한 응답인가? 자녀별 조회 키, 로그인 계정의 캐시, 진행 중인 세션 요청을 각각 나눠 처리했다.

늦은 응답은 실패가 아니라 이전 요청의 성공이다

처음 흐름은 선택한 자녀가 바뀌면 조회하고, 응답을 공통 로컬 상태에 넣는 방식이었다. 요청 시작 순서와 응답 도착 순서는 다를 수 있는데 저장할 공간은 하나였다.

시각 1: 자녀 A 조회 시작 ──────────────────────┐
시각 2: 자녀 B 조회 시작 ──┐                   │
시각 3:                   └─ B 응답 → 화면 B  │
시각 4:                                      └─ A 응답 → 화면 A (문제)

지연 시간을 줄이는 것으로는 해결되지 않는다. 응답 순서가 뒤집히는 조건은 언제든 다시 생길 수 있다. 응답이 도착할 장소를 대상별로 나누는 것이 먼저였다.

학습 조회를 React Query로 옮기고 선택한 자녀를 query key에 포함했다. 이후 계정 경계까지 추가한 현재 형태는 다음과 같다.

useQuery({
  queryKey: [queryKeys.GET_MY_PAGE_STUDY, loginId, childLoginId ?? null],
  queryFn: () => getMyPageStudy(childLoginId),
  enabled: Boolean(loginId) && enabled !== false,
  staleTime: 0,
});

이 키는 계정과 자녀가 데이터의 정체성을 결정한다는 선언이다. B를 보고 있을 때 A의 응답이 도착해도 A의 키에 속하는 결과이지 B의 결과가 아니다. TanStack Query의 query key 안내도 조회 함수가 의존하는 변수를 키에 포함해 데이터를 독립적으로 캐시하도록 설명한다.

이 변경을 ‘이전 HTTP 요청을 모두 중단했다’고 설명하지는 않는다. 이 조회 함수에 전송 계층의 AbortSignal을 연결한 변경은 아니었다. 화면의 조회 대상과 캐시의 소유권을 분리한 것이다.

자녀를 바꿀 때 무엇을 잠깐 보여줄 것인가

query key를 나눠도 화면 정책은 별도로 결정해야 한다. B를 처음 선택했는데 A의 학습 내역을 계속 보여주면, 잠깐이라도 B의 정보처럼 읽힐 수 있다.

처음 보는 자녀는 로딩을 보여주고, 이미 조회한 자녀로 돌아갈 때는 그 자녀의 캐시를 보여주면서 다시 조회하도록 했다. ‘이전 화면의 데이터’와 ‘현재 대상의 이전 데이터’는 다르다. 전자는 대상이 틀릴 수 있고, 후자는 같은 대상의 오래된 정보다.

학습은 다른 학습앱에서 진행될 수 있어 브랜드 화면이 변경을 모두 감지하지는 못한다. 이 조회는 전역 staleTime을 그대로 따르지 않고 staleTime: 0으로 화면 진입 시 최신 조회가 일어나도록 했다. 캐싱 가이드에 나오는 캐시 표시와 백그라운드 재조회도 이 구분을 이해하는 데 도움이 된다.

선택 값도 정리했다. 현재 계정의 활성 자녀 목록에 선택한 자녀가 없으면 렌더에서 유효한 첫 자녀를 선택했다. 효과가 실행된 뒤 선택을 보정하면 자녀 없는 조회와 첫 자녀 조회가 연달아 생길 수 있기 때문이다. 관련 등록 변경 뒤에는 자녀별 조회를 포함하는 키 prefix를 무효화해 다시 확인하도록 했다.

자녀 키만 넣으면 계정 전환은 안전할까

자녀 전환을 고친 것으로 계정 전환까지 해결된 것은 아니었다. A 계정에서 로그아웃한 뒤 B 계정으로 로그인할 때, B의 응답이 늦거나 실패하면 A의 학습권이나 주소가 남는 문제가 있었다.

계정과 자녀는 다른 경계다. 주문·상품·학습 쿼리에 로그인 계정을 넣고, 계정 식별자가 없으면 개인 조회가 시작되지 않도록 했다. 로그인 상태가 확정되기 전에 빈 식별자로 개인 데이터를 요청할 이유는 없다.

또한 로그인·로그아웃으로 계정이 실제 변경되는 이벤트에는 개인 쿼리 캐시를 정리했다. 당시 해당 앱의 쿼리는 모두 개인 데이터였기 때문에 QueryClient 전체를 clear()했다. 앞으로 공용 카탈로그처럼 계정과 무관한 쿼리가 들어오면 이 범위는 다시 나눠야 한다.

단순 회원 정보 갱신은 계정 교체와 같지 않다. 정상적인 정보 새로고침에는 캐시 전체를 지우지 않고 세션 재조회만 알렸다. 로그아웃 요청이 실패했을 때도 성공한 것처럼 세션과 캐시를 제거하지 않았다. 서버가 이미 미인증 상태라고 확인한 경우와 통신 실패로 결과를 모르는 경우를 구분했다.

세션 조회에는 요청 세대가 필요했다

캐시만 정리해도 훅의 로컬 상태를 갱신하는 오래된 세션 응답은 별도로 남는다. A 계정의 /auths/me 요청을 기다리던 중 B로 로그인하면, A의 응답을 버릴 기준이 필요하다.

세션 훅에는 조회 세대를 두었다. 새 조회를 시작할 때 세대를 올리고, 응답이 현재 세대와 같을 때만 반영한다. 아래는 이 조건만 추린 코드다.

const currentGeneration = ++generation;

fetchMeShared(event).then((me) => {
  if (!alive || generation !== currentGeneration) return;
  setUser(me);
  setError(false);
});

alive는 컴포넌트 생명주기를, generation은 같은 훅 안의 요청 순서를 다룬다. 오류 처리와 로딩 종료에도 같은 기준을 적용해야 한다. 오래된 요청의 finally가 새 요청의 로딩을 꺼버리면 성공 응답만 막은 것으로는 부족하다.

계정 변경 이벤트를 받았을 때는 기존 사용자 표시도 바로 비우고 새 조회를 기다리게 했다. A의 응답을 나중에 무시하더라도 B를 확인하는 동안 A의 이름을 계속 보여주면 사용자 입장에서는 전환이 끝났는지 알기 어렵다.

확인 실패를 로그아웃으로 취급하지 않기

마지막으로 user === null의 의미를 나눴다. 쿠키가 없거나 서버가 401을 반환하면 미인증으로 판단할 수 있다. 하지만 5xx나 네트워크 오류라면 아직 로그인 여부를 확인하지 못한 것이다.

세션을 { user, loading, error }로 표현하고 보호 화면에서는 다음 순서로 판단했다.

loading         → 확인 중
error           → 확인 실패, 다시 시도 안내
!user && !error → 로그인 필요
user            → 현재 계정의 화면

확인 실패를 무조건 로그인 화면으로 보내면 장애가 세션 만료처럼 보이고 하던 작업도 끊긴다. 헤더에서도 상태가 불명확한 동안 미인증이라고 단정하는 링크를 보여주지 않도록 했다.

오류와 빈 결과의 구분도 같은 원리다. 학습 데이터가 없는 것과 학습 데이터를 가져오지 못한 것은 서로 다른 화면이어야 한다. 상태를 줄이는 것보다 상태의 의미를 보존하는 것이 중요했다.

검증은 응답 순서를 일부러 뒤집었다

자녀 A의 응답을 지연시킨 상태에서 B로 전환해 B의 화면이 A로 덮이지 않는지 확인했다. 이미 본 자녀로 돌아갈 때 해당 자녀의 캐시가 보이면서 재조회되는지도 확인했다. 계정 전환에서는 이전 요청을 지연시키고 새 계정 조회가 실패하는 조건까지 나눴다.

당시에는 모의 API와 테스트로 응답 순서를 통제했다. 실제 운영 계정의 모든 전환 경로나 웹뷰 환경까지 검증한 것은 아니다. 중요한 것은 정상 응답 한 번을 보는 대신, 이전 요청이 더 늦게 성공하거나 새 요청이 실패하는 상황을 테스트 대상으로 삼았다는 점이다.

회고: 서버 상태와 화면 상태 사이에도 소유권이 있다

이번 작업을 통해 비동기 문제를 ‘응답이 늦어서 생긴 버그’로만 설명하면 중요한 부분을 놓친다는 것을 정리했다. 늦은 응답 자체는 정상일 수 있다. 잘못된 것은 그 응답을 현재 화면의 데이터로 받아들이는 조건이다.

자녀는 query key로, 계정 변경은 캐시 수명으로, 진행 중인 세션 조회는 세대로 나눴다. 그리고 확인 실패는 로그아웃과 구분했다. 한 가지 장치가 모든 경계를 대신하지는 않는다.

이제 개인화 화면에서는 조회 함수보다 먼저 데이터가 어떤 계정과 선택 대상에 속하는지부터 확인하려 한다. 빠르게 보여주는 것보다, 다른 사람의 정보를 현재 데이터처럼 보여주지 않는 것이 우선이다.

세션 조회의 경계를 지킨 뒤에는 불필요한 호출도 줄였다. 마이페이지 세션 요청 3회→1회에서는 같은 시점의 요청 공유와 자식의 순차 조회 제거를 따로 다뤘다. 요청을 적게 보내더라도 계정 전환의 안전성을 유지해야 한다는 전제는 같았다.

작업 기록

자녀별 학습 조회 #9564, 계정 전환과 캐시 경계 #9615, 세션 확인 실패 구분 #9620에 반영했다. 저장소 링크는 접근 권한이 필요할 수 있다.

profile
기어갈지언정 한 발자국씩이라도 가보자

0개의 댓글