Typescript로 Local Storage 안전하게 사용하기

최근 1년 간 뱅크샐러드 웹은 많은 변화가 있었습니다. 새로운 금융상품 추천부터 어려운 금융지식을 풀어쓰는 매거진 까지 다양한 기능을 제공하고 많은 사용자들이 방문하기 시작했습니다. 거대해진 프로젝트 규모에 따라 보다 안정적인 코드베이스를 지향하게 되었고, 최근에 개발된 CMA 추천을 시작으로 Typescript 기반의 아키텍쳐를 구성했습니다. 그 중 다양한 용도로 사용되는 LocalStorage를 보다 안전하게 사용할 수 있도록 구현한 이야기를 공유하고자 합니다.

기존 방식의 문제점

일반적인 LocalStorage의 사용 방법은 아래와 같습니다.

localStorage.setItem('key', 'value');
const result = localStorage.getItem('key');

string key와 value를 받아 브라우저에 저장하고 불러오는 단순한 명세가 제공되고 있습니다.

이 저장공간은 실제로 다양한 용도로 사용됩니다. 로그인 상태를 유지하기 위한 token을 저장하거나, 웹사이트에서 사용자가 작업한 내용을 일시적으로 유지하기 위한 용도로 사용됩니다. 뱅크샐러드 웹에서도 LocalStorage는 금융상품 추천 과정 중, 사용자가 입력한 설문 내용을 유지하기 위해 사용됩니다.

단순히 하나의 문자열 값을 저장하기도 하지만 대부분의 경우에 실제 데이터 모델을 저장하는 경우가 많습니다.

class Model {
    id: number;
    name: string;

    constructor(id: number, name: string) {
        this.id = id;
        this.name = name;
    }
}

const model = new Model(1, 'rainist');

localStorage.setItem('model', JSON.stringify(model));
Model 데이터를 LocalStorage에 저장

단순한 저장을 위해서는 문제 없는 방식이지만 해당 모델이 JSON형태의 string으로 바뀌었는지, 그리고 불러올 때도 동일한 모델 형태가 되는지가 보장되지 않습니다. 그래서 아래와 같이 실험해보았습니다.

class Parent {
    children: Array<Child>;
}

class Child {
    name: string;
}
실험모델 정의 (constructor는 생략)
const mom = new Parent([
    new Child('son'),
    new Child('daughter')
]);

// 생성된 parent type의 mom을 정의합니다.
localStorage.setItem('mom', JSON.stringify(mon));

// 저장된 mom을 불러옵니다.
const revivedMom = JSON.parse(localStorage.getItem('mom'));

// 불러온 mom이 parent type인지 체크합니다.
console.log(revivedMom instanceOf Parent); // false
실험 결과 fail

실험 결과, 모델을 그대로 저장하고 불러올 경우 기존의 instance와 다른 모델이 되어버립니다. 특히 이와 같은 코드가 presentation 내에 존재하는 불상사(?)가 많아 모델의 인스턴스를 잃어버린채 사용하거나, 어디서 저장되고 불러와 사용하는지 관리 되지 않는 경우가 많습니다.

어떻게 안전하게 관리할 수 있을까?

사실 더 근본적인 문제는 LocalStorage가 데이터베이스와 유사한 성격임에도 DataSource 와 같은 개념으로 사용되지 않는다는 점이었습니다. (사용 방법이 너무 단순한 것도 크게 한 몫 하고있습니다…) 이런 문제 의식를 갖고 먼저 LocalStorage를 바라보는 관점을 DataSource 로 통일하였습니다.

Data layer에 자리한 BrowserStorage(LocalStorage)
Data layer에 자리한 BrowserStorage(LocalStorage)

뱅크샐러드 웹은 Clean Architecture 구조에 따라 제품을 설계했습니다. 그래서 위 디렉토리 구조와 같이 Data Layer에 하나의 채널인 BrowserStorage라는 이름으로 LocalStorage를 관리하기로 했습니다. BrowserStorage라고 이름 붙인 이유는, 브라우저에 데이터를 저장하는 LocalStorage와 SessionStorage를 모두 활용할 수 있도록 하기 위함이었습니다. 위와 같이 문제를 정의하고 관점을 통일한 다음 본격적인 문제해결을 위한 추상화를 진행했습니다. 먼저 떠올린 그림은 아래와 같습니다.

mapper

기존에 UseCase 레벨에서 DataSource로 직접 접근하던 방식을 지양하고, 프로젝트 상에 명확한 개념으로 존재하는 Model 만으로 Storage를 사용하도록 API를 제공하는 방식을 생각했습니다. 그림과 같은 모습으로 BrowserStorage가 제 역할을 한다면 Model 기반으로 저장/불러오기가 가능하고, 과정 중에 발생한 오류 처리도 용이해져 문제를 해결할 수 있습니다.

