@toss/storage 라이브러리는 어떤식으로 작동할까?

지인·2023년 8월 13일
0

TIL

목록 보기
12/17
post-thumbnail

문제

프로젝트를 진행하면서 '만약 웹 스토리지를 사용할 수 없는 상황이라면 어떤 식으로 처리해야 할까'에 대한 고민을 했던 경험이 있었습니다. 브라우저의 시크릿 모드같이 Web Storage를 쓸 수 없는 상황이 발생한다면 localStorage나 sessionStorage 등에 접근하려고 할 때 에러가 발생하게 됩니다. 이 때, @toss/storage 패키지를 사용한다면 안전하게 스토리지의 값을 다룰 수 있게 됩니다. 이 라이브러리를 소개해주신 프론트엔드 개발자 승규님에게 감사드리며 토스에서는 이 문제를 어떤식으로 해결했는지 확인해보도록 하겠습니다.

@toss/storage 라이브러리 소개

브라우저의 시크릿 모드나 테스트 환경과 같이 Web Storage를 쓸 수 없는 환경에서 localStorage나 sessionStorage 등에 접근하려고 하면 에러가 발생합니다. @toss/storage 패키지는 그런 환경에서도 에러 없이 Web Storage를 쓸 수 있도록 돕는 라이브러리입니다.

@toss/storage 라이브러리 분석

먼저, @toss/storage의 전체 코드는 아래와 같습니다.

export interface Storage {
  get(key: string): string | null;
  set(key: string, value: string): void;
  remove(key: string): void;
  clear(): void;
}

class MemoStorage implements Storage {
  private storage = new Map<string, string>();
  public get(key: string) {
    return this.storage.get(key) || null;
  }

  public set(key: string, value: string) {
    this.storage.set(key, value);
  }

  public remove(key: string) {
    this.storage.delete(key);
  }

  public clear() {
    this.storage.clear();
  }
}

class LocalStorage implements Storage {
  public static canUse(): boolean {
    const TEST_KEY = generateTestKey();

    // 사용자가 쿠키 차단을 하는 경우 LocalStorage '접근' 시에 예외가 발생합니다.
    try {
      localStorage.setItem(TEST_KEY, 'test');
      localStorage.removeItem(TEST_KEY);
      return true;
    } catch (err) {
      return false;
    }
  }

  public get(key: string) {
    return localStorage.getItem(key);
  }

  public set(key: string, value: string) {
    localStorage.setItem(key, value);
  }

  public remove(key: string) {
    localStorage.removeItem(key);
  }

  public clear() {
    localStorage.clear();
  }
}

class SessionStorage implements Storage {
  public static canUse(): boolean {
    const TEST_KEY = generateTestKey();

    // sessionStorage를 사용할 수 없는 경우에 대응합니다.
    try {
      sessionStorage.setItem(TEST_KEY, 'test');
      sessionStorage.removeItem(TEST_KEY);
      return true;
    } catch (err) {
      return false;
    }
  }

  public get(key: string) {
    return sessionStorage.getItem(key);
  }

  public set(key: string, value: string) {
    sessionStorage.setItem(key, value);
  }

  public remove(key: string) {
    sessionStorage.removeItem(key);
  }

  public clear() {
    sessionStorage.clear();
  }
}

function generateTestKey() {
  return new Array(4)
    .fill(null)
    .map(() => Math.random().toString(36).slice(2))
    .join('');
}

export function generateStorage(): Storage {
  if (LocalStorage.canUse()) {
    return new LocalStorage();
  }
  return new MemoStorage();
}

export function generateSessionStorage(): Storage {
  if (SessionStorage.canUse()) {
    return new SessionStorage();
  }
  return new MemoStorage();
}

export const safeLocalStorage = generateStorage();
export const safeSessionStorage = generateSessionStorage();

  1. 먼저, 라이브러리 사용자는 로컬스토리지를 쓸 것인지(generateStorage()), 세션 스토리지를 사용할 것인지(generateSessionStorage())를 결정해 아래 코드를 통해 라이브러리에 접근하여 사용할 수 있습니다.
