날씨 효과음, UI 클릭음, 음성 안내 등 다양한 사운드를 한 곳에서 관리하는 서브시스템을 직접 구현했다.
왜 만들었나
프로젝트에서 사운드 재생이 여러 곳에 흩어져 있었다. 날씨가 바뀔 때 비 소리, 바람 소리를 동시에 제어해야 하고, 음성 안내는 새 안내가 오면 이전 것을 끊어야 하며, UI 클릭음은 그냥 단발성으로 쏘면 된다. 이걸 PlaySound2D 하나로 다 처리하다 보니 중복 재생, 볼륨 관리 불가 등의 문제가 생겼다.
그래서 SoundManagerSubsystem 을 만들어 카테고리별로 관리하는 구조를 잡았다.
왜 GameInstanceSubsystem인가
처음엔 레벨에 Actor를 배치해서 관리하려 했는데, 레벨 전환 시 Actor가 소멸되면서 재생 중이던 환경음이 끊기는 문제가 생겼다.
| 방식 | 문제 |
|---|---|
| AActor 배치 | 레벨 전환 시 소멸 |
| Singleton 패턴 | 언리얼 GC와 충돌 위험 |
| GameInstanceSubsystem | 게임 인스턴스와 생명주기 동일, 레벨 전환에도 유지 |
UGameInstanceSubsystem 은 게임 실행 내내 살아있고 어디서든 GetSubsystem<>() 으로 접근 가능해서 사운드 매니저에 가장 적합했다.
생성자 대신 Initialize 를 쓴 이유는, 생성자는 CDO 생성 시에도 호출되지만 Initialize 는 실제 게임 시작 시에만 호출되어 GetWorld() 같은 컨텍스트를 안전하게 사용할 수 있기 때문이다.
카테고리 설계
소리의 성격이 다 달라서 카테고리를 3가지로 나눴다.
| 카테고리 | 특성 | 관리 방식 |
|---|---|---|
| Ambient (환경음) | 루프, 동시에 여러 개 가능 | TMap<FName, UAudioComponent*> |
| Voice (음성 안내) | 한 번에 하나, 새 음성이 오면 이전 것 중단 | 단일 UAudioComponent* |
| System (UI/알람) | 단발성, 이후 제어 불필요 | PlaySound2D 직접 호출 |
Ambient를 TMap 으로 관리하는 이유는, FName("Rain") / FName("Wind") 처럼 이름으로 각 환경음을 독립적으로 켜고 끌 수 있어야 하기 때문이다. 배열이었다면 매번 순회해야 하지만 맵은 O(1)으로 바로 찾아낸다.
Voice에 PlaySound2D 대신 SpawnSound2D 를 쓰는 이유는, PlaySound2D 는 컴포넌트를 반환하지 않아서 재생 중인지 확인하거나 중간에 끊을 수가 없기 때문이다.
발견한 버그들
버그 1 — 볼륨 중복 곱셈
// 문제 코드
UAudioComponent* AudioComp = UGameplayStatics::SpawnSound2D(GetWorld(), Sound, MasterVolume);
AudioComp->FadeIn(FadeTime, MasterVolume); // 볼륨이 제곱으로 적용됨
SpawnSound2D 의 세 번째 인자가 VolumeMultiplier 인데, FadeIn 의 두 번째 인자(목표 볼륨)에도 같은 값을 넣으면 곱해져서 마스터 볼륨이 0.5일 때 실제로는 0.25가 된다.
// 수정 코드
UAudioComponent* AudioComp = UGameplayStatics::SpawnSound2D(GetWorld(), Sound, 1.0f);
AudioComp->SetVolumeMultiplier(GetEffectiveAmbientVolume());
AudioComp->FadeIn(FadeTime, 1.0f); // 정규화된 목표값
1.0f 로 스폰 후 SetVolumeMultiplier 한 곳에서만 볼륨을 관리하면 SetMasterVolume 호출 시에도 일관되게 반영된다.
버그 2 — FadeOut 도중 즉시 Remove
// 문제 코드
AmbientComponents[Name]->FadeOut(FadeTime, 0.0f);
AmbientComponents.Remove(Name); // 페이드 중인데 맵에서 삭제
FadeOut 은 비동기라 시간이 걸리는데, 즉시 맵에서 제거하면 페이드가 끝나기 전에 같은 이름으로 PlayAmbientSound 를 호출했을 때 중복 재생이 발생한다.
페이드 완료 콜백(OnAudioFinished)으로 제거하도록 수정했다.
Dynamic Delegate 문제
처음에 OnAudioFinished.AddLambda 로 바인딩하려 했는데 컴파일 오류가 났다.
OnAudioFinished 는 DECLARE_DYNAMIC_MULTICAST_DELEGATE 로 선언된 Dynamic Delegate라서 AddLambda 를 지원하지 않는다. UFUNCTION() 이 붙은 멤버 함수만 AddDynamic 으로 바인딩할 수 있다.
그런데 콜백 시그니처에 파라미터가 없어서 어떤 컴포넌트가 끝났는지 알 수 없는 문제도 있었다. PendingRemoveNames 배열에 FadeOut 대기 중인 이름을 순서대로 쌓아두고, 콜백이 올 때마다 앞에서부터 꺼내 제거하는 방식으로 해결했다.
볼륨 계층 구조
마스터 볼륨과 카테고리별 볼륨을 독립적으로 조절할 수 있도록 설계했다.
실제 볼륨 = MasterVolume × CategoryVolume
예를 들어 마스터를 0.8, 환경음을 0.5로 설정하면 실제 환경음 볼륨은 0.4가 된다. 게임 설정 화면에서 "전체 볼륨" 슬라이더와 "효과음 볼륨" 슬라이더를 독립적으로 제공할 수 있는 구조다.
최종 코드
SoundManagerSubsystem.h
#pragma once
#include "CoreMinimal.h"
#include "Sound/SoundBase.h"
#include "Components/AudioComponent.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "SoundManagerSubsystem.generated.h"
UCLASS()
class ANTARCITCKIDS_API USoundManagerSubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
virtual void Deinitialize() override;
UFUNCTION(BlueprintCallable, Category = "Sound|Ambient")
void PlayAmbientSound(FName Name, USoundBase* Sound, float FadeTime = 1.0f);
UFUNCTION(BlueprintCallable, Category = "Sound|Ambient")
void StopAmbientSound(FName Name, float FadeTime = 1.0f);
UFUNCTION(BlueprintCallable, Category = "Sound|Ambient")
void StopAllAmbientSounds(float FadeTime = 1.0f);
UFUNCTION(BlueprintCallable, Category = "Sound|UI")
void PlaySystemSound(USoundBase* Sound, float Volume = 1.0f);
UFUNCTION(BlueprintCallable, Category = "Sound|Voice")
void PlayVoiceGuidance(USoundBase* VoiceSound);
UFUNCTION(BlueprintCallable, Category = "Sound|Voice")
void StopVoiceGuidance(float FadeTime = 0.3f);
UFUNCTION(BlueprintCallable, Category = "Sound|World")
void PlaySoundAtLocation(USoundBase* Sound, FVector Location, float Volume = 1.0f);
UFUNCTION(BlueprintCallable, Category = "Sound")
void SetMasterVolume(float Volume);
UFUNCTION(BlueprintCallable, Category = "Sound")
void SetAmbientVolume(float Volume);
UFUNCTION(BlueprintCallable, Category = "Sound")
void SetVoiceVolume(float Volume);
UFUNCTION(BlueprintCallable, Category = "Sound")
void SetSystemVolume(float Volume);
UFUNCTION(BlueprintCallable, Category = "Sound")
float GetMasterVolume() const { return MasterVolume; }
private:
UPROPERTY()
TMap<FName, TObjectPtr<UAudioComponent>> AmbientComponents;
TArray<FName> PendingRemoveNames;
UPROPERTY()
TObjectPtr<UAudioComponent> VoiceComponent;
float MasterVolume = 1.0f;
float AmbientVolume = 1.0f;
float VoiceVolume = 1.0f;
float SystemVolume = 1.0f;
float GetEffectiveAmbientVolume() const { return MasterVolume * AmbientVolume; }
float GetEffectiveVoiceVolume() const { return MasterVolume * VoiceVolume; }
float GetEffectiveSystemVolume() const { return MasterVolume * SystemVolume; }
UFUNCTION()
void OnAmbientFadeOutFinished();
void CleanupFinishedAmbientComponents();
};
SoundManagerSubsystem.cpp
#include "Manager/SoundManagerSubsystem.h"
#include "Kismet/GameplayStatics.h"
void USoundManagerSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
MasterVolume = AmbientVolume = VoiceVolume = SystemVolume = 1.0f;
}
void USoundManagerSubsystem::Deinitialize()
{
StopAllAmbientSounds(0.0f);
StopVoiceGuidance(0.0f);
Super::Deinitialize();
}
void USoundManagerSubsystem::PlayAmbientSound(FName Name, USoundBase* Sound, float FadeTime)
{
if (!Sound) return;
if (AmbientComponents.Contains(Name))
{
UAudioComponent* Existing = AmbientComponents[Name];
if (Existing && Existing->IsPlaying()) return;
AmbientComponents.Remove(Name);
}
UAudioComponent* AudioComp = UGameplayStatics::SpawnSound2D(GetWorld(), Sound, 1.0f);
if (!AudioComp) return;
AudioComp->SetVolumeMultiplier(GetEffectiveAmbientVolume());
AudioComp->FadeIn(FadeTime, 1.0f);
AmbientComponents.Add(Name, AudioComp);
}
void USoundManagerSubsystem::StopAmbientSound(FName Name, float FadeTime)
{
if (!AmbientComponents.Contains(Name)) return;
UAudioComponent* Comp = AmbientComponents[Name];
if (Comp)
{
if (FadeTime > 0.0f)
{
PendingRemoveNames.AddUnique(Name);
Comp->OnAudioFinished.AddDynamic(this, &USoundManagerSubsystem::OnAmbientFadeOutFinished);
Comp->FadeOut(FadeTime, 0.0f);
}
else
{
Comp->Stop();
AmbientComponents.Remove(Name);
}
}
else
{
AmbientComponents.Remove(Name);
}
}
void USoundManagerSubsystem::OnAmbientFadeOutFinished()
{
if (PendingRemoveNames.Num() > 0)
{
FName Name = PendingRemoveNames[0];
PendingRemoveNames.RemoveAt(0);
AmbientComponents.Remove(Name);
}
}
void USoundManagerSubsystem::StopAllAmbientSounds(float FadeTime)
{
TArray<FName> Names;
AmbientComponents.GetKeys(Names);
for (const FName& Name : Names)
StopAmbientSound(Name, FadeTime);
}
void USoundManagerSubsystem::PlaySystemSound(USoundBase* Sound, float Volume)
{
if (Sound) UGameplayStatics::PlaySound2D(GetWorld(), Sound, Volume * GetEffectiveSystemVolume());
}
void USoundManagerSubsystem::PlayVoiceGuidance(USoundBase* VoiceSound)
{
if (!VoiceSound) return;
if (VoiceComponent && VoiceComponent->IsPlaying()) VoiceComponent->Stop();
VoiceComponent = UGameplayStatics::SpawnSound2D(GetWorld(), VoiceSound, 1.0f);
if (VoiceComponent) VoiceComponent->SetVolumeMultiplier(GetEffectiveVoiceVolume());
}
void USoundManagerSubsystem::StopVoiceGuidance(float FadeTime)
{
if (!VoiceComponent) return;
FadeTime > 0.0f ? VoiceComponent->FadeOut(FadeTime, 0.0f) : VoiceComponent->Stop();
VoiceComponent = nullptr;
}
void USoundManagerSubsystem::PlaySoundAtLocation(USoundBase* Sound, FVector Location, float Volume)
{
if (Sound) UGameplayStatics::PlaySoundAtLocation(GetWorld(), Sound, Location, Volume * MasterVolume);
}
void USoundManagerSubsystem::SetMasterVolume(float Volume)
{
MasterVolume = FMath::Clamp(Volume, 0.0f, 1.0f);
for (auto& Pair : AmbientComponents)
if (Pair.Value) Pair.Value->SetVolumeMultiplier(GetEffectiveAmbientVolume());
if (VoiceComponent) VoiceComponent->SetVolumeMultiplier(GetEffectiveVoiceVolume());
}
void USoundManagerSubsystem::SetAmbientVolume(float Volume)
{
AmbientVolume = FMath::Clamp(Volume, 0.0f, 1.0f);
for (auto& Pair : AmbientComponents)
if (Pair.Value) Pair.Value->SetVolumeMultiplier(GetEffectiveAmbientVolume());
}
void USoundManagerSubsystem::SetVoiceVolume(float Volume)
{
VoiceVolume = FMath::Clamp(Volume, 0.0f, 1.0f);
if (VoiceComponent) VoiceComponent->SetVolumeMultiplier(GetEffectiveVoiceVolume());
}
void USoundManagerSubsystem::SetSystemVolume(float Volume)
{
SystemVolume = FMath::Clamp(Volume, 0.0f, 1.0f);
}
사용법 예시 (블루프린트)
배경음 사용 시

