블로그

YouTube·Facebook 동시 송출, 6단계 수동 설정을 1번 인증으로 — OAuth 자동화 구현기

라이브 스트리밍 플랫폼에서 소셜 미디어 동시 송출을 위한 6단계 수동 설정 과정을 OAuth 2.0 기반 자동화로 1회 인증으로 줄인 구현 과정을 공유합니다.

OAuth 2.0YouTube APIFacebook Live APINode.jsCatenoid

참고: 이 글의 코드는 실제 업무에서 경험한 내용을 바탕으로 개념적으로 재작성한 것입니다. 실제 회사 코드와는 무관합니다.

OAuth 2.0 인증 + 자동 송출 흐름

계정 연동 요청 (1회) OAuth 인가 URL 생성 로그인/동의 화면 동의 Authorization Code Access Token + Refresh Token 교환 토큰 암호화 저장 송출 시작 라이브 방송 생성 (토큰 자동 사용) 스트림 키 스트리밍 엔진 자동 설정 완료 이후 송출 시 자동 처리 사용자 Backend YouTube

들어가며

Catenoid의 라이브 스트리밍 솔루션을 사용하는 고객들은 YouTube나 Facebook에 동시 송출(Syndication)하기 위해 매번 이런 과정을 거쳐야 했습니다.

  1. YouTube Studio에 접속
  2. 로그인
  3. "스트리밍 시작" 클릭
  4. 스트림 키와 서버 주소 복사
  5. Catenoid 관리 콘솔로 돌아와서 붙여넣기
  6. 송출 시작

6단계. 그리고 이 과정에서 스트림 키를 잘못 복사하거나, 오타가 생기면 바로 CS로 이어졌습니다. "왜 동시 송출이 안 되나요?"라는 문의의 대부분이 스트림 키 입력 오류였습니다.

이 글은 이 문제를 OAuth 2.0 기반 자동화로 해결한 과정입니다.


설계: 백엔드에서 모든 것을 처리하기

핵심 접근법은 사용자 대신 백엔드가 소셜 플랫폼 API를 직접 호출하는 것이었습니다.

[사용자] → [계정 연동 (1회)] → [OAuth 토큰 저장]
[사용자] → [송출 시작 클릭]
                    ↓
            [백엔드 자동 처리]
            1. 저장된 토큰으로 YouTube API 호출
            2. 라이브 방송 생성
            3. 스트림 키 획득
            4. Catenoid 스트리밍 엔진 자동 설정
            5. 송출 시작

사용자는 최초 1회만 "YouTube 계정 연결"을 하면, 이후 모든 과정이 자동으로 이루어집니다.


OAuth 2.0 플로우 구현

1. 플랫폼별 앱 등록

YouTube와 Facebook 각각의 개발자 콘솔에서 앱을 등록하고 필요한 권한(Scope)을 신청했습니다.

YouTube 필요 권한:
- https://www.googleapis.com/auth/youtube
- https://www.googleapis.com/auth/youtube.readonly

Facebook 필요 권한:
- publish_video
- pages_manage_posts
- pages_read_engagement

2. 인가 코드 플로우 구현

// 인증 URL 생성 (사용자를 YouTube 로그인 페이지로 리디렉션)
router.get('/auth/youtube/connect', (req, res) => {
  const authUrl = `https://accounts.google.com/o/oauth2/auth?` +
    `client_id=${YOUTUBE_CLIENT_ID}&` +
    `redirect_uri=${encodeURIComponent(REDIRECT_URI)}&` +
    `scope=${encodeURIComponent(YOUTUBE_SCOPES)}&` +
    `response_type=code&` +
    `access_type=offline&`  // Refresh Token 발급을 위해 필수
    `prompt=consent`;       // 항상 Refresh Token 받기 위해
  
  res.redirect(authUrl);
});

// Redirect URI 처리: 인가 코드를 토큰으로 교환
router.get('/auth/youtube/callback', async (req, res) => {
  const { code } = req.query;
  
  const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    body: new URLSearchParams({
      code,
      client_id: YOUTUBE_CLIENT_ID,
      client_secret: YOUTUBE_CLIENT_SECRET,
      redirect_uri: REDIRECT_URI,
      grant_type: 'authorization_code'
    })
  });
  
  const { access_token, refresh_token, expires_in } = await tokenResponse.json();
  
  // 토큰 암호화 후 DB 저장
  await saveTokens(req.user.id, 'youtube', {
    accessToken: encrypt(access_token),
    refreshToken: encrypt(refresh_token),
    expiresAt: new Date(Date.now() + expires_in * 1000)
  });
  
  res.redirect('/settings/social?connected=youtube');
});

