이벤트 버스
OCC에 대해 알아보기 전 이벤트 버스를 알아야 한다. 이벤트 버스는 각 서비스에서 발생하는 알림이나 이벤트를 처리한다. 알림이나 이벤트는 애플리케이션 내에서 어떤 일이 일어났거나 일어나야 할 일을 설명하는 메모와 같다. 각 서비스는 이벤트 버스에 연결되며 이벤트를 발생시키거나 이벤트 버스로부터 이벤트를 수신할 수 있다. 여기서 알 수 있는 점은 모든 서비스가 하나의 공통된 것에 연결되어 있다는 것이다. 즉, 이벤트 버스는 단일 장애 지점이다. 따라서, 이벤트 버스를 배포할 때는 시스템이 강건하고 다운되지 않도록 하여 서비스 간의 통신을 제한하지 않도록 해야한다. 이벤트 버스는 자동으로 들어오는 이벤트를 처리하고 해당 이벤트를 처리할 수 있는 다른 서비스로 라우팅할 수 있다. 이벤트는 어떤 종류의 이벤트가 발생했는지 설명하는 유형이 있을 수 있으며 이와 관련된 데이터나 컨텍스트 정보도 포함될 수 있다.
NATS Streaming
메시지 브로커는 시스템, 애플리케이션 또는 서비스 간의 통신을 조정하는 소프트웨어로 메시지를 번역, 라우팅, 변환 그리고 저장하는 작업을 처리하여 적절한 수신자에게 전달한다. 생산자와 소비자를 분리할 수 있으며 비동기적으로 통신이 가능해져 확장성과 내결함성을 높일 수 있다. NATS Streaming은 일종의 메시지 브로커로 가벼운 NATS 메시징 시스템을 기반으로 한다. 물론 대표적인 메시지 브로커는 단연 Apache Kafka와 RabbitMQ인데 학습 장벽이 높다고 하여 일단 간단하게 사용할 수 있는 NATS Streaming을 프로젝트에 적용했다.
Channel
NATS Streaming의 채널에 구독한 구독자(subscriber)는 이벤트 발생 시 메시지를 받을 수 있으며 모든 이벤트를 기본적으로 인-메모리에 저장한다. 플랫(flat) 파일로 디스크나 MySQL, PostgreSQL과 같은 데이터베이스에 이벤트를 저장할 수 있다. 예를 들어, 리뷰 서비스가 NATS Streaming 서버로 이벤트를 전송하면 그 이벤트는 메모리에 저장되는 동시에 이벤트의 복사본이 관련 서비스들에게 전송된다. 다른 관련 서비스가 연결되면 NATS Streaming 서버에 접속하여 놓쳤던 모든 이벤트의 복사본을 요청할 수 있다. NATS Streaming 서버는 해당 서비스가 놓친 관련 이벤트들을 모두 응답으로 보내준다. 이러한 방식은 플랫 파일, MySQL, PostgreSQL에 이벤트를 저장할 때도 동일하게 적용된다.
Queue Group
만약 동일한 서비스의 여러 인스턴스가 존재할 때 이벤트를 여러 개 전달하면 어떻게 될까? 이는 당연히 데이터 중복 혹은 충돌의 문제로 이어진다. 따라서 NATS Streaming은 큐 그룹이라는 기능을 사용해서 이를 해결한다. 큐 그룹은 채널 안에 생성되며 하나의 채널에 여러 개의 큐 그룹을 연관시킬 수 있으며 각 큐 그룹에 원하는 이름을 지정할 수 있다. 요약하면, 큐 그룹은 채널 안에서 서비스의 여러 인스턴스가 동일한 이벤트를 처리하지 않고 한 인스턴스만 이벤트를 받도록 조정하는 기능이다.
ACK
이벤트를 수신하고 처리한 다음 정보를 저장하려고 하는데 데이터베이스 문제가 발생하면 어떻게 될까? 그러니까 데이터베이스 연결이 일시적으로 끊기거나 데이터베이스가 업그레이드 중이라서 다운될 수 있다. 또는 저장하려는 정보 자체가 잘못되어 데이터베이스에서 요청을 거부할 수 있다. NATS Streaming은 기본적으로 이벤트를 수신하면 자동으로 이벤트를 처리한다. 그래서 오류가 발생하더라도 이벤트는 사실상 손실되며 다시 처리할 기회는 주어지지 않는다. 옵션으로 이를 변경할 수 있으며 변경해야 하는 이유는 해당 이벤트에 중요한 정보가 담겨 있을 수 있기 때문이다. 여행를 예약하고 결제를 진행하는데 이벤트에 사용자가 방금 결제한 정보가 들어 있다면 그 서비스는 결제 정보가 생성되었다는 것을 반드시 알아야 한다. 따라서 이벤트 수신 시 오류가 발생할 경우 이벤트를 다시 처리하거나 다시 시도해야 한다. 이를 위해 수동 확인 모드(manual acknowledgment mode)를 설정한다.
수동 확인 모드를 활성화하면 자동으로 처리 완료를 알리지 않고 직접 이벤트를 처리한 후 데이터베이스에 정보를 저장하고 모든 처리가 완료된 후에야 메시지를 수동으로 확인한다. 만약 들어온 이벤트를 확인하지 않으면 NATS Streaming 서버는 기본적으로 30초 정도 대기하고 그 후 30초가 지나도 확인이 없으면 해당 이벤트를 큐 그룹 내 다른 인스턴스에게 보내거나 그룹이 없다면 동일한 인스턴스 다시 이벤트를 전달해 재처리할 기회를 준다. 즉, 메시지를 정상적으로 처리한 후 ack() 메서드를 호출하면 NATS Streaming 서버는 더 이상 해당 이벤트에 대해 신경쓰지 않는다. 요약하면, 수동 확인 모드에서 ack() 메서드 호출을 통해 메시지를 처리 완료 상태로 만들지 않으면 NATS Streaming 서버는 계속해서 동일한 이벤트를 재전송하여 성공적으로 처리될 때까지 기회를 준다. 이 방식은 메시지 처리 중 오류가 발생해도 재시도를 통해 안정적으로 데이터 처리가 이루어지도록 보장한다.
동시성 문제
이벤트 버스와 메시지 브로커 중 하나인 NATS Streaming에 대해서 알아봤기에 동시성 문제에 들어간다. 동시성 문제와 관련해서 1번째로 고려해야 할 점은 구독자가 들어오는 이벤트를 처리하지 못하는 경우이다. 포스트의 도입에서 등장한 입출금 예시에 대해 다시 알아본다.
이벤트 처리 실패
2번의 입금(10만원 30만원)과 1번의 출금(50 만원)을 사용자가 요청한다고 가정한다. 10만운 입금이 발생하고 입금 이벤트가 계좌 서비스에 할당된다. 그런 다음 계좌 서비스가 이 이벤트를 처리하려고 시도하지만 여러 가지 문제 중 하나로 이벤트가 처리되지 않는다. 어떤 이유든 간에 구독자에 문제가 발생하면 이벤트를 승인하지 않기에 결국 이벤트를 재처리 해야한다. 하지만 NATS Streaming 서버가 이벤트를 재처리하고 다른 서비스에 전달하기까지 30초가 걸리는데 이 시간에 출판자(publisher)는 남은 두 개의 이벤트를 계속 발행할 수 있다. 구독자는 30만원 입금 이벤트를 발행시키고 이벤트가 성공적으로 처리된다. 바로 이어서 50만원 출금 이벤트가 발행하고 여기서 문제가 발생한다. 계좌에 40만원 밖에 없는데 50만원을 출금하려고 하기 때문에 오류가 발생하는 것이다. 따라서 어떤 이벤트라도 처리에 실패하면 애플리케이션의 비즈니스 논리에 치명적인 오류를 일으킬 수 있다.
처리 속도 차이
또 다른 동시성 문제는 한 구독자가 다른 구독자보다 더 빠르게 실행되는 경우이다. 예를 들어 30만원을 입금하는 이벤트를 계좌 서비스에 보냈는데 해당 구독자는 이벤트 대기열이 처리해야 할 이벤트로 쌓여 있을 경우(e.g., 100개의 이벤트) 실행 중 인 기계가 과부하 상태에 처해 있을 것이다. 따라서 30만원 입금 이벤트는 승인까지 대기하는데 그 사이에 계좌 서비스에 20만원 입금이라는 다른 이벤트를 발행한다. 따라서, 2개의 이벤트가 처리되고 승인되기를 기다려야 한다. 이 상황에서 40만원을 출금하는 이벤트를 계좌 서비스에 발행하고 이 이벤트는 계좌 서비스의 다른 인스턴스에 전송된다. 마침 이 인스턴스는 처리할 다른 이벤트가 없어서 들어오는 이벤트를 즉시 처리할 수 있다. 그 결과 40만원을 출금하려다가 잔액이 부족하게 되어 잔액이 마이너스로 떨어지는 상황이 발생한다.
메시지 브로커의 실수
NATS Streaming은 클라이언트가 종료되더라도 HB 설정에 따라 10~20초 정도 동안 해당 클라이언트를 실제로 종료된 것으로 간주하지 않을 수 있다. 계좌 서비스의 인스턴스가 갑작스럽게 완전히 종료되었다고 가정한다. 즉, 정상적으로 종료되지 않고 어떤 이유로 갑자기 끝났다. 하지만 10~20초 동안 NATS Streaming 서버는 여전히 해당 서비스의 인스턴스가 살아있다고 생각한다. 이 상황에서 NATS Streaming는 20만원 입금 이벤트를 종료된 서비스에 할당하려고 시도할 수 있는데 NATS Streaming는 서비스가 여전히 실행 중이라고 생각하기 때문이다. 이어서 30만원 입금 이벤트와 40만원 출급 이벤트도 계좌 서비스에 전달되는데 30만원은 같은 인스턴스 40만원은 계좌 서비스의 다른 인스턴스에 할당된다. 입금의 경우 인스턴스가 종료되어 처리할 수 없지만 출금은 인스턴스가 실행하고 있어 또 다시 잔액이 부족한 상황이 발생한다.
같은 이벤트
앞의 모든 예시는 입금과 출금이 거의 동시에 이루어진다고 가정했다. 다시 말해, 모든 이벤트가 거의 동시간대 NATS Streaming 서버로 전송된다는 것이다. 이번에는 좀 더 현실적인 상황을 살펴본다. 1번째 50만원 입금을 월요일에 2번째 60만원 입금을 화요일, 그리고 100만원 출금을 금요일에 할 수 있다. 이 시나리오에서는 각 이벤트가 처리되는 시간 사이에 상당한 시간이 있을 수 있다. 임급 이벤트 처리가 실패해도 서비스의 다른 인스턴스에 할당할 수 있고 충분한 시간이 있기 때문에 이벤트를 처리하는 데 문제가 없다. 이는 화요일에도 마찬가지이다. 출금을 하는 금요일에 디스크에 저장된 파일이 매우 느려져서 NATS Streaming의 기본 시간이 30초에서 29.999초에 파일을 열고 읽고 수정하는데 1초가 걸리면 NATS Streaming은 이벤트가 처리되지 못했다고 생각하여 다른 인스턴스로 재할당한다. 하지만 현재 인스턴스는 이벤트를 성공적으로 처리하고 있다. 다시 말해, 30초가 지나도 처리 타임아웃이 없기 때문에 인스턴스는 정상적으로 작동한다. 조금 뒤 인스턴스가 100만원 출금을 완료하고 10만원을 파일에 저장한다. 하지만 이미 NATS Streaming는 이벤트를 다른 인스턴스로 재할당했다. 여기서 다시 100만원을 출금하려고 시도하면 잔액이 부족하다는 오류가 다시 발생한다.
동시성 문제는 애플리케이션 내에서 실제로 발생할 가능성이 매우 높다. 이러한 문제들은 이벤트가 순서대로 처리되지 않거나, 서비스의 처리 속도가 다르거나, 서비스 중단, 한 이벤트가 두 번 처리되는 상황 등으로 인해 발생할 수 있다.
해결책
동시성 문제의 유형 중 일부에 대해 알아봤다. 발생할 수 있는 동시성 문제는 당연히 이보다 훨씬 더 많을 것이다. 가능한 해결책을 살펴본다.
서비스 당 인스턴스 하나
동시성 문제를 살펴보면 많은 문제가 서비스 두 개의 인스턴스가 이벤트를 처리하는 방식에서 발생한다는 걸 알 수 있다. 왜냐하면 인스턴스 간의 속도 차이, 파일 저장소와의 통신 문제 등이 원인일 수 있기 때문이다. 따라서 하나의 서비스 인스턴스만 실행하는 것이다. 즉, 복수의 인스턴스 대신 하나의 서비스 인스턴스만 실행해서 모든 이벤트를 처리하도록 만드는 것이다. 입출금을 예로 들면, 계좌 서비스의 한 인스턴스가 입금과 출금과 같은 모든 이벤트를 순차적으로 처리한다. 하지만 이 경우에도 여전히 실패할 가능성은 존재한다. 예를 들어, 1번 이벤트에서 디스크의 일시적인 문제로 파일을 열 수 없는 문제가 발생해서 이로 인해 1번 이벤트를 처리하지 못하며 다시 NATS Streaming이 이벤트를 전달해야 한다. 더 큰 문제는 바로 병목 현상이다. 만약 하나의 인스턴스만 실행한다면 애플리케이션에서 데이터를 처리하는 속도가 매우 제한될 것이고 확장성이 크게 떨어진다. 또한 인스턴스를 단 하나만 실행하기 때문에 수평적 확장이 불가능하다. 애플리케이션이 인기를 끌고 트래픽이 증가할수록 큰 치명적인 문제가 될 수 있다. 따라서 이 해결책은 작동하지 않는다.
가능한 모든 오류 원인 파악
간단하게 말해서 애플리케이션 내 발생할 수 있는 모든 가능한 동시성 문제를 찾아내어 이를 전부 코드로 해결하려는 시도라고 볼 수 있다. 참고로 애플리케이션 내부에는 무한에 가까운 동시성 문제들이 존재할 수 있다. 따라서, 모든 가능성을 찾아내어 각각의 문제를 해결하는 코드를 작성하는 것은 현실적으로 불가능에 가깝다. 물론, 예외가 있을 수 있다. 우주선과 같은 중요한 시스템을 만들 때는 항상 작동해야 하기 때문에 모든 상황을 처리하는 코드가 필요할 수 있지만 여러분이 트위터 클론 같은 애플리케이션을 만든다고 가정할 때 트윗이 순서가 어긋나는 게 정말 큰 문제가 될 수 있을까? 두 개의 트윗이 중복된다거나 그게 정말 중요한 문제일까? 즉, 이러한 문제를 해결하기 위해 엄청난 시간과 비용을 투자하는 게 과연 현명할까? 대부분의 경우 이러한 문제들은 애플리케이션에 큰 영향을 미치지 않으며 현실적으로 발생 가능성이 매우 낮다. 또한, 많은 경우 그 문제를 해결하려면 너무 많은 시간과 자원이 소모된다. 따라서 "이 문제가 정말 해결할 가치가 있는가?"라는 질문을 스스로 던져야 한다. 대부분의 경우, 해결하는 데는 너무 많은 비용이 들고 실제로는 별로 중요하지 않다는 결론에 도달한다. 따라서 항상 모든 경우를 처리하려는 시도는 비효율적이며 실제로 발생 가능성이 낮다면 해결할 필요가 없을 수 있다.
공유 상태를 사용하여 마지막으로 처리된 이벤트를 서비스 간에 공유
이 해결책에서 다시 한 번 계좌 서비스의 여러 인스턴스(A와 B)를 실행한다. A와 B가는 공유 상태(shared state)를 가지고 있다고 가정한다. 공유 상태는 Redis 서버일 수도 있고 공유 데이터베이스일 수도 있으며 또는 네트워크 연결일 수도 있다. 공유 상태는 각 이벤트의 시퀀스 번호(sequence number)를 기록한다. 시퀀스 번호는 A와 B가 처리한 모든 이벤트를 추적한다. 다시 말해, 이벤트 1 인스턴스 A로 들어오고 A는 이 이벤트를 처리한 후 40만원을 입금한다. 그 다음 시퀀스 번호 1을 공유 데이터 저장소에 기록한다. 이벤트 2가 인스턴스 B로 들어오고 B는 공유 데이터 저장소에서 이전 시퀀스 번호(1번)가 이미 처리되었는지 확인하고 B는 이벤트 2를 처리하여 10만원을 입금하고 시퀀스 번호 2를 저장한다. 마지막으로 이벤트 3이 인스턴스 A로 가서 시퀀스 번호 2가 처리되었는지 확인 후 20만원을 출금하고 시퀀스 번호 3을 기록한다. 이 해결책의 장점은 모든 이벤트가 정확히 한 번만 처리되며 이벤트들이 올바른 순서로 처리되도록 보장한다. 하지만 단점은 모든 이벤트를 순차적으로 처리해야 한다는 제약 때문에 성능 상 큰 문제가 될 수 있다. 예를 들어, 만약 사용자 A와 사용자 B가 각각 다른 계정을 가지고 있고 각자의 계정에서 입출금을 하려고 한다고 가정한다. 이벤트 1은 사용자 A의 입금 이벤트, 이벤트 2는 사용자 B의 입금 이벤트이다. 이제 인스턴스 A가 이벤트 1을 처리하려고 하지만 이 과정에서 파일 저장소와의 연결이 끊기거나 서비스 자체가 지연되는 상황이 발생하여 이벤트 1의 처리가 지연된다. 인스턴스 B는 이벤트 2를 처리하려고 하지만 공유 데이터 저장소에서 이전 시퀀스 번호(1번)가 처리되지 않았기 때문에 이벤트 2의 처리를 할 수 없다. 사용자 A와 B의 계정이 서로 독립적임에도 불구하고 사용자 A의 문제로 인해 사용자 B의 처리가 지연된다. 즉, 이 방식은 전역으로 단 하나의 수정만 처리할 수 있도록 제한되며 이는 애플리케이션의 처리 속도를 심각하게 저하시킬 수 있다. 따라서 이 해결책은 모양새는 좋지만 성능 문제로 인해 실용적이지 않다는 것을 알 수 있다.
자원 아이디별로 마지막 처리된 이벤트를 추적
이 해결책에서는 이전 방법에서 언급된 문제를 사용자별 독립적인 시퀀스 번호를 사용하는 방식을 도입하여 해결한다. 자원 소유자마다 별도의 시퀀스 풀을 만들어 한 자원 소유자의 이벤트 처리가 다른 자원 소유자에게 영향을 미치지 않도록 한다. 각 사용자는 자신만의 독립적인 시퀀스 번호를 가진다. 사용자1은 시퀀스 번호 1로 100만원을 입금하고 사용자2는 시퀀스 번호 1로 50만원을 입금한다. 사용자별로 시퀀스 번호를 추적하여 한 사용자의 이벤트가 다른 사용자의 이벤트와 겹치지 않도록 한다. 이전 해결책에서 인스턴스 A와 B가 공유 저장소에 있는 전역 시퀀스를 참조했기 때문에 어느 한 사용자의 이벤트 처리 중 지연이나 실패가 발생하면 다른 사용자들의 이벤트 처리도 차단되는 문제가 있다. 하지만 이 방식에서는 사용자1의 이벤트가 처리되는 동안 사용자2의 이벤트가 처리되지 않아도 사용자1의 이벤트가 계속 처리될 수 있다. 사용자1의 시퀀스 2와 3이 차례대로 처리되면서 사용자2의 1번 이벤트는 별개로 처리된다. 이 방식은 단순히 사용자뿐만 아니라 자원 소유자(e.g., 계정, 주문, 상품 등)에 대해서도 적용할 수 있으며 각 자원에 대한 이벤트가 개별적으로 관리되기 때문에 서로 간섭 없이 처리할 수 있다. 각 자원이 독립적인 시퀀스 번호를 가지므로 하나의 자원 이벤트 처리 실패가 다른 사용자에게 영향을 미치치 않아 병렬 처리가 가능하며 이벤트의 순서가 정확하게 보장되면서도 각 자원에 대한 독립적인 처리가 가능해 동시성 문제를 최소화 한다. 자원마다 개별적인 채널을 만들어야 하기에 오버헤드가 심하게 발생할 수 있다. 또한, 자원에 대해 별도의 채널을 생성하면 채널 수가 많아질수록 성능 저하가 발생할 수 있다. 이벤트 버스에서 자원에 대해 독립적인 시퀀스 번호를 관리하려면 해당 이벤트 버스가 많은 처리 오버헤드를 감수해야 한다. 결론적으로, 자원 간의 동시성 문제를 해결하는 좋은 방법처럼 보이지만 채널 수 증가로 인한 오버헤드가 발생하기 때문에 실용적이지 않을 수 있다. 이는 확장성을 고려해야 하는 대규모 애플리케이션에서는 더 큰 문제가 될 수 있다.
자원에 대한 이벤트를 처리할 때 각 이벤트의 마지막 처리된 시퀀스 번호를 추적
발행자는 각 이벤트를 발행할 때마다 이벤트를 기록하는 데이터베이스를 가지고 있다. 이를 통해 발행자는 자신이 발행한 이벤트에 대한 정보를 유지할 수 있다. 이벤트가 NATS Streaming 서버로 전송되면 NATS Streaming 서버는 각 이벤트에 고유한 시퀀스 번호를 할당하고 이를 발행자에게 다시 반환한다. 발행자는 이 시퀀스 번호를 자신의 데이터베이스에 기록한다. 각 자원은 마지막 처리된 이벤트 시퀀스 번호를 기록하여 이후에 발생하는 이벤트가 처리되기 전에 이 번호를 검증한다. 예를 들어, 사용자1의 경우 마지막 이벤트 시퀀스 번호가 1이라면 새로운 이벤트는 이전 시퀀스 번호와 비교하여 처리 여부를 결정한다. 서로 다른 자원의 이벤트가 동시에 발생할 수 있으므로 각 지원의 이벤트는 독립적으로 처리된다. 예를 들어, 사용자1이 100만원을 입금하고 동시에 사용자2가 50만원을 입금하는 경우 두 이벤트는 각각의 마지막 처리된 이벤트 번호를 검증하여 독립적으로 처리한다. 각 자원의 이벤트 처리가 독립적으로 이루어질 수 있도록 하는 것이 이 접근 방식의 핵심 목표이다. 단점은 NATS Streaming의 기술적 한계와 관련이 있는데 NATS Streaming 서버에서 발행된 이벤트의 시퀀스 번호는 발행자가 알 수 없기 때문에 발행자는 직접적으로 이 번호를 확인할 수 없다. 이는 발행자가 시퀀스 번호를 기준으로 이벤트를 처리하는 데 어려움을 초래한다. 프로젝트에 적용한 방법은 이 해결책과 유사하다.
이벤트 재전송
서비스가 오프라인 상태일 때는 발행되는 이벤트를 처리할 수 없어서 서비스가 다시 온라인 상태로 돌아올 때 놓친 모든 이벤트의 목록을 제공할 수 있어야 한다. NATS Streaming은 이벤트를 발행할 때마다 해당 이벤트를 채널 내에 내부적으로 저장한다. 예를 들어, 리뷰 생성 같은 이벤트를 발행할 수 있다. 그러면 이 이벤트는 서비스에 전달되고 동시에 해당 이벤트의 복사본이 저장된다. 채널 내에서 발행하는 모든 이벤트는 자동으로 저장되며 나중에 특정 시점에 구독을 생성할 때 이 구독을 사용자 정의하여 과거에 발행된 이벤트 목록을 가져오도록 설정할 수 있다.
NATS Streamingd은 구독을 설정하는 옵션을 제공하는데 그 중 하나가 setDeliverAllAvailable() 메서드이다. 이 메서드는 구독이 생성될 때 NATS Streaming은 구독이 생성되기 전 또는 구독이 다운된 동안 놓친 모든 이벤트를 전송한다. 유일한 단점은 애플리케이션 실행 후 저장된 이벤트의 방대한 목록을 다시 전달하는 것이다. 저장된 이벤트는 수천 개, 수십만 개, 심지어 수백만 개에 이를 수 있다. 따라서 장기적으로는 매번 구독할 때마다 발행된 모든 이벤트를 요청하는 것은 실용적이지 않다. 이를 보완하는 메서드가 setDurableSubscription()이다. 지속적인(durable) 구독은 구독에 식별자를 부여할 때 생성되며 이벤트를 놓치지 않도록 보장해주며 향후 이벤트를 잘못 처리하지 않도록 하는 데 정말로 유용하다. 두 메서드는 같이 사용해야 하는데 setDeliverAllAvailable() 메서드는 지속적인 구독이 생성될 때, 처음으로 이벤트가 생성될 때 중요한 역할을 한다. 서비스가 처음으로 온라인에 올라올 때 setDeliverAllAvailable() 메서드를 사용하면 NATS Streaming은 과거의 모든 이벤트를 가져와 전달한다. 이는 구독이 처음 생성될 때만 발생한다. 따라서, setDeliverAllAvailable() 메서드는 서비스가 처음 온라인에 올라올 때 과거에 발행된 모든 이벤트를 받을 수 있게 해주기 때문에데이터 손실을 방지하는 데 큰 도움이 된다.
이벤트 발행 실패
이벤트 발행에 실패하면 데이터 일관성에 어떤 문제가 발생할까? 즉, 원래 서비스에서는 데이터베이스에 데이터가 성공적으로 반영되었지만 네트워크 연결이 갑자기 끊어져 이벤트 발행이 실패하는 것이다. 사용자는 입금이 성공했다는 정보를 확인했음에도 불구하고 실제로 잔액에는 변화가 없는 것을 보고 당혹을 금치 못할 것이다. 여기서 핵심 문제는 단순히 이벤트 발행이 실패했다는 사실을 사용자에게 어떻게 전달할 것인가가 아니라 이벤트 발행에 실패한 사실을 어떻게 기록하고 해결하여 서비스 간의 데이터 무결성을 유지하는 것이다.
해결하는 방법은 데이터를 데이터베이스에 저장할 때 이벤트를 바로 이벤트 버스에 발행하는 것이 아니라 발행할 이벤트도 동시에 데이터베이스에 저장한다. 이때 이벤트가 아직 발행되지 않았다는 플래그를 설정한다. 다음으로 이벤트 테이블/컬렉션을 감시하는 별도의 프로세스 혹은 코드 새로운 이벤트가 저장되면 이를 NATS Streaming에 발행한다. 성공적으로 발행된 후에는 발충 플래그를 업데이트하여 이벤트가 이미 발행되었음을 표시합니다. 이 방법을 사용하면 NATS Streaming 서버나 네트워크 장애가 발생해도 데이터 일관성을 유지할 수 있다. NATS Streaming 서버에 연결할 수 없는 상황에서도 데이터와 이벤트는 데이터베이스에 안전하게 저장되고 연결이 복구되면 이벤트 발행이 가능하다.
OCC
해결책에 등장한 여러 방법 중에서 마지막 방법인 각 이벤트의 마지막 처리된 시퀀스 번호를 추적하는 방법이 프로젝트에 적용한 방법과 유사하다고 말했다. MongoDB의 도큐먼트는 모두 버전이라는 필드를 가지는데 이 필드를 시퀸스 번호라고 생각할 수 있다. 즉, 도큐먼트 생성 시 0 혹은 1을 필드에 부여하고 도큐먼트를 수정하려고 하면 이벤트에서 보낸 아이디와 버전을 데이터베이스의 아이디와 버전을 비교해서 일치하면 수정을 진행한다. 그렇다면 이 버저 필드를 어떻게 관리해야 할까? 다행히도 MongoDB와 Mogooose는 이를 자동으로 처리해준다.
일단 MongoDB와 Mongoose가 일반적으로 도큐먼트를 어떻게 수정하는지 살펴본다. Mongoose는 MongoDB에서 도큐먼트를 가져온 다음 수정을 하고 저장을 한다. 중요한 부분은 Mongoose가 MongoDB에게 접근하여 수정 실행 요청을 전달한다. 요청 안에는 아이디에 맞는 도큐먼트를 수정하라는 내용이 포함되어 있으며 MongoDB는 해당 컬렉션에서 아이디에 해당하는 도큐먼트를 수정을 실행한다. 이제 버전 필드를 사용하여 수정을 실행하는 과정을 살펴본다.
흐름은 동일하지만 버전 필드가 통합되어있는데 이 과정은 낙관전 동시성 제어(OCC)라고 불린다. OCC는 MongoDB 뿐만 아니라 다른 유형의 데이터베이스에도 적용 가능하다. OCC는 여러 트랜잭션이 서로 간섭하지 않고 자주 완료될 수 있다고 가정한다. 트랜잭션이 실행되는 동안 트랜잭션은 데이터 자원을 잠금(lock) 없이 사용한다. 커밋하기 전 각 트랜잭션은 다른 트랜잭션이 자신이 읽은 데이터를 수정하지 않았는지 확인한다. 만약 이 확인 과정에서 충돌하는 수정이 발견되면 커밋 중인 트랜잭션은 롤백되고 다시 시작할 수 있다. 버전 필드에 이를 적용하면 도큐먼트를 가져오고 수정을 하고 저장한다. 내부적으로 Mongoose에서 설정을 활성화하면 Mongoose가 버전 필드를 자동으로 수정하낟. 즉, Mongoose가 버전 필드를 1→2→3→4로 자동으로 증가시킨다. 그런 다음 Mongoose는 MongoDB에 수정 요청을 전달한다. 버전 관리 기능이 활성화되면 도큐먼트를 가져올 때 아이디뿐만 아니라 버전 필드의 번호까지 일치하는지 확인한다. 따라서 MongoDB가 도큐먼트를 수정하려고 할 때 실제로 버전 번호를 비교하지 않으며 Mongoose가 아이디와 버전이 일치하는 도큐먼트를 찾는다. 이러한 방식이 정확한 버전을 가진 도큐먼트를 선택하도록 보장하는 핵심이다. 주의할 점은 이 모든 과정이 도큐먼트 수정 연산과 관련있다. 삽입의 경우 버전이 항상 0 혹은 1로 설정된다고 가정한다. 따라서, 삽입 연산과 관련된 큰 문제는 발생하지 않는다. 이제 Mongoose가 이 기능을 어떻게 구현하는지 살펴본다.
Mongoose는 mongoose-update-if-current 패키지를 사용해서 OCC를 적용한다. 패키지는 도큐먼트의 __v 필드를 기본적으로 수정하는데 이는 맞춤 설정으로 변경할 수 있다. 참고로 반드시 Mongoose의 save() 메서드를 사용해야 버전 필드가 자동으로 수정된다. 예시로 여행 모델 코드를 보면 다음과 같다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 | /* * 여행 도큐먼트가 가지는 속성을 기술하는 인터페이스. * 여행 도큐먼트가 여행 인스턴스를 생성할 때 전달하는 속성이 도큐먼트에 존재하는 속성과 다를 때 발생하는 문제를 해결한다. * sequence는 OCC에 사용되는 필드. */ interface TourDocument extends mongoose.Document { _id: mongoose.Types.ObjectId; coverImage: string; difficulty: string; discount: number; ... bookingId?: mongoose.Types.ObjectId; sequence: number; } interface TourModel extends mongoose.Model<TourDocument> { build(attrs: TourAttribute): Promise<TourDocument>; } const tourSchema = new mongoose.Schema( { coverImage: { type: String, required: [true, '표지 이미지가 있어야 합니다.'], }, description: { type: String, trim: true, }, difficulty: { type: String, required: [true, '난이도가 있어야 합니다.'], enum: { values: ['상', '중', '하'], message: '난이도는 상, 중, 하 중 하나입니다.', }, }, ... createdAt: { type: Date, default: Date.now(), select: false, }, updatedAt: { type: Date, select: false, }, deletedAt: { type: Date, select: false, }, }, { /* * https://mongoosejs.com/docs/api/document.html * MVC 패턴 관점에서 보면 모델에서 뷰의 논리를 작성하는 것은 적합하지 않다. * 왜냐하면 뷰가 출력과 관련되어 있기 때문이다. */ toJSON: { virtuals: true, versionKey: false, /* * 결과 객체를 변환해야 할 수도 있다. * 예를 들어, 민감한 정보를 제거하거나 사용자 정의 객체를 반환해야 할 때가 있다. * 이 경우 선택적인 변환 함수를 설정한다. */ transform(document, pojo) { delete pojo._id; delete pojo.secret; delete pojo.createdAt; }, }, /* 도큐먼트를 일반 JavaScript 객체(POJO)로 변환한다. */ toObject: { virtuals: true, versionKey: false }, }, ); // OCC를 Mongoose에 적용한다. tourSchema.set('versionKey', 'sequence'); tourSchema.plugin(updateIfCurrentPlugin); | cs |
그렇다면 버전 필드는 언제 누가 수정해야 할까? 모든 이벤트가 항상 버전 번호를 증가시키거나 포함할 필요는 없다. 도큐먼트에 대한 버전 번호를 증가시키거나 포함하는 것은 오직 그 도큐먼트에 대해 주된 책임을 지는 서비스가 해당 도큐먼트를 생성, 수정, 또는 삭제하는 것을 설명하기 위해 이벤트를 발생시킬 때만 고려한다.
mongoose-update-if-current 패키지가 어떻게 버전 번호를 수정하는지 살펴보았다. 간편하고 편하지만 큰 단점이 있다. 일단 이 패지키를 사용한다는 의미는 이벤트가 어디에서 오고 있는지를 알고 어떻게 버전 번호가 수정되는지 안다는 것을 뜻한다. 하지만 만약 한 서비스가 Spring 프레임워크에 MySQL을 데이터베이스로 사용할 수 있다. 그러면 mongoose-update-if-current 패키지로 버전을 관리할 수 없다. 게다가, 이벤트에서 버전 번호를 적용하는 방식이 완전히 다를 수 있다. 즉, 0 혹은 1부터 시작하지 않고 10 혹은 100부터 시작해서 10 혹은 100씩 증가할 수 있다. 또는, 번호가 아니라 수정이 발생한 시간을 저장하는 타임스탬프를 사용할 수 있다. 따라서, 이러한 문제점을 반드시 고려해야 한다.
update: 2024.10.24
댓글 없음:
댓글 쓰기