먼저 ✨완성본 Git Repository✨는 아래와 같다.
1주일짜리 개인 프로젝트를 고민하다가 그래도 첫 개인 프로젝트는 내가 좋아하는 주제를 해야겠다고 마음 먹었다. 자칭 생활뮤지션인만큼 음악 사이트 API를 찾아 돌아다니게 되었는데, 찾아본 결과, 쓸 수 있을 것 같은 API는 3가지 정도 되었다.
결론적으로는 Spotify API를 쓰게 되었는데, 물론 Spotify의 데이터 호출 방식이 좀 까다롭지만, iTunes와 ManiaDB에는 치명적인 단점이 있었기 때문이다.
iTunes는 API Key도 필요없고 사용하기 쉬우나, 사용량 제한이 있어 유료 서비스가 따로 존재할 정도라, 테스트용으로만 적합했고, ManiaDB는 사용불가한 사이트가 되어 있었다.

Spotify Web API는 OAuth 2.0 방식으로 되어있다. 리소스에 접근하기 위해선 access_token이 필요했기 때문에 위 이미지와 같은 Client Credentials Grant 방식으로 받아왔다.
로그인 기능을 구현하기 위한 access_token, refresh_token이나 track에 대한 data를 가져오기엔 express 프레임워크나 Next.js 등 백앤드 로직이 필요했다. 결론적으로 난 백앤드 로직은 없이 1주일짜리 프로젝트를 끝내기로 했다. 그 이유 중 제일 중요한 건, 내가 이 프로젝트를 시작하게 된 기획의도에 따르면 로그인 기능이 필요 없었고, 다른 이유로는 개인 프로젝트로 1주일간 진행하기 위해 공부할 양은 아니기도 했거니와, Back이 필요없는 Supabase나 PKCE방식을 사용하기보다는, 나중에 프로젝트를 확장하게 되면 Spring을 활용해 서버를 제대로 연결해보고 싶은 욕심이 있었다.
그렇게 개인 프로젝트의 방향은 프론트앤드 구현완료로 변경되었다.
발급받은 Client ID, Client Secret을 활용하여 access_token만 가져와 이 프로젝트를 진행하게 되었다.


Create app 버튼을 누른다.
information을 채운다. 아래와 같이:
App Name: My App
App Description: This is my first Spotify app
Redirect URI: http://localhost:3000.
바로 발급이 안될 수 있으니 조금 기다린다.


