자바스크립트와 의존성

thumbnail

 

문제

프론트엔드 개발을 하다보면, 컴포넌트나 함수 내부에서 외부 모듈에 대한 의존성을 생각안하고 코드를 작성하게 될 때가 많습니다.

예시로 로컬스토리지에 저장된 투두리스트의 항목을 가져오거나, 추가하는 함수를 봅시다.

todos.ts

// todos 가져오기 export function getTodos() { const rawTodos = window.localStorage.getItem("todos") ?? `[]`; const todos = JSON.parse(rawTodos); return todos; } // 새로운 todo 추가 export function addTodo(newTodo: string) { // 기존 todoList 가져오기 const todos = getTodos(); // 새로운 값 추가한 투두 리스트 생성 const newTodos = [...todos, newTodo]; // 새로운 투두 리스트로 갱신 const newTodosString = JSON.stringify(newTodos); window.localStorage.setItem("todos", newTodosString); }

제가 처음 자바스크립트를 배울 때 작성했던 코드입니다.

간단해 보이는 이 코드는 사실 변경에 굉장히 취약한 코드입니다. 이유는 바로 소리소문 없이 조용히 window 객체에 의존하고 있기 때문입니다. 어차피 자바스크립트를 브라우저에서 실행하기 때문에 그런 의존관계를 걱정하지 않아도 된다고 생각할 수 있습니다.

하지만, todos를 저장하는 장소를 바꾼다고 하면 어떻게 될까요? window의 localStorage가 아닌 MyStorage라는 별도의 외부 API를 매개로 저장한다고 생각해봅시다. 또한, MyStorage는 자바스크립트 객체 형태로 저장이 가능하다고 합시다. 위 코드를 아래와 같이 수정합니다.

todos.ts

import { MyStorage } from "..." // todos 가져오기 export function getTodos() { const todos = MyStorage.get('todos'); return todos; } // 새로운 todo 추가 export function addTodo(newTodo: string) { // 기존 todoList 가져오기 const todos = getTodos(); // 새로운 값 추가한 투두 리스트 생성 const newTodos = [...todos, newTodo]; // 새로운 투두 리스트로 갱신 MyStorage.set("todos", newTodos); }

이전 코드에서 localStorage를 사용한 부분과, localStorage를 사용했기 때문에 추가적으로 작성된 코드를 찾아서 고쳤습니다. 하지만, todos를 조작하는 함수가 addTodos외에도 아래와 같이 deleteTodo, clearTodos가 존재했다면?

todos.ts

// 특정 아이템 삭제 function deleteTodo(targetTodo){ // 기존 todos 가져오기 const rawTodos = window.localStorage.getItem('todos'); const todos= JSON.parse(rawTodos); // 제거한 todos 생성 const filteredTodos = todos.filter(todo => todo === targetTodo); // 새로운 todos로 갱신 const filteredTodosString=JSON.stringify(newTodos); window.localStorage.setItem('todos', filteredTodosString); } // 초기화 function clearTodos(){ // 빈 todos 생성 const emptyTodos=JSON.stringify([]); // 새로운 todos로 갱신 window.localStorage.setItem('todos', emptyTodos); }

두 함수에서도 마찬가지로, localStorage가 사용된 부분의 코드를 찾아가서 내부 구현을 변경해주어야 합니다. 이 예시에서는 코드 수정 비용이 커 보이지 않지만, 함수 내부 로직이 복잡할 수록 비용이 정말 커지고, 그 비용은 오롯이 개발자가 감당하게 됩니다.

또한, 코드 수정이 많아질 수록, 자연스럽게 개발자는 본인 코드가 정상 작동하는지에 대한 의구심이 커지게 됩니다. 그리고 이를 해결하고자 테스트 코드를 작성하곤 하는데요, 최초에 localStorage를 사용한 예시에 대한 테스트 코드를 아래와 같이 작성할 수 있습니다.

todos.test.ts