3. Access Token 자동 갱신

Access Token은 보통 1시간 후 만료됩니다. Refresh Token으로 자동 갱신하는 로직이 필수입니다.

async function getValidAccessToken(userId: string, platform: string): Promise<string> {
  const tokens = await db.socialTokens.findOne({ userId, platform });
  
  // 만료 5분 전부터 갱신
  if (tokens.expiresAt < new Date(Date.now() + 5 * 60 * 1000)) {
    const refreshed = await refreshAccessToken(
      platform,
      decrypt(tokens.refreshToken)
    );
    
    await db.socialTokens.update({
      where: { userId, platform },
      data: {
        accessToken: encrypt(refreshed.access_token),
        expiresAt: new Date(Date.now() + refreshed.expires_in * 1000)
      }
    });
    
    return refreshed.access_token;
  }
  
  return decrypt(tokens.accessToken);
}

플랫폼 추상화: 새 플랫폼 추가를 쉽게

YouTube와 Facebook의 API는 완전히 다릅니다. 이를 공통 인터페이스로 추상화했습니다.

interface SocialStreamProvider {
  createLiveBroadcast(title: string, scheduledAt: Date): Promise<string>;
  getStreamKey(broadcastId: string): Promise<StreamKey>;
  startBroadcast(broadcastId: string): Promise<void>;
  endBroadcast(broadcastId: string): Promise<void>;
}

class YouTubeProvider implements SocialStreamProvider {
  async createLiveBroadcast(title: string, scheduledAt: Date): Promise<string> {
    const response = await youtube.liveBroadcasts.insert({
      part: ['snippet', 'status', 'contentDetails'],
      requestBody: {
        snippet: { title, scheduledStartTime: scheduledAt.toISOString() },
        status: { privacyStatus: 'public' },
        contentDetails: { enableAutoStart: true }
      }
    });
    return response.data.id;
  }
  // ...
}

class FacebookProvider implements SocialStreamProvider {
  async createLiveBroadcast(title: string): Promise<string> {
    const response = await fb.api(
      `/${PAGE_ID}/live_videos`,
      'POST',
      { title, status: 'LIVE_NOW' }
    );
    return response.id;
  }
  // ...
}

새로운 플랫폼(예: Twitch)을 추가할 때 이 인터페이스만 구현하면 됩니다.


자동화 워크플로우

사용자가 "송출 시작"을 클릭하면 백엔드에서 이 과정이 자동으로 실행됩니다:

async function startSyndication(channelId: string, platforms: string[]) {
  const results = await Promise.allSettled(
    platforms.map(async (platform) => {
      const provider = getProvider(platform);  // YouTube | Facebook
      const accessToken = await getValidAccessToken(channelId, platform);
      
      // 1. 라이브 방송 생성
      const broadcastId = await provider.createLiveBroadcast(channel.title);
      
      // 2. 스트림 키 획득
      const streamKey = await provider.getStreamKey(broadcastId);
      
      // 3. Catenoid 스트리밍 엔진에 설정
      await cateonoindStreamingEngine.addDestination({
        channelId,
        rtmpUrl: streamKey.ingestionAddress,
        streamKey: streamKey.key
      });
      
      // 4. 방송 시작
      await provider.startBroadcast(broadcastId);
      
      return { platform, broadcastId, status: 'STARTED' };
    })
  );
  
  // 실패한 플랫폼 처리
  results.forEach(result => {
    if (result.status === 'rejected') {
      log.error('Syndication failed:', result.reason);
      // 알림 발송
    }
  });
}

Promise.allSettled를 사용해 한 플랫폼이 실패해도 다른 플랫폼의 송출은 계속되도록 했습니다.


결과

  • 6단계 수동 설정 → 1회 계정 연동으로 완전 자동화
  • 스트림 키 오입력으로 인한 CS 대폭 감소
  • YouTube, Facebook 동시 송출 완전 자동화 달성

OAuth 구현 시 주의사항

실제 구현하면서 놓치기 쉬웠던 포인트들:

  1. access_type=offline 필수: Refresh Token을 받으려면 반드시 포함해야 함
  2. prompt=consent 설정: 이미 인증한 계정이라도 다시 동의화면을 띄워 Refresh Token을 항상 받을 수 있게
  3. 토큰 암호화 저장: DB에 평문으로 저장하면 안 됨. AES-256 등으로 암호화
  4. 만료 전 갱신: 정확한 만료 시점보다 여유 있게(5분 전) 갱신해야 Race Condition 방지