LINE Manga 데이터베이스 샤딩 – 서버 엔지니어 편

들어가며

안녕하세요. LINE Manga 개발을 담당하고 있는 Ito입니다. LINE Manga의 데이터베이스 샤딩(sharding) 작업에 대해서 서버 엔지니어 편, 데이터베이스 엔지니어 편으로 나누어 소개하려 합니다.

이번 글은 서버 엔지니어 편으로, 샤딩을 하게 된 배경과 대응 방침, 애플리케이션에서 수정한 사항을 중심으로 공유하겠습니다.

 

LINE Manga란?

‘LINE Manga’는 앱이나 웹 브라우저, 혹은 LINE 메신저에서 만화 작품을 즐길 수 있는 디지털 만화 서비스입니다. 현재 일본어로 서비스하고 있으며 2013년 4월에 서비스를 시작하여 2019년 4월 9일 자로 6주년을 맞이했습니다. 250곳 이상의 출판사와 레이블을 통해 이제까지 38만 작품 이상의 만화를 서비스했고, 일본에서 다운로드 수 2,300만 이상(2019년 4월 현재)이며, App Annie가 발표한 ‘Top Publisher Award 2018’의 일본 앱 연간 수익 랭킹(비게임 제외)에서 커뮤니케이션 앱 ‘LINE’의 뒤를 이어 무려 2위(참고(일본어))를 차지했습니다!

 

샤딩을 하게 된 배경

정말 감사하게도 서비스 사용자가 계속 증가하고 있던 터라 이미 샤딩을 검토해 봤는데요. 샤딩을 전제로 설계된 서비스가 아니라는 점과 개발 리소스를 확보하기 쉽지 않다는 점 등의 문제가 있어 샤딩 대신 스케일 업을 통해 데이터 증가에 대처해 왔습니다. 2018년 2월에도 NVMe SSD를 3TB에서 6TB로 교체하면서 ‘이걸로 3년은 버틸 수 있겠다!’고 예상했습니다.

하지만 그로부터 반년 후, 복제 지연 경고와 마주치면서 상황이 달라졌습니다. 지연 발생의 원인은 네이버 웹툰의 인기 디지털 만화 서비스인 XOY와의 통합 때문이었습니다. XOY에서 연재하던 작품을 LINE Manga로 마이그레이션(migration)하고 전편 무료로 서비스하는 정책을 시행한 결과, 레코드 삽입이 급증했습니다. 비록 지연 자체는 일시적인 현상이었지만, 데이터가 예상했던 것보다 훨씬 많이 증가하는 바람에 이대로 가다가는 반년도 버티지 못할 것 같았습니다. 게다가 테이블이 비대해지면서 데이터베이스의 입출력 성능도 떨어졌다는 것을 알 수 있었습니다. 비대해진 테이블은 ‘연재 만화 알람 내역’, ‘구입 내역’, ‘연재 만화 즐겨찾기’이며 각 테이블의 특징을 설명하겠습니다.

연재 만화 열람 내역

만화 ‘읽음, 읽지 않음’ 표시 기능에 사용하며, 사용자가 한 편을 읽으면 하나의 레코드가 생성됩니다. 전편 무료 보기 캠페인 때문에 이 테이블에 대량의 레코드 삽입이 발생했습니다. ‘읽음, 읽지 않음’ 정보는 서비스 내 여기저기에서 활용되고 있습니다.

구입 내역

사용자가 사용료를 지불하고 구입한 단행본을 관리하는 테이블(무료 분량도 포함)입니다.

연재 만화 즐겨찾기

사용자의 즐겨찾기를 관리하는 테이블입니다. 사용자가 즐겨찾기를 해제하면 좋아하는 만화 구독이 취소됩니다.

한 번 제공하기 시작한 만화는 기본적으로 계속 서비스하지만, 갑자기 출판이 중단되는 경우에 대비해 이 테이블과 마스터 테이블을 조인(join)해서 사용합니다. 기본적으로 레코드 삭제 작업은 없으며, 사용자 수, 콘텐츠 수, 운용 기간에 비례하여 데이터가 증가하는 성질이 있습니다.
앞으로도 데이터가 계속 증가할 테니 스케일 업과 같은 사양을 높이는 방법으로는 해결하기 어려울 것으로 판단, 근본적으로 해결하기 위해 샤딩을 단행하게 되었습니다.

 

마이그레이션 방법 검토

NAVER 자체 제작 미들웨어 ‘NBase’ 사용

NBase는 무중단으로 샤드 데이터베이스를 추가할 수 있는 MySQL과 같은 미들웨어입니다. 하지만 NBase는 Java나 C용 API만 제공하기 때문에 Perl로 구현한 LINE Manga에서는 그대로 사용할 수 없습니다. 라이브러리를 이식하거나, LINE Manga를 Perl에서 Java로 재구현하는 방법은 필요한 공수를 생각했을 때 비현실적이어서 이번에는 도입을 보류했습니다.

