1-1. 과제 복습 일지

MJ·2025년 11월 10일
post-thumbnail

(순수 자바스크립트로 저런 걸 만드는 과제였따.)

저 때는 쇼핑몰을 한 번만 할 줄 알았찌...
또핑몰이 될지는 몰랐던 ㅎ

1-1. 프레임워크 없이 SPA 만들기

1-1 과제를 잠깐 돌아보면,

처음에는 이 정도면 할만할 꺼 같은데..? 라는 생각으로 뛰어들었지만 그거슨 나의 메타인지 부족이었다.
시간은 부족한데 테스트 코드에 내 코드는 자꾸 Fail을 하고 있었다.
순수 자바스크립트는 너무나도 오랜만에 핸들링해봐서 이벤트 함수를 선언하는 방법조차 까먹었고 있었다.
어찌저찌 GPT도 이용해먹고~ 과제 통과를 조금 쉽게 낮춰줘서 얼레벌레 통과한 감이 없지 않아 있었다.
그래서 복습해야하는 과제 중에 Top3 안에 든다고 생각했다.

그렇게 시간은 약 한 달 정도 걸려서 과제를 완성했다. 그 때 과제를 막 받았을 때 어떻게 한 주 만에 했는지 스스로도 의아할 지경이긴 했다.
아마도 지금은,
1. 강제성이 없다.
2. 그 전보다 구조화된 코드를 짜기 위해 노력했다.
3. 과제할 당시에는 하지 않았던 리팩토링을 여러 번 하였다.
4. 그 때는 과제를 하기 위해 직장에 있을 때 빼고는 온종히 과제를 하기 위해 매달렸고, 지금은 이래저래 과제에 온전히 몰입하지 못했기 때문이 아닐까

로 예상해본다.

처음할 때보다 무엇을 개선했는가

1. 상태 관리 Observer 패턴으로 모듈화

이전
과제에서는 const도 아니고 let을 해당 페이지 컴포넌트에서 선언하여 어디서든 변경이 가능한 전역 구조의 형태가 띄었다.

// src/pages/HomePage.js
// 아래와 같은 방식으로 선언하여 내부에서 사용하였다.

let state = { products: [], ... };

또한 상태 변경이 됐을 때, 일일히 innerHTML를 사용해서 수동으로 렌더링해주는 기함을 토했다..
(그 때는 이런 스파게티같은 코드를 신경쓸 틈이 없었다고...)


// 대놓고 state에 데이터를 저장시켰다. wow.. (불변성은 어디감? ㅋㅋ)
  state.products = projectData.products;
// 심지어 아래의 코드를 반복해서 쓰는데 함수화시킬 생각조차도 생각하지 못했다는 게 아쉬운 대목...
  document.body.querySelector("#root").innerHTML = Main(state)

현재
createStore로 상태관리를 캡슐화

// createStore에 초기상태를 받아서 선언해주면 되도록 바꿔주었다.
	const productListStore = createStore(initialState);

// 그래서 실제 다른데서 갖다 쓸 때는 커스텀 훅처럼 선언하여 쓸 수 있다.
	const { getState, getProductDetail, setState } = productItemDetail();

상태 변화를 구독하여 자동으로 렌더링

// 이전에 선언했던것처럼 하지 않아도 구독하고 있기 때문에 setState로 데이터를 변경하면 자동으로 알아서 렌더링된다.
	productListStore.setState({ products: newProducts });

2. 아키텍쳐 개선

이전
컴포넌트화도 제대로 하지 않고, page 파일에 모든 로직을 박아넣었기 때문에 굉장히 폴더 구조가 간단하다.

src/
├── components/ 
│   ├── FirstCategory.js
│   ├── List.js
│   ├── LoadingProduct.js
│   ├── Product.js
│   └── detail.js
├── pages/      
│   ├── HomePage.js                       
│   ├── Main.js                          
│   ├── NotFoundPage.js
│   ├── ProjectDetail.js
│   └── ProjectDetailPage.js
└── main.js

현재

  • state, pages, components, utils, type별로 폴더화하여 아키텍쳐를 개선하였다.
  • 또한 그 페이지에서만 쓰는 컴포넌트는 pages에서 해당 폴더 안에 컴포넌트로 구현하여 구조를 개선시켰다.
