카테고리 없음

SoundManagerSubsystem 개발 일지

Tsukino Ren 2026. 5. 20. 16:51

날씨 효과음, 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 로 바인딩하려 했는데 컴파일 오류가 났다.

OnAudioFinishedDECLARE_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 이후 정확히 해당 이름만 제거하는 방식으로 개선할 예정이다.