#MySQL, #동시성, #반정규화

좋아요 개수 조회 최적화하기

속닥속닥 프로젝트(https://github.com/woowacourse-teams/2022-sokdak)를 진행하면서 현재는 문제가 되지 않지만 시간이 갈수록 데이터가 쌓이면서 문제가 될 수도 있겠다라고 생각한 부분이 있습니다. 게시글에 대한 ‘좋아요’ 기능인데요, 현재 구조에서는 게시글을 조회할 때, ‘좋아요’ 테이블과 함께 조인을 해서 가져오고 있습니다. 단순히 좋아요 개수만 저장하지 않는 이유는, 한번 좋아요 버튼을 누른 사람은 두 번째 누를 때 취소할 수 있어야 하기 때문입니다.

하지만 이런 방식이라면 만약 게시글 하나에 좋아요가 100만개가 된다면 게시글을 조회 한번 할때마다 100만개를 조인하게되어 오버헤드가 커지게 됩니다. 실제로 유튜브, 인스타의 경우 좋아요 개수가 100만개를 훌쩍 넘는 경우가 많은데요, 어떻게 이런 문제를 처리할 수 있는지 궁금했습니다. 현재 속닥속닥에는 ‘좋아요’ 개수가 딱 정확하게 나와야 하는 비즈니스적 요구 사항은 없지만, 게시글의 성격에 따라 충분히 발생할 수 있는 요구사항이라고 생각했습니다.

image

실제로 저희가 테스트를 해본 결과 게시글의 좋아요 개수를 조회하는데에만 0.68초가 걸렸습니다.

image

image

그래서 저희는 반정규화를 선택했습니다. post 테이블에 like_count 컬럼을 추가함으로서 likes 테이블을 조인하지 않고도 해당 게시글의 좋아요 개수를 알 수 있습니다. 이렇게 해서 해당 게시글의 좋아요 개수를 가져오는데 시간을 0.0013으로 줄일 수 있었습니다.

하지만 이렇게 되면 좋아요 개수에 대한 정보가 두 테이블에 나타나게 되어 데이터 정합성을 맞추는 것이 중요한 문제가 됩니다. 데이터 정합성이 안맞는다는 말에 대해 풀어보자면, 좋아요를 누를때는 like 테이블에서 삽입, 삭제가 일어나게 되는데, post 테이블의 like_count 에 대해서도 수정을 해주어야 됩니다. 한 가지 데이터에 대해, 두 테이블에서 수정이 이루어지니 데이터가 맞지 않을수도 있게 됩니다.

예를 들어 A 사용자가 좋아요를 누른다면, likes 테이블에 삽입을 해주고, post 테이블의 likecount에 기존 likecount + 1을 해줍니다. 그런데, 이 트랜잭션이 커밋되기 전에 B 사용자가 좋아요를 누른다면, 변화된 likecount 에 + 1 을 해주는 것이 아니라, 커밋되기 전 상태의 likecount에 + 1 을 해주어 데이터가 맞지 않게 되는 것입니다. 다시 말해, 좋아요가 0개인 상태에서 2명이 좋아요를 눌렀으므로 2개가 되어야 하는데, 1 개가 되는 것입니다. 이를 갱신 분실(Lost Update)라고 합니다.

image

image

화면 왼쪽은 A 트랜잭션, 오른쪽은 B 트랜잭션입니다. 자바 코드로 예시를 작성할 수도 있지만 보다 직관적으로 트랜잭션의 흐름을 보기 위해 MySQL 쿼리로 예시를 만들어봤습니다. 이 트랜잭션은 좋아요 개수를 조회하고, 좋아요의 개수를 1 증가하는 쿼리입니다. 처음 post의 likecount에는 1,000,001개의 데이터가 있었으므로 두 트랜잭션이 정상적으로 실행됐으면 1,000,003개가 되어야합니다. 하지만 A 트랜잭션에서 4번째 줄까지 실행하고 B 트랜잭션이 나머지를 실행하면 likecount는 1,000,002개로 하나의 write 요청이 다른 요청에 의해 덮어쓰여진 것을 알 수 있습니다.

1. Lock

image

image

첫번째로 락을 거는 방법을 생각했습니다. 조회 쿼리에 락을 걸어 데이터 정합성을 맞추는 것이죠. A 트랜잭션이 likecount 를 변경하는 업데이트 작업을 하고 커밋을 할 때 까지 B 트랜잭션은 likecount에 대한 조회에 대한 접근이 불가능합니다. 결과적으로 A 트랜잭션이 커밋 되고 나서 B 트랜잭션은 likecount를 얻기 때문에 정확한 likecount 데이터에서 1을 더하게 되고, 데이터 정합성이 맞게 됩니다. 기존 100,001 개의 좋아요 개수에서 두 트랜잭션이 커밋된 후에 100,003 개가 된 것을 볼 수 있습니다.

하지만, 락을 거는 방법은 서비스의 확장성을 고려했을 때 적절하지 않다고 생각했습니다. 조회에 대한 락을 걸게 되면 트랜잭션이 끝나는 것을 다 기다려야 하기 때문에 성능이 매우 나빠지게 될 것이기 때문입니다.

2. Native Query

image

image

두번째 방법은 네이티브 쿼리를 이용해 post 테이블의 like_count 데이터 자체를 읽어 1을 더해주는 것입니다. update 명령어는 그 자체로 atomic 합니다. 따라서 값을 읽고 그 읽은 값을 증가시키는 과정을 분리하지 않고 한번에 업데이트 하도록 하면 동시성 문제를 해결할 수 있습니다.

하지만, 한 트랜잭션이 업데이트를 진행하고 있는 경우에는 다른 트랜잭션에서 해당 row에 대해서는 update 작업을 하지 못합니다. 게시글 수정같이 업데이트가 빈번하지 않은 작업이라면 괜찮겠지만, 좋아요는 클릭 한번으로 수정이 되는 변경이 쉬운 작업입니다. 여러 사용자가 동시에 좋아요를 여러번 누르는 상황이 발생한다면, 서버에 무리가 갈 것이라고 판단되었습니다.

3. Sync Schedule

세번째 방법은 특정 주기마다 좋아요 개수를 맞추어 주는 것입니다. 처음에 문제였던 것이 동시에 사용자 요청이 들어올 때 같은 likecount 를 얻어 좋아요 개수가 맞지 않는다는 것인데, 이렇게 맞지 않았던 데이터를 주기적으로 한번씩 맞추어 주는 것입니다. likecount 는 데이터가 정확하지 않더라도, like 테이블을 통해서 정확한 좋아요 개수를 얻을 수 있기 때문에 가능한 방법입니다. 이 방법을 사용한다면 like_count 에 대한 접근을 할 때 다른 트랜잭션이 커밋되기를 기다리지 않아도 되기 때문에 성능적으로 이점이 있습니다.

그러나 매 주기마다 데이터 정합성이 맞추어 지기는 하지만, 업데이트 주기가 오기 전까지는 데이터가 맞지 않을 수 있기 때문에, 정확한 좋아요 개수를 요구하지 않는 환경에서 사용하기에 좋다고 생각됩니다. 이렇게 시간이 지나서 최종적으로 같은 데이터로 동기화하는 것을 궁극적 일관성(Eventual Consistency)라고 합니다.

image

실제 인스타나 유튜브를 보면 좋아요 개수가 한 자리 수까지 정확하게 표시되지 않습니다. 일정 크기의 단위로 끊어서 표시하는데요 여기에는 사용자의 편의성을 배려한 이유도 있을 수 있습니다. 실제로 사용자들은 좋아요가 몇 개인지 한 자리 수까지 그렇게 궁금하지 않을 것입니다. 이런 경우라면 sync schedule을 이용해 성능을 최적화 할 수 있을 것입니다.

결론

저희 속닥속닥은 마지막 방법인 Sync schedule 방법을 선택했습니다. 락을 활용한 방법과 네이티브 쿼리를 사용한 방법은 성능적으로 문제가 발생할 것으로 판단되었기 때문입니다. Sync Schedule 방법은 업데이트 주기가 오기 전에는 데이터의 정합성이 맞지 않아 사용자에게 좋아요 개수가 부정확하게 표시될 수 있습니다. 하지만, 속닥속닥은 좋아요 개수가 일시적으로 부정확하게 표기 되더라도 큰 문제가 되지 않는다고 생각했습니다.

속닥속닥은 Sync Schedule 방법을 선택했지만, 이 방법이 정답인 것은 아닙니다. 상황에 따라 적합한 방법을 택하는 것이 좋은 서비스를 만드는 길이라 생각합니다.

참고 및 출처

https://en.wikipedia.org/wiki/Concurrency_control

https://en.wikipedia.org/wiki/Eventual_consistency