[회고] 고양이 사진첩 만들기 보충 과제

yoon·2023년 11월 22일
0

과제 기간 : 2023년 10월 31일(화) ~ 2023년 11월 3일(금)

임시 저장으로만 잠깐 적어놓았다가 제대로 적는데까지 한달이라는 시간이나 걸리게 되었다.
중간중간 기록해두어서 다행이지, 예전처럼 기록하지 않은 채 회고를 한달 후 작성하려고 했다면 아마 못했을 것이다.
앞으로도 과제를 하면서 고려할 부분이나 고민한 부분, 구현하고 싶었던 부분 등을 기록하는 습관을 가져야 겠다!

과제

과제는 제공해준 실습 코드를 보완하는 거였다.

  • state에 대한 정합성 체크 & 컴포넌트별로 올바르지 않은 state를 넣었을 때 에러 체크
  • 각 컴포넌트의 setState 최적화(변경사항이 있을 때만 render 함수 호출)
  • 루트 탐색 중이 아닌 경우, 백스페이스 키를 눌렀을 때 이전 경로로 이동하도록 구현

과제 전 생각한 것들

state에 대한 정합성 체크 & 컴포넌트별로 올바르지 않은 state를 넣었을 때 에러 체크

사실 이제껏 프로젝트를 하면서 상태에 대한 정합성/유효성 체크를 소홀히 했던 경향이 있다.

  • 서버에서 데이터를 받아오는데 서버에서 받아오는 것까지 체크를 해야해?
  • 내가 조심하면 되는데 휴먼 에러까지 고려해야해?
  • 사용자와의 인터렉션 관련해서만 하면 되는거 아니야?

라는 생각을 주로 가졌었다. 더기 멘토님한테 이와 관련해서 여쭈어보니 모든 것을 고려하는게 맞다고 말씀주셨다.
그래서 반성도 하게 되고, 같은 팀원 분들이 꼼꼼하게 유효성 검사를 잘 해주셔서 코드를 보면서 이때까지 많이 배운 것 같다.
그럼에도 아직 놓치는 게 많고 반복되는 검사를 함수화하는건 어려운 것 같다.! ;)

각 컴포넌트의 setState 최적화(변경사항이 있을 때만 render 함수 호출)

리액트를 하면서도 렌더링에 관한 고민이 많았고 정말 어려웠는데 바닐라 자바스크립트로 컴포넌트의 렌더링 최적화를 해볼 수 있게 되어서 이번에 잘 해본다면 많은 도움이 될 것 같아서 가장 욕심이 나기도 했던 부분이다!

실습 코드 중에서는 로딩 상태에 대해 로딩 애니메이션도 추가를 해놓은 상태라 로딩 상태에 따라서도 렌더링 여부를 결정하면 좋을 것 같다. 또한 각 컴포넌트의 유기적인 관계를 잘 따져서 렌더링 여부를 결정해야겠다고 생각을 했다.

루트 탐색 중이 아닌 경우, 백스페이스 키를 눌렀을 때 이전 경로로 이동하도록 구현

이 과제는 구현하기 간편할 것이라 판단을 했고 이벤트를 등록할 위치만 잘 선정해주면 될 것 같다고 생각했다.


과제를 구현하면서 신경 쓴 부분이나 고민한 부분만 기록해보려고 한다 :)

state의 정합성 체크

validation.js 파일을 만들어 안에 객체 내 메서드를 참조해서 체크를 했다.
validation.loading으로 참조를 하면 어느 컴포넌트의 유효성 검사를 하는지 파악하기 쉬워서 객체를 만들고 export 했다.

const validation = {
  newTarget: (target) => {
    if (!target) throw new Error("생성자 키워드 new를 누락하였습니다.");
  },
  loading: (state) => {
    if (typeof state !== "boolean") {
      throw new Error("loading 컴포넌트의 state는 boolean 타입이어야 합니다.");
    }
  },
  // code ...
};

export default validation;

아쉬운 점

  // code ...
  app: (state) => {
    if (typeof state.isRoot !== "boolean") {
      throw new Error("app 컴포넌트의 isRoot는 boolean 타입이어야 합니다.");
    }

    if (typeof state.isLoading !== "boolean") {
      throw new Error("app 컴포넌트의 isLoading은 boolean 타입이어야 합니다.");
    }

    if (!Array.isArray(state.paths)) {
      throw new Error("app 컴포넌트의 paths는 배열 타입이어야 합니다.");
    }
  },

