Skip to content

Commit 469a1bc

Browse files
committed
update post
1 parent 8b6cf35 commit 469a1bc

File tree

4 files changed

+362
-0
lines changed

4 files changed

+362
-0
lines changed
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
---
2+
tags:
3+
- project
4+
- goedamjip
5+
- howler
6+
createdAt: 2025-07-08 08:36:34
7+
modifiedAt: 2025-07-09 13:15:36
8+
publish: 프로젝트/괴담집
9+
related:
10+
- "[[괴담집]]"
11+
series: ""
12+
---
13+
14+
# 괴담집 프로젝트에서 Howler.js 사용하기
15+
16+
Howler는 웹에서의 오디오 제어를 위한 라이브러리로 좋은 성능에 다양한 기능을 지원한다.
17+
특히 괴담집 프로젝트에 필요한 `fade`효과나 `loop`효과 그리고 여러 소리에 대한 개별 제어등 딱 알맞는 라이브러리라고 생각된다.
18+
19+
이후에 업데이트할 때에는 공간음향같은 `Howler.js`에서 제공하는 특수효과도 사용하여 속삭임같은 무서운 음향효과를 구현할수도 있을것 같다는 생각이다.
20+
21+
## 기본 사용법
22+
23+
```typescript
24+
import { Howl, Howler } from "howler";
25+
26+
// Setup the new Howl.
27+
const sound = new Howl({
28+
src: ["sound.webm", "sound.mp3"],
29+
});
30+
31+
// Play the sound.
32+
sound.play();
33+
34+
// Change global volume.
35+
Howler.volume(0.5);
36+
```
37+
38+
howler는 기본적으로 두가지 객체를 통해 소리를 로드하고 제어한다.
39+
40+
- `Howl`: `Howl`은 개별 사운드에 대한 객체이며 해당 객체를 통해 각각의 소리를 제어할 수 있다.
41+
- `Howler`: `Howler`는 글로벌 제어를 위한 객체이며 전체 볼륨제어와 같은 전체 사운드에 대한 제어를 가능하게 한다.
42+
43+
## React에서의 사용
44+
45+
```tsx
46+
import { useRef } from "react";
47+
import { Howl, Howler } from "howler";
48+
의;
49+
export default function SoundTest({ asset }: { assets: AudioAsset }) {
50+
const soundRef = useRef(null);
51+
52+
useEffect(() => {
53+
if (!soundRef.current && asset?.file_url) {
54+
soundRef.current = new Howl({
55+
src: [asset.file_url],
56+
});
57+
}
58+
soundRef.current.play();
59+
}, []);
60+
61+
return <div>test</div>;
62+
}
63+
```
64+
65+
에셋을 불러오는 작업과 React의 리렌더링은 수많은 메모리 누수를 일으킬 수 있다. 따라서 사운드를 불러오기 위해선 `useRef`를 활용하는것이 가장 좋다.
66+
67+
### useRef를 사용하지 않았을때의 문제
68+
69+
#### ❌ useRef 없이 일반 변수 사용:
70+
71+
```tsx
72+
function BadExample() {
73+
const [count, setCount] = useState(0);
74+
let sound = null; // 매 렌더링마다 null로 초기화됨!
75+
76+
const playSound = () => {
77+
sound = new Howl({ src: ["click.mp3"] });
78+
sound.play();
79+
setCount(count + 1); // 리렌더링 발생!
80+
};
81+
82+
const stopSound = () => {
83+
sound.stop(); // 에러! sound는 null
84+
};
85+
86+
return (
87+
<div>
88+
<p>클릭 횟수: {count}</p>
89+
<button onClick={playSound}>재생</button>
90+
<button onClick={stopSound}>정지</button> {/* 작동 안 함! */}
91+
</div>
92+
);
93+
}
94+
```
95+
96+
문제 시나리오:
97+
98+
1. "재생" 클릭 → 소리 재생 시작
99+
2. setCount → 컴포넌트 리렌더링
100+
3. let sound = null 다시 실행 → 이전 Howl 인스턴스 참조 잃음
101+
4. "정지" 클릭 → sound는 null이라 에러!
102+
5. 오디오는 백그라운드에서 계속 재생 (제어 불가)
103+
104+
#### ❌ useState 사용:
105+
106+
```tsx
107+
function AlsoBadExample() {
108+
const [sound, setSound] = useState<Howl | null>(null);
109+
110+
const playSound = () => {
111+
const newSound = new Howl({ src: ["click.mp3"] });
112+
setSound(newSound); // 리렌더링 발생!
113+
newSound.play();
114+
};
115+
116+
// setState로 인한 불필요한 리렌더링
117+
// Howl 인스턴스를 state에 저장하는 것은 안티패턴
118+
}
119+
```
120+
121+
1. UI변경이 없어 리렌더링이 불필요 한데도 소리의 변경 때문에 리렌더링이 발생할 수 있음.
122+
2. UI와 무관한 값은 ref에 저장한다는 철학에 위배됨ㄴ
123+
124+
#### ✅ useRef 사용 (올바른 방법):
125+
126+
```tsx
127+
function GoodExample() {
128+
const [count, setCount] = useState(0);
129+
const soundRef = useRef<Howl | null>(null);
130+
131+
const playSound = () => {
132+
if (!soundRef.current) {
133+
soundRef.current = new Howl({ src: ["click.mp3"] });
134+
}
135+
soundRef.current.play();
136+
setCount(count + 1); // 리렌더링 발생해도 OK
137+
};
138+
139+
const stopSound = () => {
140+
soundRef.current?.stop(); // 정상 작동!
141+
};
142+
143+
return (
144+
<div>
145+
<p>클릭 횟수: {count}</p>
146+
<button onClick={playSound}>재생</button>
147+
<button onClick={stopSound}>정지</button> {/* 정상 작동! */}
148+
</div>
149+
);
150+
}
151+
```
152+
153+
## 본격적인 사용을 위한 고민
154+
155+
`Howler.js`를 잘 사용하기 위해서 그리고 앱에서 아무 문제 없도록 사용하기 위해서 React와 관련된 몇가지 문제를 생각해 보아야 한다.
156+
157+
1. 인스턴스 문제
158+
2. 사운드의 캐싱 문제
159+
3. 중복 로딩 문제
160+
161+
보편적인 리엑트의 훅 혹은 context를 사용하게 되면 위와 같은 문제가 발생할 수 있다.
162+
163+
훅을 통해 관리하면 여러 인스턴스가 겹치거나 인스턴스에 대한 제어권을 놓치게 되는 상황이 발생할 수 있고 context의 경우엔 해당 context를 벗어나면 이미 다운로드해서 캐싱됬던 메모리를 잃게 되거나 각각의 컴포넌트가 별개의 캐시를 갖게 될수도 있다.
164+
165+
따라서 `Class`형식을 통해 싱글톤 패턴을 구현하여 추상화하고 추상화된 모듈을 훅을 통하여 접근할 수 있도록 하는게 좋을것이라고 판단된다.
166+
167+
`Class`를 통해 로직을
168+
169+
### AudioManager 만들기
170+
171+
```typescript
172+
class AudioManager {
173+
// private static: 클래스에 속하는 유일한 인스턴스
174+
private static instance: AudioManager;
175+
176+
// private constructor: 외부에서 new AudioManager() 못하게 막음
177+
private constructor() {}
178+
179+
// 유일한 인스턴스를 가져오는 메서드
180+
static getInstance(): AudioManager {
181+
if (!AudioManager.instance) {
182+
AudioManager.instance = new AudioManager();
183+
}
184+
return AudioManager.instance;
185+
}
186+
}
187+
188+
// 왜 싱글톤?
189+
// → 앱 전체에서 하나의 AudioManager만 있어야 캐시 공유 가능
190+
```
191+
192+
캐싱 문제와 인스턴스 문제를 해결하기 위해 싱글톤 패턴의 클래스를 하나 만든다.
193+
클래스를 통해서 관련 기능들을 하나로 묶을 수 있고, private을 통해 보호할 수 있다.
194+
195+
### 캐싱 시스템 구현하기
196+
197+
```typescript
198+
interface LoadedAudio {
199+
howl: Howl; // 실제 오디오 객체
200+
asset: AudioAsset; // 원본 정보 (URL, 이름 등)
201+
loadedAt: Date; // 언제 로드했는지 (디버깅용)
202+
}
203+
204+
class AudioManager {
205+
// Map을 쓰는 이유: key-value 저장에 최적화
206+
private audioCache: Map<string, LoadedAudio> = new Map();
207+
208+
// 로딩 중인 것도 추적 (중복 로딩 방지)
209+
private loadingPromises: Map<string, Promise<LoadedAudio>> = new Map();
210+
}
211+
```
212+
213+
오디오의 캐싱과 중복 로딩을 방지하기 위해 private으로 map자료구조의 변수를 선언해준다.
214+
215+
### 프리로드 기능
216+
217+
```typescript
218+
interface AudioAsset {
219+
id: number;
220+
tag_name: string | null;
221+
display_name: string | null;
222+
file_url: string | null;
223+
file_mime: string | null;
224+
}
225+
226+
class AudioManager {
227+
...
228+
async preloadAudio(asset: AudioAsset): Promise<LoadedAudio> {
229+
// 1. 캐시 확인 - 이미 있으면 바로 반환
230+
const cached = this.audioCache.get(asset.tag_name || "");
231+
if (cached) return cached;
232+
233+
// 2. 로딩 중인지 확인 - 중복 로딩 방지
234+
const loading = this.loadingPromises.get(asset.tag_name || "");
235+
if (loading) return loading;
236+
237+
// 3. 새로 로드
238+
const loadPromise = this.loadAudioAsset(asset);
239+
this.loadingPromises.set(asset.tag_name || "", loadPromise);
240+
241+
try {
242+
const loaded = await loadPromise;
243+
// 4. 캐시에 저장
244+
this.audioCache.set(asset.tag_name || "", loaded);
245+
return loaded;
246+
} finally {
247+
// 5. 로딩 목록에서 제거
248+
this.loadingPromises.delete(asset.tag_name || "");
249+
}
250+
}
251+
252+
// 왜 이렇게 복잡하게?
253+
// → 동시에 같은 파일 요청 시 한 번만 로드하기 위해
254+
private loadAudioAsset(asset: AudioAsset): Promise<LoadedAudio> {
255+
return new Promise((resolve, reject) => {
256+
const howl = new Howl({
257+
src: [asset.file_url!],
258+
format: this.getAudioFormat(asset.file_mime),
259+
preload: true,
260+
onload: () => {
261+
resolve({
262+
howl,
263+
asset,
264+
loadedAt: new Date(),
265+
});
266+
},
267+
onloaderror: (id, error) => {
268+
reject(new Error(`Failed to load audio ${asset.tag_name}: ${error}`));
269+
},
270+
});
271+
});
272+
}
273+
// 왜 private?
274+
// → 외부에서는 preloadAudio를 사용해야 캐시 시스템이 작동
275+
// → 직접 호출하면 캐시 우회 = 중복 로드 발생
276+
277+
private getAudioFormat(mime: string | null): string[] | undefined {
278+
if (!mime) return undefined;
279+
280+
const formatMap: Record<string, string> = {
281+
"audio/mpeg": "mp3",
282+
"audio/mp3": "mp3",
283+
"audio/ogg": "ogg",
284+
"audio/wav": "wav",
285+
"audio/webm": "webm",
286+
"audio/mp4": "mp4",
287+
"audio/aac": "aac",
288+
};
289+
290+
const format = formatMap[mime];
291+
return format ? [format] : undefined;
292+
}
293+
294+
// 왜 필요한가?
295+
// → Howler.js가 파일 확장자를 못 알아볼 때 명시적으로 알려줌
296+
// → DB에서 mime type으로 저장하기 때문
297+
...
298+
}
299+
300+
```
301+
302+
넘겨받을 에셋의 타입을 정의하고 해당 정보를 등록한다. 이때 존재한다면 이미 존재하는걸 반환해야 한다.
303+
304+
로직의 큰 구조는 먼저 캐시 혹은 로딩중인것 즉 메모리를 먼저 확인하여 현재 작업해야하는 에셋과 비교한 이후에 있다면 현재 저장된 데이터를 반환하고 그렇지 않다면 `loadAudioAsset`을 활용해 `howl` 객체를 만든다. 해당 객체가 load 되면 `onload`메소드를 통해 Promise의 결과값을 위한 `resolve({howl,asset,loadedAt})`을 반환한다.
305+
306+
### 제어 기능들
307+
308+
```typescript
309+
// 정지 - ID가 있으면 특정 재생만, 없으면 전체
310+
stopAudio(tagName: string, id?: number): void {
311+
const loaded = this.audioCache.get(tagName);
312+
if (loaded) {
313+
loaded.howl.stop(id);
314+
}
315+
}
316+
317+
// 일시정지 - 게임 일시정지, 탭 전환 시 사용
318+
pauseAudio(tagName: string, id?: number): void {
319+
const loaded = this.audioCache.get(tagName);
320+
if (loaded) loaded.howl.pause(id);
321+
}
322+
323+
// 재개 - 일시정지한 오디오 계속 재생
324+
resumeAudio(tagName: string, id?: number): void {
325+
const loaded = this.audioCache.get(tagName);
326+
if (loaded) {
327+
loaded.howl.play(id); // play에 ID 전달하면 재개
328+
}
329+
}
330+
331+
// 페이드 - 부드러운 볼륨 전환
332+
fadeAudio(
333+
tagName: string,
334+
from: number, // 시작 볼륨 (0~1)
335+
to: number, // 끝 볼륨 (0~1)
336+
duration: number,// 시간 (밀리초)
337+
id?: number
338+
): void {
339+
const loaded = this.audioCache.get(tagName);
340+
if (loaded) {
341+
loaded.howl.fade(from, to, duration, id);
342+
}
343+
}
344+
```
345+
346+
이제 기본적인 제어에 대한 구현은 완료 되었다. 실제로 이 모듈이 어떻게 사용될지는 구조를 조금 더 살펴보면서 정해야 할것으로 보인다.

