FFmpeg Loudnorm 필터를 이용한 볼륨 평준화 기능

도입

지난 글에서 소개한 MusicBot(https://pyxispub.uzuki.live/?p=1648)의 기능 중에, 볼륨 평준화 기능이 있다.

해당 기능은 volumedetect 필터를 사용해서 max_volume를 추출해서 해당 max_volume에 -1를 곱한 수치를 volume=%sdB 로 설정하는 방식으로 구현되어 있다.

VolumeDetect 필터를 사용하면 다음과 같은 정보를 가지고 올 수 있다.

n_samples: 4845242
mean_volume: -25.8 dB
max_volume: -8.6 dB
histogram_9db: 245
histogram_10db: 4214
histogram_11db: 12522

하지만 volume 필터는 음원에 있는 샘플의 볼륨을 지정한 값 만큼 변환하는 작업을 하기 때문에, 음원의 왜곡이 거의 없으나 클리핑(0dBFS를 넘는 오디오 샘플을 재생하려 할 때 의도되로 재생되지 않는 현상) 이 일어남에 따라 실제 사용하기에는 치명적인 문제점이 존재한다.

따라서 FFmpeg의 Loudnorm(https://ffmpeg.org/ffmpeg-filters.html#loudnorm) 필터를 사용하여 이를 보정하기로 했고, Python으로 구현한 결과를 간단히 정리하려 한다.

Loudnorm 필터의 장점과 단점

앞서 volumedetect 필터는 임계점을 넘어 클리핑이 발생한다고 언급했는데, Loudnorm 필터는 이 임계점을 넘지 않게 하는 True Peak Limiter 를 내장하고 있기 때문에 클리핑이 발생할 수 없게 된다.

또한, 인지 음량(perceived loudness, 사람이 소리를 들을 때 주파수에 따라 인지하는 음량에 차이가 있는 것을 설명)을 고려하기 때문에, 단순히 dBFS 를 기준으로 보정을 가할 때 보다 사람의 귀에 들리는 소리의 크기에 맞춰서 정확하게 측정할 수 있다.

다만 인코딩할 때 시간이 걸리고, 부분부분 적절하게 조절하는 것이 아니고 전체적으로 목표 수치에 맞춰서 조절하는 것이므로 이에 수반하는 다양한 현상 (소리 크기가 왕복하거나, 잡음이 크게 들린다던가, 배경 소리가 크게 들린다던가 등)이 나타나게 되는데, 이는 오디오 자체를 변형시키는 것이기 때문에 왜곡은 당연한 것이라고 봐야 한다.

적용 방법

먼저, single pass 인코딩을 구동하여 인풋된 볼륨 정보를 찾는다. 여기서는 파일을 저장할 필요가 없으므로 -f null 를 통하여 아웃풋을 저장하지 않도록 한다.

ffmpeg -i {input_File} -af loudnorm=I=-16:TP=-1.5:LRA=11:print_format=summary -f null /dev/null

이 결과로, 아래와 같은 텍스트를 얻을 수 있다.

[Parsed_loudnorm_0 @ 00a4e800] 
Input Integrated:   -11.8 LUFS
Input True Peak:     +0.5 dBTP
Input LRA:             7.8 LU
Input Threshold:     -21.9 LUFS

Output Integrated:   -16.0 LUFS
Output True Peak:     -1.5 dBTP
Output LRA:           7.0 LU
Output Threshold:   -26.0 LUFS

Normalization Type:   Dynamic
Target Offset:       -0.0 LU

여기에서 주목해야 되는 값은 Input Integrated, Input True Peak, Input LRA, Input Threshold. Target Offset 이다. 각각 평균 볼륨, 최대 임계점, 볼륨 범위, 임계값, gain offset 를 나타낸다.

이 정보를 반영해서 다시 한번 인코딩을 하면 더 정확하게 나오는데, 이 방법이 dual pass이다.

ffmpeg -i {input_FILE} -af loudnorm=I=-16:TP=-1.5:LRA=11:measured_I=-11.8:measured_TP=0.5:measured_LRA=7.8:measured_thresh=-21.9:offset=0:linear=true::print_format=summary...

Input Integrated 값을 measured_I에, Input True Peak를 measured_TP, Input LRA를 measured_LRA, Input Threshold를 measured_thresh, Target Offset를 offset에 대응시켜서 명령어를 구동하면 된다.

실행한 결과는 다음과 같다.

Input Integrated:    -11.8 LUFS
Input True Peak:     +0.4 dBTP
Input LRA:             7.9 LU
Input Threshold:     -21.8 LUFS

Output Integrated:   -16.0 LUFS
Output True Peak:     -3.8 dBTP
Output LRA:           7.9 LU
Output Threshold:   -26.0 LUFS

Normalization Type:   Linear
Target Offset:       -0.0 LU

음원에 따라 다르겠지만, 사용한 음원의 경우 single pass와 double pass의 목표값(Output Integrated) 는 같게 나왔다. 의도와는 살짝 다르지만 loudnorm 필터는 완벽하게 인코딩을 하는 것이 아닌, 최대한 의도에 맞게 인코딩한 결과를 보장한다고 볼 수 있다.

음원 비교

사용한 음원은 Unminus 의 Lit(https://soundcloud.com/wowamusik/lit-120-bpm-latino-party-wwwwowame?in=wowamusik/sets/free-do-whatever-you-want) 음원이고, 보정한 결과는 다음과 같다.

마무리

loudnorm 필터를 실제 음악 봇에 적용하고 올리고 테스트를 몇 번 거친 결과, 시간은 다소 오래 걸릴지 몰라도 계속 volume를 부분적으로 적용하던 불편함은 없어졌다고 한다.

마지막으로, 구현에 사용한 코드는 다음과 같다.

async def get_loudnorm_argument(self, input_file):
    log.debug('Calculating loudnorm argument of {0}'.format(input_file))
    cmd = '"' + self.get(
        'ffmpeg') + '" -i "' + input_file + '" -af "loudnorm=I=-16:TP=-1.5:LRA=11:print_format=summary" -f null /dev/null'
    output = await self.run_command(cmd)
    output = output.decode("utf-8")

    input_integrated_matches = re.findall("Input Integrated:[ ]*([\-+\d\.]+) LUFS", output)
    input_tree_peak_matches = re.findall("Input True Peak:[ ]*([\-+\d\.]+) dBTP", output)
    input_lra_matches = re.findall("Input LRA:[ ]*([\-+\d\.]+) LU", output)
    input_threshold_matches = re.findall("Input Threshold:[ ]*([\-+\d\.]+) LUFS", output)
    offset_matches = re.findall("Target Offset:[ ]*([\-+\d\.]+) LU", output)

    log.debug(output)

    if input_integrated_matches:
        integrated = float(input_integrated_matches[0])
    else:
        integrated = float(0)

    if input_tree_peak_matches:
        tp = float(input_tree_peak_matches[0])
    else:
        tp = float(0)

    if input_lra_matches:
        lra = float(input_lra_matches[0])
    else:
        lra = float(0)

    if input_threshold_matches:
        threshold = float(input_threshold_matches[0])
    else:
        threshold = float(0)

    if offset_matches:
        offset = float(offset_matches[0])
    else:
        offset = float(0)

    argument = 'loudnorm=I=-16:TP=-1.5:LRA=11:measured_I={0}:measured_TP={1}:measured_LRA={2}:measured_thresh={3}:offset={4}:linear=true:print_format=json' \
            .format(integrated, tp, lra, threshold, offset)

    operate = integrated != float(0) or tp != float(0) or lra != float(0) \
              or threshold != float(0) or offset != float(0)

    log.debug('Calculate complete! argument = {0}, operate = {1}'.format(argument, operate))
    return argument, operate

해당 음악 봇에서는 discords.py로 음원을 전송할 때 ffmpeg로 바로 실행하므로, 여기서는 loudnorm 필터 안에 들어갈 것만 판단하고 보내주는 역할을 한다.

디스코드 음악봇, MusicBot를 Docker로 올리기

디스코드 음악봇, MusicBot를 Docker로 올리기

고향 동창과 디스코드를 애용하고 있는데, 최근까지만 해도 S.A.T.8-Bot라는 어떤 게임의 캐릭터를 모티브로 한 봇을 사용하고 있었다.

다만 언제서부턴가 끊김이 발생하거나 계속 연결 <-> 연결 중지를 반복하는 바람에 사용하지 못하게 되었고, 그 대신 개인 서버를 이용하여 올리기로 했다.

따라서 이 글에서는 https://github.com/Just-Some-Bots/MusicBot 를 올리는 과정에 대해 간략하게 정리하려고 한다.

사양

사양은 Vultr 1Core 1GB (월 $5)로 구성했다. 봇만 굴릴 것이기 때문에 그다지 높은 사양은 필요로 하지 않을 것이다.

OS는 Ubuntu 18.04 기준으로 한다.

최소 구성요소 설치

설치할 요소는 Docker-CE와 Docker-compose이다.

 sudo apt-get update
sudo apt-get install apt-transport-https ca-certificates curl gnupg-agent software-properties-common git
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
sudo apt-key fingerprint 0EBFCD88
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io
sudo curl -L "https://github.com/docker/compose/releases/download/1.24.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose

Git Clone & docker-compose 파일 생성

git clone https://github.com/Just-Some-Bots/MusicBot

Git Clone를 받은 다음, 해당 폴더에 진입하여 docker-compose.yml 파일을 생성한다.

version: '2.4'
services:
musicbot:
  build:
    context: .
    dockerfile: Dockerfile
  restart: always
  volumes:
    - "/home/{USERNAME}/musicbot:/usr/src/musicbot/config"
  container_name: musicbot
  command: -update

이 때, volumes 에서 config 폴더를 설정해주게 되는데 이 경로를 실제로 생성해서 파일들을 수동으로 넣어야 한다.

Git clone 받은 폴더 안에 config 폴더를 복사해서 volumes에 매핑한 경로에 붙여넣기 하면 된다.

즉, /home/{USERNAME}/musicbot 내부에 MusicBot/config 내부에 있는 파일들이 존재하면 된다. (example_alias 나 i18n, example_options 등)

디스코드 봇 정보 가져오기

설정을 본격적으로 하기 전에 두 가지 정보가 필요한데, 하나는 Bot Token이고 하나는 UserId 이다.

Bot Token

https://discordapp.com/developers/applications/ 에 접속하여 New Application 을 눌러 새로운 Application을 작성한다.

좌측의 Bot를 클릭하여 Add Bot를 클릭한다.

아이콘과 유저 이름을 설정하고, 밑의 체크박스 두 개를 모두 해제한다. 마지막으로 Click to Reveal Token을 눌러 토큰을 발급받은 뒤, 메모장 등에 저장한다.

UserId 가져오기

디스코드 채팅방에서 자신의 닉네임을 검색하여 선택하면 @Pyxis#1324 가 메세지 창에 표시되는데, 맨 앞에 \를 붙이고서 전송 버튼을 누른다.

이 때, 채팅창에 <@ 로 시작하는 숫자가 나오게 되는데, 이 id가 user id이다.

설정 파일 작성하기

아까 생성한 musicbot 폴더 내에 example_options.ini를 options.ini로 복사하거나, 아래 내용을 참고해서 options.ini를 설정한다.

# This is the configuration file for MusicBot. You need to edit this file
# to setup the bot. Do not edit this file using Notepad as it ruins the
# formatting - use Notepad++ or a code editor like Visual Studio Code.

# For help, see: https://just-some-bots.github.io/MusicBot/

# To get IDs, enable Developer Mode (Options -> Settings -> Appearance)
# on Discord and then right-click the person/channel you want to get the
# channel of, then click 'Copy ID'. You can also use the 'listids' command.
# (http://i.imgur.com/GhKpBMQ.gif)


; HOW TO GET VARIOUS IDS:
; http://i.imgur.com/GhKpBMQ.gif
; Enable developer mode (options, settings, appearance), right click the object you want the id of, and click Copy ID
; This works for basically everything you would want the id of (channels and users). For roles you have to right click a role mention.


[Credentials]
# This is your Discord bot account token.
# Find your bot's token here: https://discordapp.com/developers/applications/me/
# Create a new application, with no redirect URI or boxes ticked.
# Then click 'Create Bot User' on the application page and copy the token here.
Token = {봇 ID}

# The bot supports converting Spotify links and URIs to YouTube videos and
# playing them. To enable this feature, please fill in these two options with valid
# details, following these instructions: https://just-some-bots.github.io/MusicBot/using/spotify/
Spotify_ClientID =
Spotify_ClientSecret =

[Permissions]
# This option determines which user has full permissions and control of the bot.
# You can only set one owner, but you can use permissions.ini to give other
# users access to more commands.
# Setting this option to 'auto' will set the owner of the bot to the person who
# created the bot application, which is usually what you want. Else, change it
# to another user's ID.
OwnerID = {자신의 User ID}

# This option determines which users have access to developer-only commands.
# Developer only commands are very dangerous and may break your bot if used
# incorrectly, so it's highly recommended that you ignore this option unless you
# are familiar with Python code.
DevIDs =

[Chat]
# Determines the prefix that must be used before commands in the Discord chat.
# e.g if you set this to *, the play command would be triggered using *play.
# 봇을 호출할 명령어 앞에 붙는 접두사 설정. !play 나 !queue 등
CommandPrefix = !

# Restricts the bot to only listening to certain text channels. To use this, add
# the IDs of the text channels you would like the bot to listen to, seperated by
# a space.
BindToChannels =

# Changes the behavior of BindToChannels. Normally any messages sent to a channel not in
# BindToChannels will be ignored. This option allows servers that do not have any bound
# channels while other server have some defined to still use commands in any channel with
# the Music Bot. Setting this to yes when there are no bound channels does nothing.
AllowUnboundServers = no

# Allows the bot to automatically join servers on startup. To use this, add the IDs
# of the voice channels you would like the bot to join on startup, seperated by a
# space. Each server can have one channel. If this option and AutoSummon are
# enabled, this option will take priority.
AutojoinChannels =

# Send direct messages for now playing messages instead of sending them into the guild. They are
# sent to the user who added the media being played. Now playing messages for automatic entries
# are unaffected and follows NowPlayingChannels config. The bot will not delete direct messages.
DMNowPlaying = no

# Disable now playing messages for entries automatically added by the bot, via the autoplaylist.
DisableNowPlayingAutomatic = no

# For now playing messages that are unaffected by DMNowPlaying and DisableNowPlayingAutomatic,
# determine which channels the bot is going to output now playing messages to. If this is not
# specified for a server, now playing message for manually added entries will be sent in the same
# channel that users used the command to add that entry, and now playing messages for automatically
# added entries will be sent to the same channel that the last now playing message was sent to if
# this is not specified for a server if possible. Specifying more than one channel for a server
# forces the bot to pick only one channel from the list to send messages to.
NowPlayingChannels =

# The bot would try to delete (or edit) previously sent now playing messages by default. If you
# don't want the bot to delete them (for keeping a log of what has been played), turn this
# option off.
DeleteNowPlaying = yes

[MusicBot]
# The volume of the bot, between 0.01 and 1.0.
DefaultVolume = 0.25

# Only allows whitelisted users (in whitelist.txt) to use commands.
# WARNING: This option has been deprecated and will be removed in a future version
# of the bot. Use permissions.ini instead.
WhiteListCheck = no

# The number of people voting to skip in order for a song to be skipped successfully,
# whichever value is lower will be used. Ratio refers to the percentage of undefeaned, non-
# owner users in the channel.
# 자신이 올린 곡이 아닌 곡을 스킵하려 할 때 요청할 투표의 갯수
SkipsRequired = 4
SkipRatio = 0.5

# Determines if downloaded videos will be saved to the audio_cache folder. If this is yes,
# they will not be redownloaded if found in the folder and queued again. Else, videos will
# be downloaded to the folder temporarily to play, then deleted after to avoid filling space.
# 비디오의 스토리지 저장. 용량이 신경쓰이면 no로 하는 것이 좋다.
SaveVideos = yes

# Mentions the user who queued a song when it starts to play.
NowPlayingMentions = no

# Automatically joins the owner's voice channel on startup, if possible. The bot must be on
# the same server and have permission to join the channel.
AutoSummon = yes

# Start playing songs from the autoplaylist.txt file after joining a channel. This does not
# stop users from queueing songs, you can do that by restricting command access in permissions.ini.
# example_autoplaylist.txt 나 autoplaylist 안에 있는 재생목록을 자동으로 재생한다.
UseAutoPlaylist = no

# Sets if the autoplaylist should play through songs in a random order when enabled. If no,
# songs will be played in a sequential order instead.
AutoPlaylistRandom = no

# Pause the music when nobody is in a voice channel, until someone joins again.
AutoPause = yes

# Automatically cleanup the bot's messages after a small period of time.
DeleteMessages = yes

# If this and DeleteMessages is enabled, the bot will also try to delete messages from other
# users that called commands. The bot requires the 'Manage Messages' permission for this.
DeleteInvoking = no

# Regularly saves the queue to the disk. If the bot is then shut down, the queue will
# resume from where it left off.
PersistentQueue = no

# Determines what messages are logged to the console. The default level is INFO, which is
# everything an average user would need. Other levels include CRITICAL, ERROR, WARNING,
# DEBUG, VOICEDEBUG, FFMPEG, NOISY, and EVERYTHING. You should only change this if you
# are debugging, or you want the bot to have a quieter console output.
DebugLevel = INFO

# Specify a custom message to use as the bot's status. If left empty, the bot
# will display dynamic info about music currently being played in its status instead.
# 상태 메세지 커스텀
StatusMessage =

# Write what the bot is currently playing to the data/<server id>/current.txt FILE.
# This can then be used with OBS and anything else that takes a dynamic input.
WriteCurrentSong = no

# Allows the person who queued a song to skip their OWN songs instantly, similar to the
# functionality that owners have where they can skip every song instantly.
AllowAuthorSkip = yes

# Enables experimental equalization code. This will cause all songs to sound similar in
# volume at the cost of higher processing consumption when the song is initially being played.
UseExperimentalEqualization = no

# Enables the use of embeds throughout the bot. These are messages that are formatted to
# look cleaner, however they don't appear to users who have link previews disabled in their
# Discord settings.
UseEmbeds = yes

# The amount of items to show when using the queue command.
QueueLength = 10

# Remove songs from the autoplaylist if an error occurred while trying to play them.
# If enabled, unplayable songs will be moved to another file and out of the autoplaylist.
# You may want to disable this if you have internet issues or frequent issues playing songs.
RemoveFromAPOnError = yes

# Whether to show the configuration for the bot in the console when it launches.
ShowConfigOnLaunch = no

# Whether to use leagcy skip behaviour. This will change it so that those with permission
# do not need to use "skip f" to force-skip a song, they will instead force-skip by default.
LegacySkip = no

# Leave servers if the owner is not found in them.
LeaveServersWithoutOwner = no

# Use command alias defined in aliases.json.
UseAlias = yes

[Files]
# Path to your i18n file. Do not set this if you do not know what it does.
i18nFile =

일부 자주 사용하는 옵션의 경우 한글로 코멘트를 달아놓았으나, 자세한 설명은 https://just-some-bots.github.io/MusicBot/using/configuration/ 를 참고한다.

봇 실행

options.ini 까지 만들었으면, 다시 clone 받은 폴더로 들어가서 아래 명령어를 실행한다.

docker-compose up -d --build

이 과정을 통해 docker-compose가 선언된 Dockerfile를 가지고 이미지를 만들 것이고, 이 이미지를 사용하여 컨테이너를 올릴 것이다.

이 후, 로그를 보면 어떤 채널과도 연결되지 않았습니다. 아래 url로 채널에 가입하세요 라는 메세지가 나오게 된다.

해당 url를 브라우저에 복사하면 해당 봇을 디스코드 채널 등에 추가할 수 있다.

차후에 이 url를 다시 보기 위해서는 채널에서 !joinserver 를 발동하면 된다.

몇 가지 기능 소개

  • !play {URL 또는 검색어} : 유투브 (또는 다른 소스) 에서 해당 링크 또는 검색했을 때 나오는 첫 번째 항목을 재생한다
  • !search [불러올 갯수] {검색어} : 유투브 (또는 다른 소스) 에서 검색했을 때 나오는 n개의 리스트를 불러오고, 사용자가 재생할 음악을 선택하게 한다. 불러올 갯수는 지정하지 않으면 3이 기본이다.
  • !np : 현재 재생중인 음악의 이름, 요청한 사람, 재생바, 재생 시간 등을 표시한다.
  • !queue : 재생할 음악들의 대기열을 보여준다. options.ini에서 표시할 대기열의 숫자를 지정할 수 있다.
  • !skip: 현재 재생중인 음악을 스킵한다.
  • !volume [숫자] : 볼륨을 조정할 수 있다. +10 등으로 상대적으로 키울 수 있으며, 숫자를 입력하지 않을 경우 현재 볼륨을 출력한다.
  • !shufle : 대기열을 셔플한다.
  • !clear: 대기열을 초기화한다.
  • !pause / !resume : 현재 음악을 일시중지 / 재개한다.
  • !remove [숫자] : (어드민용 기능) 해당 위치에 있는 queue를 제거한다.
  • !save : 현재 대기열을 autoplaylist에 저장한다.

나머지 기능들에 대해서는 https://just-some-bots.github.io/MusicBot/using/commands/를 참조하면 된다.

UI 상태 저장하기

이 글은 Android Developers 사이트의 Saving UI States 의 한국어 번역본입니다. 아주 많은 의역과 오역이 들어갈 수 있으므로, 만일 틀린 부분이 있다면 댓글로 지적 부탁드립니다.


시스템에서 시작한 Activity나 애플리케이션이 파괴될 때 마다 Activity의 UI의 상태를 적시에 보존하고 복원하는 것은 사용자 경험(User Experience) 에 있어서 중요한 부분입니다. 이러한 경우, 사용자는 UI 상태가 그대로 유지될 것이라고 예상하지만 시스템은 Activity와 Activity에 보관된 모든 상태를 파괴합니다.

사용자 기대와 시스템 동작 간의 차이를 메꾸기 위해 ViewModel 객체, onSaveInstanceState() 메서드 및 로컬 저장소를 조합하여 사용해서 이러한 애플리케이션 및 Activity 인스턴스의 전환에 있어 UI 상태를 유지할 수 있습니다. 이러한 옵션(ViewModel, onSavedInstanceState, 로컬 저장소)들을 결합하는 방법을 결정하는 데에는 UI 상태 데이터의 복잡성, 앱의 사용 사례 및 검색 속도, 메모리 사용 속도 측면에서 비교하고 결정하게 됩니다.

어떤 방법을 사용하든 관계 없이 앱이 UI 상태와 관련하여 사용자의 기대치를 충족하는지 확인하고 원할한 UI를 제공해야 합니다. (특히 화면 회전과 같이 자주 발생하는 설정의 변경(Configuration Changes) 후에 데이터를 다시 불러오는 데에 걸리는 시간을 피해야 합니다.) 대부분의 경우, ViewModel와 onSaveInstanceState를 모두 사용해야 합니다.

이 페이지에서는 UI 상태를 보존하는 데에 사용할 수 있는 옵션, 각각의 절충점 및 제한 사항을 소개합니다.

사용자가 기대하는 것과 시스템의 동작

사용자가 취하는 행동에 따라 사용자는 해당 Activity의 상태가 지워지거나 상태가 보존되기를 기대합니다. 경우에 따라 시스템은 사용자가 예상하는 작업을 자동으로 수행하는 반면, 다른 경우에는 시스템이 사용자가 기대하는 것과 반대되는 행동을 할 수 있습니다.

사용자에 의한 UI 상태 제거

사용자는 Activity를 시작할 때에 완전히 제거할 때 까지 해당 Activity의 UI 상태가 동일하게 유지될 것으로 예상합니다. 완전히 제거하는 예는 다음과 같습니다.

  • 뒤로 버튼 누르기
  • 최근 내역(Recents) 에서 끌어내리기
  • Navigation의 Up 활동
  • 애플리케이션 설정 화면에서 강제종료
  • 완료 화면 등에서의 화면 종료 (다시 말하면 Activity.finish())

이러한 완전 제거에 대해서는 사용자가 이 Activity를 완전히 종료하고, 다시 생성시켰을 때 깨끗한 상태에서 시작될 것으로 기대한다는 것입니다. 이러한 시나리오에 대한 기본 시스템 동작은 사용자의 기대 사항과 일치합니다. 즉, Activity의 인스턴스Activity에 저장된 모든 상태Activity와 관련된 상태 레코드삭제되고 메모리에서 제거되는 것을 의미합니다.

완전 제거 규칙에는 몇 가지 예외가 있습니다. 예를 들어, 뒤로 버튼을 누른다고 해도 브라우저 같은 앱에서는 Activity가 종료되는 것이 아닌 전 페이지를 보여줍니다.

시스템에 의한 UI 상태 제거

사용자는 화면 회전 또는 멀티태스킹 모드 전환으로 같은 설정의 변경(Configuration Changes) 에서 Activity의 UI 상태가 동일하게 유지될 것으로 기대합니다. 그러나 기본적으로 시스템은 이러한 설정의 변경이 일어날 경우 Activity의 UI 상태를 제거하고 Activity를 제거합니다. 설정의 변경에 대해서는 설정 참조 페이지 를 참조하세요. 참고로, 설정의 변경에 대해 기본 동작을 재정의하는 것은 권장되지는 않지만 가능합니다. 자세한 내용은 설정의 변경에 대한 재정의 를 참조하세요.

또한 사용자는 임시로 다른 앱으로 전환한 다음에 앱으로 돌아올 경우 Activity의 UI 상태가 동일하게 유지될 것으로 기대합니다. 예를 들어 사용자는 검색 Activity에서 검색을 수행한 다음 홈 버튼을 누르거나 전화 통화에 응답합니다. 다시 검색 Activity에 돌아가면 이전과 마찬가지로 검색 키워드와 그에 맞는 결과가 보여질 것으로 기대합니다.

이 시나리오에서는 앱이 백그라운드에서 실행되며 시스템이 앱 프로세스를 메모리에 저장하기 위해 최선을 다합니다. 그러나 시스템은 사용자가 앱과 상호 작용하지 않는 동안 애플리케이션 프로세스를 파괴할 수 있습니다. 이러한 경우 Activity 인스턴스는 해당 Activity에 저장된 모든 상태와 함께 제거됩니다. 사용자가 앱을 다시 실행하면 예기치 않게도 깨끗한 상태가 됩니다. 프로세스 중단에 대한 자세한 내용은 프로세스 및 애플리케이션 라이프사이클 주기를 참조하세요.

UI 상태 보존 옵션

UI 상태에 대한 사용자의 기대가 기본 시스템 동작과 일치하지 않는 경우 시스템 시작 파괴가 사용자에게 영향을 미치지 않도록 사용자의 UI 상태를 저장 및 복원해야 합니다.

UI상태를 보존하기 위한 각 옵션은 사용자 경험에 영향을 미치는 차원에 따라 달라집니다.

UI 상태 저장하기

ViewModelSavedStateInstance로컬 스토리지
저장 위치메모리직렬화되어 디스크 내부디스크 또는 네트워크
설정 변경의 경우 살아남음
시스템에 의해 제거될 경우 살아남음아니오
사용자가 제거할 경우 살아남음아니오아니오
데이터 제한복잡한 객체도 괜찮지만 메모리 공간에 의해 제한될 수 있음Primitive Type나 작은 객체(String)만 해당디스크 공간 및 네트워크 리소스의 쿼리 시간에 의해 제한될 수 있음
읽기/쓰기 시간빠름(메모리 엑세스만 필요)느림((역)직렬화 및 디스크 엑세스 필요)느림 (디스크 엑세스 또는 네트워크 트랜잭션 필요)

ViewModel를 사용하여 설정 변경의 경우에 대한 처리

ViewModel은 사용자가 애플리케이션을 사용하는 동안 OS 관련 데이터를 저장하고 관리하는 데 적합합니다. 이를 통해 UI 데이터에 빠르게 액세스 할 수 있으며, 화면 회전이나, 창 크기 조정 및 기타 일반적으로 발생하는 설정 변경을 방지할 수 있습니다. ViewModel을 구현하는 방법에 대한 자세한 내용은 ViewModel 가이드를 참조하세요.

ViewModel은 데이터를 메모리에 유지하므로 디스크나 네트워크의 데이터보다 접근하는 것보다 더 저렴합니다. ViewModel은 Activity 또는 다른 LifecycleOwner과 연결되며, 시스템은 설정 변경으로 인해 발생한 새 Activity 인스턴스와 ViewModel을 자동으로 연결합니다.

ViewModel은 사용자가 Activity또는 Fragment을 사용하지 않거나 finish()를 요청하면 시스템에 의해 자동으로 제거됩니다. 즉, 이러한 시나리오에서 사용자가 예상하는 대로 상태가 지워집니다.

onSavedInstanceState와 달리 ViewModels는 시스템에 의해 제거될 경우 같이 제거됩니다. 따라서 ViewModel과 함께 onSaveInstanceState (또는 다른 저장 방법)을 사용하여 onSavedInstanceState에 ID를 저장하여 ViewModel의 데이터 복구를 도우는 등의 행동을 해야 합니다.

설정 변경 때 UI 상태를 저장하기 위한 메모리 내 방법이 이미 있다면 ViewModel를 굳이 사용할 필요는 없습니다.

onSavedInstanceState를 백업으로 사용하여 시스템에 의해 제거될 경우에 대한 처리

onSaveInstanceState() 메서드에는 시스템이 UI 컨트롤러(Activity 또는 Fragment)를 제거한 후 다시 생성하는 경우 UI 컨트롤러 상태를 다시 로드하는 데 필요한 데이터가 저장됩니다. 저장된 인스턴스 상태를 구현하는 방법에 대한 자세한 내용은 Activity 라이프 사이클 가이드에서 Activity 상태 저장 및 복원을 참조하세요.

SavedInstanceBundle은 설정 변경 및 시스템에 의한 제거 둘 다 유지하지만 디스크에 데이터를 보존하기 때문에 저장 용량과 속도에 따라 제한됩니다. 직렬화할 개체가 복잡한 경우 직렬화하는 데 많은 메모리가 소모될 수 있습니다. 이 프로세스는 설정 변경 중에 메인 스레드에서 발생하므로, 직렬화하는 데 너무 오래 걸릴 경우 프레임 손실과 시각적 스터칭이 발생할 수 있습니다.

onSaveInstanceState()를 사용하여 대량의 데이터를 저장하거나 긴 (역)직렬화가 필요한 복잡한 데이터 구조를 저장하지 마십시오. 대신 Primitive 유형과 String와 같은 단순하고 작은 객체만 저장하십시오. 따라서 onSaveInstanceState()를 사용하여 ID와 같이 최소한의 데이터만 저장하고 다른 지속성 메커니즘(ViewModel 등)이 실패할 경우 UI를 이전 상태로 복원하는 데 사용하세요. 대부분의 앱은 시스템에 의해 제거된 경우를 처리하기 위해 onSaveInstanceState()를 구현해야 합니다.

애플리케이션의 사용 사례에 따라 onSaveInstanceState()를 전혀 사용할 필요가 없을 수도 있습니다. 예를 들어 브라우저를 사용하면 사용자가 브라우저를 종료하기 전에 보던 웹 페이지로 돌아갈 수 있습니다. Activity가 이러한 방식으로 수행되는 경우 onSaveInstanceState()를 사용하지 않고 모든 작업을 로컬로 유지할 수 있습니다.

또한 Intent를 통해 Activity를 시작할 때 설정 변경과 시스템에 의해 제거된 경우를 복원할 때 모두 Activity Bundle가 제공됩니다. Activity를 시작할 때 UI 상태 데이터가 추가 용도로 전달된 경우에는 SavedInstanceBundle 대신 Extra Bundle을 사용할 수 있습니다. Intent Extras에 대한 자세한 내용은 Intent 및 Intent Filter를 참조하세요.

이러한 시나리오 중 하나에서는 설정 변경중에 데이터베이스에서 데이터를 다시 로드하는 로직이 낭비되지 않도록 ViewModel을 사용해야 합니다.

보존할 UI 데이터가 단순하고 가벼울 경우onSaveInstanceState()를 통해서 데이터를 보존할 수 있습니다.

참조: 이제 Saved State 모듈을 사용하여 ViewModel 객체에 Saved State를 제공할 수 있습니다. 저장된 상태는 SavedStateHandle라는 객체를 통해 접근할 수 있습니다. Android Lifecycle-aware components 코드랩 에서 어떻게 작동하는지 확인할 수 있습니다.

로컬 스토리지를 사용하여 복잡하거나 큰 데이터에 대한 프로세스 중단 처리

데이터베이스 또는 SharedPreference 과 같은 영구 로컬 스토리지는 애플리케이션이 사용자 기기에 설치되어 있는 동안에는 유지됩니다. 이러한 로컬 스토리지는 사용자에 의하거나 시스템에 의해 제거된 Activity에 대해 모두 대응할 수 있지만, 로컬 스토리지에서 메모리로 읽어 와야 하므로 접근하는 데 많은 비용이 들 수 있습니다. 이러한 영구 로컬 스토리지는 Activity를 열고 닫을 때 손실되지 않아야 할 모든 데이터를 저장하는 애플리케이션의 주 아키텍쳐의 일부일 수 있습니다.

ViewModel 또는 onSaveInstanceState()는 모두 장기적인 임시 스토리지 솔루션이므로 데이터베이스와 같은 로컬 스토리지를 대체할 수 없습니다. UI 상태를 일시적으로 저장하는 데에는 이러한 임시 스토리지 솔루션을 활용할 수 있지만, 보존되어야 할 앱 데이터에는 영구 스토리지를 사용해야 합니다. 로컬 스토리지를 활용하여 애플리케이션 모델 데이터를 장기간 유지하는 방법에 대한 자세한 내용은 애플리케이션 아키텍쳐 가이드를 참조하세요.

UI 상태 관리: 분할 및 정복

Activity를 다양한 유형의 지속성 메커니즘으로 나누어 효율적으로 UI 상태를 저장하고 복원할 수 있습니다. 대부분의 경우, 이러한 메커니즘은 데이터 복잡성, 접근 속도 및 수명의 절충점에 기반하여 Activity에 사용되는 다양한 유형의 데이터를 저장해야 합니다.

  • 로컬 스토리지: 앱을 열고 닫을 때에도 손실되지 않아야 할 데이터
    • 예: 노래 객체, 오디오 파일이나 메타데이터
  • ViewModel: 연결된 UI 컨트롤러를 표시하는 데 필요한 모든 데이터
    • 예: 가장 최근에 검색한 노래와 그 검색어
  • onSavedInstanceState: Activity가 중지되었다가 다시 생성되는 경우 Activity의 상태를 쉽게 로드하는 데 필요한 소량의 데이터. 여기에 복잡한 객체를 저장하는 대신 로컬 스토리지에 복잡한 객체를 유지하고 이러한 객체에 대한 고유 ID를 onSavedInstanceState에 저장합니다.
    • 에: 가장 최근에 검색하는데 사용한 검색어

예를 들어, 노래 라이브러리를 검색하는 Activity를 떠올려 보세요. 다양한 이벤트를 처리하는 방법은 다음과 같습니다.

사용자가 노래를 추가하면 ViewModel은 즉시 이 데이터를 로컬로 유지합니다. 새로 추가된 이 곡이 UI에 표시해야 하는 곡인 경우 ViewModel 객체의 데이터도 업데이트하여 곡을 추가해야 합니다. 메인 스레드에서 모든 데이터베이스 삽입을 수행해야 하는 것을 잊지 마세요.

사용자가 노래를 검색할 때 UI 컨트롤러를 위해 데이터베이스에서 로드한 복잡한 노래 데이터는 즉시 ViewModel 객체에 저장되어야 합니다. 또한 검색 조회 자체를 ViewModel 객체에 저장해야 합니다.

활동이 백그라운드로 진행되면 onSaveInstanceState()를 통해 검색 쿼리를 저장해야 합니다. 적은 양의 데이터에 있어서는 저장하기 쉽습니다. 이 데이터는 Activity를 이전의 상태로 되돌리는 데 필요한 모든 정보라고 할 수 있습니다.

복잡한 상태의 복원 : 부품을 다시 조립하다

사용자가 Activity로 돌아갈 시간이 되면 Activity을 다시 만드는 데 사용할 수 있는 두가지 시나리오가 있습니다.

  • 시스템에 의해 중지된 후 Activity이 재생성 되는 경우: Activity에 저장된 쿼리가onSaveInstanceState() 번들로 제공되며 쿼리를 ViewModel에 전달해야 합니다. ViewModel은 캐시 된 검색 결과가 없음을 확인하고 검색 쿼리를 사용하여 검색 결과를 로드합니다.
  • Activity가 설정 변경에 의해 재성성 되는 경우: ViewModel가 이미 캐시된 검색 결과가 있으므로 데이터베이스에 다시 요청할 필요가 없습니다.

참조: Activity가 처음 생성되면 onSavedInstanceState() 번들에는 데이터가 없으며 ViewModel 또한 비어있습니다. ViewModel 객체를 생성할 때 빈 쿼리를 통과하여 ViewModel 객체에 아직 로드할 데이터가 없음을 알 수 있습니다. 따라서 깔끔한 상태에서 Activity가 시작됩니다.

추가 링크

UI 상태 저장에 대한 자세한 내용을 알고 싶다면, 다음 링크를 참조하세요.