ApolloClient onError๋ฅผ ํ™œ์šฉํ•œ Token Error Handling ๐Ÿง™๐Ÿปโ€โ™€๏ธ

ApolloClient์˜ onError ๋ฉ”์†Œ๋“œ๋ฅผ ํ†ตํ•ด graphql ์„œ๋ฒ„, ๋˜๋Š” network์—์„œ ๋ฐœ์ƒํ•œ ์—๋Ÿฌ๋ฅผ ์บ์น˜ํ•˜์—ฌ ๊ฐ๊ฐ์˜ ์—๋Ÿฌ ์ƒํ™ฉ์— ๋งž๋Š” ์ ์ ˆํ•œ ํ•ธ๋“ค๋ง์„ ํ•  ์ˆ˜ ์žˆ๋‹ค.
์ด๋ฒˆ ํฌ์ŠคํŒ…์—์„œ๋Š” ํ† ํฐ์ด ์œ ํšจํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ ๋ฐœ์ƒํ•˜๋Š” Unauthorized ์—๋Ÿฌ๋ฅผ ํ•ธ๋“ค๋งํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์•Œ์•„๋ณธ๋‹ค.

์šฐ์„ , Apollo Client ์ธ์Šคํ„ด์Šค๋ฅผ ๋งŒ๋“ ๋‹ค.

import { ApolloClient, createHttpLink, InMemoryCache } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";

const token = localStorage.getItem("token");

const httpLink = createHttpLink({
  uri: "http://localhost:4000/graphql",
});

export const client = new ApolloClient({
  link: httpLink,
  cache: new InMemoryCache(),
});

์„œ๋ฒ„๋กœ ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๊ฐ€์ง€๊ณ  ์žˆ๋Š” token์„ authLink๋ฅผ ์ถ”๊ฐ€ํ•˜์—ฌ ํ—ค๋”์— ์‹ค์–ด ๋ณด๋‚ด๋Š” ๋กœ์ง์„ ์ž‘์„ฑํ•ด๋ณด์ž.

import { ApolloClient, createHttpLink, InMemoryCache } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";

const httpLink = createHttpLink({
  uri: "http://localhost:4000/graphql",
});

const authLink: ApolloLink = setContext(async (_, { headers }) => {
  const token = localStorage.getItem("token");

  return {
    headers: {
      ...headers,
      token: token,
    },
  };
});

export const client = new ApolloClient({
  link: from(authLink, httpLink),
  cache: new InMemoryCache(),
});

authLink๋ฅผ ํ†ตํ•ด localStorage์— ๋‹ด๊ธด token์ด header์— ๋‹ด๊ฒจ ์„œ๋ฒ„๋กœ ์ „์†ก๋  ๊ฒƒ์ด๋‹ค.

์„œ๋ฒ„์—์„œ ๊ตฌํ˜„๋œ ์‚ฌ์šฉ์ž ์ธ์ฆ ์‹œ๋‚˜๋ฆฌ์˜ค์— ๋”ฐ๋ผ ๋‹ค๋ฅด๊ฒ ์ง€๋งŒ,

  1. accessToken๊ณผ refreshToken์„ ์ด์šฉํ•ด ์‚ฌ์šฉ์ž์˜ ํ† ํฐ์„ ๊ฒ€์ฆํ•˜๋Š” ์ƒํ™ฉ์—์„œ ์‚ฌ์šฉ์ž์˜ accessToken์ด ๋งŒ๋ฃŒ๋œ ๊ฒฝ์šฐ,
  2. ๋˜๋Š” ๋กœ๊ทธ์ธ์„ ํ•˜๊ธฐ ์ „์—๋„ ๋ฏธ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž(anonymous user)๋กœ์„œ ํ† ํฐ ๋ฐœ๊ธ‰์ด ํ•„์š”ํ•œ ๊ฒฝ์šฐ

์‚ฌ์šฉ์ž๊ฐ€ ์›น์‚ฌ์ดํŠธ์— ์ง„์ž…ํ•˜๋ฉด ์„œ๋ฒ„๋กœ๋ถ€ํ„ฐ Unauthorized ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•  ๊ฒƒ์ด๋‹ค.

