최근 사내에서 YouTube 기반의 강의장 기능이 필요해졌고, 이를 여러 서비스에서 공통으로 활용할 수 있도록 라이브러리 형태로 구성하게 되었습니다. 그 과정에서 YouTube IFrame API를 React 환경에 맞춰 추상화한 React SDK 형태의 컴포넌트를 만들게 되었고, 단순한 래핑을 넘어 생명주기 관리, 이벤트 핸들링, 제어 인터페이스 설계 등 다양한 기술적 고민을 마주하게 되었습니다.
이번 글에서는 YouTube IFrame API를 React SDK로 추상화한 과정과 그 과정에서 겪은 문제들, 그리고 해결 방안들을 공유해보려 합니다.
React는 프론트엔드에서 가장 널리 사용되는 프레임워크입니다. 그만큼 수많은 외부 JavaScript 기반 API들이 React 개발자 친화적으로 래핑(wrapping) 되어 오픈소스로 제공되고 있습니다.
대표적인 사례는 아래와 같습니다.
이처럼 JS API를 React SDK로 추상화하는 이유는 단순한 구조 변경이나 트렌드에 따른 선택이 아닙니다. React의 개발 철학에 맞는 사용성과 유지보수성을 확보하고, 팀 내 일관된 개발 환경을 만들기 위한 실질적인 필요에서 비롯됩니다.
대부분의 JS API는 전역 객체, DOM 직접 조작, 명령형 방식 등 React의 선언형 패러다임과 충돌하는 방식으로 구성되어 있습니다. 이러한 API를 그대로 사용하면 비효율적인 패턴이 발생하기 쉽고, 타입 안정성이나 컴포넌트 기반 개발의 장점을 살리기 어렵습니다.
하지만 이를 React SDK로 추상화하면 props, ref, useEffect 기반의 React 친화적인 인터페이스로 통일할 수 있습니다. 덕분에 팀 내 개발자들은 API 문서를 새로 학습하지 않아도 일관된 방식으로 기능을 사용할 수 있으며, 코드의 재사용성과 유지보수성이 크게 향상됩니다.
JS API는 자체적으로 객체를 생성하고 초기화되며, 별도로 destroy 처리를 요구하는 등 생명주기 관리가 필요합니다. React에서는 useEffect, unmount, ref 등을 통해 생명주기를 관리하지만, 외부 JS API는 이와 쉽게 엮이지 않기 때문에 비동기 처리, 메모리 누수, 상태 불일치 같은 문제가 발생할 수 있습니다.
React SDK로 감싸면 외부 객체의 초기화와 정리를 React의 생명주기 안에서 명시적으로 관리할 수 있으며, useState, useRef, useEffect를 활용해 상태 동기화 또한 안정적으로 처리할 수 있습니다.
저희는 이미 다양한 외부 라이브러리를 사내 React 앱에 쉽게 적용할 수 있도록, 공통된 컴포넌트 형태로 래핑하여 사내 라이브러리로 제공하고 있었습니다.
또한 이미 타사 동영상 플레이어 기반의 강의장 기능을 React SDK로 추상화하여 사내 서비스에서 활용하고 있었고, YouTube 플레이어 역시 기존 플레이어 함께 강의장 플랫폼에서 제공되어야했기 때문에, 기존 플레이어와 유사한 기능, 동일한 제어 인터페이스, 재사용 가능한 컴포넌트 구조를 유지할 필요가 있었습니다.
단순히 YouTube API를 붙이는 것이 아니라, 다음과 같은 요구사항들을 만족시켜야 했습니다.
초기에는 오픈소스인 react-youtube 라이브러리 사용을 검토했습니다. 하지만 해당 라이브러리의 마지막 업데이트가 3년 전에 머물러 있었고, React19호환성 문제, TypeScript 정의, 핵심 기능에 대한 유지보수 부족 등 실제로 사용하기엔 치명적인 이슈들이 해결되지 않은 채 방치되고 있었습니다.
이러한 이유들로 저는 YouTube IFrame API를 기반으로 하되, React 환경에 최적화된 SDK 형태의 컴포넌트를 직접 설계하고 개발하게 되었습니다. 단순한 라이브러리 대체가 아닌, 실제 서비스 맥락에 맞춘 추상화와 DX(개발자 경험) 최적화가 이번 구현의 핵심 목표였습니다.
YouTube IFrame API는 JS 기반 명령형 API를 제공합니다.
// 기본 YouTube IFrame API 사용 예시
const player = new YT.Player('player', {
videoId: 'VIDEO_ID',
events: {
'onReady': onPlayerReady,
'onStateChange': onPlayerStateChange
}
});
function onPlayerReady(event) {
event.target.playVideo();
}
이를 React 환경에서 재사용성과 확장성을 갖춘 형태로 감싸기 위해 3단계 레이어 구조로 추상화했습니다.
가장 아래 계층인 Raw레이어는 YouTube IFrame API와 1:1로 대응되는 저수준 API 바인딩 컴포넌트입니다. YouTube 스크립트를 로드하고, 실제 iframe 태그를 삽입하여 YT.Player 인스턴스를 초기화하며, 이벤트 핸들러(onReady, onStateChange 등)들을 최신 상태로 유지할 수 있도록 ref로 관리합니다.
const YOUTUBE_SCRIPT_URL = 'https://www.youtube.com/iframe_api';
interface ReactYoutubePlayerProps extends Omit<YT.PlayerOptions, 'events'>, YT.Events {
className?: string;
youtubePlayerRef: MutableRefObject<YT.Player | null>;
}
const ReactYoutubePlayer = ({
className,
width,
height,
videoId,
playerVars,
youtubePlayerRef,
onReady,
onStateChange,
onError,
// ... 기타 이벤트 핸들러들
}: ReactYoutubePlayerProps) => {
const youtubePlayerContainerRef = useRef<HTMLDivElement | null>(null);
const onReadyRef = useRef(onReady);
const onStateChangeRef = useRef(onStateChange);
const onErrorRef = useRef(onError);
// ...
// 이벤트 핸들러 최신 상태 유지
useEffect(() => {
onReadyRef.current = onReady;
}, [onReady]);
useEffect(() => {
onStateChangeRef.current = onStateChange;
}, [onStateChange]);
useEffect(() => {
onErrorRef.current = onError;
}, [onError]);
// ...
// 플레이어 초기화 및 정리
useEffect(() => {
const initYouTubePlayer = () => {
youtubePlayerRef.current = new window.YT.Player(
youtubePlayerContainerRef.current!,
{
videoId,
height,
width,
playerVars,
events: {
onReady: (event) => onReadyRef.current?.(event),
onStateChange: (event) => onStateChangeRef.current?.(event),
onError: (event) => onErrorRef.current?.(event),
//...
},
}
);
};
const loadPlayerScript = async () => {
await loadScript(YOUTUBE_SCRIPT_URL);
(window as any).onYouTubeIframeAPIReady = initYouTubePlayer;
// 스크립트가 이미 로드된 경우 직접 초기화
if (window.YT && window.YT.Player) {
initYouTubePlayer();
}
};
if (youtubePlayerContainerRef.current) {
loadPlayerScript();
}
return () => {
youtubePlayerRef.current?.destroy();
};
}, []);
return <div id="yt-player" className={className} ref={youtubePlayerContainerRef} />;
};
export default ReactYoutubePlayer;
주요 특징은 다음과 같습니다.
이 레이어는 말 그대로 YouTube API의 “원형”을 React 안에서 사용할 수 있도록 옮겨온 바닥층입니다.
JavaScript의 클로저 특성상, useEffect 내부에서 이벤트 핸들러를 등록하면 핸들러가 등록 시점의 값을 계속 참조하게 됩니다.
// 문제가 되는 코드
const [userName, setUserName] = useState('');
useEffect(() => {
const player = new YT.Player('player', {
events: {
onReady: () => {
console.log(userName); // 항상 초기값 ''만 출력됨
}
}
});
}, []);
이렇게 되면 userName이 업데이트되어도 onReady 핸들러는 여전히 초기값인 빈 문자열만 참조하게 됩니다.
해결 방안: ref를 사용하여 항상 최신 핸들러를 참조하도록 구현했습니다.
const onReadyRef = useRef(onReady);
useEffect(() => {
onReadyRef.current = onReady;
}, [onReady]);
useEffect(() => {
const player = new YT.Player('player', {
events: {
onReady: (event) => onReadyRef.current?.(event) // 항상 최신 함수 참조
}
});
}, []);
YouTube IFrame API를 React에 통합하려면 기술적으로는 ReactYoutubePlayer 하나만으로도 충분해 보일 수 있습니다. 하지만 실제 서비스에서 다양한 상황과 요구사항에 유연하게 대응하려면 한 단계 더 추상화된 레이어가 필요했습니다.
const PLAYER_STATES = {
UNSTARTED: -1,
ENDED: 0,
PLAYING: 1,
PAUSED: 2,
BUFFERING: 3,
CUED: 5,
} as const;
const YoutubePlayer = ({
videoId,
width = '100%',
height = '100%',
autoplay = false,
isControllerVisibility = true,
commandRef,
onReady,
onPlaying,
onPaused,
onEnded,
onError,
className,
// ... 기타 props들
}: YoutubePlayerProps) => {
const youtubePlayerRef = useRef<YT.Player | null>(null);
const [player, setPlayer] = useState<YT.Player | null>(null);
// 명령형 API를 ref로 노출
useImperativeHandle(
commandRef,
() => ({
play: () => youtubePlayerRef.current?.playVideo(),
pause: () => youtubePlayerRef.current?.pauseVideo(),
seekTo: (seconds: number) => youtubePlayerRef.current?.seekTo(seconds, true),
getCurrentTime: () => youtubePlayerRef.current?.getCurrentTime() ?? 0,
getDuration: () => youtubePlayerRef.current?.getDuration() ?? 0,
getPlayerState: () => youtubePlayerRef.current?.getPlayerState() ?? -1,
// ...
}),
[]
);
const playerVars: YT.PlayerVars = useMemo(() => {
return {
autoplay: autoplay ? 1 : 0,
cc_lang_pref: subtitleLanguage,
cc_load_policy: isSubtitleVisibility ? 1 : 0,
controls: isControllerVisibility ? 1 : 0,
disablekb: disabledKeyboard ? 1 : 0,
end: endSeconds,
fs: disableFullScreen ? 0 : 1,
hl: interfaceLanguage,
// ... 기타 설정들
};
}, [
autoplay,
subtitleLanguage,
isSubtitleVisibility,
isControllerVisibility,
disabledKeyboard,
endSeconds,
disableFullScreen,
interfaceLanguage,
// ...
]);
// 비디오 변경 처리
useEffect(() => {
if (!player) return;
if (!videoId) return;
if (playerVars?.autoplay) {
player?.loadVideoById({
videoId,
startSeconds: playerVars?.start ?? 0,
endSeconds: playerVars?.end,
});
} else {
player?.cueVideoById({
videoId,
startSeconds: playerVars?.start ?? 0,
endSeconds: playerVars?.end,
});
}
}, [videoId, playerVars?.autoplay, playerVars?.start, playerVars?.end, player]);
// 사이즈 변경 처리
useEffect(() => {
const numWidth = Number(width);
const numHeight = Number(height);
if (!(player && !isNaN(numWidth) && !isNaN(numHeight))) return;
player?.setSize(numWidth, numHeight);
}, [height, width, player]);
const handlePlayerReady = (event: YT.PlayerEvent) => {
setPlayer(event.target);
onReady?.();
};
const handleStateChange = (event: YT.OnStateChangeEvent) => {
switch (event.data) {
case YT.PlayerState.PLAYING:
onPlaying?.();
break;
case YT.PlayerState.PAUSED:
onPaused?.();
break;
case YT.PlayerState.ENDED:
onEnded?.();
break;
}
};
useEffect(() => {
return () => {
player?.destroy();
};
}, [player]);
return (
<ReactYoutubePlayer
onReady={handlePlayerReady}
onStateChange={handleStateChange}
onError={onError}
className={className}
youtubePlayerRef={youtubePlayerRef}
videoId={videoId}
width={width}
height={height}
playerVars={playerVars}
// ...
/>
);
};
export default YoutubePlayer;
이 레이어를 별도로 둔 이유는 다음과 같습니다.
ReactYoutubePlayer는 YT.Player의 기능을 거의 그대로 노출합니다. 하지만 서비스에서 사용하는 명령(재생, 일시정지, 볼륨 변경, 영상 이동 등)은 명확하고 제한된 고수준 API로 통합되어야 합니다. YoutubePlayer에서는 이 명령들을 ref로 정의하고, 서비스 입장에서는 commandRef.current.play() 처럼 간단하게 사용할 수 있게 했습니다.
useImperativeHandle로 외부 제어 인터페이스를 명확히 추상화한 것이 핵심입니다.
ReactYoutubePlayer는 YouTube IFrame API의 거의 원형 그대로 React 환경에서 사용할 수 있게 해주는 얇은 바인딩 레이어입니다. 반면 YoutubePlayer는 이를 한 단계 더 추상화하여 명령형 API를 선언형으로 변환하는 브릿지 역할을 합니다.
예를 들어, YouTube API에서는 player.loadVideoById(videoId) 처럼 명령형으로 비디오를 변경해야 하지만, YoutubePlayer에서는 videoId props가 변경되면 자동으로 새로운 비디오를 로드합니다. 마찬가지로 player.setSize(width, height) 도 width, height props 변경 시 자동으로 처리됩니다.
// 명령형 API (YouTube 원본)
useEffect(() => {
player.loadVideoById(newVideoId);
}, [newVideoId]);
// 선언형 API (YoutubePlayer 추상화)
<YoutubePlayer videoId={videoId} width={width} height={height} />
이런 방식으로 YoutubePlayer는 autoplay 설정, playerVars 조합, 재생 상태 처리 등 YouTube 도메인 내부에서 반복되는 공통 로직을 한 곳에 모아 정리합니다. 이렇게 하면 나중에 Vimeo, 자체 스트리밍 플레이어 등으로 교체할 때도 Interface 레이어만 교체하면 동일한 서비스 코드를 유지할 수 있습니다.
YouTube 플레이어는 다양한 상태 이벤트(onReady, onStateChange, onPlaybackRateChange 등)를 제공합니다. 이 이벤트들을 서비스 로직에서 바로 핸들링하는 대신, YoutubePlayer에서 먼저 처리한 뒤 의미 있는 상태(onPaused, onPlaying, onEnded 등)로 정제하여 넘기면, 서비스 레이어에서는 더 간결하게 사용할 수 있습니다.
최상단인 Service 레이어는 실제 비즈니스 맥락에 맞는 컴포넌트입니다. 플레이어를 학습 콘텐츠 재생용으로 활용하기 위해 실제 서비스 내 비즈니스 요구사항에 맞게 기능을 조합한 영역입니다.
const YoutubeContentPlayer = ({ videoId }: { videoId: string }) => {
const commandRef = useRef<YoutubePlayerCommand>(null);
const { data: product } = useClassroomProduct();
const [isPlayButtonVisible, setIsPlayButtonVisible] = useState(false);
const { completeCourseContent, isCourseContentCompleted } = useCompleteCourseContent();
const { playNextPrevContent } = useClassroomController();
const autoplay = !product?.extras.isAutoplayDisabled;
const handlePlayerReady = () => {
if (!autoplay) {
setIsPlayButtonVisible(true);
}
};
const handlePlay = async () => {
setIsPlayButtonVisible(false);
if (!isCourseContentCompleted) {
await completeCourseContent(); // 수강완료 요청
}
};
const handleEnded = () => {
playNextPrevContent(1); // 다음 강의로 이동
};
const playVideo = () => {
commandRef.current?.play();
};
return (
<YoutubeContentPlayerContainer>
<YoutubePlayer
onReady={handlePlayerReady}
onCued={handlePlayerReady}
videoId={videoId}
commandRef={commandRef}
onPlaying={handlePlay}
onEnded={handleEnded}
width='100%'
height='100%'
isControllerVisibility={true}
autoplay={autoplay}
/>
{isPlayButtonVisible && (
<PlayerOverlay onClick={playVideo}>
<Button.solid color="accent" size="large" shape="capsule" prefixIcon={<Icon.MonoPlayCircleFilled />}>
강의 보기
</Button.solid>
</PlayerOverlay>
)}
</YoutubeContentPlayerContainer>
);
};
export default YoutubeContentPlayer;
이 레이어에서는 다음과 같은 역할을 합니다.
즉, 이 컴포넌트는 강의장이라는 도메인 요구사항을 해결하기 위해 플레이어 SDK를 사용하는 실제 사용처입니다.
이러한 계층화 구조를 통해 우리는 다음을 얻을 수 있었습니다.
YouTube IFrame API를 React SDK로 구현하는 일은 생각보다 단순하지 않았습니다. 공식 문서를 읽어도 YouTube IFrame API의 동작방식에 대한 구체적인 설명이 부족하여 의도대로 동작하지 않아도 어떤 것이 문제인지 파악하기가 어려웠습니다.
추후 확장성과 사내 다른 서비스에서 유튜브 강의장을 사용하게 될 가능성을 고려하여 SDK 작업을 하였지만, 공수 대비 구현 효과가 드라마틱하게 효율적이지는 않았던 것 같습니다.
따라서 JS API를 리액트 앱에서 사용할 때, 모든 JS API를 무조건 React SDK로 감쌀 필요는 없다고 생각합니다. 다음 기준을 고민해보세요.
→ 만약 위 질문들 중 2~3개 이상이 '예'라면 SDK 형태로 추상화하는 것이 좋은 전략일 수 있습니다.
이번 유튜브 플레이어 컴포넌트를 React SDK로 만들며, 단순 기능 구현 이상의 고민이 담겼습니다.
React에서 외부 JS API를 사용할 일이 있다면, 단순 wrapper를 넘어서 SDK 레벨의 추상화를 한 번쯤 고려해보세요. 개발자 경험은 물론, 서비스 완성도까지 크게 향상될 수 있습니다.
참고 자료