한국어
  • saas
  • multi-tenancy
  • postgres
  • row-level security
  • rls
  • trigger function
  • multi-tenant architecture
  • single-tenant architecture

PostgreSQL 를 사용한 멀티 테넌시 구현: 간단한 실제 예제로 배우기

PostgreSQL 행 수준 보안 (RLS) 및 데이터베이스 역할을 통해 테넌트 간의 안전한 데이터 격리를 위한 멀티 테넌트 아키텍처를 구현하는 방법을 실제 예제를 통해 배워보세요.

Yijun
Yijun
Developer

이전의 몇몇 기사에서 우리는 멀티 테넌시의 개념과 제품 및 실제 비즈니스 시나리오에서의 적용에 대해 깊이 탐구했습니다.

이 기사에서는 기술적 관점에서 PostgreSQL 을 사용하여 애플리케이션의 멀티 테넌트 아키텍처를 구현하는 방법을 탐구할 것입니다.

싱글 테넌트 아키텍처란 무엇인가?

싱글 테넌트 아키텍처는 각 고객이 애플리케이션 및 데이터베이스의 자체 전용 인스턴스를 갖는 소프트웨어 아키텍처를 나타냅니다.

이 아키텍처에서는 각 테넌트의 데이터와 리소스가 다른 테넌트와 완전히 격리됩니다.

싱글 테넌시

멀티 테넌트 아키텍처란 무엇인가?

멀티 테넌트 아키텍처는 여러 고객(테넌트)이 데이터 격리를 유지하면서 동일한 애플리케이션 인스턴스 및 인프라를 공유하는 소프트웨어 아키텍처입니다. 이 아키텍처에서는 소프트웨어의 단일 인스턴스가 여러 테넌트를 제공하며 각 테넌트의 데이터는 다양한 격리 메커니즘을 통해 다른 테넌트와 분리됩니다.

멀티 테넌시

싱글 테넌트 아키텍처 vs 멀티 테넌트 아키텍처

싱글 테넌트 아키텍처와 멀티 테넌트 아키텍처는 데이터 격리, 리소스 활용, 확장성, 관리 및 유지보수, 보안 등의 측면에서 다릅니다.

싱글 테넌트 아키텍처에서는 각 고객이 독립된 데이터 공간을 가지며, 리소스 활용도가 낮지만, 상대적으로 맞춤화가 더 간단합니다. 일반적으로, 특정 고객의 요구에 맞춘 싱글 테넌트 소프트웨어는 특정 직물 공급자를 위한 재고 시스템이나 개인 블로그 웹앱과 같이 사용자 요구에 맞춰지게 됩니다. 이들의 공통점은 각 고객이 애플리케이션 서비스의 독립된 인스턴스를 차지하며, 요구 사항에 맞춰 커스터마이즈를 용이하게 만든다는 것입니다.

멀티 테넌트 아키텍처에서는 다수의 테넌트가 동일한 기본 리소스를 공유하여 더 높은 리소스 활용을 얻습니다. 하지만 데이터 격리 및 보안 보장이 중요합니다.

멀티 테넌트 아키텍처는 서비스 제공자가 다양한 고객에게 표준화된 서비스를 제공할 때 선호되는 소프트웨어 아키텍처입니다. 이들 서비스는 일반적으로 낮은 수준의 커스터마이징을 가지고 있으며 모든 고객이 동일한 애플리케이션 인스턴스를 공유합니다. 애플리케이션이 업데이트될 때, 하나의 애플리케이션 인스턴스를 업데이트하는 것은 모든 고객을 위한 애플리케이션을 업데이트하는 것과 같습니다. 예를 들어, CRM(고객 관계 관리)은 표준화된 요구입니다. 이 시스템은 일반적으로 멀티 테넌트 아키텍처를 사용하여 모든 테넌트에게 동일한 서비스를 제공합니다.

멀티 테넌트 아키텍처에서 테넌트 데이터 격리 전략

멀티 테넌트 아키텍처에서는 모든 테넌트가 동일한 기본 리소스를 공유하므로, 테넌트 간 리소스의 격리가 중요합니다. 이 격리는 물리적일 필요는 없으며, 테넌트 간 리소스가 서로 보이지 않도록 보장하기만 하면 됩니다.

아키텍처 설계에서, 테넌트 간 다양한 수준의 자원 격리를 달성할 수 있습니다:

격리에서 공유까지

