음악 플레이어 프로젝트를 시작하게 된 이유는 단순하다. React 강의를 듣고 나서 제대로 실습을 해본 적이 없어 연습을 위한 프로젝트가 필요했다. 사실 여러 주제를 생각했었는데 내가 가장 좋아하고 관심있는 분야에서 출발해야 프로젝트를 제대로 완성할 수 있겠다 생각하여, 음악 플레이어를 제작하기로 했다.
그래서 해당 프로젝트는 React hook 등 전반적인 사용법을 익히고, 웹에서 오디오를 재생하는 기능 구현에 초점을 맞추어 진행했다.
1-1. local에 있는 음악파일을 React로 가져오기
local에 가지고 있는 음악파일의 경로와 제목, 아티스트명,이미지 파일의 경로를 json으로 만들어 가져와 songs state에 저장했다. React hook인 useRef를 이용하여 해당 오디오 요소에 접근했다.

function Audio(){
const audioEl = useRef(null);
return (
<audio
src={process.env.PUBLIC_URL + songIndex.audio}
ref={audioEl}
></audio>
);
}
1-2. 오디오 요소를 제어하기
웹에서 오디오 파일을 제어하기 위해서는 HTML5 오디오 요소에 대한 메서드와 프로퍼티, 이벤트에 대한 지식이 필요하다. 아래에 기능을 구현하면서 사용했던 것들을 정리해보았다. W3Schools 참고
HTML Audio Methods
| Method | Description |
|---|---|
| load() | 오디오 요소를 로드한다. |
| play() | 오디오 요소를 재생한다. |
| pause() | 재생되고 있는 요소를 멈춘다. |
HTML Audio Properties
| Property | Description |
|---|---|
| currentTime | 오디오 요소의 현재 재생시간을 초단위로 가져온다. |
| duration | 오디오 요소의 전체 재생시간을 초단위로 가져온다. |
| volume | 오디오의 볼륨을 가져온다. |
HTML Audio Events
| Event | Description |
|---|---|
| ended | 오디오 재생이 끝났을 때 실행된다. |
| loadedmetadata | duration 등 오디오의 메타데이터를 가져올 때 사용한다. |
| timeupdate | currentTime 속성이 업데이트 될 때마다 실행된다. |
1-3. 셔플 기능 구현하기
다른 기능은 대부분 위에서 정리한 메서드와 이벤트들을 활용하면 쉽게 구현할 수 있었으나, 셔플은 목록을 랜덤하게 섞어주는 함수가 따로 필요했다. 현재의 재생목록인 songs를 배열로 만든 후, Math.random를 이용하여 j라는 임의의 인덱스를 만든다. 이때 j는 0부터 해당 배열의 길이(즉, 현재 목록의 개수) 내에서 임의의 수를 갖는다. 이를 구조분해 할당으로 i번째 요소와 j번째 요소를 바꾸어 목록을 랜덤하게 섞는다.
// 랜덤하게 셔플하는 함수
const shuffleArray = (array) => {
const shuffledArray = [...array];
for (let i = shuffledArray.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffledArray[i], shuffledArray[j]] = [shuffledArray[j], shuffledArray[i]];
}
return shuffledArray;
}
셔플을 하고 난 결과를 현재의 재생목록을 반드시 업데이트 해주어야 하며, 새로운 재생목록의 첫 곡부터 재생될 수 있도록 currentSongIndex도 0으로 변경해주었다.
// 랜덤 셔플 후 songs 배열 업데이트
const shuffleSongs = () => {
setIsShuffled(isShuffle + 1);
const shuffledList = shuffleArray(songs);
setSongs(shuffledList);
setCurrentSongIndex(0); // 셔플 후 첫 번째 곡으로 설정
};
2-1. 제목의 길이에 따른 자동 스크롤하기
제목의 길이가 컨테이너를 초과할 경우, 제목이 자동으로 움직이는 기능을 추가했다. 먼저 useRef를 이용하여 제목과 컨테이너 DOM 요소를 가져온다. offsetWidth로 컨테이너와 제목의 너비를 각각 가져온다. 제목의 너비가 컨테이너를 초과할 경우, scrollPosition의 값을 -1하며 좌측으로 제목을 이동시킨다. 제목이 왼쪽으로 끝까지 이동하면 제목이 다시 컨테이너의 오른쪽 끝에서부터 나오도록 했다. useEffect를 이용하여 음악이 변경될 때마다 값을 초기화한다.
function Card({songs, currentSongIndex}){
const [scrollPosition, setScrollPosition] = useState(0);
const titleEl = useRef(0);
const cardEl = useRef(0);
// 음악 타이틀의 길이가 컨테이너를 초과할 경우, 스크롤하기
useEffect(()=>{
let cardWidth = cardEl.current.offsetWidth;
let titleWidth = titleEl.current.offsetWidth;
let scrollInterval;
if(titleWidth > cardWidth){
scrollInterval = setInterval(()=>{
setScrollPosition((prev) =>{
if(Math.abs(prev) >= titleWidth){
return cardWidth;
} else {
return prev - 1}
})
}, 80)
}
// currentSongIndex가 변경될때 초기화
return ()=>{
setScrollPosition(0);
clearInterval(scrollInterval);
}
},[currentSongIndex])
return (
{ /* 중략 */ }
<h3 style={ {transform : `translateX(${scrollPosition}px)`}}>
<span ref={titleEl}>{songs[currentSongIndex].song}</span>
</h3>
)
}
2-2. 남은 재생 시간 보여주기
음원의 총 재생시간은 duration, 현재의 재생시간은 currentTime 프로퍼티로 가져온다. currentTime은 TimeUpdate 이벤트로 재생 시간이 업데이트 될 때마다 값이 변경된다. 이렇게 가져온 값들은 초단위이므로 "분:초"로 보여주기 위해서 단위를 변환해준다. 타임라인 input은 음악이 재생됨에 따라 타임라인의 게이지가 차오르는 스타일 적용이 필요하다. input의 max를 총 재생시간(duration)으로 두고 총 재생시간 중 현재의 재생시간을 %로 나타내어 linear-gradient에 현재 재생시간 값이 변경될때마다 value값이 하였다.
function Timeline({ currentTime, totalDuration, onChange }) {
// 남은 시간을 분:초로 보여주기
const formatTime = (time) => {
const min = Math.floor(time / 60);
const sec = Math.floor(time % 60);
return `${min} : ${sec < 10 ? "0" : ""}${sec}`;
};
// 타임라인 input range 스타일
const timelineBackground = {
background: `linear-gradient(to right, #FF0060 ${
(currentTime / totalDuration) * 100
}%, #fff 3%)`,
};
return (
<div className={styles.wrapper}>
<p style={{paddingRight: "1.2em"}}>{formatTime(totalDuration - currentTime)}</p>
<input
type="range"
className={styles.timeline}
value={currentTime}
min="0"
max={totalDuration}
step={0.1}
style={timelineBackground}
onChange={onChange}
></input>
</div>
);
}
2-3. 📱 반응형 웹
반응형은 최소한으로만 구현하였다. 화면이 작아질수록 재생목록을 띄워줄 공간이 부족하여 재생목록을 클릭하면 목록을 접었다가 펴는 기능을 추가했다. 모바일 화면에서는 UI를 전반적으로 수정하였고 재생목록을 상단에 배치하여 위에서 아래로 내려오도록 했다.
![]() | ![]() |
|---|
✅ 오류 내용
일시정지 후 다시 재생 버튼 클릭 시 음원이 처음부터 시작되는 오류
✅ 기존 재생/일시정지 기능 흐름
isPlaying의 상태에 따라 같은 위치에 다른 컴포넌트가 렌더링 된다.isPlaying state가 true로 변경(일시정지 버튼 클릭 시에는 false로 변경) + 음원의 총 길이를 가져오는 함수 호출✅ 개선 방법
일시정지 버튼을 클릭했을 때도 동일한 함수를 호출하면서 음원을 재로드하기 때문에 발생한 문제였다. useEffect를 이용하여 재생할 음악이 변경될 때(currentSongIndex가 변경될 때)에만 해당 함수를 호출하도록 코드 수정
// 음원의 총 길이 가져오기
useEffect(()=>{
if (audioEl) {
audioEl.current.load(); // 새로운 음원을 로드하여 메타데이터를 업데이트합니다.
audioEl.current.onloadedmetadata = () => {
setTotalDuration(audioEl.current.duration);
};
}
}, [currentSongIndex])
// isPlaying 상태 변경
const handlePlay = () => {
setIsPlaying(!isPlaying);
};
// 플레이 버튼 클릭 시 재생, 일시정지
useEffect(() => {
if (isPlaying) {
audioEl.current.play();
} else {
audioEl.current.pause();
}
}, [isPlaying, currentSongIndex, isShuffle]);
transposition의 값이 변경되고 화면이 렌더링이 되기 때문에 끊김 현상이 발생한다고 파악했다. requestAnimationFrame 메서드를 이용하여 개선을 해야할 듯하다.