항해플러스 1주차 회고: 프레임워크 없이 SPA 만들기(1)

사실 1주차 결과물은 대실패이다.
항플항패플러스회고스터디
avatar
2025.08.03
·
22 min read

사실 1주차 결과물은 대실패이다.

항해를 시작하게 된 이유

무척 긴 취준 시간을 보내면서 어느새 주변 친구들은 다양한 경험과 멋진 실력을 가진 어엿한 경력 개발자가 되어있었다.

2025년 입사와 당일 퇴사 통보, 다시 입사를 진행하면서 나의 개발자 시작점은 더 미뤄지게 되었고, 조바심은 더욱 커졌다.

새로 입사한 회사는 프론트엔드 리드개발자가 없던 터라 어떤 방향으로 커리어를 쌓아야 할지 무척 고민이 되었다. 늦게 시작하는 만큼 짧은 시간에 많은 것을 경험하고 실력을 쌓고 싶었다.

그런 방향을 잡고 싶어 항해플러스를 시작하게 되었다.

험난했던 시작

항해가 시작함과 동시에 회사는 정말 바빠졌다. 입사하자마자 투입된 신규 서비스 릴리즈가 다가왔기 때문이다.

유관자들로 인해 내 일정은 미뤄져서 더욱 촉박했고, 기존의 잘못된 라우터 설계 구조 때문에 원하는 곳으로 네비게이트되지 않는 이슈들이 발생했다. 해당 이슈의 문제점을 찾는 것조차 많은 시도를 통해 알아내야 했다.

그렇기에 항해 1~2주차 때는 과제 내용과 별개로도 너무 힘들고 억울하고 슬펐던 것 같다.

그래도 퇴근 후 다양하고 좋은 스터디원분들과 만나면서 다양한 이야기를 나누며 정말 많은 힐링을 받았기 때문에, 3주차가 끝난 지금 회고글 작성을 다짐하게 된 것 같다.
1주차부터 천천히 회고글을 포스팅할 예정이다. 물론, 2주차와 3주차는 한 번에 포스팅할 수도 있다.

1주차부터 3주차까지는 1챕터 'JS & React 딥다이브' 주제를 학습하는 기간이다.

1주차 과제: 프레임워크 없이 SPA 만들기

처음 과제를 받았을 때, 회사에서는 신규 서비스 릴리즈가 다가오는 시기여서 물리적으로나 심적으로 무척 바빴다.

여유가 없던 나는 전형적인 "순서대로 해야 한다"는 강박에 사로잡히게 되었다. '프레임워크 없이 SPA 만들기'라는 핵심 주제보다는 눈에 보이는 기능들부터 구현하려 했다. 특히 목록과 장바구니 모달의 유닛테스트에 매몰되어 진도가 부진했다.

훗날 리팩토링을 위해 코치님께 리팩토링 순서에 대해 질문을 드렸을 때, 나만의 설계 순서도 강조하셨지만 상태관리 → 라이프사이클 → 이벤트시스템 → 라우팅시스템 순서에 대한 가이드라인을 받았다.

"상태에서부터 렌더링이 전파되고, 상태를 잘 만들어놓으면 이벤트에서 상태만 변경해도 UI에 반영될 수 있다"

이 말씀이 솔루션 코드를 확인하면서 와닿았다. 정말로 상태관리가 모든 것의 중심이었다.


나의 코드 vs 솔루션 비교 분석

1. 상태관리: 모든 문제의 출발점

내 코드의 문제점

// 나의 과제물: 모든 상태가 한 곳에 뒤섞임
let appState = {
  products: [],
  categories: {},
  total: 0,
  loading: false,
  cart: CartStorage.get() || [],
  cartModalOpen: false,
  // ... 모든 앱 상태가 한 곳에 집중
};

const setAppState = (newState) => {
  const prevState = { ...appState };
  appState = { ...prevState, ...newState };

  // 수동 렌더링 트리거
  render();

  // 수동 사이드 이펙트 처리
  if (prevState.cartModalOpen !== appState.cartModalOpen) {
    // ... 모달 처리 로직
  }
};

가장 큰 문제는 상태 관리부터 시작된다. 상품 데이터부터 UI 상태까지 모든 것이 appState라는 하나의 거대한 객체에 몰려있었다. 이렇다 보니 어느 함수에서든 appState.products = newProducts처럼 직접적으로 상태를 변경할 수 있었고, 이러한 변경이 언제 어디서 일어났는지 추적하는 것은 거의 불가능했다.