일반적으로, 테넌트 간의 공유 자원이 많을수록 시스템 반복 및 유지보수 비용이 적게 듭니다. 반대로 공유 자원이 적을수록 비용이 높아집니다.

실제 예제로 시작하는 멀티 테넌트 구현

이 기사에서는 CRM 시스템을 예로 들어 간단하면서도 실용적인 멀티 테넌트 아키텍처를 소개합니다.

모든 테넌트가 동일한 표준 서비스를 사용한다고 인식하여, 모든 테넌트가 동일한 기본 리소스를 공유하도록 결정했으며, PostgreSQL 의 행 수준 보안을 사용하여 데이터베이스 수준에서 테넌트 간의 데이터 격리를 구현할 것입니다.

또한, 각 테넌트를 위한 별도의 데이터 연결을 생성하여 테넌트 권한 관리를 더 용이하게 할 것입니다.

다음으로, 이 멀티 테넌트 아키텍처를 구현하는 방법을 소개하겠습니다.

PostgreSQL 을 사용한 멀티 테넌트 아키텍처 구현 방법

모든 리소스에 테넌트 식별자 추가

CRM 시스템에서는 많은 리소스가 있으며, 이들은 다양한 테이블에 저장됩니다. 예를 들어, 고객 정보는 customers 테이블에 저장됩니다.

멀티 테넌시를 구현하기 전, 이러한 리소스들은 특정 테넌트와 연관되지 않았습니다:

다른 리소스를 소유한 테넌트를 구분하기 위해 테넌트 테이블을 도입하여 테넌트 정보를 저장하고(db_userdb_user_password는 각 테넌트의 데이터베이스 연결 정보를 저장하는 데 사용되며, 이는 아래에 자세히 설명됩니다), 각 리소스에 tenant_id 필드를 추가하여 어느 테넌트에 속하는지를 식별합니다:

이제 각 리소스는 tenant_id와 연관되어 있으며, 이론적으로 모든 쿼리에 where 절을 추가하여 각 테넌트에 대한 리소스 접근을 제한할 수 있습니다:

처음에는 이 방법이 단순하고 실행 가능해 보입니다. 그러나 다음과 같은 문제들이 발생할 것입니다:

  • 거의 모든 쿼리에서 이 where 절을 포함해야 하며, 이는 코드가 복잡해지고 유지보수가 어렵게 만듭니다. 특히 복잡한 조인 문을 작성할 때 더욱 그렇습니다.
  • 코드베이스에 새로 합류한 사람들은 이 where 절을 쉽게 추가하는 것을 잊어버릴 수 있습니다.
  • 다양한 테넌트 간 데이터가 진정으로 격리되지 않습니다. 각 테넌트는 여전히 다른 테넌트의 데이터에 접근할 수 있는 권한을 가지고 있습니다.

따라서 이 방법을 채택하지 않을 것입니다. 대신, PostgreSQL 의 행 수준 보안을 사용하여 이러한 문제를 해결할 것입니다. 그러나 진행하기 전에, 이 공유 데이터베이스에 접근하기 위해 각 테넌트를 위한 별도의 데이터베이스 계정을 생성할 것입니다.

테넌트를 위한 DB 역할 설정

데이터베이스에 연결할 수 있는 각 사용자에게 데이터베이스 역할을 할당하는 것은 좋은 관행입니다. 이를 통해 각 사용자의 데이터베이스 접근을 더 잘 제어할 수 있으며, 다른 사용자 간의 작업 격리를 용이하게 하고 시스템 안정성과 보안을 향상시킬 수 있습니다.

모든 테넌트가 동일한 데이터베이스 작업 권한을 가지고 있으므로, 이러한 권한을 관리할 기본 역할을 생성할 수 있습니다:

그런 다음 각 테넌트 역할을 구별하기 위해, 생성 시 기본 역할에서 상속된 역할을 각 테넌트에게 할당합니다:

다음으로, 각 테넌트의 데이터베이스 연결 정보는 테넌트 테이블에 저장됩니다:

iddb_userdb_user_password
x2euiccrm_tenant_x2euicpa55w0rd

이 메커니즘은 각 테넌트에 고유한 데이터베이스 역할을 제공하며, 이러한 역할은 crm_tenant 역할에 부여된 권한을 공유합니다.