์ด๋Ÿฌํ•œ ๊ฒฝ์šฐ apollo client์˜ onError ๋ฉ”์†Œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด `Unauthorized` ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ ํ† ํฐ ์žฌ๋ฐœ๊ธ‰์„ ํ†ตํ•ด ์œ„ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ๋‹ค.

import { ApolloClient, createHttpLink, InMemoryCache } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";

// ...

const errorLink = onError(
  ({ graphQLErrors, networkError, operation, forward }) => {
    if (graphQLErrors) {
      for (let error of graphQLErrors) {
        switch (error.extensions.code) {
          case "UNAUTHENTICATED":
            const oldHeaders = operation.getContext().headers;

            operation.setContext({
              headers: {
                ...oldHeaders,
                authorization: getNewToken(),
              },
            });
        }
      }
    }

    if (networkError) {
      console.log(`[Network error]: ${networkError}`);
    }
  }
);

export const client = new ApolloClient({
  link: from(errorLink, authLink, httpLink),
  cache: new InMemoryCache(),
});
code from apollo-client doc

graphQL ์š”์ฒญ ์‹œ ๋ฐœ์ƒํ•˜๋Š” ์—๋Ÿฌ๋Š” ํฌ๊ฒŒ graphQL error, network error๋กœ ๋‚˜๋ˆŒ ์ˆ˜ ์žˆ๋‹ค.

apollo client์—์„œ๋Š” ์—๋Ÿฌ๋ฅผ ํ•ธ๋“ค๋งํ•˜๊ธฐ ์œ„ํ•œ link๋กœ onError์™€ RetryLink ๊ฐ€ ์žˆ๋Š”๋ฐ ๊ณต์‹๋ฌธ์„œ์—์„œ๋Š” graphQL error๋Š” onError ๋กœ, network error๋Š” RetryLink๋กœ ๊ฐ๊ฐ ํ•ธ๋“ค๋งํ•  ๊ฒƒ์„ ๊ถŒ์žฅํ•˜๊ณ  ์žˆ๋‹ค.

์„œ๋ฒ„์—์„œ์˜ ํ† ํฐ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ์—์„œ ํ†ต๊ณผํ•˜์ง€ ๋ชปํ•œ ์š”์ฒญ์—์„œ ๋ฐœ์ƒํ•˜๋Š” ์—๋Ÿฌ๋Š” graphQL์„œ๋ฒ„์—์„œ ๋ฐœ์ƒ์‹œํ‚ค๋Š” graphQL Error์ด๋ฏ€๋กœ onError๋ฅผ ์ด์šฉํ•ด ์—๋Ÿฌ๋ฅผ ์ฒ˜๋ฆฌํ•ด์ฃผ์—ˆ๋‹ค.

onError๋ž€

  • onError๋ฅผ ์ด์šฉํ•ด graphQL ์„œ๋ฒ„๋กœ ๋ฐ์ดํ„ฐ ์š”์ฒญ ์ค‘์— ๋ฐœ์ƒํ•œ ์—๋Ÿฌ๋ฅผ ์บ์น˜ํ•˜์—ฌ ์ ์ ˆํ•œ ํ•ธ๋“ค๋ง ๋กœ์ง์„ ์ถ”๊ฐ€ํ•ด์ค„ ์ˆ˜ ์žˆ๋‹ค.
  • ์š”์ฒญ์„ ๋‹ค์‹œ ๋ณด๋‚ด๊ธฐ ์œ„ํ•ด forwardํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ•ด์•ผํ•œ๋‹ค.
  • ๋งŒ์•ฝ ์žฌ์š”์ฒญ์‹œ์—๋„ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•  ๊ฒฝ์šฐ, onError๋Š” ๋˜๋‹ค์‹œ ์š”์ฒญ์„ ๋ณด๋‚ด์ง€๋Š” ์•Š๋Š”๋‹ค. (์ด ๊ฒฝ์šฐ ๋ฌดํ•œ ๋ฃจํ”„์— ๋น ์งˆ ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ) ์ฆ‰, ์‹คํŒจํ•œ operation ๋‹น ํ•œ ๋ฒˆ๋งŒ ์žฌ์š”์ฒญํ•œ๋‹ค.


โ—๏ธProblem