바쁜 일정과 "어떻게든 기본과제를 패스하겠다"는 의지가 담긴, 아무렇게나 쑤셔넣은 서랍장과 같았다.

더 심각한 문제는 상태 변경과 렌더링이 수동으로 연결되어 있다는 점이다. setAppState 함수 안에서 직접 render() 함수를 호출하고, 장바구니 모달 상태 변경 같은 사이드 이펙트도 수동으로 처리해야 했다. 이를 빼먹으면 UI가 업데이트되지 않는 기이한 현상이 발생하기도 했다.

솔루션의 접근법

// 도메인별 분리
const productStore = createStore(productReducer, initialProductState);
const cartStore = createStore(cartReducer, initialCartState);
const uiStore = createStore(uiReducer, initialUIState);

// Action-based 상태 변경
productStore.dispatch({
  type: PRODUCT_ACTIONS.SET_PRODUCTS,
  payload: { products, totalCount: total }
});

// 순수 함수 리듀서
const productReducer = (state, action) => {
  switch (action.type) {
    case PRODUCT_ACTIONS.SET_PRODUCTS:
      return {
        ...state,
        products: action.payload.products,
        totalCount: action.payload.totalCount,
        loading: false,
        error: null,
        status: "done",
      };
    default:
      return state;
  }
};

솔루션에서는 Redux 패턴을 차용하여 상태를 도메인별로 분리했다. productStore, cartStore, uiStore로 나누어 각각이 독립적으로 관리된다. 상품 관련 상태는 상품 스토어에서만, 장바구니 관련 상태는 장바구니 스토어에서만 관리하게 된다.

Flux 패턴을 따른 액션 기반의 상태 변경 시스템도 인상적이었다. 상태를 직접 변경하는 대신 "이런 일이 일어났다"는 의미의 액션을 발송하면, 리듀서가 그 액션을 받아서 새로운 상태를 계산한다. 이 과정에서 기존 상태는 절대 변경되지 않고, 항상 새로운 객체가 생성된다.

옵저버 패턴을 도입해서 상태가 변경되면 자동으로 구독하고 있는 컴포넌트들에게 알림이 간다. 개발자가 언제 렌더링을 해야 할지 고민할 필요 없이, 상태 변경만 일으키면 자동으로 UI가 업데이트된다.

솔루션 코드 중에 제일 흥미로웠던 것은 UI Store를 만들어서 서비스 계층을 분리한 점이었다. 각 도메인에 따라 스토어를 만들고 상태를 관리하는 것은 이미 머리에 그려졌지만, 도메인에 속하지 않는 부가적인 부분은 어떻게 처리하는지 무척 궁금했기 때문이다.

2. 컴포넌트 라이프사이클

내 코드의 문제점

// 나의 과제물: 라이프사이클 관리 없음
function render() {
  const state = getFullState();
  let html = "";

  switch (state.currentPage) {
    case "home":
      html = renderHomePage(state);
      break;
    // ...
  }

  // 단순 DOM 교체
  const root = document.body.querySelector("#root");
  root.innerHTML = html;
}

// 수동 이벤트 리스너 관리
function initEventListeners() {
  root.removeEventListener("change", handleRootChange);
  root.addEventListener("change", handleRootChange);
}

나의 과제물 코드에는 라이프사이클이라는 개념 자체가 존재하지 않았다. 페이지가 바뀌면 단순히 HTML 자체를 교체하고, 이벤트 리스너를 다시 등록했다. 이렇다 보니 이전 페이지에서 설정했던 타이머나 이벤트 리스너, 비동기 작업들이 제대로 정리되지 않아서 메모리 누수가 발생할 수 있었다.

이런 문제는 무한 스크롤 기능에서 발생하기 쉬운 구조였다. 홈 페이지에서 상품 상세 페이지로 이동할 때 기존의 스크롤 이벤트 리스너가 제대로 제거되지 않으면 상품 상세 페이지에서도 계속 상품 목록을 로드하려고 시도할 수 있기 때문이다.

솔루션의 접근법

// 페이지 컴포넌트에 사용된 고차함수 withLifecycle
export const HomePage = withLifecycle(
  {
    onMount: () => {
      loadProductsAndCategories();
    },
    watches: [
      () => { // 페이지 컴포넌트의 의존성 배열
        const { search, limit, sort, category1, category2 } = router.query;
        return [search, limit, sort, category1, category2];
      },
      () => loadProducts(true), // 콜백 함수
    ],
  },
  () => {
    // 페이지 렌더링 로직
    console.log("🏠 홈 페이지 로드");
    // ... 렌더링 코드
  },
);