MySQL Spider 엔진 사용

정보가 별로 많지 않고 사내에서 운영했던 사례도 없어서 보류했습니다.

이중 쓰기 방식

기존 데이터베이스와 샤드 데이터베이스를 미러링하여 양쪽 데이터베이스에 이중 쓰기(double write) 작업을 합니다. 이중 쓰기한 후에 읽기, 쓰기 순으로 기존 데이터베이스에서 샤드 데이터베이스로 보내는 비교적 정통적인 방법입니다. 구현을 많이 수정해야 하고, 이중으로 쓰기 처리를 하는 탓에 응답 시간이 저하되거나 데이터 부정합이 발생할 위험 요소가 있지만, 불확실한 요소는 적어서 이번에는 이 방법을 채택했습니다.

 

샤딩 적용 과정

실제로 적용하는 과정에서 많은 우여곡절을 겪었는데요. 크게 6단계로 나누어 계획을 세웠습니다. 단계를 나누니 애플리케이션의 수정 범위가 명확해져서, 구현 시 누락된 부분이나 리뷰할 때 확인해야 할 부분을 확실하게 알 수 있었고, 잘못 구현된 부분을 리뷰를 통해 거의 잡아낼 수 있었으며, QA(quality assurance) 공수도 줄어들었습니다.

1단계 조사 및 준비

서비스에서 사용하지 않는 테이블을 추려낸 후 읽기, 쓰기가 없는 테이블과 대조해서 삭제하는 리팩토링을 진행했습니다. 용량을 조금이라도 더 확보해서 시간을 벌고, 적절한 리팩토링으로 다음 단계에서 수정해야 할 부분을 줄이는 것이 이번 단계의 목적입니다. 또한 외부 키와 인덱스를 재검토하여 레코드 삽입 성능을 개선하는 작업도 이번 단계에서 진행했습니다.

2단계 이중화 구성

샤드용 데이터베이스를 준비해서 기존 데이터베이스의 복제를 구축하여 이중화했습니다. 이와 동시에 MySQL도 5.6에서 5.7로 버전을 업그레이드했습니다.

3단계 기존 데이터베이스와 샤드 데이터베이스 양쪽에 쓰기 처리

이번 샤딩 작업에선 샤드를 8분할하기로 결정했습니다. 쓰기 작업할 샤드에선 사용자의 ID(LINE Manga에서는 member_id)를 기반으로 계산하여 해시 슬롯 값을 정했습니다.

{
   my $hashslot = crc32(member_id) % 65535;
   # 0 ~ 8191 => 샤드1
   # 8192 ~ 16383 => 샤드2
   # 16384 ~ 24575 => 샤드3
   # 24576 ~ 32767 => 샤드4
   # 32768 ~ 40959 => 샤드5
   # 40960 ~ 49151 => 샤드6
   # 49152 ~ 57343 => 샤드7
   # 57344 ~ 65535 => 샤드8
}

8로 나눈 나머지가 아니라 65,535로 나눈 나머지로 해시 슬롯 값을 정하는 것이 이번 작업의 포인트입니다. 이 방법을 사용하면 샤드 수를 쉽게 증감시킬 수 있다는 이점이 있습니다(애플리케이션은 범위를 8분할에서 16분할로 바꾸기만 하면 되고, MySQL은 각 인스턴스를 2분할하면 됩니다).

3.5단계 파티셔닝 방식 등장

단계 3의 구현이 완료된 시점에서 MySQL의 generated column과 파티셔닝 기능을 조합하면 이중 쓰기할 필요가 없을 것 같다는 의견이 나왔습니다. 이미 구현과 QA까지 끝난 상태였지만 이중 쓰기로 인한 성능 저하는 가장 많이 우려하고 있던 사항이었고, MySQL의 generated column과 파티셔닝 기능을 조합한다는 방법이 재미있을 것 같아서 도전해 보고 싶은 마음에 과감하게 방침을 변경했습니다. 이 아이디어는 추후 발행될 ‘데이터베이스 엔지니어 편’에서 자세하게 설명할 예정이니 꼭 같이 읽어 보세요!

4단계 참조를 샤드 DB에만 할당하기

구현을 수정할 때 가장 어려웠던 부분이 바로 이 단계였습니다. 수정 패턴은 크게 3가지로 나뉩니다.

  • 사용자에게 연결된 레코드를 조회하는 작업처럼 수정 자체는 단순하지만 분량이 많은 작업
  • 판매 랭킹처럼 샤드에 흩어져 있는 데이터를 집약시켜 서머리(summary)를 만드는 배치 관련 작업
  • 마스터 테이블과 조인된 부분을 다시 분리하여 쿼리를 나누는 작업

