📕 전자책 전문 읽기 공유하는 시스템 · 36쪽
브라우저가 PDF를 바로 표시하지 못합니다. 새 탭에서 열기
이 글은 요약해 다시 쓴 것이고, PDF가 전문입니다.

두 번째 고객을 받거나 막 받은 1인 개발자를 위한 글입니다. 읽고 나면 ‘고객 데이터가 옆집으로 새지 않게 하는 것’을 시스템 구조로 지켜내는 원칙과, 그 구조를 점검하는 방법을 갖게 됩니다. 기준 자체는 길지 않습니다. 경계를 어디에 두느냐, 누가 지키느냐, 어떻게 빼먹지 않게 만드느냐. 이 세 가지로 끝납니다.

결론부터 말하겠습니다. 멀티테넌트 사고의 대부분은 평범한 금요일 저녁의 배포에서 시작됩니다. WHERE 절 하나를 빼먹은 쿼리, 테넌트가 없는 캐시 키, 모든 사용자에게 메일을 보내는 밤샘 잡이 전부 그런 얼굴을 합니다. 그래서 멀티테넌트 설계의 진짜 목표는 실수를 구조적으로 어렵게 만드는 일입니다. 성능이 아닙니다.

장면 하나를 그려 보겠습니다. 서버 한 대 위에 두 명 빵집, 여섯 명 디자인 스튜디오, 오십 명 에이전시. 세 테넌트는 같은 사이트에 접속하고 같은 코드를 돌리며 같은 디스크에 데이터를 씁니다. 빵집은 스튜디오의 고객 계약서를 절대 봐서는 안 되고 에이전시가 밤새 무거운 처리를 해도 빵집의 페이지는 같은 속도로 열려야 합니다. 이 두 문장이 멀티테넌트 시스템의 전부입니다. 이 글이 다룰 것은, 이 두 문장이 실수로 깨지지 않게 만드는 구조입니다.

멀티테넌트 격리는 습관이 아니라 구조의 문제 개념을 형상화한 이미지 글의 핵심 개념을 형상화했습니다.

테넌트는 사용자가 아니라 경계다

멀티테넌트 시스템에서 테넌트는 ‘고객’을 가리키는 단위입니다. 같은 애플리케이션 인스턴스를 쓰면서 서로의 데이터가 보이해서는 안 되는 사용자 집합입니다. 한 회사 하나가 하나의 테넌트입니다. 디자인 스튜디오도 테넌트이고 옆집 두 명 빵집도 테넌트입니다.

중요한 구분은 하나입니다. 테넌트는 경계입니다. 같은 테넌트 안에서는 사용자가 데이터를 공유해도 됩니다. 스튜디오 대표와 팀장이 같은 문서를 보는 건 정상적인 운영입니다. 경계 너머로는 한 줄도 보이지 않아야 합니다. 빵집이 스튜디오의 고객 계약서를 읽는 건 허용되지 않는 일이고 허용되면 사고입니다.

이 구분 때문에 ‘사용자 계정별로 데이터를 나눠야지’라는 생각은 이미 출발이 틀립니다. 계정은 경계 안에서 움직이는 단위일 뿐입니다. 로그인한 사용자가 누구든, 먼저 그 사용자가 속한 테넌트가 어디인지 정해져야 하고 그 다음에 그 테넌트 안에서만 데이터를 볼 수 있습니다. 이 순서가 뒤집힌 시스템은 그 뒤의 모든 설계 결정에 같은 오류를 물려줍니다.

멀티테넌트 설계의 거의 모든 결정은 한 질문으로 요약됩니다. 경계를 어디에 두고 누가 어떻게 지켜내느냐. 이 글의 나머지는 그 질문을 순서대로 답하는 과정입니다.

the-multi-tenant-engineer 슬라이드 1

왜 나누지 않고 함께 쓰는가: 시간 문제다

1인 개발자의 가장 큰 자산은 시간이고 가장 큰 부채도 시간입니다. 서른 개의 데이터베이스 인스턴스를 관리할 수 없습니다. 서른 개의 백업 잡을 돌리지도, 서른 건의 업그레이드 티켓에 응답하지도 못합니다. 서버가 하나면 배포는 한 번이고 백업은 한 번이고 보안 패치는 한 번입니다. 고객이 열만 명이더라도 산술은 변하지 않습니다.

그래서 고객이 늘면 자연스러운 본능은 ‘인프라는 하나, 다 같이 쓴다’는 쪽입니다. 멀티테넌트입니다. 한 대의 하드웨어 비용에 여러 고객의 데이터를 얹는 구조는, 1인 개발자가 SaaS로 갈 때 사실상 유일한 현실적인 경로입니다. 고객이 한 명 늘 때마다 서버를 한 대 더 사기 시작하면, 수익이 늘기 전에 운영이 먼저 무너집니다.