구현하기

본격적으로 머리 속에 있던 BrowserStorage를 구현해보겠습니다. 핵심 구성인 아래 두가지 요소를 구현하려고 합니다.

  • BrowserStorage (Class)
  • BrowserStorageMapper (Interface)

BrowserStorage

하나의 Model을 보관하는 저장소 입니다. 외부에서는 이 저장소에 데이터를 저장하기, 불러오기, 삭제하기 라는 행동으로 처리를 요청하는 것이 핵심입니다. 미리 코드 상에 등장하는 Mapper라는 개념은 보관하고자 하는 대상을 JSON으로 바꿔주거나 JSON인 대상을 다시 Model로 읽어주는 역할을 담당합니다. 아래 자세한 설명이 있습니다.

구현은 다음과 같습니다.

class BrowserStorage<E> {
    private key: BrowserStorageKey;
    private mapper: BrowserStorageMapper<E>;

    constructor(
        key: BrowserStorageKey,
        mapper: BrowserStorageMapper<E>
    ) {
        this.key = key;
        this.mapper = mapper;
    }

    get(): E {
        return this.mapper.fromJson(
            BrowserStorageHelper.get(this.key)
        );
    };

    set(target: E, temporary: boolean = false) {
        BrowserStorageHelper.set(
            this.key,
            this.mapper.toJson(target),
            temporary
        );
    }

    clear() {
        BrowserStorageHelper.clear(this.key);
    }
}

먼저 BrowserStorage는 다루고자 하는 대상 E를 정의하고, get(), set(), clear() 메소드를 통해 설계했던 행동들을 실행합니다. constructor 에서는 저장될 key값과 mapper를 주입받습니다. mapper를 주입받도록 한 이유는 LocalStorage의 key 하나가 value 하나를 의미하는 것 처럼 BrowserStorage도 key와 model이 1:1 관계라고 설계했기 때문에 key와 mapper 또한 1:1 관계가 성립하기 때문입니다.

실제 사용방법은 더욱 간단합니다.

const SomethingStorage = new BrowserStorage(
    BrowserStorageKey.SOMETHING,
    new SomethingMapper()
);

// 불러오기
const something: Something = SomethingStorage.get();
// 저장하기
SomethingStorage.set(new Something());
// 삭제하기
SomethingStorage.clear();
Model만으로 통신가능한 Storage

SomethingStorage와 같이 고유한 key와 mapper를 넣어 생성하고 나면, 원하던 것 처럼 model 을 저장하고 불러올 수 있습니다.

BrowserStorageMapper

Mapper는 독립적인 두 개념을 이어주는 역할을 합니다. 지금 상황에서는 LocalStorage와 Model이라는 두 개념을 이어줍니다. 그렇기 때문에 Mapper는 LocalStorage가 JSON string을 이용한다는 것을 알고 있고, Model의 형태 또한 알고 있습니다. Mapper라는 하나의 객체가 양측의 맥락을 알고있기 때문에 Model에도 BrowserStorage에도 JSON string이라는 개념을 전할 필요가 없게 됩니다.

장황한 설명과 다르게 역할은 아주 간단합니다. JSON 과 Model이 상호교환 가능한 함수만 지니고 있습니다.

export interface BrowserStorageMapper<E> {
    fromJson(json: any): E
    toJson(target: E): any
}

위에서 계속 예시로 들었던 Something을 Mapping 해보면 아래와 같습니다.

class SomethingMapper implements BrowserStorageMapper<Something> {
    fromJson(json: any): Something {
        return new Something(
            json.id,
            json.name
        );
    }

    toJson(target: Something): any {
        return {
            id: target.id,
            name: target.name
        }
    }
}

기존에 이와 같은 Mapper를 구현한 경험이 없다면 이 과정이 번거롭게 느껴질 수 있습니다. 단순한 객체도 일일히 Mapping해야하는 단점은 있지만, 에러가 발생하는 지점이 명확하고 테스트 코드를 작성하기에도 아주 용이한 형태입니다.

글을 맺으며

위와 같은 구현으로 절대적인 코드량은 증가했지만, 확실한 관심사 분리로 LocalStorage 사용에 대한 안정성을 높일 수 있었습니다.

웹 개발은 다른 개발 환경에 비해 상대적으로 문제 해결을 안전하지 않은 방식으로 간단히 처리하기 쉽습니다. 최근 웹에 많은 발전이 일어나며 이러한 문제들이 어느정도 해소되고 있지만, 여전히 위험한 코드도 잘 돌아갑니다. 이제는 그런 위험한 코드를 걷어내고 보다 건강한 코드베이스를 만들어 안정적인 품질의 제품을 만들고 싶습니다.


보다 빠르게 뱅크샐러드에 도달하는 방법 🚀

지원하기