기술블로그

LINE 오픈챗 서버가 100배 급증하는 트래픽을 다루는 방법 - 본론2

ignuy 2023. 8. 29.

1억 6천명을 연결하는 글로벌 메신저 '라인'

서론에서 말했듯 LINE에서 활용하는 이벤트 기반 아키텍처의 개요는 아래와 같다.

🔥 오픈챗 서버에서는 메시지 전송과 메시지 리액션, 메시지 읽음 등과 같은 오픈챗 내 다양한 행위를 모두 이벤트로 간주하고 이벤트가 생성될 때마다 스토리지에 저장한 후 오픈챗에 참여하고 있는 모든 사용자에게 서버 푸시로 ‘새로운 이벤트가 생성됐으니 받아 가세요’라고 알린다. 서버 푸시를 받은 사용자(클라이언트)는 스토리지에 새로 들어온 이벤트를 페치(fetch) 이벤트 API로 받아가고 새 메시지 등을 화면에 추가하는 액션을 실행한다.

이번엔 핫 챗에서 급증하는 트래픽을 다룰 수 있는 LINE 만의 노하우 두 번째 방법을 알아보자.

🎮핫 챗에서 급증하는 트래픽을 다루는 방법

✅case2. 오픈챗 참여 요청 급증

라인 오픈챗은 직접 오픈챗을 검색하거나 추천 오픈챗을 통해 오픈 챗에 참여할 수 있고, 오픈챗 참여 QR 코드나 링크 공유를 통해서도 참여할 수 있었다. 2021년까지만 해도 한 오픈챗에 짧은 시간 동안 오픈챗 참여가 급격하게 몰리는 경우는 거의 없었는데 22년부터는 1초에 최대 2천 개의 오픈챗 참여 요청이 한 오픈챗으로 몰리는 경우가 발생했다. 소셜미디어 인플루언서들이 자신의 오픈챗 참여 QR코드를 소셜미디어에 업로드하는 등 오픈챗 서비스가 성장하며 점점 더 많은 곳에서 다양한 방법으로 활용되고 있기 때문이다.

오픈챗 서버는 오픈챗 참여 요청이 오면 챗 멤버 데이터를 MySQL에 저장한다. 라인은 오픈챗 참여 요청이 한 챗에 몰리자 특히 MySQL 부하가 급증하는 것을 확인하였다. 1초에 2천개 이상의 오픈챗 참여 요청이 한 챗에서 발생했을 때의 지표가 아래 사진에 나와있다. MySQL 1개 샤드에 INSERT 쿼리가 순간적으로 몰리고 느린 쿼리와 CPU 사용량이 급증하는 것을 확인할 수 있다.

이에 따라 오픈챗 서버는 응답 타임아웃이 발생하고, MySQL의 한 샤드로 전달되는 요청들의 처리가 지연되는 것(Slow query)을 확인할 수 있었다.

😀Solution.1 - MySQL 병목 지점 제거

이 문제를 해결하기 위해 우선 MySQL의 병목 지점들을 찾아서 해결했다.

첫째로 찾은 병목 지점은 바로 ‘챗 멤버 INSERT 쿼리’이다. MySQL에서는 오픈챗 참여 요청이 오면 해당 쿼리를 실행하는데, 이때 이미 가입된 사용자인지, 중복된 멤버 이름인지를 검사하기 위해 INSERT 쿼리 내부에서 다시 SELECT 쿼리를 사용하는 서브 쿼리를 사용하고 있었다.

INSERT 쿼리 내부에서 다시 서브쿼리를 사용하고 있다.

이와 같은 서브 쿼리는 간단한 INSERT 쿼리와는 달리 MySQL에서 벌크 INSERT 구문으로 취급한다. 이때 MySQL의 innodb_autoinc_lock_mode가 기본값이 1이라면 벌크 INSERT 구문은 AUTO-INCREMENT 값을 증가시키기 위해 테이블 락을 잡는다. 오픈챗 서버에서는 MySQL의 AUTO-INCREMENT 락(lock) 모델을 기본값(1)으로 사용하고 있기 때문에 한 오픈챗에 참여 요청이 급증하면 AUTO_INCREMENT 테이블 락 경합도 같이 급증하게 되는 구조이다.

