본문 바로가기
Project/StyleLab

StyleLab의 여섯 번째 노트: 상품 목록 조회 최적화를 위한 파티셔닝 - 1

by 규난 2024. 1. 28.
728x90

상품 목록 조회 성능 개선을 위해 파티셔닝을 도입한 경험을 공유해 보도록 하겠습니다.

목차

  1. offset paging의 문제점
  2. cursor paging을 사용하여 조회 성능 개선
  3. 극복하지 못한 한계
  4. Partitioning 도입
  5. 느낀점

 

1. offset paging의 문제점

상품 테이블에 많은 데이터가 쌓이기 전에는 offset paging 방식을 사용하여 상품 목록을 조회하고 있었습니다.

적은 양의 데이터가 있는 테이블에서는 offset paging의 성능 저하는 발생하지 않습니다. 하지만 많은 양의 데이터가 있는 테이블에서는 성능 저하가 발생하게 됩니다.

현재 상품 테이블에 카테고리가 상의(001001)인 상품 개수는 2,323,428개이며, offset paging을 사용하여 2,300,00번째 부터 상의 목록을 조회한 결과를 보시면 1초가 걸린 것을 보실 수 있습니다. 테이블에 데이터가 쌓이면 쌓일수록 성능 저하는 더욱더 심해지겠죠.

offset paging에서의 조회 성능 저하

 

2. cursor paging을 사용하여 조회 성능 개선

조회 성능 문제를 개선하기 위해 고려한 방안은 cursor paging을 사용하는 것입니다.

offset paging은 limit의 첫 번째 인자인 starting point 조건에 맞는 데이터가 나올 때까지 스킵하는 형태이기 때문에 starting point 값이 커질수록 성능의 저하가 오지만 cursor paging은 where 조건에 데이터를 읽을 시작점을 명시해 주기 때문에 대량의 데이터를 다룰 때도 성능의 저하 없이 상품 목록을 조회할 수 있습니다.

 

밑의 사진은 cursor paging을 사용하여 상품 목록 조회 성능을 개선한 결과입니다.

cursor paging을 사용하여 조회 성능 개선

 

3. 극복하지 못한 한계

cursor paging으로 상품 목록 조회 성능의 최적화가 된 거 같았지만 한 가지 문제점이 발생합니다.

현재 카테고리 테이블에 데이터 구조는 다음과 같습니다. 상위 카테고리와 하위 카테고리가 존재하며 테이블에 존재하는 category_path를 사용하여 하나의 상품 테이블에 모든 카테고리의 상품 데이터가 저장되고 있습니다.

상품 카테고리 테이블

 

일반적으로 사용자들은 상품 목록을 조회할 때 모든 카테고리의 상품을 조회하는 것이 아니라 특정 카테고리의 상품만을 조회하게 됩니다. 따라서 대부분의 커머스 서비스에서는 상위 카테고리로 상품 목록을 조회하면 해당 상위 카테고리에 속한 하위 카테고리의 상품들도 함께 조회되어야 하는 요구사항이 있습니다.

 

무신사 페이지를 보면서 예를 들자면 사용자가 상의 전체 버튼을 클릭 시 상의와 관련된 모든 카테고리의 상품이 최신순으로 조회가 가능해야 합니다. (무신사가 최신순으로 조회한 다는 뜻은 아닙니다.)

무신사 상의 전체 조회 화면

 

이러한 요구사항이 존재하기 때문에 상품 목록 조회 API를 처음 호출 시 해당 카테고리에 가장 최신 상품의 아이디를 구해서 where 조건에 시작점을 명시해 주어여 합니다. 그렇지 않으면 cursor paging 방식으로 조회 성능을 개선했다 해도 where 조건에 product_id를 사용하지 못하기 때문에 조회 성능이 좋지 않게 됩니다.

 

그렇다면 이제 카테고리에 해당하는 가장 최신 상품 아이디를 구해야겠죠???

하지만 하나의 상품 테이블에 모든 카테고리의 상품 데이터가 존재하기 때문에 조회하려는 카테고리에 가장 최신 상품의 아이디를 구하는 쿼리의 성능이 좋지 않았습니다. 며칠 동안 쿼리와 씨름한 결과 결국 쿼리의 성능을 개선하지 못하고 다른 방안을 모색하게 됩니다.

 

4. Partitioning 도입

상품 목록 조회 성능 개선을 위한 방법으로 파티셔닝을 선택하였습니다.