const APIController = (function () {
const _getToken = async () => {
const result = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
// client id, secret key
'Authorization': 'Basic ' + btoa('client_id' + ':' + 'secret_key')
},
body: 'grant_type=client_credentials'
});
const data = await result.json();
return data.access_token;
}
const _getGenres = async (token) => {
const result = await fetch(`https://api.spotify.com/v1/browse/categories?locale=sv_KR`, {
method: 'GET',
headers: {'Authorization': 'Bearer ' + token}
});
const data = await result.json();
return data.categories.items;
}
const _getPlaylistByGenre = async (token, genreId) => {
const limit = 20;
const result = await fetch(`https://api.spotify.com/v1/browse/categories/${genreId}/playlists?limit=${limit}`, {
method: 'GET',
headers: {'Authorization': 'Bearer ' + token}
});
const data = await result.json();
return data.playlists.items;
}
const _getTracks = async (token, tracksEndPoint) => {
const limit = 20;
const result = await fetch(`${tracksEndPoint}?limit=${limit}`, {
method: 'GET',
headers: {'Authorization': 'Bearer ' + token}
});
const data = await result.json();
return data.items.filter(item => item.track.preview_url !== null);
}
return {
getToken() {
return _getToken();
},
getGenres(token) {
return _getGenres(token);
},
getPlaylistByGenre(token, genreId) {
return _getPlaylistByGenre(token, genreId);
},
getTracks(token, tracksEndPoint) {
return _getTracks(token, tracksEndPoint);
},
}
})();
API Controller는 Spotify의 음악 데이터베이스와 대화하는 데 도움을 주는 관리자 정도의 역할을 한다.
const APIController = (function() {
// 코드
})();
이것은 "즉시 실행 함수 표현(IIFE: Immediately Invoked Function Expression)"라고 한다. 금고처럼 중요한 코드를 비공개로 유지하고 다른 사람들이 볼 수 있는 것만 보여주는 역할이다.
이 "금고" 안에는 네 가지 주요기능이 있다.
a. _getToken
const _getToken = async () => {
// Spotify로부터 권한을 얻는 코드
}
이것은 콘서트에서 특별한 VIP 패스를 받는 것과 같다. Spotify에게 음악을 요청하기 전에 이 특별한 패스(토큰)를 받아야 한다. 앞에 있는 언더스코어(_)는 이것이 비공식적이라는 것을 의미한다.
b. _getGenres
const _getGenres = async (token) => {
// 음악 카테고리를 얻는 코드
}
VIP 패스(토큰)를 받은 후, 이를 사용하여 Spotify에서 어떤 종류의 음악 카테고리가 있는지 볼 수 있다.
c. _getPlaylistByGenre
const _getPlaylistByGenre = async (token, genreId) => {
// 특정 장르의 재생 목록을 얻는 코드
}
이건 좋아하는 장르를 선택한 후 "모든 록 플레이리스트를 보여줘!"라고 요청하는 것이다.
d. _getTracks
const _getTracks = async (token, tracksEndPoint) => {
// 실제 노래를 가져오는 코드
}
마지막으로, 이 함수는 우리가 선택한 플레이리스트에서 실제 노래를 가져온다.
각 함수의 fetch 부분은 Spotify에게 요청을 보내는 메신저와 같다. async와 await 키워드는 "Spotify가 대답할 때까지 기다려"라는 의미다. 누군가가 문자 메시지를 보낸 후 기다리는 것과 비슷하다.
return {
getToken() { return _getToken(); },
getGenres(token) { return _getGenres(token); },
// 코드
}
이것은 마치 레스토랑의 공개 메뉴와 같다. 사람들이 주문할 수 있는 것만 보여준다. 비공식적인 요리 방법들(_getToken 등)은 주방에 남아 있는 느낌인 것이다.
// 먼저, VIP 패스(토큰)를 얻고
const token = await APIController.getToken();
// 그 다음, 그 토큰을 사용하여 음악 카테고리를 가져오고
const genres = await APIController.getGenres(token);
// 장르를 선택하고 그에 맞는 재생목록을 가져오고
const playlists = await APIController.getPlaylistByGenre(token, "rock");
// 마지막으로, 플레이리스트에서 노래를 가져온다
const tracks = await APIController.getTracks(token, playlistEndpoint);
const UIController = (function() {
const DOMElements = {
selectGenre: '#select_genre',
selectPlaylist: '#select_playlist',
buttonSubmit: '#btn_submit',
divSongDetail: '#song-detail',
hfToken: '#hidden_token',
divSonglist: '.song-list',
searchSection: '#search-section',
comingSoonSection: '#coming-soon-section',
goBackButton: '#go-back-button',
errorMessage: '#error-message',
form: '.search-form'
}
return {
inputField() {
return {
genre: document.querySelector(DOMElements.selectGenre),
playlist: document.querySelector(DOMElements.selectPlaylist),
tracks: document.querySelector(DOMElements.divSonglist),
submit: document.querySelector(DOMElements.buttonSubmit),
songDetail: document.querySelector(DOMElements.divSongDetail),
form: document.querySelector(DOMElements.form)
}
},
createGenre(text, value) {
const html = `<option value="${value}">${text}</option>`;
document.querySelector(DOMElements.selectGenre).insertAdjacentHTML('beforeend', html);
},
createPlaylist(text, value) {
const html = `<option value="${value}">${text}</option>`;
document.querySelector(DOMElements.selectPlaylist).insertAdjacentHTML('beforeend', html);
},
resetTrackDetail() {
this.inputField().songDetail.innerHTML = '';
},
resetTracks() {
this.inputField().tracks.innerHTML = '';
this.resetTrackDetail();
},
resetPlaylist() {
this.inputField().playlist.innerHTML = '<option value="">Keyword</option>';
this.resetTracks();
},
storeToken(value) {
document.querySelector(DOMElements.hfToken).value = value;
},
getStoredToken() {
return {
token: document.querySelector(DOMElements.hfToken).value
}
},
showComingSoon() {
document.querySelector(DOMElements.searchSection).style.display = 'none';
document.querySelector(DOMElements.comingSoonSection).style.display = 'block';
},
hideComingSoon() {
document.querySelector(DOMElements.searchSection).style.display = 'block';
document.querySelector(DOMElements.comingSoonSection).style.display = 'none';
},
showFieldError(fieldName, message) {
const field = document.querySelector(DOMElements[`select${fieldName.charAt(0).toUpperCase() + fieldName.slice(1)}`]);
field.classList.add('error');
let errorDiv = field.nextElementSibling;
if (!errorDiv || !errorDiv.classList.contains('field-error')) {
errorDiv = document.createElement('div');
errorDiv.classList.add('field-error');
field.parentNode.insertBefore(errorDiv, field.nextSibling);
}
errorDiv.textContent = message;
errorDiv.style.display = 'block';
},
clearFieldError(fieldName) {
const field = document.querySelector(DOMElements[`select${fieldName.charAt(0).toUpperCase() + fieldName.slice(1)}`]);
field.classList.remove('error');
const errorDiv = field.nextElementSibling;
if (errorDiv && errorDiv.classList.contains('field-error')) {
errorDiv.style.display = 'none';
}
},
showError(message) {
const errorDiv = document.querySelector(DOMElements.errorMessage);
errorDiv.textContent = message;
errorDiv.style.display = 'block';
},
hideError() {
const errorDiv = document.querySelector(DOMElements.errorMessage);
errorDiv.style.display = 'none';
},
clearAllErrors() {
['genre', 'playlist'].forEach(fieldName => {
this.clearFieldError(fieldName);
});
this.hideError();
},
disableSubmit() {
this.inputField().submit.disabled = true;
this.inputField().submit.style.opacity = '0.5';
},
enableSubmit() {
this.inputField().submit.disabled = false;
this.inputField().submit.style.opacity = '1';
},
getDOMElements() {
return DOMElements;
}
};
})();
UI Controller는 웹페이지의 모든 요소들을 관리하는 매니저 정도라고 생각하면 된다.
const DOMElements = {
selectGenre: '#select_genre',
selectPlaylist: '#select_playlist',
buttonSubmit: '#btn_submit',
divSongDetail: '#song-detail',
// ... 기타 요소들
}
건물의 설계도와 같이 우리가 사용할 모든 HTML 요소들의 위치를 미리 정해둔다.
return {
// 1. 입력 필드 가져오기
inputField() {
return {
genre: document.querySelector(DOMElements.selectGenre),
playlist: document.querySelector(DOMElements.selectPlaylist),
// ... 기타 필드들
}
},
// 2. 장르 옵션 생성하기
createGenre(text, value) {
const html = `<option value="${value}">${text}</option>`;
document.querySelector(DOMElements.selectGenre)
.insertAdjacentHTML('beforeend', html);
},
// 3. 플레이리스트 옵션 생성하기
createPlaylist(text, value) {
const html = `<option value="${value}">${text}</option>`;
document.querySelector(DOMElements.selectPlaylist)
.insertAdjacentHTML('beforeend', html);
}
}
리셋 기능들은
// 트랙 상세정보 리셋
resetTrackDetail() {
this.inputField().songDetail.innerHTML = '';
},
// 트랙 목록 리셋
resetTracks() {
this.inputField().tracks.innerHTML = '';
this.resetTrackDetail();
},
// 플레이리스트 리셋
resetPlaylist() {
this.inputField().playlist.innerHTML = '<option value="">Keyword</option>';
this.resetTracks();
}
새로운 내용을 쓰기 전에 깨끗이 지워주는 역할을 하도록 구현한다.
에러 처리 기능들로
// 필드별 에러 표시
showFieldError(fieldName, message) {
const field = document.querySelector(DOMElements[`select${fieldName.charAt(0).toUpperCase() + fieldName.slice(1)}`]);
field.classList.add('error');
// ... 에러 메시지 표시
},
// 에러 지우기
clearFieldError(fieldName) {
const field = document.querySelector(DOMElements[`select${fieldName.charAt(0).toUpperCase() + fieldName.slice(1)}`]);
field.classList.remove('error');
// ... 에러 메시지 제거
}
체인 방식의 리셋을 통해 playlist를 리셋하면 tracks도 자동으로 리셋하고, 토큰 관리를 통해 Spotify API 토큰을 안전하게 저장하고 가져올 수 있도록 구현하는 것이 중점이 되었다.
const APPController = (function(UICtrl, APICtrl, FormValidator) {
const DOMInputs = UICtrl.inputField();
const DOMElements = UICtrl.getDOMElements();
const loadGenres = async () => {
try {
const token = await APICtrl.getToken();
UICtrl.storeToken(token);
const genres = await APICtrl.getGenres(token);
genres.forEach(element => UICtrl.createGenre(element.name, element.id));
} catch (error) {
console.error('Error loading genres:', error);
UICtrl.showError('Failed to load genres. Please refresh the page.');
}
}
document.querySelector(DOMElements.goBackButton).addEventListener('click', () => {
// ... 코드
});
// 장르 선택 변경
DOMInputs.genre.addEventListener('change', async () => {
// ... 코드
});
// 플레이리스트 선택 변경
DOMInputs.playlist.addEventListener('change', () => {
// ... 코드
});
// 폼 제출
DOMInputs.form.addEventListener('submit', async (e) => {
// ... 코드
});
return {
init() {
loadGenres();
UICtrl.hideComingSoon();
UICtrl.hideError();
UICtrl.disableSubmit();
}
}
})(UIController, APIController, FormValidator);
APPController.init();
UICtrl, APICtrl가 UIController, APIController를 가리키는 것은
const APPController = (function(UICtrl, APICtrl, FormValidator) {
// ... 코드 내용 ...
})(UIController, APIController, FormValidator);
이 코드에서 가장 중요한 부분은 마지막 줄을 보면 알 수 있다.
여기서 즉시 실행 함수(IIFE)를 사용하고 있는데, 함수를 선언하자마자 바로 실행하면서 UIController, APIController, FormValidator를 인자로 전달하고 있다.
이렇게 전달된 인자들은 함수의 매개변수 UICtrl, APICtrl, FormValidator로 전달된다.
UICtrl = UIController
APICtrl = APIController
세 개의 컨트롤러(UI, API, Form)를 결합해서 하나의 앱을 만드는 구조인데, 먼저
const DOMInputs = UICtrl.inputField();
const DOMElements = UICtrl.getDOMElements();
웹 페이지의 입력 필드와 DOM Elements를 가져온다.
필요 이벤트 리스너들은
// 뒤로가기 버튼
document.querySelector(DOMElements.goBackButton).addEventListener('click', () => {
// ... 코드
});
// 장르 선택 변경
DOMInputs.genre.addEventListener('change', async () => {
// ... 코드
});
// 플레이리스트 선택 변경
DOMInputs.playlist.addEventListener('change', () => {
// ... 코드
});
// 폼 제출
DOMInputs.form.addEventListener('submit', async (e) => {
// ... 코드
});
비동기 처리는
const loadGenres = async () => {
try {
const token = await APICtrl.getToken();
// ... 코드
} catch (error) {
// ... 에러 처리
}
}
async/await를 사용해 API 호출을 처리한다.
초기화 함수는
return {
init() {
loadGenres();
UICtrl.hideComingSoon();
UICtrl.hideError();
UICtrl.disableSubmit();
}
}
앱이 시작될 때 필요한 초기 설정을 담당한다.
APPController는 Spotify API를 사용해서 음악 장르와 플레이리스트를 가져오는 웹 애플리케이션의 메인 컨트롤러다. 사용자가 장르를 선택하면 관련 플레이리스트를 보여주고, 플레이리스트를 선택하면 트랙 정보를 가져와서 QR 코드 페이지로 이동하는 기능을 구현하고 있다.
토큰 발급 후 API 연동을 한 뒤, 퀴즈 형식의 웹 페이지를 제작하게 되었고, 코드는 깃허브 링크에 올라가 있다. 물론 프런트만 구현하긴 했지만 일주일짜리 프로젝트를 혼자 해내기 위해 고민하고 공부한 시간들 속에서 많은 것을 배웠다. 서치했을 때, API를 쓰기 위해서 백앤드까지 전부 구현하거나, React를 사용하는 경우가 많아 Vanilla Js로는 아무래도 무리일까, 하는 생각에도 Js의 비동기 방식을 활용한 구현을 진행하며 JavaScript에 많이 익숙해지고 값진 시간이었다.
Spring과 React 공부도 계속하고 있으니 다음 목표를 백까지 연동시키고 디자인도 좀 더 손쉽게 만질 수 있도록 변경하는 것으로 잡았다.