멀티테넌트 백엔드에서 절대 새면 안 되는 게 두 가지 있다. 다른 고객의 데이터, 그리고 외부 서비스 연동에 쓰는 액세스 토큰.
이 둘은 한 번 새면 버그 하나로 안 끝난다. 처음엔 "쿼리 짤 때 조심하자", "토큰 조심히 다루자" 같은 팀 규칙으로 막으려 했는데 안 됐다. 규칙은 사람이 지키는 거고, 사람은 바쁘면 까먹는다.
그래서 조심하는 대신 실수할 수 없는 구조로 바꿨다. 아래는 그 두 군데를 어떻게 손봤는지 정리한 거다. 코드는 실제 사내 코드가 아니라 패턴만 추린 예시다.
멀티테넌시는 하나의 애플리케이션을 여러 고객(테넌트)이 나눠 쓰는 구조다. 인프라와 코드는 공유하지만 각 테넌트의 데이터는 서로 안 보이게 분리해야 한다. 이 "분리를 어디서 하느냐"에 따라 방식이 갈린다. 크게 세 가지다.
DB 분리 (database per tenant). 테넌트마다 아예 별도 DB를 둔다. 격리가 제일 세다. 물리적으로 남의 데이터에 닿을 방법이 없으니까. 대신 테넌트가 늘수록 연결·마이그레이션·백업이 전부 N배로 늘어난다. 금융·의료처럼 규제가 강하거나, 테넌트가 소수의 대형 고객일 때 쓴다.
스키마 분리 (schema per tenant). 한 DB 안에서 테넌트별로 스키마를 나눈다. DB 분리보다 가볍고 격리는 중간쯤이다. 테넌트 수가 어느 정도까지는 괜찮은데, 수백~수천으로 늘면 스키마가 폭증하고 마이그레이션을 스키마마다 돌려야 해서 관리가 버거워진다. 테넌트 수가 적당하면서 격리도 어느 정도 필요할 때 쓴다.
공유 스키마 + 식별자 컬럼. 모든 테넌트 데이터를 같은 테이블에 넣고 tenant_id 컬럼으로 구분한다. 제일 저렴하고, 테넌트가 수천 개로 늘어도 인프라 부담이 거의 안 는다. 대신 격리가 물리적 경계가 아니라 코드(쿼리의 tenant_id 조건)에 달린다. 테넌트가 많고 계속 느는 SaaS에서 제일 흔하게 쓰는 방식이다.
위에서 아래로 갈수록 격리는 약해지고 비용·운영은 싸진다.
우리는 세 번째, 공유 스키마 + tenant_id 컬럼 방식을 골랐다. 테넌트 수가 많고 계속 늘어서 DB나 스키마를 테넌트마다 두는 건 운영이 안 맞았다. 대신 이 방식은 약점이 분명하다. 격리가 쿼리 한 줄(WHERE tenant_id = ?)에 달려 있다는 것. 이 약점을 사람 주의력이 아니라 구조로 메우는 게 아래 1번 내용이다.
멀티테넌트에서 제일 흔한 사고는 쿼리에서 WHERE tenant_id = ? 한 줄을 빼먹는 거다. 리뷰에서도 잘 놓친다. 다른 조건이 잔뜩 붙어 있으면 눈에 안 들어온다.
처음엔 인증 미들웨어에서 막으려고 했는데, 생각해보니 그 레이어는 "지금 요청한 사람이 누구인지"까지밖에 모른다. 정작 사고가 나는 지점은 한참 아래, DB에 직접 닿는 쿼리다. 그래서 검증을 거기로 내렸다. 데이터에 제일 가까운 곳에서 막는 게 맞더라.
방법은 간단하다. 조회 함수에 tenantId가 안 들어오면 예외를 던진다.
function requireTenantId(tenantId?: number | null): number {
if (tenantId == null || !Number.isFinite(tenantId)) {
throw new Error('tenantId가 없습니다. 모든 조회는 tenant_id 필터가 필수입니다.');
}
return tenantId;
}
그리고 데이터 접근하는 모든 메서드는 첫 줄에서 이걸 부른다.
async findRecords(tenantId: number, accountIds: string[]) {
requireTenantId(tenantId); // 없으면 여기서 바로 throw
if (accountIds.length === 0) return [];
return this.db.query(`
SELECT ...
FROM records
WHERE tenant_id = @tenantId -- 항상 존재
AND account_id IN UNNEST(@accountIds)
`, { tenantId, accountIds });
}
포인트는 "빼먹었을 때 어떻게 되느냐"다. 예전 방식이면 tenantId를 안 넘겨도 조용히 전체 데이터가 나오거나 빈 값이 나온다. 둘 다 나쁘다. 지금은 그냥 터진다. 개발하다가, 늦어도 테스트에서 바로 걸리고 프로덕션까지 못 간다. "조용한 전체 조회"가 불가능해진 거다.
여기에 스키마에서도 한 겹 더 깔았다. 핵심 테이블엔 tenant_id를 NOT NULL로 박았고, 서비스에선 아예 프로젝트 정보를 읽을 때 tenantId랑 접근 가능한 accountIds를 한 세트로 가져오게 했다. tenantId만 따로 잊고 넘어가는 경로 자체를 없앤 거다.
대단한 건 아니고 "빼먹으면 티 나게" 만든 것뿐인데, 리뷰로 매번 잡는 것보다 훨씬 낫다.
두 번째는 연동 토큰이다. 외부 서비스랑 연결하려고 받아둔 액세스 토큰·시크릿이 DB에 들어가는데, 최악의 상황(DB 덤프 유출)에서도 이건 읽히면 안 된다.
암호화는 AES-256-GCM을 골랐다. 이유는 단순한데, GCM은 암호화(기밀성)뿐 아니라 인증 태그로 변조 여부까지 잡아준다(무결성). 누가 암호문을 조작하면 복호화 자체가 실패한다. 토큰처럼 "몰래 바꿔치기"도 막아야 하는 데이터엔 이게 맞다.
저장 포맷은 접두어 하나 붙이고 base64(IV | 태그 | 암호문)로 통일했다.
const PREFIX = 'enc:gcm:';
const IV_BYTES = 12; // GCM 권장 nonce 길이
function encrypt(plain: string): string {
if (plain.startsWith(PREFIX)) return plain; // ① 이미 암호화면 패스
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv('aes-256-gcm', getKey(), iv);
const ct = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return PREFIX + Buffer.concat([iv, tag, ct]).toString('base64');
}
function decrypt(token: string): string {
if (!token.startsWith(PREFIX)) return token; // ② 접두어 없으면 옛날 평문
const buf = Buffer.from(token.slice(PREFIX.length), 'base64');
const iv = buf.subarray(0, IV_BYTES);
const tag = buf.subarray(IV_BYTES, IV_BYTES + 16);
const ct = buf.subarray(IV_BYTES + 16);
const decipher = createDecipheriv('aes-256-gcm', getKey(), iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
}
여기서 실제로 굴려보고 얻은 게 세 가지 있다.
하나, 멱등하게 만들 것. 이미 암호화된 값이면 다시 안 건드리고 그대로 돌려준다. 별거 아닌 것 같은데 기존 데이터를 암호화하는 배치를 돌릴 때 이게 은근 중요하다. 배치가 중간에 죽든 두 번 돌든 이중 암호화가 안 난다. 다시 돌리면 그만이다.
둘, 옛날 평문이랑 공존하게 할 것. 복호화할 때 접두어가 없으면 "이건 암호화 전 값이구나" 하고 그대로 반환한다. 덕분에 한 번에 다 바꿀 필요가 없었다. "신규 저장분부터 암호화하고, 기존 값은 배치로 천천히" 라는 무중단 전환이 가능했다. 전면 마이그레이션은 항상 무섭다. 이건 안 무섭다.
셋, 다 암호화하면 안 된다. 이게 처음에 삽질했던 부분이다. 연동 정보 안에는 시크릿만 있는 게 아니라, 중복 연동 검사 같은 데서 평문으로 검색해야 하는 식별자도 같이 들어있다. 그런데 GCM은 매번 IV가 달라서 같은 값도 암호문이 매번 다르게 나온다. 식별자까지 암호화하면 "이 계정 이미 연동했나?" 같은 조회가 아예 안 된다. 그래서 키를 나눴다. 시크릿은 암호화, 검색용 식별자는 평문.
// 검색·중복검사에 쓰여서 평문으로 둬야 하는 키들
const PLAINTEXT_KEYS = new Set(['name', 'accountId', 'externalId']);
function mapValues(obj, fn) {
const out = {};
for (const [k, v] of Object.entries(obj)) {
// 식별자거나 문자열이 아니면(만료시각 등) 그대로 둔다
out[k] = PLAINTEXT_KEYS.has(k) || typeof v !== 'string' ? v : fn(v);
}
return out;
}
마지막으로 개인적으로 제일 만족한 건 이걸 ORM 컬럼 트랜스포머로 붙인 거다. 저장할 때 자동 암호화, 읽을 때 자동 복호화. 그래서 서비스 코드 어디에도 encrypt() 호출이 없다.
// 저장 시 암호화 / 로드 시 복호화 — 서비스 코드는 이걸 몰라도 됨
const credentialsTransformer = {
to: encryptCredentials,
from: decryptCredentials,
};
이게 왜 좋냐면, 새 개발자가 연동 기능 하나 더 붙일 때 "여기 암호화 넣어야 하나?"를 고민할 필요가 없어진다. 안 넣어도 이미 들어가 있으니까. 방어선 1이랑 똑같은 생각이다. 안전을 옵션이 아니라 기본값으로 만드는 것.
당연한 얘기지만 짚고 간다. 암호화 코드보다 중요한 건 키를 어디 두느냐다. 키가 리포지토리에 있으면 위에 한 거 전부 의미 없다.
키는 환경변수로 주입하고, 앱 뜰 때 길이까지 확인한다. 32바이트 아니면 부팅을 못 하게.
function getKey(): Buffer {
const raw = process.env.ENC_KEY;
if (!raw) throw new Error('ENC_KEY가 없습니다.');
const key = Buffer.from(raw, 'base64');
if (key.length !== 32) {
throw new Error(`ENC_KEY 길이가 이상합니다: ${key.length}바이트 (32 필요).`);
}
return key;
}
실제 키 값은 배포 환경의 시크릿 매니저에 넣고, 인프라 코드(IaC)에는 "참조"만 남긴다. 값 자체는 IaC 밖에서 주입한다. 안 그러면 인프라 저장소에 시크릿이 그대로 박히거나, 배포할 때마다 키 참조가 흔들린다. 그리고 키가 없으면 앱이 토큰을 불러오는 순간 바로 죽는다. 이것도 앞이랑 같은 fail-fast다. 애매하게 도는 것보다 확실하게 죽는 게 낫다.
테넌트 필터는 빼먹으면 서버가 터지게, 토큰은 저장하면 자동으로 암호화되게, 키는 없으면 아예 못 뜨게. 공통점은 "사람이 매번 신경 써야 하는 일"을 "시스템이 알아서 강제하는 일"로 옮긴 거다.
멀티테넌트에서 보안은 조심해서 되는 게 아니다. 조심은 언젠가 무너진다. 실수할 수 있는 경로 자체를 막아두면 그 다음부터는 신경 안 써도 된다.