• LocalStorage + Typescript :: 마이구미
    Typescript 2021. 10. 30. 21:17
    반응형
    이 글은 로컬 스토리지(LocalStorage) 를 타입스크립트 함께 사용하는 방식을 다룬다.
    로컬스토리지는 HTML5 에서 제공된 기능으로 브라우저에서 지원하고 있는 저장소이다.
    타입스크립트를 활용하여 로컬스토리지를 사용하면서 런타임에 발생할 수 있는 이슈들을 개선해본다.
    로컬 스토리지 - https://developer.mozilla.org/ko/docs/Web/API/Window/localStorage

     

    로컬 스토리지는 브라우저에서 제공해주는 key-value 저장소로써, 많은 경우에서 유용하게 사용중이다.

    자체적으로 제공해주는 메소드로 '저장', '삭제', '조회', '초기화' 를 쉽게 작성할 수 있다.

     

    // Key - "accessToken" | Value - "eyJraWQiOiJVQ2JINXNS"
    localStorage.setItem('accessToken', 'eyJraWQiOiJVQ2JINXNS');
    // Key - "accessToken" 인 값 가져오기
    const data = localStorage.getItem('accessToken');
    // Key - "accessToken" 제거하기
    localStorage.removeItem('accessToken');
    // 로컬 스토리지 초기화
    localStorage.clear();

     

    누구나 쉽게 파악할 수 있고, 자체적으로 지원해주는 기능이기 때문에 개선할 점은 딱히 없어보인다.

    하지만 만약 개선할 점이 있다면, 그 이유는 key, value 모두 타입이 string 이라는 것이다.

    string 타입이 문제가 되는 이유는 크게 2가지로 분류할 수 있다.

     

    만약 Key 를 통해 조회해야하는데 Key 가 없으면?

     

    const data = localStorage.getItem('accessToekn');

     

    accessToken 을 실수로 오타가 존재하는 코드이다.

    만약 상수로 빼서 관리한다하더라도, 그 상수가 실제로 로컬 스토리지 Key 로 사용 중이라는 것을 추정할 수 없다.

    타입스크립트를 사용해봤다면, IDE 에서 실제로 사용할 수 있는 것들만 추론해서 알려주는 게 얼마나 편하고 안정적인지 알 것이다.

     

    만약 Value 에 배열이나 객체를 저장하고 싶다면?

     

    Value 는 string 타입으로 저장해야하기 때문에 string 이 아니라면 string 형태로 변환해줘야한다.

     

    localStorage.setItem('userData', JSON.stringify({ name: 'mygumi' }));

     

    반대로 위처럼 저장된 값을 조회해야 한다면, 객체로 변환해서 사용해야한다.

     

    const data = JSON.parse(localStorage.getItem('userData'));

     

    위와 같은 문제점들을 타입스크립트를 통해 개선한다면, 런타임 환경에서 많은 부분이 유용하다.

     

    이미 존재하는 기능을 개선해야한다면, 대부분 그 기능을 Wrapper 로 감싸서 타입을 보다 명확하게 부여해주는 방식이 있다.

    즉, LocalStorage 를 Wrapper 로 감싸는 것이다.

     

    type LocalStorage = typeof window.localStorage;
    
    abstract class Storage<T extends string> {
      private readonly storage: LocalStorage;
    
      protected constructor(getStorage = (): LocalStorage => window.localStorage) {
        this.storage = getStorage();
      }
    }

     

    추상 클래스로 선언된 Storage 는 window.localStorage 를 단순히 감싼 Wrapper 형태이다. (추상 클래스?)

    추후 추상 클래스 Wrapper 는 실제 인스턴스화가 가능한 클래스에서 상속하게 된다.

     

    추상 클래스를 상속할 때 선언되는 제네릭 타입 T 는 로컬 스토리지를 접근하는 key 를 의미한다.

    이것이 의미하는 것은 Wrapper 에 localStorage 의 getItem, removeItem, clear 등을 위한 메소드를 구현해보면 알 수 있다.

     

    protected get(key: T): string | null {
      return this.storage.getItem(key);
    }
    
    protected set(key: T, value: string): void {
      this.storage.setItem(key, value);
    }
    
    protected clearItem(key: T): void {
      this.storage.removeItem(key);
    }

     

    즉, 추상 클래스를 위해 선언되는 제네릭 타입 T 는 key 파라미터의 타입으로 선언된 모습을 볼 수 있다.

    결과적으로 올바르지 않은 key 를 전달한다면, 타입 에러를 발생하여 타입의 안정성을 향상시켜준다. (아래 이미지 첨부)

     

    set 의 경우에는 두번째 파라미터로 타입이 string 인 value 를 볼 수 있다.

    로컬 스토리지에 set 할 경우에는 무조건 string 타입으로 변환하는 것을 타입 추론으로 요구하는 것이다. (아래 이미지 첨부)

     

    전체 코드는 다음과 같다.

     

    type LocalStorage = typeof window.localStorage;
    
    const PREFIX_LOCALSTORAGE = 'Mygumi_';
    
    export default abstract class Storage<T extends string> {
      private readonly storage: LocalStorage;
    
      protected constructor(getStorage = (): LocalStorage => window.localStorage) {
        this.storage = getStorage();
      }
    
      private getOriginKey(key: T) {
        return `${PREFIX_LOCALSTORAGE}${key}`;
      }
    
      protected get(key: T): string | null {
        return this.storage.getItem(this.getOriginKey(key));
      }
    
      protected set(key: T, value: string): void {
        this.storage.setItem(this.getOriginKey(key), value);
      }
    
      protected clearItem(key: T): void {
        this.storage.removeItem(this.getOriginKey(key));
      }
    
      protected clearItems(keys: T[]): void {
        keys.forEach(key => this.clearItem(this.getOriginKey(key) as T));
      }
    }

     

    여기서 하나 더 언급하자면, 로컬 스토리지의 Key 를 위한 Prefix 가 존재하는 것을 볼 수 있다.

    언듯 보면, 다른 도메인과 Key 겹치는 일은 존재하는 것을 방지하는 거라고 생각할 수 있다.

    하지만 로컬 스토리지의 유지 범위는 origin 기준이다. (protocol://host:port)

    그렇기에 다른 서비스와 Key 가 겹치는 일은 존재하지 않는다.

     

    이제 실제로 추상 클래스를 상속받아 사용하는 클래스를 작성해보자.

     

    enum UserStorageKey {
      ACCESS_TOKEN = 'accessToken',
      REFRESH_TOKEN = 'refreshToken',
      USER_DATA = 'userData'
    }
    
    class UserStorage extends Storage<UserStorageKey> {
      constructor() {
        super();
      }
    }

     

    위에서 언급했듯이, 제네릭 타입으로 선언된 enum 을 넘김으로써 선언된 Key 가 아니라면 타입 에러를 발생하게 된다.

     

     

    set 의 경우에도 마찬가지이다.

    로컬 스토리지의 value 는 string 타입이기 때문에 Wrapper 에서 value 의 타입을 string 으로 정의하였다.

    그렇기에 set 하는 쪽에서 string 으로 변환해서 넘기지 않으면 타입 에러를 발생하게 된다.

     

     

    전체 코드는 다음과 같다.

     

    import Storage from './index';
    
    enum UserStorageKey {
      ACCESS_TOKEN = 'accessToken',
      REFRESH_TOKEN = 'refreshToken',
      USER_DATA = 'userData'
    }
    
    type UserAttribute = {
      name: 'nickname' | 'email' | 'profile';
      value: string;
    };
    
    interface UserData {
      UserAttributes: UserAttribute[];
      UserName: string;
    }
    
    class UserStorage extends Storage<UserStorageKey> {
      constructor() {
        super();
      }
    
      getAccessToken() {
        return this.get(UserStorageKey.ACCESS_TOKEN);
      }
    
      setAccessToken(accessToken: string) {
        this.set(UserStorageKey.ACCESS_TOKEN, accessToken);
      }
    
      getRefreshToken() {
        return this.get(UserStorageKey.REFRESH_TOKEN);
      }
    
      setRefreshToken(refreshToken: string) {
        this.set(UserStorageKey.REFRESH_TOKEN, refreshToken);
      }
    
      getUserData(): UserData | null {
        const data = this.get(UserStorageKey.USER_DATA);
        if (data) {
          return JSON.parse(data);
        }
        return null;
      }
    
      setUserData(userData: UserData) {
        this.set(UserStorageKey.USER_DATA, JSON.stringify(userData));
      }
    
      clear() {
        this.clearItems([
          UserStorageKey.ACCESS_TOKEN,
          UserStorageKey.REFRESH_TOKEN,
          UserStorageKey.USER_DATA
        ]);
      }
    }
    
    export default new UserStorage();

     

    Wrapper 의 역할을 크게 2가지로 설명했지만, 그 이외에도 유용한 용도가 많다.

    아래 링크들을 참고하길 바란다.

     

    참고한 글

    https://betterprogramming.pub/creating-localstorage-wrapper-with-typescript-7ff6b71b35cb

     

    더 보면 좋은 글

    https://medium.com/banksalad/safe-localstorage-using-typescdript-eac147f59ae

    반응형

    'Typescript' 카테고리의 다른 글

    덕 타이핑(Duck typing) 이란? :: 마이구미  (2) 2020.02.23

    댓글

Designed by Tistory.