AI유전변이 해석 서비스인 GEBRA는 특정 페이지에서 클라이언트 사이드에서 샘플별 복잡한 유전체 데이터 정보를 기반으로 보여주는 페이지가 있어요.
여기서 유전 변이 종류별로 연관된 변이들을 효율적으로 태깅하는 작업을 하게 되었는데, 쉬운 예시로 바꾸어 설명해볼게요.
시작: 성능 개선하기
프로젝트에서 여러 종류의 데이터를 특정 조건으로 서로 연결해야 하는 상황이 있었어요.
예를 들면 이런 거죠:
- 제품(Product), 리뷰(Review), 배송(Delivery) 데이터가 있고
- 이들을 "같은 상품 카테고리 + 같은 지역" 기준으로 묶어야 함
// 예시 코드 - Before
function matchRelatedData(products, reviews, deliveries) {
// TODO: 성능 개선 필요 - 중첩 순회로 인한 병목
// 각 제품마다 전체 리뷰, 배송 리스트를 순회
products.forEach(product => {
// 제품과 매칭되는 리뷰 찾기
const relatedReviews = reviews.filter(review =>
review.category === product.category &&
review.region === product.region
);
// 제품과 매칭되는 배송 찾기
const relatedDeliveries = deliveries.filter(delivery =>
delivery.category === product.category &&
delivery.region === product.region
);
// ... 처리 로직
});
}
문제가 뭘까요?
제품 100개 × 리뷰 50개 = 5,000번 비교가 일어나요. 배송까지 더하면 더 많아지죠.
해결: Map 기반으로 한 번만 그룹핑하기
1단계: 데이터를 미리 그룹으로 묶어두기
"같은 카테고리 + 같은 지역"을 키로 만들어서, 관련된 데이터를 한 곳에 모아둔 거예요.
// 예시 코드 - After
function groupDataByKey(products, reviews, deliveries) {
// 복합 키 만들기
const makeKey = (category: string, region: string) =>
`${category}:${region}`;
// 그룹을 저장할 Map
const dataGroupMap = new Map<string, {
category: string;
region: string;
products: Product[];
reviews: Review[];
deliveries: Delivery[];
}>();
// 헬퍼 함수: 그룹이 없으면 생성
const getOrCreateGroup = (key: string, category: string, region: string) => {
if (!dataGroupMap.has(key)) {
dataGroupMap.set(key, {
category,
region,
products: [],
reviews: [],
deliveries: []
});
}
return dataGroupMap.get(key)!;
};
// 제품 데이터 그룹핑
products.forEach(product => {
const key = makeKey(product.category, product.region);
const group = getOrCreateGroup(key, product.category, product.region);
group.products.push(product);
});
// 리뷰 데이터 그룹핑
reviews.forEach(review => {
const key = makeKey(review.category, review.region);
const group = getOrCreateGroup(key, review.category, review.region);
group.reviews.push(review);
});
// 배송 데이터 그룹핑
deliveries.forEach(delivery => {
const key = makeKey(delivery.category, delivery.region);
const group = getOrCreateGroup(key, delivery.category, delivery.region);
group.deliveries.push(delivery);
});
return dataGroupMap;
}
뭐가 달라졌나요?
- 모든 데이터를 딱 한 번만 순회 (O(N))
- 이후에 특정 카테고리+지역의 데이터가 필요하면? 바로 꺼내 쓰면 됨 (O(1))
// 사용할 때는 이렇게!
const key = makeKey('전자제품', '서울');
const group = dataGroupMap.get(key);
console.log(group.products); // 해당 그룹의 제품들
console.log(group.reviews); // 해당 그룹의 리뷰들
console.log(group.deliveries); // 해당 그룹의 배송들
2단계: 반복 계산도 미리 해두기
렌더링할 때마다 계산하던 것들도 미리 계산해서 Map으로 저장해뒀어요.
예를 들어, "이 제품의 평점이 4점 이상인가?"를 매번 계산하는 대신 map에서 꺼내 사용했어요.
// 평점 정보를 미리 계산
const ratingMap = new Map<string, number>();
products.forEach(product => {
const relatedReviews = reviews.filter(r =>
r.productId === product.id
);
const avgRating = relatedReviews.reduce((sum, r) =>
sum + r.rating, 0
) / relatedReviews.length;
ratingMap.set(product.id, avgRating);
});
// 렌더링할 때는 바로 꺼내 쓰기
const rating = ratingMap.get(productId) ?? 0;
const isHighRated = rating >= 4.0;
중요: 정규화 Map은 깊이 1단계로 유지하세요
Redux 공식 문서에도 나오듯이, 정규화된 데이터는 중첩을 피하고 평평하게(flat) 유지해야 해요.
실제 프로젝트에서 저는 두 가지 목적의 Map을 분리했어요:
// 나쁜 예: 하나의 Map에 여러 관심사 섞기
const mixedMap = new Map<string, {
productInfo: {...},
isHighRated: boolean,
deliveryStatus: string,
// ... 계속 추가되면 Map이 오염됨
}>();
// 좋은 예: 관심사별로 Map 분리
const productCategoryMap = new Map<string, ProductGroup>();
const ratingStatusMap = new Map<string, boolean>();
const deliveryStatusMap = new Map<string, string>();왜 분리해야 할까요?
하나의 Map에 모든 걸 담으면:
- Map의 목적이 모호해짐 (productGroup 조회용? isHighRated 체크용?)
- 키 구조가 복잡해짐 (어떤 키로 무엇을 조회하는지 불명확)
- 나중에 다른 로직 추가 시 Map 구조 변경 필요 → 기존 코드 영향
이렇게 하면 각 Map이 하나의 명확한 목적을 가지고, 키 구조도 그 목적에 최적화할 수 있어요.
언제 이 방법을 쓰면 좋을까요?
이럴 때 쓰세요
1. 전체 데이터를 클라이언트에서 가지고 있어야 할 때
- 오프라인으로 필터링/정렬을 해야 하는 경우
- 페이지네이션 없이 전체 데이터를 다뤄야 하는 경우
2. 복잡한 관계 연결이 필요할 때
- 여러 종류의 데이터를 여러 조건으로 매칭해야 할 때
- "카테고리 + 지역" 같은 복합 키로 빠르게 찾아야 할 때
3. 같은 계산을 반복할 때
- 렌더링할 때마다 동일한 로직을 실행하는 경우
- 미리 계산해두고 꺼내 쓰는 게 더 효율적인 경우
이럴 때는 쓰지 마세요
1. 서버에서 필터링할 수 있을 때
- REST API나 GraphQL로 필요한 데이터만 요청 가능
- 페이지네이션으로 조금씩 로드할 수 있는 경우
예시:
// 이게 가능하면 굳이 클라이언트에서 전체 데이터를 들고 있을 필요 없음
const products = await api.getProducts({
category: '전자제품',
region: '서울',
page: 1,
limit: 20
});
2. 데이터가 자주 바뀔 때
- 실시간 업데이트가 필요한 경우
- Map을 계속 다시 만들어야 해서 오히려 비효율적
3. 간단한 필터링만 필요할 때
Array.filter()로 충분한 경우- 정규화의 복잡도가 이득보다 큰 경우
// 이 정도면 그냥 filter 쓰는 게 나아요
const activeProducts = products.filter(p => p.isActive);
정리하면서
이번 최적화의 핵심은 조회 성능 개선이었어요.
핵심 포인트:
- Map 자료구조로 O(1) 조회 가능
- key 기반 그룹핑으로 중복 연산 제거
- 메모리는 어차피 참조 얕은복사라 정규화 하나 안하나 차이 없음
다만 이 패턴은 "전체 데이터를 클라이언트에서 다뤄야 하는 특수한 상황"에서 빛을 발해요.
대부분의 웹 앱에서는:
- 서버에서 필요한 데이터만 요청
- 페이지네이션으로 점진적 로드
- 간단한 필터링은
Array메서드 활용
이게 더 나은 선택일 거예요.
본인 프로젝트의 상황을 잘 판단해서, 정말 필요할 때만 이 패턴을 사용하세요!
체크리스트
이 패턴을 적용하기 전에 스스로에게 물어보세요:
- 전체 데이터를 클라이언트에서 가지고 있어야 하나?
- 서버에서 필터링/정렬할 수 없나?
- 중첩 순회로 인한 성능 문제가 실제로 있나?
- 데이터 변환 로직을 최상위에서 한 번만 하고 있나?
- 각 컴포넌트에서 중복으로 배열을 생성하고 있진 않나?
다섯 개 질문에 명확히 답할 수 있다면, 제대로 된 선택을 할 수 있을 거예요!
'<frontend>' 카테고리의 다른 글
| 토스 Frontend Fundamentals 모의고사 후기 (15) | 2025.11.26 |
|---|---|
| 주석대신 순수함수로 TODO적기 (20) | 2025.10.19 |
| 역할 기반 엑세스 제어(RBAC) (4) | 2025.09.29 |
| Single Source of Truth Config Pattern (16) | 2025.09.21 |
| 프론트엔드 검색 알고리즘 최적화(Feat. Trie) (12) | 2025.08.09 |