feat: youtube-oauth-egress/#83 - #87
Merged
Merged
Conversation
사용자별 RTMP 송출 대상을 도입하기 위한 인증·저장 계층이다(#83). 송출 대상을 서버 전역 환경변수 하나로 고정하던 구조를 사용자별로 바꾸는 작업의 전반부이며, 방송 라이프사이클 제어와 송출 시작·중지 배선은 후속 작업으로 남긴다. streaming_accounts 테이블을 신설한다. 로그인 신원인 oauth_accounts와 분리한 것은 로그인에 쓰는 Google 계정과 송출할 채널의 계정이 다를 수 있기 때문이다. provider CHECK에 chzzk 자리를 미리 넣어 두어 나중에 제약을 ALTER 하지 않도록 했고, GORM AutoMigrate 목록과 versioned SQL 양쪽을 함께 갱신했다. 연결은 두 단계다. POST /auth/youtube/connect 가 Bearer 인증으로 사용자를 확인하고 state 를 발급한 뒤 authorize URL 을 반환하면, 클라이언트가 그 URL 로 이동하고 콜백에서 state 로 사용자를 복원한다. 콜백은 브라우저 리다이렉트로 도착해 Authorization 헤더가 없으므로 state 가 유일한 사용자 바인딩 수단이다. state 는 1회용이며 TTL 10분이다. 리프레시 토큰은 기존 ProviderTokenCipher 로 암호화해 저장하고 평문은 남기지 않는다. Apple 로그인이 비활성인 환경에서도 암호화기가 생성되도록 배선을 고쳤다. 액세스 토큰 갱신은 사용자 단위로 직렬화해 다중 세션에서 중복 갱신이 발생하지 않게 했다. 구현 전 수동 E2E 로 확인한 사실을 반영했다. 토큰 응답의 refresh_token_expires_in 이 7일이어서 만료 시각을 추적하는 컬럼을 두었고, 콜백에 문서에 없는 iss 파라미터가 함께 오므로 명시한 파라미터만 읽는다. 인가 코드에 슬래시가 포함되어 교환 시 form 인코딩이 필요하고, 채널이 없는 계정으로 연결을 시도하면 응답의 items 가 비어 오므로 이를 오류로 다룬다.
재사용 가능한 방송 스트림을 한 번 만들어 두고 재사용하려면 스트림 식별자와 ingest 주소를 보관해야 한다. 주소 4종은 공개 상수 성격이라 평문으로 두고, 스트림 이름은 방송 비밀번호에 해당하므로 리프레시 토큰과 동일하게 암호화해 저장한다. GORM 모델과 versioned SQL 양쪽을 함께 갱신했다.
브라우저 리다이렉트 대신 클라이언트가 구글에서 직접 받은 인가 코드를 서버로 넘기는 방식으로 바꿨다. 기존 로그인이 이미 같은 형태이고, 리다이렉트가 없으면 콜백 경로와 state 관리가 통째로 필요 없어진다. 코드 출처에 따라 교환 파라미터가 다르다. 네이티브 SDK가 준 코드는 redirect_uri 없이 교환되지만 웹 팝업이 준 코드는 postmessage를 요구한다. 실측으로 확인한 사실이라 출처를 요청 본문으로 받아 서버가 매핑한다. 출처를 생략하면 네이티브로 간주하므로 기존 계약과 호환된다. 웹 클라이언트가 팝업을 띄우려면 client_id와 스코프가 필요해 조회 엔드포인트를 두었다. client_id는 공개 식별자라 인증을 요구하지 않으며, 스코프를 서버가 내려주므로 클라이언트에 하드코딩이 남지 않는다.
플랫폼마다 송출 준비 절차가 다르다. YouTube는 방송과 스트림을 만들고 둘을 연결해야 하지만 치지직은 스트림 키 조회만으로 끝난다. 키 조회 수준으로 추상화하면 두 플랫폼이 한 구조에 들어가지 않으므로 준비와 종료라는 라이프사이클 단위로 인터페이스를 잡았다. YouTube 구현은 방송 생성 시 자동 시작과 자동 종료를 켠다. 실측 결과 송출이 시작되면 별도 전환 호출 없이 13.7초 만에 라이브가 되고, 송출을 멈추면 57.6초 뒤 자동으로 종료됐다. 덕분에 상태를 폴링하며 전환을 재시도하는 비동기 처리가 필요 없다. 재사용 스트림은 계정 연결 시점이 아니라 첫 준비 시점에 만든다. 연결 시점에 만들면 라이브가 아직 활성화되지 않은 계정은 연결 자체가 실패하는데, 활성화에는 최대 24시간이 걸리므로 연결과 방송 가능 여부는 분리돼야 한다. 라이브 미활성 계정은 응답 사유가 livePermissionBlocked로 온다. 활성화 전후로 같은 호출을 실행해 확인한 값이며, 함께 오는 도움말 주소를 사용자 안내에 쓴다.
송출 대상이 서버 전역 환경변수 하나로 고정돼 있어 모든 세션이 운영자 채널 하나로만 나갔고, 동시 세션이 둘 이상이면 같은 키로 충돌했다. 이제 세션 소유자가 연결한 계정에서 대상을 가져오고, 송출 개시는 명시적 요청으로만 이루어진다. 뷰어 송출은 그대로 둔 채 플랫폼 송출만 끄는 것도 가능해졌다. 시작 요청은 트랙이 도착한 뒤에 오는데 기존 파이프라인은 egress를 인자로 고정해 잡고 있어 실행 중에 꽂을 방법이 없었다. 파이프라인이 슬롯을 통해 참조하도록 바꿔 시작이 채우고 중지가 비운다. 프레임당 원자적 읽기 한 번이 추가 비용이다. ingest 주소는 API 응답값을 쓰므로 하드코딩 상수가 사라졌고, 그 과정에서 평문 RTMP 대신 RTMPS로 전환된다. 플랫폼이 서버에 구성되지 않은 경우 기존 501 응답을 유지해 인증을 끄고 도는 벤치마크와 미설정 배포의 동작을 보존한다.
로그인한 사용자에게 연결 버튼을 노출하고, 구글 팝업으로 받은 인가 코드를 서버로 넘긴다. 로그인 흐름은 그대로 두고 연결 동작만 추가했다. client_id와 스코프는 서버 조회 엔드포인트에서 받아오므로 클라이언트에 하드코딩이 없다. 인가 코드는 브라우저를 거쳐 갈 뿐 저장되지 않으며 토큰과 스트림 키는 서버에만 남는다.
chaeyn
marked this pull request as ready for review
August 10, 2026 08:33
chaeyn
approved these changes
Aug 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
변경 내용
송출 대상이 서버 전역 환경변수 하나로 고정돼 있어 모든 세션이 운영자 채널 하나로만 나갔고, 동시 세션이 둘 이상이면 같은 키로 충돌했어요. 사용자가 자기 YouTube 계정을 연결하고 명시적으로 요청할 때만 자기 채널로 송출되도록 바꿨어요.
handleStartStream501 스텁을 실배선하고 생성 이후 갱신되지 않던StreamState를 채웠어요. 뷰어 송출은 그대로 둔 채 플랫폼 송출만 끄는 것도 가능해졌어요.YOUTUBE_STREAM_KEY를 제거했어요. ingest 주소도 API 응답값을 쓰므로 하드코딩 상수가 사라졌고, 그 과정에서 평문 RTMP 대신 RTMPS로 전환돼요.확인 방법
go vet ./...,go test -count=1 ./...,go test -race -count=1 ./...를 캐시 없이 실행해 전 패키지 통과를 확인했어요. PostgreSQL 게이트 테스트(TestPostgresStreamingAccountUpsert)는 실제 PostgreSQL 16에 붙여 실행했고, GORM AutoMigrate와 versioned SQL이 만드는 스키마가 컬럼·인덱스·제약까지 일치하는지 두 DB를 만들어 대조했어요.로컬에서 브라우저로 전 구간을 완주했어요. 이메일 로그인 → 계정 연결(구글 팝업) → 카메라 연결 → 송출 시작 → 실제 YouTube 라이브 확인 → 중지까지 진행했고, 중지 후에도 세션과 뷰어 트랙이 살아 있는 것을 확인했어요. 스트림 키는 암호문으로만 저장되고 로그에는 마스킹돼 나와요.
실측으로 확정한 동작
구현 전에 API를 손으로 호출해 확인한 값이에요. 문서와 달랐던 것이 네 건 있어서 코드에 반영했어요.
livePermissionBlocked로 거절돼요. 문서 기준으로 예상했던insufficientPermissions가 아니에요. 활성화 전후로 같은 호출을 실행해 확인했어요.redirect_uri=postmessage를 요구해요. 생략하면Missing parameter: redirect_uri로 거절돼요. 네이티브 SDK 코드는 생략이 문서 기준이라 출처별로 나눴어요.rtmpsIngestionAddress에는 포트 표기가 없어요. URL 조합은 응답값을 그대로 써요.아직 확인하지 못한 것
관련
MAX_SESSIONS재실측) — 송출이 실제로 켜지면 세션당 FFmpeg가 늘어나요.Closes #83