Loki's Blog

getServerSideProps에서 실제로 일어나는 일

  • CS
  • Next.js
2026. 08. 21.
Notion image

들어가며


getServerSideProps 안에서 일어나는 일을 정확히 설명하지 못했다.

getServerSideProps를 설명해 보라고 하면 “서버에서 데이터를 가져와 props로 넘기는 함수”라고 말할 수 있다. 하지만 브라우저의 요청이 들어온 뒤 이 함수가 언제 실행되고, 반환값이 어떤 과정을 거쳐 페이지에 표시되는지까지 정확히 설명하려면 생각보다 많은 부분을 구분해야 한다.

Next.js Pages Router에서 getServerSideProps를 사용하다 보면 다음과 같은 코드도 자주 작성하게 된다. 코드는 동작하지만, 왜 이런 값을 반환하거나 헤더를 설정하는지 설명하려고 하면 막히는 경우가 있었다.

res.setHeader(
  "Cache-Control",
  "public, s-maxage=10, stale-while-revalidate=59",
);

getServerSideProps는 서버에서만 실행된다


서버에서 실행되며, 서버 전용 코드와 데이터를 다룰 수 있다.

getServerSideProps는 Pages Router의 페이지 파일에서 export할 수 있는 특별한 함수다. 이 함수 내부의 코드는 브라우저가 아니라 서버에서 실행된다. 따라서 데이터베이스에 직접 접근하거나, 서버 전용 API를 호출하거나, 서버 환경변수를 읽을 수 있다.

// pages/index.tsx

import type {
  GetServerSideProps,
  InferGetServerSidePropsType,
} from "next";

type Repo = {
  name: string;
  stargazers_count: number;
};

// 페이지 요청마다 서버에서 실행
export const getServerSideProps = (async () => {
  // 서버에서 GitHub API 호출
  const res = await fetch("https://api.github.com/repos/vercel/next.js");

  if (!res.ok) {
    throw new Error("Failed to fetch repository");
  }

  const repo: Repo = await res.json();

  // 반환한 props를 페이지 컴포넌트에 전달
  return {
    props: {
      repo,
    },
  };
}) satisfies GetServerSideProps<{ repo: Repo }>;

