들어가며
저는 프론트엔드 개발을 하면서 때때로 사용해야 하는 저장소 중에 하나인 Browser의 Storage를 어떻게 하면 잘 활용할 수 있을까?
에 대한 글을 작성해보려고 하는데요. 저희가 많이 사용하는 Storage에는 LocalStorage, SessionStorage등이 있을 것 같은데요. 이 중에서 LocalStorage에 대한 예시를 같이 보면서 얘기하려고 해요. 기존에 Stroage Interface가 제공하는 API들을 사용하는 것은 개발을 할 때 어떤 아쉬움이 있는지 확인하고 어떤 방향으로 개선하려고 하는지 고민을 하고 구현체를 만들어 보려고 해요.
Storage Interface
https://developer.mozilla.org/en-US/docs/Web/API/Storage
Storage Interface가 제공하고 있는 메소드 중에서 주로 사용하는 것에는 아래와 같은 것들이 있어요.
- Storage.getItem(): key값을 넘겨주면 해당 key값에 해당하는 value값을 반환한다.
- Storage.setItem(): key, value값을 넘겨주는 storage에 해당 key에 value값을 저장한다, 만약 이미 해당 key에 값이 존재하는 경우에는 update 처리를 한다.
- Storage.removeItem(): key값을 넘겨주면 해당 key를 제거한다.
- Storage.clear(): storage의 모든 key가 제거된다.
많이들 익숙하실 것이라고 생각이 들지만 만약 잘 모르시더라도 조회, 추가, 수정, 삭제등의 CRUD 기능을 할 수 있는 메소드를 제공한다고 생각하시면 이해하시는 데 문제가 없을 것 같아요.
인터페이스를 보면 사실 크게 문제는 없는 것 같아요. 메소드 이름도 적절하다고 느껴지고 input, output도 적절하다고 느껴지기 때문이에요. 그러면 해당 인터페이스의 구현체인 localStorage를 사용하는 코드를 같이 보면서 어디서 아쉬움을 느꼈는지 확인해보려고 해요.
문제점
많은 코드를 작성하기 보다는 실제로 로컬 스토리지 코드를 사용하는 부분만 보면서 예상되는 문제점을 얘기해보려고 해요. 아래는 투두리스트에서 사용할법한 코드 조각들이에요. 로컬스토리지와 관련된 코드가 어떻게 사용되는지 위주로 확인해주시면 좋을 것 같아요. (불변성에 대한 얘기는 하지 않으려고 해요)
// 할 일 목록을 조회해요.
function getTodos() {
const todos = JSON.stringify(localstoage.getItem('todos'));
return todos;
}
// 할 일을 추가해요.
function addTodo(todo) {
const todos = JSON.stringify(localstoage.getItem('todos'));
todos.push(todo)
localStorage.setItem('todos', JSON.stringify(newTodos));
}
// 할 일을 변경해요.
function updateTodo(id, newTodo) {
const todos = JSON.stringify(localstoage.getItem('todos'));
const index = todos.findIndex(todo => todo.id === id);
todos[index] = newTodo;
localStorage.setItem('todos', JSON.stringify(todos));
}
// 할 일을 삭제해요.
function deleteTodo(id) {
const todos = JSON.stringify(localstoage.getItem('todos'));
const newTodos = todos.filter(todo => todo.id !== id);
localStorage.setItem('todos', JSON.stringify(newTodos));
}
// 할 일을 초기화해요.
function clearTodos() {
localStorage.clear();
}
위에서 작성된 코드들을 봤을 때 제가 생각하는 아쉬운 점들은 아래와 같아요.
- 스토리지에 접근할 때 key값에 해당하는 문자열을 넘겨줘야 한다.
- 스토리지에서 데이터를 조회하거나 추가할 때 직렬화 과정이 필요하다.
- 여 러 곳에서 스토리지 코드를 사용하는 경우 key값을 변경해야 할 때 비용이 크다.
- 에러 케이스에 대응해야 한다.
- 어떤 스토리지에 접근하는지 직관적이지 않다.
4, 5번에 대해서는 조금 더 얘기를 드려보려고 해요.
로컬 스토리지의 에러 케이스
대부분 로컬 스토리지를 활용할 때 에러 처리를 거의 해보지 않으셨을 것 같아요. 그러나 로컬 스토리지를 사용할 때 발생할 수 있는 에러들은 분명히 있기 때문에 간단하게 설명드리려고 해요.
- 브라우저에서 로컬 스토리지를 사용하지 못하게 설정되어 있는 경우
- 로컬 스토리지에 저장되어 있는 데이터가 깨져서 파싱에 실패하는 경우
- 로컬 스토리지의 약 5MB 저장 공간을 초과하는 경우
물론 위와 같은 에러가 발생할 경우가 드물긴 하지만, 에러가 발생할 가능성이 있다라고 하면 그에 대한 대응을 하는게 맞다고 생각을 해요. 따라서 에러 대응이 필요한데, 위의 예제 코드와 같이 로컬 스토리지를 사용한다면 사용하는 곳에서 반복적으로 에러 처리를 해야하는 불편함이 있을 것 같아요.
스토리지 접근의 직관성
이 내용은 개인적인 취향이라고 볼 수 있을 것 같아요. 코드를 같이 보면서 저는 코드를 어떤식으로 읽어나가는지 말씀드리려고 해요.
- localStorage.getItem('todos'): 로컬스토리지에서 값을 가져올 건데, 그 값은 투두 목록에 해당하는 값이야.
- localStorage.setItem('todos', todos): 로컬스토리지에 값을 저장할건데, todos라는 key에 투두 목록을 저장할거야.
그런데 아래와 같이 코드를 읽을 수 있다면 어떨까요?
- 스토리지에서 할일 목록을 가져올거야.
- 스토리지에 할일 목록을 저장할거야.
지금 정도의 간단한 코드에서는 어떤 방식으로 코드를 작성하더라도 전혀 상관이 없지만, 코드의 양이 많아지고 복잡도가 높아지는 경우에는 최대한 간결하게 읽을 수 있도록 표현하는게 좋다고 생각해요. 그래서 조금 더 명확하게 코드를 읽을 수 있도록 하는 것도 고려해보려고 해요.
이러면 어떨까
저는 위에서 얘기했던 아쉬운 점들을 개선해보려고 해요.
로컬 스토리지를 사용하는 쪽에서 어떻게 사용할 수 있으면 좋을까요?
저는 아래와 같이 사용할 수 있으면 좋을 것 같아요.
// todo 목록을 조회해요.
function getTodos() {
const todos = todoLocalStorage.getItem();
return todos;
}
// todo를 추가해요.
function addTodo(todo) {
const todos = todoLocalStorage.getItem();
todos.push(todo)
todoLocalStorage.setItem(newTodos)
}
// todo를 변경해요.
function updateTodo(id, newTodo) {
const todos = todoLocalStorage.getItem();
const index = todos.findIndex(todo => todo.id === id);
todos[index] = newTodo;
todoLocalStorage.setItem(todos)
}
// todo를 삭제해요.
function deleteTodo(id) {
const todos = todoLocalStorage.getItem();
const newTodos = todos.filter(todo => todo.id !== id);
todoLocalStorage.setItem(todos)
}
function clearTodos() {
todoStorage.clear();
}
불필요하게 반복되는 코드들을 제거하고 무엇(What)을 하고 싶은지에 대해 집중할 수 있는 코드로 표현이 된 것 같아요. 이와 같이 사용하기 위해서는 todoLocalStorage는 어떻게 구성이 되어야 할까요?
예제는 로컬 스토리지와 할일 목록에 대한 데이터를 다루고 있지만, 실제로는 세션 스토리지를 사용할 수도 있고 여러 데이터를 저장할 수 있기 때문에 저는 원형이 되는 하나의 클래스를 만들고, 특정 데이터를 저장하고 싶을 때는 클래스를 통해 객체를 생성하여 데이터 저장소를 만들어 보려고 해요.
먼저 원형이 되는 클래스는 아래와 같이 만들 수 있을 것 같아요.
export class BaseStorage<T> {
private storage: Storage;
private key: string;
private errorFallbackValue?: T;
constructor({
storage = localStorage,
key,
initialValue,
errorFallbackValue,
}: {
storage?: Storage;
key: string;
initialValue?: T;
errorFallbackValue?: T;
}) {
this.storage = storage;
this.key = key;
this.errorFallbackValue = errorFallbackValue;
if (initialValue) {
this.setItem(initialValue);
}
}
getItem() {
try {
const item = JSON.parse(this.storage.getItem(this.key) ?? JSON.stringify(this.errorFallbackValue)) as T;
return item;
} catch {
// TODO: 스토리지를 사용하는 상황에 맞게 별도의 처리를 할 수 있을 것 같은데요.
// 만약 스토리지에 데이터가 꺠져서 에러가 발생하는 경우에 해당 데이터를 아예 제거하거나
// errorFallbackValue로 교환할 수 있을 것 같아요.
return this.errorFallbackValue;
}
}
setItem(value: T) {
try {
this.storage.setItem(this.key, JSON.stringify(value));
} catch (error) {
// 여기서의 에러 처리는 어떤 데이터를 저장하는지에 따라 달라질 것 같아요.
if (error instanceof Error) {
console.warn("스토리지에 저장하는 동작이 실패했어요.", error.message);
return;
}
}
}
removeItem() {
try {
this.storage.removeItem(this.key);
} catch (error) {
// 여기서의 에러 처리는 어떤 데이터를 저장하는지에 따라 달라질 것 같아요.
if (error instanceof Error) {
console.warn("스토리지의 아이템 제거에 실패했어요.", error.message);
return;
}
}
}
}
해당 클래스는 생성자로 storage, key, initialValue, errorFallbackValue등의 값을 주입 받아요.
storage: 어떤 스토리지를 사용할 것인지
key: 스토리지에 어떤 key 값으로 접근할 것인지
initialValue: 스토리지를 사용할 때 초기값을 지정하고 싶은 경우
errorFallbackValue: 만약 에러가 발생했을 경우 대신해서 어떤 값을 반환할 것인지
(initialValue와 errorFallbackValue는 많이 사용 될 옵션은 아닐 것 같지만 글을 작성하면서 추가해봤어요)
그리고 위 클래스를 이용해서 특정 데이터를 저장하게 될 스토리지는 아래와 같이 만들 수 있을 것 같아요.
export type Todo = {
id: string;
title: string;
description: string;
done: boolean;
dueDate?: Date;
};
export const todoStorage = new BaseStorage<Todo[]>({ key: "todos", storage: localStorage });
이렇게 스토리지를 다루는 코드를 만들어보면 스토지리를 사용하는 입장에서 알 필요가 없는 How에 대한 것들은 클래스 내부에 숨기고 어떤 데이터를 조회하고 추가할 것인지에 대한 관심사만 명확하게 드러낼 수 있다고 생각해요.
마무리
브라우저에서 제공하는 스토리지를 사용할 때 주어진 Storage API를 그대로 사용하는 것도 물론 괜찮을 것 같아요. 그러나 한번 래핑을 했고 한 단계 상위 수준의 추상화를 통해서 불필요한 정보를 내부로 숨기고 꼭 알아야 되는 정보만 외부로 노출시킬 수 있었던 것 같아요. 이와 같이 평소에 당연하게 사용하던 인터페이스도 불편함을 느끼거나 더 좋은 방식으로 사용할 수 있는 방법이 있다면 추상화를 시도해봐도 좋을 것 같아요.
들어가며
저는 프론트엔드 개발을 하면서 때때로 사용해야 하는 저장소 중에 하나인 Browser의 Storage를 어떻게 하면 잘 활용할 수 있을까?
에 대한 글을 작성해보려고 하는데요. 저희가 많이 사용하는 Storage에는 LocalStorage, SessionStorage등이 있을 것 같은데요. 이 중에서 LocalStorage에 대한 예시를 같이 보면서 얘기하려고 해요. 기존에 Stroage Interface가 제공하는 API들을 사용하는 것은 개발을 할 때 어떤 아쉬움이 있는지 확인하고 어떤 방향으로 개선하려고 하는지 고민을 하고 구현체를 만들어 보려고 해요.
Storage Interface
https://developer.mozilla.org/en-US/docs/Web/API/Storage
Storage Interface가 제공하고 있는 메소드 중에서 주로 사용하는 것에는 아래와 같은 것들이 있어요.
- Storage.getItem(): key값을 넘겨주면 해당 key값에 해당하는 value값을 반환한다.
- Storage.setItem(): key, value값을 넘겨주는 storage에 해당 key에 value값을 저장한다, 만약 이미 해당 key에 값이 존재하는 경우에는 update 처리를 한다.
- Storage.removeItem(): key값을 넘겨주면 해당 key를 제거한다.
- Storage.clear(): storage의 모든 key가 제거된다.
많이들 익숙하실 것이라고 생각이 들지만 만약 잘 모르시더라도 조회, 추가, 수정, 삭제등의 CRUD 기능을 할 수 있는 메소드를 제공한다고 생각하시면 이해하시는 데 문제가 없을 것 같아요.
인터페이스를 보면 사실 크게 문제는 없는 것 같아요. 메소드 이름도 적절하다고 느껴지고 input, output도 적절하다고 느껴지기 때문이에요. 그러면 해당 인터페이스의 구현체인 localStorage를 사용하는 코드를 같이 보면서 어디서 아쉬움을 느꼈는지 확인해보려고 해요.
문제점
많은 코드를 작성하기 보다는 실제로 로컬 스토리지 코드를 사용하는 부분만 보면서 예상되는 문제점을 얘기해보려고 해요. 아래는 투두리스트에서 사용할법한 코드 조각들이에요. 로컬스토리지와 관련된 코드가 어떻게 사용되는지 위주로 확인해주시면 좋을 것 같아요. (불변성에 대한 얘기는 하지 않으려고 해요)
// 할 일 목록을 조회해요.
function getTodos() {
const todos = JSON.stringify(localstoage.getItem('todos'));
return todos;
}
// 할 일을 추가해요.
function addTodo(todo) {
const todos = JSON.stringify(localstoage.getItem('todos'));
todos.push(todo)
localStorage.setItem('todos', JSON.stringify(newTodos));
}
// 할 일을 변경해요.
function updateTodo(id, newTodo) {
const todos = JSON.stringify(localstoage.getItem('todos'));
const index = todos.findIndex(todo => todo.id === id);
todos[index] = newTodo;
localStorage.setItem('todos', JSON.stringify(todos));
}
// 할 일을 삭제해요.
function deleteTodo(id) {
const todos = JSON.stringify(localstoage.getItem('todos'));
const newTodos = todos.filter(todo => todo.id !== id);
localStorage.setItem('todos', JSON.stringify(newTodos));
}
// 할 일을 초기화해요.
function clearTodos() {
localStorage.clear();
}
위에서 작성된 코드들을 봤을 때 제가 생각하는 아쉬운 점들은 아래와 같아요.
- 스토리지에 접근할 때 key값에 해당하는 문자열을 넘겨줘야 한다.
- 스토리지에서 데이터를 조회하거나 추가할 때 직렬화 과정이 필요하다.
- 여 러 곳에서 스토리지 코드를 사용하는 경우 key값을 변경해야 할 때 비용이 크다.
- 에러 케이스에 대응해야 한다.
- 어떤 스토리지에 접근하는지 직관적이지 않다.
4, 5번에 대해서는 조금 더 얘기를 드려보려고 해요.
로컬 스토리지의 에러 케이스
대부분 로컬 스토리지를 활용할 때 에러 처리를 거의 해보지 않으셨을 것 같아요. 그러나 로컬 스토리지를 사용할 때 발생할 수 있는 에러들은 분명히 있기 때문에 간단하게 설명드리려고 해요.
- 브라우저에서 로컬 스토리지를 사용하지 못하게 설정되어 있는 경우
- 로컬 스토리지에 저장되어 있는 데이터가 깨져서 파싱에 실패하는 경우
- 로컬 스토리지의 약 5MB 저장 공간을 초과하는 경우
물론 위와 같은 에러가 발생할 경우가 드물긴 하지만, 에러가 발생할 가능성이 있다라고 하면 그에 대한 대응을 하는게 맞다고 생각을 해요. 따라서 에러 대응이 필요한데, 위의 예제 코드와 같이 로컬 스토리지를 사용한다면 사용하는 곳에서 반복적으로 에러 처리를 해야하는 불편함이 있을 것 같아요.
스토리지 접근의 직관성
이 내용은 개인적인 취향이라고 볼 수 있을 것 같아요. 코드를 같이 보면서 저는 코드를 어떤식으로 읽어나가는지 말씀드리려고 해요.
- localStorage.getItem('todos'): 로컬스토리지에서 값을 가져올 건데, 그 값은 투두 목록에 해당하는 값이야.
- localStorage.setItem('todos', todos): 로컬스토리지에 값을 저장할건데, todos라는 key에 투두 목록을 저장할거야.
그런데 아래와 같이 코드를 읽을 수 있다면 어떨까요?
- 스토리지에서 할일 목록을 가져올거야.
- 스토리지에 할일 목록을 저장할거야.
지금 정도의 간단한 코드에서는 어떤 방식으로 코드를 작성하더라도 전혀 상관이 없지만, 코드의 양이 많아지고 복잡도가 높아지는 경우에는 최대한 간결하게 읽을 수 있도록 표현하는게 좋다고 생각해요. 그래서 조금 더 명확하게 코드를 읽을 수 있도록 하는 것도 고려해보려고 해요.
이러면 어떨까
저는 위에서 얘기했던 아쉬운 점들을 개선해보려고 해요.
로컬 스토리지를 사용하는 쪽에서 어떻게 사용할 수 있으면 좋을까요?
저는 아래와 같이 사용할 수 있으면 좋을 것 같아요.
// todo 목록을 조회해요.
function getTodos() {
const todos = todoLocalStorage.getItem();
return todos;
}
// todo를 추가해요.
function addTodo(todo) {
const todos = todoLocalStorage.getItem();
todos.push(todo)
todoLocalStorage.setItem(newTodos)
}
// todo를 변경해요.
function updateTodo(id, newTodo) {
const todos = todoLocalStorage.getItem();
const index = todos.findIndex(todo => todo.id === id);
todos[index] = newTodo;
todoLocalStorage.setItem(todos)
}
// todo를 삭제해요.
function deleteTodo(id) {
const todos = todoLocalStorage.getItem();
const newTodos = todos.filter(todo => todo.id !== id);
todoLocalStorage.setItem(todos)
}
function clearTodos() {
todoStorage.clear();
}
불필요하게 반복되는 코드들을 제거하고 무엇(What)을 하고 싶은지에 대해 집중할 수 있는 코드로 표현이 된 것 같아요. 이와 같이 사용하기 위해서는 todoLocalStorage는 어떻게 구성이 되어야 할까요?
예제는 로컬 스토리지와 할일 목록에 대한 데이터를 다루고 있지만, 실제로는 세션 스토리지를 사용할 수도 있고 여러 데이터를 저장할 수 있기 때문에 저는 원형이 되는 하나의 클래스를 만들고, 특정 데이터를 저장하고 싶을 때는 클래스를 통해 객체를 생성하여 데이터 저장소를 만들어 보려고 해요.
먼저 원형이 되는 클래스는 아래와 같이 만들 수 있을 것 같아요.
export class BaseStorage<T> {
private storage: Storage;
private key: string;
private errorFallbackValue?: T;
constructor({
storage = localStorage,
key,
initialValue,
errorFallbackValue,
}: {
storage?: Storage;
key: string;
initialValue?: T;
errorFallbackValue?: T;
}) {
this.storage = storage;
this.key = key;
this.errorFallbackValue = errorFallbackValue;
if (initialValue) {
this.setItem(initialValue);
}
}
getItem() {
try {
const item = JSON.parse(this.storage.getItem(this.key) ?? JSON.stringify(this.errorFallbackValue)) as T;
return item;
} catch {
// TODO: 스토리지를 사용하는 상황에 맞게 별도의 처리를 할 수 있을 것 같은데요.
// 만약 스토리지에 데이터가 꺠져서 에러가 발생하는 경우에 해당 데이터를 아예 제거하거나
// errorFallbackValue로 교환할 수 있을 것 같아요.
return this.errorFallbackValue;
}
}
setItem(value: T) {
try {
this.storage.setItem(this.key, JSON.stringify(value));
} catch (error) {
// 여기서의 에러 처리는 어떤 데이터를 저장하는지에 따라 달라질 것 같아요.
if (error instanceof Error) {
console.warn("스토리지에 저장하는 동작이 실패했어요.", error.message);
return;
}
}
}
removeItem() {
try {
this.storage.removeItem(this.key);
} catch (error) {
// 여기서의 에러 처리는 어떤 데이터를 저장하는지에 따라 달라질 것 같아요.
if (error instanceof Error) {
console.warn("스토리지의 아이템 제거에 실패했어요.", error.message);
return;
}
}
}
}
해당 클래스는 생성자로 storage, key, initialValue, errorFallbackValue등의 값을 주입 받아요.
storage: 어떤 스토리지를 사용할 것인지
key: 스토리지에 어떤 key 값으로 접근할 것인지
initialValue: 스토리지를 사용할 때 초기값을 지정하고 싶은 경우
errorFallbackValue: 만약 에러가 발생했을 경우 대신해서 어떤 값을 반환할 것인지
(initialValue와 errorFallbackValue는 많이 사용 될 옵션은 아닐 것 같지만 글을 작성하면서 추가해봤어요)
그리고 위 클래스를 이용해서 특정 데이터를 저장하게 될 스토리지는 아래와 같이 만들 수 있을 것 같아요.
export type Todo = {
id: string;
title: string;
description: string;
done: boolean;
dueDate?: Date;
};
export const todoStorage = new BaseStorage<Todo[]>({ key: "todos", storage: localStorage });
이렇게 스토리지를 다루는 코드를 만들어보면 스토지리를 사용하는 입장에서 알 필요가 없는 How에 대한 것들은 클래스 내부에 숨기고 어떤 데이터를 조회하고 추가할 것인지에 대한 관심사만 명확하게 드러낼 수 있다고 생각해요.
마무리
브라우저에서 제공하는 스토리지를 사용할 때 주어진 Storage API를 그대로 사용하는 것도 물론 괜찮을 것 같아요. 그러나 한번 래핑을 했고 한 단계 상위 수준의 추상화를 통해서 불필요한 정보를 내부로 숨기고 꼭 알아야 되는 정보만 외부로 노출시킬 수 있었던 것 같아요. 이와 같이 평소에 당연하게 사용하던 인터페이스도 불편함을 느끼거나 더 좋은 방식으로 사용할 수 있는 방법이 있다면 추상화를 시도해봐도 좋을 것 같아요.