전체 글

엔지니어링

확장 가능한 시스템을 만드는 원칙

저희가 운영하는 시스템은 사용자 백만 명이 아니라 두 번째 언어에서 무너집니다. 그 대가가 스키마에서, 검색에서, 직접 운영하는 데이터베이스에서 어떻게 나타나는지 적었습니다.

Firdavs · AI 엔지니어9분 분량

여기서 다뤄 본 모든 시스템은 하나같이 멋없는 곳에서 무너졌고, 트래픽 때문에 무너진 것은 하나도 없었습니다. 커뮤니티 플랫폼이 힘들어진 이유는 가이드를 읽는 사람이 너무 많아서가 아니었습니다. 가이드에는 제목이 있고, 제목은 한 가지 이상의 언어로 존재해야 하는데, 일곱 개 번역 중 셋이 비어 있을 때 "존재한다"가 무슨 뜻인지 아무도 정하지 않았기 때문에 바꾸기 어려워진 것입니다.

저희 같은 스튜디오에서 확장은 그런 모양입니다. 저희는 한국어로만 만들어진 소프트웨어가 잘 챙기지 못하는 사람들을 위해 만듭니다. 커뮤니티 플랫폼, 영어 우선 마켓플레이스, 7개 언어로 돌아가는 리테일 상담 도구, 그리고 유학생을 위한 지원 도구입니다. 부하는 초당 요청 수가 아닙니다. 콘텐츠 형태의 수 × 로케일의 수 × 각각이 그려지는 화면의 수입니다. 이걸 틀리면 네 번째 언어가 세 번째보다 비싸고, 세 번째는 이미 두 번째보다 비쌉니다. 작은 팀을 죽이는 것은 그 곡선이지, 동시성이 아닙니다. 한참 먼저 옵니다.

데이터 모델이 다음 언어의 가격을 정합니다

번역 가능한 필드를 저장하는 방법은 세 가지이고, 테이블에 실제 콘텐츠가 들어간 시점에는 그 선택이 사실상 영구적입니다.

형태 로케일 추가 비용 싼 것 비싼 것
언어마다 컬럼 (title_en, title_ko) 번역 가능한 모든 테이블에 마이그레이션, 그리고 그 컬럼을 지목하는 모든 쿼리와 시리얼라이저 정렬, 필터, 인덱싱, 스키마 읽기 두 번째 언어 이후의 모든 것
(행, 필드, 로케일) 키의 번역 테이블 행 하나 삽입 로케일 추가, 부분 번역, 번역률 확인 읽기: 번역 필드마다 조인, 그리고 번역된 값 기준 정렬
로케일을 키로 하는 JSONB 컬럼 객체에 키 하나 읽기. 한 행이 모든 로케일을 담고 있으니까요 정렬과 필터. 실제로 정렬하는 로케일마다 표현식 인덱스가 필요합니다

콘텐츠에는 세 번째를 씁니다. 그 선택을 옹호하겠지만, 청구서는 실재합니다. 한국어 독자를 위해 가이드를 가나다순으로 정렬하려면 (title->>'ko')에 인덱스가 필요하고, 자기만의 정렬 목록을 갖는 다른 로케일마다 별도의 인덱스가 필요합니다. Postgres는 일곱 개를 하나의 인덱스로 처리해 주지 않습니다. 그래서 정한 규칙은 이렇습니다. 어떤 화면이 그 로케일에서 그 필드로 정렬할 때 비로소 그 로케일이 표현식 인덱스를 갖습니다. 그전에는 아닙니다. 지금 인덱스는 세 개입니다. 나머지 네 로케일은 최신순으로 정렬하고, 최신순은 로케일별 인덱스가 전혀 필요 없습니다.

언어마다 컬럼을 두는 형태는 코드 리뷰에서 가장 좋아 보이고 가장 나쁘게 늙습니다. 언어가 둘일 때는 괜찮습니다. 일곱이 되면 번역 필드마다 컬럼이 일곱 개, 분기가 일곱 개인 시리얼라이저, 그리고 고객이 베트남어를 요청할 때마다 마이그레이션이 됩니다.

