100만 건 이상의 데이터를 처리하기 위한 방안에 대한 이야기
현재 진행 중인 프로젝트에 대해서 가볍게 설명하자면, 네이버 스트리밍 플랫폼인 치지직의 서드파티로서 채팅 수집 및 통계를 내는 사이트를 개발 중에 있다.
채팅이 가장 활발하게 이뤄지는 저녘 시간대는 스트리머 방송마다 초당 10건~50건 정도의 채팅이 발생하고 모든 스트리머를 수집한다고 하면 그 데이터의 양은 천차만별로 달라지게 된다.
채팅을 수집하고 그 데이터를 토대로 통계를 실시간으로 내려고 하다보니 발생하는 문제는 성능에 있다.
현재 서버는 AWS Lightsail에 웹 서버와 DB, 스토리지를 위탁한 상태이다. EC2의 경우 사용량에 비해 너무 많은 금액이 나오는 문제가 있어 Lightsail로 옮겨왔지만, 최근 수집하는 데이터의 양을 늘리고 난 뒤부턴 문제가 되어왔다.
어디선가부터 병목현상이 발생하기 시작했는데, 일단 채팅 테이블의 ROW가 100만 건을 돌파해버린 것이고
이 채팅 테이블은 Transaction이 매우 활발하게 이뤄진다는 점이다.
초당 100건 이상의 데이터가 INSERT되고, 사이트에 접속 할 때마다 SELECT가 이루어지는 형태.
다행히 DELETE나 UPDATE는 발생하지 않는다.
데이터 수집은 아래와 같은 패턴으로 진행되는데.
1. 채팅수집 엔진을 통해 메모리에 채팅을 저장한다.
2. 1분 마다 엔진에 데이터를 요청하며 데이터가 정상적으로 조회되었다면 엔진의 메모리를 비운다.
3. 수집된 데이터는 전부 DATABASE에 반복문을 통해 저장한다.
실시간으로 발생하는 후원 랭킹이나 채팅들을 보여주고 싶었고 이 채팅 수집의 텀은 길어도 5분을 넘기고 싶지 않다는 생각을 가지고 있어 1분이라는 시간을 정해둔 것이다.
사실 채팅 그 자체는 큰 의미가 없는 데이터일 수 있지만, 하루에 몇 건의 채팅이 발생하는지. 욕설을 하는 유저는 몇 명인지. 이들이 다른 방송에서도 활동하고 있는지에 대한 정말 흥미위주의 데이터들을 갖고싶었다.
그러다보니 모든 데이터를 수집하게 된 게 병목현상의 주된 원인이 아닌가 짐작이 된다.
일단 병목현상을 줄이기 위해 무엇을 해야할지 정리를 해보면
1. 특정 채팅에 대해서만 수집하도록 한다. = Transaction이 일어나는 절대적인 양을 줄인다.
2. INSERT의 텀을 늘리고 한번에 Multiple Insert를 하는 방안을 찾아본다.
3. 필요한 데이터 외에 모든 데이터를 지운다. = SELECT 성능 향상
4. 하드웨어의 성능 자체를 올린다.
5. 그 외 병목이 발생할만한 로직들을 최적화한다.
데이터를 건드리지 않고 가장 먼저 할 수 있는 2번을 먼저 적용하였다.
Transaction이 줄어들자, 전체적인 사이트 성능이 눈에 띄게 개선되었고 사용되는 네트워크 리소스도 줄어들었다.
아래 그래프와 같이 queue depth(요청온 I/O를 저장하는 개수)가 현저히 줄었다. queue depth가 쌓여있다는 것은 요청을 처리하지 못하고 계속 transaction이 발생한다는 것과 같아 보인다. (추정) 따라서 이 값은 0에 수렴할 수록 좋은 것이다.
다만 SELECT 성능은 여전히 나오지 않았다. 당연하게도 100만 건 이상의 데이터는 그대로 남아있을 뿐더러 지금도 추가되고 있다.
그렇다면 단순히 데이터가 많아서 느린 것일까? 정확한 문제파악을 위해 현재 쌓아놓은 더미데이터를 전부 삭제하고 30분 정도 데이터를 수집하여 3만 건 정도의 데이터로 SELECT 성능을 테스트해보았다. 여전히 매우 느리다. 단순히 데이터 양 때문이 아닌 것 같다.
테스트 당시 쿼리
SELECT * FROM "channelChatLog" ccl
ORDER BY ccl."createdAt" DESC
DB Tool을 통해 SELECT를 해보아도 8초 가량의 시간이 걸리는 것은 뭔가 이상하다. 아무런 JOIN도 걸려있지 않은 Qurey가 8초라는 건 무언가 잘못되었다. 다시 보니 모든 쿼리에 ORDER BY createdAt DESC 이 적용되어 있는 것이 뭔가 이상하여 EXPLAIN SELECT로 확인해보니 문제는 ORDER BY였다. 허무하게도 createdAt을 INDEX 적용하므로서 성능 문제 해결되었다.
CREATE INDEX idx_channelChatLog_createdAt ON "channelChatLog" ("createdAt");
INDEX 적용 후 대부분의 쿼리가 정상화 되었으나 아래와 같이 jsonb에 저장된 값을 정렬하는 경우에는 INDEX를 걸기가 굉장히 애매해졌다. jsonb에 저장된 payAmount를 꺼내와서 정렬하는 방식인데. 이 경우 payAmount를 별도로 저장하는 것이 나은지.
이럴 때를 대비해 json 형식이 아닌 정렬이 가능한 jsonb 방식으로 추가를 해두었던 것인데.
jsonb를 정렬하는 것이 나을지, 컬럼을 새로 추가할 것인지에 대한 고민이 필요해졌다.
query
.groupBy('ccl.nickname')
.orderBy('"donateAmount"', 'DESC')
.select(
`(CASE WHEN ccl.nickname IS NULL THEN '익명의 후원자' ELSE ccl.nickname END)`,
'nickname',
)
.addSelect("sum((ccl.extras -> 'payAmount')::int)::int", 'donateAmount')
.addSelect('count(*)::int', 'donateCount');
만약 INDEX를 설정한다면 아래와 같이 다중 컬럼을 INDEX로 설정 할 수도 있는데. 현재 postgres 버전의 이슈인지 저 방법대로는 설정되지 않았다.
CREATE INDEX idx_channelChatLog_createdAt ON "channelChatLog" ("createdAt", "extras" -> 'payAmount');