// window.localStorage 모킹 const localStorageMock = (() => { const store: { [key: string]: string } = {}; return { getItem: (key: string) => store[key] || null, setItem: (key: string, value: string) => { store[key] = value.toString(); }, }; })(); Object.defineProperty(globalThis, "window", { value: { localStorage: localStorageMock }, }); // todos에 대한 테스트 describe("todos test", () => { beforeEach(() => { // NOTE: 최초로 initialItem이 들어간 상태로 테스트 합니다. localStorageMock.setItem("todos", JSON.stringify(["initialItem"])); }); afterEach(() => { vi.resetAllMocks(); }); it("getTodos", () => { expect(getTodos()).toStrictEqual(["initialItem"]); }); it("addTodos", () => { addTodo("hello"); expect(getTodos()).toStrictEqual(["initialItem", "hello"]); }); it("deleteTodo", () => { deleteTodo("initialItem"); expect(getTodos()).toStrictEqual([]); }); it("clearTodos", () => { clearTodos(); expect(getTodos()).toStrictEqual([]); }); });

getTodos, addTodos, deleteTodo, clearTodos 모두 window.localStorage에 의존하고 있습니다. 따라서 각 함수 로직을 테스트 하기 위해 window 객체에 대한 모킹이 불가피 합니다. (jsdom을 이용해서 테스트 환경에 주입할 수도 있습니다.) 저는 가져오는 todos를 조작하는 핵심 로직만 테스트하고 싶은데, 함수마다 곳곳에서 방해가 있어서 모킹을 위한 코드가 많아졌습니다. 그리고 모킹에 따른 테스트 신뢰도 하락을 피할 수 없게 됐습니다.

또한, 중요한 것은 테스트 코드 역시 유지보수 해야할 대상입니다. localStorage관련 테스트 코드를 걷어내면, 그에 따라 새로 사용하는 모듈을 모킹하면서 테스트 하거나 주입하면서 수정해야 합니다. 프론트엔드 개발을 하면서 테스트 코드 작성이 불편하고 번거롭게 느껴지는 가장 큰 이유입니다.

 

개선하기

처음에 작성한 코드를 개선해봅시다. 우선 위에서 말한 문제점들을 다시 정리해보면 다음과 같습니다.

위 문제들을 해결하기 위해, 결국에는 todos 배열을 조작하는 핵심 로직window.localStorage를 이용해 저장소를 다루는 로직을 분리해야 합니다. 각각 함수마다 핵심 로직만 담고, 저장소를 다루는 로직을 외부에서 주입받도록 합시다.

이때, 인자로 받는 저장소의 인터페이스를 먼저 규정해야합니다.

storage.ts

export interface Storage<V> { get: (key: string) => V | null; set: (key: string, value: V) => void; }

저장소의 값을 가져오는 get과, 값을 갱신하기 위한 set 함수의 인터페이스를 정의했습니다. 앞으로 이 인터페이스에 맞는 저장소만 인자로 받으면, 저장소가 변경되어도 핵심 로직의 코드 수정이 필요가 없습니다.

위 인터페이스에 맞게 localStorage를 사용하기 위해, LOCAL_STORAGE라는 객체로 레이어를 두고 이 레이어를 거쳐서 window.localStorage를 호출하도록 합니다.

storage.ts

export const LOCAL_STORAGE: Storage<string[]> = { get: (key) => JSON.parse(window.localStorage.getItem(key) ?? ""), set: (key, value) => { const valueString = JSON.stringify(value); window.localStorage.setItem(key, valueString); }, };

window.localStorage의 호출이 한 곳으로 응집되는 부수적인 이점도 얻게 되었습니다.

이제 이 LOCAL_STORAGE와 같은 저장소들을 핵심 로직에서 사용할 수 있도록 주입해주어야 합니다. 아래와 같은 두 가지 구현이 있을 것 같습니다.

 

1. Todos 클래스

todos를 제어하는 Todos 라는 클래스를 만들고, 생성자에서 저장소를 주입해주는, 전형적인 의존성 주입 방식입니다.

todos.ts

