대규모 읽기 및 쓰기 이해
이 문서를 참고하여 안정성 있는 고성능 애플리케이션 설계에 대해 정보에 입각한 결정을 내리세요. 이 문서에는 고급 Firestore 주제가 포함되어 있습니다. Firestore를 처음 사용하는 경우에는 빠른 시작 가이드를 참조하세요.
Cloud Firestore는 Firebase 및 Google Cloud의 휴대기기, 웹, 서버 개발에 사용되는 유연하고 확장 가능한 데이터베이스입니다. Firestore로 시작하면 강력하고 다채로운 애플리케이션을 손쉽게 작성할 수 있습니다.
데이터베이스 크기와 트래픽 증가에 따라 애플리케이션이 계속 원활하게 작동하도록 하려면 Firestore 백엔드에서의 읽기 및 쓰기 방식을 이해하는 것이 도움이 됩니다. 또한 읽기 및 쓰기와 스토리지 레이어 간의 상호작용, 성능에 영향을 줄 수 있는 기본 제약조건을 이해해야 합니다.
애플리케이션을 설계하기 전에 다음 섹션에서 권장사항을 참조하세요.
고급 구성요소 이해
다음 다이어그램은 Firestore API 요청과 관련된 고급 구성요소를 보여줍니다.
Firestore SDK 및 클라이언트 라이브러리
Firestore는 다양한 플랫폼의 SDK 및 클라이언트 라이브러리를 지원합니다. 앱에서 직접 Firestore API로 HTTP 및 RPC 호출을 수행할 수도 있지만, 클라이언트 라이브러리가 API 사용을 간소화하고 권장사항을 구현할 수 있도록 추상화 레이어를 제공합니다. 또한 오프라인 액세스, 캐시 등과 같은 추가 기능을 제공할 수도 있습니다.
Google 프런트엔드(GFE)
이는 모든 Google Cloud 서비스에 공통적으로 적용되는 인프라 서비스입니다. GFE는 들어오는 요청을 수락하여 관련 Google 서비스(이 컨텍스트에서는 Firestore 서비스)로 전달합니다. 또한 서비스 거부 공격에 대한 보호를 포함한 다른 중요한 기능도 제공합니다.
Firestore 서비스
Firestore 서비스는 인증, 승인, 할당량 확인, 보안 규칙을 포함하는 API 요청에 대해 검사를 수행하고 트랜잭션도 관리합니다. 이 Firestore 서비스에는 데이터 읽기 및 쓰기를 위해 스토리지 레이어와 상호작용하는 스토리지 클라이언트가 포함되어 있습니다.
Firestore 스토리지 레이어
Firestore 스토리지 레이어는 데이터 및 메타데이터와 Firestore에서 제공하는 관련 데이터베이스 기능을 모두 저장합니다. 다음 섹션에서는 Firestore 스토리지 레이어의 데이터 구성 방식과 시스템 확장 방식을 설명합니다. 데이터 구성 방식을 학습하면 확장 가능한 데이터 모델을 설계하고 Firestore의 권장사항을 더 잘 이해하는 데 도움이 됩니다.
키 범위 및 분할
Firestore는 NoSQL 문서 중심의 데이터베이스입니다. 컬렉션의 계층 구조로 정리되는 문서에 데이터를 저장합니다. 컬렉션 계층 구조 및 문서 ID는 각 문서에 대해 단일 키로 변환됩니다. 이 단일 키에 의해 문서가 논리적으로 저장되고 사전순으로 정렬됩니다. 키 범위라는 용어는 사전순으로 연속된 키 범위를 나타냅니다.
일반적인 Firestore 데이터베이스는 너무 커서 단일 물리적 머신에는 적합하지 않습니다. 데이터의 워크로드가 너무 무거워서 머신 하나로는 처리할 수 없는 경우도 있습니다. Firestore는 대규모 워크로드를 처리하기 위해 여러 머신 또는 스토리지 서버에서 저장하고 제공할 수 있는 별도의 조각으로 데이터를 분할합니다. 이러한 파티션은 분할이라는 키 범위 블록의 데이터베이스 테이블에서 만들어집니다.
동기식 복제
데이터베이스는 항상 자동으로 동기식으로 복제된다는 점에 유의해야 합니다. 영역에 액세스할 수 없는 경우에도 데이터의 분할을 사용할 수 있도록 여러 영역에 데이터 분할 복제본이 있습니다. 분할의 여러 복사본에 대한 일관된 복제는 합의를 위해 Paxos 알고리즘이 관리합니다. 각 분할의 복제본 한 개가 Paxos 리더로서 해당 분할에 대해 쓰기를 처리하도록 선택됩니다. 동기식 복제를 사용하면 Firestore에서 항상 최신 버전의 데이터를 읽을 수 있습니다.
결과적으로 확장 가능하며 가용성이 높은 시스템이 구축되어 워크로드 무게와 상관없이 대규모 읽기 및 쓰기 지연 시간이 모두 짧아집니다.
데이터 레이아웃
Firestore는 스키마가 없는 문서 데이터베이스입니다. 그러나 내부적으로는 다음과 같이 스토리지 레이어에 있는 두 개의 관계형 데이터베이스 스타일 테이블에 주로 데이터를 배치합니다.
- 문서 테이블: 문서가 이 테이블에 저장됩니다.
- 색인 테이블: 효율적으로 결과를 가져오고 색인 값을 기준으로 정렬할 수 있는 색인 항목이 이 테이블에 저장됩니다.
다음 다이어그램은 Firestore 데이터베이스의 테이블이 분할된 모습을 보여줍니다. 분할은 서로 다른 3개의 영역에 복제되며 각 분할에는 할당된 Paxos 리더가 있습니다.
단일 리전과 멀티 리전 비교
데이터베이스를 만들 때 리전 또는 멀티 리전을 선택해야 합니다.
단일 리전 위치는 us-west1과 같은 특정 지리적 위치입니다. 앞서 설명한 대로 Firestore 데이터베이스의 데이터 분할에는 선택한 리전 내의 여러 영역에 복제본이 있습니다.
멀티 리전 위치는 데이터베이스의 복제본이 저장되는 정의된 리전 집합으로 구성됩니다. Firestore의 멀티 리전 배포에서 두 리전에는 데이터베이스에 전체 데이터의 전체 복제본이 있습니다. 세 번째 리전에는 전체 데이터 세트를 관리하지는 않지만 복제에는 참여하는 감시 복제본이 있습니다. 여러 리전 간에 데이터를 복제하면 리전 전체가 손실되더라도 데이터를 읽고 쓸 수 있습니다.
리전의 위치에 대한 자세한 내용은 Firestore 위치를 참조하세요.
Firestore의 쓰기 수명 이해
Firestore 클라이언트는 단일 문서를 생성, 업데이트 또는 삭제하여 데이터를 쓸 수 있습니다. 단일 문서에 쓰기 작업을 수행하려면 스토리지 레이어에서 문서 및 관련 색인 항목을 모두 원자적으로 업데이트해야 합니다. 또한 Firestore는 하나 이상의 문서에 대해 여러 읽기 또는 쓰기로 구성된 원자적 작업을 지원합니다.
Firestore는 모든 종류의 쓰기에 관계형 데이터베이스의 ACID 속성(원자성, 일관성, 격리성, 내구성)을 제공합니다. Firestore는 직렬 가능성도 제공합니다. 즉, 모든 트랜잭션이 순차적으로 실행되는 것처럼 나타납니다.
쓰기 트랜잭션의 고급 단계
Firestore 클라이언트가 앞서 언급한 방법 중 하나를 사용하여 쓰기를 실행하거나 트랜잭션을 커밋할 때 내부적으로 이 작업은 스토리지 레이어에서 데이터베이스 읽기-쓰기 트랜잭션으로 실행됩니다. 트랜잭션을 통해 Firestore는 앞에서 언급한 ACID 속성을 제공할 수 있습니다.
트랜잭션의 첫 번째 단계로 Firestore는 기존 문서를 읽고 문서 테이블의 데이터에 적용할 변형을 결정합니다.
여기에는 다음과 같이 색인 테이블에 필요한 업데이트가 포함됩니다.
- 문서에 추가되는 필드는 색인 테이블에 해당 항목을 삽입해야 합니다.
- 문서에서 삭제되는 필드는 색인 테이블에서 해당 항목을 삭제해야 합니다.
- 문서에서 수정 중인 필드는 색인 테이블에서 삭제(이전 값의 경우) 및 삽입(새 값의 경우)이 모두 필요합니다.
앞서 언급한 변형을 계산하기 위해 Firestore에서 프로젝트의 색인 생성 구성을 읽습니다. 색인 생성 구성은 프로젝트의 색인에 대한 정보를 저장합니다. Firestore에서는 두 가지 색인 유형인 단일 필드와 복합을 사용합니다. Firestore에서 생성된 색인에 대한 자세한 내용은 Firestore의 색인 유형을 참조하세요.
변형이 계산되면 Firestore가 트랜잭션 내에서 이를 수집한 후 커밋합니다.
스토리지 레이어의 쓰기 트랜잭션 이해
앞서 설명한 대로 Firestore의 쓰기에는 스토리지 레이어의 읽기-쓰기 트랜잭션이 포함됩니다. 데이터 레이아웃에 따라 데이터 레이아웃에 표시된 대로 쓰기에 하나 이상의 분할이 포함될 수 있습니다.
다음 다이어그램에서 Firestore 데이터베이스에는 단일 영역에 있는 3개의 서로 다른 스토리지 서버에서 호스팅되는 8개의 분할(1~8로 표시)이 있으며 분할은 각각 3개 이상의 서로 다른 영역에 복제됩니다. 각 분할에는 Paxos 리더가 하나씩 있고 Paxos 리더는 분할마다 다른 영역에 있을 수 있습니다.
다음과 같이 Restaurants
컬렉션이 있는 Firestore 데이터베이스를 살펴보겠습니다.
Firestore 클라이언트는 priceCategory
필드 값을 업데이트하여 Restaurant
컬렉션의 문서에 다음과 같은 변경사항을 요청합니다.
다음 고급 단계에서는 쓰기의 일부로 어떤 일이 일어나는지를 설명합니다.
- 읽기-쓰기 트랜잭션을 만듭니다.
- 스토리지 레이어의 문서 테이블에서
Restaurants
컬렉션의restaurant1
문서를 읽습니다. - 색인 테이블에서 문서의 색인을 읽습니다.
- 데이터에 적용할 변형을 계산합니다. 이 경우 다섯 가지 변형이 있습니다.
- M1:
priceCategory
필드의 변경된 값을 반영하도록 문서 테이블의restaurant1
행을 업데이트합니다. - M2 및 M3: 내림차순 및 오름차순 색인의 색인 테이블에서
priceCategory
의 이전 값 행을 삭제합니다. - M4 및 M5: 내림차순 및 오름차순 색인의 색인 테이블에
priceCategory
의 새 값 행을 삽입합니다.
- M1:
- 이러한 변형을 커밋합니다.
Firestore 서비스의 스토리지 클라이언트는 변경할 행의 키를 소유한 분할을 조회합니다. 분할 3이 M1을, 분할 6이 M2~M5를 제공하는 경우를 생각해 보겠습니다. 이러한 모든 분할을 참여자로 포함하는 분산형 트랜잭션이 있습니다. 참여자 분할에는 이전에 데이터를 읽기-쓰기 트랜잭션의 일부로 읽었던 다른 분할도 포함될 수 있습니다.
다음 단계에서는 커밋의 일부로 어떤 일이 일어나는지를 설명합니다.
- 스토리지 클라이언트가 커밋을 실행합니다. 커밋에는 M1~M5 변형이 포함되어 있습니다.
- 분할 3과 6은 이 트랜잭션의 참여자입니다. 분할 3과 같이 참여자 중 하나가 조정자로 선택됩니다. 조정자는 모든 참여자 간에 트랜잭션이 자동으로 커밋 또는 중단되는지 확인합니다.
- 이러한 분할의 리더 복제본이 참여자와 조정자가 수행한 작업을 담당합니다.
- 각 참여자와 조정자는 각각의 복제본으로 Paxos 알고리즘을 실행합니다.
- 리더가 복제본으로 Paxos 알고리즘을 실행합니다. 대부분의 복제본이 리더에
ok to commit
응답을 반환하면 쿼럼이 달성됩니다. - 그런 다음 각 참여자는 준비(2단계 커밋의 첫 번째 단계)가 되면 조정자에게 알립니다. 참여자가 트랜잭션을 커밋할 수 없는 경우에는 전체 트랜잭션이
aborts
입니다.
- 리더가 복제본으로 Paxos 알고리즘을 실행합니다. 대부분의 복제본이 리더에
- 조정자는 자신을 비롯해 모든 참여자가 준비되었음을 확인하면
accept
트랜잭션 결과를 모든 참여자에게 전달합니다(2단계 커밋의 두 번째 단계). 이 단계에서는 각 참여자가 안정적인 스토리지에 커밋 결정을 기록하고 트랜잭션이 커밋됩니다. - 조정자는 Firestore의 스토리지 클라이언트에 트랜잭션이 커밋되었다고 응답합니다. 동시에 조정자와 모든 참가자가 데이터에 변형을 적용합니다.
Firestore 데이터베이스가 작으면 단일 분할이 M1~M5 변형의 모든 키를 소유하는 경우가 있습니다. 이 경우 트랜잭션에는 참여자가 하나뿐이며 앞서 언급한 2단계 커밋이 필요하지 않으므로 쓰기가 더 빨라집니다.
멀티 리전의 쓰기
멀티 리전 배포에서 여러 리전에 복제본을 분산하면 가용성이 높아지지만 성능 비용이 발생합니다. 서로 다른 리전의 복제본 간 통신에는 왕복 시간이 더 오래 걸립니다. 따라서 Firestore 작업의 기준 지연 시간은 단일 리전 배포에 비해 약간 더 깁니다.
Google은 분할의 리더십을 항상 기본 리전에 유지하는 방식으로 복제본을 구성합니다. 이 기본 리전으로부터 트래픽이 Firestore 서버로 수신됩니다. 이러한 리더십 결정이 Firestore의 스토리지 클라이언트와 복제본 리더(또는 다중 분할 트랜잭션의 조정자) 간의 통신 왕복 지연을 줄여줍니다.
Firestore의 각 쓰기에는 Firestore의 실시간 엔진과의 상호 작용도 포함됩니다. 실시간 쿼리에 대한 자세한 내용은 대규모 실시간 쿼리 이해를 참조하세요.
Firestore의 읽기 수명 이해
이 섹션에서는 Firestore의 독립형 비실시간 읽기에 대해 자세히 설명합니다. 내부적으로 Firestore 서버는 이러한 쿼리 대부분을 두 가지 주요 단계로 처리합니다.
- 색인 테이블에 대한 단일 범위 스캔
- 이전 스캔의 결과를 기준으로 문서 테이블에서 포인트 조회
데이터베이스 트랜잭션을 사용하여 스토리지 레이어의 데이터 읽기를 내부적으로 수행하면 일관된 읽기를 보장할 수 있습니다. 그러나 쓰기에 사용되는 트랜잭션과 달리 이러한 트랜잭션은 잠금을 사용하지 않습니다. 대신 타임스탬프를 선택한 다음 해당 타임스탬프에서 모든 읽기를 실행합니다. 잠금을 획득하지 않으므로 동시 실행되는 읽기-쓰기 트랜잭션을 차단하지 않습니다. 이 트랜잭션을 실행하기 위해 Firestore의 스토리지 클라이언트는 읽기 타임스탬프를 선택하는 방법을 스토리지 레이어에 알려주는 타임스탬프 경계를 지정합니다. Firestore의 스토리지 클라이언트에서 선택한 타임스탬프 경계의 유형은 읽기 요청의 읽기 옵션에 따라 결정됩니다.
스토리지 레이어의 읽기 트랜잭션 이해
이 섹션에서는 읽기 유형과 Firestore의 스토리지 레이어에서 읽기가 처리되는 방식을 설명합니다.
강력 읽기
기본적으로 Firestore 읽기에는 strong consistency가 있습니다. strong consistency는 Firestore 읽기가 읽기 시작할 때까지 커밋된 모든 쓰기를 반영하는 최신 버전의 데이터를 반환한다는 뜻입니다.
단일 분할 읽기
Firestore의 스토리지 클라이언트는 읽을 행의 키를 소유한 분할을 조회합니다. 이전 섹션의 분할 3에서 읽어야 한다고 가정해 보겠습니다. 클라이언트는 왕복 지연 시간을 줄이기 위해 가장 가까운 복제본에 읽기 요청을 보냅니다.
선택한 복제본에 따라 이 시점에서 다음과 같은 상황이 발생할 수 있습니다.
- 읽기 요청은 리더 복제본(영역 A)으로 전달됩니다.
- 리더는 항상 최신 상태이므로 읽기를 직접 진행할 수 있습니다.
- 읽기 요청이 리더가 아닌 복제본(예: 영역 B)으로 전달됩니다.
- 분할 3은 내부 상태를 통해 충분한 정보가 있다는 것을 인지하고 읽기를 제공합니다.
- 분할 3은 최신 데이터가 확인되었는지 여부를 인식하지 못할 수 있습니다. 리더에 메시지를 전송해서 읽기 제공을 위해 적용해야 하는 마지막 트랜잭션의 타임스탬프를 요청합니다. 트랜잭션이 적용되면 읽기를 진행할 수 있습니다.
그러면 Firestore에서 응답을 클라이언트에 반환합니다.
다중 분할 읽기
여러 분할에서 읽기를 수행해야 하는 상황에서는 모든 분할에 동일한 메커니즘이 적용됩니다. 모든 분할에서 데이터가 반환되면 Firestore의 스토리지 클라이언트가 결과를 결합합니다. 그러면 Firestore가 이 데이터로 클라이언트에 응답합니다.
비활성 읽기
강력 읽기는 Firestore의 기본 모드입니다. 그러나 리더와 통신이 필요할 수 있으므로 지연 시간이 길어질 수 있습니다. Firestore 애플리케이션은 최신 버전의 데이터를 읽을 필요가 없는 경우가 많으며, 몇 초 정도 비활성 상태일 수 있는 데이터와도 잘 작동합니다.
이러한 경우 클라이언트는 read_time
읽기 옵션을 사용하여 비활성 읽기를 수신하도록 선택할 수 있습니다. 여기서는 데이터가 read_time
에 있었기 때문에 읽기가 수행되며, 가장 가까운 복제본은 지정된 read_time
에 데이터가 있음을 이미 확인했을 가능성이 매우 높습니다.
성능을 크게 높이려면 적정한 비활성 값은 15초입니다. 비활성 읽기의 경우에도 생성된 행은 서로 일관됩니다.
핫스팟 방지
Firestore의 분할은 필요할 때 또는 키 공간이 확장될 때 더 많은 스토리지 서버에 트래픽을 제공하는 작업을 분산하기 위해 자동으로 더 작은 조각으로 분할됩니다. 초과 트래픽을 처리하기 위해 생성된 분할은 트래픽이 사라지더라도 약 24시간 동안 보관됩니다. 따라서 반복적으로 트래픽이 급증하면 분할이 유지되고 필요할 때마다 더 많은 분할이 도입됩니다. 이러한 메커니즘은 트래픽 부하 또는 데이터베이스 크기가 증가하면 Firestore 데이터베이스가 자동 확장되는 데 도움이 됩니다. 하지만 아래에 설명된 대로 주의해야 할 몇 가지 제한사항이 있습니다.
스토리지와 부하를 분할하는 데 시간이 걸리는 데다가, 트래픽을 너무 빨리 늘리면 서비스가 조정되는 동안 지연 시간 증가 또는 기한 초과 오류(일반적으로 핫스팟이라고 함)가 발생할 수 있습니다. 그러므로 키 범위 전반에 작업을 분산하면서 초당 500개의 작업을 수행하는 데이터베이스의 컬렉션에서 트래픽을 늘리는 것이 좋습니다. 점진적으로 늘린 후에 5분마다 최대 50%씩 트래픽을 늘립니다. 이 프로세스를 500/50/5 규칙이라고 하며, 워크로드에 맞게 최적으로 확장되도록 데이터베이스를 배치합니다.
분할은 부하 증가에 따라 자동으로 생성되지만 Firestore에서는 전용 복제 스토리지 서버 집합을 사용하여 단일 문서를 제공할 때까지만 키 범위를 분할할 수 있습니다. 그 결과, 단일 문서에서 지속적으로 많은 양의 작업을 동시 실행하면 해당 문서에 핫스팟이 발생할 수 있습니다. 단일 문서에서 긴 지연 시간이 지속적으로 발생하는 경우 여러 문서에 걸쳐 데이터를 분할하거나 복제하도록 데이터 모델을 수정해야 합니다.
여러 작업에서 같은 문서를 동시에 읽거나 쓰려고 하면 경합 오류가 발생합니다.
Firestore에서 순차적으로 증가/감소하는 키를 문서 ID로 사용하고 초당 작업 수가 상당히 많은 경우 또 다른 특수한 부하 집중 사례가 발생합니다. 급증한 트래픽이 새로 생성된 분할로 이동하기 때문에 여기서 분할을 더 만드는 것은 도움이 되지 않습니다. Firestore는 기본적으로 문서에 있는 모든 필드의 색인을 자동으로 생성하므로, 타임스탬프와 같이 순차적으로 증가/감소하는 값이 포함된 문서 필드의 색인 공간에 움직이는 핫스팟이 생성될 수도 있습니다.
위와 같은 권장사항을 따르면 Firestore는 구성을 조정하지 않고도 임의의 대규모 워크로드를 처리하도록 확장할 수 있습니다.
문제 해결
Firestore는 사용 패턴을 분석하고 핫스팟 문제를 해결하기 위해 특별히 설계된 진단 도구로 Key Visualizer를 제공합니다.
다음 단계
- 권장사항에 대해 자세히 알아보기
- 대규모 실시간 쿼리에 대해 알아보기