반대의 끝, 과격리도 같은 속도로 무너뜨립니다. 고객마다 데이터베이스를 따로 만들어 주는 건 다섯 명까지는 괜찮습니다. 쉰 명이면 마이그레이션 스크립트가 밤새 돌아가고 이백 명이면 그냥 멈춰 섭니다. 마이그레이션이 N배, 백업이 N배, 자격 증명이 N배가 되고 인스턴스 하나가 죽으면 고객 하나가 함께 죽습니다.

격리는 일방통행이라는 사실도 이 선택에 영향을 줍니다. 데이터가 섞이기 전에 나누는 쪽이 압도적으로 쉽습니다. 이미 뒤섞인 데이터를 정직하게 분리하려면, 각 줄이 누구의 것이었는지를 역추적해야 하고 그 역추적은 당신에게만 전해졌던 입말에 의존하게 된다. 입말은 코드와 비슷하지만 코드처럼 테스트되지 않습니다.

그래서 시작 시점은 ‘두 번째 유료 고객이 나타난 순간’입니다. ‘시스템이 커졌을 때’가 아닙니다. 데이터 분리를 요구하는 계약서, SLA를 언급하는 문장도 같은 신호입니다. 반대로, 제품이 한 고객만 두고 영영 그대로일 것 같다면 멀티테넌트를 만들지 마십시오. 두 번째 고객이 영영 없을지도 모르는 제품에 테넌트 컬럼을 붙이는 것은 회수 없는 비용입니다.

the-multi-tenant-engineer 슬라이드 2

흔한 사고의 얼굴: 평범한 금요일의 배포

멀티테넌트 사고가 실제로 어떻게 시작되는지 보면 패턴은 지루합니다. WHERE 절을 하나 빼먹은 쿼리. 테넌트를 구분하지 않는 검색 API. 테넌트가 없는 캐시 키. 기본값으로 모든 테넌트를 보여주는 관리자 API. 모든 사용자에게 돌려보내는 밤샘 이메일 잡. 하나도 공격자가 필요하지 않습니다. 전부 평범한 금요일 저녁의 평범한 배포 안에 있었습니다.

누출의 대가는 버그 하나에서 끝나지 않습니다. 고객이 당신에게 사는 건 기능이기도 하지만 동시에 데이터를 맡기는 행위이기도 합니다. 고객 A의 데이터가 고객 B에게 새어나가는 순간, 계약 위반입니다.

그리고 이야기는 퍼집니다. 한 고객과의 관계만 깨지는 게 아닙니다. ‘이 플랫폼에선 내 데이터가 옆집한테도 보일 수 있다’는 사실이 알려지는 순간, 모든 고객이 같은 불안에 시달리게 됩니다. 패치는 수 시간 만에 올라가지만 신뢰는 수개월 뒤에야 돌아오며 때로는 돌아오지 않습니다.

그러니 설계는 사람의 기억을 전제로 하면 안 합니다. 사람은 필터를 빼먹습니다. 기억력과 성품을 믿는 보안 모델은 처음부터 성립하지 않습니다. 설계가 답해야 할 질문은 하나입니다. 빼먹은 필터가 쿼리 하나를 깨뜨리는 수준에서 끝나게 하는 구조를 어떻게 만들 것인가.

the-multi-tenant-engineer 슬라이드 3

절반만 한 격리는 없는 것보다 나쁘다

멀티테넌트가 실패하는 모양은 대개 세 갈래입니다. 격리 없는 상태, 과격리, 그리고 절반만 한 격리. 과격리는 앞 섹션에서 다뤘습니다. 격리 없는 상태도 진단은 쉽지 않습니다. 두 번째 고객의 요구를 받은 순간 아무것도 바꾸지 않아, 데이터가 통째로 뒤섞여 있기 때문입니다. 가장 흔하고 가장 위험한 것은 세 번째, 절반만 한 격리입니다.

절반만 한 격리란, 데이터베이스는 테넌트로 나눴는데 캐시는 그러지 못한 상태입니다. 큐 메시지에 테넌트가 없고 파일 저장소의 경로에도 테넌트가 없습니다. 이런 구조에서는 데이터베이스가 완벽히 깨끗해도 검색 결과에 옆집 파일명이 뜹니다. A 테넌트의 키로 캐시에 넣은 값이 B 테넌트의 응답으로 돌아오고, 메시지에서 테넌트가 빠진 워커는 접속할 데이터베이스를 알아낼 길이 없습니다.

