
OwlAI SaaS에서는 여러 조직이 PostgreSQL DB 하나를 나눠 씁니다. 한 사람이 두 조직에 속할 수도 있습니다. 이 구조에서 조직을 가르는 일을 쿼리마다 맡기면 두 군데서 깨집니다. 조직 조건을 빠뜨린 쿼리 하나가 조직 A의 대화에서 배운 사실을 조직 B의 대화에 싣습니다. 조직을 지울 때는 나중에 생긴 테이블 하나가 정리 목록에서 빠져, 지운 조직의 대화 사본이 DB에 남습니다.
두 결함의 원인은 같습니다. 어느 테이블이 조직의 것인지를 사람이 기억해야 한다는 점입니다. 그래서 이 목록을 코드의 분류표 하나에 두었습니다. 분류표가 조직 소유라고 정한 테이블에는 DB가 행 수준 보안(RLS)을 걸고, 조직을 지울 때의 정리 계획도 같은 테이블 집합으로 짜며, 테스트가 RLS 대상과 정리 계획이 분류표와 어긋나지 않는지 확인합니다. 이 글은 조직 경계를 DB에 맡긴 방식과, 지운 기억이 어느 테이블에도 남지 않게 하는 정리를 다룹니다.
개인 메모리도 사람이 아니라 조직에 속한다
분류표는 메타스토어의 모든 테이블을 넷 가운데 하나로 나눕니다.
| 분류 | 뜻 | 예 |
|---|---|---|
| 조직 소유 | 조직의 데이터. 조직 칸이 있고 RLS를 건다 | 대화, 지난 대화 조각, 개인 메모리, 테이블 메모, 회의 용어집 |
| 혼합 | 플랫폼이 넣은 행과 조직이 만든 행이 섞인다 | 시스템 스킬과 조직이 만든 스킬 |
| 플랫폼 | 모든 조직이 함께 쓰는 설치 설정·카탈로그 | 조직 목록, 소속 |
| 신원 | 조직보다 앞선 계정·로그인 | 계정 |
판단이 갈린 곳은 개인 메모리입니다. 사람에 대한 기억이니 계정에 붙이면 두 조직에 속한 사람이 같은 말을 두 번 하지 않아도 됩니다. 그런데 개인 메모리에 적히는 것은 그 조직의 대화에서 배운 사실입니다. 조직 A의 이슈 트래커에서 이 사람의 계정 ID가 무엇인지는 조직 A의 사실이고, 계정에 붙이면 조직 B의 대화에도 실립니다. 그래서 개인 메모리는 (사람, 조직) 한 쌍마다 한 행이고 조직 소유로 분류했습니다. 같은 사람이라도 조직마다 메모리가 따로 있습니다.
조직 조건은 쿼리가 아니라 DB가 건다
흔한 방법은 애플리케이션이 쿼리마다 WHERE org_id = ? 를 붙이고, 저장소 함수가 이 조건을 자동으로 붙이게 감싸는 것입니다. 단순하고 DB 기능에 기대지 않습니다. 하지만 감싼 함수를 거치지 않는 SQL, 조인 한쪽에서 조건을 잊은 쿼리가 하나만 있어도 다른 조직의 행이 돌아옵니다. 메모리 테이블은 채팅·플레이북·관리자 화면·백그라운드 작업이 모두 읽어서, 경로가 늘 때마다 빠뜨릴 기회도 늘어납니다.
그래서 조직 소유 테이블의 경계는 DB가 강제합니다.
- 앱의 DB 연결은 처음 붙는 순간부터 조직 역할로 돕니다. 테이블 소유자 권한으로 열린 연결이 존재하는 틈이 없고, 이 역할에는 RLS를 건너뛰는 권한이 없습니다.
- 트랜잭션마다 세션에 현재 조직을 세웁니다. 조직은 DB 접속 정보와 한 값으로 묶여 함수 인자로 끝까지 넘어가고, 컨텍스트 변수로 흘리지 않습니다. 워커에는 요청 컨텍스트가 없어서, 컨텍스트가 비었을 때 기본 조직으로 떨어지는 경로가 생기면 그것이 곧 RLS가 막으려는 사고가 됩니다.
- 쓰기는 DB가 조직 칸을 현재 조직으로 채웁니다. 조직을 세우지 않은 경로의 INSERT는 조직 칸이 비어 실패합니다. 다른 조직이나 빈 조직에 행이 조용히 쌓이지 않습니다.
- 읽기는 RLS 정책이 걸러서, 질의 조건에 무엇을 쓰든 다른 조직의 행은 보이지 않습니다. 조직을 세우지 않은 세션이 조직 소유 테이블을 읽으면 0행이 아니라 오류가 납니다. 0행이면 조직을 빠뜨린 코드가 "결과 없음"으로 지나가지만, 오류면 그 자리에서 드러납니다.
- RLS는 테이블 소유자에게도 강제합니다. 소유자 연결이 정책을 건너뛰면 그 연결로 도는 코드가 경계 밖에 놓이기 때문입니다.
PostgreSQL의 외래 키 검사는 RLS를 보지 않습니다. 외래 키가 부모의 ID만 가리키면 조직 A의 행이 조직 B의 부모 행을 가리킬 수 있습니다. 그래서 조직 소유 테이블의 외래 키는 (부모 ID, 조직) 두 칸을 함께 묶습니다. 자식 테이블도 조직 칸을 부모에게서 빌려 오지 않고 자기 칸으로 갖습니다. RLS 정책은 테이블마다 따로 평가되므로 부모를 거쳐 조직을 확인할 수 없습니다. 혼합 테이블에서 조직 세션은 플랫폼이 넣은 행을 읽기만 할 수 있습니다.
조직을 넘나드는 작업은 목록만 읽고, 쓰기는 그 행의 조직으로 한다
오래된 대화를 지우는 보존 작업, 예약된 플레이북을 실행하는 작업처럼 설치 전체를 돌보는 작업은 조직 하나에 묶일 수 없습니다. 이런 작업에는 모든 조직의 행을 여는 플랫폼 접근을 따로 두고, 다음 규칙을 겁니다.
- 플랫폼 접근은 일감을 고르는 조회 하나만 하고, ID·조직·상태·시각 칸만 돌려받는다.
- 고른 행을 고치는 쓰기는 그 행이 속한 조직으로 세션을 다시 열어서 한다.
- 대화 본문, 회의 받아쓰기, 문서 텍스트는 플랫폼 접근으로 읽지 않는다.
플랫폼 접근을 열 때는 미리 정해 둔 사유를 함께 넘겨야 합니다. 사유마다 그 사유로 접근을 열 수 있는 코드 자리가 목록에 적혀 있고, 테스트가 소스 코드의 실제 호출 자리와 이 목록이 같은지 확인합니다. 새 자리에서 접근을 열려면 이 목록부터 고쳐야 하므로, 리뷰어는 목록의 변경만 보면 경계가 어디서 새로 열렸는지 압니다.
새 테이블은 분류하지 않으면 머지되지 않는다
DB가 경계를 지켜도 RLS를 걸지 않은 조직 테이블이 하나 생기면 그 테이블은 경계 밖에 놓입니다. 그래서 테스트 셋이 분류표와 실제 DB를 맞춥니다.
| 테스트 | 확인하는 것 |
|---|---|
| 분류표 검사(단위 테스트) | 코드에 선언된 모든 테이블이 분류표에 있는가, 분류와 조직 칸 유무가 맞는가 |
| RLS 카탈로그 검사(실제 PostgreSQL) | 조직 소유·혼합 테이블에 RLS·정책·제약이 실제로 걸렸는가 |
| 테이블별 격리 검사(실제 PostgreSQL) | 테이블마다 조직 A·B의 행을 만들고 앱 연결로 경계를 시험한다 |
테이블별 격리 검사는 테이블마다 다섯 가지를 봅니다. 조직 B 세션에서 A의 행이 0행으로 보이는지, B 세션에서 A의 조직으로 쓰는 INSERT가 거부되는지, 조직을 세우지 않은 세션의 조회가 오류를 내는지, 플랫폼 접근은 두 조직의 행을 모두 보는지, B 세션에서 A의 부모 행을 가리키는 자식 INSERT가 외래 키 위반으로 막히는지입니다. 검사 대상은 분류표에서 바로 만들고, 그 집합이 DB에서 RLS가 켜진 테이블 집합과 같아야 합니다. 새 메모리 테이블을 만들면 따로 등록하지 않아도 검사 대상에 들어가고, 분류를 잊거나 RLS를 빠뜨리면 머지 전 게이트에서 실패합니다.
지운 대화는 검색에서 먼저 빠지고, 유예 기간 뒤 사본까지 지운다
사용자가 대화를 지우면 그 대화는 먼저 숨김 상태가 되고, 보존 작업이 30일 유예 뒤에 행째 지웁니다. 마지막 활동 뒤 1년이 지난 대화도 같은 경로로 지웁니다. 유예 기간 동안에도 지난 대화 검색은 숨긴 대화의 조각을 꺼내지 않습니다(「개인 메모리는 매 턴 싣고, 고칠 때는 통째로 다시 쓴다」).
행째 지울 때 빠지기 쉬운 것이 지난 대화 조각입니다. 조각은 턴의 질문과 답을 검색용으로 옮겨 둔 사본인데, 대화에 외래 키가 없어서 대화를 지워도 연쇄 삭제로 따라 지워지지 않습니다. 남으면 지운 대화의 내용이 메모리 검색으로 계속 나옵니다. 그래서 대화를 행째 지우는 곳(대화 보존, 플레이북 실행 기록 정리, 플레이북 삭제)은 모두 같은 하드 삭제 함수 하나를 부르고, 그 함수가 조각까지 지웁니다. 어느 한 곳만 "대화를 지울 때 조각도"를 알면 다른 곳에 사본이 남기 때문입니다.
대화에 딸린 파일(산출물, 첨부)은 행 삭제가 커밋된 뒤에 지웁니다. 파일을 먼저 지웠는데 트랜잭션이 롤백되면 살아 있는 대화가 파일을 잃습니다. 플레이북의 원천이 된 대화는 그 플레이북의 수명을 따르므로 보존 작업이 지우지 않지만, 숨긴 동안 검색에서는 빠집니다.
계정을 지우면 개인 메모리와 지난 대화 조각은 외래 키 연쇄 삭제로 함께 사라집니다. 테이블 메모는 데이터에 대한 조직의 지식이라 남기고, 남긴 사람 칸만 비웁니다(「대화에서 바로잡은 컬럼 뜻, 테이블 메모로 다음 사람에게 싣기」).
조직 삭제 계획은 분류표와 같은 테이블 집합으로 짠다
조직을 해지하면 바로 지우지 않고 보관 기간(organization.closingRetentionDays, 기본이자 하한 30일)을 둡니다. 그동안 구성원은 읽기만 할 수 있고, 다시 구독하면 그대로 돌아옵니다. 기간이 끝나면 워커의 삭제 작업이 정리 계획대로 지웁니다. 계획은 조직 소유·혼합 테이블마다 처리 하나를 붙입니다.
| 처리 | 붙는 정보 | 대상 예 |
|---|---|---|
| 전부 삭제 | — | 개인 메모리, 지난 대화 조각, 테이블 메모, 대화, 회의 용어집 |
| 보존 | 사유 | 감사 기록, 과금 기록 |
| 익명화 | 비울 칸 목록 | 사용량 원장(사람·대화 식별자를 비운 채 남긴다) |
메모리 계열은 모두 지웁니다. 감사 기록은 앱의 DB 역할이 지울 수 없는 덧붙이기 전용 테이블이라 남기고, 보존 기간이 지나면 보존 작업이 지웁니다.
계획의 테이블 집합은 분류표의 조직 소유·혼합 테이블 집합과 같아야 하고, 테스트는 부분집합이 아니라 집합 동일성으로 확인합니다. 부분집합 검사는 계획에 적힌 테이블이 실재하는지만 보므로, 새 조직 테이블이 계획에서 빠져도 통과합니다. 집합 동일성에서는 새 테이블을 분류표에 넣고 삭제 처리를 정하지 않으면 실패합니다. 같은 방식으로 두 가지를 더 봅니다. 객체 저장소의 용도(문서, 회의 음성, 샌드박스 등)마다 조직 삭제 처리기가 있는지, 남기는 테이블이 지우는 테이블을 외래 키로 가리키지 않는지입니다.
삭제 순서는 "지울 대상을 어디서 찾는가"로 정했습니다.
- 그 조직의 대기 중인 작업을 취소한다.
- 외부 음성 인식 서비스에 올린 객체를 먼저 지운다. DB의 기록을 먼저 지우면 원격 객체를 찾을 길이 없다.
- 객체 저장소의 파일을 지운다. 파일의 키가 DB 행에 있으므로 행보다 먼저다.
- DB 행을 외래 키 의존의 역순으로, 테이블마다 정해진 크기로 나눠 지우거나 익명화한다. 중간에 멈춰도 다음 실행이 남은 행부터 이어 간다.
행을 지우는 세션은 그 조직으로 엽니다. RLS 때문에 삭제 작업은 다른 조직의 행에 닿을 수 없고, 플랫폼 접근도 필요 없습니다. 테이블마다 몇 행을 지웠는지는 플랫폼 감사 기록에 남기고, 지운 내용은 남기지 않습니다.
그 조직에만 속했던 계정은 지우지 않고 개인 프로바이더 키만 지웁니다. 조직 해지가 서비스 탈퇴는 아니어서, 같은 계정으로 다른 조직의 초대를 받을 수 있습니다. 개인 메모리는 조직 소유라 이미 조직과 함께 지워졌으므로 남은 계정에 이전 조직의 기억은 따라가지 않습니다.
설계에서 고른 것
조직마다 스키마나 DB를 따로 두면 격리가 더 강하고, 조직을 지울 때 스키마 하나를 버리면 됩니다. 대신 마이그레이션을 조직 수만큼 돌려야 하고 커넥션 풀과 백그라운드 작업이 조직마다 갈립니다. 테이블을 함께 쓰고 RLS로 가르는 쪽을 골랐고, 그 대가로 모든 조직 테이블이 자기 조직 칸과 조직을 묶은 외래 키를 가져야 하고, 조직 삭제는 테이블마다 처리를 정한 계획으로 해야 합니다. 이 대가가 사람의 기억에 기대지 않게, 분류표·RLS·삭제 계획을 집합 동일성 테스트로 묶었습니다.
보존 작업처럼 모든 조직을 훑는 일이 있어 플랫폼 접근은 없앨 수 없습니다. 그래서 여는 자리를 사유가 적힌 목록으로 묶고, 그 자리에서는 일감 목록만 읽게 했습니다.
개인 메모리를 조직 소유로 둔 대가로, 여러 조직에 속한 사람은 조직마다 자기 이야기를 다시 해야 합니다.
여러 조직이 한 DB를 나눠 쓰는 환경이라면, 조직 소유 테이블 목록을 코드의 한곳에 두고 RLS 대상과 삭제 계획을 그 목록에서 만든 뒤 테스트가 집합 동일성으로 대조하게 하면, 새 메모리 테이블이 경계나 삭제에서 빠지는 일을 머지 전에 잡을 수 있습니다.
어떤 문제를 해결하나요?
메모리와 검색의 다른 글

엔지니어링 노트 · 메모리와 검색
개인 메모리는 매 턴 싣고, 고칠 때는 통째로 다시 쓴다
사용자가 「이슈 트래커에서 제 계정은 이 ID예요. 앞으로 내 이슈는 이걸로 찾아 주세요」라고 하면 모델은 「기억하겠습니다」라고 답합니다.

엔지니어링 노트 · 메모리와 검색
대화에서 바로잡은 컬럼 뜻, 테이블 메모로 다음 사람에게 싣기
고객 테이블의 상태 컬럼 STATCD 에는 1, 2, 9가 들어 있습니다. 스키마만 봐서는 9가 해지라는 것을 알 수 없어서, 「활성 고객 수」를 물으면 모델은 해지 고객까지 세어 보고서에 올립니다.

엔지니어링 노트 · 메모리와 검색
조사 붙은 검색어는 전문 검색에 안 걸린다, 낱말을 OR로 잇는다
테이블 메모에 「반품 주문은 ORDRTN 테이블에 따로 쌓인다」라고 적혀 있고, 사용자가 「지난달 반품 주문 건수를 알려 줘」라고 묻습니다.