getNewToken()์€ ๋น„๋™๊ธฐ์ ์œผ๋กœ ์ˆ˜ํ–‰๋˜๋Š” ํ•จ์ˆ˜์ด๋‹ค.(์•„๋งˆ ๋Œ€๋ถ€๋ถ„์˜ ๊ฒฝ์šฐ ๊ทธ๋Ÿด ๊ฒƒ์ด๋ผ ์ƒ๊ฐํ•œ๋‹ค.)

๊ทธ๋ž˜์„œ async ๋‚˜ promise์™€ ๊ฐ™์€ ๋ฌธ๋ฒ•์„ ์ถ”๊ฐ€ํ•˜์—ฌ ํ•ด๊ฒฐํ•˜๊ณ ์žํ•˜์˜€๋Š”๋ฐ, onError์˜ ์ฝœ๋ฐฑํ•จ์ˆ˜์— async ํ‚ค์›Œ๋“œ๋ฅผ ์ถ”๊ฐ€ํ–ˆ๋”๋‹ˆ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ–ˆ๋‹ค.

ErrorHandler์˜ ๋ฐ˜ํ™˜๊ฐ’์€ Observable ๊ฐ์ฒด์ด๊ฑฐ๋‚˜ ์—†์–ด์•ผ ํ•œ๋‹ค(void | Observable).
๊ทธ๋Ÿฐ๋ฐ async ํ‚ค์›Œ๋“œ๊ฐ€ ๋ถ™์€ ํ•จ์ˆ˜๋Š” Promise๋ฅผ ๋ฐ˜ํ™˜ํ•˜๊ฒŒ ๋˜๋ฏ€๋กœ ์œ„์™€๊ฐ™์€ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ–ˆ๋‹ค.

..Observable์€ ๋ฌด์—‡์ธ๊ฐ€๐Ÿค”

๋จผ์ € RxJS๋ถ€ํ„ฐ ์‚ดํŽด๋ณด์ž.

RxJS

  • RxJS๋Š” ํŒŒ์ผ ์ฝ๊ธฐ, data fetching, ํ‚ค ์ž…๋ ฅ ๋“ฑ์˜ ์ด๋ฒคํŠธ ์†Œ์Šค๋ฅผ ๋น„๋™๊ธฐ์ ์œผ๋กœ ์ฒ˜๋ฆฌํ•˜๊ธฐ ์œ„ํ•œ JS์˜ ํ™•์žฅ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์ด๋‹ค. ์ฝœ๋ฐฑ์ด๋‚˜ ํ”„๋กœ๋ฏธ์Šค๋ฅผ ๋Œ€์ฒดํ•  ์ˆ˜ ์žˆ๋‹ค.
  • RxJS๋Š” ์ด๋ฒคํŠธ ์ŠคํŠธ๋ฆผ์„ observable ์ด๋ผ๋Š” ๊ฐ์ฒด๋กœ ํ‘œํ˜„ํ•œ ํ›„ ๋น„๋™๊ธฐ๋กœ ๋™์ž‘ํ•˜๋Š” ๋กœ์ง์„ ๊ตฌํ˜„ํ•œ๋‹ค.
  • observable ๊ฐ์ฒด๋ž€ ๋ฐ์ดํ„ฐ ์ŠคํŠธ๋ฆผ์„ ๋งŒ๋“œ๋Š” ๋ฐ์ดํ„ฐ ์ƒ์‚ฐ์ž์ด๋‹ค.
  • Apollo ๋Š” Observable ๊ตฌํ˜„์„ ์œ„ํ•ด zen-observable์ด๋ผ๋Š” ํŒจํ‚ค์ง€๋ฅผ ๋‚ด๋ถ€์ ์œผ๋กœ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ๋‹ค.

์šฐ์„  Observable์€ apollo link์—์„œ ๋น„๋™๊ธฐ์ ์ธ ๋™์ž‘์„ ์ฒ˜๋ฆฌํ•˜๋Š” ๊ฐ์ฒด๋กœ, getNewToken()์˜ ๊ฒฐ๊ณผ ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•œ ๊ฐ์ฒด๋ผ๊ณ  ์ดํ•ดํ•˜๋ฉด ๋  ๊ฒƒ ๊ฐ™๋‹ค.

๐Ÿ’กSolution

