RDB·Firestore 동기화: 읽기 모델과 폴백 설계
반복 조인을 줄이면서 RDB를 원본으로 유지하는 Firestore 읽기 모델, 체크포인트·멱등 재처리·기존 API 폴백을 설명한다.
이 글의 목차쿼리 개선·캐시·읽기 모델 중 무엇을 고를까9
홈과 검색 화면의 조회 경로를 살펴보다가 비슷한 데이터가 매번 원본 데이터베이스에서 다시 조립되는 것을 발견했다.
브랜드, 메뉴와 판매 가능 여부를 한 화면에 보여 주려면 여러 테이블을 연결하는 조인이 반복됐다.
조인은 서로 나뉜 표에서 관계가 있는 행을 합치는 작업이다.
정확한 원본을 다룰 때는 자연스러운 선택이지만, 읽기가 몰리는 화면에서 같은 조립을 계속 반복하면 데이터베이스가 화면 전용 계산까지 떠안는다.
당시 해결 방향은 원본 데이터베이스를 없애는 것이 아니었다.
쓰기와 업무 규칙은 원본에 남겨 두고, 화면이 바로 읽을 수 있는 모양을 Cloud Firestore에 따로 만드는 것이었다.
RDB(Relational Database)는 데이터를 테이블과 관계로 나누어 저장하는 관계형 데이터베이스다.
주문과 결제처럼 일관된 변경이 중요한 업무에는 잘 맞지만, 화면마다 같은 여러 테이블을 계속 합쳐야 하면 읽기 전용 계산이 반복될 수 있다.
Firestore는 행과 테이블 대신 문서와 컬렉션을 사용하는 문서형 데이터베이스다.
여기서 문서는 화면 하나가 필요한 브랜드와 메뉴 정보를 미리 모아 둔 작은 JSON 기록에 가깝다.
Firestore 공식 제한상 문서 하나는 최대 1MiB이므로, 관련 데이터를 끝없이 한 문서에 모으기보다 화면이 실제로 함께 읽는 범위를 정해야 한다.
쿼리 개선·캐시·읽기 모델 중 무엇을 고를까
조회가 느리다고 바로 Firestore를 추가할 필요는 없다.
상품 상세처럼 원본 테이블 몇 개를 한 번 조회하는 화면이라면 인덱스와 SQL 실행 계획부터 보는 편이 단순하다.
같은 결과를 짧은 시간 반복해서 읽고 잠시 오래된 값이 허용된다면 만료시간이 있는 캐시가 더 작게 풀 수 있다.
내가 본 홈과 검색은 브랜드·메뉴·판매 가능 여부를 같은 모양으로 자주 조립했고, 원본 변경이 생기면 해당 화면용 결과도 계속 따라가야 했다.
그래서 사라져도 다시 채우는 임시 값보다, 원본에서 언제든 재생성할 수 있는 읽기 모델이 맞았다.
| 상황 | 먼저 검토할 선택 | 이유 |
|---|---|---|
| 쿼리 하나가 불필요하게 많은 행을 읽는다 | RDB 인덱스와 SQL 개선 | 저장소를 늘리지 않고 원인을 직접 줄인다 |
| 같은 결과가 짧은 시간 반복되고 오래된 값이 허용된다 | 만료시간이 있는 캐시 | 동기화 상태를 오래 관리할 필요가 없다 |
| 여러 화면이 같은 조립 결과를 반복해서 읽는다 | Firestore 같은 읽기 모델 | 화면이 필요한 모양을 미리 만들 수 있다 |
| 결제·재고 차감처럼 최신 원본이 반드시 필요하다 | 원본 RDB 직접 조회 | 동기화 지연이 잘못된 업무 판단으로 이어질 수 있다 |
읽기 모델은 원본보다 잠시 늦을 수 있다.
이 차이를 동기화 지연이라고 하며, 몇 초의 지연도 허용할 수 없는 화면에는 폴백만으로 해결하려 하지 말아야 한다.
이 구조에서 최종 판단의 기준이 되는 원본 RDB를 source of truth라고 부른다.
원본 변경 뒤 읽기 모델이 조금 늦게 같은 상태에 도달하는 특성은 eventual consistency, 곧 최종적 일관성이다.
사용자에게 “저장 완료”를 보여 준 직후 어느 화면까지 새 값을 반드시 보여 줘야 하는지 정해야 허용 가능한 지연도 정할 수 있다.
캐시를 하나 붙이는 일보다 범위가 컸다
처음에는 자주 조회하는 결과를 캐시에 넣는 문제처럼 보였다.
하지만 구현해야 할 것은 시간이 지나면 사라져도 되는 임시 복사본이 아니라, 원본의 변경을 계속 따라가는 화면용 데이터였다.
이런 복사본을 읽기 모델이라고 부른다.
읽기 모델은 원본을 대신해 모든 업무를 처리하지 않고, 특정 화면이 빠르게 읽기 좋은 형태만 책임진다.
브랜드 문서에는 판매 재고 정보를 합쳤고, 메뉴 문서에는 판매 재고와 브랜드 정보를 함께 넣었다.
화면은 여러 관계를 다시 조인하는 대신 문서 하나를 읽을 수 있게 됐다.
이 구조는 같은 데이터를 두 곳에 저장하기 때문에 동기화가 틀리면 더 위험해질 수도 있다.
초기 적재보다 중요했던 것은 어디까지 처리했는지 잊지 않는 일이었다.
변경 이력과 체크포인트를 함께 뒀다
원본 데이터가 바뀔 때마다 브랜드와 메뉴의 변경 이력에 증가하는 식별자가 남았다.
동기화 작업은 마지막으로 처리한 식별자보다 큰 변경만 가져와 해당 문서를 다시 만들었다.
마지막 처리 위치를 저장한 값을 체크포인트라고 부른다.
책갈피가 있으면 작업이 다시 시작돼도 처음부터 모든 페이지를 읽지 않고 이어서 처리할 수 있다.
실제 구조에서 이름만 일반화한 코드는 다음과 같다.
const checkpoint = await metaStore.get("menu-sync");
const changes = await findChangesAfter(checkpoint.lastId);
if (changes.length > 0) {
const menuIds = changes.map((change) => change.menuId);
const documents = await buildMenuDocuments(menuIds);
await firestore.updateList(documents, "menus", "menuId");
await metaStore.update({
lastId: Math.max(...changes.map((change) => change.changeId)),
});
}findChangesAfter()는 체크포인트 이후의 변경만 읽어 처리 범위를 줄인다.
buildMenuDocuments()는 원본 데이터를 화면이 읽을 문서 모양으로 조립한다.
마지막 metaStore.update()는 문서 갱신이 끝난 뒤에 실행해 처리하지 않은 변경을 건너뛰지 않게 한다.
이 코드는 구조를 설명하기 위해 단순화한 예시다.
여러 작업자가 동시에 실행되거나 한 묶음의 일부만 저장될 수 있는 환경이라면 체크포인트 소유권, 중복 실행과 부분 실패를 추가로 다뤄야 한다.
Firestore의 트랜잭션은 여러 읽기와 쓰기를 하나의 원자적 작업으로 묶어 전부 성공하거나 전부 적용하지 않을 수 있게 한다.
다만 원본 관계형 DB의 변경 이력과 Firestore 쓰기는 서로 다른 시스템에 있으므로 Firestore 트랜잭션 하나만으로 둘을 동시에 확정할 수는 없다.
그래서 이 구조에서는 같은 문서를 다시 만들어도 결과가 같도록 갱신을 멱등하게 만드는 것이 중요하다.
멱등은 같은 작업을 두 번 실행해도 최종 상태가 한 번 실행한 것과 같다는 뜻이다.
실패하면 체크포인트보다 문서를 먼저 의심했다
동기화 작업의 실패는 세 구간으로 나눠 볼 수 있었다.
- 변경 이력을 읽기 전에 실패하면 체크포인트가 그대로이므로 다음 실행에서 다시 시작한다.
- 문서 생성 중 실패하면 저장하지 않고 원인을 고친 뒤 같은 변경을 다시 읽는다.
- 문서는 저장됐지만 체크포인트 갱신 전에 실패하면 다음 실행에서 같은 문서를 다시 쓴다.
세 번째 경우 때문에 중복 실행을 오류로 취급하지 않는 저장 방식이 필요했다.
반대로 문서 저장 전에 체크포인트부터 옮기면 실패한 변경이 다음 실행 대상에서 빠질 수 있으므로 순서를 뒤집지 않았다.
비슷한 문제를 배치 크기와 재시도 범위 관점에서 다룬 내용은 큐 작업을 복구 가능한 묶음으로 나눈 기록에 따로 정리했다.
Firestore가 비어 있을 때 기존 API로 돌아갔다
전환 첫날부터 모든 화면이 새 저장소만 믿게 만들지는 않았다.
Firestore 문서가 아직 없으면 기존 API가 원본 데이터베이스에서 값을 읽어 응답하도록 폴백을 뒀다.
폴백은 주 경로가 준비되지 않았을 때 사용하는 예비 경로다.
초기 적재가 덜 됐거나 특정 문서의 동기화가 늦어도 화면 전체가 비는 일을 막을 수 있었다.
폴백이 영구적인 해결책은 아니다.
새 경로의 누락을 계속 가려 버릴 수 있으므로 Firestore 조회 실패와 기존 API 사용 횟수를 따로 기록하고, 예상보다 오래 유지되면 동기화 오류로 조사해야 한다.
문서가 없다는 사실만으로 “동기화가 아직 안 됐다”고 단정해서도 안 된다.
원본에서도 삭제된 정상 상태와 동기화가 밀린 상태를 구분하려면 삭제 사건, 문서 버전이나 마지막 반영 시각 같은 상태가 필요하다.
이 구분이 없으면 폴백이 오래된 데이터를 다시 보여 주거나 실제 동기화 실패를 조용히 숨길 수 있다.
원본 DB의 느린 조회를 찾는 과정은 시간 범위 쿼리를 병렬로 나누기 전에 확인한 것과도 이어진다.
쿼리 하나를 빠르게 만드는 일과 반복 조립 자체를 읽기 경로에서 빼는 일은 서로 다른 선택이다.
확인한 결과와 다음부터 함께 측정할 기준
2023년 8월에 브랜드와 메뉴마스터의 화면용 데이터를 Firestore 문서로 읽는 경로를 만들었다.
원본 DB의 변경 이력을 기준으로 필요한 문서만 증분 동기화했고, 마지막 처리 식별자를 체크포인트로 저장했다.
Firestore 문서가 없을 때는 기존 API를 사용하는 폴백도 남겼다.
확인 가능한 구현 기록은 관련 핵심 변경에서 13개 파일, 222줄 추가와 88줄 삭제다.
원본의 쓰기 모델과 화면용 읽기 모델을 분리했다는 구조 변화도 확인할 수 있다.
구조 변화의 효과를 숫자로 이어 보려면 전환 전후의 원본 DB 쿼리 수, 읽기 지연시간, 폴백 비율과 동기화 지연을 같은 대시보드에서 측정해야 한다.
당시 기록에는 부하 감소율과 응답시간 변화가 남아 있지 않아 확인된 구현 결과만 제시했다.
다음 작업부터는 네 지표를 전환 기준에 포함하면 구조가 바뀌었다는 사실과 운영 효과를 한 흐름으로 검증할 수 있다.
다시 만든다면 먼저 확인할 목록
- 원본 데이터의 소유자는 기존 DB로 유지하고 읽기 모델이 역으로 원본을 수정하지 않게 한다.
- 변경 이력에는 순서를 비교할 수 있는 식별자와 다시 처리할 대상의 식별자를 함께 남긴다.
- 문서 저장이 끝난 뒤 체크포인트를 옮기고 중복 처리는 안전하게 허용한다.
- 삭제도 하나의 변경 사건으로 남겨 Firestore에 오래된 문서가 남지 않게 한다.
- 초기 전체 적재와 이후 증분 동기화가 같은 문서 생성 규칙을 사용하게 한다.
- 폴백 횟수와 가장 오래 밀린 변경의 시간을 관측해 조용한 실패를 찾는다.
- 문서 필드와 쿼리에 필요한 인덱스를 함께 관리한다.
- 원본 샘플과 Firestore 문서를 주기적으로 대조하는 재동기화 경로를 둔다.
Firestore는 기본적으로 문서 필드에 자동 인덱스를 만들고, 여러 필드를 조합한 쿼리는 별도 인덱스가 필요할 수 있다.
인덱스는 책의 찾아보기처럼 조건에 맞는 문서 위치를 미리 정리하지만, 쓰기 때 함께 갱신해야 하므로 실제 쿼리에 필요한 필드만 설계하는 편이 낫다.
읽기 모델은 복사본보다 복구 방법이 중요했다
이 작업의 결과는 Firestore를 사용했다는 사실보다 역할을 나눈 데 있다.
원본 DB는 쓰기와 업무 규칙을 계속 맡았고, Firestore는 홈과 검색이 바로 읽을 문서를 맡았다.
운영에서 더 중요했던 것은 변경 이력, 체크포인트, 멱등한 재처리와 기존 API 폴백이었다.
읽기 모델은 언젠가 원본과 어긋날 수 있다는 전제에서 다시 맞출 방법까지 있어야 비로소 사용할 수 있다.
반복 조인이 읽기 화면의 부담이 되는 조건에서는 원본 DB를 쓰기 기준으로 유지하고, Firestore 읽기 모델에 변경 이력과 체크포인트를 순서대로 반영하며, 누락 시 기존 API로 폴백하는 구조가 이번 문제를 닫은 해결책이었다.