Next.js의 App Router는 기존 Page Router와 비교했을 때 데이터 페칭 방식에서 많은 변화가 있었다. 이번 글에서는 App Router에서 데이터 페칭이 어떻게 바뀌었는지, 구체적인 차이점과 장점, 그리고 실습 예제까지 살펴보도록 하겠다.
기존의 Page Router에서는 다음과 같은 특수한 함수들을 이용해 서버 측 데이터 페칭을 처리했다.
getServerSideProps
: SSR(Server Side Rendering) 방식으로 데이터를 페칭.
getStaticProps
: SSG(Static Site Generation) 또는 ISR 방식으로 데이터를 페칭.
getStaticPaths
: 동적 경로를 가지는 페이지를 정적으로 생성하기 위해 사용.
Page Router에서는 모든 컴포넌트가 클라이언트 컴포넌트로 동작했기 때문에, 데이터 페칭 로직이 브라우저에서도 hydration과정에서 한 번 더 실행되었다.
데이터는 최상단의 page
컴포넌트에서만 페칭할 수 있었고, 이를 하위 컴포넌트에 전달하기 위해 props나 Context API를 사용해야 했다.
결과적으로 컴포넌트 트리가 복잡할수록 데이터 전달이 번거로워졌다.
App Router에서는 서버 컴포넌트(Server Component)를 활용할 수 있게 되었다.
서버 컴포넌트는 서버에서만 실행되므로, 데이터 페칭 로직을 컴포넌트 내부에 직접 작성할 수 있다.
async
키워드와 await
을 활용해 비동기적으로 데이터를 페칭할 수 있다.
데이터 페칭 위치: getServerSideProps
, getStaticProps
, getStaticPaths
같은 특수 함수에서 데이터를 페칭.
제한점:
이러한 함수에서 페칭된 데이터는 최상단의 Page 컴포넌트에서만 사용할 수 있다.
하위 컴포넌트로 데이터를 전달하려면 props
나 Context API
등을 사용해야 하며, 컴포넌트 트리가 복잡할수록 관리가 어려워진다.
데이터 페칭 위치: 데이터가 필요한 서버 컴포넌트 내부에서 직접 페칭 가능.
장점:
컴포넌트 트리가 깊어지더라도 해당하는 컴포넌트가 직접 페칭해 오면 됐기 때문에 props전달이나 Context API를 사용할 필요 없이 필요한 컴포넌트에서 데이터를 직접 페칭 가능.
유지보수성 및 코드 가독성이 크게 향상
구분 | Page Router | App Router |
---|---|---|
데이터 페칭 위치 | 특수 함수 내부 (getServerSideProps 등) | 컴포넌트 내부에서 직접 작성 |
데이터 전달 방식 | 최상단 컴포넌트에서 props로 전달 | 필요한 컴포넌트에서 직접 페칭 |
컴포넌트 유형 | 모든 컴포넌트가 클라이언트 컴포넌트 | 서버 및 클라이언트 컴포넌트 분리 |
중복 실행 문제 | 클라이언트에서도 한 번 더 실행됨 | 서버에서만 실행되므로 중복 실행 문제 없음 |
원래의 React컴포넌트 즉, 클라이언트 컴포넌트 에서는 이렇게 async키워드를 붙여서 비동기 함수로 설정할 수 없다. 그 이유는, 클라이언트 컴포넌트들은 브라우저에서도 동작을 해야되기 때문에 async키워드를 붙여서 비동기로 동작하게 설정할 경우에는 props의 전달이나 메모이제이션 useMemo나 useCallback등을 사용하는 메모이제이션 차원에서 여러가지 문제를 일으킬 수 있기 때문이다.
서버 컴포넌트는 서버에서만 실행되기 때문에 async
키워드를 붙여 비동기 함수로 사용할 수 있다.
await
과 fetch
를 사용하여 데이터를 바로 가져와 컴포넌트 내부에서 활용할 수 있다.
클라이언트 컴포넌트와 달리, 브라우저에서 실행되지 않기 때문에 데이터 페칭이 보안적으로도 안전하다.
Next.js App Router의 가장 큰 변화 중 하나는 서버 컴포넌트를 활용하여 컴포넌트 자체에서 데이터를 직접 페칭할 수 있다는 점이다.
기존 Page Router의 데이터 페칭 방식과 비교해 큰 장점이 생겼으며, Next.js의 공식 문서에서도 강조하는 "데이터가 필요한 곳에서 데이터를 페칭(Fetching data where it's needed)" 원칙을 실현할 수 있게 되었다.
index
페이지에서 추천 도서와 등록된 도서 데이터를 각각 불러와 렌더링한다.// src/app/page.tsx
import BookItem from "@/components/book-item";
import style from "./page.module.css";
export default async function Home() {
// 데이터 페칭
const response = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/books`);
if (!response.ok) {
return <div>오류가 발생했습니다.</div>;
}
const allBooks = await response.json();
return (
<div className={style.container}>
<section>
<h3>추천 도서</h3>
{allBooks.slice(0, 5).map((book) => (
<BookItem key={book.id} {...book} />
))}
</section>
<section>
<h3>등록된 모든 도서</h3>
{allBooks.map((book) => (
<BookItem key={book.id} {...book} />
))}
</section>
</div>
);
}
async 키워드를 사용하여 Home 컴포넌트를 비동기 함수로 선언.
fetch 메서드를 활용해 백엔드에서 데이터를 가져옴.
다음으로는 예외처리까지 추가해야 한다. api서버로부터 데이터를 불러올 때는 반드시 예외처리가 있어야 한다.
응답 상태(response.ok
)가 false
인 경우, 요청이 실패했다는 뜻이기 때문에 이럴 때에 처리할 수 있는 예외처리 코드를 작성해 주는데, 여기서는 간단하게 return div태그로 "오류가 발생했습니다." 라고 작성해 준다.
이어서 allBooks 변수에 response의 json메서드를 호출해서 응답을 json형태로 변환한 값을 담아준다. 그리고 결과를 console로 호출해 보면, 브라우저 콘솔엔 아무것도 출력되지 않는다. 왜냐면 이 Home컴포넌트는 서버에서만 실행되는 서버 컴포넌트이기 때문이다. 그래서 브라우저에서는 이런 데이터 페칭이 동작하지 않는다.
데이터를 매핑하여, JSON 형태로 파싱한 데이터를 BookItem 컴포넌트에 전달하여 렌더링한다.
이렇듯이 Next의 App Router
버전에서는 서버 측에서만 실행되는 서버 컴포넌트
를 활용할 수 있기 때문에 컴포넌트 자체를 async키워드를 붙여 비동기 함수로 만들어 줄 수 있다. 그리고 그렇기 때문에 await키워드와 함께 데이터를 불러오는 로직을 컴포넌트 내부에 작성할 수 있다.
추천도서 섹션의 도서도 불러와야 되는데 그럼 Home컴포넌트 내부에서 fetch메서드를 두 번 호출하게 된다. 그럼 예외처리도 총 두 번 해줘야 되기 때문에 컴포넌트 내부의 코드가 길어지게 되어서 가독성이 떨어지게 된다. 이런 경우에는 불러와야 하는 데이터에 따라서 컴포넌트를 나눠주는게 좋다. 그럼 훨씬 더 가독성 있게 컴포넌트를 작성할 수 있다.
// src/components/all-books.tsx
import BookItem from "@/components/book-item";
import { BookData } from "@/types";
export default async function AllBooks() {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/books`);
if (!response.ok) {
return <div>오류가 발생했습니다.</div>;
}
const allBooks: BookData[] = await response.json();
return (
<div>
{allBooks.map((book) => (
<BookItem key={book.id} {...book} />
))}
</div>
);
}
기존의 Home컴포넌트에 작성했던 코드를 그대로 가져와서 컴포넌트만 따로 만들어준다.
한 가지 문제인 타입 오류를 해결하자면, book매개변수의 타입이 현재 any타입으로 추론되고 있기 때문에 오류가 발생하고 있다. 이유는, 데이터를 api서버로부터 받아오는 과정에서 타입스크립트는 현재 fetch요청의 결과 값이 어떤 타입으로 들어올지 알 수가 없다.
그래서 기본적으로는 allBooks의 타입을 any타입으로 추론한다.
그래서 이럴 때에는 직접 명시적으로 타입 정보를 정의해주면 된다.
// src/components/reco-books.tsx
import BookItem from "@/components/book-item";
import { BookData } from "@/types";
export default async function RecoBooks() {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/recommended`);
if (!response.ok) {
return <div>오류가 발생했습니다.</div>;
}
const recoBooks: BookData[] = await response.json();
return (
<div>
{recoBooks.map((book) => (
<BookItem key={book.id} {...book} />
))}
</div>
);
}
// src/app/page.tsx
import AllBooks from "@/components/all-books";
import RecoBooks from "@/components/reco-books";
import style from "./page.module.css";
export default function Home() {
return (
<div className={style.container}>
<section>
<h3>추천 도서</h3>
<RecoBooks />
</section>
<section>
<h3>등록된 모든 도서</h3>
<AllBooks />
</section>
</div>
);
}
AllBooks컴포넌트와 RecoBooks컴포넌트를 Home컴포넌트의 return문 안에서 기존의 코드에 대체한다.
이렇게 필요한 데이터를 서버 컴포넌트에서 직접 불러오도록 코드를 작성해 봤다.
위 실습 코드에서 보면 백엔드 api서버의 주소를 환경변수를 불러와 사용하고 있는데, 이렇게 사용해야 하는 이유는, api서버에서 데이터를 가져오기 위해 fetch메서드를 호출할 때마다 매번 반복되어 작성되기 때문이다. 이렇게 되면 나중에 api서버의 주소가 바뀌게 되면 모든 fetch메서드를 찾아서 주소를 일괄적으로 바꿔줘야 하기 때문에 불편한 상황이 생기게 된다. 그래서 이럴 때에는 백엔드 api서버의 주소를 환경변수로서 등록한 다음에 컴포넌트에서는 그냥 불러다가 사용할 수 있도록 만들어 주는게 좋다.
Root 디렉토리에 .env
라는 환경변수 파일을 만들어 준다.
파일 안에서 NEXT_PUBLIC_API_SERVER_UR
이것처럼 환경 변수를 대문자로 만든 다음에 백엔드 주소를 등록한다. (환경 변수 이름에 NEXT_PUBLIC_
접두사를 붙이면 클라이언트 컴포넌트에서도 접근 가능하다.)
NEXT_PUBLIC_API_SERVER_URL=https://api.example.com
process.env
를 사용해 NEXT_PUBLIC_API_SERVER_URL
이라고 입력을 해주면 모든 데이터가 잘 불러와 진다.NEXT_PUBLIC_
접두사의 역할환경변수 앞에 NEXT_PUBLIC
접두사를 붙여주는 이유는, 이 접두사를 붙이지 않으면 Next는 자동으로 해당 환경 변수를 서버 측에서만 활용할 수 있게 private으로 설정해 버린다.
그래서 그냥 API_SERVER_URL
이라는 이름으로만 환경 변수를 등록하면 Next가 자동으로 해당 환경 변수는 서버 측에서만 접근할 수 있도록 설정을 해버리기 때문에 클라이언트 컴포넌트 에서는 환경변수에 접근할 수 없게 된다.
그래서 클라이언트 컴포넌트에서도 활용해야 하는 환경 변수가 있다면, NEXT_PUBLIC이라는 접두사를 꼭 앞에 붙여주도록 하자.
Next.js의 클라이언트 컴포넌트는 React 컴포넌트로, 브라우저에서도 동작해야 한다. 그러나 async/await
을 사용하면 React의 렌더링 및 상태 관리 원칙과 충돌이 발생할 수 있다. 이와 관련된 주요 이유들을 정리하면 다음과 같다.
React는 컴포넌트가 실행되면 즉시 HTML 구조나 반환값을 생성하기를 기대한다.
하지만 async/await
키워드를 사용하면 함수는 항상 Promise를 반환한다. 이 경우 React는 비동기적으로 대기하는 동안 컴포넌트가 결과를 즉시 반환하지 못하고 컴포넌트가 반환하는 결과를 사용할 수 없게 된다. 이는 React의 렌더링 흐름을 깨뜨릴 수 있다.
React는 클라이언트 컴포넌트를 브라우저에서도 다시 실행(hydration)한다.
브라우저는 비동기 함수에서 데이터를 기다릴 수 없기 때문에, 컴포넌트 렌더링이 지연되거나 예상치 못한 동작이 발생할 수 있다.
useMemo
나 useCallback
은 동기적으로 동작하도록 설계되었다.React의 메모이제이션 훅(useMemo, useCallback)은 동기적으로 실행되며, 렌더링 중 즉각적으로 값을 반환해야 한다.
그러나 async/await
같은 비동기 데이터는 서버에서 응답이 오는 시점에 따라 갱신된다.
예를 들어, 서버의 부하, 네트워크 지연, 또는 여러 번의 요청이 있을 경우 데이터가 순차적이 아니라 불규칙하게 도착하게 되는데 이렇게 되면 잘못된 값 (ex: null, undefined...)을 참조하게 될 수도 있고, 이렇게 네트워크 지연이나 비동기 갱신으로 인해 데이터가 자주 업데이트 되면, 메모이제이션된 값이 의존성 배열의 변경에 따라 여러 번 재계산되기도 한다.
비동기 작업은 요청이 완료되는 순서가 고정되지 않는다. 특히 여러 비동기 작업이 병렬로 실행되는 경우, 비동기 데이터가 도착 시점이 다르거나, 순서가 꼬일 경우가 있어 잘못된 값이 메모이제이션될 가능성이 있다.
/api/data2
가 /api/data1
보다 먼저 도착), 이전 값이 더 나중에 렌더링될 가능성이 있다. 결과적으로 사용자 인터페이스에 잘못된 값이 나타날 수 있다.결과적으로 메모이제이션된 값이 예기치 않게 여러 번 재계산되거나 잘못된 결과를 반환할 수 있다.
순차적 도착의 경우
요청 1 → 요청 2 순서로 응답이 도착한다.
의존성 배열 갱신도 요청 1 응답 → 요청 2 응답 순서로 차례대로 일어난다.
React는 이전 상태에서 새로운 값을 계산하는 과정이 예측 가능하기 때문에, 계산된 값이나 렌더링에 문제가 발생하지 않는다.
비순차적 도착의 경우
요청 2 → 요청 1 순서로 응답이 도착한다.
의존성 배열 갱신은 요청 2 응답 → 요청 1 응답 순서로 일어나지만, 문제는 React가 첫 번째 갱신(요청 2 응답) 기준으로 계산을 수행한 후, 두 번째 갱신(요청 1 응답) 시점에서 잘못된 중간 상태를 참조할 가능성이 있다는 점이다.
요청 1의 응답이 도착해 의존성 배열이 다시 갱신될 때, 이전 계산 결과(요청 2의 결과)가 잘못된 상태로 반영되거나 덮어쓰여질 수 있다.
서버 컴포넌트는 오직 서버에서만 실행되므로, 브라우저의 동작과는 무관하게 동작한다.
클라이언트 컴포넌트는 React의 동기적 렌더링 방식과 브라우저 실행 환경 때문에 async 키워드를 사용할 수 없다.
반면, 서버 컴포넌트는 브라우저에서 실행되지 않으므로 async/await을 활용해 비동기 데이터를 안전하게 처리할 수 있다.
App Router의 도입으로 인해 데이터 페칭 방식이 크게 개선되었다.
기존 Page Router에서의 번거로운 데이터 전달 구조 예를 들면, getServerSideProps
같은 특수 함수 없이도 필요한 컴포넌트 내부에서 데이터를 직접 가져올 수 있다.
데이터가 필요한 컴포넌트에서 직접 페칭할 수 있어 가독성과 유지보수성이 향상되었다.