본문으로 바로가기 본문 바로가기
WIKI 기술 지식 베이스

멀티 리전 데이터베이스와 fly-replay

원문 보기 위키 갱신

앱과 데이터베이스를 사용자와 가까운 여러 리전에서 실행하고 싶고, 데이터베이스 읽기 레플리카를 배포하면 읽기 요청 성능이 좋아져요. 이는 읽기 트래픽이 많은 데이터베이스 워크로드에 좋은 해결책이에요. 이 문서에서는 프라이머리 데이터베이스와 여러 리전의 읽기 레플리카를 갖춘 앱이 fly-replay 응답 헤더로 쓰기 요청을 프라이머리 리전으로 다시 라우팅하는 개념을 설명해 드릴게요.

출처: 문서

본문

개요

앱과 데이터베이스를 사용자와 가까운 여러 리전에서 실행하고 싶고, 데이터베이스 읽기 레플리카를 배포하면 읽기 요청 성능이 좋아져요. 이것은 읽기 트래픽이 많은 데이터베이스 워크로드를 가진 앱에 좋은 해결책이에요. 하지만 지구 반대편의 프라이머리 데이터베이스에 써야 하면서도 쓰기를 효율적으로 만들고 싶어요.

이 가이드는 프라이머리 데이터베이스와 여러 리전의 읽기 레플리카를 갖추고, fly-replay 응답 헤더를 사용해 쓰기 요청을 프라이머리 리전으로 다시 라우팅하는 앱을 구현하기 위한 개념을 이해하는 데 도움을 줘요. 이 가이드는 다음 경우에 사용하는 것을 고려하세요:

  • 앱 사용자가 두 개 이상의 지리적 리전에 있고 모든 리전에서 낮은 읽기 지연 시간을 원하는 경우
  • 앱의 요청 분포에서 쓰기가 50% 미만인 경우
  • 멀티 프라이머리 데이터베이스 클러스터를 운영할 때 오는 아키텍처 복잡성과 충돌 가능성을 원하지 않는 경우

Fly.io는 멀티 리전 앱을 위해 만들어졌어요

Fly.io는 이미 앱이 배포된 가장 가까운 리전으로 요청을 라우팅해요. 리전별 데이터베이스 읽기 레플리카를 사용하면 요청을 받는 Fly Machine(빠르게 시작되는 VM)이 같은 리전의 데이터베이스 인스턴스에 연결할 수 있으므로 지연 시간을 더 줄일 수 있어요. 읽기 레플리카는 프라이머리 데이터베이스의 전체 부하도 줄여줘요. 이 다중 읽기자-단일 작성자(multi-reader single-writer) 구성은 읽기 트래픽이 많은 앱에 훌륭해요.

앱이 쓰기 요청을 받으면 fly-replay 응답 헤더를 사용해 전체 HTTP 요청을 프라이머리 리전으로 전달할 수 있어요. 이 방법은 다른 리전에서 프라이머리 데이터베이스에 직접 연결하는 것보다 종종 더 빨라요. 특히 엔드포인트에 대한 요청이 여러 데이터베이스 쿼리를 완료해야 하는 경우, 세컨더리 리전에서 각 쿼리에 프라이머리 리전의 로컬 연결에 비해 추가 지연이 발생하기 때문이에요.

작동 방식

fly-replay 응답 헤더는 Fly proxy가 원래 요청을 다른 리전이나 앱의 Machine, 심지어 조직의 다른 앱으로 다시 전달(재생, replay)하도록 지시해요. 이 경우 프라이머리 리전의 Machine으로 쓰기 요청을 재생하게 돼요. fly-replay로 쓰기 요청을 재생하는 것은 하나의 프라이머리와 여러 읽기 레플리카가 있는 데이터베이스에 대해 대부분의 언어와 프레임워크에서 적용할 수 있는 일반적인 패턴이에요.

아래 다이어그램에서 앱은 세 리전 각각에 Machine 하나씩 실행되고 있어요. 프라이머리 리전은 시카고이며, 읽기/쓰기 프라이머리 데이터베이스가 여기 있어요. 리우데자네이루와 암스테르담 두 리전에는 각각 읽기 레플리카가 있는 Machine이 있어요. 이 예제는 단순함을 위해 세 리전을 사용하지만, 세 개 이상의 리전에 배포하고 리전당 하나 이상의 Machine이 같은 읽기 레플리카에 연결할 수도 있어요.

**참고:** 다이어그램에서 `fly-replay` 개념을 설명하기 위해 재생된 HTTP 요청이 리우데자네이루와 암스테르담의 Machine에서 시카고의 Machine으로 직접 가는 것처럼 보여 드렸어요. 실제로는 Fly Proxy가 먼저 엣지 노드를 통해 요청을 다시 라우팅해요. 이 라우팅 비용은 작지만, 극도의 효율성이 중요한 사용 사례라면 더 많은 리전에서 앱을 실행해 그 비용을 완화할 수 있어요.

앱은 Fly.io에서 실행돼요. 데이터베이스도 Fly.io에서 실행할 수 있는데, 그 경우 앱 리전과 데이터베이스 리전이 일치해요. 또는 다른 프로바이더에서 실행하면서 Machine의 Fly.io 리전에 가장 가까운 리전을 고를 수도 있어요.

앱에서 작동하게 만들기