벌크 INSERT 구문
MySQL에서 INSERT 해야하는 데이터가 많을 경우 대량으로 INSERT를 수행하게 해주는 SQL문이다. 다량의 튜플을 묶어 한번에 INSERT 하게 되는데 튜플 수가 많을수록 성능은 기하급수적으로 상승한다.

innodb_autoinc_lock_mode .0, .1
MySQL의 자동 증가 번호 기능인 AUTO-INCREMENT와 연관된 파라미터이다. 이 파라미터가 0이라면 전통적인 방식으로 동작한다. INSERT 범주에 들어가는 모든 문장은 테이블 수준의 AUTO-INCREMENT 잠금이 사용되며 쿼리문장이 끝날 때까지 유지된다. 이 경우 자동 증가 값은 연속으로 할당된다. 1은 디폴트 값으로 벌크 INSERT의 경우 AUTO_INCREMENT 테이블 레벨 잠금을 사용하며 문장이 끝날 때까지 잠금상태를 유지한다. 단순 INSERT(행이 몇 개 INSERT 되는지 아는 경우)는 요구되는 AUTO INCREMENT값을 획득함으로써 테이블 레벨 잠금을 피할 수 있다. 이는 문장의 처리가 완료될 때까지가 아닌 각 행이 처리되는 동안만 유지되도록 뮤텍스(경량 잠금)에 의해 제어되기 때문이다. 만약 INSERT하는 행수를 사전에 모르는 경우 임의의 INSERT 문에 할당된 모든 AUTO INCREMENT 값은 반드시 연속적인 값이 되기 때문에 statement 기반 복제에 사용해도 안전하다. 이로써 확장성이 크게 향상될 수 있다.

테이블 락
RDBMS는 Select 문을 제외하고 DELETE/INSERT/UPDATE 문에 대해서 테이블 락을 걸게 된다. 가령 A라는 사람이 DELETE Table; 이라는 명령문을 실행한 상태에서 커밋을 치지 않는다면 해당 커넥션이 아닌 다른 커넥션 즉 B, C, D라는 사람은 해당 테이블에 SELECT를 제외한 DELETE/INSERT/UPDATE를 수행할 수 없다.

AUTO-INC 테이블 락 경합(오른쪽 위에서 두번째 그래프)

실제로 한 오픈챗에 참여 요청이 몰렸을 때 MySQL 지표를 확인해 보면 AUTO_INCREMENT 테이블 락을 잡기 위한 경합이 굉장히 많았고 이 때문에 MySQL의 CPU 사용률이 100%에 도달해 응답 타임아웃이 발생하는 것을 확인할 수 있었다.

 

따라서 테이블 락 경합을 줄이기 위해 테이블 락을 잡지 않도록 MySQL의 AUTO-INCREMENT 락 모드를 기본값인 1에서 2(Inteleaved 모드)로 변경했다. 변경한 모드에서는 동시에 여러 개의 값을 INSERT할 때 AUTO-INCREMENT 값이 연속적이지 않을 수 있는데, 사용하고 있는 쿼리에서는 한 번에 한 명만 INSERT하고, 또 AUTO-INCREMENT 값이 연속된다고 가정한 로직이 없어서 락 모드를 변경해도 괜찮다고 판단하였다.

innodb_autoinc_lock_mode .2(Inteleaved 모드)
”INSERT-like” 구문에 대해서 테이블 레벨 락을 사용하지 않는다. 따라서 cocurrency가 가장 높지만 복구/복제용으로 SQL 바이너리 로그 replay를 사용하지 않고 있는 경우에만 사용이 가능하다. AUTO-INCREMENT 값의 유니크성, 단조 증가성은 보장되지만 동일한 쿼리를 실행하더라도 실행 순서에 따라 매번 rows들이 가지는 AUTO-INCREMENT값들은 달라질 수 있다. 또한 AUTO-INCREMENT 값에 Gap이 존재할 수 있다. simple INSERT가 수행될 때는 할당된 자동 증가 값에 gap이 존재하지 않겠지만 bulk INSERT가 수행될 때는 gap이 존재할 수 있다.

