평소에 음악을 많이 듣고 좋아하다 보니 자연스럽게 음악 관련된 프로젝트를 해보고 싶다는 생각이 들었다. 그래서 open API가 어떤 게 있나 찾던 중 Spotify API를 발견했다. 문서도 깔끔하게 잘 되어있고 다양한 API들이 있어서 Spotify API를 사용하기로 했다.
프로젝트에 Next.js를 사용했다. 몇 번 사용해보기는 했지만 익숙하지 않은 부분도 많고 SSR 관련해서도 해보고 싶은 것들이 있어서 적용해보았다.
기술스택은 다음과 같다.
React, TypeScript, Next.js, Recoil, React-Query(Tanstack Query), emotion
원래 FLO 라는 음악 스트리밍 플랫폼을 이용 중이었는데 프로젝트 진행하면서 Spotify로 넘어갔다. 첫 이용 3개월 무료라길래 이용하고 있는데 내가 듣는 음악 취향에 맞춰 플레이리스트를 만들어줘서 만족하면서 지내고 있다!!
최종 결과물은 여기서 확인할 수 있다.
사용법
앱 생성
Spotify API를 내 프로젝트에서 사용하기 위해서는 Client ID 및 Client Secret를 발급받아야 한다.
Dashboard에서 'CREATE AN APP' 버튼을 눌러 앱을 생성하면 키 값을 얻을 수 있다.
'EDIT SETTING' 에서 Redirect URIs를 설정할 수 있는데, 인증 시 Redirect 될 URI를 적으면 된다.
토큰 발급
Spotify Web API는 OAuth 2.0 방식으로 되어있다. 공식 문서에 가이드가 잘 나와있어서 어렵지 않게 할 수 있었다.
리소스에 접근하기 위해선 access_token이 필요했기 때문에 Client Credentials Grant 방식으로 받아왔다.
그리고 로그인한 유저만 접근 가능한 리소스(유저 프로필, 음악 재생 등)들은 Authorization Code Grant 방식으로 가져온 access_token이 필요하다.
받아온 access_token을 어떤 식으로 저장할 지 고민했다.
1. 로컬스토리지에 저장하기
2. 쿠키에 저장하기
로컬스토리지는 브라우저 환경에서만 접근이 가능해서 쿠키에 저장하는 방식으로 진행했다.
그리고 cookies-next 라이브러리를 사용하여 토큰을 쉽게 접근 가능하도록 했다.
로그인
로그인을 구현하기 위해 Next.js의 API routes 기능을 사용했다.
로그인 버튼 클릭 > api/login > Spotify 로그인 창 > api/callback > root page
// api/login/index.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import qs from 'querystring';
import { BASE_URL } from 'constants/path';
import generateRandomString from 'utils/generateRandomString';
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const state = generateRandomString(16);
const scope =
'user-read-private user-read-email user-read-playback-state user-modify-playback-state streaming';
res.redirect(
'https://accounts.spotify.com/authorize?' +
qs.stringify({
response_type: 'code',
client_id: process.env.NEXT_PUBLIC_CLIENT_ID,
scope: scope,
redirect_uri: `${BASE_URL}/api/callback`,
state: state,
})
);
}
추후에 음악 재생 기능을 사용하기 위해 scope를 추가하여 허용 범위를 넓혔다.
// api/callback/index.ts
import { setCookie } from 'cookies-next';
import { NextApiRequest, NextApiResponse } from 'next';
import qs from 'querystring';
import { postAuthorizationCodeToken } from 'api/token';
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const code = typeof req.query.code === 'string' ? req.query.code : null;
const state = typeof req.query.state === 'string' ? req.query.state : null;
if (state === null || code === null) {
res.redirect(
'/#' +
qs.stringify({
error: 'state_mismatch',
})
);
} else {
postAuthorizationCodeToken(code)
.then((response) => {
setCookie('access_token', response.data.access_token, {
req,
res,
maxAge: response.data.expires_in,
});
setCookie('refresh_token', response.data.refresh_token, {
req,
res,
maxAge: response.data.expires_in,
httpOnly: true,
});
res.redirect('/');
})
.catch((error) => {
res.status(500).json(error);
});
}
}
callback api에서 받아온 토큰들을 쿠키에 저장하고 root 페이지로 리다이렉트 시켰다.
token 요청하는 로직은 따로 분리하여 관리했다.
import axios from 'axios';
import { BASE_URL } from 'constants/path';
interface TokenObject {
access_token: string;
token_type: string;
expires_in: number;
}
interface ClientCredentialsTokenResponse extends TokenObject {}
interface AuthorizationCodeTokenResponse extends TokenObject {
refresh_token: string;
scope: string;
}
interface RefreshTokenResponse extends TokenObject {
scope: string;
}
export const postClientCredentialsToken = () => {
return axios<ClientCredentialsTokenResponse>({
method: 'post',
url: 'https://accounts.spotify.com/api/token',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization:
'Basic ' +
Buffer.from(
`${process.env.NEXT_PUBLIC_CLIENT_ID}:${process.env.NEXT_PUBLIC_CLIENT_SECRET}`
).toString('base64'),
},
data: {
grant_type: 'client_credentials',
},
});
};
export const postAuthorizationCodeToken = (code: string) => {
return axios<AuthorizationCodeTokenResponse>({
method: 'post',
url: 'https://accounts.spotify.com/api/token',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization:
'Basic ' +
Buffer.from(
`${process.env.NEXT_PUBLIC_CLIENT_ID}:${process.env.NEXT_PUBLIC_CLIENT_SECRET}`
).toString('base64'),
},
data: {
code: code,
redirect_uri: BASE_URL + '/api/callback',
grant_type: 'authorization_code',
},
});
};
export const postRefreshToken = (refresh_token: string) => {
return axios<RefreshTokenResponse>({
method: 'post',
url: 'https://accounts.spotify.com/api/token',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization:
'Basic ' +
Buffer.from(
`${process.env.NEXT_PUBLIC_CLIENT_ID}:${process.env.NEXT_PUBLIC_CLIENT_SECRET}`
).toString('base64'),
},
data: {
grant_type: 'refresh_token',
refresh_token,
},
});
};
고민 1 : 언제 access_token을 가져오지?
일단 access_token의 종류를 두 가지로 나눌 수 있다.
- 비로그인 유저의 access_token
- 로그인 유저의 access_token
어떤 유저든 access_token을 가지고 있기 때문에 유저가 로그인했는지 구분하기 힘들었다. 그러면 어떻게 1번과 2번을 구분할까?
2번의 경우에는 쿠키에 access_token과 refresh_token 두 개가 저장되어 있기 때문에 나는 이렇게 구분했다.
- refresh_token이 없으면 비로그인 유저로 판단
- refresh_token이 있으면 로그인 유저로 판단
axios interceptor를 사용해서 요청 시 토큰을 확인해서 헤더의 Authorization에 넣어주었다.
const api = axios.create({
baseURL: BASE_API_URL,
});
api.interceptors.request.use(
async (req) => {
if (typeof window !== 'undefined') {
try {
const access_token = getCookie('access_token');
const refresh_token = getCookie('refresh_token');
// access_token있으면 그대로 사용
if (typeof access_token === 'string' && access_token !== '') {
req.headers['Authorization'] = `Bearer ${access_token}`;
return req;
}
// refresh_token는 있지만 access_token 없을 때 새로운 토큰 발급
if (typeof refresh_token === 'string' && refresh_token !== '') {
const { data } = await postRefreshToken(refresh_token);
req.headers['Authorization'] = `Bearer ${data.access_token}`;
setCookie('access_token', data.access_token, {
maxAge: data.expires_in,
});
return req;
}
// access_token과 refresh_token 둘 다 없으면 client credential 방식의 access_token 발급
const { data } = await postClientCredentialsToken();
req.headers['Authorization'] = `Bearer ${data.access_token}`;
setCookie('access_token', data.access_token, {
maxAge: data.expires_in,
});
} catch (error) {
return req;
}
}
return req;
},
(error) => Promise.reject(error)
);
만약 토큰이 유효하지 않거나 없다면 401 에러가 뜬다. 그렇기 때문에 응답 interceptor를 통해서 에러 핸들링을 해주었다.
api.interceptors.response.use(
(res) => res,
async (error) => {
const { config, response } = error;
if (response?.status === 401 && !config._retry) {
try {
config._retry = true;
const refresh_token = getCookie('refresh_token');
// refresh_token 있으면 access_token 재발급 후 다시 요청
if (typeof refresh_token === 'string' && refresh_token !== '') {
const { data } = await postRefreshToken(refresh_token);
api.defaults.headers.common['Authorization'] = `Bearer ${data.access_token}`;
setCookie('access_token', data.access_token, {
maxAge: data.expires_in,
});
return api(config);
}
// refresh_token 없으면 client credential 방식의 access_token 발급 후 다시 요청
const { data } = await postClientCredentialsToken();
api.defaults.headers.common['Authorization'] = `Bearer ${data.access_token}`;
setCookie('access_token', data.access_token, {
maxAge: data.expires_in,
});
return api(config);
} catch (error) {
return Promise.reject(error);
}
}
return Promise.reject(error);
}
);
고민 2 : 로그인한 유저의 기본 정보는 언제 가져오지?
getInitialProps를 사용하면 매 페이지 접근 시마다 server side에서 데이터 페칭하여 미리 받아올 수 있다. 이렇게 받아온 데이터로 로그인 여부도 판단할 수 있고 refresh_token만 유효하다면 로그인이 풀리지 않는다.
// _app.tsx
interface MyAppProps extends AppProps {
loginData?: SpotifyApi.UserProfileResponse;
}
function App({ Component, pageProps, loginData }: MyAppProps) {
// ...
}
App.getInitialProps = async (context: AppContext) => {
const { ctx, Component } = context;
let pageProps = {};
let loginData: SpotifyApi.UserProfileResponse | null;
const accessToken = getCookie('access_token', ctx);
const refreshToken = getCookie('refresh_token', ctx);
if (!refreshToken) {
return { pageProps, loginData: null };
}
try {
const loginDataResponse = await axios<SpotifyApi.UserProfileResponse>({
method: 'get',
url: 'https://api.spotify.com/v1/me',
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
loginData = loginDataResponse.data;
} catch {
loginData = null;
}
if (Component.getInitialProps) {
pageProps = await Component.getInitialProps(ctx);
}
return { pageProps, loginData };
};
export default App;
고민 2 - 1 : recoil의 loginDataState의 default이 null로 되어있어서 로그인한 유저도 렌더링 시 로그인 버튼이 무조건 보이게 된다..
recoil 공식 문서를 확인하니 initializeState 속성을 추가하여 초기화를 할 수 있다고 한다.
// _app.tsx
function App({ Component, pageProps, loginData }: MyAppProps) {
// ...
const initializer = ({ set }: MutableSnapshot) => {
if (loginData) set(loginDataState, loginData);
};
return (
<RecoilRoot initializeState={initializer}>
// ...
</RecoilRoot>
);
}
getInitialProps에서 loginData를 미리 받아오기 때문에 loginDataState를 초기화하여 문제를 해결했다.
'React' 카테고리의 다른 글
Spotify API 사용기 (3) - Spotify Web Playback SDK를 사용하여 음악 재생 기능 만들기 (0) | 2023.04.02 |
---|---|
Spotify API 사용기 (2) - SSR에서의 React Query (0) | 2023.03.26 |
[React] 상태 관리 라이브러리의 이해 - Redux 동작 원리 (1) | 2023.02.02 |
[React] 상태 관리(feat. React-Query) (0) | 2022.09.03 |
[React] props? state? (0) | 2022.09.01 |