이 코드는 app 컴포넌트의 상태 유효성 검사를 진행하기 위한 메서드인데, 본질적으로 같은 내용이 반복된다고 공용화할 수 있는지 고민해보라는 리뷰를 받았다.

어떻게 공용화하면 좋을지 아직도 모르겠다! 매개변수로 다 전달하는 방법도 생각해보았지만 매개변수가 너무 많아져도 문제가 될 것 같아서 더 고민해봐야겠다.

각 컴포넌트의 setState 최적화

각 컴포넌트의 setState를 최적화하기 위해 early return을 적극적으로 사용해보려 했다.
요즘 early return 하는 방법이 깔끔해보여서 많이 사용해보고 싶기도 했다 ㅎㅎ

// App.js

this.setState = (nextState) => {
  // ...code

  this.state = nextState;
  loading.setState(this.state.isLoading);

  if (this.state.isLoading) return;

  imageViewer.setState({ selectedImageUrl: this.state.selectedImageUrl });
  breadcrumb.setState(this.state.paths);
  nodes.setState({ isRoot: this.state.isRoot, nodes: this.state.nodes });
};

모든 컴포넌트를 관리하는 App.js에서는 로딩 상태인 경우에 early return을 하여 하위 컴포넌트들이 렌더링되지 않게끔 막았다.

this.setState = (nextState) => {
  // ...code

  if (this.state.selectedImageUrl === nextState.selectedImageUrl) return;

  this.state = nextState;
  this.render();
};

하위 컴포넌트들에서도 이전 값과 비교하여 같은 경우에는 early return을 통해 렌더링 처리를 했다.
하위 컴포넌트의 로직은 전부 같다.

(추가 구현) 캐싱

캐싱을 사용한 이유

실습 과제에 제공되는 api가 로딩 컴포넌트를 충분히 노출시키기 위해서 랜덤으로 딜레이를 걸어놓은 것이 생각보다 과제할 때 많이 불편했었다. 또한 고양이 사진첩은 GET 요청만 받고 있고 데이터가 변하지 않기 때문에 계속해서 api 요청을 할 필요가 없다고 생각했다. 그래서 스토리지 같은 것을 이용해야겠다 라고 생각을 하게 되었다.

로컬 스토리지를 이용할까 하다가 로컬 스토리지는 예전 노션 클론코딩 과제를 구현하면서도 써봤고 제법 많이 사용을 해봤기에 이번엔 다른 것을 사용해보고 싶었다. 그러던 도중 캐싱이라는 말을 많이 들어보기도 했고 웹에서 캐싱을 할 수 있지 않을까? 하며 웹에서 캐시를 저장하는 법을 찾게 되었다.

캐싱은 주어진 리소스의 복사본을 저장하고 있다가 요청 시에 제공하는 기술이다. 웹 캐시가 저장소 내에 요청된 리소스를 가지고 있다면, 요청을 가로채 원래의 서버로부터 리소스를 다시 받아오는 대신 리소스의 복사본을 반환한다. 이를 통해 서버의 부하를 줄일 수 있어 성능을 향상시킬 수 있다.

구현

캐싱을 구현하기 위해 요청에 따라 리소스를 캐싱해야 하므로 history API를 이용하여 id를 통해 경로를 설정했다. 새로고침해도 pathname에 있는 아이디 값을 통해 캐싱된 값을 받아오도록 했다. 라우터 함수는 즉시 실행 함수를 사용하여 초기에만 한번 실행되도록 했다. 라우팅 처리는 캐싱을 구현하면서 여러 고민이 생겼는데 이것들을 공유하며 마지막에 코드를 추가할 것 같다.

// cache.js

const CASH_NAME = "cat";

const cache = {
  put: async (response) => {
    const { pathname } = window.location;
    const c = await caches.open(CASH_NAME);
    await c.put(pathname, response);
  },
  match: async (pathname) => {
    const response = await caches.match(pathname);
    if (response) {
      return await response.json();
    } else return [];
  },
};

export default cache;

예제 코드와 달리 나는 async/await으로 구현했다.

웹 캐시에서 put 메서드가 캐시 객체에 키/값 쌍을 추가하기 때문에 키로는 경로를(요청 객체를 넣어도 된다.), 값으로는 응답을 넣어 저장했다. match 메서드는 캐시 객체에서 url(혹은 요청)에 관련된 응답을 가져올 수 있다. Promise를 반환하기 때문에 비동기 처리를 해주어야 한다.