폴백 체인은 관례가 아니라 함수입니다

이걸 가르쳐 준 버그는 같은 카드 안에서 한국어 본문 위에 영어 제목을 그렸습니다. 두 값 다 존재했고, 두 값 다 옳았고, 결과는 고장 난 것처럼 보였습니다. 다른 행에서는 번역이 null이 아니라 빈 문자열로 저장돼 있어서 "번역됐는가" 검사를 통과했고, 카드가 빈 제목을 그렸습니다.

이제 해석은 모든 시리얼라이저가 호출하는 함수 하나에 삽니다. 요청된 로케일, 그다음 콘텐츠가 작성된 로케일, 그다음 영어 순으로 훑고, 값과 함께 어느 로케일로 해석됐는지를 돌려줍니다. 그러면 화면이 조용히 시치미를 떼는 대신, 독자가 무엇을 보고 있는지 말해 줄 수 있습니다. 조용한 폴백은 저희는 볼 수 없고 사용자는 볼 수 있는 버그이고, 그건 가능한 최악의 조합입니다.

대가가 있습니다. 전송되는 모든 번역 필드가 이제 문자열이 아니라 객체라서 페이로드가 두꺼워지고 프론트엔드 타입이 지저분해집니다. 그래도 받아들였습니다. 대안은 "사이트가 좀 이상해요"라고만 접수되는 종류의 버그였기 때문입니다.

검색에서 다국어 데이터베이스는 더 이상 시치미를 떼지 못합니다

Postgres 전문 검색은 컬럼마다 언어 설정을 원합니다. to_tsvector('english', ...)는 어간을 추출하므로 "banking"으로 검색하면 "Banks and transfers"라는 제목의 가이드가 걸립니다. 기본 Postgres에는 한국어 설정이 없으므로, 정직한 선택지는 simple입니다. 어간도 추출하지 않고 교착 형태를 나누지도 않습니다. 회원 가입을 두 토큰으로 입력한 독자와 회원가입을 한 덩어리로 쓴 가이드는 만나지 못합니다.

두 설정 어느 쪽도 건드리지 못하는 세 번째 경우가 있습니다. 라틴 문자 키보드를 쓰는 독자가 한국 브랜드를 로마자로 검색하는 경우입니다. 테이블의 모든 tsvector가 그 사람에게는 무용지물입니다.

그래서 매처를 하나가 아니라 둘 돌립니다.

-- 언어 설정마다 tsvector 하나, 각각 자기 GIN 인덱스를 가집니다
ALTER TABLE guide ADD COLUMN search_en tsvector GENERATED ALWAYS AS (
  to_tsvector('english', coalesce(title->>'en','') || ' ' || coalesce(body->>'en',''))
) STORED;

ALTER TABLE guide ADD COLUMN search_ko tsvector GENERATED ALWAYS AS (
  to_tsvector('simple', coalesce(title->>'ko','') || ' ' || coalesce(body->>'ko',''))
) STORED;

CREATE INDEX guide_search_en_idx ON guide USING gin (search_en);
CREATE INDEX guide_search_ko_idx ON guide USING gin (search_ko);

-- 로마자 표기와 오타를 위해, 제목의 모든 로케일에 트라이그램
CREATE INDEX guide_title_trgm_idx ON guide USING gin (title_all gin_trgm_ops);

매처가 둘이면 서로 비교할 수 없는 점수가 둘 나옵니다. ts_rank와 트라이그램 유사도는 척도가 다르고, 그 둘을 가중 합산한 값은 누군가 어느 오후에 지어낸 숫자입니다. 저희는 합치지 않습니다. tsvector 결과가 자기 순위대로 먼저 오고, 트라이그램 쿼리는 tsvector 쿼리가 한 페이지를 채우지 못할 때만 돕니다. 그 결과는 "비슷한 결과"라고 보이게 표시된 구분선 아래에 붙습니다. 이 규칙도 임의적이지만, 독자에게 설명할 수 있고 한곳에서 바꿀 수 있는 방식으로 임의적입니다.

