성능과 동시성을 고려하여 게시글 좋아요 시스템 최적화하기

밀크야살빼자·2024년 3월 11일
0

아이돔 프로젝트에서 좋아요 수를 조회하는 부분을 리팩토링할 때, 더 효율적인 방법이 있을지 고민했습니다. 현재 시스템에서는 postLike 테이블을 조인하여 isDeleted가 false인 데이터를 가져오는 방식을 사용하고 있습니다. 좋아요를 추가하거나 취소할 때 해당 행의 상태를 true와 false로 표현하고 있습니다.

하지만 이 방식은 게시글이 많은 좋아요를 받을 경우, 모든 행을 반복하여 isDeleted가 false인 것만 필터링하는 작업이 번거로워지며, 좋아요한 게시글을 찾을 때에도 모든 행을 확인해야 하므로 성능 문제가 발생할 수 있습니다. 이를 해결하기 위해 의도적으로 반정규화를 도입하여 Post 테이블에 likeCount 필드를 추가했습니다. 이렇게 하면 필요할 때마다 해당 필드를 직접 조회할 수 있습니다.

실제 유튜브와 같은 플랫폼에서는 좋아요 개수가 10만 개를 넘는 경우가 많기 때문에, 대용량 데이터를 효율적으로 처리하기 위한 다양한 전략을 조사하며 동시성 문제에 대한 솔루션도 함께 살펴보았습니다.

0. 동시성 개념

  • 어떤 두 사건이 같은 시간에 일어나는 것입니다.

예를 들어,

  1. User A가 게시글A에 좋아요를 클랙했을 때 likeCount 조회(likeCount = 0)
  2. UserB도 게시글A에 좋아요를 클릭했을 때 likeCount 조회(likeCount = 0, A의 작업이 아직 커밋되지 않아 likeCount는 0이 된다)
  3. User A가 작업을 커밋하여, 좋아요 수 + 1 (likeCount = 1)
  4. UserB도 작업을 커밋하여, 좋아요 수 + 1 (likeCount = 1)

두 사용자가 동시에 좋아요를 클릭하여 최종 likeCount가 2가 되어야 하는데, 각 사용자가 좋아요 수를 조회했을 때 likeCount가 0이므로 2 대신 1이 증가되어 최종 결과가 +1이 되는 동시성 문제가 발생했습니다.

1. Lock

낙관적 락과 비관적 락은 동시성 제어를 위한 두가지 주요 전략 중 하나로 사용됩니다. 낙관적 락은 트랜잭션이 애초에 발생하지 않는다고 가정한 방식으로, 실제로 충돌이 발생했을 시점에 예외를 처리하고 다시 시도해야 하는 방식으로, 빈번한 업데이트가 발생하는 비즈니스 로직에는 적합하지 않습니다. 반면, 비관적 락은 충돌이 빈번히 일어난다고 가정하여 사용하는 방식으로으로, 조회할 때부터 쿼리에 락을 걸어 데이터 정합성을 유지하는 방식입니다. 예를 들어, A 트랜잭션이 좋아요를 누르는 도중에 B 트랜잭션이 동시에 접근하더라도 B 트랜잭션은 A 트랜잭션이 완료될 때까지 대기하게 됩니다. 이로써 A 트랜잭션이 좋아요를 증가시키고 나면, B 트랜잭션은 업데이트된 좋아요 수를 읽어 올 수 있게 됩니다.

하지만 이 방식은 해당 자원에 대한 다른 트랜잭션의 접근을 막게되어, 동시성이 감소하고 성능이 저하될 수 있으며, 특히 큰 규모의 트랜잭션이나 빈번한 동시 업데이트가 발생하는 likeCount에서는 성능 문제가 더 심화될 것입니다. 또한, 여러 트랜잭션이 서로 다른 자원을 기다릴 때 데드락이 발생할 수 있어 서로를 대기하면서 무한 대기 상태에 빠지는 상황이 발생할 수 있습니다.