특히 3번째 작업은 쿼리 종류도 매우 다양해서, SQL 실행 계획을 확인하면서 쿼리를 튜닝하는 작업과 로직 자체를 재검토하는 작업도 함께 진행했습니다.

5단계 쓰기를 샤드 DB에만 할당하기

3단계에서 진행했던 작업을 신중하게 수정했습니다. 또한 샤딩 대응과 병행해서 정기 릴리스 개발도 진행하고 있었기 때문에, 관련 부분의 수정 사항이 누락되지 않도록 주의해야 했습니다.

6단계 불필요한 레코드 삭제

시작할 때 모든 데이터를 미러링했기 때문에 불필요한 레코드가 남아 있을 것으로 예상했습니다. 따라서 이런 레코드를 삭제하는 작업이 계획에 있었지만, 파티셔닝 방법으로 변경하면서 삭제 작업 없이 넘어갈 수 있었습니다.

 

샤딩 적용 시의 과제

앞서 설명한 바와 같이 샤딩하면 마스터 계열 테이블과 조인하는 게 불가능합니다. 조인이 불가능해지니 책꽂이(本棚)의 연재 만화 리스트를 반환하는 API가 큰 고민거리가 되었습니다.

책꽃이의 연재 만화 리스트란?

LINE Manga에서는 출판사의 콘텐츠, LINE Manga의 오리지널 콘텐츠, 일반 작가가 올리는 인디즈 만화를 ‘연재 만화’로 분류합니다. 이런 만화를 즐겨찾기에 등록하면 작품이 업데이트될 때마다 LINE Manga의 LINE 공식 계정에서 알림을 받을 수 있습니다. 이렇게 즐겨찾기에 등록한 만화를 목록으로 볼 수 있는 기능이 바로 책꽂이(本棚) 기능입니다.

 

책꽂이 기능 과제

책꽂이에는 사용자가 업데이트된 작품을 쉽게 찾을 수 있도록 아직 읽지 않은 최신 편이 남아 있는 만화를 상위에 표시합니다. 기존에는 즐겨찾기한 만화, 만화의 최신 편, 최신 편 열람 이력을 모두 조인해서 정렬하는 방식으로 구현했는데요. 샤딩을 적용하니 만화의 최신 편을 조인할 수 없게 되었습니다. 정렬 조건이 ‘아직 읽지 않은 최신 편’이기 때문에 어떻게든 로직으로 문제를 해결해야 합니다.

대응 내용

조인이 불가능하기 때문에 샤딩된 테이블과 기존 데이터베이스 테이블에서 각각 조회해야 합니다. 하지만 최신 편을 전부 그때그때 데이터베이스에서 참조하는 방법은 데이터베이스에 발생할 부하 문제 때문에 적절하지 않을 것으로 판단했습니다. 그래서 최신 편의 데이터를 Redis에 저장한 후 HMSET()HMGET()으로 조회하는 로직으로 수정했습니다.

의사 코드(pseudocode)

대응 내용을 구현한 의사 코드입니다. 

sub set {
my $redis_key = 'subscription';
my @products;
my $productA = {
id => 'Z0000000',
name => '하드보일드 원아 우주군',
author => '후쿠보시 히데하루',
};
push @products, $productA->{id} => JSON::XS::encode_json($productA);
my $productB = {
id => 'Z0000002',
name => '메리 미!',
author => '유키 미쿠',
};
push @products, $productB->{id} => JSON::XS::encode_json($productB);
$redis->hmset($redis_key, @products);
}
sub get {
my $redis_key = 'subscription';
my @fields = ($productA->{id}, $productB->{id});
return [ map { JSON::XS::decode_json($_) } grep { $_ } @{$redis->hmget($redis_key, @fields)} ];
}

JSON을 인코딩, 디코딩하기 위한 비용은 발생하지만, 그때그때 데이터베이스를 참조하는 방법보다는 속도가 훨씬 빠릅니다. 벤치마킹해 보니 Cache::Memcached::Fast의 get_multi()set_multi()를 이용한 구현 방식보다 약 2배 정도 빨라서 Redis를 채택하게 되었습니다.

 

마치며

샤딩을 적용하니 기존 데이터베이스의 용량과 부하가 대폭 감소했고, API의 응답 속도도 전보다 빨라졌습니다. 앞으로 사용자가 꾸준히 증가해도 원활하게 사용할 수 있을 것으로 보입니다. 

LINE Manga는 전자 만화 서비스에서 압도적인 1위가 되는 것이 목표입니다. 계속 규모가 확장되는 서비스를 함께 즐겨 보아요! 부디 LINE Manga에서 인생 만화를 많이 만나시기를 바랍니다.