발제 시간과 과제 해설 시간에 제일 흥미롭게 들었던 부분이 컴포넌트 라이프사이클을 위해 고차함수를 만든 부분이다.

일반적으로 컴포넌트 라이프사이클은 클래스로 만들어 상속받아서 설계하는 방식이 많은데, 고차함수로 만든 것이 뇌리에 강하게 박혔다.

withLifecycle 고차함수는 각 페이지 컴포넌트를 감싸서 마운트와 언마운트 시점을 자동으로 관리한다. watches 매개변수를 이용해서 React의 useEffect 훅처럼, 특정 값들이 변경되었을 때만 업데이트 로직을 실행하도록 할 수 있게 만든 것도 인상적이었다.

단순하게 고차함수를 인증인가나 래핑함수 용도로만 사용해왔던 나에게는 색다른 충격이었다.

3. 이벤트 시스템: 거대한 if문의 늪

내 코드의 문제점

// 내 코드 분석: 거대한 이벤트 핸들러
root.removeEventListener("click", handleRootClick);
root.addEventListener("click", handleRootClick);

async function handleRootClick(event) {
  const { target } = event;

  if (target.id === "cart-icon-btn") { /* ... */ }
  if (target.classList.contains("add-to-cart-btn")) { /* ... */ }
  if (target.classList.contains("breadcrumb-link")) { /* ... */ }
  if (target.classList.contains("product-image")) { /* ... */ }
  // ... 정말 많은 if문이 계속됨
}

main.js에서 상태와 이벤트 등 많은 것들을 관리하고 있었다. handleRootClick, handleRootChange, handleRootKeydown 같은 거대한 함수들이 수십 개의 if문으로 이루어져 있어서, 새로운 기능을 추가하거나 기존 기능을 수정할 때마다 이 거대한 함수들을 건드려야 했다.

새로운 버튼을 추가하려면 handleRootClick 함수에 새로운 if문을 추가해야 하는데, 이때 기존의 조건문들과 충돌하지 않는지 확인해야 하고, 함수가 너무 커서 전체 로직을 파악하기 어려웠다.

솔루션의 접근법

// 이벤트 위임 시스템
const eventHandlers = {};

const handleGlobalEvents = (e) => {
  const handlers = eventHandlers[e.type];
  if (!handlers) return;

  // 각 선택자에 대해 확인
  for (const [selector, handler] of Object.entries(handlers)) {
    const targetElement = e.target.closest(selector);

    if (targetElement) {
      try {
        handler(e);
      } catch (error) {
        console.error(`이벤트 핸들러 실행 오류 (${selector}):`, error);
      }
    }
  }
};

// 전역 이벤트 리스너 등록 (한 번만 실행)
export const registerGlobalEvents = (() => {
  let initialized = false;
  return () => {
    if (initialized) return;

    Object.keys(eventHandlers).forEach((eventType) => {
      document.body.addEventListener(eventType, handleGlobalEvents);
    });

    initialized = true;
  };
})();

솔루션에서는 선언적 이벤트 등록 시스템을 구축했다. 문서 레벨에서 하나의 이벤트 리스너를 등록하고, 실제 이벤트가 발생하면 타겟 요소가 어떤 선택자와 매치되는지 확인해서 해당하는 핸들러만 실행하는 방식이다.

특히 이벤트를 도메인별로 분리한 점이 인상적이었다. registerProductEvents, registerCartEvents, registerUIEvents 같은 함수들로 관련된 이벤트들을 그룹화해서, 각 도메인의 이벤트 로직을 독립적으로 관리할 수 있게 했다.

4. 라우팅 시스템: 하드코딩의 한계

내 코드의 문제점

// 내 코드 분석: 하드코딩된 패턴 매칭
async handleRoute() {
  const path = getAppPath();

  if (this.routes.has(path)) {
    await this.routes.get(path)();
    return;
  }

  // 상품 상세 라우팅 하드코딩
  const productMatch = path.match(/^\/product\/(.+)$/);
  if (productMatch && this.routes.has("/product/:id")) {
    const productId = productMatch[1];
    await this.routes.get("/product/:id")(productId);
  }
}

급한 마음에 빠르게 만들었던 나의 라우팅 시스템은 모든 페이지에 대해서 하드코딩되어 있었다. 새로운 동적 라우트를 추가하려면 handleRoute 함수에 하드코딩된 정규식을 추가해야 하고, URL 매개변수를 추출하는 로직도 각각 따로 작성해야 했다.