public/link-map.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"1.project/당근마켓 클론코딩/당근마켓 클론코딩으로 Zod 배우기.md": "프로젝트/당근마켓 클론코딩/당근마켓 클론코딩으로 Zod 배우기",
5757
"1.project/당근마켓 클론코딩/당근마켓 클론코딩.md": "프로젝트/당근마켓 클론코딩/당근마켓 클론코딩",
5858
"1.project/당근마켓 클론코딩/당근마켓 클론코딩으로 Next.js 복습하기.md": "프로젝트/당근마켓 클론코딩/당근마켓 클론코딩으로 Next.js 복습하기",
59+
"1.project/괴담집/괴담집 프로젝트에서 Howler.js 사용하기.md": "프로젝트/괴담집/괴담집 프로젝트에서 Howler.js 사용하기",
5960
"1.project/괴담집/괴담집 프로젝트에 Prisma와 Supabase 설정하기.md": "프로젝트/괴담집/괴담집 프로젝트에 Prisma와 Supabase 설정하기",
6061
"1.project/괴담집/괴담집 프로젝트 소개.md": "프로젝트/괴담집/괴담집 프로젝트 소개",
6162
"1.project/괴담집/괴담집 이야기 생성 워크플로우 구축.md": "프로젝트/괴담집/괴담집 이야기 생성 워크플로우 구축",

public/meta-data.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,6 +626,17 @@
626626
"modifiedAt": "2025-07-04 14:41:20",
627627
"publish": "프로젝트/당근마켓 클론코딩"
628628
},
629+
{
630+
"urlPath": "프로젝트/괴담집/괴담집 프로젝트에서 Howler.js 사용하기",
631+
"title": "괴담집 프로젝트에서 Howler.js 사용하기",
632+
"summary": "",
633+
"image": "",
634+
"tags": ["project", "goedamjip", "howler"],
635+
"series": "",
636+
"createdAt": "2025-07-08 08:36:34",
637+
"modifiedAt": "2025-07-09 13:15:36",
638+
"publish": "프로젝트/괴담집"
639+
},
629640
{
630641
"urlPath": "프로젝트/괴담집/괴담집 프로젝트에 Prisma와 Supabase 설정하기",
631642
"title": "괴담집 프로젝트에 Prisma와 Supabase 설정하기",

0 commit comments

Comments
 (0)