나를 열받게 하는 Prefetch

minseok baek·2024년 8월 24일

프로젝트

목록 보기
13/20
post-thumbnail

미들웨어 너 왜 그래??

미들웨어를 활용해 접근 권한을 통제했을 때, 개발자 모드에서는 모든 것이 정상적으로 작동하는 것처럼 보였다. 그러나 프로덕션 모드에서는 예기치 못한 버그가 발생했다.

개발자 모드

개발자모드 동작

프로덕션 모드

프로덕션 동작

영상에서 확인할 수 있듯이, 미들웨어는 분명히 정상적으로 동작하는 것처럼 보였다. 하지만 이상하게도, 개발자 모드에서는 토큰이 제대로 인증되고 스토어에 권한이 등록되었음에도 불구하고, 자기소개서 작성 페이지에 접근할 수 없는 문제가 발생했다.

문제를 확인한 과정

로그인 페이지로 이동하는 경우는 프로젝트 내에서 세 가지로 나뉜다. 첫째, 회원가입 시. 둘째, 토큰이 만료되었을 때. 셋째, 접근 권한이 없는 페이지에 접근했을 때다. 그런데 현재 문제를 검토해보니, 회원가입과 관련된 문제는 아닌 것 같다. 그렇다면 토큰이 만료된 경우를 생각해볼 수 있는데, 이를 확인하기 위해 토큰이 필요한 마이프로필 페이지에 접근해본 결과 정상적으로 동작한다. 생각해보니 이상하다. 마이프로필 페이지도 미들웨어가 동작하는 페이지 중 하나인데, 왜 자기소개서 페이지에서만 문제가 발생하는 걸까?

원인을 파악하기 위해 미들웨어가 정상적으로 동작하는지 확인할 필요성을 느꼈다. 다음과 같이 콘솔 로그를 찍어보았다. console.log(req.url, '현재'), console.log(req.nextUrl,'다음 경로')를 추가하고, 다시 빌드를 한 후 프로덕션 모드에서 확인해 보았다. 그런데 예상치 못한 동작이 발생했다. 분명 현재 페이지는 home인데, 미들웨어가 동작했다.

export function middleware(req: NextRequest) {
  const token = req.cookies.get(TOKEN_KEYS.ACCESS_TOKEN);
  console.log('미들웨어 동작해요~ 뿌뿌');
  if (!token) {
    console.log(req.url, '현재');
    console.log(req.nextUrl.pathname);
    return NextResponse.redirect(`${req.nextUrl.origin}/login`);
  }

  return NextResponse.next();
}

// 특정 경로에만 미들웨어 적용
export const config = {
  matcher: ['/admin', '/coverLetter/:path*', '/myProfile', '/qna/detail/:path*'],
};

왜??? 분명 브라우저URL은 home인데 미들웨어가 왜 동작하는지 이해가 되지 않는다...

다른 접근 권한을 통제하는 링크를 확인하기 위해 드롭다운을 클릭하자, myProfile주소값와 미들웨어가 동작하는 것을 확인했다. 이 순간, 이전에 Link와 a 태그의 차이에 대해 학습했던 기억이 문득 떠올랐다. 결정적으로, Link는 페이지를 빠르게 로드하기 위해 prefetch 기능을 사용해 다음 렌더링을 사전에 준비한다는 점이 생각났다.

prefetch 너 뭔데 ??

prefetch는 사용자가 Link 컴포넌트로 연결된 페이지를 클릭하기 전에 해당 페이지의 데이터를 미리 가져와 빠르게 로드될 수 있도록 도와주는 기능이다.

기본동작 :

  • Link 컴포넌트는 prefetch 속성을 통해 링크된 페이지를 미리가져올지 여부를 결정한다.
  • prefetch={true}가 기본값으로 설정되어 있으며, 이경우 Next.js는 사용자가 페이지를 보는 동안 해당 페이지 링크들을 미리 가져온다.