솔루션의 접근법 (확장 가능한 구조)

// 동적 라우트 등록 시스템
addRoute(path, handler) {
  const paramNames = [];
  const regexPath = path
    .replace(/:\w+/g, (match) => {
      paramNames.push(match.slice(1)); // ':id' -> 'id'
      return "([^/]+)";
    })
    .replace(/\//g, "\\/");

  const regex = new RegExp(`^${this.#baseUrl}${regexPath}$`);

  this.#routes.set(path, {
    regex,
    paramNames,
    handler,
  });
}

// 라우트 등록
router.addRoute("/", HomePage);
router.addRoute("/product/:id", ProductDetailPage);
router.addRoute("/category/:category1/:category2?", CategoryPage);
router.addRoute(".*", NotFoundPage);

솔루션에서는 동적 매개변수 라우팅을 지원한다. /product/:id 같은 패턴으로 라우트를 정의하면, 자동으로 정규식으로 변환해서 매칭하고, URL 매개변수도 자동으로 추출해서 객체로 제공한다.

상태관리에서 사용했던 옵저버 패턴을 라우터 객체에서도 사용하고 있었다. 라우트가 변경되면 자동으로 구독하고 있는 렌더 함수에게 알림이 가서 새로운 페이지가 렌더링된다.


테스트를 통해 얻은 깨달음

설계 구조를 잡지 않고 테스트 코드 통과에만 집중했던 개발을 진행해보니, 요구사항을 확인할 때 어떤 것을 우선순위로 두어야 하는지에 대한 중요성을 깨달았다.

장바구니 모달 테스트

// 내가 구현한 방식: 모달 콘텐츠 전체 재렌더링
const updateCartModal = () => {
  const modalContent = document.querySelector('.cart-modal-content');
  modalContent.innerHTML = renderCartItems();
};

장바구니 모달에서 수량 조절 테스트 코드가 통과하지 못하는 이유를 디버깅했을 때가 기억에 남는다. 브라우저에서는 기능이 동작하지만 Vitest는 통과하지 못했다.

이는 Vitest에서 querySelector로 찾은 엘리먼트의 참조 문제 때문이었다. 모달 콘텐츠를 새로 그려 새로운 엘리먼트로 교체하는 내 코드와 달리, Vitest는 이벤트 이후에도 계속 동일한 엘리먼트를 참조하고 있었다.

이 문제도 결국 상태 기반 렌더링의 부재 때문이었다. 상태 변경에 의한 자동 렌더링 구조였다면 이런 문제가 발생하지 않았을 것이다.


KPT 회고

Keep (계속 유지할 점)

  • 실무와 학습의 병행: 회사 업무와 과제를 동시에 진행하며 얻은 실전 경험

  • 깊이 있는 분석: 솔루션과 본인 코드를 상세히 비교 분석하는 자세

  • 끝까지 포기하지 않는 태도: 어려운 상황에서도 과제를 완주한 의지력

Problem (개선이 필요한 점)

  • 설계 우선순위 문제: 기능 구현에 급급해 아키텍처 설계를 후순위로 둔 점

  • "순서대로 해야 한다"는 강박: 핵심을 놓치고 세부사항에 매몰되는 경향

  • 기존 지식 활용 부족: 예전에 만든 라우터를 죄책감 때문에 활용하지 못한 점

Try (앞으로 시도할 점)

  • 점진적 리팩토링 연습: 완벽한 설계가 어려울 때는 작은 단위로 지속적 개선

  • 기존 학습 내용 적극 활용: 죄책감보다는 학습 효과를 우선시

  • 시간 관리 시스템 구축: 바쁜 상황에서도 핵심 개념 학습에 집중할 수 있는 루틴 만들기

  • 설계 패턴 체화: 다양한 패턴을 상황에 맞게 선택하고 조합할 수 있는 능력 기르기

마무리하며

회사 업무와 항해 과제를 병행하며 정신없이 보낸 1주차였지만, 오히려 이런 압박 상황에서 더 많은 것을 배울 수 있었던 것 같다.

바쁘다는 핑계로 설계를 소홀히 했을 때의 결과를 몸소 체험했고, 좋은 아키텍처가 얼마나 중요한지 솔루션을 통해 확실히 깨달았다.

앞으로 남은 과정들도 이런 자세로 임하고 싶다. 급하다고 설계를 생략하지 않고, 작더라도 견고한 기초를 쌓아가는 개발자가 되고자 한다.