개인의 기록
  • 소개
  • 프로젝트
  • 글
  • 링크

© 2026 newgirok

← YouTube Shorts 자동화

소유권 구조 — 채널별 멀티유저 지원

2026년 7월 18일
PrismaNextAuthOAuthAPI

지금까지는 채널이 "누구 것"인지 구분이 없었다. 로그인 허용 이메일만 관리했지, 로그인한 사람과 채널을 연결하는 개념 자체가 없었던 거다. 이걸 바꿨다.

Channel에 userId FK 추가

Channel 테이블에 userId 컬럼을 넣었다. 이미 운영 중인 채널이 있어서 마이그레이션은 nullable로 추가 → 기존 채널 backfill → NOT NULL 순서로 갔다.

ALTER TABLE "Channel" ADD COLUMN "userId" TEXT;

-- 기존 채널을 단일 User에 할당 (싱글 오너 시스템 backfill)
UPDATE "Channel" SET "userId" = (SELECT "id" FROM "User" LIMIT 1) WHERE "userId" IS NULL;

ALTER TABLE "Channel" ALTER COLUMN "userId" SET NOT NULL;
ALTER TABLE "Channel" ADD CONSTRAINT "Channel_userId_fkey"
  FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

OAuth state로 소유자 기록

YouTube 채널을 새로 연결할 때, 지금 누가 연결하는 건지를 콜백에서 알아야 Channel.userId를 채울 수 있다. OAuth state 파라미터에 userId를 실어 보내고 콜백에서 그대로 꺼내는 방식을 썼다.

// getAuthUrl
getAuthUrl(userId: string): string {
  return client.generateAuthUrl({
    access_type: 'offline',
    scope: SCOPES,
    state: userId,
  });
}

// handleCallback
async handleCallback(code: string, state?: string) {
  const userId = state;
  if (!userId) throw new Error('userId가 없습니다 — OAuth 흐름을 다시 시작하세요');
  const channel = await prisma.channel.upsert({
    where: { youtubeId: ytChannel.id! },
    create: { ...  userId, ... },
  });
}

x-user-id 헤더로 API 요청 식별

web에서 API로 요청을 보낼 때마다 "누구 요청인지"를 실어야 한다. NextAuth JWT에 userId를 넣고, session.user.id로 노출한 다음, API 호출마다 x-user-id 헤더로 붙였다.

// auth.ts — jwt/session 콜백
async jwt({ token, user }) {
  if (user?.email) {
    const found = await prisma.user.findUnique({ where: { email: user.email.toLowerCase() } });
    if (found) token.userId = found.id;
  }
  return token;
},
async session({ session, token }) {
  if (token.userId) session.user.id = token.userId as string;
  return session;
},

API 쪽 InternalKeyGuard는 원래 내부 시크릿만 검증했는데, 여기에 x-user-id 헤더를 파싱해서 req.userId에 심는 역할을 추가했다. CurrentUser 데코레이터로 컨트롤러에서 바로 꺼내 쓴다.

export const CurrentUser = createParamDecorator((_, ctx: ExecutionContext) => {
  const req = ctx.switchToHttp().getRequest<FastifyRequest & { userId?: string }>();
  return req.userId;
});

// channels.controller.ts
@Get()
findAll(@CurrentUser() userId: string | undefined) {
  return this.service.findAll(userId);
}

// channels.repository.ts
findAll(userId?: string) {
  return prisma.channel.findMany({
    where: { isActive: true, ...(userId ? { userId } : {}) },
  });
}

userId가 없으면(워커 내부 호출처럼 x-user-id를 안 보내는 경우) 전체를 반환하도록 옵셔널로 뒀다. 사람이 웹에서 들어올 때만 자기 채널로 필터링되는 구조다.

apiGet/apiPost/apiPatch/apiDelete 헬퍼 네 개에 전부 extraHeaders 파라미터를 추가하고, 서버 컴포넌트(page.tsx)에서 auth()로 세션을 꺼내 x-user-id를 만들어 클라이언트 컴포넌트까지 내려주는 구조로 web 쪽을 정리했다.

← 이전 글IAM 정비 — 배포 권한과 Phase 재정렬
다음 글 →OAuth 안정화 — 배포 환경 인증 흐름