prefetch의 자동동작 :

  • 브라우저의 intersectionObserver API를 사용하여 사용자가 페이지에서 링크를 볼 때 Next.js는 자동으로 해당 페이지를 미리로드 한다.
  • 프리패치는 사용자 경험을 해치지 않도록 비동기적으로 처리된다.

prefetch={false}

prefetchfalse로 설정하면 Next.js는 해당 링크에 대한 프리패치를 수행하지 않는다.
이 경우 페이지가 실제로 요청될 때만 데이터를 가져오며, 이는 페이지 로딩시간이 길어질 수 있다.

Next.js에서는 경로를 미리 로드하는 두가지 방법

  1. <Link> 컴포넌트 : 경로는 사용자의 뷰포트 (viewport) 안에 들어오면 자동으로 미리 로드된다.
  2. router.prefetch: useRouter훅을 사용하여 프로그래밍 방식으로 경로를 미리 로드할 수 있다.

문제원인 파악 정리

prefetch가 동작하면서 coverLetter 페이지를 준비하기 위해 미들웨어가 트리거되고, 이 과정에서 미들웨어가 토큰을 검증하게 된다. 이때 토큰이 없는 상황이라면, 미들웨어는 미리 coverLetter 링크를 로그인 페이지로 리디렉션하는 상태로 준비해두게 된다. 이 문제를 파악하고 나니, "그럼 일단 그냥 prefetch를 false로 설정하면 간단하게 해결되겠지?"라고 생각했다.

그래서 prefetch를 false로 제어해보았다. 그러나 예상과는 다르게 동일한 문제가 다시 발생했다. 이전과 다르게 home 화면에서는 미들웨어가 동작하지 않는 것으로 보아 prefetch가 동작하지 않는 것은 확실했다.

일단 문제는 무조건 prefetch라는 생각에 미들웨어에서 prefetch를 제어하는 방법에 대해서 찾아보았다.

purpose: prefetch
x-middleware-prefetch: 1
x-nextjs-data: 1

스택 오버 플로우에서 확인한 결과 prefetch가 특정 헤더와 상태를 통해 표시된다는 사실을 발견했다. 이를 바탕으로 isPreFetch = req.headers.get('purpose') === 'prefetch 라는 조건을 추가하여 prefetch인 경우에는 return NextResponse.next()처리를 해주었다. 하지만 이로 인해 상황은 더 악화되어, 이제 미들웨어 검증 자체가 동작하지 않으며, 결과적으로 null 페이지가 반환되는 문제가 생겼다.

차분히 생각을 정리해보니, 이미 prefetch가 발생하여 다음 페이지를 준비하기 위한 검증이 이루어진 상태에서, prefetch 요청을 그냥 통과시켜버리면 다음 페이지에서는 권한이 인증되지 않을 수 있다는 사실을 깨달았다. 이 문제로 한참 동안 헤매던 중, 게시판에서 중요한 단서를 발견할 수 있었다.

게시판 링크 - 다른 영상에서 캐싱 문제 증명

게시판 단서

근본적인 원인은 prefetch가 아니었다. 문제는 미들웨어의 동작 방식에 있었다. 링크에 접근했을 때 토큰이 없으면, 미들웨어는 사용자를 로그인 페이지로 리디렉션시킨다. 그러나 로그인 페이지에서 인증을 마치고 다시 돌아왔을 때, 해당 페이지는 Next.js에 의해 캐싱되어 있었고, 미들웨어는 동작하지 않아 여전히 로그인상태로 준비되었던 렌더링 페이지가 캐싱되어 남아있게된다. 그동안 게시판에서 다른 게시글만 계속 클릭했기 때문에, 이 사실을 미처 인지하지 못했던 것이다.

미들웨어에 대해서 자세히 알아보기

미들웨어는 요청이 완료되기 전에 코드를 실행할 수 있도록 한다. 그런 다음, 들어오는 요청에 따라 응답을 수정할 수 있다.

수정 작업은 응답을 재작성하거나, 리다이렉션을 하거나 요청 및 헤더를 수정하거나, 직접 응답을 처리하는 것이 포함된다.