apollo-link ํŒจํ‚ค์ง€์—์„œ๋Š” promise๋ฅผ ์ด์šฉํ•ด observable ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•ด์ฃผ๋Š” fromPromise๋ผ๋Š” ๋ฉ”์„œ๋“œ๋ฅผ ์ œ๊ณตํ•œ๋‹ค.

fromPromise

  • from promise to observable์˜ ์˜๋ฏธ์ธ ๊ฒƒ ๊ฐ™๋‹ค.(์•„๋งˆ๋„..?)
  • promise ๊ฐ์ฒด๋ฅผ ์ธ์ž๋กœ ์ „๋‹ฌํ•˜๋ฉด observable ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
const errorLink = onError(
  ({ graphQLErrors, networkError, operation, forward }) => {
    if (graphQLErrors) {
      for (let error of graphQLErrors) {
        switch (error.extensions.code) {
          case "UNAUTHENTICATED":
            return fromPromise(getNewToken())
              .filter((value) => {
                return Boolean(value);
              })
              .flatMap((token) => {
                setUserToken({ isKeepLogin: false, token });
                const oldHeaders = operation.getContext().headers;
                // ์ด์ „ header๋ฅผ ํ™œ์šฉํ•ด ์ด์ „ ์š”์ฒญ์˜ context๋ฅผ ์žฌ์ƒ์„ฑ
                operation.setContext({
                  headers: {
                    ...oldHeaders,
                    token: token || "",
                  },
                });

                return forward(operation); // ์‹คํŒจํ•œ ์ด์ „ ์š”์ฒญ ๋‹ค์‹œ ๋ณด๋‚ด๊ธฐ
              });
        }
      }
    }

    if (networkError) {
      console.log(`[Network error]: ${networkError}`);
    }
  }
);

filter: ๊ฒฐ๊ณผ๊ฐ’ ์ค‘ ์ฝœ๋ฐฑํ•จ์ˆ˜๋ฅผ ๋งŒ์กฑํ•˜๋Š” ๊ฒฐ๊ณผ๊ฐ’๋งŒ ํ•„ํ„ฐ๋ง
flatMap(mergeMap): ๋ฐ˜ํ™˜๋œ observable ๊ฐ์ฒด๋“ค์˜ ๋ฐ์ดํ„ฐ ์ŠคํŠธ๋ฆผ์„ ๋ณ‘ํ•ฉํ•˜์—ฌ ๊ฒฐ๊ณผ๊ฐ’์„ ๋‚ด๋ณด๋‚ธ๋‹ค.

์ตœ์ข… ApolloClient Provider ์ฝ”๋“œ
import { ApolloClient, createHttpLink, InMemoryCache } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";

const httpLink = createHttpLink({
  uri: "http://localhost:4000/graphql",
});

const authLink: ApolloLink = setContext(async (_, { headers }) => {
  const token = localStorage.getItem("token");

  return {
    headers: {
      ...headers,
      token: token,
    },
  };
});

const errorLink = onError(
  ({ graphQLErrors, networkError, operation, forward }) => {
    if (graphQLErrors) {
      for (let error of graphQLErrors) {
        switch (error.extensions.code) {
          case "UNAUTHENTICATED":
            return fromPromise(getNewToken())
              .filter((value) => {
                return Boolean(value);
              })
              .flatMap((token) => {
                setUserToken({ isKeepLogin: false, token });
                const oldHeaders = operation.getContext().headers;
                // ์ด์ „ header๋ฅผ ํ™œ์šฉํ•ด ์ด์ „ ์š”์ฒญ์˜ context๋ฅผ ์žฌ์ƒ์„ฑ
                operation.setContext({
                  headers: {
                    ...oldHeaders,
                    token: token || "",
                  },
                });

                return forward(operation); // ์‹คํŒจํ•œ ์ด์ „ ์š”์ฒญ ๋‹ค์‹œ ๋ณด๋‚ด๊ธฐ
              });
        }
      }
    }

    if (networkError) {
      console.log(`[Network error]: ${networkError}`);
    }
  }
);

export const client = new ApolloClient({
  link: from(errorLink, authLink, httpLink),
  cache: new InMemoryCache(),
});

