주문서비스 ShardingSphere-Proxy 도입기

--

안녕하세요. 요기요 R&D Center에서 주문 서비스인 Orderyo를 개발하고 있는 Backend Develoer 김병철입니다. 이번에 Apache ShardingSphere를 요기요 주문 서비스에 도입했는데, 간략한 설명과 함께 도입한 배경 및 경험을 공유하려고 합니다.

주문서비스 DB 구조

먼저 도입 배경을 설명하기 위해, 요기요 주문서비스가 어떤 구조로 이루어졌는지 소개하고자 합니다. 일단 요기요의 주문서비스 구조는 이렇습니다.

주문 서비스 구조

주문 생성/취소/업데이트 트래픽이 들어오면 Orderyo 애플리케이션 코드에서 customer_id 기반의 모듈러 연산을 통해 4개의 Shard Cluster로 write 부하를 분산처리하고 있습니다. 따라서 애플리케이션에서 customer_id만 있으면 접근이 필요한 shard cluster를 찾아서 연산을 할 수 있는 구조로 되어 있습니다.

주문 서비스에 Sharding을 도입하면서 customer_id 기반의 주문 기능들의 전반적인 응답 시간이 개선되었으며 db 부하가 분산되면서 데이터 베이스단에서의 병목이 많이 해소되었습니다. 또한 앞으로 늘어날 트래픽에 대비해 적절한 수준으로 DB를 확장할 수 있게 되었습니다.

하지만 데이터베이스 Sharding이 좋은 점만 있는 것은 아닙니다. 지난 1년간 요기요의 주문서비스에 데이터베이스 Sharding을 도입하고 운영해 보면서 기술 부채가 있다는 것을 깨달았습니다. 바로 통합조회 요구사항을 반영하기 위한 DB 구조에 문제가 있다는 것입니다.

주문서비스의 기능의 대부분의 트래픽은 customer_id를 가지고 있어서 Shard DB를 사용하지만 그 외에도 주문의 원천 데이터를 가지고 있다 보니 운영향, 사장님향 기능들에서 Sharding 키가 없는 통합조회에 대한 요구사항이 있습니다. 지난 1년간은 운영성 쿼리를 지원하기 위해 shard DB 들에서 한대의 DB(편의상 Integration DB로 부르겠습니다)로 레플리케이션 하는 방식으로 데이터를 모아 조회가 가능하도록 했습니다.

이 구조의 경우 Shard들의 처리량이 늘어나게 되면 해당 부하가 이 한대의 Integration DB에 몰리게 됩니다. 따라서 Integration DB에 병목이 생기면서 Sharding의 장점 중 하나인 수평적 DB 확장을 사용할 수 없는 구조가 됩니다.

새로운 요구사항으로 지원해야하는 사장님향 기능

실제로 테스트했을 때 수평적 확장에 병목이 생기는 임계치는 현재 트래픽보다 훨씬 높은 수준의 일이지만 새로운 요구사항이 들어오면서 이는 해결해야 하는 문제가 되었습니다.

기존에는 운영향 쿼리들만 지원을 해야 했지만 새로운 요구사항으로 사장님향 기능들의 쿼리들을 지원해야 했습니다. 그리고 사장님향 쿼리들은 기존 운영향 쿼리들 보다 훨씬 트래픽이 많고 DB에 부하를 주도록 되어 있었습니다.

ShardingSphere-Proxy 도입 과정

기존의 기술 부채를 해결하고 새로운 요구사항을 반영하기 위해 Apache ShardingSphere를 도입하기로 의사결정했습니다.

Apache ShardingSphere 프로젝트에 대해 간단하게 설명하자면 데이터베이스를 분산 데이터베이스 생태계로 구축하는 것을 목표로 하는 프로젝트로 이름에서 알 수 있듯이 RDB를 Sharding 하여 분산 데이터베이스 환경으로 운영할 수 있도록 하는 솔루션입니다.

Apache ShardingSphere 도입을 생각하게 된 가장 큰 이유는 손 안 대고 코 풀기가 가능했기 때문입니다. Apache ShardingSphere 프로젝트에서 제공하는 데이터베이스 프록시 서버인 ShardingSphere-Proxy를 도입해서 Integration DB를 사용하던 쿼리들만 데이터베이스 프록시 서버로 옮기기만 하면 되기 때문입니다.

로컬 환경에서 간단하게 튜토리얼을 따라 하면서 요기요 주문 서비스에 도입할 수 있는지 확인 후 스테이징 서버에서 일정 기간 검증하고 로드테스트에서 성능상 유의미한지 확인 후에 프로덕션에 도입하는 방식으로 진행했습니다.

로컬 환경 세팅

먼저 로컬에서 현재 운영되고 있는 서비스와 동일한 환경을 세팅해 주었습니다. 현재 운영되는 Shard DB는 아래와 같은 Mysqld 설정으로 각 테이블의 p.k의 증가폭을 1024을 공통으로 두고 offset을 다르게 두어 테이블이 Shard에 나눠져있어도 고유한 pk를 가질 수 있도록 세팅했습니다.

