개발 도중 알고리즘을 써본 경혐이 있어 따로 정리하려고 이 글을 작성했다.
알고리즘을 실사용한 건 얼마 없는데 이번에 이렇게 쓰게 돼서 작성하고싶었다.
먼저, 모든 코드와 데이터는 임의로 수정해 구현 과정 및 구조를 나타내는 역할만 할 뿐 실제 작용 데이터/코드와 다르다.
먼저 약사 - 제약사 간 플랫폼을 만들고 있다. 그에 따라 약에 관련한 상품들을 Table로 정리하여 만들고 있었고, 이미 만들어놓은 상태이다.
여기서 상품 리스트의 API 불러오는데 응답 후 가공해야되는 데이터들 중 각각의 카테고리들이 있다.
위처럼 말이다. 원래는 category
하나로, constants
폴더에 따로 정리하여 각 코드를 찾아 name
으로 치환해 사용하고 있었다.
그런데 카테고리의 다양성을 위해 Depth와 그 가짓수가 늘어감에 따라 constant하게 정리하기 불편해 졌고, 그에 따라 서버에 데이터를 담아두고 서버에서 가져오는 방식으로 마이그레이션하는 도중이었다.
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차, 3차 이렇게 각각 서버에 요청을 보내야한다. 그게 10개 있으니 30번 통신해야하고, 만약 상품 리스트를 50건 씩 본다면 자그마치 150번 통신을 해야한다.
무서워서 다시 통신은 못하지만은 처음 봤을 땐 크롬 네트워크 탭이 그렇게 빨리 움직이는 건 처음 봤다,,
이렇게 된다면 가장 큰 문제는 대규모 사용자가 있을 시 트래픽이 증가된다. 즉, 서버 부하가 증가돼 응답 속도가 지연되고, 제일 큰 문제인 서버 비용이 매우 증가하게 된다. 애초에 같은 데이터를 여러번 가져온다는게..
이 문제를 어떻게 해결해야할까 생각 중 문득 메모 알고리즘이 떠올랐다.
Memoization
동일한 계산을 반복해야 할 경우 한 번 계산한 결과를 메모리에 저장해 두었다가 꺼내 씀으로써 중복 계산을 방지할 수 있게 하는 기법어원이 Memo에서 온 만큼 Memo를 한 뒤 다시 꺼내 쓰는 알고리즘을 뜻한다.
상품 리스트의 불러온 값들을 보면 중복되는 카테고리들이 좀 많다. 그래서 아래와 같은 과정을 추가해보고 싶었다.
이러한 로직을 짜야겠다고 생각했다. 바로 메모이제이션!
이제 실제로 사용해보자. 여기서 무조건 불러오는 것이 1차 카테고리다. 1차 카테고리는 각 항목을 나누는 상품 카테고리라서, 먼저 불러와야해 미리 정의했다.
parent_category
가 상품 카테고리-Z1
인 카테고리 데이터를 미리 정의했다.
상태 관리 : 각 상품별로 1차, 2차, 3차 카테고리 이름을 관리할 수 있는 객체를 생성해 효율적으로 관리.
cateOfList
상태로 각 상품별로 카테고리 이름 리스트를 저장.useCmnCode
훅을 사용해 캐싱된 데이터를 활용.카테고리 데이터 캐싱 구조 : API 호출 후 받은 카테고리 데이터를 객체에 캐싱.
카테고리 데이터 검증 후 캐싱된 데이터 불러오기 : 각 상품의 카테고리 데이터가 이미 캐싱된 경우 이를 즉시 활용하고, 캐싱되지 않은 경우에만 API를 호출하여 데이터를 가져오는 방식으로 최적화.
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]);
useEffect
훅: 컴포넌트가 렌더링될 때마다 실행되는 useEffect
훅을 사용함. 이 훅 안에서 fetchData
함수가 호출.
fetchData
함수: 제품 목록 및 1단계 카테고리 코드(cmnCode1
)가 존재하는 경우에만 데이터를 가져오는 로직이 포함.
굳이 함수로 뺀 이유는 fetch
를 사용하기에 비동기적으로 처리(async
)하기 위해 따로 뺌.
제품 목록 순회: for...of
루프를 사용해 제품 목록을 순회하면서 각 제품에 대한 카테고리 정보를 가져옴.
2단계 카테고리 코드 가져오기: 해당 2단계 카테고리 코드가 아직 캐시(저장)돼 있지 않은 경우에만 해당 코드를 가져와서 cmnCode2s
객체에 1을 Key로, 불러온 값을 Value로 저장.
3단계 카테고리 코드 가져오기: 해당 3단계 카테고리 코드가 아직 캐시(저장)돼 있지 않은 경우에만 해당 코드를 가져와서 cmnCode3s
객체에 2을 Key로, Value로 불러온 값을 저장.
각 카테고리 이름 가져오기: getNameByCode
함수를 사용해 각 카테고리의 이름을 가져옴.
getNameByCode
함수는 API 응답 객체에서 카테고리 코드로 name
을 찾는 유틸 함수다.
초기 카테고리 정보 설정: initialCateOfList
객체에 각 상품에 대한 1, 2, 3 카테고리 이름을 저장.
setCateOfList
: 최종적으로 productId
를 Key로, Value로 initialCateOfList
를 setCateOfList
함수를 사용해 상태를 업데이트.
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문이나 조건 등을 걸어서 알고리즘들을 사용하긴 하지만 이 경우는 진짜 면접볼 때 봤던 코딩테스트 느낌이 났어서 신기해 작성해봤다. ㅋㅋㅋ
이를 계기로 알고리즘을 왜 배우는지 더욱이 알게 됐고, 복습을 게을리해서는 안되겠다고 생각했다.
저도 오늘 알고리즘에 대해 필요성을 느꼈는데..ㅠㅠ
데이터를 어떻게 관리하느냐가 가 정말 중요한 것 같아요 그런 의미에서 usememo도 큰 역할을 하는 것 같구요! 잘 읽고 갑니당