마이페이지 요청 3회→1회: 동시 요청을 합쳐도 한 번이 더 남았던 이유

하승진·2일 전

Level Up 개발자

목록 보기
22/30

세 개의 요청 흐름을 하나로 합치는 마이페이지 세션 조회 최적화 개념 썸네일

마이페이지를 한 번 열었는데 같은 /auths/me 요청이 세 번 발생했다. 헤더, 부모 페이지, 서브 내비게이션이 각각 세션을 확인하고 있었다.

같은 요청이니 진행 중인 Promise 하나를 공유하면 끝날 것 같았다. 실제로 요청은 줄었다. 하지만 3회에서 2회였다. 남은 한 번은 중복 제거가 실패한 것이 아니라, 앞선 요청이 끝난 뒤에 새 컴포넌트가 마운트되면서 시작한 요청이었다.

이 작업은 캐시를 더 오래 유지하는 대신, 동시에 기다리는 요청과 부모가 이미 가진 데이터를 나눠 처리해 최종 1회로 줄인 과정이다. 먼저 범위를 적으면, 로컬 dev와 응답 지연 300ms의 모의 API에서 측정한 세션 요청 횟수다. 운영 응답 시간이 세 배 빨라졌다는 결과는 아니다.

훅을 공유해도 요청은 공유되지 않았다

각 컴포넌트가 useSession()을 호출하면 코드는 재사용된다. 하지만 훅 안의 상태와 effect는 호출한 컴포넌트마다 독립적이다. 같은 함수 이름을 쓴다는 것만으로 API 요청 하나를 같이 기다리는 것은 아니다.

헤더           → /auths/me
마이페이지     → /auths/me
서브 내비      → /auths/me

세션 쿠키는 이미 인증 정보의 기준이었다. 이 문제를 해결하기 위해 또 다른 장기 사용자 저장소를 만들기보다는, 현재 진행 중인 요청만 공유하도록 했다.

진행 중인 Promise만 함께 기다리기

구현의 핵심은 짧다. 요청이 진행 중이면 같은 Promise를 반환하고, 완료되면 비운다. 세션 변경 이벤트에 대한 예외도 함께 둔다.

let inflight: Promise<UserDto | null> | null = null;
let inflightEvent: Event | undefined;

function fetchMeShared(event?: Event): Promise<UserDto | null> {
  if (!inflight || (event && event !== inflightEvent)) {
    const request = fetchMe().finally(() => {
      if (inflight === request) inflight = null;
    });
    inflight = request;
    inflightEvent = event;
  }
  return inflight;
}

예를 들어 헤더가 요청을 시작한 직후 부모 페이지가 세션을 조회하면, 두 컴포넌트는 같은 요청의 완료를 기다린다. 실패도 같은 Promise를 통해 전달되고 각 훅은 자신의 상태를 갱신한다.

여기서 공유하는 것은 응답 결과를 보관하는 캐시가 아니다. 완료된 뒤 새 컴포넌트가 들어오면 다시 조회한다. 세션 정보를 오래 고정해서 요청 수를 줄인 것이 아니라, 같은 시간에 필요한 동일한 작업을 합친 것이다.

Promise.finally 문서가 설명하듯 완료 정리는 성공과 실패 양쪽에서 수행할 수 있다. 다만 여기서는 inflight === request라는 조건이 중요하다. 이전 요청이 끝나는 사이 새 세션의 요청이 시작됐다면, 이전 요청의 정리가 새 Promise까지 비우면 안 된다.

로그인 상태가 바뀌면 같은 요청이 아니다

진행 중인 요청을 무조건 공유하면 계정 전환에서 이전 사용자의 응답을 새 사용자가 기다리게 될 수 있다. 따라서 세션 변경 이벤트가 들어오면 새 이벤트 객체를 기준으로 새 요청을 만든다.

같은 이벤트를 받은 여러 리스너는 그 새 요청을 함께 기다린다. 다음 변경 이벤트는 다시 별도 요청이다. ‘API URL이 같다’와 ‘같은 인증 상태의 조회다’를 구분한 것이다.

이전 응답이 화면 상태를 덮지 않게 하는 세대 검사도 세션 훅에 남아 있다. 요청 공유는 호출 횟수를 줄이는 장치이고, 세대 검사는 어떤 응답을 적용할지 정하는 장치다. 둘 중 하나로 다른 역할까지 대신하려고 하지 않았다.

모듈 범위의 Promise를 서버의 모든 사용자 요청에 공유하라는 뜻도 아니다. 이 구현은 해당 클라이언트 세션 조회 경로의 사례다. 서버에서 요청 간 사용자 정보를 공유하는 전역 상태로 그대로 옮기면 전혀 다른 격리 문제가 생긴다.

3회→2회에서 멈췄던 이유

동시 요청 공유를 넣은 뒤 한 요청이 남았다. 컴포넌트 마운트 순서를 보면 이유가 분명했다.

