개요
최근 진행한 프로젝트에서 동시성 문제로 인한 에러를 경험했습니다. 게시글에 '좋아요'를 토글할 수 있는 기능이 있는데, 사용자가 이 버튼을 빠르게 연타하면 MySQL에 동일한 Unique Key로 insert를 시도하게 되면서 중복 엔트리 예외가 발생하는 케이스였습니다.
처음에는 조회 쿼리에 배타적 Lock을 추가해서 해결했습니다. 에러를 해결하는게 우선이니, 가장 쉽고 빠른 방법을 선택했습니다. 그런데 Lock으로 해결하는 방식은 최선의 방법이 아니었다고 생각했습니다. Lock으로 인해 불필요한 DB 부하가 발생하고, 무엇보다 응답 시간이 길어져 사용자 경험에 악영향을 끼친다고 느꼈기 때문입니다.
그래서 동시에 들어오는 요청들을 순서대로 처리해주는 메시지 큐 시스템을 도입했습니다. 요청에 대한 성공 응답은 즉각 보내주어 사용자 경험을 향상시키고, 데이터 변경 로직은 비동기로 처리하였습니다.
메시지 브로커로는 RabbitMQ를 선택했습니다. 예제도 많고, 요즘 많이 쓰는 Kafka보다 러닝 커브가 낮고 기본적인 설정이 쉽다는 점 때문에 선택했습니다. 하지만 이 글에서 RabbitMQ의 설치법이나 연동 방법 등은 다루지는 않겠습니다. RabbitMQ에 대한 내용보다는 '메시지 큐' 개념 자체에 연관된 내용만을 다루기 때문입니다.
동시성 문제 발생 상황
앞서 얘기한 좋아요 토글 기능은 다음과 같은 로직으로 동작합니다.
function 좋아요_토글(user, post):
like = 사용자와게시글로_좋아요_찾기(user, post)
if like exists:
return 좋아요_삭제(like)
else:
return 좋아요_추가(user, post)
function 사용자와게시글로_좋아요_찾기(user, post):
// 데이터베이스에서 사용자와 게시글에 해당하는 좋아요 정보를 조회
function 좋아요_삭제(like):
// 기존 좋아요 정보를 삭제하고 결과 반환
function 좋아요_추가(user, post):
// 새로운 좋아요 정보를 생성하고 저장한 후 결과 반환
이때, 유저가 첫 번째로 요청한 Toggle 트랜잭션이 끝나기 전에 한번 더 요청을 하게 된다면 문제가 발생합니다.
만약 첫번째 요청 시점에 레코드가 존재하지 않아 데이터를 Insert 하는 트랜잭션이 실행되었다고 가정했을 때, 이 트랜잭션이 commit 되기 전에 두 번째 요청이 들어와서 Select를 시도한다면, 첫 번째 요청에서 Insert 된 데이터를 아직 조회할 수 없기 때문에 또다시 Insert를 시도하게 될 것입니다. 그 사이에 첫번째 요청이 commit 되어버렸다면, 중복된 PK를 갖고 있는 데이터가 Insert 되려 하기 때문에 다음과 같은 예외가 발생하게 됩니다.
java.sql.SQLIntegrityConstraintViolationException: Duplicate entry {unique key 값} for key {unique_key 명}
이 문제를 처음 발견했을때는 조회 쿼리에 Lock을 걸어서 예외가 발생하지 않도록 했습니다. 하지만 이는 장기적으로 봤을 때 리팩토링 대상이 될 수밖에 없다고 생각했습니다. 이 문제는 Lock으로 해결하는 것이 최선인 상황이 아니라고 생각했습니다. Lock을 사용하는 쿼리로 인해 응답 레이턴시가 길어지고, 이는 사용자 경험 저하로 이어지게 됩니다. 그리고, Lock은 DB에 부하를 줍니다. 당연히 Lock을 사용해야 하는 상황도 있겠지만, 불필요한 상황에 Lock을 사용해서 문제를 회피하는 것은 말 그대로 DB에 불필요한 부하를 주게 되는 것이라고 생각합니다.
메시지 큐로 처리
그래서 메시지 큐를 도입해서 위와 같은 동시성 문제를 해결했습니다. 말 그대로 큐(Queue)의 특성으로 인해, 사용자가 요청을 보낸 '순서대로 하나씩' 처리를 할 수 있게 됩니다. 그래서 다수의 트랜잭션이 동시에 같은 데이터 레코드에 접근 또는 조작하려다가 문제가 되는 상황을 방지할 수 있게 됩니다.
앞서 말한 케이스를 메시지 큐로 처리하도록 변경하게 되면 흐름은 다음과 같이 변경될 것입니다.
사용자가 Controller 에게 요청을 보내면, 메시지 브로커의 Queue에 메시지를 적재한 뒤 곧바로 정상 응답을 반환합니다. 복잡하거나 시간이 오래 걸리는 처리는 모두 Consumer로 넘겨, 비동기로 처리합니다. 이제 사용자는 좋아요 버튼을 클릭하면, 네트워크 환경이나 다른 문제가 없다면 거의 즉시 응답을 받을 수 있게 됩니다. 추가로, 프론트에서 좋아요의 UI 상태를 낙관적 업데이트로 처리하면 사용자는 레이턴시를 거의 느끼지 못하게 됩니다.
메시지 큐는 하나의 메시지에 대한 처리가 완전히 끝난 뒤 다음 메시지를 처리하기 때문에, 다수의 트랜잭션이 동시에 충돌하여 발생하는 문제를 예방할 수 있게 도와줍니다. 그리고 '좋아요' 기능의 경우는 사용자가 버튼을 10번 누르던 100번 누르던, 가장 마지막으로 눌렀을 때의 상태만 최종적으로 DB에 저장되어 있으면 비즈니스 요구사항을 충분히 만족할 수 있습니다. 따라서 비동기로 처리하여도 사용성을 해치지 않아, 비동기로 처리하기 좋은 대표적인 케이스라고 볼 수 있습니다.
마치며
동기 방식으로 동작하던 Toggle 요청을 메시지 큐 기반으로 비동기로 처리하도록 변경하여, 동시성 문제를 해결해보았습니다. 이번 기회에 메시지 큐 시스템을 처음 접해보고 공부해 보았는데, 메시지 큐를 사용하는 이유와 그 효과를 더 잘 이해하게 되었다고 생각합니다. 역시 직접 겪어보는 것이 중요하다는 것을 또 한 번 느낍니다.
메시지 큐를 이용해서 데이터를 처리하게 되면, 자연스럽게 데이터 처리 과정이 비동기로 동작하게 됩니다. 저는 주로 클라이언트 프로그램을 개발해왔던 사람으로서, '백엔드 API를 비동기로 동작시킨다'라는 말 자체가 조금 이해하기 어려웠던 부분이 있었는데, 제가 이전까지는 '비동기'라는 개념 자체에 대해 깊게 알고 있지 못했기 때문이었던 것 같습니다. 제 머릿속에 들어있던 비동기라는 개념은, 'API 호출을 `async-await`으로 처리한다' 정도에 머물러있었거든요. 이번 이슈 대응 과정에서 '비동기'라는 개념 자체에 대해 더 잘 알게 되었다는 점이 저 개인에게는 가장 큰 성과였습니다.
앞으로는 백엔드 시스템을 설계할 때, 메시지 큐 기반 비동기 처리를 적극적으로 고려할 수 있을 것 같습니다. 비동기 처리를 '왜'하는지에 대해서 잘 알게 되었고 비동기로 처리하기 좋은 예시도 알게 되었으니, 반대로 비동기로 처리하면 안되는 경우에 대해서도 알게 된 것 같습니다. 역시 '왜'만큼 중요한 게 없다는 것을 다시 한번 되새기며, 글을 마칩니다.
'Server' 카테고리의 다른 글
BCrypt 암호화가 안전한 이유? (1) | 2025.03.08 |
---|