ApolloClient์—์„œ link๋ฅผ ์œ„์™€ ๊ฐ™์ด from์œผ๋กœ ์—ฐ๊ฒฐํ•ด์ฃผ๋ฉด link chain์ด ํ˜•์„ฑ๋œ๋‹ค.

  • from๋ฉ”์†Œ๋“œ๋Š” ์—ฌ๋Ÿฌ ๊ฐœ์˜ ๋งํฌ๋ฅผ ๊ฒฐํ•ฉํ•˜์—ฌ ํ•˜๋‚˜์˜ link chain์„ ํ˜•์„ฑํ•ด์ฃผ๋Š” ๋ฉ”์„œ๋“œ์ด๋‹ค. httpLink๋Š” ์ข…๋ฃŒ ๋งํฌ(terminating link)๋กœ์„œ ๋งํฌ ์ฒด์ด๋‹์˜ ๊ฐ€์žฅ ์ข…๋‹จ์— ์œ„์น˜ํ•˜์—ฌ graphQL ์„œ๋ฒ„์— ์š”์ฒญ์„ ๋ณด๋‚ด๊ณ  ์‹คํ–‰ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ›๋Š” link์ด๋‹ค.
  • link chain์œผ๋กœ ์—ฐ๊ฒฐ๋˜์–ด์žˆ๋Š” link๋“ค์€ forward ๋ฅผ ํ†ตํ•ด ์ฒด์ธ ์ƒ์˜ ๋‹ค์Œ ๋งํฌ๋ฅผ ํ˜ธ์ถœํ•œ๋‹ค.

๊ฒฐ๋ก 

๊ธฐ์กด์—๋Š” ApolloClientProvider๊ฐ€ ์‚ฌ์šฉ๋˜๋Š” ์ปดํฌ๋„ŒํŠธ์—์„œ token์„ ๊ฒ€์‚ฌํ•ด token์ด ์กด์žฌํ•˜๋Š” ๊ฒฝ์šฐ์—๋งŒ ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋ณด์—ฌ์ฃผ๋Š” ๋ฐฉ์‹์œผ๋กœ ๊ตฌํ˜„ํ–ˆ์—ˆ๋‹ค. ๊ทธ๋Ÿฌ๋‚˜ onError ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜๊ฒŒ ๋˜๋ฉด์„œ ๋” ๋ช…ํ™•ํ•˜๊ฒŒ ํ† ํฐ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ–ˆ์„ ๊ฒฝ์šฐ์— ๋Œ€ํ•œ ํ•ธ๋“ค๋ง์„ ํ•ด์ค„ ์ˆ˜ ์žˆ๊ฒŒ ๋˜์—ˆ๋‹ค. ๋˜ํ•œ ํ† ํฐ ์—๋Ÿฌ ๋ง๊ณ ๋„ ๋ฐ์ดํ„ฐ ์š”์ฒญ ์ค‘ ๋ฐœ์ƒํ•˜๋Š” graphQL error, network error๋“ค๋„ ํ•œ ๊ณณ์—์„œ ํ•ธ๋“ค๋งํ•ด์ค„ ์ˆ˜ ์žˆ๊ฒŒ ๋˜์–ด ์ถ”ํ›„์— ์—๋Ÿฌ ๋ชจ๋‹ˆํ„ฐ๋ง ์‹œ์Šคํ…œ์„ ๋„์ž…ํ•˜๊ฒŒ ๋  ๊ฒฝ์šฐ ๋ฌธ์ œ๋ฅผ ์‰ฝ๊ฒŒ ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ์„ ๊ฒƒ ๊ฐ™๋‹ค.

apollo link๊ฐ€ link chain ๋ฐฉ์‹์œผ๋กœ ๊ตฌํ˜„๋˜์–ด์žˆ๋‹ค๋Š” ๊ฒŒ ๋„ˆ๋ฌด ์‹ ๊ธฐํ–ˆ๋‹ค. ์—ฌ๊ธฐ์„œ ๋น„๋™๊ธฐ ๋™์ž‘ ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•ด ์‚ฌ์šฉ๋œ observable๊ฐ์ฒด์— ๋Œ€ํ•ด์„œ๋„ ์ข€ ๋” ์กฐ์‚ฌํ•ด๋ณด๊ณ  rxJS์— ๋Œ€ํ•ด ์ข€ ๋” ์•Œ์•„๋ณด๊ณ ์‹ถ๋‹ค.

Resources