Claudius의 기반 구축하기
요약
Claude 기반 챗봇인 Claudius를 구축하는 시리즈의 세 번째 단계로, 보안을 위한 ID 시스템과 역할(Role) 관리 방식을 다룹니다. 클라이언트가 권한을 조작할 수 없도록 서버 측에서 Google OAuth와 Auth.js를 통해 역할을 결정하는 구조를 설명합니다.
핵심 포인트
- 클라이언트가 아닌 서버에서 사용자 역할을 결정하여 보안 강화
- Auth.js v5와 Google 프로바이더를 활용한 인증 구현
- JWT를 사용하여 매 요청마다 DB를 조회하지 않고 역할을 캐싱
- 관리자, 멤버, 게스트로 구분되는 명확한 권한 체계 구축
이 튜토리얼은 Néstor Daza가 작성했습니다.
이 글은 제가 직접 만든 Claude 기반 챗봇인 Claudius (Github)를 구축하는 시리즈의 세 번째 기사입니다. 이전 기사에서는 앱에 사용할 MongoDB 데이터 모델에 대해 다루었습니다.
이전 기사에서 데이터의 형태를 결정했습니다. 하지만 이를 둘러싼 앱이 작동하기 전까지는 그 무엇도 중요하지 않으며, 앱을 작동하게 만드는 과정은 이 단계에서 다소 지루한 절반의 과정입니다. 이는 세 가지 요소로 요약됩니다: 클라이언트가 조작할 수 없는 ID 시스템(identity system), Claudius가 의존하는 두 서비스에 도달할 수 있다는 증명, 그리고 이 모든 것이 실제로 실행될지 여부를 결정하는 배포 현실(deployment realities)입니다. 이것은 프로젝트가 운영 환경(production)에 진입했을 때 생존할 수 있을지를 조용히 결정하는 지루한 작업입니다.
Identity: 클라이언트는 결코 투표권을 갖지 않는다
지구상의 어떤 Google 계정이든 Claudius에 안전하게 로그인할 수 있는데, 그 이유는 사용자의 역할(role)이 클라이언트가 전송하는 것이 아니기 때문입니다. 역할은 매번 서버에서 결정됩니다.
이 중 한 부분은 코드 외부에 존재합니다. Google 프로바이더(provider)는 Google Cloud Console에서 한 번 등록해야 하는 OAuth (Open Authorization) 클라이언트가 필요하며, 해당 등록에서 생성된 클라이언트 식별자(client identifier)와 비밀번호(secret)는 상응하는 환경 변수(env variables)에 설정됩니다. 이러한 설정 단계는 Auth.js 및 Google 문서에 나와 있으므로 여기서는 반복하지 않겠습니다.
로그인은 Google 프로바이더와 MongoDB 어댑터(adapter)를 사용하는 Auth.js v5에서 실행됩니다. 역할에는 admin, member, guest의 세 가지가 있으며, 이들은 서버의 정확히 한 곳에서 명확한 우선순위에 따라 결정됩니다:
export async function resolveRole(email: string | null | undefined): Promise<Role> {
if (!email) return "guest";
...
부트스트랩(bootstrap) 관리자 이메일이 최우선권을 가지며, 그다음으로는 설정(settings) 컬렉션에 있는 멤버 이메일 허용 목록(allowlist)이 적용됩니다. 그 외의 모든 사용자는 가장 낮은 권한의 기본값인 게스트(guest)로 분류됩니다. 클라이언트는 Google 신원(identity) 정보만을 제공할 뿐 그 이상의 것은 제공하지 않습니다. 클라이언트가 자신의 역할(role)을 직접 명시하지 않으므로, 사용자가 스스로를 승격시키기 위해 위조할 수 있는 요청은 존재하지 않습니다.
세션은 데이터베이스 기반 세션 대신 JWT (JSON Web Token)를 사용하므로, 결정된 역할이 토큰에 저장되어 매 요청마다 데이터베이스를 읽지 않고도 사용할 수 있습니다. 이 선택에는 한 가지 작은 단점이 있습니다. 토큰이 갱신될 때까지 역할을 캐싱(cache)하기 때문에, 누군가를 멤버 허용 목록에 추가하더라도 목록을 수정한 즉시 반영되는 것이 아니라 다음 로그인 시점에 반영됩니다. 개인 프로젝트라면 충분히 감수할 만한 트레이드오프(trade-off)입니다.
연결 구조는 간단합니다. 첫 로그인 시, jwt 콜백이 provisionUser를 호출하여 반환된 역할을 토큰에 저장하면, session 콜백이 이를 session.user로 복사하여 앱에서 읽을 수 있게 합니다.
프로비저닝(Provisioning)은 로그인 프로세스의 나머지 절반입니다. 어댑터(adapter)는 이미 Google이 반환한 이름, 이메일, 이미지 정보만 포함된 기본적인 사용자 문서를 작성해 두었습니다. 그 후 $ifNull을 중심으로 구축된 단일 집계 파이프라인(aggregation-pipeline) 업데이트를 통해 Claudius 전용 필드들을 채웁니다. 이렇게 하면 각 필드는 값이 없을 때만 기본값으로 설정되며, 관리자가 나중에 변경한 내용이 덮어쓰여지는 일도 없습니다. 역할(role)은 예외입니다. 전체 보안 모델이 의존하는 필드이기 때문에 매번 다시 계산됩니다.
export async function provisionUser(
userId: string,
email: string | null | undefined,
...
이 계층에서의 TypeScript 세부 사항 중 하나는 다소 까다로웠습니다. 세션 타입을 확장(Augmenting)하는 것은 예상대로 next-auth 모듈을 통해 작동하지만, JWT 타입은 그렇지 않았습니다! JWT 타입은 next-auth/jwt에서 재내보내기(re-exported)될 뿐 실제로는 @auth/core/jwt에 존재합니다. 따라서 next-auth/jwt를 대상으로 작성된 선언은 병합(merge)에 조용히 실패하며, 타입을 지정하려 했던 토큰 필드는 unknown 타입으로 남게 됩니다. 실제 모듈을 타겟팅해야 합니다:
declare module "next-auth" {
interface Session {
user: {
...
그러한 조용한 실패는 최악의 종류입니다. 에러가 발생하지 않으면서 타입이 조용히 잘못되어 버리기 때문입니다.
연결성 (Connectivity): Atlas와 Bedrock에 도달하는지 증명하기
스켈레톤(skeleton)의 마지막 조각은 앱이 의존하는 대상들과 통신할 수 있음을 증명하는 단일 보호된 경로(protected route)입니다. 로그인된 어떤 역할(role)이든 /api/health를 호출할 수 있습니다. 이 경로는 항상 Atlas에 핑(ping)을 보내며, ?probe=bedrock 파라미터가 주어지면 한 단계 더 나아가 Claude Haiku 4.5를 대상으로 단일 토큰 Converse 호출을 수행하여, 자격 증명(credentials)이 실제 모델을 호출할 수 있는지 증명합니다. 이 프로브(probe)가 흥미로운 부분인데, 출력 토큰을 단 하나로 제한하고, AWS 내부 정보가 호출자에게 유출되지 않도록 래핑(wrap)하며, Converse 사용 메타데이터(usage metadata)에서 읽어온 카운트를 반환합니다.
export async function bedrockHealthProbe(): Promise<BedrockHealth> {
try {
const llm = new ChatBedrockConverse({
...
실제 실행 시, 원하는 결과가 반환됩니다:
{"ok":true,"inputTokens":8,"outputTokens":1}
8개의 입력 토큰, 1개의 출력 토큰. 이것이 AI 앱의 상태 확인(health check)에서 원하는 신호입니다. 네트워크가 작동할 뿐만 아니라, 자격 증명이 유효하며 미터기(meter)가 돌아가고 있다는 것을 의미합니다.
모델, 프로브, 그리고 앱의 나머지 부분은 카탈로그로서 settings에 시드(seed)됩니다. 각 항목은 단순한 모델 ID 대신 실제 Bedrock 교차 지역 추론 프로필 ID(cross-region inference profile ID)를 포함하며, 이는 교차 지역 라우팅(cross-region routing)을 위한 올바른 선택입니다:
[
{"id":"haiku","inferenceProfileId":"us.anthropic.claude-haiku-4-5-20251001-v1:0"},
{"id":"sonnet","inferenceProfileId":"us.anthropic.claude-sonnet-4-6"},
...
또한 각 항목은 백만 토큰당 입력 및 출력 가격을 포함하며, 이는 이후 단계에서 티어별 비용을 강제하는 데 사용됩니다. 현재 이 필드들은 의도적인 플레이스홀더(placeholder)이며, 과금 계산이 이 값에 의존하기 전에 현재 Bedrock 가격과 대조하여 확인될 예정입니다. 따라서 오해를 불러일으킬 수 있는 숫자를 여기에서 인용하지는 않겠습니다.
환경 변수 (Env): 이 단계에서 사용하는 것만 검증하기
이 시리즈를 진행하면서, 각 개발 단계는 프로젝트의 전체 표면을 미리 선언하는 대신 자신이 사용하는 환경 변수(Environment Variables)만을 검증합니다. 지금까지는 데이터베이스, Google 인증 (Google auth), Bedrock, 그리고 관리자 부트스트랩 (admin bootstrap)을 위해 단 8개만 필요하므로, 환경 변수 스키마 (env schema)는 정확히 해당 항목들만 통제합니다. 누락되거나 형식이 잘못된 값이 있으면 시작 시점에 오류를 발생시키며, 문제가 되는 변수의 이름은 명시하되 그 값은 절대 출력하지 않습니다:
const EnvSchema = z.object({
MONGODB_URI: z.string().url(),
AUTH_SECRET: z.string().min(1),
...
웹 검색을 위한 Tavily, 임베딩 (embeddings)을 위한 Voyage, 파일 저장 (file storage)을 위한 Blob, 그리고 트레이싱 (tracing)을 위한 LangSmith와 같이 나중에 도입될 다른 변수들은 아직 여기에 포함되어 있지 않습니다.
실제 시간을 잡아먹는 주의 사항 (The gotchas)
뼈대는 작동하지만, 처음부터 바로 작동했던 것은 아닙니다. 그리고 이러한 실패 과정이야말로 이 구축 보고서에서 아마도 가장 유용한 부분일 것입니다.
첫 번째 실패는 가장 고통스러웠습니다. 연결 문자열 (connection string)에 데이터베이스 경로가 없어서, 데이터베이스 이름을 명시적으로 지정하지 않은 모든 것은 test로 대체되는데, Atlas 자격 증명 (credentials)은 여기에 접근할 수 없습니다. 이 문제는 두 번이나 발목을 잡았습니다. 첫 번째는 인덱스 스크립트 (index script)에서였습니다:
MongoServerError: not authorized on test to execute command { listCollections: 1, ... $db: "test" }
그다음은 배포 후 프로덕션 (production) 로그인 시에 다시 발생했습니다. Auth.js 어댑터 (adapter)가 앱의 데이터베이스 헬퍼 (database helper)를 거치지 않고, 별도의 지시가 있을 때까지 조용히 test를 기본값으로 사용하기 때문입니다:
[auth][error] AdapterError
[auth][cause]: MongoServerError: not authorized on test to execute command
{ find: "accounts", ... $db: "test" }
해결 방법은 두 곳 모두에서 데이터베이스 이름을 명시적으로 지정하는 것입니다. 어댑터에는 자체적인 databaseName이 설정됩니다:
// 연결 문자열에 기본 데이터베이스가 없으므로
// databaseName을 명시적으로 설정합니다.
// 이것이 없으면 어댑터는 "test"를 대상으로 삼게 되며
// (Atlas 자격 증명은 여기에 접근할 수 없습니다).
...
그리고 앱은 동일한 방식으로 데이터베이스의 이름을 지정하는 하나의 헬퍼 (helper)를 통해 데이터베이스에 도달합니다. 이 헬퍼에는 두 번째 주의 사항 (gotcha)도 포함되어 있습니다. 요청마다 새로운 MongoClient()를 생성하는 단순한 방식은 모든 서버리스 호출 (serverless invocation)과 개발 시의 핫 리로드 (hot reload)마다 커넥션 풀 (connection pool)을 누수시키게 됩니다. 따라서 클라이언트는 globalThis에 단일 인스턴스로 캐싱되며, DB_NAME은 환경 변수 (env var)가 아닌 상수로 취급됩니다. 데이터베이스 이름은 비밀 정보도 아니고 환경에 따라 달라지는 것도 아니기 때문입니다:
const globalForMongo = globalThis as unknown as {
_claudiusMongoClientPromise?: Promise<MongoClient>;
};
...
어댑터에는 동일한 clientPromise가 전달되므로, 인증 (auth)을 처리할 때 앱은 두 개의 연결을 여는 대신 하나의 클라이언트와 하나의 풀을 공유하게 됩니다. 어댑터와 앱이 서로 다른 드라이버 버전을 해결 (resolve)할 수 있기 때문에, 두 드라이버의 버전을 일치시키는 작업은 리포지토리 (repo) 루트에 한 줄만 추가하면 되었습니다:
"overrides": { "mongodb": "7.3.0" }
세 번째는 모노레포 (monorepo) 함정입니다. 공유 패키지 (shared package)는 가공되지 않은 TypeScript 상태로 소비되며, 별도의 빌드 단계 없이 앱의 빌드 과정에서 transpilePackages: ["@claudius/shared"]를 통해 트랜스파일 (transpiled)됩니다. 이는 편리하지만, 공유 코드가 임포트 (import)하는 모든 런타임 의존성 (runtime dependency)이 앱 워크스페이스 (app workspace)에서 해결되어야 함을 의미합니다. Vercel은 설치 범위를 앱의 루트 디렉토리로 제한하기 때문입니다. 빌드를 성공적으로 유지하는 규칙은, 중복처럼 보이더라도 해당 패키지를 임포트하는 모든 워크스페이스에 런타임 의존성을 선언하는 것입니다.
마지막은 배포 시에만 나타나는 종류의 문제입니다. macOS에서 생성된 락파일 (lockfile)에 Tailwind의 빌드에 필요한 Linux 바이너리 (binaries)가 누락되었고, Vercel의 Linux 설치 과정에서 이를 찾을 수 없었습니다:
Error: Cannot find module '../lightningcss.linux-x64-gnu.node'
이러한 바이너리들을 optionalDependencies로 선언하면 모든 플랫폼에 대해 락파일에 포함되게 되며, 배포가 성공적으로 완료됩니다:
"optionalDependencies": {
"@tailwindcss/oxide-linux-x64-gnu": "4.3.1",
"lightningcss-linux-x64-gnu": "1.32.0"
...
유지되는 것과 헤드리스 빌드 (headless build)가 증명할 수 없는 것
아직 채팅 기능은 없지만, 기반은 확실히 구축되었습니다. Identity (ID)는 서버에서 해결되며 토큰 (token) 내에서 안전하게 전달됩니다. health route (상태 확인 경로)를 통해 Claudius가 Atlas와 Bedrock 모두에 도달할 수 있음을 확인했으며, 설정에 누락된 사항이 있을 경우 즉각적이고 명확하게 오류를 발생시킵니다. 지난 기사에서 다루었던 데이터 모델 (data model)은 이제 그 아래에 견고한 토대를 갖추게 되었습니다.
역할 해결 (role-resolution) 로직은 유닛 테스트 (unit-test)를 마쳤지만, 실제 로그인 과정은 OAuth 왕복 (round trip) 과정이며, 이는 단순히 이 헤드리스 빌드 (headless build)가 아닌 실제 브라우저가 필요합니다. 관리자 이메일이 페이지와 영구 저장된 사용자 문서 (persisted user document) 모두에서 관리자로 등록되는 것을 확인하는 작업은 제가 수동으로 마무리할 유일한 단계입니다.
다음 단계는 대화가 마침내 머무를 수 있는 공간이 될 스트리밍 채팅 백본 (streaming chat backbone)이며, 시작부터 약속해 왔던 실제 체크포인트 문서 (checkpoint document)를 공개하겠습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기