TL;DR
YouViCo의 게스트 액세스 모델은 로그인 자격 증명 대신 안전하게 서명되고 만료되는 토큰을 사용합니다. 리뷰어는 링크를 클릭해 영상 프로젝트에 접근하고 댓글을 남깁니다. 계정을 만들 필요가 없습니다. 아키텍처는 JWT 토큰(서명, 만료), 권한 스코프(읽기 전용 vs. 댓글 vs. 편집), RBAC(역할 기반 접근 제어), 그리고 실시간 폐기로 구성됩니다. 이 구조 덕분에 협업 마찰을 줄이면서도 엔터프라이즈 수준의 보안을 유지할 수 있습니다.
문제
협업 도구의 접근 방식은 종종 고통스럽습니다.
- 내부 팀: “계정을 만들고, 초대 메일을 기다리고, 링크를 클릭하고, 비밀번호를 설정하고, 인증 앱을 다운로드하고…”
- 외부 클라이언트: 같은 마찰에 더해 법무/보안 검토까지 따라붙습니다
- 결과: 영상에 피드백 한 줄 남기려고 30분짜리 온보딩을 거칩니다
YouViCo의 통찰은 분명했습니다. 대부분의 리뷰어는 계정이 필요 없습니다. 그들에게 필요한 건 다음 세 가지뿐입니다.
- 하나의 영상 프로젝트에 대한 접근 권한
- 댓글(또는 단순 보기) 권한
- 며칠 안에 만료되는 세션
이걸 위해 굳이 계정을 만들 필요가 있을까요?
아키텍처: 세 개의 레이어
게스트 액세스 스택은 다음과 같습니다.
Layer 1: Token Generation (Server-side)
↓
Layer 2: Token Validation (API Gateway)
↓
Layer 3: Permission Enforcement (Service Level)
Layer 1: 토큰 생성
프로젝트 소유자가 외부 리뷰어를 초대하고 싶을 때 “Generate Guest Link”를 클릭합니다.
interface GuestAccessRequest {
projectId: string;
expiresIn: number; // hours until token expires
permissions: Permission[]; // ['view', 'comment', 'resolve-comments']
maxUses?: number; // optional: one-time link
}
class GuestAccessService {
generateToken(request: GuestAccessRequest): string {
const payload = {
sub: `guest:${uuid()}`, // Subject: anonymous guest
projectId: request.projectId,
permissions: request.permissions,
iat: now(),
exp: now() + (request.expiresIn * 3600), // Expiration time
maxUses: request.maxUses || unlimited
};
// Sign with server private key
const token = jwt.sign(payload, PRIVATE_KEY, {
algorithm: 'RS256'
});
// Generate shareable link
return `https://youvico.com/project/${request.projectId}?token=${token}`;
}
}
핵심 속성:
- 토큰은 서명되어 있습니다: 위조하거나 변경할 수 없습니다
- 토큰은 만료됩니다: 영구 접근은 없습니다
- 토큰은 프로젝트 단위로 제한됩니다: 다른 프로젝트에는 접근할 수 없습니다
- 토큰에는 권한이 명시됩니다: 리뷰어는 댓글만 달 수 있고 삭제할 수는 없습니다
Layer 2: 토큰 검증 (API Gateway)
게스트 토큰이 포함된 모든 요청은 API 게이트웨이에서 검증됩니다.
class GuestTokenMiddleware {
async validateToken(req: Request): Promise<GuestContext | null> {
const token = extractFromQuery(req, 'token') || extractFromHeader(req, 'Authorization');
if (!token) return null;
try {
// Verify signature and expiration
const decoded = jwt.verify(token, PUBLIC_KEY);
// Check if token has been revoked
const isRevoked = await redis.get(`revoked:${decoded.jti}`);
if (isRevoked) {
throw new Error('Token revoked');
}
// Check usage limit
if (decoded.maxUses) {
const usageCount = await redis.incr(`token:usage:${decoded.jti}`);
if (usageCount > decoded.maxUses) {
throw new Error('Token usage limit exceeded');
}
}
return {
guestId: decoded.sub,
projectId: decoded.projectId,
permissions: decoded.permissions,
expiresAt: new Date(decoded.exp * 1000)
};
} catch (err) {
return null;
}
}
}
폐기 처리에 Redis를 쓰는 이유는 무엇일까요? JWT의 만료 시점을 기다릴 필요 없이 즉시 토큰을 무효화할 수 있기 때문입니다.
Layer 3: 권한 적용
각 서비스는 게스트의 권한을 직접 검사합니다.
class CommentService {
async createComment(guestContext: GuestContext, input: CreateCommentInput) {
// Verify guest has 'comment' permission
if (!guestContext.permissions.includes('comment')) {
throw new Error('Insufficient permissions');
}
// Verify guest is accessing their own project
const project = await getProject(input.projectId);
if (project.id !== guestContext.projectId) {
throw new Error('Unauthorized project access');
}
// Create comment with guest attribution
return await Comment.create({
...input,
authorId: guestContext.guestId,
isGuest: true // Track as guest for analytics
});
}
}
class VideoService {
async getVideo(guestContext: GuestContext, videoId: string) {
// 'view' permission is implicit - if token is valid, guest can view
// (no need to check 'view' explicitly, presence of token = access)
const video = await getVideo(videoId);
// Verify guest can access this video (it's in their project)
if (video.projectId !== guestContext.projectId) {
throw new Error('Unauthorized access');
}
return video;
}
}
권한 모델
게스트에게는 역할 기반 권한이 부여됩니다. 대표적인 역할은 다음과 같습니다.
| 역할 | 권한 | 사용 사례 |
|---|---|---|
| Viewer | view | 이해관계자 리뷰 (읽기 전용) |
| Commenter | view, comment | 클라이언트 피드백 (댓글은 달지만 해소(resolve)는 못 함) |
| Approver | view, comment, resolve | 크리에이티브 디렉터 (리뷰 전체 권한) |
각 권한은 서비스 레이어에서 강제됩니다.
실시간 폐기
지금 즉시 접근 권한을 회수해야 한다면 어떻게 해야 할까요(예: 클라이언트 관계 종료)?
class GuestAccessController {
async revokeToken(projectId: string, tokenJti: string) {
// Set revocation flag in Redis with expiration = token's original expiration
const token = await Token.findByJti(tokenJti);
const expirationSeconds = Math.floor((token.expiresAt - now()) / 1000);
await redis.setex(
`revoked:${tokenJti}`,
expirationSeconds,
'true'
);
// Revoked token becomes useless immediately
// No API calls will succeed with this token
return { success: true, message: 'Token revoked' };
}
}
결과: 게스트는 몇 초 안에 접근 권한을 잃습니다. 별도의 세션 정리도 필요 없습니다.
URL 인코딩과 보안
게스트 링크는 이메일, Slack, 문자 메시지를 통해 공유됩니다. 그래서 다음 조건을 만족해야 합니다.
- 짧을 것 (공유 가능)
- 안전할 것 (변조 불가)
- 만료될 것 (수명 제한)
우리는 다음과 같이 사용합니다.
https://youvico.com/project/abc123?token=eyJhbGc...
1. Project ID in URL path: Transparent, easy to understand
2. Token in query string: Cryptographically signed, can't be modified
3. Token signed with RS256: Client verifies with public key, can't forge
보안 참고: HTTPS 전용 링크를 강력히 권장합니다. 토큰은 bearer 토큰이므로 절대 HTTP로 전송되어서는 안 됩니다.
분석: 게스트 액세스 추적
게스트는 기본적으로 익명이지만, 분석을 위해 활동은 추적합니다.
SELECT
token_id,
project_id,
permissions,
created_at,
expires_at,
comments_created,
videos_viewed,
last_accessed
FROM guest_access_logs
WHERE project_id = 'proj_xyz'
ORDER BY created_at DESC;
이를 통해 프로젝트 소유자는 다음과 같은 질문에 답할 수 있습니다. “외부 리뷰어가 몇 명인가? 얼마나 활발히 참여하고 있는가?”
배운 점
-
일회성 접근에는 비밀번호보다 토큰이 낫습니다. 비밀번호는 지속적인 정체성을 전제로 하지만, 토큰은 한시적 권한을 전제로 합니다.
-
만료가 곧 보안입니다. 7일 안에 만료되는 토큰은 1년짜리 토큰보다 안전합니다. 편의성과 보안 사이의 균형을 잡아야 합니다.
-
Redis 기반 폐기는 즉시 동작합니다. 민감한 프로젝트라면 JWT 만료에만 기대지 말고 반드시 폐기 레이어를 두세요.
-
스코프가 핵심입니다. 게스트 토큰은 하나의 프로젝트, (선택적으로) 하나의 IP 대역, (선택적으로) 하나의 User-Agent에서만 동작해야 합니다. 지나치게 넓은 스코프는 보안 리스크입니다.
-
감사 로깅을 남기세요. 모든 게스트 액세스를 추적해야 합니다. 보안 사고가 발생했을 때, 게스트가 무엇에 접근했는지 정확히 파악할 수 있습니다.
게스트 액세스는 YouViCo를 “계정 마찰”에서 “원클릭 협업”으로 바꿔놓았습니다. 이 기능을 출시한 뒤 채택률은 3배 증가했습니다.