절반이 없는 것보다 나쁜 이유는, 없으면 적어도 경계가 없다는 걸 알기 때문입니다. 경계가 없는 시스템은 한 번에 모두를 고칠 수 있습니다. 절반이면 안전하다고 착각합니다. 그리고 새는 곳은 당신이 잊은 부분입니다. 잊은 부분을 나열하면 캐시, 큐, 파일, 검색, 배치, 관리자 화면이 됩니다.

기본 원리는 간단합니다. 데이터가 지나가는 모든 통로에 경계가 있어야 합니다. 통로는 늘 생각했던 것보다 많습니다. 테이블을 설계할 때 머릿속에 떠오르는 것은 대개 데이터베이스 자체뿐이지만, 데이터가 실제로 지나가는 곳은 캐시, 큐, 파일 스토리지, 검색 인덱스, 배치 잡, 관리자 경로까지 이어집니다. 이 경로를 한 줄씩 나열하는 작업이 격리 설계의 절반입니다.

the-multi-tenant-engineer 슬라이드 4

데이터가 지나는 모든 통로에 경계가 있어야 한다

통로를 하나씩 따라가 보겠습니다. 캐시 키에는 테넌트가 들어갑니다. 접두사로 테넌트를 붙이거나, 테넌트별로 캐시 이름 공간을 따로 만드는 식입니다. 캐시 키에 테넌트가 먼저 있어야, 나중에 무거운 테넌트가 다른 테넌트의 엔트리를 밀어내는 문제를 용량 상한으로 다룰 수도 있습니다.

큐 메시지는 테넌트 식별자를 실어 나릅니다. 워커는 데이터에 손을 대기 전에 컨텍스트를 설정합니다. 메시지에서 테넌트가 빠지면 워커는 접속할 데이터베이스를 알아낼 길이 없습니다. 파일 저장소 경로에는 테넌트별 접두사가 있고 접근은 파일 식별자를 믿는 게 아니라 테넌트를 대조하면서 허가됩니다.

검색 인덱스는 테넌트 범위 안에 있거나, 엔진 층에서 모든 쿼리가 테넌트로 필터되도록 만들어집니다. 누가 보냈든 쿼리가 테넌트 범위 밖을 나가지 못하는 쪽이 안전합니다. 관리자 경로는 사용자 경로에서 아예 별개입니다. 모든 테넌트를 보는 화면은 감사되는 특권이지, 기본값의 단축키가 아닙니다.

배치와 크론은 전형적인 맹점입니다. 이 경로들은 요청 미들웨어를 거치지 않습니다. 매일 밤 모든 사용자에게 보고서를 보낸다는 잡은 테넌트별로 루프를 돌리면서, 각 회차에서 컨텍스트를 설정해야 합니다. 요청 경로에는 경계가 있는데 잡 경로에는 없다면, 새는 곳은 잡 경로입니다.

끝으로, 테넌트는 항상 인증된 아이덴티티에서 나와야 합니다. 요청 본문이나 쿼리 파라미터에서 받아 쓰면 안 합니다. 자신의 테넌트 식별자를 말할 수 있는 사용자는, 남의 테넌트 식별자도 말할 수 있는 사용자입니다. 바깥에서 오는 값은 신뢰할 경계가 못 합니다.

필터를 기본값으로, RLS를 뒷받침으로, 테스트를 기억으로

경계를 지키는 주체는 셋입니다. 첫 번째는 애플리케이션 층입니다. 요청이 시작되면 미들웨어가 이 요청이 어느 테넌트에 속하는지를 정해 요청 컨텍스트에 넣습니다. 쿼리 라이브러리나 ORM이 그 컨텍스트 안의 모든 쿼리에 테넌트 필터를 자동으로 붙입니다. 핵심은 필터를 기본값으로 만드는 일입니다. 직접 써야 한다면, 사람은 빼먹습니다.

두 번째는 데이터베이스 층입니다. PostgreSQL에는 RLS, 즉 행 단위 보안이라는 기구가 있습니다. 각 테이블에 ‘이 세션의 변수와 테넌트 식별자가 일치하는 줄만 보인다’는 정책을 걸어 두고, 애플리케이션이 연결될 때 변수를 설정합니다. RLS는 애플리케이션 스코핑을 대체하지 않습니다. 뒷받침일 뿐입니다. ORM을 우회하는 버그가 지나가면 RLS가 잡습니다. 세션 변수를 설정하지 못한 연결은 아무것도 보이지 않습니다. 실패하는 방향이 안전한 쪽입니다.

세 번째는 운영 층입니다. 대부분이 빼먹는 부분입니다. 배치 잡, 크론, 관리 도구, 백업 스크립트는 요청 미들웨어를 거치지 않는 경로입니다. 이 경로들은 테넌트 컨텍스트를 스스로 가져가야 합니다. 요청 경로만 지키는 설계는, 고객이 데이터를 보는 경로는 지켰으면서 본인이 데이터를 옮기는 경로는 못 지킨 상태입니다.