그런 다음, crm_tenant 역할을 사용하여 테넌트에 대한 권한 범위를 정의할 수 있습니다:

  • 테넌트는 모든 CRM 시스템 리소스 테이블에 대한 CRUD 접근을 가져야 합니다.
  • CRM 시스템 리소스와 관련이 없는 테이블은 테넌트에게 보이지 않아야 합니다(예를 들어 시스템 테이블만 있다고 가정합니다).
  • 테넌트는 테넌트 테이블을 수정할 수 없어야 하며, 오직 iddb_user 필드만 그들에게 보여야 합니다.

테넌트를 위한 역할이 설정되면, 테넌트가 서비스에 접근을 요청할 때, 해당 테넌트를 나타내는 데이터베이스 역할을 사용하여 데이터베이스와 상호작용할 수 있습니다:

PostgreSQL 행 수준 보안을 사용하여 테넌트 데이터를 보호하기

지금까지 우리는 테넌트에 대한 대응하는 데이터베이스 역할을 설정했지만, 이는 테넌트 간 데이터 접근을 제한하지 않습니다. 다음으로, PostgreSQL 의 행 수준 보안 기능을 활용하여 각 테넌트가 자신의 데이터에만 접근하도록 제한할 것입니다.

PostgreSQL 에서는 테이블이 행 보안 정책 을 가지고 있어서 쿼리가 접근 가능하거나 데이터 조작 명령이 수정할 수 있는 행을 제한할 수 있습니다. 이 기능은 또한 RLS (행 수준 보안) 라고 알려져 있습니다.

기본적으로 테이블은 행 보안 정책을 가지고 있지 않습니다. RLS 를 활용하려면, 해당 테이블에 대한 RLS 를 활성화하고 테이블에 접근할 때마다 실행될 수 있는 보안 정책을 생성해야 합니다.

CRM 시스템의 고객 테이블을 예로 들어, RLS 를 활성화하고 각 테넌트가 자신들의 고객 데이터에만 접근할 수 있도록 하는 보안 정책을 생성해 보겠습니다:

보안 정책 생성 구문에서:

  • for all (옵션) 은 이 접근 정책이 테이블에 대한 select, insert, update, 그리고 delete 작업에 대해 사용될 것임을 나타냅니다. 특정 작업에 대한 접근 정책을 지정하려면 for 뒤에 명령어 키워드를 추가합니다.
  • to crm_tenant 는 이 정책이 데이터베이스 역할 crm_tenant 를 가진 사용자에게 적용됨을 나타내며, 이는 모든 테넌트를 의미합니다.
  • as restrictive 는 정책의 강제 모드를 지정하며, 접근을 엄격히 제한해야 함을 나타냅니다. 기본적으로 테이블은 여러 정책을 가질 수 있으며, 여러 허용 정책은 OR 관계로 결합됩니다. 이 시나리오에서는 CRM 시스템 테넌트를 위한 사용자에게 이 정책 검사를 반드시 실행하도록 만들고 싶기 때문에 이 정책을 제한적 으로 선언합니다.
  • using 표현식은 실제 접근을 위한 조건을 정의하며, 현재 쿼리하는 데이터베이스 사용자가 상응하는 테넌트의 데이터만 볼 수 있도록 제한합니다. 이 제약은 명령어(select, update, 또는 delete)로 선택된 행에 적용됩니다.
  • with check 표현식은 데이터 행을 수정할 때(insert 또는 update) 필요한 제약을 정의하며, 테넌트가 자신들의 기록만 추가하거나 업데이트할 수 있도록 보장합니다.

RLS 를 사용하여 리소스 테이블에 대한 테넌트 접근을 제한하는 것은 여러 이점이 있습니다:

  • 이 정책은 모든 쿼리 작업(select, update, 또는 delete)에 where tenant_id = (select id from tenants where db_user = current_user) 를 효과적으로 추가합니다. 예를 들어, select * from customers 를 실행할 때, 이는 select * from customers where tenant_id = (select id from tenants where db_user = current_user) 를 실행하는 것과 동일합니다. 이는 애플리케이션 코드에서 where 조건을 명시적으로 추가할 필요성을 제거하여 단순화시키고 오류 발생 가능성을 줄입니다.
  • 데이터 접근 권한을 데이터베이스 수준에서 중앙 집중적으로 제어하여 애플리케이션에서의 취약점이나 불일치를 완화하며, 시스템 보안을 향상시킵니다.