0번 Shard의 설정

[mysqld]
server_id=20
auto_increment_increment = 1024
auto_increment_offset = 1

1번 Shard의 설정

[mysqld]
server_id=21
auto_increment_increment = 1024
auto_increment_offset = 2

그리고 아래처럼 위에 설정한 2개의 DB를 DataSource로 설정하고 테이블의 pk인 id를 기반으로 논리적 데이터베이스인 ds를 찾을 수 있도록 ShardingSphere-Proxy 서버의 샤딩 DB에 대한 룰을 설정해 주었습니다. 또한 Django 프레임워크의 디폴트로 생성되는 테이블은 Sharding될 필요가 없도록 broadcastTables로 예외 처리 해주었습니다.

######################################################################################################
#
# Here you can configure the rules for the proxy.
# This example is configuration of sharding rule.
#
# If you want to use sharding, please refer to this file;
# if you want to use master-slave, please refer to the config-master_slave.yaml.
#
######################################################################################################

databaseName: orderyo

dataSources:
ds_0:
url: jdbc:mysql://sharding-sphere-mysql-shard-0:3306/orderyo?serverTimezone=Asia/Seoul&useSSL=false&characterEncoding=UTF-8
username: root
password: root
connectionTimeoutMilliseconds: 30000
idleTimeoutMilliseconds: 60000
maxLifetimeMilliseconds: 1800000
maxPoolSize: 50
ds_1:
url: jdbc:mysql://sharding-sphere-mysql-shard-1:3306/orderyo?serverTimezone=Asia/Seoul&useSSL=false&characterEncoding=UTF-8
username: root
password: root
connectionTimeoutMilliseconds: 30000
idleTimeoutMilliseconds: 60000
maxLifetimeMilliseconds: 1800000
maxPoolSize: 50

rules:
- !SHARDING
tables:
order_order:
actualDataNodes: ds_${0..1}.order_order
order_orderitem:
actualDataNodes: ds_${0..1}.order_orderitem
order_orderitemoption:
actualDataNodes: ds_${0..1}.order_orderitemoption

broadcastTables:
- django_admin_log,django_content_type,django_migrations,django_session

defaultShardingColumn: id

defaultDatabaseStrategy:
standard:
shardingColumn: id
shardingAlgorithmName: database-inline

defaultTableStrategy:
none:

shardingAlgorithms:
database-inline:
type: INLINE
props:
algorithm-expression: ds_${id % 1024 - 1}

이렇게 몇 가지 설정을 추가하고 해당 설정을 노드로 띄우면, 애플리케이션에서 DB 경로만 변경해서 Integration DB를 대체할 수 있다는 것을 확인했습니다.

스테이징 환경 적용

로컬 환경에서 테스트하고 스테이징 환경에서 테스트를 진행했습니다. 기능 검증과 성능 검증을 하기 위해 장고의 DBWrapper에 Integration DB에 쿼리가 나가는 경우 ShardingSphere-Proxy 서버에도 동일한 쿼리를 날리고 결과와 응답시간을 비교하는 로그를 남기도록 했습니다.

class IntegrationDatabaseWrapper:
def __call__(self, execute, sql, params, many, context):
db_alias = context["connection"].alias

if db_alias != settings.INTEGRATION_DB_READ_ONLY_NAME:
return execute(sql, params, many, context)

try:
integration_start = time.monotonic()
integration_result = execute(sql, params, many, context)
integration_execution_time = time.monotonic() - integration_start
except Exception as e:
raise e
else:
return integration_result
finally:
if config.INTEGRATION_DB_MODE == DatabaseMode.DUAL:
self._call_proxy_db(sql, params, integration_result, integration_execution_time)

def _call_proxy_db(self, sql, params, integration_result, integration_execution_time):
from django.db import connections

proxy_start = time.monotonic()
shardingsphere_cursor = connections[settings.SHARDINGSHPERE_PROXY_DB_READ_ONLY_NAME].cursor()
shardingsphere_result = shardingsphere_cursor.execute(sql, params)
shardingsphere_cursor.close()
proxy_execution_time = time.monotonic() - proxy_start
logger.info(....)

integration_db_wrapper = IntegrationDatabaseWrapper()

with connections[settings.INTEGRATION_DB_READ_ONLY_NAME].execute_wrapper(integration_db_wrapper):
do_queries()

스테이징에서 테스트했을 때 2가지 문제가 발생했습니다.

  1. DB 스키마 변경이 있을때 쿼리가 안되는 문제가 있었습니다.
  2. 기존과 결과가 달라지는 쿼리가 있다는 것을 확인했습니다.

