
App Router의 장점은 우리에게도 장점일까요?
안녕하세요, 토스뱅크 프론트엔드 개발자 이다용입니다.
토스뱅크 프론트엔드에서는 Next.js Pages Router를 주로 사용해요. 그런데 Pages Router는 유지보수 모드예요. 보안 패치와 버그 수정은 이어지지만 새 기능은 들어오지 않죠. 언젠가 다음에 쓸 것을 정해야 했고, 가장 먼저 살펴본 선택지가 App Router와 React Server Components(RSC)였어요. 필요한 데이터를 컴포넌트마다 가져오고, 준비된 영역부터 보여준다는 점이 매력적이었거든요.
2025년 가을, 프레임워크에 관심 있는 동료들이 모여 기술그룹을 만들었어요. 현구님, 지우님, 현웅님과 실제 서비스를 옮겨보고 매주 막히는 지점을 공유했어요.
결론은 도입하지 않는 것이었어요. 저희 화면에서 얻을 수 있는 이득에 비해, 서비스의 안정성을 지키며 전환하고 유지하는 비용이 컸거든요.
어떻게 그 결론에 닿았는지, 검토 과정에서 마주한 질문들을 따라 이야기해볼게요.
준비된 것부터 보여주면 더 좋을까요?
기존에는 서버에서 화면을 그릴 때 필요한 데이터를 getServerSideProps 한 곳에서 모아 가져왔어요. 페이지 단위로만 쓸 수 있다 보니, 저희 구현에서는 API 하나가 실패하면 정상적으로 받은 데이터까지 다시 요청하는 경우가 있었어요.
App Router에서는 데이터를 가져오는 컴포넌트를 나누고 각각을 Suspense로 감싸, 준비된 영역부터 보여줄 수 있어요.
import { Suspense } from 'react';
export default function Page() {
return (
<>
<Suspense fallback={<AccountSkeleton />}>
<AccountSection />
</Suspense>
<Suspense fallback={<BannerSkeleton />}>
<BannerSection />
</Suspense>
</>
);
}토스뱅크의 화면은 대부분 토스 앱 안의 웹뷰로 이뤄져 있고, 요소가 주로 세로 한 줄로 쌓여요. 이 구조에서도 영역을 따로 보여주는 것이 자연스러운지 직접 재봤어요.
세 영역에 각각 1초, 3초, 5초 지연을 주고, 브라우저가 화면을 언제 그리는지 녹화했어요. 웹뷰는 대부분 안드로이드에서 Chromium, iOS에서 WebKit으로 돌아가기 때문에 두 엔진을 함께 봤고요.

첫 콘텐츠가 그려지는 시점(FCP)이 Chromium에서는 64밀리초, WebKit에서는 5,065밀리초였어요. WebKit은 마지막 섹션이 준비될 때까지 첫 표시를 미뤘어요. 화면을 그리기 전에 콘텐츠가 충분한지 판단하는 기준이 있어서인데, 이 때문에 표시가 늦어지는 현상은 공개 이슈에서도 논의되고 있어요.
우회는 가능했어요. 화면 위에 눈에 보이지 않는 SVG를 넣자 WebKit의 FCP가 103밀리초로 줄었어요. 두 엔진 모두 준비된 영역부터 그리기 시작한 거예요.
그런데 먼저 그리기 시작하니 다음 문제가 보였어요. 스켈레톤이 실제 콘텐츠로 바뀔 때 높이가 달라지거든요. 세로로 쌓인 화면에서는 위쪽 영역의 높이가 늘어나면, 이미 보고 있던 아래쪽 내용까지 그만큼 아래로 밀렸어요.