미들웨어는 캐시된 콘텐츠와 라우트가 매치되기 전에 실행된다.

사용 시나리오

  • 인증 및 권한 부여 : 특정 페이지나 API 라우트에 접근하기 전에 사용자 신원을 확인하고 세션 쿠키를 검사한다.

  • 서버 측 리다이렉트 : 특정 조건에 따라 서버 수준에서 리다이렉트 한다.

  • 경로 재작성 : A/B테스트, 기능 롤아웃, 또는 레거시 경로를 지원하기 위해 요청 속성에 따라 API라우트나 페이지로의 경로를 동적으로 재작성한다.

  • 봇 탐지 : 자원을 보호하기 위해 봇 트래픽을 탐지하고 차단한다.

주의 시나리오

  • 복잡한 데이터 가져오기 및 조작 : 미들웨어는 직접적인 데이터 가죠오기(fetch)나 조작을 위해 설계되지 않았다. 이러한 작업은 라우트 핸들러나 서버측 유틸리티에서 수행하는 것이 좋다.

  • 무거운 계산 작업 : 미들웨어는 가벼운 작업을 신속하게 처리해야 하므로 페이지 로드 지연을 초래할 수 있다. 무거운 계산 작업이나 오랜 시간이 걸리는 프로세스는 전용 라우트 핸들러에서 처리해야한다.

  • 광범위한 세션 관리 : 미들웨어는 기본적인 세션 작업을 관리할 수 있찌만, 광범위한 세션 관리는 전용 인증 서비스나 라우트 핸들러에서 관리하는 것이 좋다.

범인은 prefetch

오.. 캐시된 콘텐츠 ???
그럼 미들웨어가 캐시를 하는것이 아닌 prefetch가 캐싱 하는 것인가?? 해당 의문점에 대해서 찾아보았다.

미들웨어를 통해 prefetchfalse한 상태에서 링크에 마우스를 호버하는 순간 페이지 컴포넌트 스크립트가 보였다.

            r.jsxs)(S, {
                children: [(0,
                r.jsx)("p", {
                    children: "선택된 리스트가 없습니다."
                }), (0,
                r.jsx)("p", {
                    children: "리스트를 선택해주세요."
                })]
            })
        };

코드 내부를 살펴본 결과 컴포넌트를 트랜스파일로 변환한 코드가 보였다. prefetch는 분명 false하면 동작을 하지 않는다고 했는데 왜 동작을 하는거지....

스택 오버플로우에도 prefetch에 관련된 문제사항이 많은듯 한데... 특정 버전 버그인가..
일단 그 문제는 넘어가고 해당 파일이 캐싱이 되는건지 확인을 해보았다.

아 범인 prefetch 맞네... prefetch가 동작하면서 해당 페이지가 캐싱이되고, 인증 후에 캐싱된 페이지에 재 접근 할려고하니깐 midlleware가 동작을 안하는거구나ㅎㅎㅎ...

오키 캐싱을 제어하자..

원점으로 ...

   const response = NextResponse.redirect(url);
    // 캐싱 제어를 위한 응답 헤더 설정
    response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate');
    response.headers.set('Pragma', 'no-cache');
    response.headers.set('Expires', '0');

캐싱을 제어하기 위해 토큰이 없는 경우(!token)에 반환된 페이지는 캐싱이 되지 않게 설정 해주었다. 개발자모드에서 네트워크탭을 통해 확인 해본 결과 캐싱이 되지 않는 것을 확인하였다. 다음으로 프로덕션모드의 상황은 오히려 악화가 되어 완전한 무한 로딩에 빠지는 상황을 초래했다.

현재 까지 확인한 방법중 제일 간단한 해결 방법은 <a/> 태그를 사용하는 방법이다. 하지만 이 경우 사용자의 부드러운 화면전환의 경험을 해치기 때문에 끔찍하다. 스택 오버플로우에서 비슷한 문제를 겪은 사용자를 찾을 수 있었지만, 명확한 해결 방법은 발견하지 못했다. 여러 가지 방법을 시도해 보았지만, 현재는 미래에 참고할 만한 예제만 기록 해두었다.