좀 더 디테일하게 오픈소스 코드를 확인해 보고 문서를 확인해 본 결과 DB 스키마 변경이 있는 경우 datetime order_by 같이 데이터 가공이 필요한 필드의 경우에는 아래의 커맨드로 변경된 테이블 정보를 ShardingSphere-Proxy 서버에도 반영시켜 주어야 했습니다.

스키마 변경이 있는 경우에 자동으로 커맨드를 수행할 수 있도록 배포 프로세스를 변경하면서 해결되었습니다.

REFRESH TABLE METADATA;

기존과 결과가 달라지는 쿼리는 기존 쿼리가 상당히 복잡한 subquery를 가지고 있었고 ShardingSphere 프로젝트에서 특정 subquery에 대한 지원이 안되기 때문에 발생하는 경우였습니다. 이 경우 subquery를 제거하고 쿼리를 튜닝하여 해결할 수 있었습니다.

로드 테스트

로컬과 스테이징 환경에서 기능상 대체가 가능하다는 것은 확인했으나 프로덕션에 도입했을 때 성능을 테스트하고 확인하는 것도 필요했습니다. 이를 검증하기 위해 사내 인프라팀과 자동화 팀에 요청해서 프로덕션과 동일한 환경에서 성능 테스트를 진행했습니다.

성능 테스트는 기존 Integration DB와, ShardingSphere-Proxy 구조의 한계점을 비교하는 방식으로 이루어졌습니다. 테스트 툴은 locust를 사용했습니다.

테스트 결과 동일한 유저풀에서 두 구조의 결과가 다르게 나왔습니다. 먼저 Integration DB의 경우 Replication 을 처리하기 위한 리소스도 필요하기 때문에 주문 생성 부하가 증가하게 되면 그 영향으로 병목이 발생합니다. 이는 Integration DB and Proxy CPU 지표에서 부하율이 75%까지 올라가는 걸로 확인할 수 있습니다. 이 말은 곧 앞으로 확장성에 문제가 있다는 이야기입니다.

ShardingSphere-Proxy 구조는 쿼리 분산을 Master로 보내기 때문에 Shard Master에 부하가 전반적으로 증가한 양상을 보입니다. 프로덕션 환경에서는 Shard Slave에 분산해서 처리하기 때문에, 이론상 DB 확장성에 제약이 없다는 것 또한 확인할 수 있습니다.

테스트한 API들의 응답속도 양상도 구조에 따라 많이 달라지는 것을 확인할 수 있었습니다. 위 표에서 무거운 쿼리가 발생하는 사장님향 주문 통계 API는 분산처리를 하는 ShardingSphere-Proxy에서 일관되게 좋은 성능을 보여주는 것을 확인할 수 있습니다. 그러나 빠른 처리가 가능한 사장님향 주문 조회 API의 경우 네트워크 홉 오버헤드가 없는 Integration DB가 일관되게 좋은 성능을 보입니다.

성능 테스트에서 최대 부하 측면에서 Integration DB 최대 Throughput이 한계가 있다는 것을 확인했고 ShardingSphere-Proxy의 경우 Integration DB 대비 대략 1.5배의 처리량을 가지게 되고 때문에 Shard의 개수와 상관없이 수평적으로 확장이 가능하다고 판단되었습니다. 따라서 기존 Integration DB을 제거하고 ShardingSphere-Proxy를 도입하는 방향으로 의사결정을 했습니다.

마무리

요기요에서 주문서비스는 DB 쓰기 연산이 가장 많은 서비스 중 하나입니다. DB의 수평적 확장을 위해 선제적으로 Sharding을 해서 운영하고 있지만 통합조회에 대한 요구사항을 충족시키기 위해 구축한 환경이 Sharding의 장점을 못 살리는 구조인 걸 확인했습니다. 고민 끝에 오픈 소스인 Apache ShardingSphere를 도입해서 Sharding의 장점을 다시 살리며 분산처리가 가능해졌고, 최대 4배까지 성능 향상도 기대할 수 있는 구조로 의사결정했습니다.

이번에 구조개선이 되면서 기존 대비 효율적인 Sharding 구조를 도입하고자 하지만 특정 요구사항 때문에 도입이 어려운 분들께 이 글이 도움이 되었으면 좋겠습니다. 기회가 된다면 ShardingSphere를 운영해보면서 발생하는 이슈들과 성능 개선에 대해서 다음 글을 게시해보겠습니다.

이제 이론상 Orderyo의 주문쓰기 연산을 막을 수 있는 방법은 없습니다. (확인해보려면 주문 늘려보시던가..)

마지막으로 ShardingSphere를 도입할 때 도움 주신 저희 팀 분들과 인프라팀, 성능 테스트팀 분들께 감사의 말을 드리면서 글을 마치겠습니다.

P.S 요기요 주문서비스에 함께할 개발자를 적극적으로 모집합니다. 대용량 트래픽을 위한 기술적인 고민을 경험하실 분들이 있었으면 좋겠습니다. 감사합니다.

--

--