파티셔닝은 같은 데이터베이스 내에서 하나의 큰 테이블을 여러 개의 테이블로 분할하여 특정 조건에 따라

분할된 테이블에 저장하는 기법입니다. 테이블을 행을 기준으로 분할하면 수평 파티셔닝, 열 단위로 분리하면 수직 파티셔닝이라 불리게 됩니다.

상품 목록 조회를 개선하기 위해 수평 파티셔닝을 선택하였으며 이유는 다음과 같습니다.

  • 비용 문제로 인해 추가로 데이터베이스 서버를 구축할 수 없어 샤딩보다 파티셔닝이 적합하다고 판단하였습니다.
  • 카테고리를 기준으로 분할된 테이블에 상품 데이터의 대부분의 컬럼이 필요하기 때문에 수직 파티셔닝보다 수평 파티셔닝이 적합하다고 판단하였습니다.
  • 분할된 테이블에는 같은 카테고리 상품만 저장되기 때문에 조회 성능을 향상시킬 수 있습니다.

효율적인 상품 데이터 관리를 위한 파티셔닝 전략은 다음과 같습니다.

  • 카테고리를 기준으로 분할된 테이블 명의 규칙은 product_category_{category_path}로 정하였습니다.
  • 상품 데이터의 저장/수정/삭제 범위는 상품 테이블과 해당 카테고리 파티셔닝 테이블, 부모 카테고리 파티셔닝 테이블까지입니다.
  • 파티셔닝을 통해 저장된 데이터는 조회 시에만 사용하고 주문/결제는 상품 테이블의 정보를 사용합니다.

카테고리를 기준으로 파티셔닝 테이블을 생성하도록 하겠습니다.

테이블을 생성 후 상품 테이블의 데이터를 각 파티셔닝 테이블에 마이그레이션을 해줍니다. 조인 없이 상품 데이터를 조회하기 위해 사용자에게 보여주기 위한 데이터는 모두 저장하도록 하겠습니다.

-- 상의 파티셔닝 테이블
create table `product_category_001001` (
  `product_category_001001_id` bigint NOT NULL AUTO_INCREMENT,
  `product_id` bigint NOT NULL COMMENT '상품 일련번호',
  `store_id` bigint NOT NULL COMMENT '스토어 일련 번호',
  `store_name` varchar(255) NOT NULL COMMENT '스토어 이름',
  `product_category_path` varchar(50) NOT NULL COMMENT '상품 카테고리 경로',
  `product_category_name` varchar(50) NOT NULL COMMENT '상품 카테고리 이름',
  `name` varchar(100) NOT NULL COMMENT '상품 이름',
  `price` int NOT NULL DEFAULT '0' COMMENT '상품 가격',
  `discount_price` int NOT NULL DEFAULT '0' COMMENT '상품 할인 가격',
  `discount_rate` tinyint NOT NULL DEFAULT '0' COMMENT '상품 할인율',
  `product_main_image` varchar(255) COMMENT '상품 메인 이미지',
  `product_main_image_type` varchar(50) COMMENT '상품 이미지 타입',
  `sold_out` bit(1) NOT NULL DEFAULT b'0' COMMENT '품절 여부',
  `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '삭제 여부',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '등록일시',
  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '수정일시',
  PRIMARY KEY (`product_category_001001_id`)
)ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

-- 맨투맨/후드 파티셔닝 테이블
create table `product_category_001001001` (
  `product_category_001001001_id` bigint NOT NULL AUTO_INCREMENT,
  `product_id` bigint NOT NULL COMMENT '상품 일련번호',
  `store_id` bigint NOT NULL COMMENT '스토어 일련 번호',
  `store_name` varchar(255) NOT NULL COMMENT '스토어 이름',
  `product_category_path` varchar(50) NOT NULL COMMENT '상품 카테고리 경로',
  `product_category_name` varchar(50) NOT NULL COMMENT '상품 카테고리 이름',
  `name` varchar(100) NOT NULL COMMENT '상품 이름',
  `price` int NOT NULL DEFAULT '0' COMMENT '상품 가격',
  `discount_price` int NOT NULL DEFAULT '0' COMMENT '상품 할인 가격',
  `discount_rate` tinyint NOT NULL DEFAULT '0' COMMENT '상품 할인율',
  `product_main_image` varchar(255) COMMENT '상품 메인 이미지',
  `product_main_image_type` varchar(50) COMMENT '상품 이미지 타입',
  `sold_out` bit(1) NOT NULL DEFAULT b'0' COMMENT '품절 여부',
  `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '삭제 여부',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '등록일시',
  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '수정일시',
  PRIMARY KEY (`product_category_001001001_id`)
)ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