이 모든 값은 쓰기가 냅니다. 생성 컬럼은 모든 삽입과 수정에서 계산되고, 두 GIN 인덱스가 동시에 건드려집니다. 가이드에는 명백히 맞는 거래입니다. 가이드는 서른 몇 개이고, 가장 많이 읽힌 것이 1,100회를 넘겼으며, 각 가이드는 평생 몇 번 수정됩니다. 마켓플레이스 게시글은 정반대입니다. 판매자가 올린 첫 한 시간 동안 가격과 사진을 반복해서 고치기 때문입니다. 그래서 게시글은 제목과 카테고리만 색인하고, 설명은 tsvector에서 아예 뺍니다.

마켓플레이스는 읽기 중심입니다, 아닌 그 순간 전까지는

둘러보기가 압도적입니다. 마켓플레이스로 오는 거의 모든 요청은 누군가 카테고리를 스크롤하는 것이고, 그중 아주 얇은 일부만이 올리거나, 고치거나, 삽니다. 그 비율 때문에 카테고리 목록 캐싱이 명백한 첫 최적화가 되고, 그건 판매자가 물건을 올리고, 새로고침하고, 자기 물건이 안 보여서 다시 올리는 순간까지 옳습니다. 이제 게시글이 두 개이고, 혼란스러운 판매자가 있고, 문의가 들어옵니다. 캐시는 시킨 일을 정확히 했습니다. 잘못된 것은 제품이었습니다.

작동하는 버전은 이렇습니다. 캐시된 목록은 비로그인 사용자의 것이고, 로그인한 판매자의 응답에는 그 사람 본인의 최근 행을 Postgres에서 직접 읽어 그 위에 얹습니다. 그 추가 쿼리는 정의상 범위가 정해져 있습니다. 한 사람의 최근 게시글은 언제나 몇 개뿐이니까요. 나머지 모두는 TTL만큼 오래된 목록을 봅니다.

이벤트 기반 무효화 대신 짧은 TTL을 고른 것은 의도한 선택입니다. 이벤트 기반 무효화가 더 정확하고, 동시에 분산 시스템입니다. 발행자, 구독자, 재시도 정책, 데드레터 경로, 그리고 메시지 하나가 유실되면 카테고리 페이지가 무기한 틀린 채로 남는데 알람이 울릴 곳도 없는 실패 모드까지 따라옵니다. TTL은 알려진, 한정된 시간 동안 틀리고 그다음 스스로 고쳐집니다. 저희 규모에서는 그쪽이 더 나은 엔지니어링이고, 그렇지 않게 되는 날에는 알게 됩니다. 불평이 구체적일 테니까요.

카운터는 읽기의 옷을 입은 쓰기입니다

가이드에는 조회수와 좋아요가 붙고, 그 수치는 장식이 아닙니다. 플랫폼이 무엇을 위로 올릴지 정하는 근거입니다. 천 명이 읽은 가이드는 그 카테고리 맨 위에 있어야 할 가이드입니다.

순진한 구현은 한 문장입니다.

UPDATE guide SET views = views + 1 WHERE id = $1;

라이브러리에서 가장 인기 있는 가이드에서는, 이 문장이 동시에 접속한 모든 독자를 테이블에서 가장 뜨거운 단 하나의 행에 걸린 락 뒤에 세웁니다. 그리고 조회마다 죽은 튜플을 하나씩 만들어 나중에 오토배큠이 치우게 합니다. 읽기 경로가 조용히 쓰기 병목이 됐고, 가이드가 잘될수록 정확히 더 나빠집니다.