put 메서드에 응답 객체를 전달할 때는 response.clone()을 통해 응댑 객체를 클론해야 한다. 응답 본문이 이미 사용하고 있는 것은 안된다. 클론 처리를 해서 넘겨주지 않으면 다음과 같은 에러가 뜬다.

Uncaught (in promise) TypeError: Failed to execute 'put' on 'Cache': Response body is already used 

따라서 api를 호출하는 request 함수 또한 캐시를 고려하여 다음과 같이 작성했고, 요청 시에는 put 메서드를 통해 캐싱했다.

// api.js

import cache from "./utils/cache.js";

const API_END_POINT = "https://...";

export const request = async (url) => {
  try {
    const res = await fetch(`${API_END_POINT}${url}`);
    if (res.ok) {
      cache.put(res.clone()); // <<< response.clone() 구현
      return await res.json();
    }
    throw new Error("API 처리 중 에러 발생");
  } catch (error) {
    alert(error.message);
  }
};

캐시 스토리지에 설정한 url에 맞는 응답이 기록된 것을 확인할 수 있다.

데이터를 불러올 때도 match 메서드를 통해 캐싱된 응답이 있으면 가져오고, 없으면 패치를 하도록 구현했다.

const fetchNodes = async (id) => {
  // ...code
  const cacheData = await cache.match(window.location.pathname);
  const nodes = cacheData.length ? cacheData : await request(id ? `/${id}` : "/");
  this.setState({ ...this.state, nodes });
};

fetchNodes 함수는 데이터를 받아오기만 하면 되는 목적이기에 match 메서드도 api를 처리하는 request 함수 내부에 구현을 해야할까 생각을 했지만 match 메서드도 응답을 바로 반환하기에 별 차이가 없을 것 같아 fetchNodes 함수 내부에 구현했다.

구현하다가 생긴 고민

고양이 사진첩에는 지나온 경로들을 다 노출시켜야하는 브래드크럼이 존재한다.

여기서 생긴 고민은 아이디를 통해 받아온 응답들에는 부모의 아이디 정보만 있지, 모든 조상의 정보가 들어있지 않다. 따라서 캐싱된 응답을 사용하는 경우에 현재 경로의 아이디에 해당하는 응답을 불러올 수는 있지만, 브래드크럼을 다 구현해내지 못한다는 문제점이 생겼다. 부모 아이디에 해당하는 응답도 캐싱되어 있을 수 있지만, 그렇지 않은 경우에는 부모 노드를 타고타고 가서 패치를 통해 브래드크럼을 불러오게 된다면..? 캐싱을 사용한 의미가 없을 것 같았고, 이를 위해 로컬 스토리지를 사용해야 하나 생각도 했지만 수많은 경우의 수가 생길 수 있을 것 같아 로컬 스토리지를 사용해야 하는게 적합한가? 하는 의문이 들었다.

따라서 브래드크럼이 지나온 경로를 나타내기 때문에 경로에 저장하면 되지 않을까? 라는 생각을 하게 되었고 history에 폴더 이름을 같이 저장해서 가져와서 쓰자는.. 상여자식 방법으로 하게 된다.

코드는 다음과 같고 경로는 /id=13&name=삼색이 고양이/id=15&name=2021 이와 같이 저장된다.

// history.js

const historyPush = (paths) => {
  const path = paths.map((path) => `id=${path.id}&name=${path.name}`).join("/");
  history.pushState(null, null, `/${path}`);
};

export default historyPush;

받은 paths 객체의 아이디와 이름을 쿼리스트링을 사용하여 history API에 push했다.
초반에 말한 라우팅 처리를 하는 즉시 실행 함수는 다음과 같다.

// App.js

(async () => {
  const { pathname } = window.location;
  const query = pathname.split("/").pop().split("&")[0];
  const id = query.replace("id=", "");
  fetchNodes(id);
})();

이 부분은 아직도 어떤 방법이 좋은지 고민이 된다. (멘토님께도 다른 방법을 생각해보자는 리뷰를 받았다.)

느낀점

평소에 캐싱에 대해 궁금한 것도 많았는데 웹 캐시를 이용해 볼 수 있어서 좋았고 캐싱에 대한 접근이 좋다는 리뷰도 받아서 조금 뿌듯했다. 그러나 많은 부분에서 부족한 점이 보였던 것 같다. 해결 방안도 좋은 방법을 마련하지 못한 것 같아 만족스러우면서도 아쉬운 부분이 참 많다! 나중에 기회가 된다면 다시 되돌아보며 리팩토링을 해 볼 생각이다.!

profile
얼레벌레 개발자

0개의 댓글