그러나 몇 가지 유의할 점이 있습니다:

  • RLS 정책은 데이터의 각 행에 대해 실행됩니다. RLS 정책 내 쿼리 조건이 너무 복잡하면 시스템 성능에 큰 영향을 미칠 수 있습니다. 다행히도, 우리의 테넌트 데이터 확인 쿼리는 충분히 간단하여 성능에 영향을 미치지 않습니다. 이후 RLS 를 사용하여 다른 기능을 구현할 계획이 있다면 Supabase 의 행 수준 보안 성능 권장 사항을 따르며 RLS 성능을 최적화할 수 있습니다.
  • RLS 정책은 insert 작업이 일어날 때 자동으로 tenant_id 를 채우지 않습니다. 이는 테넌트가 자신들의 데이터를 삽입할 때만 제한하며, 데이터 삽입 시 여전히 테넌트 ID 를 제공해야 합니다. 이는 쿼리 과정과 일치하지 않으며, 개발 중 혼란을 초래하여 오류 발생 가능성을 증가시킬 수 있습니다(이는 이후 단계에서 해결할 것입니다).

customers 테이블 외에도, 모든 CRM 시스템 리소스 테이블에 동일한 작업을 적용해야 합니다(이 과정은 약간 번거롭겠지만, 테이블 초기화 시 이를 설정하는 프로그램을 작성할 수 있습니다), 따라서 다양한 테넌트 간 데이터를 격리합니다.

데이터 삽입을 위한 트리거 함수 생성

앞에서 언급한 것처럼, RLS(행 수준 보안)는 쿼리를 수행할 때 tenant_id 의 존재에 대해 걱정할 필요가 없게 합니다. 데이터베이스가 자동으로 처리하기 때문입니다. 하지만 insert 작업의 경우 여전히 적절한 tenant_id 를 수동으로 지정해야 합니다.

데이터 삽입 시 RLS 와 유사한 편리함을 얻기 위해, 데이터 삽입 시 데이터베이스가 tenant_id 를 자동으로 처리할 수 있어야 합니다.

이러한 편리함은 명확한 이점을 제공합니다: 애플리케이션 개발 레벨에서 데이터가 속한 테넌트를 더 이상 고려할 필요가 없으며, 오류 발생 가능성을 줄이고 멀티 테넌트 애플리케이션 개발 상황에서 우리가 감내해야 하는 정신적 부담을 덜어줍니다.

다행히도 PostgreSQL 는 강력한 트리거 기능을 제공합니다.

트리거는 테이블과 관련된 특별한 함수로, 특정 작업(예: insert, update, 또는 delete)이 수행될 때 자동으로 특정 작업을 수행합니다. 이들 작업은 행 수준(각 행) 또는 문 수준(전체 문)에서 실행될 수 있습니다. 트리거를 사용하면 특정 데이터베이스 작업 전후에 사용자 정의 로직을 실행할 수 있으며, 이를 통해 우리의 목표를 쉽게 달성할 수 있습니다.

먼저, 각 데이터 삽입 전에 실행될 트리거 함수 set_tenant_id 를 생성합니다:

다음으로, 이 트리거 함수를 customers 테이블의 삽입 작업과 연결합니다(마치 테이블에 대해 RLS 를 활성화하듯이, 이 트리거 함수는 모든 관련 테이블과 연결되어야 합니다):

이 트리거는 삽입된 데이터가 올바른 tenant_id 를 포함하도록 보장합니다. 새로운 데이터에 이미 tenant_id 가 포함되어 있다면, 트리거 함수는 아무 작업도 하지 않습니다. 그렇지 않으면, tenants 테이블의 현재 사용자 정보를 기준으로 tenant_id 필드를 자동으로 채웁니다.

이렇게 하면 테넌트가 데이터 삽입 시 데이터베이스 레벨에서 tenant_id 를 자동으로 처리하는 것을 달성합니다.

요약

이 기사에서는 CRM 시스템을 예로 들어 PostgreSQL 데이터베이스를 활용한 실용적인 멀티 테넌트 아키텍처 구현을 탐구했습니다.

데이터베이스 역할 관리, 접근 제어, 그리고 PostgreSQL의 행 수준 보안 기능 등을 논의하여 테넌트 간의 데이터 격리를 보장했습니다. 또한 트리거 함수를 사용하여 여러 테넌트를 관리하는데 개발자들의 인지적 부담을 줄였습니다.

이 기사에서 다룬 내용은 여기까지 입니다. 사용자 접근 관리를 통해 멀티 테넌트 애플리케이션을 더욱 강력하게 만들고 싶다면, Logto 조직으로 멀티 테넌트 앱 뻑뻑하게 가이드 - Logto로 시작하기를 참조할 수 있습니다.