// getServerSideProps의 반환값을 기준으로 props 타입 추론
export default function Page({
  repo,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
  return <p>{repo.stargazers_count}</p>;
}
서버 코드는 클라이언트 번들에 포함되지 않는다

getServerSideProps 내부의 함수와 서버 전용 import는 브라우저용 클라이언트 번들에 포함되지 않는다. 따라서 이 함수 안에서는 데이터베이스 클라이언트나 서버 전용 SDK를 사용해도 해당 코드가 브라우저로 전송되지 않는다.

Notion image

여기서 서버에 남는 코드와 클라이언트로 전달되는 데이터를 구분해야 한다. 서버에서 실행된 로직 자체는 브라우저로 전송되지 않지만, 그 로직이 props로 반환한 값은 페이지 컴포넌트에 전달된다.

export async function getServerSideProps() {
  const user = await getUserFromDatabase();

  return {
    props: {
      user,
    },
  };
}

user 값은 서버가 HTML을 생성할 때 사용되고, 클라이언트가 하이드레이션할 때 초기 데이터로도 사용된다. 따라서 props에 포함된 값은 클라이언트에서 확인할 수 있다.

비밀번호 해시, 내부 권한 정보, 서버 전용 토큰처럼 외부에 노출되면 안 되는 값은 props에 그대로 담아 반환해서는 안 된다. 필요한 필드만 선택해 클라이언트에 전달할 데이터를 제한하는 것이 안전하다.

export async function getServerSideProps() {
  const user = await getUserFromDatabase();

  return {
    props: {
      user: {
        id: user.id,
        name: user.name,
        profileImageUrl: user.profileImageUrl,
      },
    },
  };
}

즉, getServerSideProps 내부의 로직은 서버 전용이며, props로 반환한 값은 클라이언트에 전달된다.

새로고침 시 실행되는 순서


getServerSideProps가 실행된 뒤 HTML이 만들어진다.

브라우저에서 /repo에 직접 접속하거나 페이지를 새로고침하면 Next.js는 요청을 처리하는 과정에서 해당 페이지의 getServerSideProps를 실행한다. 반환된 props는 페이지 컴포넌트에 전달되고, 서버는 이 데이터를 사용해 HTML을 생성한다.

Notion image
  1. 브라우저가 /repo를 서버에 요청한다.
  2. Next.js 서버가 해당 페이지의 getServerSideProps를 실행한다.
  3. 함수가 데이터베이스나 외부 API에서 데이터를 가져온다.
  4. getServerSideProps가 반환한 props를 페이지 컴포넌트에 전달한다.
  5. Next.js가 페이지 컴포넌트를 서버에서 렌더링한다.
  6. 서버가 생성한 HTML과 클라이언트에서 사용할 초기 페이지 데이터를 브라우저에 전달한다.
  7. 브라우저는 HTML을 먼저 화면에 표시한다.
  8. React가 서버 HTML과 초기 props를 이용해 하이드레이션한다.
하이드레이션에 초기 데이터가 필요한 이유

서버가 다음과 같은 props를 사용해 HTML을 만들었다고 하자.

{
  repo: {
    stargazers_count: 12345
  }
}

서버는 이 값을 사용해 다음과 같은 HTML을 생성한다.

<p>12345</p>

하이드레이션할 때 클라이언트가 다른 값인 67890을 사용하면 서버가 만든 HTML과 클라이언트가 렌더링하려는 결과가 달라진다. React는 서버와 클라이언트의 렌더링 결과가 일치한다고 기대하기 때문에, 클라이언트도 서버 렌더링에 사용한 것과 같은 초기 데이터를 사용해야 한다.

세 가지 결과를 반환할 수 있다


getServerSideProps는 일반적인 함수처럼 임의의 값을 반환하지 않는다. Next.js가 요청 결과로 해석할 수 있는 다음 세 가지 형태 중 하나를 반환해야 한다.

props: 페이지를 정상적으로 렌더링한다

데이터를 조회한 뒤 페이지를 렌더링하려면 props를 반환한다.

export async function getServerSideProps() {
  const data = await getData();

  return {
    props: {
      data,
    },
  };
}

반환한 props는 페이지 컴포넌트의 props로 전달된다.

export default function Page({ data }) {
  return <div>{data.title}</div>;
}

이때 props에 담긴 값은 서버 렌더링과 클라이언트 하이드레이션에 사용된다. 따라서 직렬화할 수 있고, 클라이언트에 공개해도 되는 값만 포함해야 한다.

notFound: 404 페이지를 표시한다
export async function getServerSideProps({ params }) {
  // 동적 라우트의 id 추출
  const id = params?.id;

  // id가 없거나 문자열이 아니면 404 반환
  if (typeof id !== "string") {
    return {
      notFound: true,
    };
  }

  // id로 게시글 조회
  const post = await getPost(id);

  // 게시글이 없으면 404 반환
  if (!post) {
    return {
      notFound: true,
    };
  }

  // 조회한 게시글을 props로 전달
  return {
    props: {
      post,
    },
  };
}

notFound: true를 반환하면 Next.js는 해당 요청을 404로 처리하고 404 페이지를 표시한다.

이 결과는 해당 URL이 과거에 존재했는지와 관계없이 현재 요청의 상태를 기준으로 적용된다. 예를 들어 이전에는 존재하던 게시글이 삭제되었다면, 같은 URL에 대해서도 이후 요청에서는 notFound: true를 반환할 수 있다.

redirect: 다른 페이지로 이동시킨다

요청을 현재 페이지에서 처리하지 않고 다른 URL로 보내야 한다면 redirect를 반환한다.

// 요청 쿠키에서 인증 토큰 확인
export async function getServerSideProps({ req }) {
  const token = req.cookies.authToken;

  // 토큰이 없으면 로그인 페이지로 리다이렉트
  if (!token) {
    return {
      redirect: {
        destination: "/login",
        permanent: false,
      },
    };
  }

  // 인증된 요청에 빈 props 반환
  return {
    props: {},
  };
}

로그인하지 않은 사용자를 로그인 페이지로 보내는 경우에는 인증 상태가 나중에 바뀔 수 있으므로 일반적으로 임시 리다이렉트를 사용한다. 반면 페이지가 영구적으로 다른 주소로 이동된 경우에는 영구 리다이렉트를 고려할 수 있다.

클라이언트 전환에서는 페이지 데이터를 요청한다


페이지를 이동할 때 전체 HTML 대신 필요한 페이지 데이터만 가져온다.

브라우저에서 next/link를 클릭하거나 next/router를 사용해 페이지를 이동하면 전체 문서를 새로고침하지 않는다. Next.js의 클라이언트 라우터가 현재 React 애플리케이션을 유지한 채, 이동할 페이지에 필요한 데이터를 서버에 요청한다.

이 과정에서도 서버는 해당 페이지의 getServerSideProps를 실행한다. 다만 직접 접속하거나 새로고침할 때처럼 완성된 HTML 문서 전체를 반환하는 것이 아니라, 클라이언트 라우터가 페이지를 갱신하는 데 필요한 페이지 데이터를 반환한다. 이 데이터는 네트워크 응답에서 JSON 형태로 전달될 수 있다.

공식 문서도 next/link 또는 next/router를 통한 클라이언트 전환에서 Next.js가 서버에 데이터 요청을 보내고, 서버 렌더링 페이지의 데이터를 받아 현재 페이지를 갱신한다고 설명한다.

Notion image
resolvedUrl은 원래 페이지 URL을 보여준다

클라이언트 전환이 발생하면 브라우저가 요청하는 내부 경로에 /_next/data와 빌드 ID가 포함될 수 있다.

/_next/data/{buildId}/repo.json

하지만 getServerSideProps 내부에서 context.resolvedUrl을 확인하면 이런 내부 데이터 요청 경로가 아니라, 사용자가 이동하려던 원래 페이지 URL을 확인할 수 있다.

예를 들어 클라이언트 라우터가 내부적으로 다음과 같은 요청을 보내더라도,

/_next/data/{buildId}/repo.json

resolvedUrl에서는 다음과 같이 페이지 기준의 URL을 확인할 수 있다.

/repo

resolvedUrl은 내부 데이터 요청 경로를 페이지가 이해할 수 있는 URL 형태로 정규화한 값이다. 따라서 요청 URL을 기준으로 로직을 작성할 때는 /_next/data 같은 내부 경로를 직접 파싱하기보다 resolvedUrl을 사용하는 편이 적절하다.

Notion image

props로 반환하는 값은 직렬화할 수 있어야 한다


직렬화할 수 있는 값만 반환한다

props는 서버에서 클라이언트로 전달되는 값이므로 직렬화할 수 있어야 한다. 일반적으로 JSON으로 표현할 수 있는 형태로 변환해 반환해야 한다.

return {
  props: {
    userId: "user-1",
    name: "Kim",
    createdAt: new Date(),
  },
};

Date는 직렬화 과정에서 문자열로 변환된다. 클라이언트에서 날짜 객체로 사용하려면 다시 복원해야 한다.

// "2026-08-23T12:00:00.000Z"
const createdAt = new Date(user.createdAt);

실제 애플리케이션에서는 서버 모델을 그대로 반환하기보다, 클라이언트에 전달할 데이터 형태를 별도의 타입으로 정의하는 방식이 좋다. 이렇게 하면 불필요한 필드와 민감한 정보가 실수로 포함되는 것을 줄일 수 있다.

type UserProps = {
  id: string;
  name: string;
  createdAt: string;
};

// 사용자 정보를 조회하고 클라이언트 전달용 데이터로 변환
export async function getServerSideProps() {
  const user = await getUserFromDatabase();

  // 날짜를 직렬화 가능한 문자열로 변환
  const userProps: UserProps = {
    id: user.id,
    name: user.name,
    createdAt: user.createdAt.toISOString(),
  };

  // 클라이언트에 필요한 사용자 정보만 props에 포함
  return {
    props: {
      user: userProps,
    },
  };
}

getServerSideProps는 요청 시 실행된다


요청 시점의 정보를 사용해야 하는 페이지에 적합하다.

getServerSidePropsgetStaticProps처럼 빌드 시점에 한 번 실행되는 함수가 아니다. 페이지 요청을 처리하는 과정에서 실행되므로, 쿠키 / 헤더 / 세션 / 동적 라우트 파라미터처럼 요청이 들어온 뒤에야 알 수 있는 값을 사용할 수 있다.

따라서 다음과 같은 페이지에 적합하다.

  • 로그인한 사용자마다 다른 페이지
  • 요청 쿠키에 따라 결과가 달라지는 페이지
  • 최신 권한이나 세션 정보를 반영해야 하는 페이지
  • 요청 시점의 위치나 Authorization 헤더가 필요한 페이지
  • 매번 최신 데이터를 서버에서 조회해야 하는 페이지
  • 반대로 데이터가 자주 바뀌지 않고 사용자별 차이도 없다면, 매 요청마다 서버에서 페이지를 생성할 필요가 없을 수 있다. 이런 경우에는 getStaticProps와 ISR을 먼저 검토하는 편이 좋다.

    캐시 가능한 응답은 공유 캐시를 사용할 수 있다

    getServerSideProps를 사용하더라도, 모든 요청에 대해 반드시 새로운 응답을 생성해야 하는 것은 아니다. 사용자별로 결과가 달라지지 않고 일정 시간 동안 같은 응답을 공유해도 안전하다면 Cache-Control 헤더를 설정할 수 있다.

    export async function getServerSideProps({ res }) {
      res.setHeader(
        "Cache-Control",
        "public, s-maxage=10, stale-while-revalidate=59",
      );
    
      return {
        props: {},
      };
    }

    이 설정은 공유 캐시나 CDN이 응답을 재사용할 수 있도록 한다.

  • s-maxage=10: 공유 캐시에서 10초 동안 응답을 신선한 값으로 간주한다.
  • stale-while-revalidate=59: 이후 59초 동안 오래된 응답을 우선 제공하면서 백그라운드에서 새 응답을 생성할 수 있다.
  • 여기서 getServerSideProps 자체가 캐시 함수로 바뀌는 것은 아니다. 캐시가 애플리케이션 서버 앞에서 응답을 대신 반환하면 해당 요청이 애플리케이션 서버까지 도달하지 않을 수 있고, 그 경우 해당 요청에서는 getServerSideProps가 실행되지 않는다. 다만 이 동작은 배포 환경의 CDN이나 프록시가 해당 캐시 헤더를 올바르게 지원할 때만 기대할 수 있다.

    개인화된 페이지에는 public 캐시를 주의한다

    public 캐시는 여러 사용자가 응답을 공유할 수 있다는 의미다. 따라서 응답이 사용자마다 달라지는 페이지에 적용하면 한 사용자의 HTML이나 props가 다른 사용자에게 전달될 수 있다.

    다음과 같은 페이지에는 공유 캐시를 신중하게 적용해야 한다.

  • 로그인한 사용자의 이름을 표시하는 페이지
  • 사용자별 장바구니 페이지
  • 쿠키에 따라 권한이 달라지는 페이지
  • 개인화된 추천 결과를 보여주는 페이지
  • 캐시를 적용하기 전에 “이 페이지의 HTML과 props를 여러 사용자가 공유해도 안전한가?” 를 먼저 확인해야 한다. 안전하지 않다면 public 캐시를 사용해서는 안 된다. 개인화된 응답은 캐시하지 않거나, 사용자별 캐시 키와 적절한 Vary 정책을 별도로 설계해야 한다.

    context에는 요청 시점의 정보가 들어 있다


    context는 현재 요청을 처리하는 데 필요한 정보를 제공한다.

    getServerSidePropscontext에는 빌드 시점에는 알 수 없고, 사용자의 요청이 들어온 뒤에 확인할 수 있는 값들이 담겨 있다.

    필드설명
    params동적 라우트 파라미터
    reqNode.js의 HTTP 요청 객체. cookies 속성이 추가되어 있다
    resNode.js의 HTTP 응답 객체
    query쿼리스트링과 동적 라우트 파라미터
    resolvedUrl내부 /_next/data 경로가 제거된 정규화 URL
    draftModeDraft Mode 활성화 여부
    locale현재 로케일
    locales지원하는 전체 로케일
    defaultLocale기본 로케일
    요청 쿠키로 인증을 확인한다.

    로그인 여부는 빌드 시점에 결정할 수 없다. 사용자의 요청에 포함된 쿠키를 확인해야 하므로, getServerSideProps에서 req.cookies를 읽어 인증 상태를 판단할 수 있다.

    // 요청 쿠키에서 인증 토큰 확인
    export async function getServerSideProps({ req }) {
      const token = req.cookies.authToken;
    
      // 토큰이 없으면 로그인 페이지로 리다이렉트
      if (!token) {
        return {
          redirect: {
            destination: "/login",
            permanent: false,
          },
        };
      }
    
      // 서버에서 토큰 검증 및 사용자 조회
      const user = await getUserByToken(token);
    
      // 유효하지 않은 토큰이면 로그인 페이지로 리다이렉트
      if (!user) {
        return {
          redirect: {
            destination: "/login",
            permanent: false,
          },
        };
      }
    
      // 클라이언트에 필요한 사용자 정보만 props에 포함
      return {
        props: {
          user: {
            id: user.id,
            name: user.name,
          },
        },
      };
    }

    이 코드에서 getServerSideProps는 요청마다 다음 작업을 수행한다.

    1. 요청 쿠키에서 인증 토큰을 읽는다.
    2. 서버에서 토큰을 검증한다.
    3. 토큰이 없거나 유효하지 않으면 로그인 페이지로 리다이렉트한다.
    4. 인증된 사용자에게만 필요한 정보를 props로 전달한다.
    동적 라우트의 파라미터도 요청마다 달라진다.

    pages/posts/[id].tsx처럼 동적 라우트를 사용하면 params에서 경로 파라미터를 가져올 수 있다.

    // pages/posts/[id].tsx
    
    import type {
      GetServerSideProps,
      InferGetServerSidePropsType,
    } from "next";
    
    type Props = {
      post: {
        id: string;
        title: string;
      };
    };
    
    // [id]에 해당하는 게시글을 요청마다 서버에서 조회
    export const getServerSideProps = (async ({ params }) => {
      // 동적 라우트의 id 추출
      const id = params?.id;
    
      // id 누락 또는 잘못된 타입이면 404 반환
      if (typeof id !== "string") {
        return {
          notFound: true,
        };
      }
    
      // 서버에서 게시글 조회
      const post = await getPost(id);
    
      // 게시글이 없으면 404 반환
      if (!post) {
        return {
          notFound: true,
        };
      }
    
      // 클라이언트에 전달할 필드만 props에 포함
      return {
        props: {
          post: {
            id: post.id,
            title: post.title,
          },
        },
      };
    }) satisfies GetServerSideProps<Props>;
    
    // getServerSideProps가 반환한 post로 페이지 렌더링
    export default function Page({
      post,
    }: InferGetServerSidePropsType<typeof getServerSideProps>) {
      return <h1>{post.title}</h1>;
    }

    /posts/123을 요청하면 params.id에는 "123"이 들어온다. 이 값을 사용해 게시글을 조회하고, 게시글이 존재하면 props를 반환한다. 게시글을 찾을 수 없으면 notFound: true를 반환해 404 페이지를 표시한다.

    이처럼 params, 쿠키, 헤더, 쿼리스트링, 로케일은 모두 요청이 들어온 뒤에야 확인할 수 있는 값이다. 요청마다 달라질 수 있는 정보를 사용해야 한다면 getServerSideProps는 해당 페이지를 서버에서 렌더링할 수 있는 진입점이 된다.

    정리하며


    getServerSideProps를 선택하는 기준

    페이지에 fetch가 있다고 해서 반드시 getServerSideProps를 사용해야 하는 것은 아니다. 같은 API를 호출하더라도 호출 시점과 초기 HTML에 데이터가 필요한지에 따라 적합한 방식이 달라진다.

    상황적합한 방식
    사용자별 쿠키, 인증, 권한이 필요하다getServerSideProps
    요청 시점의 헤더나 지역 정보를 사용한다getServerSideProps
    빌드 시점에 데이터를 생성해도 된다getStaticProps
    데이터가 자주 바뀌지 않으며 정적 HTML을 캐시하고 싶다getStaticProps + ISR
    초기 HTML에 데이터가 필요하지 않다클라이언트에서 fetching
    서버에서 외부 API나 DB에 직접 접근할 수 있다getServerSideProps 내부에서 직접 호출

    getServerSideProps는 단순히 서버에서 데이터를 가져오는 함수가 아니다. 요청이 들어온 시점의 정보를 사용해 페이지를 서버에서 렌더링하고, 그 결과를 브라우저에 전달하기 위한 진입점이다. 따라서 사용 여부를 판단할 때는 다음 질문을 순서대로 확인하면 된다.

    Notion image

    Next.js 공식 문서 역시 SEO나 사전 렌더링이 필요하지 않은 페이지, 또는 런타임에 자주 갱신되는 콘텐츠에는 클라이언트 데이터 fetching을 사용할 수 있다고 설명한다.

    데이터를 어디서 가져오느냐가 아니라, 언제 어떤 방식으로 페이지에 반영하느냐

    이 기준을 알고 있으면 res.setHeader, notFound, redirect 같은 코드도 관례처럼 복사하는 데서 그치지 않고, 현재 요청을 어떤 방식으로 처리하기 위해 필요한지 설명할 수 있다.

    관련 글