증가분은 Redis로 갑니다. 주기적인 작업이 그것을 Postgres로 흘려보내고, 읽기는 저장된 값에 아직 반영되지 않은 증가분을 더해 돌려줍니다. Redis가 죽으면 한 주기 분량의 조회수를 잃습니다. 조회 카운터라면 잃어도 괜찮은 것입니다. 제가 지키는 선은 이렇습니다. 이 방식은 조금 어긋나도 아무도 항의하지 않을 것에만 허용되고, 돈이나 자격에 붙은 것에는 절대 쓰지 않습니다. 주문에 닿는 카운터는 어느 것도 Redis에만 살지 않습니다.

데이터베이스를 직접 가진다는 것은 그 멋없는 5분을 갖는다는 뜻입니다

운영 환경은 저희가 소유한 하드웨어 위, Docker 안, 터널 뒤에서 돕니다. Postgres, Redis, API, 백그라운드 워커입니다. 예측 가능한 비용과 완전한 통제권을 사는 대신, 장비가 전원을 잃는 날 그 둘 모두에 대한 값을 치릅니다.

비정상 종료 뒤에 Postgres는 미리 쓰기 로그를 재생하고, 그 전에 데이터 디렉터리 전체를 fsync합니다. 저희 디스크에서는 몇 분이 걸립니다. 그때쯤이면 다른 컨테이너는 전부 이미 떠 있습니다. 재시작 정책은 모든 것을 한꺼번에 띄우고 부팅 시의 의존 순서를 지켜 주지 않기 때문입니다. API는 앱 초기화 중에 데이터베이스 커넥션을 열고, 거기서 멈추고, 포트를 끝내 열지 않습니다. 그 앞의 터널에는 보낼 원본이 없습니다. 바깥에서 보면 사이트 전체가 게이트웨이 오류이고, 데이터베이스는 죽은 것처럼 보입니다.

죽지 않았습니다. 일하고 있고, 자기 로그에 한 줄씩 그렇게 적고 있습니다. 경과 시간 카운터가 올라가면서요.

처음에 제가 저지른 실수가 적어 둘 값이 있는 부분입니다. 재시작했습니다. Postgres 이미지는 SIGINT에서 멈추고, 그건 빠른 종료이고, 이미 진행 중인 복구를 중단시킵니다. 다음 시작은 복구를 처음부터 다시 합니다. 재시작할 때마다 시계가 0으로 돌아갔고, 밖에서 헬스 엔드포인트만 보고 있으면 "정상적으로 복구 중"과 "루프에 갇힘"은 똑같아 보입니다.

그 한 시간에서 세 가지가 나왔습니다.

  • 데이터베이스 헬스체크에 진짜 복구를 덮을 만큼 긴 start period를 줬습니다. 그래서 일하는 동안 unhealthy가 아니라 starting으로 보고합니다. unhealthy에 반응하는 어떤 워치독도 이제 구조적으로 상황을 악화시킬 수 없습니다.
  • API 진입점은 import 시점에 커넥션을 열고 멈추는 대신, 횟수가 정해진 재시도 루프로 데이터베이스를 기다립니다. 분명한 메시지를 남기고 종료하는 프로세스는 진단할 수 있습니다. import 안에서 멈춘 프로세스는 아닙니다.
  • 부팅은 재시작 정책 더미가 아니라 순서가 명시된 스크립트입니다. 데이터베이스 먼저, 연결을 받을 때까지 기다린 뒤, API와 워커, 그다음 운영이 아닌 것들. 그 밖의 어떤 것도 부팅 시 아예 뜨지 않습니다.

셋 아래에 깔린 것은 코드가 아니라 행동에 관한 규칙입니다. 데이터베이스가 복구 중일 때 올바른 행동은 경과 카운터를 두 번 읽어 올라가는지 확인한 다음, 아무것도 하지 않는 것입니다. 아무것도 하지 않는 것이 가장 익히기 어려운 운영 기술입니다. 사이트가 죽어 있는 동안 모든 본능이 무언가를 입력하라고 하기 때문입니다.

요청 안에 있으면 안 되는 일