💡 데드락
두 개 이상의 프로세스나 스레드가 서로 자원을 얻지 못해서 다음 처리를 하지 못하는 상태로, 무한히 다음 자원을 기다리게 되는 상태를 뜻합니다.
시스템적으로 한정된 자원을 여러 곳에서 사용하려고 할 때 발생합니다.
예를 들어, 헬스장에서 A와 B 두 사람이 각자 원하는 운동 기구를 사용하려고 합니다. A는 기구 A를, B는 기구 B를 사용하고 싶어합니다. 그러나 양측 모두 소심해서 상대방과 의사소통하지 않고, 서로 눈치만 보며 기다리고 있습니다. 이로 인해 둘 다 자신의 원하는 기구를 사용하지 못하고 있는 상황을 교착 상태라고 합니다.

2. Native Query

update 명령어는 원자성을 가지고 있어서 조회한 데이터 값과 그 값을 증가시키는 과정을 분리하지 않고 한 번에 업데이트합니다. 하지만 이로 인해 한 트랜잭션이 업데이트를 수행 중인 동안 다른 트랜잭션은 해당 행에 대한 업데이트를 진행하지 못 합니다.

게시글 수정과 같이 빈번하게 발생하지 않는 작업에는 적합할 수 있지만, 좋아요와 같이 클릭 한 번으로 수정이 빈번하게 발생하는 작업에는 서버에 부담이 될 수 있습니다. 특히 여러 사용자가 동시에 좋아요를 누르는 상황이 발생한다면 서버에 부하가 걸릴 수 있습니다.

3. Sync Schedule

일정 주기마다 좋아요 개수를 조정하는 방법은 likeCount가 완전히 정확하지 않더라도, like 테이블을 통해 정확한 좋아요 개수를 얻을 수 있습니다. 이 방식은 likeCount에 접근할 때 다른 트랜잭션이 커밋되기를 기다릴 필요가 없으므로 성능적으로 이점을 가집니다.

그러나 업데이트 주기가 오기 전까지는 데이터가 일치하지 않을 수 있기 때문에, 정확한 좋아요 개수가 필수적이지 않은 환경에서 사용하는 것이 적합합니다. 이런 방식은 시간이 지남에 따라 최종적으로 동일한 데이터로 동기화되는 궁극적 일관성의 예입니다.

실제로 유튜브와 같은 플랫폼에서는 좋아요 개수를 일의 자리수까지 정확하게 표시하지 않습니다. 대신, 일정 크기의 단위로 끊어서 표시하여 사용자의 편의성을 높이고 있습니다. 사용자들이 보통 일의 자리 숫자까지 정확한 정보를 필요로하지 않는다고 판단되는 경우 Sync Schedule을 통해 성능을 최적화할 수 있습니다.

4. 캐싱

캐싱은 데이터에 접근하는 속도를 향상시키기 위한 중요한 전략 중 하나입니다. 특히 좋아요 개수와 같은 빈번하게 조회되는 데이터는 캐싱을 통해 성능을 높일 수 있습니다.

캐싱은 데이터를 메모리나 분산 시스템에 저장하여, 매번 데이터베이스에서 읽어오는 않아 데이터베이스에 대한 부하를 감소시켜 응답 시간을 단축합니다. 또한, 좋아요와 같은 빈번하게 조회되는 데이터를 캐싱하면, 반복적인 데이터베이스 조회를 피하고 더 빠른 속도로 정보를 제공할 수 있습니다.

하지만 메모리나 분산 시스템에 데이터가 없는 상황이라면 동시성 문제가 발생할 수 있다.

5. 선택

아이돔에서는 정확한 좋아요 개수가 중요하여 정합성을 유지하면서 데이터를 보여줄 수 있는 비관적 락을 선택했습니다. 아이돔에서 낙관적 락을 선택하지 않은 이유는 낙관적 락은 데이터 변경이 많지 않을때를 가정하여 동시 접근을 허용하는 방식으로 좋아요와 같이 빈번하게 수정이 일어나는 경우에는 적합하지 않다고 판단하였습니다. Native Query를 사용한다면 동시에 많은 좋아요가 발생할 경우 성능 문제가 발생할 우려가 있었고, Sync Schedule을 이용한 방식은 일정 주기가 오기 전에는 데이터의 정합성이 보장되지 않아 사용자에게 정확한 좋아요 개수를 줄 수 없다고 판단하였습니다. 또한, 실제 운영 경험상 좋아요 개수로 인한 트래픽이 크게 발생시키지 않아 비관적 락을 사용해도 괜찮다고 판단했습니다.