미들웨어냐, prefetch 그것이 문제..

결론적으로 미들웨어 사용을 포기하기로 결정했다. 그 이유는 Vercel에서 미들웨어를 일정량 이상 사용하면 추가 과금이 발생한다. 그리고 현재로서는 도저히 해결 방법을 찾을 수 없었다. 다양한 시도를 해봤고, 문제의 원인을 파악했지만, 지금의 나로서는 해결할 수 없는 문제인 것 같다.

애초에 동적 세그먼트에 대한 접근 권한을 편하게 제어하려고 찾은 방법중 하나일 뿐인데 prefetch를 포기하고 <a/>태그를 사용하면서까지 사용자의 경험을 해치는 것은 바람직하지 않다고 생각된다.

대안

기존

  1. 기존에는 HOC로 접근 권한을 관리했지만, 동적 세그먼트를 고려하지 못해 정적 세그먼트만 처리했고, 이로 인해 문제가 발생했다

  2. 이후 페이지 접근 권한 관리를 미들웨어로 넘겼으나, prefetch로 인한 캐싱 이슈가 발생했다.

고려사항

FSD 아키텍처에서는 레이어 간의 접근 규칙이 중요하므로, 각 ProtectedPage마다 HOC로 감싸는 방식은 적절하지 않다.

인증을 제어하려면 entities이상의 레이어에서 처리해야 한다. 이유는 인증 관련 로직이 entities 레이어에 접근할 수 있어야 하기 때문이다. FSD에서 entities의 바로 상위 계층은 features이지만, 현재 타겟 대상이 features에 속해 있다. 이 경우, 동일한 레이어 내의 슬라이스들 간에는 직접 접근이 불가능하므로 문제를 해결할 수 없다.

따라서 결론적으로, 인증 관리를 위해 AuthProvider를 개선하는 것이 올바른 접근이다.

결과

export default function AuthProvider({ children }: Props) {
  const isVerifiedToken = useAuth();
  const router = useRouter();

  const protectedPages = [
    /^\/coverLetter(?:\/.*)?$/,
    /^\/qna\/detail\/.*/,
    /^\/profile\/.+/,
    /^\/myProfile/,
  ];

  const isProtectedPage = protectedPages.some((pattern) =>
    pattern.test(router.asPath));

  if (!isVerifiedToken && isProtectedPage) {
    return <ProtectedPage />;
  }

  return <div>{children}</div>;
}

AuthProvider의 역할은 현재 페이지 보호된 페이지일 경우 인증을 통과했는지 유무를 판단한다.

인증에 대한 확인은 useAuth가 담당한다.

느낀점

결론적으로, prefetch 문제를 완전히 해결하지는 못했지만, 문제의 원인을 파악하기 위해 탐색하는 과정에서 prefetch와 middleware에 대해 이전보다 훨씬 깊이 이해할 수 있었다. 이전에도 이 주제들을 간략히 찾아본 적은 있지만, 기억에 남지 않았던 걸 보면 사용 방법에만 집중했었나 보다. 이번 경험을 통해 트렌디한 기술에 도전하는 것도 좋지만, 그 기술을 제대로 이해하고 활용하는 것이 중요하다는 사실을 다시 한번 몸소 깨달았다.

그 와중에 잘했다고 생각되는 점도 있었다. FSD 아키텍처와 같은 구조화된 설계가 트러블슈팅 과정에서 큰 도움이 됐다는 점이다. 덕분에 문제 해결 시 어느 부분에서 접근해야 할지 빠르게 파악할 수 있었다.

profile
성장은 점진적 과부하, 매주 회고를 목표로 시작했지만 그때 그때 컨셉이 달라요. 시행착오를 통해 저만의 방식을 찾아가는중입니다.

0개의 댓글