📕 전자책 전문 읽기 공유하는 시스템 · 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

카테고리:

업데이트: