메모이제이션 실사용

윤뿔소·2023년 12월 1일
0

현업 관련 경험

목록 보기
4/4
post-thumbnail

개발 도중 알고리즘을 써본 경혐이 있어 따로 정리하려고 이 글을 작성했다.
알고리즘을 실사용한 건 얼마 없는데 이번에 이렇게 쓰게 돼서 작성하고싶었다.

먼저, 모든 코드와 데이터는 임의로 수정해 구현 과정 및 구조를 나타내는 역할만 할 뿐 실제 작용 데이터/코드와 다르다.

배경

먼저 약사 - 제약사 간 플랫폼을 만들고 있다. 그에 따라 약에 관련한 상품들을 Table로 정리하여 만들고 있었고, 이미 만들어놓은 상태이다.

여기서 상품 리스트의 API 불러오는데 응답 후 가공해야되는 데이터들 중 각각의 카테고리들이 있다.

위처럼 말이다. 원래는 category 하나로, constants 폴더에 따로 정리하여 각 코드를 찾아 name으로 치환해 사용하고 있었다.
그런데 카테고리의 다양성을 위해 Depth와 그 가짓수가 늘어감에 따라 constant하게 정리하기 불편해 졌고, 그에 따라 서버에 데이터를 담아두고 서버에서 가져오는 방식으로 마이그레이션하는 도중이었다.

서버에서 변경된 API

1차 카테고리가 있고, 1차 안에 2차 카테고리가 최소 5개 가 있고, 2차 하나 고르면 3차 카테고리가 나오는 형태다.

내부 데이터는 유출 위험이 있어서 API 주소 및 Props 키/값은 따로 가공하였다.

// 카테고리 데이터 : 실제 데이터와 다름

// ?category=${Z2}
{
  "category": "Z201",
  "parent_category" : "Z2",
  "name":"부모 카테고리 1",
  "created_at": "2099-99-08T03:23:53.665Z",
  "updated_at" : "2099-99-08T03:23:53.6652"
}
// ?category=${Z201}
{
  "category": "Z10204",
  "parent_category" : "Z201",
  "name":"자식 카테고리 4",
  "created_at": "2099-99-08T03:23:53.665Z",
  "updated_at" : "2099-99-08T03:23:53.6652"
}

이런 식으로 말이다. 그래서 위 쿼리들 중 parent_category로 API 주소에 Search Query로 넣어서 그 하위 카테고리를 가져와한다.
/example/api/category?category=${parent_category}(예시, 실제로는 다름) 이런 식으로 말이다! 그런 식으로 넣어 줘야 '일반의약품', '드링크액제', '수면유도제' 등등의 한글로 치환돼 클라이언트에 보여줄 데이터를 가져오게 된다.

그 전에는 그냥 Constant로 선언해서 가져왔었는데, 위에 말했다싶이 서버에서 관리하자고 마이그레이션을 진행했기 때문에 그렇게 했다.

+ 그러면 상품 데이터를 반환하는 API에 카테고리 이름 데이터도 담아서 보내면 되는 거 아닌가 생각했지만, 그 당시 백엔드 개발자가 카테고리 API를 만든 후 알아서 하라 그래서 이러한 고민을 하게 됐다..

구현 중 문제

여기서 문제는 저 상품 아이템들과 카테고리가 최소 몇십개가 된다는 것이다. 그렇다는 말은 상품 아이템 당 3개 씩의 카테고리가 있다.

저 카테고리 항목이 나오기까지 과정을 다시 살펴보면

  1. 상품 데이터를 가져오기
  2. 각 상품의 카테고리 코드 인식 후 카테고리 정보를 알기 위해 카테고리 API 요청
    • 1차, 2차, 3차 카테고리 데이터를 가져오기 위해 각 상품마다 API를 요청(!)
  3. 해당 상품에 가져온 카테고리 데이터 중 하나인 이름을 가져와 UI에 적용

다시 말해 1차, 2차, 3차 이렇게 각각 서버에 요청을 보내야한다. 그게 10개 있으니 30번 통신해야하고, 만약 상품 리스트를 50건 씩 본다면 자그마치 150번 통신을 해야한다.
무서워서 다시 통신은 못하지만은 처음 봤을 땐 크롬 네트워크 탭이 그렇게 빨리 움직이는 건 처음 봤다,,