src/
├── state/                                
│   ├── baseStore.ts                      Observer 패턴 구현
│   ├── cart.ts                           
│   ├── productList.ts                   
│   ├── productDetail.ts                  
│   └── toast.ts                    
├── pages/                               
│   ├── list/                           
│   │   ├── index.ts                   
│   │   ├── card.ts                   
│   │   ├── searchBar.ts    
│   │   ├── loading.ts                    
│   │   └── constants.ts (list에서만 쓰는 상수)
│   │
│   ├── detail/   
│   │   ├── index.ts 
│   │   ├── detail.ts
│   │   ├── breadcrumb.ts
│   │   ├── products.ts
│   │   ├── card.ts
│   │   ├── review.ts
│   │   └── loading.ts 
│   │
│   └── error/ 
├── components/ 
│   ├── layout.ts
│   ├── header.ts
│   ├── footer.ts 
│   ├── skeleton.ts 
│   ├── cart/
│   └── toast/
│
├── utils/ 
│   ├── eventDelegation.ts 
│   ├── fetch.ts 
│   └── clean.ts                         cleanup 함수
│
├── type/                        
├── router.ts                           라우터 모듈
└── main.js

3. 라우팅 모듈화

이전

  • main.jsrouter 코드를 진짜 단순하게 만들어서 그냥 main.js에서 사용했다.
  • 게다가 페이지 이동할 때 쓰겠다고 router 함수 export를 사용해서 만들어 버렸다.
  export function router() {
    const [, route, id] = location.pathname.replace("/front_6th_chapter1-1", "").split("/");

    if (route === "product" && id) {
      return ProjectDetailPage(id);
    }

    if (!route) {
      return HomePage();
    }

    document.body.querySelector("#root").innerHTML = _404_();
  }

현재
main.js에서는 state를 구독하기 위한 코드로 넣어졌다.

// main.js
// subscribeToStore를 통해 state를 구독하였다.

  function main() {
    const appRouter = router();

    const root = document.getElementById("root");
    if (root) {
      root.innerHTML = appRouter.render();
    }

    // ✅ 구독 - 상태 변경시 자동 렌더링
    appRouter.subscribeToStore(toastStore);
    appRouter.subscribeToStore(cartStore);
    appRouter.subscribeToStore(productStore);
    appRouter.subscribeToStore(productListStore);

    // popstate 이벤트 리스너 등록 (뒤로가기/앞으로가기, 테스트의 goTo 지원)
    window.addEventListener("popstate", () => {
      appRouter.render();
    });
  }

router 파일을 따로 구축하여 main.js의 코드를 개선했다.
또한 캡슐화하면서 render, push, subscribe를 같이 구현하였다.

// router.ts
export const router = () => {
  const render = (path?: string) => {
    const link = path ? path : location.pathname;
    const originLink = checkParameter(link)[0];
    const pathnames = link.split("/");
    let html = "";

    // (... 컴포넌트 결정하는 함수 생략)

    // DOM 업데이트
    const root = document.getElementById("root");
    if (root) {
      root.innerHTML = html;
      // DOM 변경 이벤트 발생
      root.dispatchEvent(new Event("DOMContentLoaded", { bubbles: true }));
    }
    return html;
  };

  const push = (path: string) => {
    window.history.pushState(null, "", path);
    const pathnames = path.split("/");

    //(...reset을 위한 코드 생략)

    render(path);
  };

  // store를 구독 - 상태 변경시 자동 렌더링
  const subscribeToStore = (store: { subscribe: (listener: () => void) => () => void }) => {
    return store.subscribe(() => {
      render();
    });
  };

  return { render, push, subscribeToStore };
};

4. 이벤트 리스너 모듈화 및 메모리 누수 개선

이전
어떤 (가령.. Proudct List, Product detail 라던지) 페이지에 addEventListener의 동일한 패턴의 코드를 계속 나열해서 사용하였다.

// HomePage.js
// 정말 쓸데없이 document.addEventListener의 계속 똑같은 코드가 나열되고 있다.
  document.addEventListener("change", (e) => {
    if (e.target.matches("#limit-select")) { ... }
    if (e.target.matches("#sort-select")) { ... }
  });

  document.addEventListener("input", (e) => {
    if (e.target.matches("#search-input")) { ... }
  });

  document.addEventListener("click", (e) => {
    if (e.target.matches("#quantity-increase")) { ... }
  });

현재

  • 아예 utils 폴더에 eventDelegation라는 파일로 따로 분리하여 캡슐화하여 다른 페이지에서는 import해서 사용할 수 있다.
  • AbortController의 signal로 이벤트와 연결하여 cleanup 할 때는 abort하여 일괄 제거함으로써 메모리 누수를 방지하였다.