6. 적용

좋아요 조회 쿼리 성능 개선

리팩토링 전 아이돔 게시글의 좋아요 수 조회 쿼리는 post_liked_member 테이블을 조인하여 수행되었고, 리팩토링된 코드에서는 단순히 post 테이블을 스캔하여 조회하는 방식으로 변경되었습니다. 아래는 1000개의 데이터를 가지고 쿼리 성능을 비교한 결과입니다. 여기서 cost 값은 쿼리 실행 비용을 추정한 값으로, 작을수록 성능이 우수합니다. 리팩토링 전 코드의 쿼리는 cost 값이 상대적으로 높으며, 쿼리의 actual time도 길어 단순 조회보다 성능이 떨어진다는 결과를 확인했습니다.

- 리팩토링 전

  • post
    • (cost = 124, row =1) → 해당 쿼리의 총 비용이 124, 예상 되는 행 수 1행
    • actual time = 0.611..0.611 rows = 1 loops = 1 → 실제 쿼리 실행에 걸린 시간이 0.611초에서 0.611초 사이, 결과 행 수는 9행
  • post_liked_member
    1. post_liked_member의 is_deleted 컬럼이 false인 것
    • cost = 99.1, rows = 489
    • actual time = 0.0469..0408 rows = 544 loops = 1
    1. 테이블 스캔
    • const = 99.1 rows = 978
    • actual time = 0.043..0.335 rows = 978 loops = 1

- 리팩토링 후

  • post
    • cost = 0.35 rows = 1
    • actual time = 0.607..0.0694 rows = 1 loops = 1

실제로 리팩토링된 코드에서는 좋아요 수를 가져오는 데 걸리는 총 시간이 0.00135700에서 0.00056500으로 약 58.34% 감소했습니다.

비관적 락을 적용한 좋아요 동시성 이슈 해결

- 테스트 코드

- Meter 동시성 테스트

락 걸었을 경우락을 걸지 않았을 경우

JMeter로 총 100번의 요청이 수행되었습니다. 평균적으로 락이 걸렸을 때 응답시간이 0.08초이며, 락을 걸지 않았을 경우에는 0.28초 정도 소요되었습니다. 이를 통해 락이 걸린 상황에서의 응답시간이 락이 걸리지 않은 상황보다 훨씬 더 빠르다는 것을 확인할 수 있었습니다.

또한, 10명•100명이 동시에 좋아요를 누를 경우, likeCount가 10•100이 되어야 합니다. 그러나 비관적 락을 걸지 않았을 경우,여러 트랜잭션이 병행적으로 실행되면서 각 트랜잭션의 좋아요 수를 동시에 독립적으로 읽고 쓰게 되어 likeCount가 정확하지 않게 나오는 것을 확인할 수 있었습니다. 이는 동시성으로 인한 경합 조건으로 인해 각 트랜잭션이 서로의 작업을 덮어쓰는 문제가 발생한 것입니다.

반면, 비관적 락을 걸었을 경우, for update로 쿼리가 정상적으로 수행되는 것 뿐만 아니라 결과도 요청한 수와 동일한 값을 반환하는 것을 확인할 수 있었습니다. 이는 특정 트랜잭션이 데이터를 변경 중일 때, 다른 트랜잭션이 해당 데이터에 대한 락을 획득하여 대기하게 되어, 데이터의 일관성을 유지하면서 정확한 결과를 얻을 수 있었습니다.

그러나 그럼에도 불구하고 데드락 문제가 발생할 가능성이 있다면, 확장 가능성을 고려하여 데이터베이스 트랜잭션 관리와 Sync Schedule을 도입하는 등을 계획하여 적용할 것입니다.


참고 자료

profile
기록기록기록기록기록

0개의 댓글