CPU 사용률 개선_위 그래프와 비교

그 결과 한 오픈챗에 1초에 수천 개의 참여 요청이 들어와도 MySQL은 CPU 사용률을 10~20% 사이를 유지하며 안정적으로 처리할 수 있었다.

 

두 번째로 찾은 SQL 병목 지점은 오픈챗에 참여하고 있는 멤버 수를 가져오는 쿼리이다. 해당 쿼리는 'state = JOINED'인 멤버 수를 집계하는 방식이다. 이전에는 오픈챗에 참여가 몰리는 경우가 거의 없어서 성능을 높이기 위해 오픈챗 멤버를 MySQL 쿼리 캐시를 이용해 캐싱해서 사용하고 있었다.

MySQL 쿼리 캐시
쿼리 캐시는 SELECT 명령문 텍스트를 클라이언트에 보내는 결과와 함께 저장한다. 만약에 동일한 명령문이 나중에 전달되면, 서버는 그 명령문을 기존 3단계(분석(parsing) → 최적화(Optimizing) → 실행(Executing)) 대신에 쿼리 캐시에서 그 결과 값을 추출한다. 쿼리 캐시는 자주 변경되지 않는 테이블이 있고 서버가 동일한 쿼리를 많이 받는 환경에서 매우 유용하게 사용된다. 하지만 만약 대상 테이블에 대한 변경(INSERT, UPDATE, DELETE)이 있었다면 상황을 고려하지 않고 기존의 캐시를 제거한다는 점은 치명적인 단점이다. 쿼리 캐시는 여러 세션(쓰레드)들이 공유하는 자원이기 때문에, 동기화를 위한 lock이 필요하다(query cache lock이라고 부른다). 즉, 테이블의 변경으로 인해 캐시를 제거하는 시점에, 다른 쓰레드에서는 이제 더 이상 유효하지 않은 데이터를 가져가지 못하도록 lock을 걸게 된다. 이 lock이 풀릴 때까지 쿼리 캐시에 접근하는 쓰레드들은 “Waiting for query cache” 상태에서 대기하게 된다. 따라서 테이블의 변경이 잦을수록, 쿼리 캐시를 사용하는 SELECT 쿼리가 많을수록 이 lock을 대기하는 시간은 많은 비중을 차지하게 된다.

하지만 1초에 2천 개 이상의 오픈챗 참여 요청이 몰리자 문제가 발생했다. 참여하고 있는 멤버 수를 캐싱하고 있는 MySQL 쿼리 캐시가 참여 요청 하나를 처리할 때마다 멤버 수 값을 갱신했는데 이 때 테이블 락을 잡고 멤버수를 재계산하며 쿼리 캐시를 갱신하기 때문에 MySQL에 큰 부하가 발생하였다.

해결법은 간단하다. 참여하고 있는 멤버 수 값을 집계하는 별도 테이블을 도입하고 MySQL 쿼리 캐시는 제거하는 것으로 해결하였다.

😀Solution.2 - 조인 스로틀링 적용

MySQL 병목 지점을 조사하는 중에도 오픈챗 참여 요청이 몰리는 핫 챗들은 지속적으로 발생하였다. 이에 MySQL 병목 지점을 조사하면서 동시에 오픈챗 참여 요청이 MySQL 처리 한계를 넘지 않도록 빠르게 조인 스로틀링을 적용하였다.

조인 스로틀링은 핫 챗 스로틀링과 비슷하게 오픈챗 참여 요청 처리를 완료할 때마다 Kafka로 이벤트를 전송해 퍼블리시 서버에서 각 챗 별로 몇 개의 조회 요청이 들어오고 있는지 버킷에 기록한다. 기록한 값이 설정해 둔 MySQL 처리량 한계를 넘으면 ‘잠시 뒤 다시 참여 요청해 주세요’라는 팝업 메시지를 반환하도록 구현했다. MySQL 병목 지점 해결 작업을 진행하고 있던 시점이었기 때문에 몰리는 참여 요청으로 MySQL이 사용 불능이 되는 것보다는 참여 요청이 몰린 핫 챗에만 잠시 스로틀링을 걸어서 다른 챗에 미치는 영향을 줄였다.

