odd voyage

Next.js redirect() 함수의 반환타입이 never인 걸 이해하면 Next.js의 구조가 보인다

Next.js의 next/navigation 모듈에서 redirect() 함수를 써본 적이 있는가? 이 함수는 서버 컴포넌트, 라우트 핸들러, 서버 액션에서 클라이언트를 다른 URL로 리디렉트하고 싶을 때 쓴다(영구적인 리디렉트는 permanentRedirect() 함수를 쓴다).

이 글은 redirect() 함수를 쓰다 궁금증이 생겨 시작하게 됐다.

import { redirect } from 'next/navigation';

redirect() 함수를 사용하다보면 발견되는 특이점

이 redirect() 함수를 쓰다 보면 내가 쓰는 Visual Studio Code에서 특이한 걸 보게 되는데, 바로 redirect() 함수 호출문 다음의 코드들이 반투명해지는 것이다. 이는 에디터가 코드를 분석해 불필요한 코드를 반투명 처리하는 것으로, 비교적 쉽게 마주치는 건 return 문 다음에 있는 코드들이 반투명해지는 경우로 return 문 다음의 코드들이 (대부분)쓰일 여지가 없기 때문이다.

타입스크립트 환경에서는 allowUnreachableCode 옵션을 false로 주면 쓰이지 않을 코드를 감지해 에러를 낸다(기본값은 undefined로 컴파일 에러는 내지 않고 에디터에서 경고만 해준다).