학생 지원 도구는 제출할 수 있는 문서를 만들어 냅니다. 작성된 이력서와 자기소개서를, 인쇄할 수 있고 PDF로 내려받을 수 있게요. 학생들은 그것을 한 번만 만들지 않습니다. 날짜를 고치고 다시 만들고, 전화번호를 발견하고 또 다시 만듭니다.

문서 렌더링은 CPU를 씁니다. 요청 안에서 처리하면 학생 한 명의 내려받기가 그 시간 내내 워커 프로세스 하나를 붙잡고, 워커 풀은 작고 고정돼 있습니다. 동시 내려받기가 충분히 몰리면 사이트의 다른 모든 요청이 문서 뒤에 줄을 섭니다. 문서와 아무 상관 없는 요청까지요. 사이트가 넘어지지는 않습니다. 고르게 느려집니다. 그게 더 진단하기 어렵습니다. 가리킬 오류가 어디에도 없기 때문입니다.

CPU를 쓰는 것과 외부를 호출하는 것은 전부 큐로 갑니다. 요청은 작업을 기록하고 반환하고, 결과물은 준비되면 나타납니다. 인라인으로 그리는 것보다 부품도 많고 코드도 많습니다. 그리고 그게 느린 기능 하나와 느린 제품 하나의 차이입니다.

이 문서는 결정적이기도 합니다. 같은 서식 내용은 매번 같은 파일을 만듭니다. 그래서 결과물은 내용의 해시를 키로 저장하고, 아무것도 바꾸지 않고 다시 만든 학생은 이미 만들어 둔 파일을 받습니다. 성능 기법이라기보다는 사람들이 실제로 서식을 어떻게 쓰는지에 대한 인정에 가깝습니다.

다시 할 거래, 그리고 다시 하지 않을 거래

명시적 폴백을 갖춘 JSONB 번역은 매번 다시 고르겠습니다. 정렬하는 로케일마다 표현식 인덱스 하나, 그리고 전송되는 페이로드가 두꺼워지는 값을 치릅니다. 그 대신 얻은 것은, 폰샵 도구에 여섯 번째와 일곱 번째 언어를 더하는 일이 스키마 마이그레이션에 코드 전체 쿼리 훑기가 아니라 콘텐츠 작업이었다는 사실입니다. 그게 전부입니다. 두 번째 언어를 싸게 만들어야 일곱 번째가 가능해집니다.

다시 하지 않을 것은, 적어도 이렇게 이른 시점에는 하지 않을 것은, 그것 때문에 깨어 있는 일이 직무에 적힌 사람이 아무도 없는 상태에서 주 운영 데이터베이스를 저희 하드웨어에 올린 것입니다. 엔지니어링은 탄탄했고 위의 수정들은 옳은 수정이었습니다. 어떤 견적에도 등장하지 않은 비용은, 복구 시간이 이제 사람이라는 것입니다. 그리고 작은 팀에는 사람이 적습니다. 내일 같은 구성을 시작한다면 데이터베이스만 빼고 전부 자체 호스팅하자고 주장하겠습니다. 제 취향에 반대하는 쪽으로요.

두 거래 다 같은 습관에서 나옵니다. 하나씩 더할 때마다 더 비싸지는 것을 찾아내고, 그것이 아직 다운타임을 동반한 마이그레이션이 아니라 스키마 결정일 때 값을 치르는 것.

주제

  • 아키텍처
  • 확장성
  • PostgreSQL
  • 다국어 설계

공유하기

Firdavs

Firdavs

AI 엔지니어

백엔드와 프론트엔드를 오가며 AI 기능을 만듭니다.

프로필 자세히 보기

읽는 것과 실제로 내놓는 것은 다릅니다.

여기 적힌 것 중에 지금 겪고 계신 문제가 있다면 알려주세요. 무엇이 필요한지, 대략 얼마가 드는지, 그리고 저희가 적임자인지 말씀드리겠습니다.