processPayment 함수는 같은 프로세스 안에 존재한다는 이유만으로 데이터베이스, 결제 게이트웨이, 감사 로그에 모두 접근할 수 있다. 공격자가 하나의 HTTP 핸들러에서 인젝션 버그를 발견하면, 그 모든 권한을 그대로 물려받는다. 이 함수는 이런 권한을 요청한 적 없다. 단지 배치된 위치 덕분에 마치 당연한 것처럼 가정했다.
이것이 바로 주변 권한(ambient authority)이며, 우리가 구축하는 거의 모든 시스템의 기본 모델이다. 기능 기반 보안(capability-based security)은 다른 질문을 던진다: 만약 함수가 명시적으로 전달받은 것만 할 수 있다면 어떨까?
주변 권한의 문제
대부분의 애플리케이션은 시스템 경계에서 접근 제어 목록을 검증한다. 요청이 API 게이트웨이에 도달하고, JWT가 검증되고, 역할이 확인된 뒤, 요청은 신뢰 구역(trust zone)으로 들어간다. 그 안에서는 모든 함수가 암묵적으로 모든 것에 접근할 수 있다. 데이터베이스 연결은 전역 싱글턴이다. S3 클라이언트는 공유 모듈에서 임포트된다. 프로세스 낮에서 실행되는 어떤 코드라도 이 모든 것을 호출할 수 있다.
이 방식은 망가지기 전까지는 잘 작동한다.
유틸리티 함수의 역직렬화 버그, 손상된 의존성, 혹은 confused deputy 공격은 작은 하나의 침해를 전체 시스템 접근으로 확대시킨다. 폭발 반경(blast radius)은 특정 작업에 필요한 것으로 제한되지 않는다. 전체 프로세스가 가진 것으로 제한된다.
기능 기반 보안은 이 모델을 뒤집는다. 권한(authority)은 ‘누구인가’의 속성이 아니다. ‘무엇을 가지고 있는가’의 속성이다.
기능 기반 보안이란 무엇인가?
기능 기반 보안은 접근 권한이 위조 불가능한 토큰, 즉 기능(capability)으로 표현되며, 필요한 함수에 명시적으로 전달되어야 하는 모델이다. 기능은 리소스에 대한 참조이자 사용 허가이다. 이름으로 접근을 요청할 수 없다. 이미 가지고 있는 기능만 사용할 수 있다.
이것은 단계가 추가된 역할 기반 접근 제어(RBAC)가 아니다. RBAC에서는 사용자가 역할을 가지고 있고, 시스템이 접근 시점에 그 역할을 검증한다. 기능 기반 보안에서는 중앙 집중형 검증이 없다. 기능을 가지고 있으면 사용할 수 있다. 없으면 사용할 수 없다. 기능 자체가 권한 부여의 증거다.
이 모델은 1970년대 운영체제 연구로 거슬러 올라가지만, 우리가 더욱 격리된 시스템을 구축함에 따라 점점 더 중요해지고 있다. WebAssembly 모듈, 마이크로서비스, 브라우저 API, 샌드박스된 플러그인은 이름을 쓰지 않더라도 모두 기능과 유사한 패턴을 사용한다.
실제로 기능은 어떻게 작동하는가?
코드로 본다면 이렇다. 주변 권한 모델에서는 어떤 함수라도 데이터베이스를 호출할 수 있다:
// Ambient authority: any code in this module can use db
import { db } from './db';
async function getUser(id: string) {
return db.query('SELECT * FROM users WHERE id = ?', [id]);
}
async function processOrder(orderId: string) {
const order = await db.query('SELECT * FROM orders WHERE id = ?', [orderId]);
// What if a bug here lets an attacker query arbitrary tables?
return order;
}
두 함수는 전역 db 연결을 공유하며 완전한 접근 권한을 가진다. 기능 모델에서는 범위가 제한된 기능을 전달한다:
// A capability is just a constrained handle
interface UserReadCap {
getUser(id: string): Promise<User>;
}
interface OrderReadCap {
getOrder(id: string): Promise<Order>;
}
async function getUser(id: string, db: UserReadCap) {
return db.getUser(id);
}
async function processOrder(orderId: string, orders: OrderReadCap) {
return orders.getOrder(orderId);
}
getUser는 주문에 접근할 수 없다. processOrder는 사용자에 접근할 수 없다. 컴파일러가 이를 강제한다. 손상된 processOrder 구현체는 주문 정볼만 유출할 수 있을 뿐, 테이블을 삭제하거나 비밀번호 해시를 읽을 수는 없다. 기능은 행동의 경계다.
타입 시스템이 더 강력한 언어에서는 이를 더욱 밀어붙일 수 있다. Rust에서는 기능이 파일 디스크립터를 소유할 수 있다. 타입 시스템은 허가 없이 복제할 수 없게 보장하고, borrow checker는 유효 기간을 초과하지 않게 보장한다:
use std::fs::File;
use std::io::{self, Write};
fn write_log(file: &mut File, msg: &str) -> io::Result<()> {
// This function can ONLY write to the file it was handed.
// It cannot open new files. It cannot read the filesystem.
writeln!(file, "{}", msg)
}
&mut File이 바로 기능이다. 이 함수는凭空 또 다른 기능을凭空 만들어낼 수 없다.
왜 기능 기반 보안이 기본이 아닌가
기능 기반 보안에는 실제 비용이 따른다. 가장 눈에 띄는 것은 인체공학적 비용이다. 모든 함수 시그니처가 길어진다. 기능을 호출 체인을 따라 전달해야 하며, 이는 의존성 주입이 논리적 극단까지 발전한 느낌이다. 대규모 코드베이스에서는 이것이 지루해질 수 있다.
에러 처리 역시 더 복잡해진다. 주변 권한 모델에서는 데이터베이스 연결 실패는 초기화 시점에 처리되는 전역적인 문제다. 기능 모델에서는 기능을 전달받은 모든 함수가 작업 중간에 기능이 취소되거나 유효하지 않게 되면 어떻게 될지 고려해야 한다.
디버깅 비용도 있다. ACL 시스템에서 접근이 거부되면 정책을 확인하면 된다. 기능이 누락되면 호출 체인을 역추적해 누가 전달해야 했는지 찾아야 한다. 이것은 다른 기술 집합이며, 대부분의 개발자는 이에 익숙하지 않다.
이러한 트레이드오프는 기능 기반 보안이 대부분 운영체제, 브라우저, 고보안 환경에 국한되어 있던 이유를 설명한다. 공짜가 아니다.
오늘날 기능 기반 보안이 나타나는 곳
당신은 이미 기능 기반 보안을 사용해 왔다. 이름을 몰랐을 뿐이다. 브라우저에서 fetch는 전역 함수이지만 same-origin 정책과 CORS로 제한된다. Service Worker는 특정 이벤트 기능을 전달받는다. WebAssembly 모듈은 메모리와 호스트 함수에 대한 접근을 명시적으로 부여받아야 한다.
클라우드 인프라에서 AWS IAM 정책 조건과 범위가 제한된 토큰은 기능과 유사하다. presigned S3 URL은 기능이다: 특정 시간 동안 특정 리소스에 대해 특정 작업을 수행할 수 있는 위조 불가능한 토큰이다. 범위가 좁은 RBAC 바인딩을 가진 Kubernetes 서비스 계정도 이 방향으로 나아가고 있다.
추세는 더 작은 구획(compartment)과 더 적은 주변 권한을 향하고 있다. 컨테이너는 OS로부터 그것을 제거했다. WebAssembly는 브라우저 프로세스로부터 제거했다. 다음 단계는 우리 자신의 함수로부터 제거하는 것이다.
코드에서 기능을 사용하기 시작하는 방법
전체 애플리케이션을 다시 작성할 필요는 없다. 폭발 반경이 가장 중요한 경계부터 시작하라.
데이터 접근 계층을 기능 객체로 격리하라. 전역 데이터베이스 풀을 내보내는 대신, 범위가 제한된 핸들을 반환하는 함수를 내보내라:
// capabilities.ts
export interface UserStore {
getById(id: string): Promise<User | null>;
updateEmail(id: string, email: string): Promise<void>;
}
export interface AuditLog {
record(event: AuditEvent): Promise<void>;
}
// Hand out capabilities at the application boundary
function createUserStore(db: Pool): UserStore {
return {
async getById(id) {
const row = await db.query('SELECT * FROM users WHERE id = ?', [id]);
return row ? mapUser(row) : null;
},
async updateEmail(id, email) {
await db.query('UPDATE users SET email = ? WHERE id = ?', [email, id]);
}
};
}
그런 다음 핸들러에 필요한 기능만 전달하라:
async function handleProfileUpdate(
req: Request,
users: UserStore,
audit: AuditLog
) {
const user = await users.getById(req.userId);
await users.updateEmail(req.userId, req.body.email);
await audit.record({ type: 'email_changed', userId: req.userId });
}
handleProfileUpdate는 이메일을 보낼 수 없다. 계정을 삭제할 수 없다. 전달받은 것만 할 수 있다. 이 핸들러에 역직렬화 버그가 있더라도, 공격자는 결제 시스템으로 전환할 수 없다. 해당 기능은 전달되지 않았기 때문이다.
기능 기반 보안에 관한 자주 묻는 질문
이것이 그냥 의존성 주입(dependency injection) 아닌가?
비슷해 보이지만 의도가 다르다. 의존성 주입은 테스트 용이성과 모듈성을 위한 것이다. 기능은 보안과 최소 권한을 위한 것이다. DI 컨테이너는 여전히 전역 데이터베이스 연결을 주입할 수 있다. 기능은 호출자가 허용되어야 하는 것에 정확히 범위가 제한된다.
이것이 인증(authentication)을 대체하는가?
아니다. 인증은 “당신은 누구인가?”에 답한다. 기능은 “당신이 무엇을 할 수 있는가?”에 답한다. 여전히 경계에서 신원을 검증해야 한다. 그 후, 기능은 인증된 코드가 접근할 수 있는 것을 제한한다.
어떤 언어가 이를 잘 지원하는가?
타입 시스템을 가진 모든 언어는 기능을 인터페이스나 트레이트로 표현할 수 있다. Rust와 TypeScript 모두 잘 작동한다. Python이나 엄격한 인터페이스가 없는 JavaScript 같은 동적 타입 언어에서는 컴파일 타임 강제를 잃지만, 이 패턴은 여전히 코드 명확성을 높이고 우발적인 오용을 제한한다.
주변 권한은 습관이지 법칙이 아니다. 기능 기반 보안은 이해하는 것보다 도입하기 어렵지만, 업계의 방향은 명확하다. 더 작은 경계. 더 적은 암묵적 권한. 명시적으로 전달받은 것만 할 수 있는 함수.
하나의 핸들러부터 시작하라. 데이터베이스 연결 대신 하나의 기능을 전달하라. 무엇이 망가지는지 살펴보라. 대부분의 경우, 망가지는 것은 당신의 코드가 몰래 가정하고 있던 숨겨진 전제다.