멀티테넌트 AI 서비스 설계 — 격리와 보안, SQLite로 시작하기
혼자 쓰는 AI 에이전트를 여러 고객에게 열어주는 순간, 코드 난이도는 그대로인데 실패의 대가가 완전히 달라집니다. 버그 하나가 "동작 안 함"이 아니라 "A사가 B사 데이터를 봄"이 되니까요.
이 글은 그 경계를 만드는 설계에 관한 것입니다. 화려한 기술은 없습니다. 대신 빠뜨리면 반드시 사고가 나는 지점들을 순서대로 짚습니다.
⚠️ 아래 코드의 키·토큰·경로는 전부 예시 자리표시자입니다. 실제 값을 그대로 쓰지 마세요.
1. 테넌트는 컨텍스트로 흐른다 — 그리고 fail-closed
멀티테넌시의 90%는 "모든 쿼리에 WHERE tenant_id = ?가 빠짐없이 붙는가"입니다. 문제는 사람이 빠뜨린다는 것이고요.
그래서 테넌트 ID를 함수 인자로 넘기지 말고 요청 컨텍스트에 실어 흘려보냅니다. 진입점에서 한 번 심고, 저장소 계층에서 강제로 꺼내 씁니다.
type ctxKey struct{}
func WithTenant(ctx context.Context, id uuid.UUID) context.Context {
return context.WithValue(ctx, ctxKey{}, id)
}
// 핵심: 없으면 "전체 조회"가 아니라 에러 (fail-closed)
func TenantFrom(ctx context.Context) (uuid.UUID, error) {
id, ok := ctx.Value(ctxKey{}).(uuid.UUID)
if !ok || id == uuid.Nil {
return uuid.Nil, errors.New("컨텍스트에 테넌트가 없습니다")
}
return id, nil
}
fail-closed(실패 시 잠기는 쪽으로)가 이 설계의 전부입니다. 테넌트를 못 찾았을 때 "필터 없이 전부 반환"으로 두면, 어느 날 미들웨어 한 줄이 빠졌을 때 조용히 전 고객 데이터가 나갑니다. 에러로 죽는 편이 압도적으로 낫습니다.
func (s *Store) ListAgents(ctx context.Context) ([]Agent, error) {
tid, err := TenantFrom(ctx)
if err != nil {
return nil, err // 테넌트 없으면 여기서 끝
}
return s.query(ctx,
`SELECT * FROM agents WHERE tenant_id = ?`, tid)
}
테넌트는 어디서 오나요? 진입점마다 다릅니다. 테넌트에 묶인 API 키가 가장 깔끔합니다 — 키만 보면 테넌트가 정해지므로 클라이언트가 헤더로 테넌트를 보낼 필요가 없고, 따라서 테넌트를 위조할 여지도 없습니다. 챗 채널(텔레그램·디스코드 등)은 채널 인스턴스 설정에 테넌트를 박아둡니다.
2. SQLite로 멀티테넌트가 되나
됩니다. 다만 선택지를 알고 골라야 합니다.
- 단일 DB +
tenant_id컬럼 — 운영이 단순하고 마이그레이션이 한 번입니다. 대신 격리가 전적으로 애플리케이션 코드에 달려 있습니다 - 테넌트별 DB 파일 — 격리가 파일 수준이라 확실하고 백업·삭제도 쉽습니다. 대신 마이그레이션을 N번 돌려야 하고 크로스 테넌트 통계가 번거롭습니다
수십~수백 테넌트 규모라면 단일 DB로 충분합니다. SQLite는 쓰기가 직렬화되므로 WAL 모드를 켜서 읽기와 쓰기가 서로 막지 않게 하세요.
PRAGMA journal_mode = WAL; -- 읽기/쓰기 동시성
PRAGMA busy_timeout = 5000; -- 잠금 대기 5초
PRAGMA foreign_keys = ON;
-- 복합 인덱스는 반드시 tenant_id를 앞에
CREATE INDEX idx_sessions_tenant_agent
ON sessions(tenant_id, agent_id, updated_at DESC);
인덱스 첫 컬럼을 tenant_id로 두는 건 성능 문제만이 아닙니다. 테넌트별로 데이터가 물리적으로 뭉치므로 조회가 다른 테넌트의 페이지를 건드릴 일이 줄어듭니다.
3. 워크스페이스 격리 — 경로 탈출을 막아라
에이전트에게 파일 도구를 주면 파일시스템이 새로운 공격면이 됩니다. 테넌트마다 루트를 나누는 것까지는 쉽습니다.
/data/workspaces/{tenant_id}/{agent_id}/
진짜 문제는 ../../../etc/passwd입니다. LLM이 그런 경로를 만들 수도 있고, 사용자가 유도할 수도 있습니다. 모든 경로 해석은 반드시 한 함수를 거치게 하세요.
func (fs *ScopedFS) Resolve(rel string) (string, error) {
// 1) 심볼릭 링크까지 풀어서 절대경로로
abs, err := filepath.Abs(filepath.Join(fs.root, rel))
if err != nil {
return "", err
}
real, err := filepath.EvalSymlinks(abs)
if err != nil && !os.IsNotExist(err) {
return "", err
}
if real == "" {
real = abs // 아직 없는 파일(생성 예정)
}
// 2) 루트 안에 있는지 확인 — 접두사 비교만으로는 부족
rp, err := filepath.Rel(fs.root, real)
if err != nil || rp == ".." || strings.HasPrefix(rp, ".."+string(os.PathSeparator)) {
return "", errors.New("워크스페이스 밖 경로 접근 거부")
}
return real, nil
}
두 군데가 포인트입니다. 심볼릭 링크를 먼저 풀어야 합니다 — 워크스페이스 안에 밖을 가리키는 링크를 만들어두면 단순 문자열 검사는 통과합니다. 그리고 문자열 접두사 비교 대신 filepath.Rel을 쓰세요. /data/ws/tenant-1과 /data/ws/tenant-10은 접두사 검사를 통과해버립니다.
4. API 키 암호화 — AES-256-GCM
테넌트마다 자기 LLM 프로바이더 키를 등록하는 구조라면, 그 키가 DB에 평문으로 있으면 안 됩니다. AES-256-GCM이 표준적인 선택입니다. GCM은 암호화와 무결성 검증을 동시에 하므로, 누가 DB에서 바이트를 조작하면 복호화가 실패합니다.
const prefix = "aes-gcm:"
func Encrypt(plaintext, key string) (string, error) {
if key == "" || plaintext == "" {
return plaintext, nil
}
keyBytes, err := DeriveKey(key) // 32바이트로 정규화
if err != nil {
return "", err
}
block, err := aes.NewCipher(keyBytes)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil { // 반드시 crypto/rand
return "", err
}
// Seal이 nonce 뒤에 이어붙여 준다: nonce + 암호문 + 인증태그
out := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return prefix + base64.StdEncoding.EncodeToString(out), nil
}
설계에서 챙길 것들입니다.
- nonce는 매번 새로,
crypto/rand로. GCM에서 같은 키로 nonce를 재사용하면 암호가 통째로 무너집니다.math/rand는 절대 안 됩니다 - 접두사를 붙이세요.
aes-gcm:하나로 "이 값이 암호화된 것인지"를 판별할 수 있고, 평문이 남아 있던 기존 데이터와 섞여도 점진적 마이그레이션이 됩니다 - 복호화 실패는 조용히 넘기지 마세요. 키가 틀렸거나 데이터가 손상된 것이므로 에러로 올려야 합니다. 여기서 평문을 반환하면 무결성 검증을 스스로 무력화하는 셈입니다
- 암호화 키는 DB 밖에. 환경변수나 시크릿 매니저에 두세요. DB 안에 두면 암호화하는 의미가 없습니다
그리고 로그·에러 메시지·API 응답에 키가 새지 않는지를 별도로 점검하세요. 암호화를 완벽히 해놓고 디버그 로그로 흘리는 사고가 흔합니다. 응답 직렬화 구조체에서 키 필드는 아예 빼거나 마스킹하는 게 안전합니다.
5. RBAC와 세션 분리
테넌트 격리는 가로 방향(고객 간)이고, RBAC는 세로 방향(같은 고객 안에서 역할별)입니다. 둘 다 필요합니다.
type Role string
const (
RoleOwner Role = "owner" // 청구·멤버 관리 포함 전부
RoleAdmin Role = "admin" // 에이전트·프로바이더 설정
RoleMember Role = "member" // 에이전트 사용
RoleViewer Role = "viewer" // 읽기 전용
)
func Require(ctx context.Context, need Role) error {
have, err := RoleFrom(ctx)
if err != nil {
return err // 역할 불명 → 거부 (fail-closed)
}
if rank[have] < rank[need] {
return ErrForbidden
}
return nil
}
세션도 갈라야 합니다. 세션 키를 session_id 하나로 두면 다른 테넌트가 ID를 알아냈을 때 대화 이력이 노출됩니다. (tenant_id, agent_id, user_id, channel) 조합으로 유일성을 잡으세요. 그러면 같은 사용자가 같은 에이전트를 텔레그램과 웹에서 각각 써도 맥락이 섞이지 않습니다.
6. 배포 전 점검표
- 테넌트 없는 컨텍스트로 저장소 함수를 호출하면 에러가 나는가 (테스트로 고정)
- 모든 테이블에
tenant_id가 있고, 인덱스 첫 컬럼인가 - 다른 테넌트의 리소스 ID를 직접 넣은 요청이 404/403으로 막히는가
- 파일 도구에
../, 절대경로, 심볼릭 링크를 넣어봤는가 - 키가 DB·로그·API 응답 어디에도 평문으로 없는가
- 테넌트 삭제 시 워크스페이스 파일까지 지워지는가
정리
- 테넌트는 컨텍스트로 전파하고, 없으면 에러 — fail-closed가 전부다
- SQLite도 충분하다. WAL 모드와
tenant_id선두 인덱스를 챙길 것 - 워크스페이스는 심볼릭 링크를 푼 뒤
filepath.Rel로 검사 — 접두사 비교는 뚫린다 - 키는 AES-256-GCM, nonce는 매번 새로, 접두사로 암호화 여부 표시, 복호화 실패는 에러
- 가로 격리(테넌트)와 세로 격리(RBAC)는 별개 — 둘 다 필요
- 세션 키는 (테넌트, 에이전트, 사용자, 채널) 조합으로
멀티테넌시에서 어려운 건 기술이 아니라 빠뜨리지 않는 것입니다. 그래서 "실수하면 막히는" 구조를 먼저 만들어야 합니다. 테넌트 없이 호출하면 컴파일은 되되 런타임에 반드시 죽는 저장소 계층 — 그 한 겹이 나중의 사고 대부분을 대신 막아줍니다.
댓글
댓글 쓰기