import { generateStorage } from '@toss/storage';
import { generateSessionStorage } from '@toss/storage';

const safeLocalStorage = generateStorage();
const safeSessionStorage = generateSessionStorage();
  1. 라이브러리에 접근하면, 먼저 라이브러리 내부에서 생성한 storage가 사용 가능한 상태인지 체크합니다. (LocalStorage의 canUse 메서드 호출해서 true 값이면 로컬 스토리지를 사용하도록 하고, 그렇지 않다면 메모 스토리지를 사용하도록)
export function generateStorage(): Storage {
  if (LocalStorage.canUse()) {
    return new LocalStorage();
  }
  return new MemoStorage();
}

2-1. canUse 메서드에서는 랜덤한 값을 가진 길이 4짜리 배열을 생성해 로컬스토리지에 넣어 봅니다. 만약 오류 없이 값이 정상적으로 들어간다면 true를 반환하고, 오류가 발생한다면 false를 반환합니다.

class LocalStorage implements Storage {
  public static canUse(): boolean {
    const TEST_KEY = generateTestKey();

    // 사용자가 쿠키 차단을 하는 경우 LocalStorage '접근' 시에 예외가 발생합니다.
    try {
      localStorage.setItem(TEST_KEY, 'test');
      localStorage.removeItem(TEST_KEY);
      return true;
    } catch (err) {
      return false;
    }
  }
function generateTestKey() {
  return new Array(4)
    .fill(null)
    .map(() => Math.random().toString(36).slice(2))
    .join('');
}

2-2. 다시 원래의 조건문으로 돌아와서, 문제가 없다면 LocalStorage 인스턴스를 반환하고, 문제가 있었다면 MemoStorage를 반환합니다.

export function generateStorage(): Storage {
  if (LocalStorage.canUse()) {
    return new LocalStorage();
  }
  return new MemoStorage();
}

여기서 MemoStorage는 Map 객체를 이용해 데이터를 메모리 상에 저장하도록 되어있다. 그렇기 때문에, 웹 세션을 새로고침하거나 브러우저를 종료할 경우, 저장된 데이터는 사라집니다.

class MemoStorage implements Storage {
 private storage = new Map<string, string>();
 public get(key: string) {
   return this.storage.get(key) || null;
 }
  1. 사용자는 생성된 스토리지를 내부에서 public으로 선언된 메서드들을 호출해 사용할 수 있습니다.
import { generateSessionStorage } from '@toss/storage';

const safeSessionStorage = generateSessionStorage();

safeSessionStorage.set('key', 'value');
safeSessionStorage.get('key');
safeSessionStorage.remove('key');
safeSessionStorage.clear();

(보너스: 실제 코드로 사용)
저는 해당 스토리지가 필요한 파일마다 import 하는 것보다 하나의 파일에서 스토리지를 관리하는 것이 코드 중복을 줄일 수 있다고 생각하여 store 폴더에 별도의 파일을 만들어, 스토리지를 관리했습니다.

// typedStorage.ts
import { TypedStorage } from "@toss/storage/typed";

export const accessTokenStorage = new TypedStorage<string>("accessToken");
export const refreshTokenStorage = new TypedStorage<string>("refreshToken");

// 사용법
// accessTokenStorage.get('key');
// accessTokenStorage.set('key', 'value');
// accessTokenStorage.remove('key');
// accessTokenStorage.clear();
// 해당 스토리지의 값이 필요한 컴포넌트나 페이지 파일
import { accessTokenStorage } from "../../store/typedStorage";

const accessToken = accessTokenStorage.get();

코드를 확인해보니 웹 스토리지가 동작하지 않는 상황에서도 해당 기능이 동작할 수 있도록 안정성을 확보했다는 것을 확인할 수 있었습니다.

profile
안녕하세요

0개의 댓글