์ ํ๋ธ ๋๊ธ์ ํ์๋ผ์ธ์ ๋ด ์ฌ์๋ชฉ๋ก์ผ๋ก โ ์์ผ๋ก ํ๋ ๊ฑธ ์๋ํํ๋ค
๋ ธ๋๋ ์ข์๋ฐ, ๋ช ๊ณก์ด ๊ฑธ๋ฆฐ๋ค
์ ํ๋ธ์์ "Lo-fi 1์๊ฐ ๋ชจ์", "์ฌ์ฆ ํ๋ ์ด๋ฆฌ์คํธ" ๊ฐ์ ๊ธด ์์์ ์์ฃผ ๋ฃ๋๋ค. ์ด๋ฐ ์์์ ๋๊ธ์ ๊ผญ ํ์๋ผ์ธ์ด ๋ฌ๋ ค ์๋ค.
00:00 ๊ณกA - ๊ฐ์1
03:24 ๊ณกB - ๊ฐ์2
07:11 ๊ณกC - ๊ฐ์3
...
๋ฌธ์ ๋, ํ ์๊ฐ์ง๋ฆฌ ์์ ๋ด๊ฐ ๋ณ๋ก์ธ ๊ณก๋ ์์ฌ ์๋ค๋ ๊ฒ. ๋ง์์ ๋๋ ๊ณก๋ง ๊ณจ๋ผ ๋ด ์ฌ์๋ชฉ๋ก์ผ๋ก ๋ง๋ค๊ณ ์ถ์๋ฐ, ๊ทธ๋ฌ๋ ค๋ฉด ๊ณก์ ํ๋ํ๋ ๊ฒ์ํด์ ์ฌ์๋ชฉ๋ก์ ์ถ๊ฐํด์ผ ํ๋ค. 20๊ณก์ด๋ฉด 20๋ฒ. ์ด๊ฒ ์๊ทผํ ๊ท์ฐฎ์๋ค.
๊ทธ๋์ ๋ง๋ค์๋ค. ํ์๋ผ์ธ ํ ์คํธ๋ฅผ ๋ถ์ฌ๋ฃ์ผ๋ฉด, ์ํ๋ ๊ณก๋ง ๊ณจ๋ผ YouTube ์ฌ์๋ชฉ๋ก์ผ๋ก ์๋ ์์ฑํด์ฃผ๋ ์น์ฑ. ์ด๋ฆ์ YPG(YouTube Playlist Generator).
๋ถ์ฌ๋ฃ๊ธฐ โ ์ํ๋ ๊ณก ์ฒดํฌ โ ์์ฑ ๋ฒํผ. ๋.
์ ์ฒด ํ๋ฆ
๋ง๋ค๊ณ ๋ณด๋ ํฐ ํ๋ฆ์ ๋ค ๋จ๊ณ์๋ค.
- ๋ก๊ทธ์ธ โ Google OAuth๋ก YouTube ๊ณ์ ๊ถํ์ ๋ฐ๋๋ค
- ํ์ฑ โ ๋ถ์ฌ๋ฃ์ ํ์๋ผ์ธ์์ "๊ณก๋ช - ๊ฐ์"๋ง ๋ฝ์๋ธ๋ค
- ๊ฒ์ & ์ ํ โ ๊ฐ ๊ณก์ YouTube์์ ๊ฒ์ํ๊ณ , ์ํ๋ ์์์ ๊ณ ๋ฅธ๋ค
- ์์ฑ โ ์ ์ฌ์๋ชฉ๋ก์ ๋ง๋ค๊ณ ๊ณ ๋ฅธ ๊ณก๋ค์ ๋ด๋๋ค
ํ๋์ฉ ํ์ด๋ณธ๋ค.
1. ์ง์ ๋ถํ ํ ์คํธ์์ ๊ณก๋ช ๋ง ๋ฝ๊ธฐ
์ฌ์ฉ์๊ฐ ๋ถ์ฌ๋ฃ๋ ํ์๋ผ์ธ์ ํ์์ด ์ ๊ฐ๊ฐ์ด๋ค. 00:00, [1:02:03],
1) ๊ณก๋ช
, 01. ๊ณก๋ช
- ๊ฐ์โฆ ์ฌ๊ธฐ์ ํ์์คํฌํ์ ํธ๋ ๋ฒํธ๋ฅผ ๊ฑท์ด๋ด๊ณ
๊ณก๋ช
๋ง ๋จ๊ฒจ์ผ ๊ฒ์์ด ๋๋ค. ์ ๊ท์์ผ๋ก ์ฒ๋ฆฌํ๋ค.
const makeList = (songList: string[]) =>
songList.map((value) =>
value
// [00:00], (1:02:03), 00:00, 1:02:03 ํํ์ ํ์์คํฌํ ์ ๊ฑฐ
.replace(/[[(]?\s*\d{1,2}:\d{2}(?::\d{2})?\s*[)\]]?/g, " ")
// ์๋จธ๋ฆฌ์ "1.", "01)", "1)" ๊ฐ์ ํธ๋ ๋ฒํธ ์ ๊ฑฐ
.replace(/^\s*\d{1,3}[.)]\s+/, " ")
// ์ฐ์ ๊ณต๋ฐฑ ์ ๋ฆฌ
.replace(/\s+/g, " ")
.trim()
);
๋ถ์ฌ๋ฃ์ ๋ฌธ์์ด์ ์ค ๋จ์๋ก ์ชผ๊ฐ๊ณ , ์ ํจ์๋ก ์ ๋ฆฌํ ๋ค Set์ผ๋ก ์ค๋ณต์
์ ๊ฑฐํ๋ค. (๊ฐ์ ๊ณก์ด ๋ ๋ฒ ์ ํ ํ์๋ผ์ธ๋ ํํ๋ค.)
setSongList([...new Set(makeList(raw.split("\n")))]);
์ฌ๊ธฐ์ ๋ฐฐ์ด ๊ฒ ํ๋: ์ฒ์์ ๊ณต๋ฐฑ์ join(" ")์ผ๋ก ๋์ถฉ ์ด์ด๋ถ์๋๋ฐ, ๊ทธ๋ฌ๋ฉด
"๊ณก๋ช
๊ฐ์"์ฒ๋ผ ๋ถ์ด๋ฒ๋ ค์ ๊ฒ์ ์ ํ๋๊ฐ ๋จ์ด์ก๋ค. ๊ณต๋ฐฑ์ ์ด๋ฆฌ๋ ์ ๊ท์
์นํ์ผ๋ก ๋ฐ๊พธ๋ ๊ฒ์์ด ํจ์ฌ ์ ๋๋ค. ์ฌ์ํด ๋ณด์ด๋ ์ ์ฒ๋ฆฌ๊ฐ ๊ฒฐ๊ณผ ํ์ง์
์ข์ฐํ๋ค.
2. Google OAuth๋ก YouTube ๊ถํ ๋ฐ๊ธฐ
์ฌ์๋ชฉ๋ก์ ๋ง๋ค๋ ค๋ฉด ์ฌ์ฉ์์ YouTube ๊ณ์ ๊ถํ์ด ํ์ํ๋ค. Google OAuth 2.0 Authorization Code ๋ฐฉ์์ ์ผ๋ค.
๋จผ์ ๋์ ํ๋ฉด์ผ๋ก ๋ณด๋ธ๋ค. access_type=offline์ ์ฃผ๋ฉด refresh token์
๋ฐ์ ์ ์์ด์, ๋งค๋ฒ ๋ค์ ๋ก๊ทธ์ธํ์ง ์์๋ ๋๋ค.
const REDIRECT_URI = `${window.location.origin}/create`;
const SCOPE = "https://www.googleapis.com/auth/youtube";
const url =
`https://accounts.google.com/o/oauth2/auth` +
`?prompt=consent&access_type=offline` +
`&client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}` +
`&response_type=code&scope=${SCOPE}`;
๋์ํ๋ฉด ?code=...๋ฅผ ๋ฌ๊ณ /create๋ก ๋์์จ๋ค. ์ด code๋ฅผ access token์ผ๋ก
๊ตํํ๊ณ , refresh token์ ์ฟ ํค์ ์ ์ฅํด๋๋ค.
const token = await getAccessToken({ code: auth });
setCookie("refresh_token", token.data.refresh_token, { /* ... */ });
๋ค์๋ถํฐ๋ ์ฟ ํค์ refresh token์ผ๋ก access token์ ์๋ก ๋ฐ๊ธ๋ฐ๋๋ค.
const refresh_token = getCookie("refresh_token");
const accessToken = await getAccessTokenByRefreshToken({ refresh_token });
๋ฆฌ๋ค์ด๋ ํธ URI๋ฅผ window.location.origin ๊ธฐ์ค์ผ๋ก ๋์ ์ผ๋ก ๋ง๋ ๊ฒ ํฌ์ธํธ๋ค.
๋ก์ปฌ(localhost:3000)๊ณผ ๋ฐฐํฌ(vercel) ์์ชฝ์์ ์ฝ๋ ์์ ์์ด ๋์ํ๋ค.
(๋์ Google Console์ "์น์ธ๋ ๋ฆฌ๋๋ ์
URI"์ ๋ ๋๋ฉ์ธ์ ๋ค ๋ฑ๋กํด์ผ ํ๋ค โ
์ฌ๊ธฐ์ ํ์ฐธ ํค๋งธ๋ค.)
์์งํ ํ๊ณ: ์ด ํ๋ก์ ํธ๋ ํ๋ก ํธ์๋์์ ์ง์ ํ ํฐ ๊ตํ์ ํ๋๋ผ
client_secret์ด ํด๋ผ์ด์ธํธ์ ๋ ธ์ถ๋๋ค. ๊ทธ๋ฆฌ๊ณ refresh token๋ ๋ฌธ์ ๋ค โ ์ฝ๋์์ ์ฟ ํค์httpOnly: true์ต์ ์ ์คฌ์ง๋ง, HttpOnly๋ ์๋ฒ๊ฐSet-Cookieํค๋๋ก๋ง ์ค์ ํ ์ ์์ด์ JS์setCookie์์๋ ์กฐ์ฉํ ๋ฌด์๋๋ค. ๊ฒฐ๊ตญ refresh token์ด JS๋ก ์ ๊ทผ ๊ฐ๋ฅํ ์ฟ ํค์ ํ๋ฌธ์ผ๋ก ์ ์ฅ๋๋ ์ ์ด๋ผ XSS์ ๊ทธ๋๋ก ๋ ธ์ถ๋๋ค. ๊ฐ์ธ์ฉ ํ ์ด ํ๋ก์ ํธ๋ผ ๋์ด๊ฐ์ง๋ง, ์ ๋๋ก ํ๋ ค๋ฉด ํ ํฐ ๊ตํ๊ณผ ํ ํฐ ๋ณด๊ด ๋ชจ๋ **์๋ฒ(๋๋ ์๋ฒ๋ฆฌ์ค ํจ์)**์์ ํด์ผ ํ๋ค. ์ด๊ฑด ๋ค์์ ๊ณ ์น ์์ ๋ก ๋จ๊ฒจ๋๋ค.
3. ๊ฒ์ํ๊ณ , ๊ณ ๋ฅด๊ณ , ๋ด๊ธฐ
์ฒดํฌํ ๊ณก๋ค์ YouTube Data API๋ก ๊ฒ์ํ๋ค. ๊ณก๋ง๋ค ๊ฒ์ ๊ฒฐ๊ณผ๊ฐ ์ฌ๋ฌ ๊ฐ๋ผ, ๋ชจ๋ฌ์ ๋์ ์ฌ์ฉ์๊ฐ ์ ํํ ์์์ ๊ณ ๋ฅด๊ฒ ํ๋ค. (๋ผ์ด๋ธ ๋ฒ์ , ์ปค๋ฒ, ๋ค๋ฅธ ๊ฐ์์ ๋๋ช ๊ณก ๋ฑ์ ๊ฑธ๋ฌ๋ด๊ธฐ ์ํด.)
const promises = checkValue.map(async (song) => {
const res = await searchVideo({
key: API_KEY, part: "snippet", q: song, maxResults: 5, type: "video",
});
// ๊ฒฐ๊ณผ์์ videoId/์ ๋ชฉ/์ธ๋ค์ผ์ ๋ชจ์ ๋ชจ๋ฌ์ ๋ณด์ฌ์ค๋ค
});
๊ณ ๋ฅธ ๊ณก์ด ์ ํด์ง๋ฉด ์ฌ์๋ชฉ๋ก์ ๋ง๋ค๊ณ ํ๋์ฉ ๋ด๋๋ค.
// ์ ์ฌ์๋ชฉ๋ก ์์ฑ (๊ธฐ๋ณธ ๋น๊ณต๊ฐ)
await createNewPlayList(title, accessToken, "private");
// ๊ฐ ์์์ ์ฌ์๋ชฉ๋ก์ ์ถ๊ฐ
await addToPlayList(accessToken, playlistId, videoId);
๊ณก์ด ๋ง์ผ๋ฉด ์๊ฐ์ด ๊ฑธ๋ฆฌ๋ฏ๋ก progress(done/total) ์ํ๋ก ์งํ๋ฅ ์ ๋ณด์ฌ์ค์,
"๋ฉ์ถ ๊ฑฐ ์๋?" ํ๋ ๋ถ์์ ์์ด๋ค.
๊ฐ์ฅ ํฌ๊ฒ ๋ถ๋ชํ ๋ฒฝ: API ํ ๋น๋(quota)
๋ง๋ค๋ฉด์ ์ ์ผ ํฌ๊ฒ ๋ฐ์ธ ๋ถ๋ถ. YouTube Data API๋ ํ๋ฃจ ํ ๋น๋์ด ์๋๋ฐ,
๊ทธ๋ฅ ์์ฒญ ์๊ฐ ์๋๋ผ ์์
๋ณ ๋น์ฉ(unit) ์ผ๋ก ๊ณ์ฐ๋๋ค. ํนํ
๊ฒ์(search.list)์ด ์์ฒญ๋น 100 units๋ก ์ ๋
๋น์ธ๋ค. ๊ธฐ๋ณธ ํ ๋น๋์ด
ํ๋ฃจ 10,000 units์ด๋, ๊ฒ์๋ง ํ๋ฉด ํ๋ฃจ 100๋ฒ์ด๋ฉด ๋์ด๋ค.
๊ณก 20๊ฐ์ง๋ฆฌ ํ์๋ผ์ธ ํ๋๋ง ๋๋ ค๋ ๊ฒ์ 2,000 units๊ฐ ์์๊ฐ์ ๋๊ฐ๋ค. "์ ๊ฐ์๊ธฐ ์ ๋์ง?" ํ๊ณ ๋ณด๋ฉด quota ์ด๊ณผ์๋ค. ์ด๊ฑธ ๊ฒช๊ณ ๋์
- ๋ถํ์ํ ์ฌ๊ฒ์์ ๋ง๊ณ (๊ฐ์ ๊ณก ์ค๋ณต ์ ๊ฑฐ๊ฐ ์ฌ๊ธฐ์๋ ๋์์ด ๋๋ค),
- ๊ฐ๋ฐ ์ค์ ์ค์ API๋ฅผ ๊ณ์ ๋๋ฆฌ์ง ์๋๋ก MSW(Mock Service Worker)๋ก ์๋ต์ ๋ชฉํนํด์ ํ ๋น๋์ ์๊ผ๋ค.
"์ธ๋ถ API์๋ ๋์ ์ ๋ณด์ด๋ ๋น์ฉ๊ณผ ํ๊ณ๊ฐ ์๋ค"๋ ๊ฑธ ๋ชธ์ผ๋ก ๋ฐฐ์ด ํ๋ก์ ํธ์๋ค.
๋ง์น๋ฉฐ
๊ณก 20๊ฐ ร ๊ฒ์ + ์ถ๊ฐ๋ฅผ ์์ผ๋ก ํ๋ฉด 10๋ถ ๋๊ฒ ๊ฑธ๋ฆฌ๋ ์ผ์ด, ๋ถ์ฌ๋ฃ๊ธฐ โ
์ฒดํฌ โ ๋ฒํผ ํ ๋ฒ์ผ๋ก ์ค์๋ค. ๋ด๊ฐ ๋งค์ฃผ ๊ฒช๋ ์ฌ์ํ ๊ท์ฐฎ์ ํ๋๊ฐ, ์ค์ ๋ก
์ฐ๋ ๋๊ตฌ๊ฐ ๋ ์๊ฐ์ด์๋ค.
- ๋ฐฐํฌ: youtube-playlist-generator.vercel.app
- GitHub: Youtube_Playlist_Generator