이렇게 된다면 가장 큰 문제는 대규모 사용자가 있을 시 트래픽이 증가된다. 즉, 서버 부하가 증가돼 응답 속도가 지연되고, 제일 큰 문제인 서버 비용이 매우 증가하게 된다. 애초에 같은 데이터를 여러번 가져온다는게..

이 문제를 어떻게 해결해야할까 생각 중 문득 메모 알고리즘이 떠올랐다.

메모이제이션

Memoization
동일한 계산을 반복해야 할 경우 한 번 계산한 결과를 메모리에 저장해 두었다가 꺼내 씀으로써 중복 계산을 방지할 수 있게 하는 기법

어원이 Memo에서 온 만큼 Memo를 한 뒤 다시 꺼내 쓰는 알고리즘을 뜻한다.

상품 리스트의 불러온 값들을 보면 중복되는 카테고리들이 좀 많다. 그래서 아래와 같은 과정을 추가해보고 싶었다.

  1. 처음 불러온 카테고리가 있다면 네트워크 호출
  2. 그 데이터를 저장
  3. 중복된 요청 값이 있다면 저장된 데이터에서 재사용

이러한 로직을 짜야겠다고 생각했다. 바로 메모이제이션!

구현 과정

이제 실제로 사용해보자. 여기서 무조건 불러오는 것이 1차 카테고리다. 1차 카테고리는 각 항목을 나누는 상품 카테고리라서, 먼저 불러와야해 미리 정의했다.
parent_category가 상품 카테고리-Z1인 카테고리 데이터를 미리 정의했다.

설계

  1. 상태 관리 : 각 상품별로 1차, 2차, 3차 카테고리 이름을 관리할 수 있는 객체를 생성해 효율적으로 관리.

    • 카테고리 상태 정의 : cateOfList 상태로 각 상품별로 카테고리 이름 리스트를 저장.
    • 리액트 쿼리 : 공통 코드를 가져올 때 리액트 쿼리의 useCmnCode 훅을 사용해 캐싱된 데이터를 활용.
  2. 카테고리 데이터 캐싱 구조 : API 호출 후 받은 카테고리 데이터를 객체에 캐싱.

    • 2단계 카테고리 캐싱 : 1차 카테고리 코드를 기반으로 2차 카테고리를 캐싱(메모).
    • 3단계 카테고리 캐싱 : 2차 카테고리 코드를 기반으로 3차 카테고리를 캐싱(메모).
  3. 카테고리 데이터 검증 후 캐싱된 데이터 불러오기 : 각 상품의 카테고리 데이터가 이미 캐싱된 경우 이를 즉시 활용하고, 캐싱되지 않은 경우에만 API를 호출하여 데이터를 가져오는 방식으로 최적화.

    • 1차 카테고리 확인 : 상품의 1차 카테고리가 이미 캐싱된 데이터에 있는지 확인하고, 있으면 즉시 해당 데이터를 사용합니다. 없을 경우에는 추가적인 API 호출 없이 캐싱된 데이터를 활용.
    • 2차 카테고리 검증 : 1차 카테고리를 기준으로 2차 카테고리 데이터가 캐싱되어 있는지 확인한 후, 있으면 즉시 사용하고 없을 경우에만 API를 호출하여 2차 카테고리 데이터를 가져와 캐싱.
    • 3차 카테고리 검증 : 2차 카테고리 데이터를 기준으로 3차 카테고리가 캐싱되어 있는지 확인 후, 캐싱된 데이터를 사용하거나 없으면 API 호출 후 데이터를 캐싱.
  4. UI와 데이터 연결 설계 : 일련의 과정을 거쳐서 최종적으로 가져온 카테고리 데이터를 화면에 렌더링해 표시하는 로직을 설계.

    • 카테고리 이름 출력 : cateOfList 상태를 기반으로 상품별로 카테고리 이름을 화면에 렌더링.

데이터 담을 상태 선언