Kafka를 사용하는 조인 스로틀링은 항상 몇 초 정도의 지연이 발생할 수 있다. 참여 요청 완료 후 Kafka를 통해 퍼블리시 서버로 참여 완료 이벤트를 전달하려면 Redis와 MySQL, Kafka 등 거쳐야 할 스토리지가 많다. 이때 특히 Kafka는 핫 챗 때문에 특정 파티션의 오프셋 랙이 순간적으로 증가해 몇 초 정도의 처리 지연이 발생할 수 있다. 즉, 퍼블리시 서버 앞단에서 지연이 발생하면 조인 스로틀링도 그만큼 지연되는 구조인 것이다.

만약 1초에 수천 개의 오픈챗 참여 요청이 한 챗에 몰리고 있는 상황에서 조인 스로틀링이 몇 초 지연된다면 지연된 시간만큼 정상적으로 쓰로틀링 되지 않고 모조리 MySQL로 넘어가게 된다. 이렇게 조인 스로틀링이 지연되어 MySQL로 넘어간 요청들은 MySQL에 더 큰 부하를 일으켜 지연 시간을 증가시키고, 결국 조인 스로틀링을 더욱더 지연시킨다.

실제로 조인 스로틀링이 잘 작동하다가 몇 초 정도 지연된 적이 있었는데, 하필 그때 1초에 수천 개의 요청이 몰리면서 MySQL로 모든 요청이 넘어가 큰 부하를 발생시켰고 조인 스로틀링을 더욱더 지연시켜 결국 모든 참여 요청이 허용되며 MySQL이 처리 한계를 넘겼고, 응답 타임아웃이 발생한 적이 있다.

해결을 위한 방법은 여러 가지였다. 우선 조인 스로틀링에 Kafka가 아닌 Redis를 사용해서 오픈챗 참여 요청이 들어올 때마다 바로 Redis에서 참여 요청 수를 챗별로 집계하는 방식을 사용할 수도 있었는데, 이 문제도 앞서 설명한 것처럼 핫 챗은 0.1% 미만의 비율로 발생하는 경우이고 이를 위해 99.9%에 해당하는 일반적인 챗의 모든 참여 요청까지 Redis에 함께 기록하는 것은 큰 오버헤드라고 생각했다. 따라서 Redis가 아닌 로컬 캐시를 사용해 챗 별로 허용하는 참여 요청의 최대치를 제한하는 방식을 적용했다.

로컬 캐시
로컬 캐시는 서버마다 캐시를 따로 저장한다. 따라서 다른 서버의 캐시를 참조하기는 어렵지만 서버 내에서 작동하기 때문에 속도가 빠르다는 장점이 있다. 로컬 서버 장비의 Resource(Memory, Disk)를 이용하며 캐시에 저장된 데이터가 변경되는 경우에 해당 서버를 제외한 모든 peer에 변경 사항을 전달하고 WAS 인스턴스가 늘어나 캐시 저장 데이터 크기가 커지면 성능이 저하된다.

😀Solution.3 - 서킷 브레이커와 벌크헤드 도입

핫 챗 때문에 스토리지의 한 샤드의 처리 속도가 느려지거나 사용불능이 되더라도 다른 샤드로 가는 요청은 영향받지 않고 처리되도록 서킷 브레이커와 벌크헤드(bulkhead)를 도입하였다.

서킷 브레이커는 스토리지의 샤드별로 응답 타임아웃과 같은 에러가 많이 발생하면 요청들을 빠르게 실패로 처리한다. 벌크헤드는 하나의 샤드로 몰린 요청들이 스레드 풀을 독점하지 않도록 막아준다. 이를 통해 한 샤드에 요청이 몰릴 때 다른 샤드에 영향을 주지 않도록 부하를 격리할 수 있다.

 

댓글