앱에는 다음 로직이 필요해요:

  1. Machine이 읽기 요청을 받으면 해당 리전의 레플리카(또는 프라이머리!)에서 읽는다.
  2. 쓰기 요청은 항상 다른 리전에서 프라이머리 데이터베이스에 연결하는 대신 프라이머리 리전으로 재생한다.

해당 리전의 레플리카에서 읽기

앱에는 구성 가능한 프라이머리 리전이 있고, 프라이머리 데이터베이스도 그곳에(또는 별도 데이터베이스 프로바이더의 경우 가능한 가까이에) 호스팅할 거예요. 프라이머리 리전은 앱의 모든 Machine에 PRIMARY_REGION 환경 변수로 노출돼요.

각 Machine은 자신이 실행 중인 리전을 FLY_REGION 환경 변수로 노출해요.

앱은 FLY_REGION을 PRIMARY_REGION과 비교해 리전 코드가 일치하지 않으면 DATABASE_URL, DB_HOST 또는 다른 데이터베이스 변수를 수정할 수 있어요. 어떤 경우에는 그냥 포트를 바꾸는 것이고(Postgres의 경우), 또 다른 경우에는 데이터베이스가 읽기 레플리카마다 다른 변수를 요구하면 더 복잡해져요(이 Laravel 예제처럼).

쓰기 요청을 프라이머리 리전으로 재생하기

앱은 쓰기 요청을 감지하고 fly-replay 헤더가 포함된 응답을 보내 Fly Proxy가 전체 요청을 프라이머리 리전의 Fly Machine으로 재생하도록 지시할 수 있어요.

쓰기 요청 감지하기

먼저 쓰기 요청을 감지해 프라이머리 데이터베이스로 전달할 방법이 필요해요. 쓰기 요청을 감지하는 방법은 앱과 데이터베이스에 따라 달라져요. Postgres 데이터베이스의 경우 읽기 레플리카에 쓰려고 할 때 "read only transaction" 오류를 잡는 것이 쓰기 요청을 감지하는 가장 간단한 방법일 거예요.

언어가 읽기/쓰기 연결 분리를 지원한다면 미들웨어에 라우트를 설정할 수도 있어요. 또는 앱이 GET, POST, DELETE, PUT, PATCH 요청을 다르게 처리할 수도 있는데, 역시 앱의 미들웨어에서 하는 경우가 많아요. 예를 들어 GET을 사용하는 HTTP 요청은 읽기 요청이고 나머지는 모두 쓰기 요청이라고 가정하면 대부분 잘 작동해요.

fly-replay 헤더가 포함된 응답 보내기

앱이 쓰기 요청을 감지하면, 프라이머리 리전의 리전 코드만 포함된 fly-replay 헤더와 함께 응답을 보내야 해요: fly-replay: region=<region code or env variable>. Fly Proxy는 전체 요청을 지정된 리전으로 재생해요. 코드는 대략 이렇게 생겼어요:

# This code should be in the handler for non-GET requests to the endpoint you 
# want to send to the primary region for write to the primary database.
if os.environ("PRIMARY_REGION") != os.environ("FLY_REGION"):
  # This machine is not in the primary region
  response.headers = {"fly-replay": f"region={os.environ("PRIMARY_REGION")}"}
  return response
else:
  # This machine *is* in the primary region
  # Handle request normally, write to database.

과제와 한계

쓰기 트래픽이 많은 앱은 읽기 레플리카를 사용하면 안 돼요

앱의 요청이 50% 이상 쓰기 요청이라면 리전별 읽기 레플리카를 사용하지 마세요. 그런 경우에는 멀티 리전 프라이머리 또는 그와 유사한 솔루션을 검토해야 해요.

자기 자신의 쓰기를 읽기

레플리카와 프라이머리 사이에 데이터가 일치하지 않는 짧은 기간이 있을 거예요. 내가 쓴 쓰기를 즉시 읽는 것이 필수 요구사항이라면 복제를 어떻게 처리할지 고려해야 해요.

복제 상태를 모니터링하고 복제가 완료되지 않으면 실패하도록 헬스 체크를 설정할 수 있어요.

또 다른 유용한 패턴은 데이터가 복제될 때까지 데이터베이스를 폴링하는 것이에요. Fly Postgres LSN 모듈에는 Postgres 저장 프로시저로 폴링하는 예제가 있어요. 이 패턴은 어떤 데이터베이스 라이브러리에서든 재사용할 수 있어요.

효율이 낮은 방법으로는 레코드가 존재하지 않을 때 발생하는 오류를 잡아 실패한 요청을 재시도하는 것도 있어요.

구현 리소스

fly-replay 패턴을 사용한 멀티 리전 데이터베이스의 예제 구현이 시작을 도와줄 수 있어요:

  • 다중 리전에서 앱 인스턴스와 함께 데이터베이스 레플리카를 실행하기 위한 Fly Ruby gem
  • Ecto와 PostgreSQL을 프라이머리/레플리카 구성으로 Fly.io에서 사용하는 지리적으로 분산된 Elixir 애플리케이션용 Fly Postgres Elixir 라이브러리

관련 자료

과거 블로그 게시물과 문서에서 fly-replay를 사용한 멀티 리전 데이터베이스를 다뤘어요:

더 알아보기 (Learn more)