import { function redirect(url: string, type?: RedirectType): never
This function allows you to redirect the user to another URL. It can be used in [Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components), [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers), and [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). - In a Server Component, this will insert a meta tag to redirect the user to the target page. - In a Route Handler or Server Action, it will serve a 307/303 to the caller. - In a Server Action, type defaults to 'push' and 'replace' elsewhere. Read more: [Next.js Docs: `redirect`](https://nextjs.org/docs/app/api-reference/functions/redirect)
redirect
} from 'next/navigation';
function function sum(a: number, b: number): numbersum(a: numbera: number, b: numberb: number): number { return a: numbera + b: numberb; console.log(a + b);
Unreachable code detected.
} export default function function SomeDeprecatedPage(): nullSomeDeprecatedPage() { function redirect(url: string, type?: RedirectType): never
This function allows you to redirect the user to another URL. It can be used in [Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components), [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers), and [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). - In a Server Component, this will insert a meta tag to redirect the user to the target page. - In a Route Handler or Server Action, it will serve a 307/303 to the caller. - In a Server Action, type defaults to 'push' and 'replace' elsewhere. Read more: [Next.js Docs: `redirect`](https://nextjs.org/docs/app/api-reference/functions/redirect)
redirect
('/');
return null;
Unreachable code detected.
}

그럼 왜 redirect() 함수 다음의 코드들은 불필요한 코드로 간주될까? 그 이유는 redirect() 함수의 반환 타입이 never라서 그렇다.

반환 타입이 never인 함수

반환 타입이 never인 함수는 해당 함수가 정상적으로 값을 반환하지 않는다는 것을 의미한다. 자바스크립트에서 함수는 return문이 없어도 undefined를 반환하는데 반환 자체를 하지 않는 함수라는게 있을까? 있다, 항상 예외를 던지거나, 무한루프에 빠지거나, 프로그램 실행 자체가 종료되는 경우가 해당된다. 항상 예외를 던지는 함수는 반환 없이 예외가 던져지며 함수 실행이 끝날 것이고, 무한루프에 빠지는 함수는 함수가 종료 될 일이 없기에 반환하는 것도 없을 것이고, Node.js의 process.exit() 메서드처럼 프로그램 실행 자체가 종료되는 경우도 프로세스가 종료되므로 함수가 반환하지 반환하지 않을 것이다.

// ❌ 반환타입이 never인 함수는 정상적으로 종료되면 안된다.
function function fn1(): neverfn1(): never {}
A function returning 'never' cannot have a reachable end point.
// ✅ 1. 항상 예외를 던지는 함수는 반환타입이 never다. function function fn2(): neverfn2(): never { throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
();
} // ✅ 2. 항상 무한루프에 빠지는 함수는 반환타입이 never다. function function fn3(): neverfn3(): never { while (true) {} } // ✅ 3. Node.js 프로세스를 종료시키는 process.exit()의 반환타입은 never다. type
type ProcessExitReturnType = never
ProcessExitReturnType
= type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any
Obtain the return type of a function type
ReturnType
<typeof var process: NodeJS.Processprocess.NodeJS.Process.exit(code?: number | string | null | undefined): never
The `process.exit()` method instructs Node.js to terminate the process synchronously with an exit status of `code`. If `code` is omitted, exit uses either the 'success' code `0` or the value of `process.exitCode` if it has been set. Node.js will not terminate until all the `'exit'` event listeners are called. To exit with a 'failure' code: ```js import { exit } from 'node:process'; exit(1); ``` The shell that executed Node.js should see the exit code as `1`. Calling `process.exit()` will force the process to exit as quickly as possible even if there are still asynchronous operations pending that have not yet completed fully, including I/O operations to `process.stdout` and `process.stderr`. In most situations, it is not actually necessary to call `process.exit()` explicitly. The Node.js process will exit on its own _if there is no additional_ _work pending_ in the event loop. The `process.exitCode` property can be set to tell the process which exit code to use when the process exits gracefully. For instance, the following example illustrates a _misuse_ of the `process.exit()` method that could lead to data printed to stdout being truncated and lost: ```js import { exit } from 'node:process'; // This is an example of what *not* to do: if (someConditionNotMet()) { printUsageToStdout(); exit(1); } ``` The reason this is problematic is because writes to `process.stdout` in Node.js are sometimes _asynchronous_ and may occur over multiple ticks of the Node.js event loop. Calling `process.exit()`, however, forces the process to exit _before_ those additional writes to `stdout` can be performed. Rather than calling `process.exit()` directly, the code _should_ set the `process.exitCode` and allow the process to exit naturally by avoiding scheduling any additional work for the event loop: ```js import process from 'node:process'; // How to properly set the exit code while letting // the process exit gracefully. if (someConditionNotMet()) { printUsageToStdout(); process.exitCode = 1; } ``` If it is necessary to terminate the Node.js process due to an error condition, throwing an _uncaught_ error and allowing the process to terminate accordingly is safer than calling `process.exit()`. In `Worker` threads, this function stops the current thread rather than the current process.
@sincev0.1.13@paramcode The exit code. For string type, only integer strings (e.g.,'1') are allowed.
exit
>;
//

그렇기에 반환 타입이 never인 redirect() 함수는 항상 예외를 던지거나, 무한루프에 빠지거나, 프로그램 실행 자체가 종료될 것이기에 redirect() 함수의 호출문 다음의 코드들이 실행될 여지가 없어 불필요하다는 판단하에 에디터는 반투명처리를 하고, 타입스크립트 컴파일러는 에러를 내는 것이다.

import { function redirect(url: string, type?: RedirectType): never
This function allows you to redirect the user to another URL. It can be used in [Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components), [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers), and [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). - In a Server Component, this will insert a meta tag to redirect the user to the target page. - In a Route Handler or Server Action, it will serve a 307/303 to the caller. - In a Server Action, type defaults to 'push' and 'replace' elsewhere. Read more: [Next.js Docs: `redirect`](https://nextjs.org/docs/app/api-reference/functions/redirect)
redirect
} from 'next/navigation';
export default function function SomeDeprecatedPage(): nullSomeDeprecatedPage() { function redirect(url: string, type?: RedirectType): never
This function allows you to redirect the user to another URL. It can be used in [Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components), [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers), and [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). - In a Server Component, this will insert a meta tag to redirect the user to the target page. - In a Route Handler or Server Action, it will serve a 307/303 to the caller. - In a Server Action, type defaults to 'push' and 'replace' elsewhere. Read more: [Next.js Docs: `redirect`](https://nextjs.org/docs/app/api-reference/functions/redirect)
redirect
('/');
return null;
Unreachable code detected.
}

그렇다면 redirect() 함수는 정말 항상 예외를 던지거나, 무한루프에 빠지거나, 프로그램 실행 자체를 종료시킬까? redirect() 함수의 코드를 보면 getRedirectError() 함수에서 만들어진, 리디렉트 관련 정보가 담긴 에러 객체를 throw하고 있다. 실제로 항상 예외를 던지고 있는 것이다.

export function getRedirectError(
  url: string,
  type: RedirectType,
  statusCode: RedirectStatusCode = RedirectStatusCode.TemporaryRedirect
): RedirectError<typeof url> {
  const error = new Error(REDIRECT_ERROR_CODE) as RedirectError<typeof url>;
  error.digest = `${REDIRECT_ERROR_CODE};${type};${url};${statusCode};`;
  const requestStore = requestAsyncStorage.getStore();
  if (requestStore) {
    error.mutableCookies = requestStore.mutableCookies;
  }
  return error;
}

export function redirect(
  /** The URL to redirect to */
  url: string,
  type: RedirectType = RedirectType.replace
): never {
  const actionStore = actionAsyncStorage.getStore();
  throw getRedirectError(
    url,
    type,
    // If we're in an action, we want to use a 303 redirect
    // as we don't want the POST request to follow the redirect,
    // as it could result in erroneous re-submissions.
    actionStore?.isAction
      ? RedirectStatusCode.SeeOther
      : RedirectStatusCode.TemporaryRedirect
  );
}

그러면 redirect() 함수는 왜 항상 예외를 던지게 만들어졌을까?

원래 하던 작업을 중단하고 리디렉트를 처리하라

redirect() 함수는 서버 컴포넌트, 라우트 핸들러, 서버 액션에서 사용하도록 만들어졌다. 이 세 개의 공통점은 무엇일까? 바로 Next.js 서버에서 실행된다는 것이다.

Next.js 서버는 클라이언트(브라우저)가 보낸 HTTP 요청에 따라 이 세 개의 함수 중 하나를 실행해 화면을 렌더링(서버 컴포넌트)하거나 그 외 작업을 수행(라우트 핸들러, 서버 액션)한 결과를 HTTP 응답으로 보낸다.

HTTP 요청과 응답 과정에서 실행되는 함수들이기에 리디렉트를 하고 싶은 상황도 있을 것이다. Response 객체를 직접 제어하고 반환해 HTTP 응답에 직접적인 영향을 미치는 라우트 핸들러를 제외하고, 서버 컴포넌트와 서버 액션에서는 리디렉트를 처리하는 게 일반적이지 않다. 서버 컴포넌트는 리액트 노드를 반환해야하고, 서버 액션 함수는 단순 함수로 본인의 역할에 맞는 값을 반환해야하기 때문이다.

이런 환경에서 Next.js는 항상 예외를 던지는 redirect() 함수를 만들었다. 자바스크립트에서 실행 중인 함수가 throw 문을 만나면, catch 블록을 찾을 때까지 콜스택 위의 함수들이 차례로 종료된다. 서버 컴포넌트, 라우트 핸들러, 서버 액션에서 redirect() 함수를 호출하면 리디렉트 관련 에러 객체가 던져지고, 실행중이던 서버 컴포넌트, 라우트 핸들러, 서버 액션 함수를 포함한 콜스택 위의 함수들이 차례로 종료될 것이며, 궁극적으로 Next.js 코드 내 리디렉트 관련 에러를 핸들링하는 catch 블록에서 이 에러 객체로부터 리디렉트 정보를 취해 리디렉트를 위한 HTTP 응답을 만드는 것이다.

이런 구조를 통해 서버 컴포넌트, 라우트 핸들러, 서버 액션의 역할은 변형시키지 않으면서도 Next.js 서버에서 리디렉트 처리가 가능한 것이며, 그래서 Next.js 공식문서에서는 redirect() 함수 호출을 try/catch 블록 바깥에서 해야한다고 말하는 것이다. try/catch 블록으로 감싸면 Next.js가 리디렉트 예외를 감지할 수 없기 때문이다.

redirect internally throws an error so it should be called outside of try/catch blocks.

Next.js 내부적으로 리디렉트 에러를 판별하는 isRedirectError() 함수가 있고, 이 함수를 사용하는 곳들을 보면 Next.js가 리디렉트 관련 처리를 어떻게 하는지, Next.js가 어떤 구조로 되어 있는지 조금 더 가까워지는 계기가 될 것이다(redirect-boundary.tsx, action-handler.ts, app-router.tsx, app-render.tsx 등 조금은 유추할 수 있는 이름들이 보인다).

내게 Next.js의 redirect() 함수의 반환 타입이 왜 never인지 생각해보는 건, 타입스크립트에 대한 이해를 넘어 Next.js의 내부 구조를 들여다보는 의미 있는 시간이었다.

이 글이 타입스크립트와 Next.js를 쓰는 분들에게 never 타입을 좀 더 명확히 이해하고, Next.js와 조금 더 가까워지는 계기가 되었으면 좋겠습니다.