헤더 + 부모 페이지 마운트
  → 진행 중인 세션 요청 1개 공유
  → 응답 완료, inflight 비움
  → 부모의 loading 해제
  → 서브 내비 마운트
  → 자체 useSession이 새 요청 시작

서브 내비는 부모의 세션 확인이 끝난 뒤에야 렌더됐다. 이미 완료된 Promise를 비우는 정책이라면 이 조회를 합칠 대상이 없다. 공유 시간을 임의로 늘려 결과를 캐시하면 요청 수는 줄겠지만, 새로 진입했을 때 다시 확인한다는 기존 의미까지 바꾼다.

남은 요청의 필요성을 먼저 봤다. 부모는 이미 user.loginId를 가지고 있었고 서브 내비는 그 값을 필요로 했다. 그래서 서브 내비의 자체 useSession()을 없애고 부모가 loginId prop을 전달하도록 바꿨다.

React의 상태 공유 설명처럼 부모가 가진 값을 자식에 전달하면 해당 데이터의 기준을 한 곳에 둘 수 있다. 이 경우에는 상태를 새로 끌어올릴 것도 없었다. 부모가 이미 확보한 정보를 자식이 다시 확인하고 있었을 뿐이다.

네 화면에서 같은 조건으로 비교했다

마이페이지의 네 경로를 대상으로 측정했다. API 응답 지연을 300ms로 둔 로컬 dev 환경에서 화면마다 세 번씩 반복했다.

화면수정 전 세션 요청최종 수정 후 세션 요청반복
/mypage3회1회각 3회
/mypage/report3회1회각 3회
/mypage/guide3회1회각 3회
/mypage/extend3회1회각 3회

수정 전 12번의 화면 진입에서는 모두 3회, 수정 후 12번에서는 모두 1회였다. 중간 변경에서 3회→2회로 줄어든 결과가 남은 순차 요청을 찾는 근거가 됐다. 표의 반복 측정은 최종 전후 비교 기준이다.

대상은 /auths/me 호출이지 마이페이지의 모든 API 요청이 아니다. 학습·주문 조회를 포함한 전체 요청이 한 번으로 합쳐졌다는 의미도 아니다.

당시 테스트에는 여러 컴포넌트의 동시 마운트가 한 번의 조회로 합쳐지는지와 세션 변경 이벤트마다 새 조회가 발생하는지를 추가했다. PR의 전체 테스트 기록은 30개 파일·229건 통과였다. 이 숫자에는 같은 PR의 다른 수정 테스트도 포함되므로 세션 최적화만의 테스트 수로 설명하지 않는다.

요청 수가 줄었다고 모든 성능이 증명되지는 않는다

3회에서 1회로 줄인 것은 확인했다. 하지만 응답 시간, 서버 CPU, 운영 트래픽 비용을 함께 측정한 것은 아니다. 병렬 요청 두 개를 없앴다고 화면 전체가 세 배 빨라진다고 말할 수 없다.

운영 효과를 더 확인하려면 실제 화면 진입의 요청 횟수뿐 아니라 세션 API 지연 분포와 페이지 표시 시점도 같이 봐야 한다. 빠른 응답 조건에서는 동시 마운트가 얼마나 겹치는지도 달라질 수 있다.

그래도 설명 가능한 개선은 분명하다. 동시에 발생한 조회는 한 요청을 기다리게 하고, 부모가 이미 가진 정보를 자식이 순차로 다시 조회하지 않게 했다. 요청 수를 줄이면서 계정 변경 시 재확인해야 한다는 의미는 유지했다.

회고: 남은 한 번이 설계를 설명해줬다

중복 요청이라는 이름 아래에도 서로 다른 원인이 있었다. 동시 요청은 진행 중 Promise로 합칠 수 있었지만, 늦게 마운트되는 자식의 조회는 데이터 전달 관계를 바꿔야 없어졌다.

캐시 정책부터 늘리는 대신 누가 언제 같은 데이터를 필요로 하는지 확인했다. 그리고 한 번 고친 뒤 다시 측정한 결과로 다음 변경을 정했다. 이번 작업에서 남기고 싶은 것은 3→1이라는 숫자만이 아니라, 3→2에서 남은 요청을 보고 두 번째 원인을 분리한 과정이다.

앞으로도 ‘요청을 합쳤다’에서 멈추지 않고 결과를 다시 세어보려 한다. 작은 측정 하나가 새 저장소나 복잡한 캐시보다 먼저 필요한 변경을 보여줄 수 있다.

계정이 바뀔 때 이전 응답을 적용하지 않는 조건은 계정·자녀 전환의 캐시 경계에서 더 자세히 정리했다. 최적화가 그 경계를 지우지 않도록 요청 공유와 응답 적용을 별개의 책임으로 유지했다.

작업 기록

세션 중복 조회 제거 #9626에 반영했다. 저장소 링크는 접근 권한이 필요할 수 있다.

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

0개의 댓글