|
| 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 | +이제 기본적인 제어에 대한 구현은 완료 되었다. 실제로 이 모듈이 어떻게 사용될지는 구조를 조금 더 살펴보면서 정해야 할것으로 보인다. |
0 commit comments