// eventDelegation.ts
const createEventManager = () => {
  const registeredHandlers: {
    input: Map<string, (e: Event) => void>;
    change: Map<string, (e: Event) => void>;
    click: Map<string, (e: Event) => void>;
  } = {
    input: new Map(),
    change: new Map(),
    click: new Map(),
  };

  let globalInitialized = false;
  let abortController = new AbortController();

  // 공통 로직을 헬퍼 함수로 추출(생략)

  const initializeGlobalListeners = () => {
    if (globalInitialized) return;

    document.addEventListener("input", (e: Event) => {
      const id = (e.target as HTMLElement).id;
      registeredHandlers.input.get(id)?.(e);
    });

    document.addEventListener("change", (e: Event) => {
      const id = (e.target as HTMLElement).id;
      registeredHandlers.change.get(id)?.(e);
    });

    document.addEventListener("click", (e: Event) => {
      const target = e.target as HTMLElement;
    });

    globalInitialized = true;
  };

  return {
    register: (handlers: EventHandlers) => {
      initializeGlobalListeners();

      registerHandlers("input", handlers.input);
      registerHandlers("change", handlers.change);

      registerHandlers("click", handlers.click, (id, handler) => {
        document.querySelectorAll(`#${id}`).forEach((element) => {
          if (!element.hasAttribute("data-click-attached")) {
            element.setAttribute("data-click-attached", "true");
            element.addEventListener("click", handler, { signal: abortController.signal });
          }
        });
      });
    },

    cleanup: () => {
      abortController.abort();
      abortController = new AbortController();
    },
  };
};

// 싱글톤 인스턴스
const manager = createEventManager();

export const createEventDelegation = (handlers: EventHandlers) => {
  return () => manager.register(handlers);
};

다른 파일에서 사용할 때는, createEventDelegation를 import해와서 선언하여 사용하면 이전처럼 일일히 addEventListener를 선언하지 않아도 사용할 수 있게 되었다.

  createEventDelegation({
    input: {
      "search-input": (e) => { ... }
    },
    change: {
      "limit-select": (e) => { ... },
      "sort-select": (e) => { ... }
    },
})();

5. TS 추가

이전
원래는 JS 파일을 쓰고 있었다. 그러다 보니 타입 추론하기가 쉽지 않아 화면에서 undefined가 나타나 프로덕션이 안정적이지 않았다.

let state = {
  products: [],
  pagination: {}
};

현재
타입을 추가하면서 특히 API에서 어떤 데이터가 떨어지는지 알기 쉽게 되어 타입에 대한 에러가 있을 경우, 즉각적으로 알 수 있게 되어 이전보다 한층 안정적인 프로덕션이 됐다.

interface Product {
  title: string;
  lprice: string;
  productId: string;
  // ...
}

interface Pagination {
  page: number;
  limit: number;
  total: number;
  // ...
}

const state: {
  products: Product[];
  pagination: Pagination;
} = { ... };

깨달은 점

순수 자바스크립트로 리액트의 동작을 구현해보면서,
아 '이래서 상태변화는 최소한으로 하는게 좋구나'를 알 수 있고, 순수 자바스크립트에서 가상 돔을 구현하지 않는 이상 무조건 root의 innerHTML를 돌려야 하기 때문에 큰 처리 비용이 들어서 무조건 상태 변화는 적어야 프로덕션에 무조건 이득이라서 리액트에서도 왜 상태 처리를 한 번 하려고 하는지 알 수 있던 덕목 중에 하나이며,
라우터는 페이지 이동이나 화면에서 알맞는 컴포넌트를 렌더링하는 것으로 알고 있었긴 했는데 역시 라우터 또한 상태를 'subscribe'했기 때문에 알맞는 화면을 렌더링할 수 있다는 것을 이번에 복습하면서 특히 알 수 있었던 부분이기도 했다.

마치며...

이번에 다시 과제를 하면서 느꼈지만, 왜 리팩토링은 해도 해도 끝이 없는 거 같달까 그래도 이전 했던 과제의 코드보다는 많은 것을 개선한 것 같다.
과제도 easy 버전만 했다면 이번에는 hard 버전까지 완료하면서
리팩토링 마친 것으로 끝!!!
(아니, 근데 솔루션 코드 보는데 라이프 사이클 구현이라니... 역시 클라쓰가 다르다 ㅋㅋㅋㅋ)

profile
이전 블로그: https://c11.kr/dyb0

4개의 댓글

comment-user-thumbnail
2025년 12월 14일

1주차 회고 잘 보고 가요 :) 2주차도 기대할게요~

1개의 답글
comment-user-thumbnail
2026년 1월 27일

오.. 다 잊고있었는데 다시 상기시키고 갑니다! 잘 읽었습니다!!

1개의 답글