이제 메모를 하기 위한 상태를 선언해 거기에 담아줄 것이다.

// 리스트 카테고리 상태 및 로직
const [cateOfList, setCateOfList] = useState<{
  [productId: string]: string[];
}>({});
// 공통 코드 가져오기 훅
const { data: cmnCode1 } = useCmnCode('Z2');

cateOfList 상태는 상품(productId) 리스트를 담을 상태다. 서버 데이터와의 차이점은 카테고리 1, 2, 3의 코드(category)에 해당하는 이름(name)들을 할당해줄 생각이다.
useCmnCode는 1차 카테고리를 가져와주는 커스텀 훅인데, 모든 상품의 1차 카테고리는 Z2에 포함되기 때문에 미리 불러와 cmnCode1로 선언해줬다.

또 2, 3차 코드들만 담을 객체도 따로 선언해줘야 한다. 여기엔 2차, 3차 코드만 캐싱하고, 재사용할 수 있게 설정했다.

/// 상품 목록에서 각 상품의 1, 2, 3 카테고리 정보를 효율적으로 가져와 화면에 표시 로직
// 각 카테고리 코드를 저장할 객체 초기화
const cmnCode2s: {
  [parentCode1: string]: CmnCodeItem[];
} = {};
const cmnCode3s: {
  [parentCode2: string]: CmnCodeItem[];
} = {};

각각 2차 카테고리는 1차의 코드를 Key(Z1)로 받고 있고 3차는 2차를 Key로 받고 있다.
CmnCodeItem 타입은 카테고리 데이터를 정의한 타입이다.

카테고리 데이터 검증 후 캐싱된 데이터 불러오기

가장 핵심적인 부분이다. 조건을 잘 살펴보면 메모이제이션에 더 집중할 수 있다.

// 컴포넌트가 렌더링될 때마다 실행, 메모이제이션 이용
useEffect(() => {
  const fetchData = async () => {
    if (productListData && cmnCode1) {
      // 초기 제품별 카테고리 정보 객체, cateOfList 상태에 조합해 set
      const initialCateOfList: { [productId: string]: string[] } = {};

      // 각각의 제품에 대해 순회
      for (const item of productListData) {
        if (item?.category1) {
          // 2단계 카테고리 코드를 가져오기 위한 조건 확인 및 처리
          if (!cmnCode2s[item.category1]) {
            // fetch 및 정보 가공
            const response2 = await fetch(`${카테고리 API 주소}?category=${item.category1}`, REQ_OPTIONS);
            const cmnCode2Res: CmnCodeResponse = await response2.json();
            const cmnCode2: CmnCodeItem[] = cmnCode2Res.payload.items.flat();
            // 객체에 Parent Code로 할당
            cmnCode2s[item.category1] = cmnCode2;
          }

          // 3단계 카테고리 코드를 가져오기 위한 조건 확인 및 처리
          if (item?.category3 && !cmnCode3s[item.category2]) {
            // fetch 및 정보 가공
            const response3 = await fetch(`${카테고리 API 주소}?code=${item.category2}`, REQ_OPTIONS);
            const cmnCode3Res: CmnCodeResponse = await response3.json();
            const cmnCode3: CmnCodeItem[] = cmnCode3Res?.payload.items.flat();
            // 객체에 Parent Code로 할당
            cmnCode3s[item.category2] = cmnCode3;
          }

          // 각 카테고리 이름을 가져오기
          const category1Name = getNameByCode(cmnCode1, item.category1) || '';
          const category2Name = getNameByCode(cmnCode2s[item.category1], item.category2) || '';
          const category3Name = getNameByCode(cmnCode3s[item.category2], item.category3) || '';

          initialCateOfList[item.productId] = [category1Name, category2Name, category3Name];
        }
      }

      setCateOfList(initialCateOfList);
    }
  };

  fetchData();
}, [productListData, cmnCode1]);
  1. useEffect: 컴포넌트가 렌더링될 때마다 실행되는 useEffect 훅을 사용함. 이 훅 안에서 fetchData 함수가 호출.

  2. fetchData 함수: 제품 목록 및 1단계 카테고리 코드(cmnCode1)가 존재하는 경우에만 데이터를 가져오는 로직이 포함.
    굳이 함수로 뺀 이유는 fetch를 사용하기에 비동기적으로 처리(async)하기 위해 따로 뺌.

  3. 제품 목록 순회: for...of 루프를 사용해 제품 목록을 순회하면서 각 제품에 대한 카테고리 정보를 가져옴.

  4. 2단계 카테고리 코드 가져오기: 해당 2단계 카테고리 코드가 아직 캐시(저장)돼 있지 않은 경우에만 해당 코드를 가져와서 cmnCode2s 객체에 1을 Key로, 불러온 값을 Value로 저장.

  5. 3단계 카테고리 코드 가져오기: 해당 3단계 카테고리 코드가 아직 캐시(저장)돼 있지 않은 경우에만 해당 코드를 가져와서 cmnCode3s 객체에 2을 Key로, Value로 불러온 값을 저장.

  6. 각 카테고리 이름 가져오기: getNameByCode 함수를 사용해 각 카테고리의 이름을 가져옴.
    getNameByCode 함수는 API 응답 객체에서 카테고리 코드로 name을 찾는 유틸 함수다.

  7. 초기 카테고리 정보 설정: initialCateOfList 객체에 각 상품에 대한 1, 2, 3 카테고리 이름을 저장.

  8. setCateOfList : 최종적으로 productId를 Key로, Value로 initialCateOfListsetCateOfList 함수를 사용해 상태를 업데이트.