class Todos { private storage: Storage<string[]>; constructor(storage: Storage) { this.storage = storage; } getTodos() { const todos = this.storage.get("todos"); return todos; } addTodo(newTodo: string) { const todos = this.getTodos() ?? []; // 새로운 값 추가한 투두 리스트 생성 const newTodos = [...todos, newTodo]; // 새로운 투두 리스트로 갱신 this.storage.set("todos", newTodos); } deleteTodo(targetTodo: string) { const todos = this.getTodos(); // 제거한 todos 생성 const filteredTodos = todos?.filter((todo: string) => todo !== targetTodo) ?? []; // 새로운 todos로 갱신 this.storage.set("todos", filteredTodos); } clearTodos() { const emptyTodos: string[] = []; this.storage.set("todos", emptyTodos); } } ... const todos = new Todos(LOCAL_STORAGE); todos.getTodos(); todos.addTodo("hello");

 

2. 함수마다 인자로 받기

함수마다 인자로 저장소를 받습니다.

todos.ts

// todos 가져오기 export function getTodos(storage: Storage) { const todos = storage.get("todos"); return todos; } // 새로운 todo 추가 export function addTodo(storage: Storage, newTodo: string) { // 기존 todoList 가져오기 const todos = getTodos(storage) ?? []; // 새로운 값 추가한 todos 생성 const newTodos = [...todos, newTodo]; // 새로운 todos로 갱신 storage.set("todos", newTodos); } // todo 제거 export function deleteTodo(storage: Storage, targetTodo: string) { // 기존 todos 가져오기 const todos = getTodos(storage); // 제거한 todos 생성 const filteredTodos = todos?.filter((todo: string) => todo !== targetTodo) ?? []; // 새로운 todos로 갱신 storage.set("todos", filteredTodos); } // todos 초기화 export function clearTodos(storage: Storage) { // 빈 todos 생성 const emptyTodos: string[] = []; // 새로운 todos로 갱신 storage.set("todos", emptyTodos); }

순서의 차이만 있을 뿐, 두 가지 방식은 본질적으로 같습니다. 결국 핵심 로직에서, 외부 의존성과의 결합을 끊었습니다.

테스트 코드는 어떻게 변했을까요? 두 번째, 함수의 인자로 저장소를 받는 방식을 기준으로 테스트를 작성해보았습니다.

todos.test.ts

const testStorage: Storage<string[]> = (() => { const store: Record<string, string[]> = {}; return { get: (key) => store[key] || null, set: (key, value) => { store[key] = value; }, }; })(); describe("todos test", () => { beforeEach(() => { // NOTE: 최초로 initialItem이 들어간 상태로 테스트 합니다. testStorage.set("todos", ["initialItem"]); }); it("getTodos", () => { expect(getTodos(testStorage)).toStrictEqual(["initialItem"]); }); it("addTodos", () => { addTodo(testStorage, "hello"); expect(getTodos(testStorage)).toStrictEqual(["initialItem", "hello"]); }); it("deleteTodo", () => { deleteTodo(testStorage, "initialItem"); expect(getTodos(testStorage)).toStrictEqual([]); }); it("clearTodos", () => { clearTodos(testStorage); expect(getTodos(testStorage)).toStrictEqual([]); }); });

window.localStorage를 모킹할 필요가 없어졌습니다. 왜냐하면 우리는 localStorage의 동작을 테스트하고 싶은 것이 아니라, 각 함수의 핵심 로직만 테스트하고 싶기 때문입니다. 우리가 정의한 인터페이스에 맞는 testStorage라는 객체를 임의로 생성하고, 그것을 각 함수에 인자로 넘겼을 때 원하는 동작을 하는지만 테스트하면 됩니다. 이로써 핵심 로직이 외부 의존성과의 관계를 끊고 테스트에 의해 견고하게 지켜지게 된 것입니다.

결과적으로 우리가 목표했던 바를 달성하게 되었습니다.

  1. 함수 내부에서 window.localStorage를 호출하지 않고, 인자로 미리 정의한 인터페이스를 가진 저장소를 인자로 받기 때문에 저장소가 변경되어도 각 함수 내부 구현이 변경될 필요가 없어졌습니다.
  2. 모킹하지 않고 핵심 로직만 테스트할 수 있습니다.

 

마무리

사실 이 글에서 작성한 내용은 정말 너무나도 당연한 내용입니다. 변경될 내용을 미리 예측해서 외부에서 받도록 한다! 는 우리가 프로그래밍에서 너무나도 당연시하는 부분입니다. 하지만 이상하게 웹 API나 import한 외부 모듈을 사용할 때는 이를 고려하지 않고 관대해지는 것 같습니다. 그 결과 끊임 없는 외부 의존성에 대한 강결합으로 모킹 서커스를 해야 테스트를 할 수 있게 되거나, 타협하여 아예 테스트 코드를 작성하지 않게 되곤 합니다. 최근에 스스로 경각심을 느끼면서 학습했던 내용을 정리해 보았습니다. 감사합니다.

 

참고