버튼 클릭

마무리
설계 결정에 대해
처음엔 그냥 PlaySound2D 를 여기저기 쓰면 된다고 생각했는데, 막상 환경음 중첩 제어와 볼륨 관리가 필요해지니까 한계가 바로 왔다. 기능 하나하나는 단순해 보여도 관리 포인트가 늘어나면 결국 중앙에서 모아야 한다는 걸 다시 실감했다.
GameInstanceSubsystem 을 선택한 건 잘한 것 같다. 레벨 전환에도 상태가 유지되고, 싱글턴처럼 전역 접근이 되면서도 언리얼 GC 안에서 안전하게 관리된다. 직접 싱글턴 패턴 짜는 것보다 훨씬 깔끔했다.
버그를 통해 배운 것들
볼륨 중복 곱셈 버그는 API 문서를 꼼꼼히 안 읽어서 생긴 문제였다. SpawnSound2D 의 세 번째 인자가 VolumeMultiplier 라는 걸 알면서도 FadeIn 목표 볼륨과 역할이 다르다는 걸 간과했다. 숫자가 의도한 대로 안 나올 때 곱셈 구조부터 확인하는 습관을 들여야겠다.
FadeOut 후 즉시 Remove 하는 버그는 비동기 동작을 동기처럼 착각해서 생겼다. 언리얼에서 페이드 계열 함수는 내부적으로 타이머로 처리되기 때문에, 호출 직후 상태가 바뀐다고 가정하면 안 된다.
Dynamic Delegate는 Lambda가 안 된다
이게 제일 시간을 잡아먹었다. DECLARE_DYNAMIC_MULTICAST_DELEGATE 로 선언된 델리게이트는 AddLambda 가 안 되고 반드시 UFUNCTION() 멤버 함수를 AddDynamic 으로 바인딩해야 한다. 언리얼에서 델리게이트 종류(Dynamic / Non-Dynamic / Multicast)에 따라 바인딩 방식이 달라진다는 걸 이번에 제대로 정리하게 됐다.
앞으로 개선하고 싶은 것
PendingRemoveNames 로 FadeOut 완료를 추적하는 방식은 동시에 여러 Ambient를 FadeOut할 때 순서가 보장되지 않으면 이름이 엇갈릴 수 있다는 한계가 있다. 나중에 여유가 생기면 각 컴포넌트마다 타이머를 붙여서 FadeTime 이후 정확히 해당 이름만 제거하는 방식으로 개선할 예정이다.