API 호출과 데이터 처리를 비동기로 처리하고, 메모이제이션을 사용해 중복 호출을 방지하는 등의 최적화 기법을 적용한 코드다.

화면 리렌더링

이제 넣었으니 화면에 넣어주면 이제 끝이다!

return (
  // UI 코드...
  cateOfList[product?.productId]?.map((cate, idx) => <p key={idx}>{cate}</p>);
  // ...
}

이런 식으로 하면 깔끔하게 나온다!

결과

짤렸지만 50건 씩 보기로 데이터를 불러왔다.

동그라미 친 부분이 카테고리 API 호출 부분이다.
같은 카테고리가 중복됐지만 각각 1개 씩 불러오는 모습이다. 좋다!!! 원래는 1차, 2차, 3차 각각 50개, 즉 150개 씩 불러오는 모습에서 5~10개 내외로 불러와지는 모습을 볼 수 있다.

마무리

나는 간단하게 useState를 사용해서 만들어 봤다. 물론 이 경우도 한계는 존재한다. 아무래도 로컬 상태이다보니 휘발성이 있고, 또한 여러 번 서버에서 호출을 하기 때문에 여타 리스트를 불러오는 API완 달리 서버 비용이 비교적으로 더 발생할 수 있다.
여기서 더 최적화하려면 전용 DB를 만들어 서버 비용을 줄이든가, 로컬스토리지 + 동기화 로직까지 넣어서 한계를 덜 수 있다.

실제로 내가 알고리즘을 사용해 개발하는 경우가 얼마 없다. 물론 for문이나 조건 등을 걸어서 알고리즘들을 사용하긴 하지만 이 경우는 진짜 면접볼 때 봤던 코딩테스트 느낌이 났어서 신기해 작성해봤다. ㅋㅋㅋ

이를 계기로 알고리즘을 왜 배우는지 더욱이 알게 됐고, 복습을 게을리해서는 안되겠다고 생각했다.

profile
코뿔소처럼 저돌적으로

2개의 댓글

comment-user-thumbnail
2023년 12월 8일

저도 오늘 알고리즘에 대해 필요성을 느꼈는데..ㅠㅠ

데이터를 어떻게 관리하느냐가 가 정말 중요한 것 같아요 그런 의미에서 usememo도 큰 역할을 하는 것 같구요! 잘 읽고 갑니당

답글 달기
comment-user-thumbnail
2023년 12월 10일

메모이제이션 개념만 알고 있지 실제로 쓴적없었는데 이렇게라도 볼수 있어서 도움이 되네여 ㅎㅎ

답글 달기