웹 애플리케이션의 확장성을 위한 데이터베이스 샤딩 전략
Daniel Hayes
Full-Stack Engineer · Leapcell

소개
웹 애플리케이션이 인기를 얻고 사용자 기반이 확대됨에 따라, 근본적인 데이터베이스는 종종 병목 현상이 됩니다. 단일 데이터베이스 서버는 증가하는 데이터 양과 동시 요청을 처리하는 데 어려움을 겪으며, 성능 저하, 느린 응답 시간, 불만족스러운 사용자 경험으로 이어집니다. 이러한 문제는 전자상거래 플랫폼, 소셜 네트워크, 실시간 분석 대시보드와 같은 트래픽이 많은 애플리케이션에서 특히 두드러집니다. 이러한 한계를 극복하기 위해 데이터베이스 확장이 필수가 됩니다. 읽기 복제본 및 캐싱과 같은 접근 방식은 일시적인 구제책을 제공할 수 있지만, 지속적인 성장을 위해서는 종종 더 근본적인 아키텍처 변경이 필요합니다. 이것이 바로 데이터베이스 샤딩이 등장하는 지점입니다. 샤딩은 단일 논리 데이터베이스를 여러 물리적 서버에 분산하여 수평 확장을 가능하게 하고 성능을 향상시키는 기술입니다. 이 글에서는 웹 애플리케이션을 위한 두 가지 기본 샤딩 전략인 수직 샤딩과 수평 샤딩에 대해 살펴보고, 각 전략의 원칙, 구현 및 실제적인 의미를 설명합니다.
핵심 샤딩 개념 이해
수직 및 수평 샤딩의 구체적인 내용을 살펴보기 전에, 이러한 전략의 기반이 되는 몇 가지 핵심 용어를 이해하는 것이 중요합니다.
- 샤드(Shard): 샤드는 전체 데이터셋의 일부를 보유하는 독립적인 데이터베이스 서버입니다. 각 샤드는 완전하고 기능적인 데이터베이스 인스턴스입니다.
- 샤딩 키(Sharding Key) 또는 파티션 키(Partition Key): 특정 데이터 행이 어느 샤드에 있어야 하는지를 결정하는 데 사용되는 테이블의 열 또는 열 집합입니다. 효과적인 샤딩 키를 선택하는 것은 데이터의 균형 잡힌 분산과 효율적인 쿼리 라우팅에 매우 중요합니다.
- 샤드 맵(Shard Map) 또는 라우팅 로직(Routing Logic): 샤딩 키를 기반으로 어떤 샤드가 어떤 데이터를 보유하고 있는지 결정하는 메커니즘입니다. 라우팅 계층 역할을 하여 적절한 샤드로 쿼리를 보냅니다.
- 분산 쿼리(Distributed Queries): 여러 샤드에 걸쳐 있으며 종종 다른 서버의 결과 집계를 요구하는 쿼리입니다. 이러한 쿼리는 단일 샤드 쿼리보다 복잡하고 느릴 수 있습니다.
수직 샤딩: 기능별 분할
기능 샤딩이라고도 하는 수직 샤딩은 기능 또는 도메인별로 데이터베이스를 분할하는 것을 포함합니다. 단일 서버에 모든 테이블을 넣으려고 하는 대신, 애플리케이션의 특정 기능 영역에 대한 다른 서버를 할당합니다.
원칙
수직 샤딩의 핵심 원칙은 모놀리식 데이터베이스를 여러 개의 작고 관리하기 쉬운 데이터베이스로 분해하는 것이며, 각 데이터베이스는 애플리케이션의 특정 부분을 서비스합니다. 예를 들어, 사용자 인증 데이터는 한 서버에, 제품 카탈로그 데이터는 다른 서버에, 주문 처리 데이터는 세 번째 서버에 있을 수 있습니다.
구현
수직 샤딩 구현은 일반적으로 다음을 포함합니다.
- 기능 경계 식별: 애플리케이션을 분석하여 명확하게 분리된 느슨하게 결합된 모듈 또는 서비스를 식별합니다.
- 별도의 데이터베이스 생성: 식별된 각 기능 영역에 대해 별도의 데이터베이스 스키마를 만들고 자체 서버에 배포합니다.
- 애플리케이션 로직 업데이트: 기능 컨텍스트에 따라 적절한 데이터베이스로 쿼리를 라우팅하도록 애플리케이션 코드를 수정합니다.
전자상거래 애플리케이션을 생각해 봅시다. 샤딩되지 않은 데이터베이스에는 ecommerce_db
내에 Users
, Products
, Orders
, Payments
, Carts
와 같은 테이블이 모두 포함될 수 있습니다.
수직 샤딩을 사용하면 다음과 같을 수 있습니다.
user_db
서버:Users
테이블,UserProfiles
테이블을 포함합니다.catalog_db
서버:Products
테이블,Categories
테이블,Reviews
테이블을 포함합니다.order_db
서버:Orders
테이블,OrderItems
테이블,Payments
테이블을 포함합니다.cart_db
서버:Carts
테이블을 포함합니다.
간소화된 애플리케이션 로직은 다음과 같을 수 있습니다 (SQLAlchemy를 사용한 Python으로 설명):
# 각 샤드에 대한 별도의 데이터베이스 연결이 구성되었다고 가정 from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker # 다양한 기능 샤드에 대한 데이터베이스 연결 user_engine = create_engine('mysql+pymysql://user:pass@user_db_host/user_db') catalog_engine = create_engine('mysql+pymysql://user:pass@catalog_db_host/catalog_db') order_engine = create_engine('mysql+pymysql://user:pass@order_db_host/order_db') UserSession = sessionmaker(bind=user_engine) CatalogSession = sessionmaker(bind=catalog_engine) OrderSession = sessionmaker(bind=order_engine) class User: # user_db에 매핑된 SQLAlchemy 모델 # ... class Product: # catalog_db에 매핑된 SQLAlchemy 모델 # ... class Order: # order_db에 매핑된 SQLAlchemy 모델 # ... def get_user_details(user_id): session = UserSession() user = session.query(User).filter_by(id=user_id).first() session.close() return user def get_product_details(product_id): session = CatalogSession() product = session.query(Product).filter_by(id=product_id).first() session.close() return product def place_order(user_id, product_id, quantity): # 이 작업은 catalog_db에서 제품을 가져오고 user_db에서 사용자를 가져온 다음 # order_db에 주문을 생성해야 할 수 있습니다. 이는 샤드 간 작업을 강조합니다. user_session = UserSession() catalog_session = CatalogSession() order_session = OrderSession() user = user_session.query(User).filter_by(id=user_id).first() product = catalog_session.query(Product).filter_by(id=product_id).first() if user and product: new_order = Order(user_id=user.id, product_id=product.id, quantity=quantity, total_price=product.price * quantity) order_session.add(new_order) order_session.commit() order_session.close() catalog_session.close() user_session.close()
애플리케이션 시나리오
수직 샤딩은 다음과 같은 경우에 적합합니다.
- 애플리케이션의 다른 부분이 크게 다른 데이터 액세스 패턴이나 성능 요구 사항을 가질 때.
- 다른 기능 도메인 간의 강력한 격리가 필요할 때.
- 특정 서비스를 독립적으로 확장해야 할 때.
- 다른 기능 도메인 간의 테이블 간 관계가 과도하게 복잡하지 않거나 모든 작업에 대해 모든 도메인에 걸쳐 일관된 보장이라는 강력한 요구 사항이 항상 중요하지 않을 때.
장점
- 단순성: 초기에는 수평 샤딩보다 구현하기 쉽습니다. 샤딩 키나 샤드 간의 복잡한 라우팅 로직이 필요하지 않습니다.
- 격리: 한 기능 데이터베이스의 실패 또는 과부하는 다른 데이터베이스에 직접적인 영향을 미치지 않습니다.
- 리소스 최적화: 리소스는 각 기능 영역의 특정 요구 사항에 맞게 조정될 수 있습니다.
단점
- 단일 기능에 대한 제한된 확장성: 하나의 기능 영역(예: 제품 카탈로그)이 극심한 성장을 경험하는 경우에도 해당 전용 서버가 병목 현상이 될 수 있습니다.
- 샤드 간 조인/트랜잭션 복잡성: 여러 기능 샤드의 데이터를 필요로 하는 쿼리 또는 트랜잭션은 효율적으로 구현하고 ACID 속성을 유지하기 어려울 수 있습니다.
- 데이터 중복: 때로는
user_id
또는product_id
와 같은 작은 데이터 조각이 조인 효율성을 위해 샤드 간에 중복되어 일관성 문제가 발생할 수 있습니다.
수평 샤딩: 행별 분할
종종 단순히 샤딩이라고 하는 수평 샤딩은 단일 테이블의 행을 여러 데이터베이스 서버에 걸쳐 분할하는 것을 포함합니다. 이 모델에서 각 샤드는 테이블(또는 테이블)의 전체 행 중 일부를 포함합니다.
원칙
핵심 원칙은 선택한 샤딩 키를 기반으로 대형 테이블의 행을 분산하는 것입니다. 예를 들어, Users
테이블은 user_id
별로 샤딩될 수 있으며, 특정 범위 내의 user_id
를 가진 사용자는 한 샤드로, 다른 범위의 ID를 가진 사용자는 다른 샤드로 이동합니다.
구현
수평 샤딩 구현은 다음을 요구합니다.
- 샤딩 키 선택: 이것이 가장 중요한 단계입니다. 키는 균등한 데이터 분산을 보장하고 샤드 간 쿼리를 최소화해야 합니다.
- 범위 기반 샤딩(Range-Based Sharding): 데이터는 샤딩 키의 범위에 따라 분산됩니다(예: 샤드 A의
user_id
1-1000, 샤드 B의 1001-2000). 구현이 간단하지만 데이터 액세스가 특정 키 범위에 집중되면 핫스팟으로 이어질 수 있습니다. - 해시 기반 샤딩(Hash-Based Sharding): 샤딩 키를 해시하고 해시 값이 샤드 ID를 결정합니다(예:
shard_id = hash(sharding_key) % num_shards
). 데이터를 더 균등하게 분산하는 경향이 있지만 범위 쿼리를 어렵게 만듭니다. - 목록 기반 샤딩(List-Based Sharding): 데이터는 특정 국가의 사용자 등 샤딩 키 값 목록을 기반으로 샤드에 명시적으로 할당됩니다.
- 범위 기반 샤딩(Range-Based Sharding): 데이터는 샤딩 키의 범위에 따라 분산됩니다(예: 샤드 A의
- 여러 샤드 생성: 각 샤드 역할을 하는 여러 데이터베이스 인스턴스를 설정합니다.
- 샤드 맵/라우팅 로직 구현: 이 계층(프록시와 같은 애플리케이션 외부에 있거나 애플리케이션 내에 포함됨)은 샤딩 키를 기반으로 올바른 샤드로 쿼리를 라우팅합니다.
- 스키마 변경 관리: 여러 샤드에 걸친 스키마 마이그레이션은 더 복잡할 수 있습니다.
우리 예제의 전자상거래 애플리케이션에서 Orders
테이블을 생각해 봅시다. 수직 샤딩의 order_db
가 너무 커지면 order_id
를 샤딩 키로 사용하여이를 수평으로 추가로 샤딩할 수 있습니다.
Orders
테이블에 대한 3개의 샤드를 가정합니다: order_shard_0
, order_shard_1
, order_shard_2
.
일반적인 해시 기반 라우팅 로직: shard_id = order_id % num_shards
from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker SHARD_COUNT = 3 SHARD_ENGINES = { 0: create_engine('mysql+pymysql://user:pass@order_shard_0_host/order_db_0'), 1: create_engine('mysql+pymysql://user:pass@order_shard_1_host/order_db_1'), 2: create_engine('mysql+pymysql://user:pass@order_shard_2_host/order_db_2'), } def get_session_for_order_id(order_id): shard_id = order_id % SHARD_COUNT engine = SHARD_ENGINES[shard_id] Session = sessionmaker(bind=engine) return Session() class Order: # 각 샤드에 존재하는 orders 테이블에 매핑된 SQLAlchemy 모델 # ... def get_order_details(order_id): session = get_session_for_order_id(order_id) order = session.query(Order).filter_by(id=order_id).first() session.close() return order def create_order(user_id, product_id, quantity): # 실제 시나리오에서는 먼저 고유한 order_id를 생성한 다음, 이를 사용하여 샤드를 결정합니다. # 간단하게 하기 위해 생성 후 샤드를 결정하거나 ID를 먼저 생성한다고 가정합니다. # 예를 들어, 시퀀스 생성기는 새 order_id를 제공할 수 있습니다. new_order_id = generate_unique_order_id() # 이 ID가 샤드를 결정합니다. session = get_session_for_order_id(new_order_id) new_order = Order(id=new_order_id, user_id=user_id, product_id=product_id, quantity=quantity) session.add(new_order) session.commit() session.close() return new_order
애플리케이션 시나리오
수평 샤딩은 다음과 같은 경우에 이상적입니다.
- 단일 테이블 또는 밀접하게 관련된 테이블 세트가 단일 서버에 담기에는 너무 커지거나 효율적으로 처리하기 어려운 경우.
- 특정 엔티티에 대한 대규모 데이터 및 트래픽 증가를 수용하기 위해 개별 테이블(예: 사용자, 주문, 이벤트)을 확장해야 하는 경우.
- 데이터셋이 크게 성장해도 일관된 성능을 유지해야 하는 경우.
장점
- 극한의 확장성: 더 많은 샤드를 추가하여 거의 무제한의 데이터 볼륨과 쿼리 로드를 처리할 수 있습니다.
- 성능 향상: 여러 서버에 부하를 분산하여 경합을 줄이고 쿼리 응답 시간을 개선합니다.
- 내결함성: 하나의 샤드 실패는 전체 데이터베이스가 아닌 데이터의 일부에만 영향을 미칩니다(단, 샤드 내의 적절한 복제는 여전히 필요합니다).
단점
- 복잡성: 수직 샤딩보다 설계, 구현 및 유지 관리가 훨씬 더 복잡합니다.
- 샤드 간 쿼리: 샤딩 키를 포함하지 않거나 여러 샤드의 데이터가 필요한 쿼리는 어렵고 비용이 많이 듭니다(예:
Orders
가order_id
로 샤딩되고Users
가 다르게 샤딩되는 경우 '이름이 'A'로 시작하는 사용자의 모든 주문 가져오기'). - 재샤딩(Resharding): 샤딩 키를 변경하거나 샤드 수를 늘리는 작업(재샤딩)은 매우 어렵고 종종 다운타임이 많이 발생하는 작업입니다.
- 데이터 편중(Data Skew): 샤딩 키 선택이 잘못되면 일부 샤드는 과도하게 로드되고 다른 샤드는 활용되지 않는 불균등한 데이터 분산(핫스팟)이 발생할 수 있습니다.
결론
수직 및 수평 샤딩 모두 단일 서버의 한계를 넘어 웹 애플리케이션 데이터베이스를 확장할 수 있는 강력한 방법을 제공합니다. 수직 샤딩은 별개의 애플리케이션 부분을 격리하는 데 이상적인 더 간단한 기능 분해를 제공합니다. 수평 샤딩은 더 복잡하지만, 데이터 행을 수많은 서버에 분산하여 탁월한 확장성을 제공하며, 특정 엔티티에 대한 데이터 및 트래픽의 막대한 성장을 관리하는 데 필수적입니다. 종종 두 가지 전략을 조합한 것 – 먼저 서비스별로 수직으로 분할한 다음 특정 서비스 내에서 수평으로 샤딩하는 것 – 은 매우 까다로운 웹 애플리케이션에 가장 강력하고 확장 가능한 솔루션을 제공합니다. 데이터베이스 확장은 단순히 리소스를 추가하는 것이 아니라 최적의 성능과 복원력을 위해 작업 부하와 데이터를 지능적으로 분산하는 것입니다.