그 위에 한 가지, 자기 자신을 갚아 주는 테스트를 더합니다. 크로스 테넌트 속성 테스트입니다. 두 테넌트를 만들고 한 테넌트에 대해 일단의 무작위 작업을 돌린 다음, 다른 테넌트가 아무것도 보지 않는지를 검증합니다. CI에서 매 배포마다 돕니다. 당신은 모든 쿼리가 올바를 것을 기억하지 못합니다. 이 테스트가 기억합니다. 빼먹은 필터, 캐시 미스, 루프 순서가 틀린 잡이 전부 빨간 빌드로 바뀌고 고객의 메일로 바뀌지 않습니다.

경계를 어디에 놓을 것인가: 한 컬럼에서 한 데이터베이스까지

마지막으로, 경계를 어디에 물리적으로 놓을 것인가. tenant 컬럼이냐 DB 분리냐의 이분법은 아닙니다. ‘다 같이 쓴다’에서 ‘완전 따로’까지 이어지는 스펙트럼입니다. 모든 테넌트가 하나의 데이터베이스 안에 있고 각 줄이 테넌트 식별자로 구분되는 단계, 테넌트마다 테이블의 이름 공간이 따로인 단계, 같은 인스턴스 안에서 테넌트마다 데이터베이스가 따로인 단계, 테넌트마다 인스턴스, 때로는 서버까지 따로인 단계. 네 단계입니다.

단계가 오를수록 격리는 강해지고 관리 대상은 테넌트 수만큼 늘어납니다. 1인 개발자는 보통 첫 단계로 시작해, 필요해지는 테넌트만 세 번째 단계로 올립니다. 테넌트가 작고 동질적이며 규제 요구를 가진 고객이 없고 데이터 총량이 적당하다면 첫 단계는 충분합니다. 다만 충분한 동안에는 첫 단계에 머물러 있어야 합니다.

올리는 순간의 기준은 필요해지는 전에 적어 두어야 합니다. 고객의 계약이 분리를 요구할 때, 테넌트의 데이터가 임계값을 넘을 때, 한도만으로 담아내지 못하는 소음의 이웃이 될 때, 고객 스스로 더 강한 형태를 요구하면서 대가를 지불할 때입니다. 실무에서 올리는 일은 일방통행입니다. 세 번째 단계에서 첫 단계로 되돌리는 건 데이터를 합치는 일이고, 그걸 하고 싶을 때는 드뭅니다. 그래서 기준 순간의 질문은 ‘이 테넌트를 영원히 더 비싸게 돌리고 싶은가’입니다. ‘이걸 할 수 있는가’가 아닙니다.

구분 tenant 컬럼 (1단계) DB 분리 (3단계)
격리 강도 앱 스코핑 + RLS 구조적 분리
관리 단수 데이터베이스 1개 테넌트 수만큼
삭제 방식 줄 단위 정지 데이터베이스 drop
대테넌트 대응 어려움 쉬움
초기 복잡도 낮음 중간

두 단계가 같이 살려면, 데이터 접근 층에 깔끔한 틈새 하나가 필요합니다. 테넌트 컨텍스트를 받아, 쿼리가 공유 테이블로 가는지 별개 데이터베이스로 가는지 숨겨 주는 저장소 인터페이스입니다. 틈새가 깔끔하면 ‘당신의 데이터는 별개의 데이터베이스에 있다’는 문장이 플래그 바꾸기와 가격 행 하나 더하기를 뜻합니다. 팔 수 있는 상품이 됩니다. 틈새가 깔끔하지 못하면, 테넌트 체크가 핸들러 곳곳에 흩어져 새로운 기능마다 동전 던지기가 됩니다.

끝점은 지루합니다. 새 테넌트를 받는 일이, 레지스트리에 행 하나를 추가하고 미터에 한도를 붙이고 원장 숫자 하나를 더하는 일인 시스템. 한 줄입니다. 재작성이 아닙니다. 그 순간에도 빵집은 스튜디오의 계약서를 보지 못합니다. 멀티테넌트 설계가 도착한 곳은 여기입니다. 경계가 개발자의 머릿속이 아닌 시스템의 구조 안에 있는 곳. 격리의 형태, 한도의 숫자, 부하의 한계, 그 위에 돈을 얹는 운영까지, 이 글의 연장선을 한 권의 전자책으로 정리해 두었습니다.

태그: data-security, database-design, isolation, 멀티테넌시, row-level-security, saas, solo-developer

카테고리:

업데이트: