거래내역은 블록체인 트랜잭션에 기반하여 데이터베이스에 저장됩니다.
만약, 트랜잭션이 생성되면 레코드가 생성되고, 블록이 채굴되면 레코드 상태 또한 '완료'가 되어야합니다.
이 과정에서 예상치 못한 문제로 레코드가 기록되지 않거나, 상태가 업데이트되지 않는다면?
때문에 데이터베이스 레코드로 기록된 거래내역은 블록체인 네트워크와 동기화가 필요합니다. 때문에 네트워크 트랜잭션과 대조하여 거래내역의 신뢰성을 보장 받고, 보장된 거래내역을 기반으로 거래 대사를 진행할 수 있게 됩니다. 즉, 이 마이그레이션 작업은 거래 대사의 사전 작업이라고 보시면 될 거 같습니다.
거래내역과 대조할 '전일자' 트랜잭션을 조회해야 하는데요. 문제는 블록체인 네트워크에서 특정 날짜를 기준으로 트랜잭션을 조회할 수 없다는 것입니다.
때문에 자정에 채굴된 블록(Start Block)과 전일자 마지막으로 채굴된 블록(End Block)을 이용하여 전일자 트랜잭션을 조회하려고 했는데요.
먼저, 아래 코드와 같이 전일자 시작-종료 블록을 찾아야 하는데요. 블록체인에 수 많은 블록을 하나하나 조회하는 것은 불가능하기 때문에, 순차적으로 채굴되는 블록체인의 특성을 이용하면 Binary Search가 가능합니다.
async function getPrevDayTransactions() {
const yesterdayStart = Timestamp.parse('00:00:00').minusDay(1); // 전일 00:00:00
const yesterdayEnd = Timestamp.parse('23:59:59').minusDay(1); // 전일 23:59:59
const startBlockNumber = await findBlockByTimestamp(yesterdayStart);
const endBlockNumber = await findBlockByTimestamp(yesterdayEnd);
// 스마트 컨트랙트 Transfer 이벤트 필터
const filter = [contract.filters.TransferFrom()];
const events = await contract.queryFilter(filter, startBlockNumber, endBlockNumber);
}
async function findBlockByTimestamp(targetTimestamp: number) {
let low = 0;
let high = await jsonRpcProvider.getBlockNumber();
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const block = await jsonRpcProvider.getBlock(mid);
if (block.timestamp < targetTimestamp) {
low = mid + 1;
} else if (block.timestamp > targetTimestamp) {
high = mid - 1;
} else {
return mid;
}
}
return high;
}
문제는 생각보다 블록의 양이 생각보다 어마어마 하다는 것인데요. 최초 제네시스 블록 생성 시점인 2020년부터 현재까지 쭉 쌓인 블록을 검색한다는 것은 생각보다 많은 양이었습니다.
+ ------------ + ------------------------ +
| Block Number | Date |
+ ------------ + ------------------------ +
| 10731165 | 2020-08-25T18:18:07.000Z |
|... |
| 21451639 | 2024-12-21T15:00:11.000Z | <-- 전일자 시작 블록
| 10731166 | 2020-08-25T18:18:12.000Z |
| 16096749 | 2022-12-02T11:12:59.000Z |
| 18779541 | 2023-12-13T20:11:35.000Z |
| 20120937 | 2024-06-18T20:10:23.000Z |
| 20791635 | 2024-09-20T11:55:23.000Z |
| 21126984 | 2024-11-06T07:00:59.000Z |
| 21294658 | 2024-11-29T16:52:23.000Z |
| 21378495 | 2024-12-11T09:53:11.000Z |
| 21420414 | 2024-12-17T06:18:35.000Z |
| 21441373 | 2024-12-20T04:33:47.000Z |
| 21451853 | 2024-12-21T15:42:59.000Z |
| 21457093 | 2024-12-22T09:18:11.000Z |
| 21459713 | 2024-12-22T18:05:35.000Z |
| 21458403 | 2024-12-22T13:42:11.000Z |
| 21459058 | 2024-12-22T15:53:47.000Z |
| 21458730 | 2024-12-22T14:47:59.000Z |
| 21458894 | 2024-12-22T15:20:59.000Z |
| 21458812 | 2024-12-22T15:04:23.000Z |
| 21458771 | 2024-12-22T14:56:11.000Z |
| 21458791 | 2024-12-22T15:00:11.000Z |
| 21458781 | 2024-12-22T14:58:11.000Z |
| 21458786 | 2024-12-22T14:59:11.000Z |
| 21458788 | 2024-12-22T14:59:35.000Z |
| 21458789 | 2024-12-22T14:59:47.000Z |
| 21458790 | 2024-12-22T14:59:59.000Z | <-- 전일자 종료 블록
+ ------------ + ------------------------ +
위는 블록을 탐색할 때의 로그인데요. 대략 50번 블록을 탐색했으며, 소요시간은 31초가 걸렸습니다.
예를 들어, 최초 블록부터 현재(2024.12.23 기준)까지 블록 전체의 사이즈는 21,458,790개로, 이진 탐색으로 조회할 시 대략 50개의 블록을 조회하게 됩니다. 즉, get_blockByNumber 메서드 호출이 50번 이루어지게 되는데요. 현재 사용중인 Alchemy 노드에서 소비되는 컴퓨팅 리소스는 1,000 CU가 됩니다. (Alchemy | Compute Unit Costs 참고)
CU(Compute Unit) 이란?
Alchemy에서 사용되는 컴퓨팅 리소스 단위입니다. Free Tier 기준 월 3억개의 CUs를 제공하고 있으며, RPC 메서드마다 소모되는 CU가 다른데요. 예를 들어, eth_getBlockByNumber는 20CU가 소모되며, 월 천오백만(15,000,000)번의 호출이 가능합니다.
또한, 전일자 시작-종료 블록 번호를 이용하여 이벤트 로그도 조회해야 합니다. 해당 eth_getLogs RPC 메서드 호출 비용은 60 CU로, 하루 1,060 CU를 사용하게 됩니다.
현재 서비스에서 사용중인 플랜은 Free Tier로 월 3억 CUs를 제공하고 있습니다. 해당 서비스에서 블록체인 거래가 활발하게 이루어지고 있지 않아 32k CUs는 비용적으로 크리티컬하지 않습니다만, 서비스 운영 중 특정 시간대에 과부하가 발생하여 사용자 경험이 크게 떨어질 것을 우려하여, 전체 블록을 이진 탐색하는 방식보다 더 효율적인 방법을 찾아야 했습니다.
동기화를 실시할 때 마지막 탐색 블록의 번호를 저장한다면 전일자 블록을 찾을 필요가 없게됩니다.
예를 들어, 2024.12.23에 실시한 후 동기화 내역을 아래와 같이 저장합니다.
동기화 시간 | 블록 번호 | TX Hash | 거래내역 ID | |
---|---|---|---|---|
1 | 2024.12.23 | 100 | 0xE | 10 |
이후, 다음날인 2024.12.24에 동기화를 실시할 때, 마지막 동기화 내역의 '블록 번호'와 '트랜잭션 해시' 값을 가져와 거래 대사를 실시합니다. 그렇다면 100번 블록의 0xa
해시값 다음 트랜잭션부터 대사를 시작하면 되겠네요!
블록 번호와 해시값을 저장하는 이유는?
만약, 서비스에 아무런 거래가 발생하지 않을 경우 중복된 트랜잭션이 존재할 수 있습니다.
100번 블록에0xA
,0xB
, ..0xE
트랜잭션이 저장되었다고 가정했을 때,0xA
해시값을 가지는 트랜잭션은 이미 거래 대사가 완료된 트랜잭션일 수 있거든요. 그렇게 된다면, 서비스 거래내역 중 전일자 거래에는 존재하지 않는 거래이기 때문에 중복된 거래내역을 생성할 수 있습니다.
물론, 전일자 거래내역이 아닌SELECT * FROM orders WHERE id > {lastMigration.orderId}
쿼리로 동기화 완료된 거래내역 이후부터 조회하는 방식으로 개선할 수 있겠습니다.
자정(00:00 ~ 00:30) 시간대는 은행 앱을 수 없다는 걸 다들 알고 계실겁니다.
이때는 은행에서 시스템 점검이 이루어지는데, 보안 업데이트, 데이터 백업 등을 수행합니다. 특히 입출금 내역이 정상적으로 이루어졌는지도 체크하는데요.
만약, 점검 시간대에 입출금이 발생한다면 점검 전과 이후의 거래 내역이 다를 수 있게 됩니다. 즉, 데이터 정합성에 문제가 발생하게 되는 것이죠.
간단한 예를 들어 설명해보겠습니다.
사용자 A가 B에게 1,000원을 송금하였습니다. 이후 시스템은 자정이 되어 거래 대사를 실시하였는데요. A의 내역과 B의 내역을 살펴보면 아래와 같이 산출됩니다.
거래종류 | 금액 | 사용자 | 보유중인 잔액 | |
---|---|---|---|---|
1 | 출금 | 1,000원 | A | 4,000원 |
2 | 입금 | 1,000원 | B | 1,000원 |
거래내역을 토대로 다음과 사실을 알았습니다.
이번에도 A가 B에게 1,000원을 송금하려 합니다. 그런데, 서버가 지연되어 점검시간에 송금이 완료되어 버렸네요. 점검 이후 결과는 다음과 같습니다.
거래종류 | 금액 | 사용자 | 보유중인 잔액 | |
---|---|---|---|---|
1 | 출금 | 1,000원 | A | 4,000원 |
2 | 입금 | 1,000원 | B | 1,000원 |
3 | 출금 | 1,000원 | A | 3,000원 |
4 | 입금 | ? | ? | ? |
A가 B에게 출금했던 1,000원에 대한 거래는 반영이 되었지만 아이러니 하게도 B는 아무것도 받지 못한 결과가 되었습니다. 그 이유는 서버에서 거래가 시작되었던 시점과 거래가 완료된 시점의 차이인데요.
서버 입장에서 점검이 시작된 시점에는 A->B 거래는 알고 있지만, B->A에 대한 거래 완료 시점이 점검 중 발생하였기 때문입니다. 정확히 점검 시작 이후 거래조회가 이루어진 다음에 B->A가 완료되었다고 보시면 되겠습니다.
실제로 은행시스템이 이렇게 이루어지지 않겠지만(..😅), 또 다른 예시를 들어보겠습니다.
이번에는 점검시간대에 A가 B에게 송금을 시도했다고 가정해보겠습니다.
거래종류 | 금액 | 사용자 | 보유중인 잔액 | 결과 | |
---|---|---|---|---|---|
1 | 출금 | 1,000원 | A | 4,000원 | o |
2 | 입금 | 1,000원 | B | 1,000원 | o |
3 | 출금 | 1,000원 | A | 3,000원 | x |
4 | 입금 | 1,000원 | B | 2,000원 | x |
점검 시작 시점에 조회된 레코드는 1, 2번 거래뿐, 이후에 발생한 거래를 서버가 알 수 없게된 상황입니다. 이 상황은 첫번째 예시처럼 크리티컬하지 않지만, 거래가 반영이 안된 것 또한 사용자 입장에서 굉장히 난처합니다.
문제 상황을 정리해보면 다음과 같습니다.
설명드린 예시를 블록체인 트랜잭션에 그대로 반영해 보겠습니다. 먼저, 트랜잭션 생성 후 블록이 채굴될 때까지 어느정도 시간이 소요되는데요. 여기서 출금-입금 개념을 = 트랜잭션 생성-채굴(완료) 개념으로 그대로 대입해보겠습니다.
먼저, 거래 대사 전 트랜잭션이 발생한 경우입니다. 채굴이 완료되지 않았음에도 거래 대사 작업을 시작했고, 작업 중 블록 채굴이 완료되어 실제 트랜잭션 상태는 완료되어 거래가 성사되어야 합니다.
블록 번호 | TX Hash | 거래종류 | 금액 | 사용자 | 보유중인 잔액 | 상태 |
---|---|---|---|---|---|---|
100 | 0x1 | 출금 | 1,000 | A | 4,000 | 채굴 완료 |
100 | 0x1 | 입금 | 1,000 | B | 1,000 | 채굴 완료 |
101 | 0x2 | 출금 | 1,000 | A | 3,000 | 채굴 중 |
101 | 0x2 | 입금 | 1,000 | B | 2,000 | 채굴 중 |
이런 상황에서 위와 같은 결과가 발생한 이유를 서버 입장에서 살펴보겠습니다.
이제 좀 감이 오시나요?
결국, 거래 대사 작업이 진행되는 동안에 트랜잭션의 상태가 변경된다면 데이터 정합성이 깨지게 됩니다.
거래 대사 작업 중 외부의 개입에 의해 정합성을 지키기 위해 잠금(Locking) 개념을 활용할 필요가 있습니다.
잠금(Locking)이란?
특정 데이터에 대해 하나의 사용자(또는 트랜잭션)가 작업을 수행하는 동안 다른 사용자나 트랜잭션이 해당 데이터에 동시에 접근하거나 변경하지 못하도록 막는 메커니즘입니다. 이를 통해 데이터 정합성(Consistency)과 무결성(Integrity)을 보장할 수 있습니다.
DBMS(MySQL or MariaDB)에서 제공하는 Lock은 InnoDB 스토리지 엔진을 사용하는 테이블에서 사용할 수 있는 기능입니다.
Lock은 트랜잭션 내부에서 동작하며, 두 가지 카테고리로 나뉘어집니다.
S-Lock, X-Lock은 비관적 잠금 개념에 해당하는데요. 두 락 메커니즘을 이용하여, 대사 작업이 진행 중인 레코드에 Lock을 걸어 외부에서의 개입을 차단할 수 있습니다. 대사 작업이 끝난 이후 즉, 트랜잭션이 종료되면 DB에서 Lock을 반납하게 되며, 외부에서 해당 레코드에 접근할 수 있게됩니다.
비관적 잠금(Pessimistic Lock)은 트랜잭션 수행 중 여러 서비스가 동시에 레코드에 접근한다고 가정하는 메커니즘입니다. 때문에 트랜잭션 수행 시 락을 먼저 취득하여 다른 서비스가 레코드에 접근할 수 없도록 합니다.
처음에는 테이블 자체를 잠그는 테이블 락을 사용하려고 했는데요. 그 이유는 현재 운영중인 서비스에서 거래 자체가 많이 발생하고 있지 않았기 때문입니다. 특히, 새벽 시간대는 거래가 거의 없다시피 했기 때문에 가장 쉽게 구현하면서 데이터 무결성까지 철저하게 보장할 수 있다고 생각하여 테이블 락으로 구현하려 했습니다.
하지만, 테이블 락의 경우 DB 트랜잭션 위에 동작하지 않습니다. 무슨 말이냐면, 트랜잭션이 수행되면 기존에 테이블 락은 반납하고 변경된 내용은 자동으로 커밋됩니다. 반대로 트랜잭션 수행 중 테이블 락을 취득하면 기존 트랜잭션은 자동으로 커밋되는 상호 배타적으로 동작합니다.
아래는 MariaDB 공식 문서에서의 테이블 락에 대한 설명입니다.
LOCK TABLES implicitly commits the active transaction, if any.
Also, starting a transaction always releases all table locks
acquired with LOCK TABLES. This means that there is no way to
have table locks and an active transaction at the same time.
The only exceptions are the transactions in autocommit mode.
To preserve the data integrity between transactional and
non-transactional tables, the GET_LOCK() function can be used.
LOCK TABLES는 활성 상태의 트랜잭션이 있는 경우 이를 암시적으로 커밋합니다.
또한, 트랜잭션을 시작하면 LOCK TABLES로 획득한 모든 테이블 잠금이 해제됩니다.
이는 테이블 잠금과 활성 트랜잭션을 동시에 유지할 방법이 없음을 의미합니다.
단, 예외적으로 자동 커밋 모드의 트랜잭션에서는 가능합니다.
트랜잭션 테이블과 비트랜잭션 테이블 간의 데이터 무결성을 유지하려면 GET_LOCK() 함수를 사용할 수 있습니다.
MariaDB 공식문서 참고
대사 작업은 데이터베이스 입장에서 하나의 작업으로 수행되어야 합니다. 즉, ACID 성질띄는 작업이기 때문에 트랜잭션과 함께 동작해야 하는데요. 이는 트랜잭션 위에 동작하는 S-Lock or X-Lock과 달리, 테이블 락은 트랜잭션과 공존하여 동작하지 않기 때문에 굉장히 곤란합니다.
만약, 테이블 락을 이용하여 대사 작업을 진행한다 하더라도, 트랜잭션 내에서 테이블 락을 취득한 후 CUD(Create, Update, Delete) 작업을 진행하면 트랜잭션이 롤백되더라도 변경된 CUD 작업이 롤백되지 않을 수 있습니다.
테이블 락을 정리하자면 다음과 같습니다.
테이블 락의 다른 대안으로 GET_LOCK() 쿼리가 있습니다.
GET_LOCK() 쿼리는 애플리케이션 수준의 잠금 메커니즘을 구현하는데요. 데이터베이스 레코드의 접근을 차단하는 S-Lock or X-Lock, 테이블 락과 달리, GET_LOCK()은 데이터베이스의 특정 자원의 잠금을 걸지 않습니다.
MariaDB (또는 MySQL)에서 제공하는 GET_LOCK()은 사용자 수준 락(User-Level Lock)으로 특정 테이블에 대한 락이 아닙니다. 따라서 GET_LOCK()으로 취득한 락은 어떠한 테이블 또는 레코드에도 영향을 주지 않는데요.
GET_LOCK()은 사용자 정의 잠금(user-level lock)을 제공하기 때문에 트랜잭션에서 GET_LOCK({자원의 이름})을 통해 락을 취득한 후 다른 트랜잭션의 접근을 차단하는 방식으로 락 메커니즘을 구현할 수 있습니다.
자원의 이름 기반으로 뮤텍스(Mutex, 상호 배제) 잠금을 데이터베이스에서 제공하는 것입니다.
START TRANSACTION;
-- 사용자 정의 잠금 획득
SELECT GET_LOCK('transaction_lock', 5);
-- 트랜잭션 내 작업 수행
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;
UPDATE accounts SET balance = balance + 100 WHERE user_id = 2;
-- 작업 완료 후 잠금 해제
SELECT RELEASE_LOCK('transaction_lock');
COMMIT;
GET_LOCK()도 데이터베이스 수준에서 상호 배제 메커니즘을 구현한 좋은 방법이라고 생각합니다. 하지만, 거래내역 테이블에 접근하는 모든 트랜잭션에 GET_LOCK()과 RELEASE_LOCK()을 적용해야 하고, 추가적으로 데이터베이스에 부하가 가해진다는 단점이 존재합니다.
쿼리가 아닌 애플리케이션 수준에서 가장 빠르고 쉽게 구현할 수 있는 방법이 무엇일지 생각해보았습니다. 기존 서비스에서 캐싱과 메시지 큐로 Redis를 사용하고 있었기에, 이를 이용한 분산락을 구현하는 방법을 생각하였습니다.