고객사 데이터가 섞이지 않게: 격리를 약속이 아니라 구조로 만들기
한 플랫폼 위에 여러 고객사가 올라가는데, 데이터가 섞이면 그 순간 신뢰가 끝나고 되돌릴 방법도 없습니다. 격리를 "개발자가 조심하는 것"이 아니라 구조로 만든 과정과, 고객사마다 다른 조직 구조를 코드 수정 없이 받아내는 권한 설계를 정리했습니다.
OpenIoT 플랫폼팀

격리는 지키는 것이 아니라 어길 수 없게 만드는 것이라고 생각합니다. 한 플랫폼 위에 여러 고객사를 올리면서 그 생각을 실제 구조로 옮긴 과정, 그리고 고객사마다 제각각인 조직도를 코드 수정 없이 받아낸 권한 설계를 함께 적었습니다.
"방금 저희 것이 아닌 기기가 보였는데요"
플랫폼 사업에서 가장 무서운 문의가 이것인데, 한 번이라도 들어오면 그 뒤에 무슨 해명을 하든 계약은 끝납니다. 실제로 데이터가 새어 나갔는지 화면 표시만 잘못됐는지는 중요하지 않고, 고객 입장에서는 "섞일 수 있는 시스템"이라는 사실이 확인된 것이기 때문입니다.
저희가 만드는 건 한 회사 전용 서버가 아니라 여러 고객사가 각자의 제품을 올리는 플랫폼이라, 이 문제를 기능이 아니라 전제로 놓고 시작해야 했습니다. 그리고 문제는 두 갈래였습니다.
첫째, 격리를 사람의 주의력에 맡기면 언젠가 반드시 샙니다. 데이터가 섞이지 않게 하는 가장 흔한 방식은 조회할 때마다 "내 고객사 것만"이라는 조건을 붙이는 것인데, 이 조건을 개발자가 매번 기억해서 붙여야 합니다. 화면이 수십 개면 조회 지점은 수백 곳이고 그중 한 곳만 빠뜨려도 사고가 나는데, 급한 수정이나 새로 합류한 개발자나 임시로 만든 관리자용 조회처럼 빠뜨릴 기회는 계속 생깁니다.
사고를 만든 것이 공격이 아니라 평범한 코드 한 줄이라는 점을 봐주세요. 악의를 가진 사람도 뚫린 방어벽도 없고 그저 조건 하나가 빠졌을 뿐인데 결과는 계약 종료입니다. 한 번이라도 빠뜨리면 안 되는 규칙은 사람이 지키게 만들면 안 되는 규칙입니다.
고객사마다 조직 구조가 다릅니다
둘째 갈래입니다. 역할을 코드에 고정해두면 이런 요구가 들어올 때마다 개발이 필요합니다.
| 고객사가 요구하는 것 | 고정 역할 방식에서 벌어지는 일 |
|---|---|
| "현장 기사는 펌웨어 배포만, 결제 화면은 못 보게" | 새 역할 추가 → 코드 수정 → 배포 |
| "우리는 관리자가 2단계예요" | 역할 목록 자체를 늘려야 함 → 배포 |
| "메뉴에서 아예 안 보이게 해주세요" | 권한과 별개인 메뉴 노출 로직이 또 생김 |
전부 똑같이 정당한 요구인데 매번 배포가 필요하다면 고객사가 늘어날수록 플랫폼은 굳어가고, 고객사 20곳이면 역할 요구도 20가지가 됩니다. 정리하면 격리는 무슨 일이 있어도 뚫리면 안 되고 권한은 고객사마다 자유로워야 하는데, 이 둘은 방향이 반대라서 같은 자리에서 함께 풀어야 했습니다.
격리와 권한은 방향이 반대라 같은 자리에서 풀어야 했습니다
고객사끼리 안 섞이게 하는 방법은 크게 셋이고, "건물을 어떻게 나눌 것인가"의 문제입니다.
| 방식 | 비유 | 장점 | 결정적 한계 |
|---|---|---|---|
| ① 고객사마다 별도 인프라 | 고객사마다 건물을 따로 지음 | 격리 강도 최고. 물리적으로 섞일 수 없음 | 고객사 1곳 늘 때마다 인프라 한 벌. 비용·배포·모니터링이 고객사 수만큼 곱해짐 |
| ② 인프라는 공용, 저장소만 분리 | 한 건물, 층마다 별도 금고 | 격리 강도 높음. 백업·삭제가 깔끔 | 신규 고객사마다 저장소 생성·마이그레이션 필요. 가입 즉시 사용이 어려움 |
| ③ 공용 인프라 + 논리 격리 | 한 건물, 열쇠로 구분되는 개인 사물함 | 고객사 추가가 레코드 한 줄. 운영 대상이 하나 | 격리가 소프트웨어 규칙에 달려 있음. 규칙이 뚫리면 전부 뚫림 |
아래로 내려갈수록 상자 개수가 줄어드는 대신 마지막 칸에 ⚠️ 표시가 붙는다는 점을 봐주세요. ③은 고객사를 늘리는 일을 레코드 한 줄로 만드는 대신 격리의 책임을 인프라가 아니라 소프트웨어 규칙 쪽으로 옮겨 놓으므로, ③을 고르려면 규칙이 뚫리지 않게 만드는 법을 함께 설계해야 합니다. 같은 선택지를 저울질하고 계신다면 ③의 ⚠️ 칸부터 먼저 보시길 권하는데, 나머지 장점은 이 칸을 감당할 수 있을 때만 장점입니다.
권한 쪽 후보도 셋이었습니다. 고정 역할은 가장 단순하지만 고객사가 역할을 못 만들어 요구마다 배포가 필요하고, 역할을 데이터로 두면 고객사가 직접 만들 수 있는 대신 권한 목록 설계를 처음에 잘해야 하며, 완전 자유 ACL은 이론상 무엇이든 표현할 수 있지만 아무도 현재 상태를 설명할 수 없습니다. 세 번째는 유연성의 대가가 너무 큰데, "이 사람이 지금 뭘 할 수 있는가"에 즉답할 수 없는 권한 체계는 보안 심사에서 가장 먼저 지적받는 지점이기 때문입니다.
커스텀 역할이 유료 기능이라는 건 돈 내는 요구라는 뜻입니다
오픈소스·상용 IoT 플랫폼들의 구조도 함께 봤습니다. 격리는 이름이 tenant · realm · domain으로 제각각이었을 뿐 대부분 ③ 공용 인프라 + 논리 격리가 기본이었고, 고객사별 전용 인프라는 상위 요금제의 옵션으로 빠져 있었습니다. 권한은 갈렸는데 커뮤니티 판은 고정 역할 3종으로 시작하고 커스텀 역할은 상용 판에서 제공하는 경우가 많았습니다.
두 번째가 특히 눈에 띄었습니다. 커스텀 역할이 유료 기능으로 분리돼 있다는 건 그게 실제 고객이 돈을 내는 요구라는 뜻이라, 저희는 이걸 나중에 얹을 기능이 아니라 처음부터 기본 구조로 넣기로 했습니다. 결국 격리는 ③, 권한은 역할을 데이터로 가되, 이 조합의 유일한 위험인 "규칙이 뚫리면 전부 뚫린다"를 어떻게 없앨지가 남은 숙제였습니다.
로그인에 성공했다는 것이 무엇이든 조회해도 된다는 뜻은 아닙니다
근거를 적기 전에 하나 짚고 갑니다. 격리를 굳이 이렇게까지 해야 하느냐는 질문이 늘 따라오기 때문입니다. 로그인은 이 요청을 보낸 사람이 누구인가를 정하는 일이고 권한은 그 사람이 어떤 데이터에 닿을 수 있는가를 정하는 일인데, 코드에서는 이 둘이 자주 뭉개집니다. "로그인한 사용자니까 일단 통과"가 되고 남의 고객사 것인지 아닌지는 그 뒤의 조회 코드가 알아서 거르게 되는 것입니다.
이 순간 격리의 책임이 인증 단계에서 애플리케이션 코드로 넘어가는데, 애플리케이션 코드는 매일 바뀝니다. 그리고 여기서 더 위험한 쪽은 공격이 아니라 평범한 버그입니다. 남의 데이터를 노리고 들어오는 사람은 오히려 예상할 수 있지만, 정말 무서운 건 급하게 추가한 조회 한 곳에서 고객사 조건을 빠뜨리는 일이라 악의도 뚫린 방어벽도 없이 그날 배포가 나가는 순간이 유출 시점이 됩니다.
화면에서 걸러내면 되지 않느냐는 반론도 자주 나오는데, 안 됩니다.
화면에서 걸러낸다는 건 이미 도착한 남의 데이터를 안 그리는 것입니다.
데이터는 이미 그 브라우저까지 갔으므로 응답을 열어보면 그대로 보입니다.
이미 온 것을 안 보여주는 것과 애초에 안 주는 것은 다릅니다. 계약서에 "고객사 데이터는 서로 격리됩니다"라고 쓰려면 뒤쪽이어야 해서, 규칙을 사람이 아니라 도구에 넣었습니다.
왼쪽에만 "개발자가 붙인다"는 칸이 있다는 점이 이 그림의 전부입니다. 그 칸이 있는 한 격리 수준은 그날 그 코드를 쓴 사람의 컨디션과 같아지는데, 이 차이는 서류에서 그대로 드러납니다. B2B 계약 검토와 보안 심사에서 확인하는 항목이 "격리가 애플리케이션 로직이 아니라 시스템 수준에서 강제되는가" 이므로, "우리는 조건을 잘 붙입니다"는 약속이고 "조건 없는 도구가 없습니다"는 구조이며 심사에서 통하는 건 뒤쪽입니다.
고객사 ID를 클라이언트가 말할 방법 자체를 없앴습니다
논리 격리가 뚫리는 가장 흔한 경로는 요청에 남의 고객사 ID를 적어 보내는 것입니다. 이걸 막으려면 서버가 "당신은 정말 그 회사 소속입니까?"를 매번 검사해야 하는데 한 곳이라도 빠뜨리면 그대로 뚫리므로, 저희는 검사를 강화하는 대신 경로를 없앴습니다.
불변 규칙: 고객사 ID는 항상 토큰·API 키·인증서에서 도출한다. 요청 경로·질의·본문으로 받지 않는다.
출입증에 소속 회사가 인쇄돼 있고 문은 출입증을 읽으므로, 방문자가 "저는 B사입니다"라고 말할 자리가 애초에 없습니다. 말할 자리가 없으면 거짓말을 검사할 필요도 없습니다. 접속 경로는 웹·앱 사용자의 일반 요청과 실시간 통신, 서버·외부 개발자의 API 키, 기기의 인증서까지 네 가지인데 넷 다 같은 원칙을 따릅니다.
오른쪽에는 마름모, 즉 검사가 아예 없다는 점을 봐주세요. 왼쪽은 검사를 수백 곳에서 빠짐없이 해야 이기는 싸움이지만 오른쪽은 클라이언트가 고객사를 말할 입구 자체가 없어서 검사할 거짓말이 생기지 않습니다. 같은 원칙을 입력 쪽에도 적용해서 서버가 결정하는 값인 식별자·고객사 ID·생성 시각·생성자는 클라이언트 입력에서 아예 받지 않는데, 받지 않으면 조작할 수 없기 때문입니다.
조건을 사람이 붙이지 않고 도구가 붙입니다
고객사 ID가 확정돼도 그걸 모든 조회에 실제로 붙이는 일이 남아서 세 가지를 정했습니다. 먼저 저장 구조부터 고객사 단위로 쪼개 모든 고객사 리소스가 고객사 ID를 저장소의 파티션 키로 쓰게 했으니, 우편함으로 치면 회사마다 칸이 나뉜 셈이라 조회는 애초에 우리 칸 안에서 일어납니다. 그리고 조건 주입을 SDK가 담당해 개발자가 쓰는 도구가 모든 질의에 고객사 조건을 강제로 끼워 넣고, 조건을 뺀 버전은 제공하지 않습니다.
마지막으로 우회로를 만들지 않았습니다.
| 금지 | 왜 |
|---|---|
| 전체 스캔 기능을 SDK 표면에 노출하지 않음 | "전부 훑기"가 가능한 순간, 조건을 빠뜨린 코드가 남의 데이터를 집어 옵니다 |
| 서비스 코드가 클라우드 SDK를 직접 호출 금지 | 도구를 우회하면 조건 주입도 우회됩니다 |
| 프론트엔드·앱에 클라우드 SDK 직접 사용 금지 | 자격증명이 클라이언트로 나가는 경로를 없앰 |
첫 줄이 핵심입니다. 전체 스캔은 개발할 때는 편하고 운영할 때는 재앙인 기능이라 있으면 반드시 쓰이므로, 처음부터 만들지 않았습니다.
개발자가 손대는 자리가 어디에도 없다는 것이 이 흐름의 요점입니다. 고객사 ID를 정하는 것도 조건을 붙이는 것도 전부 도구가 하고 서비스 코드는 그 사이를 지나갈 뿐이라, "우리는 조회할 때 조건을 붙입니다"가 아니라 "조건 없는 조회 도구가 존재하지 않습니다"가 됩니다.
남의 것은 "권한 없음"이 아니라 "없음"입니다
작지만 중요한 결정입니다. 다른 고객사의 리소스를 조회하면 뭐라고 답할 것인가. 403은 정직해 보이지만 "그 ID는 존재한다, 다만 내 것이 아니다"를 흘립니다. ID를 계속 바꿔가며 요청하면 403과 404가 갈리는 지점에서 이 플랫폼에 어떤 리소스가 몇 개나 있는지가 드러나는데, 경쟁사가 같은 플랫폼을 쓰고 있다면 이건 그 자체로 사업 정보입니다.
왼쪽 마름모에서 갈래가 둘로 나뉜다는 사실 그 자체가 문제입니다. 응답이 갈리는 순간 데이터를 한 건도 못 봐도 무엇이 존재하는지는 세어 나갈 수 있지만 오른쪽은 갈래가 하나뿐이라 셀 것이 없어서, 다른 고객사의 리소스는 전부 404로 통일했습니다. 권한 부족과 인증 실패는 원인별로 코드를 나눠 두되 남의 고객사 건만은 예외 없이 404입니다. 같은 발상을 로그인 실패 응답에도 적용해서, 계정 없음·비밀번호 오류·계정 잠김의 응답을 바이트 단위로 동일하게 맞춥니다.
역할은 코드가 아니라 데이터입니다
권한 쪽 결정입니다. 역할을 코드에 박힌 목록이 아니라 고객사가 만들고 고치는 데이터로 뒀고, 권한은 리소스:행동 형태의 문자열 하나입니다.
devices:read 기기 조회
devices:write 기기 생성·수정
firmware:deploy 펌웨어 배포
menu:billing 결제 메뉴 접근
역할은 이 문자열들의 묶음일 뿐이라, 열쇠꾸러미에 비유하면 권한 문자열이 열쇠 한 개고 역할이 꾸러미이며 고객사는 자기 조직에 맞는 꾸러미를 원하는 만큼 만들 수 있습니다.
멤버가 권한 문자열에 직접 연결되지 않는다는 점을 봐주세요. 사람은 역할에 붙고 권한은 역할에 담기므로, 고객사가 새 꾸러미를 하나 만들어 사람을 옮겨 붙이는 것만으로 조직 개편이 끝납니다. 가입 즉시 쓸 수 있도록 기본 역할 3종은 고객사 생성 시 자동으로 만들어지고 시스템 역할로 표시돼 삭제되지 않는데, 모든 역할을 지워서 아무도 못 들어가는 상태를 만드는 사고를 막기 위해서입니다.
메뉴 접근 권한도 같은 체계에 넣었습니다. "이 메뉴는 보이게/안 보이게"를 별도 설정으로 만들면 권한 체계가 두 벌이 되고 둘이 어긋나는 순간 메뉴는 숨겨졌는데 주소를 직접 치면 들어가지는 상태가 되는데, menu:billing 같은 문자열로 통일하면 화면 노출과 실제 접근 판정이 같은 값 하나를 봅니다. 구현 규칙도 함께 못박았습니다.
❌ if (role === 'admin')
✅ if (can(ctx, 'devices:write'))
코드 어디에도 역할 이름을 문자열로 비교하는 곳을 두지 않고 전부 "이 권한을 갖고 있는가"로만 판정하는데, 이 규칙이 없으면 커스텀 역할은 만들어져도 동작하지 않습니다. 코드가 admin이라는 이름만 알아보기 때문입니다.
역할로는 넘을 수 없는 선을 따로 뒀습니다
역할을 데이터로 만들면 새 위험이 생깁니다. 역할을 편집할 수 있는 사람이 자기 권한을 무한정 키울 수 있고, 극단적으로는 관리자 역할을 손봐서 원래 소유자를 밀어낼 수도 있습니다. 그래서 소유자는 역할이 아니라 고객사 레코드의 별도 필드로 뒀습니다.
기기 조회·제어·멤버 초대·펌웨어 배포는 역할 권한으로 판정하지만 고객사 삭제·결제 수단 변경·소유권 이전은 소유자 여부로만 판정하고, 소유권은 역할 체계 바깥에 있으므로 역할을 아무리 조작해도 넘어가지 않습니다. 건물로 치면 역할은 출입증이고 소유권은 등기부라, 출입증을 아무리 많이 발급해도 등기는 안 바뀝니다.
API 키는 발급자보다 강해질 수 없습니다
외부 연동에 쓰는 API 키에도 같은 원칙을 적용해서 키의 권한 범위를 발급자 권한의 부분집합으로 두었고, 검사는 두 단계입니다. 키에 그 범위가 있는지 확인하고, 키를 발급한 사람이 지금도 그 권한을 갖고 있는지 확인합니다.
2단계가 핵심입니다. 이게 없으면 관리자가 API 키를 발급하고 퇴사했을 때 계정은 정리했는데 키는 계속 관리자 권한으로 살아 있고, 발급 시점의 권한이 키에 화석처럼 굳습니다.
초록 상자가 파란 상자 안에 완전히 들어가 있다는 점이 요점입니다. 키는 발급자 바깥으로 한 칸도 나갈 수 없고 파란 상자가 줄어들면 그 안의 초록 상자도 함께 줄어드니, 사람의 권한을 회수하는 것만으로 그 사람이 뿌린 키까지 함께 좁혀지고 퇴사자 처리 절차에서 "어떤 키가 남아 있는지 전수 조사"라는 작업이 사라집니다.
격리 규칙은 코드 리뷰로 지킬 수 없습니다
돌아보면 여기서 한 번 걸려 넘어졌습니다. 앞에서 "사람에게 맡기면 안 된다"고 해놓고 정작 규칙을 지키는 수단은 코드 리뷰뿐이었기 때문인데, 리뷰어도 사람이고 급한 날엔 놓칩니다. 그래서 기계가 검사할 수 있는 형태로 규칙을 다시 썼습니다.
| 규칙 | 검사 방법 |
|---|---|
| 다른 고객사 리소스 접근은 404 | 격리 테스트 필수. 남의 고객사 리소스를 요청해 404가 나오는지 자동 검증 |
| 프론트엔드·앱에 클라우드 SDK 직접 사용 금지 | 소스 전체 검색 결과가 0건이어야 통과 |
| 앱이 실시간 통신 라이브러리를 직접 쓰지 않음 | 소스 전체 검색 결과가 0건이어야 통과 |
| 전체 스캔 금지 | 도구 표면에 아예 없음 (쓸 방법이 없음) |
| 인증·권한 경로 테스트 | 필수 항목 |
격리 규칙이 문서가 아니라 빨간 불이 되었다는 것이 이 그림의 전부입니다. 위쪽 세 검사는 사람의 기억력이 아니라 기계가 매번 똑같이 수행하고, 실패하면 그날이 아무리 급해도 코드가 들어가지 않습니다.
역할을 바꾸면 실시간 통신 권한도 같이 바뀌어야 합니다
일반 요청은 간단합니다. 매 요청마다 인증 단계에서 멤버십과 역할을 읽어 권한 목록을 만들므로 역할을 바꾸면 다음 요청부터 바로 반영되는데, 문제는 실시간 통신입니다. 앱은 실시간 채널에 붙을 때 별도의 임시 자격증명을 받고 이미 연결된 세션은 그 자격증명을 계속 쓰므로, 콘솔에서 권한을 회수해도 이미 연결된 앱은 한동안 그대로 동작할 수 있습니다. 1편에서 다룬 문제와 같은 뿌리인데, 권한이 두 곳에 존재하면 둘을 맞추는 일이 따로 필요합니다.
대응으로 역할·멤버십 변경과 실시간 접근 권한 갱신을 한 흐름으로 묶고, 필요한 권한을 주는 동시에 불필요해진 권한은 회수하는 차집합 방식을 썼으며, 주기적으로 실제 멤버십과 부여된 권한을 대조하는 배치를 돌리고 세션 만료를 짧게 잡았습니다.
⚠️ 상태에서 나가는 화살표가 두 개라는 점을 봐주세요. 정상 경로인 갱신이 늦더라도 세션 만료라는 두 번째 경로가 있어서 어느 쪽으로 가든 결국 새 권한으로 수렴하지만, 그 사이에 시차가 있다는 것이 이 그림이 숨기지 않는 부분입니다. 완전히 즉각적이지는 않다는 점을 문서에 그대로 적었는데 "즉시 반영된다"고 적어놓고 실제로는 아닌 것이 가장 위험하기 때문이고, 이 부분은 지금도 더 나은 방법이 있을 것 같습니다.
메뉴 권한을 별도 체계로 만들 뻔했습니다
초기 검토안에서는 "기능별 접근 권한"과 "메뉴별 접근 권한"이 별개 요구로 적혀 있었고 돌아보면 여기서 한동안 갈렸는데, 그대로 만들었다면 권한 테이블 한 벌과 메뉴 노출 설정 한 벌이 됐을 것입니다. 한 벌로 합쳐 메뉴도 똑같은 형식의 권한 문자열로 두었으니, 화면이 보이는 조건과 접근이 허용되는 조건이 같은 값 하나여서 앞서 말한 유형의 사고가 구조적으로 생기지 않습니다.
지금 손에 있는 것을 그대로 적습니다
현재 이 구조는 설계 확정 + 동작하는 화면 프로토타입 단계이고 실사용 검증은 다음 단계입니다. 지금까지 이야기한 것을 한 장으로 모으면 이렇습니다.
①~④ 어디에도 사람이 고객사를 지정하는 자리가 없다는 점이 이 글 전체의 요약입니다. 들어오는 문은 하나뿐이고 그 문에서 이미 소속이 정해지며 그 뒤로는 도구가 조건을 붙이는데, 맨 아래로 빠지는 점선 한 줄이 이 글이 지키려던 규칙입니다.
구현이 따라야 하는 계약으로 고정한 값들이고, 어기면 리뷰에서 반려됩니다.
| 항목 | 규약 |
|---|---|
| 고객사 ID | 항상 토큰·키·인증서에서 도출. 경로·질의·본문 금지 |
| 저장 | 모든 고객사 리소스는 고객사 ID를 파티션 키로 |
| 조회 | SDK가 모든 질의에 고객사 조건 강제 주입. 우회 API 없음 |
| 전체 스캔 | SDK 표면에 노출하지 않음 |
| 타 고객사 접근 | 404 (403 아님) |
| 권한 표기 | {리소스}:{행동} 문자열 |
| 기본 역할 | 관리자·편집자·읽기전용 3종 자동 시딩, 삭제 불가 |
| 커스텀 역할 | 고객사가 콘솔에서 직접 생성·수정 (배포 불필요) |
| 소유자 | 역할이 아닌 별도 필드. 삭제·결제·소유권 이전은 소유자만 |
| API 키 | 범위는 발급자 권한의 부분집합. 2단계 검사 |
| 판정 방식 | 역할 이름 비교 금지. can(ctx, '권한문자열') 로만 |
프로토타입은 클릭되는 관리자 콘솔 화면으로 만들어 이 구조가 화면으로 성립하는지 확인했습니다. 테넌트 생성 화면에서는 이름과 식별자를 입력하면 생성될 식별자·리소스 접두어·저장소 파티션 키가 즉시 미리보기로 뜨고, 함께 자동 생성되는 항목이 체크리스트로 표시됩니다. 테넌트 상세에서는 소유권 이전·일시정지·삭제를 위험 구역으로 분리하고 소유권 이전 화면에 "역할 조작으로는 넘길 수 없다"는 설명을 명시했습니다.
권한 매트릭스 화면이 특히 유용했습니다. 기기·Pool·사용자·멤버·역할·콘텐츠·자동화 같은 리소스 권한과 펌웨어·내보내기·API 키 같은 기능 권한, 그리고 메뉴 접근까지 총 35개의 권한 스위치가 들어가 있고 각 스위치 옆에 실제 권한 문자열이 그대로 보입니다. 덕분에 "우리 회사 현장 기사에게 뭘 켜주면 되는지"를 비개발자가 직접 짚을 수 있었는데, 권한 설계는 개발자만 보면 반드시 빠지는 항목이 생기므로 기획 담당자가 검토에 참여할 수 있었던 것이 컸습니다.
검증 산출물로는 기존 사내 구현체 조사, 외부 플랫폼 대조, 화면-권한 전수 대조가 남았고 매트릭스 35개가 마지막 작업의 결과입니다.
규칙은 "지키자"가 아니라 "어길 수 없다"여야 합니다
가장 크게 남은 교훈입니다. 돌아보면 처음에는 격리를 강한 규칙과 꼼꼼한 리뷰로 지키려 했는데, 설계를 구체화할수록 사람이 매번 기억해야 하는 규칙은 언젠가 반드시 깨진다는 결론이 분명해졌습니다. 그래서 고객사 ID를 요청으로 받지 않고 조건 없는 조회 도구를 만들지 않고 전체 스캔을 노출하지 않았는데, 전부 "무엇을 하지 않는가"의 결정입니다. 만들지 않은 기능은 오용될 수 없다는 것이 보안 설계에서 가장 값싸고 확실한 수단이었습니다.
유연성이 무엇인지도 다시 정의하게 됐습니다. 완전 자유 ACL은 이론상 최고로 유연하지만 "지금 이 사람이 뭘 할 수 있나"에 답할 수 없고, 답할 수 없는 권한 체계는 고객사 보안 검토를 통과하지 못합니다. 리소스:행동 문자열로 어휘를 제한한 대신 조합은 완전히 열어둔 것이 자유도와 설명 가능성을 동시에 얻는 지점이었고, 권한 매트릭스 한 장으로 역할 전체를 보여줄 수 있다는 게 그 증거입니다.
정보를 안 흘리는 것도 격리라는 것도 배웠습니다. 403 대신 404를 쓰기로 한 결정은 처음엔 사소해 보였지만 실제로는 데이터를 못 보게 하는 것과 데이터가 있다는 사실조차 못 알게 하는 것의 차이였고, 경쟁사가 같은 플랫폼에 올라와 있을 수 있다고 전제하면 두 번째까지 막아야 격리가 완성됩니다.
| 고객이 얻는 것 | 근거 |
|---|---|
| 다른 고객사를 지목하는 것이 불가능 | 고객사 ID를 요청으로 받지 않음. 토큰·키·인증서에서만 도출 |
| 격리가 앱 코드 품질과 무관 | 조건 주입은 SDK가 강제. 조건 없는 조회 도구가 존재하지 않음 |
| 리소스 존재 여부조차 노출 안 됨 | 타 고객사 건은 예외 없이 404 |
| 조직 구조가 바뀌어도 배포 불필요 | 역할을 고객사가 직접 만들고 권한을 조합 |
| 권한 체계가 한 장으로 설명됨 | 리소스·기능·메뉴 권한이 모두 같은 형식 하나 |
| 소유권은 어떤 권한 조작으로도 못 뺏김 | 소유자는 역할 바깥의 별도 필드 |
| 퇴사자 처리가 키까지 함께 정리됨 | API 키 권한은 발급자 권한의 부분집합 |
마지막 세 줄이 B2B에서 특히 큽니다. 권한 설계는 평소엔 아무도 안 보다가 사고가 난 날 전부가 보는 영역이라, 그날 설명할 수 있는 구조인지가 기준이었습니다.
조건 하나를 빠뜨렸다는 이유로 사고가 나는 시스템이라면 그건 그 개발자의 실수일까요 아니면 그렇게 만들어 둔 구조의 문제일까요. 저희는 후자로 보기로 했고, 그래서 "격리를 지키자"를 "격리를 어길 방법이 없다"로 옮겼습니다.
읽어주셔서 감사합니다.