스켈레톤 높이를 콘텐츠가 들어갈 높이와 최대한 비슷하게 맞추자 움직임은 줄었어요. 레이아웃 이동값도 0.138에서 0.036으로 내려갔고요. 다만 섹션을 추가할 때마다 로딩 화면을 만들고, 콘텐츠가 바뀔 때마다 다시 맞춰야 했어요. 영역을 잘게 나눠 먼저 보여주는 이득보다, 화면이 흔들리지 않도록 관리하는 부담이 더 컸어요.
RSC를 우리 코드에서 얼마나 활용할 수 있을까요?
스트리밍을 넓게 쓰기는 어렵겠다고 봤지만, 그것만으로 App Router를 접을 수는 없었어요. 스트리밍을 쓰지 않더라도 RSC에는 따로 기대할 것이 있었거든요.
가장 큰 건 번들 크기예요. Server Component의 구현과 거기서만 쓰는 의존성은 클라이언트 번들에서 아예 빠지거든요.
부차적으로는 데이터를 가져오는 코드를 정리할 수 있었어요. 기존에는 인증 정보를 getServerSideProps에서 꺼내 서버용 API 함수에 넘겨야 했는데, App Router의 서버 코드에서는 headers()로 직접 읽을 수 있어요. 인증을 챙기는 일이 데이터를 가져오는 함수 안으로 들어가는 거예요. 브라우저에서 쿠키가 알아서 실려 나가듯, 서버에서도 부르는 쪽은 인증을 신경 쓰지 않아도 되고요.
다만 headers()를 Client Component에서 직접 사용할 수는 없어요. 서버에서 렌더링되는 동안에도 마찬가지예요. next/headers를 가져오는 순간 그 파일 자체가 브라우저로 나갈 수 없게 되기 때문에, 한 파일 안에서 조건문으로 갈라 쓰는 방법은 통하지 않았어요.
그래서 쓴 방법이 패키지의 환경별 분기예요. package.json의 조건부 export로 RSC용 구현과 클라이언트용 구현을 나누면, 호출하는 쪽에서는 같은 경로로 가져다 쓸 수 있어요. 구조를 단순화하면 다음과 같아요.
{
"name": "@example/http",
"exports": {
"./isomorphic": {
"react-server": "./dist/isomorphic.server.js",
"default": "./dist/isomorphic.client.js"
}
}
}RSC용 구현만 headers()로 인증 정보를 읽어 붙여요. 브라우저용 구현은 기존 방식 그대로예요. 쿠키가 알아서 실려 나가니 따로 할 일이 없거든요.
// isomorphic.server.ts
import { headers } from 'next/headers';
import { serverHttp } from './server';
export const http = {
async get(path: string) {
const authorization = (await headers()).get('authorization');
return serverHttp.get(path, {
headers: { Authorization: authorization ?? '' },
});
},
};
// isomorphic.client.ts
export { clientHttp as http } from './client';이제 부르는 쪽은 어느 환경인지 몰라도 돼요. 쿼리 정의 하나로 서버에서 미리 가져올 때와 브라우저에서 조회할 때를 모두 처리해요.
import { http } from '@example/http/isomorphic';
export const userQueryOptions = (userId: string) => ({
queryKey: ['user', userId],
queryFn: () => http.get(`/api/user/${userId}`),
});쿼리마다 서버용 코드를 따로 만들던 일이 사라지는 거예요. 처음에는 부차적인 이득이라고 봤는데, 만들어서 써보니 개발 흐름이 눈에 띄게 단순해졌어요. 번들 크기만큼이나 큰 이점이라고 생각을 바꿨어요.
다만 서버에서만 렌더링되는 트리(RSC)가 생긴 게 장점만 있는 건 아니었어요. 어디까지가 그 트리이고 어디부터 브라우저에서 도는 코드인지를 계속 구분해야 하거든요. 저희가 마주한 비용은 대부분 여기에서 나왔어요.
먼저 경계를 그을 수 있는 자리가 생각보다 적었어요. 검토한 화면에는 버튼, 입력, 웹뷰에서 앱으로 요청을 보내는 앱 브릿지 호출처럼 브라우저에서 처리할 동작이 많았거든요. Client Component로 남는 영역이 많았고, RSC를 쓸 수 있다고 본 자리는 주로 화면을 그리기 전에 데이터를 가져오는 부분이었어요. 거기서 데이터를 모두 기다린 뒤 화면을 보여준다면, 사용자가 겪는 로딩 흐름은 기존 방식과 크게 다르지 않았고요.
경계를 잘못 그었을 때 바로 드러나지 않는 것도 문제였어요. 앱 브릿지가 그랬어요. 앱 밖에서도 문제없이 돌아가도록 만들어져 있다 보니, Server Component에서 호출해도 에러 없이 그냥 넘어가거든요. 앱까지 요청은 가지 않는데도요. 이런 건 컴포넌트만 옮겨서는 드러나지 않아요. 함께 쓰는 공통 패키지도 새 실행 환경에 맞는지 하나씩 살펴봐야 했어요.
서버에서 캐시를 쓸 때도 새로 익혀야 할 규칙이 있었어요. 요청 간에 데이터를 재사용하는 캐시에 사용자별 응답을 저장할 때는, 캐시 키에도 사용자 구분이 들어가야 해요. 이를 빠뜨리면 다른 사용자의 응답이 재사용될 수 있거든요. 어떤 캐시가 어디까지 공유되는지 이해하고 써야 했어요.
요청 코드는 간결해졌지만, 이 경계를 익히고 안정적으로 다루는 비용은 늘었어요.
운영하면서 조금씩 옮길 수 있을까요?
Next.js는 Pages Router와 App Router를 함께 두고 화면을 하나씩 옮기는 점진적 마이그레이션을 지원해요. 저희도 일부 화면부터 적용하면서 이득과 비용을 확인하려고 했어요.
그런데 특정 경로 구조에서 기존 화면의 이동이 깨졌어요. basePath를 설정한 상태에서 Pages Router 화면끼리 이동하면, 경로 앞에 basePath가 두 번 붙었어요.
// Pages Router 화면에서
router.push('/a/a');
// 기대: /base/a/a
// 실제: /base/base/a/a → 404같은 이동을 두고 App Router 쪽의 구성을 바꿔보니 조건이 좁혀졌어요.
/a/[id]/a클라이언트 라우터는 이동할 주소가 App Router 쪽 화면인지 판단할 때 블룸 필터를 써요. 블룸 필터는 크기를 줄이는 대신 아닌 것을 맞다고 답하는 경우를 허용하고, 그래서 잘못 답해도 되돌릴 수 있는 자리에 쓰죠. 여기서도 맞다고 판단하면 페이지를 통째로 다시 불러와요. 서버는 실제 경로를 아니까, 판단이 틀렸더라도 화면은 제대로 나와야 하고요.
문제는 다시 불러오는 그 주소가 틀렸다는 점이었어요. basePath가 두 번 붙어 없는 주소로 가고, 사용자는 404를 봐요. 잘못 판단했을 때 되돌려주는 장치가 오히려 화면을 깨뜨린 거예요.
코드를 따라가 보니 App Router의 /a/[id]가 필터에 /a로 등록돼 있었어요. 이동할 주소의 앞부분을 하나씩 넣어보기 때문에, Pages Router의 /a/a도 /a에서 걸렸고요. 확률이 아니라 등록 방식 때문에 매번 걸린 거예요.
주소가 두 번 붙는 이유도 같이 나왔어요. Pages Router는 화면을 옮길 때 주소 앞에 basePath를 먼저 붙여요. 이미 붙어 있는 주소인데, 다시 불러올 주소를 만들 때는 확인 없이 한 번 더 붙인 거예요. 같은 유형의 이슈는 지금도 열려 있어요.
두 라우터를 함께 두는 건 위험을 줄이려고 고른 방법이었어요. 그런데 이 문제는 함께 두는 동안에만 생겨요. 안전하게 옮기려던 방법이 오히려 위험을 만든 셈이에요.
많은 개발자가 쓰면 해결도 빠를까요?
앞의 문제를 겪으면서도 크게 걱정하지는 않았어요. 많이 쓰는 프레임워크니까 필요한 기능이나 수정도 곧 나올 거라고 생각했거든요.
그런데 저희에게 필요했던 것들은 그렇지 않았어요. 로깅이 그랬어요. Pages Router의 router.pathname은 /blog/[slug] 같은 경로 패턴을 돌려줬어요. 화면별로 묶어서 집계하려면 이 패턴이 있어야 하거든요. 그런데 App Router의 usePathname()은 /blog/hello-world 같은 실제 주소만 돌려줘요. 이 정보를 제공하려는 PR은 2025년 10월 29일에 열렸고, 글을 쓰는 지금도 초안 상태예요.
사라진 router.events도 마찬가지였어요. URL 변화를 관찰하는 다른 방법은 있었지만, 기존에 쓰던 동작을 그대로 옮기려면 별도 패키지를 만들어 메워야 했어요.
많이 쓰인다고 우리 문제가 먼저 풀리는 건 아니었어요. 기다리는 시간과 그동안 직접 메우는 작업도 도입 비용이었고요.
그래서 Pages Router를 유지하기로 했어요
물론 이득도 있었어요. 데이터를 가져오는 코드가 정리되는 건 분명했고요. 다만 저희가 얻을 수 있는 이득에 비해, 전환하고 운영하는 비용이 컸어요.
그래서 이번에는 App Router를 도입하지 않고 Pages Router를 유지하기로 했어요. 앞으로도 새로운 기술을 검토할 때는 그 장점이 우리 서비스에서도 유효한지, 전환하고 운영하는 비용은 감당할 수 있는지 직접 확인하려고 해요.
앞으로도 검토할 기술과 내려야 할 결정은 많아요. 어떤 기술이 우리 서비스에 더 나은 선택일지 함께 고민해보고 싶은 분들의 토스뱅크 합류를 기다릴게요.