-- 맨투맨 파티셔닝 테이블
create table `product_category_001001001001` (
  `product_category_001001001001_id` bigint NOT NULL AUTO_INCREMENT,
  `product_id` bigint NOT NULL COMMENT '상품 일련번호',
  `store_id` bigint NOT NULL COMMENT '스토어 일련 번호',
  `store_name` varchar(255) NOT NULL COMMENT '스토어 이름',
  `product_category_path` varchar(50) NOT NULL COMMENT '상품 카테고리 경로',
  `product_category_name` varchar(50) NOT NULL COMMENT '상품 카테고리 이름',
  `name` varchar(100) NOT NULL COMMENT '상품 이름',
  `price` int NOT NULL DEFAULT '0' COMMENT '상품 가격',
  `discount_price` int NOT NULL DEFAULT '0' COMMENT '상품 할인 가격',
  `discount_rate` tinyint NOT NULL DEFAULT '0' COMMENT '상품 할인율',
  `product_main_image` varchar(255) COMMENT '상품 메인 이미지',
  `product_main_image_type` varchar(50) COMMENT '상품 이미지 타입',
  `sold_out` bit(1) NOT NULL DEFAULT b'0' COMMENT '품절 여부',
  `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '삭제 여부',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '등록일시',
  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '수정일시',
  PRIMARY KEY (`product_category_001001001001_id`)
)ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

-- 후드 파티셔닝 테이블
create table `product_category_001001001002` (
  `product_category_001001001002_id` bigint NOT NULL AUTO_INCREMENT,
  `product_id` bigint NOT NULL COMMENT '상품 일련번호',
  `store_id` bigint NOT NULL COMMENT '스토어 일련 번호',
  `store_name` varchar(255) NOT NULL COMMENT '스토어 이름',
  `product_category_path` varchar(50) NOT NULL COMMENT '상품 카테고리 경로',
  `product_category_name` varchar(50) NOT NULL COMMENT '상품 카테고리 이름',
  `name` varchar(100) NOT NULL COMMENT '상품 이름',
  `price` int NOT NULL DEFAULT '0' COMMENT '상품 가격',
  `discount_price` int NOT NULL DEFAULT '0' COMMENT '상품 할인 가격',
  `discount_rate` tinyint NOT NULL DEFAULT '0' COMMENT '상품 할인율',
  `product_main_image` varchar(255) COMMENT '상품 메인 이미지',
  `product_main_image_type` varchar(50) COMMENT '상품 이미지 타입',
  `sold_out` bit(1) NOT NULL DEFAULT b'0' COMMENT '품절 여부',
  `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '삭제 여부',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '등록일시',
  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '수정일시',
  PRIMARY KEY (`product_category_001001001002_id`)
)ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 생략

 

현재 상위 카테고리 product_category_001001 파티셔닝 테이블에는 2,323,248개의 데이터가 있으며, 최신 상품 순으로 조회를 해 보도록 하겠습니다.

첫 조회 시 가장 최신 아이디를 구하지 않아도 빠른 속도로 조회할 수 있으며 이후에도 cursor paging을 사용하여 성능의 저하 없이 빠른 속도로 조회할 수 있습니다.

 

5. 느낀점

상품 목록 조회 성능 개선을 하기 위해 약 일주일 정도 많은 고민을 했습니다. 최종적으로 상품 카테고리를 기준으로 수평 파티셔닝을 도입하기로 결정하여 성능을 개선을 하였지만 같은 데이터가 여러 테이블에 저장되기 때문에 관리 포인트가 늘어난 부분과 더 많은 저장 공간 필요로 인한 비용 문제가 걱정되긴 하네요...

 

또한, 파티셔닝을 NoSQL DB를 사용해서 진행하고 싶은 마음도 있었지만 NoSQL DB 서버를 구축하기 위한 비용과 짧은 기간 내에 공부해서 적용하기에는 무리가 있다 판단하여 사용하고 있는 MySQL DB에 진행하였는데 이 부분은 개인적으로 조금 아쉬움이 남습니다.

 

다음 블로깅에는 Spring Boot 애플리케이션에서 각 파티셔닝 테이블의 데이터를 조회하는 방식에 대해 알아보도록 하겠습니다.

728x90