사전 프레임 드롭
문제

프레임 드롭

gabury1고친 사람 github-actions[bot]

프레임 드롭은 화면에 나와야 할 그림 한 장이 제때 못 나와, 화면이 새 그림 없이 한 번 넘어가는 고장입니다. 그 차례에는 앞 그림이 한 번 더 보여서 움직임이 뚝 끊겨 보입니다. 늦은 그림은 버려지지 않고 다음 차례에 나옵니다. 네트워크 분야에서는 장비가 받은 데이터 묶음을 버리는 일도 프레임 드롭이라 부릅니다. 이 편은 화면 쪽 뜻을 다룹니다.

쉽고 빠른 이해

프레임 드롭은 화면이 잠깐 멈칫하는 고장입니다. 게임에서 날아가던 공이 순간 멈췄다가 다시 움직입니다. 흔히 다음 순간에는 한 칸을 건너뛴 자리에 나타납니다.

이 고장이 문제인 까닭은 움직임이 매끄럽게 안 보이기 때문입니다. 1초 평균으로는 멀쩡해 보여도 보는 사람은 그 한 번의 멈춤을 바로 알아챕니다.

어떻게 생기나:

  1. 모니터는 정해진 간격마다 화면을 새 그림으로 바꿉니다
  2. 프로그램이 그림 한 장을 만드는 데 그 간격보다 오래 걸립니다
  3. 바꿀 때가 와도 새 그림이 없어서 앞 그림이 한 번 더 보입니다

막는 법과 그 대가 — 한 장에 들어가는 일을 그 간격 안에 끝나게 덜어 냅니다. 대신 그림이 덜 화려해지거나 계산을 여러 장에 나눠야 합니다.

상세

이 절은 프레임이 화면 갱신 시점을 놓치면 무엇이 보이고 언제 재현되는지를 봅니다.

프레임과 화면 갱신 주기

화면이 움직여 보이는 것은 멈춘 그림을 빠르게 갈아 끼우기 때문입니다. 이 멈춘 그림 한 장이 프레임입니다. 만화책 귀퉁이에 조금씩 다른 그림을 그려 두고 빠르게 넘기면 그림이 움직여 보이는 것과 같은 원리입니다.

모니터는 1초에 정해진 횟수만큼 화면을 새로 그립니다. 이 횟수가 주사율(refresh rate)입니다. 단위는 Hz(hertz, 헤르츠)입니다. 60 Hz 모니터는 약 16.7밀리초마다 한 번 화면을 갱신합니다.

모니터가 화면을 한 번 새로 그리는 간격을 갱신 간격이라고 합니다. 60 Hz 라면 갱신 간격은 16.7밀리초입니다. 갱신이 일어나는 바로 그 순간은 갱신 시점이라고 부릅니다.

프로그램 쪽에서는 게임 루프가 프레임을 하나씩 만듭니다. 게임 루프는 게임이 끝날 때까지 같은 일을 되풀이하는 반복문입니다. 한 바퀴마다 입력을 읽고, 화면 속 상태를 계산하고, 그림을 그립니다.

한 바퀴를 도는 데 걸린 시간이 프레임 시간입니다. 한 프레임에 쓸 수 있는 시간의 한도는 프레임 예산입니다. 60 Hz 모니터에 맞추려면 예산은 갱신 간격과 같은 16.7밀리초입니다. 프레임 드롭은 이 예산을 넘긴 프레임에서 생깁니다.

수직 동기화는 새 프레임으로 교체하는 때를 모니터의 갱신 시점에 맞추는 기능입니다. 모니터가 한 화면을 다 갱신한 뒤에만 프레임을 바꿉니다. 갱신 도중에 프레임이 바뀌어 화면이 찢어지는 것을 막으려고 씁니다.

갱신 시점을 놓친 프레임

수직 동기화가 켜진 60 Hz 화면을 생각해 봅시다. 화면이 막 갱신된 직후 그리기 시작한 프레임이 18밀리초 걸렸습니다. 16.7밀리초 뒤 다음 갱신 시점이 왔을 때 이 프레임은 아직 그리는 중입니다. 모니터는 교체할 새 프레임이 없으니 앞 프레임을 한 번 더 화면에 냅니다.

이 프레임은 그다음 갱신 시점, 곧 33.3밀리초 뒤에야 화면에 나옵니다. 보는 사람에게는 앞 프레임이 갱신 두 번 동안 머문 것으로 보입니다. 이것이 프레임 드롭입니다.

sequenceDiagram
    participant 루프 as 게임 루프
    participant 화면 as 모니터
    루프->>화면: 1번 프레임 완성 (12밀리초)
    Note over 화면: 첫 갱신 · 1번을 낸다
    Note over 루프: 2번 프레임이 18밀리초 걸린다
    Note over 화면: 둘째 갱신 · 2번이 없어 1번을 한 번 더
    루프->>화면: 2번 프레임 완성
    Note over 화면: 셋째 갱신 · 2번을 낸다

위 그림에서 2번 프레임은 갱신 시점을 한 번 놓쳤습니다. 1번 프레임이 갱신 두 번 동안 화면에 머물렀습니다. 그동안 움직임이 멈췄습니다.

놓친 갱신 횟수가 곧 빠진 프레임 수입니다. 여기서 빠진 것은 2번 프레임 자체가 아닙니다. 둘째 갱신 시점에 들어갔어야 할 새 프레임 한 장입니다.

프레임이 예산을 조금만 넘겨도 다음 갱신 시점까지 기다려야 합니다. 17밀리초 걸린 프레임과 33밀리초 걸린 프레임은 똑같이 갱신 시점 하나를 놓칩니다. 수직 동기화는 갱신 시점 사이에 프레임을 바꾸지 않기 때문입니다.

놓친 갱신 횟수를 세는 법

프레임 시간을 알면 갱신 시점을 몇 번 놓쳤는지 셈할 수 있습니다. 프레임 시간을 갱신 간격으로 나누고 올림한 뒤 1을 빼면 됩니다. 아래는 60 Hz 화면으로 그 셈을 적은 파이썬 코드입니다.

Python
import math

INTERVAL = 1000 / 60   # 16.67 (밀리초)

def missed(frame_ms):
    return math.ceil(frame_ms / INTERVAL) - 1

missed(12.0)           # 0
missed(18.0)           # 1
missed(40.0)           # 2

12밀리초 프레임은 갱신 간격 안에 들어와 놓친 갱신이 없습니다. 18밀리초 프레임은 한 번, 40밀리초 프레임은 두 번 놓칩니다. 40밀리초짜리 프레임 앞에서는 같은 프레임이 갱신 세 번 동안 머뭅니다.

재현되는 조건

프레임 드롭이 갱신 시점을 놓치는 꼴로 나려면 두 조건이 겹쳐야 합니다.

조건 무엇인가
갱신 시점이 고정돼 있다 수직 동기화가 켜져 있고 모니터가 정해진 주사율로 갱신한다
한 프레임이 갱신 간격보다 길다 60 Hz 라면 프레임 시간이 16.7밀리초를 넘는다

재현은 간단합니다. 60 Hz 모니터에서 수직 동기화를 켭니다. 게임 루프가 100번째 바퀴마다 20밀리초를 일부러 쉬게 합니다. 그러면 약 1.7초마다 한 번씩 화면이 멈칫합니다.

둘 중 하나가 빠지면 이 모양으로는 안 터집니다. 아래 그림은 프레임 한 장이 다 그려진 뒤 갈리는 길입니다.

flowchart TD
    A[프레임 한 장을 다 그렸다] --> B{수직 동기화가 켜졌나}
    B -->|아니오| C[바로 교체한다 · 화면이 찢어질 수 있다]
    B -->|예| D{갱신 간격 안에 끝났나}
    D -->|예| E[다음 갱신에 나온다 · 드롭 없음]
    D -->|아니오| F[갱신 시점을 놓친다 · 앞 프레임이 한 번 더 · 프레임 드롭]

수직 동기화를 끄면 갱신 시점을 기다리지 않습니다. 대신 모니터가 화면을 갱신하는 도중에 프레임이 바뀌어 위아래가 어긋나 보입니다. 이 결함이 티어링입니다. 수직 동기화를 켜고 끄는 것은 티어링과 프레임 드롭 중 무엇을 감수할지 고르는 일입니다.

모니터가 프로그램을 기다리는 방식도 있습니다. 새 프레임이 도착하면 그때 화면을 갱신합니다. 이 방식이 가변 주사율(variable refresh rate)입니다. 이때 예산을 조금 넘긴 프레임은 넘긴 만큼만 늦게 나오므로 멈춤이 훨씬 덜 보입니다.

프레임을 늦추는 흔한 원인

드롭은 대개 평소에는 예산 안에 들던 루프에서 몇 프레임만 튀어서 납니다. 그 몇 프레임에 드물게 도는 오래 걸리는 일이 겹친 것입니다.

원인 왜 그 프레임이 늦어지나
가비지 컬렉션 안 쓰는 메모리를 한꺼번에 치우는 동안 프로그램이 멈춘다
셰이더 컴파일 처음 보는 효과를 그릴 때 그 효과를 그리는 작은 프로그램인 셰이더를 그 프레임에서 컴파일한다
디스크 입출력 그림 파일이나 소리를 처음 쓸 때 디스크에서 읽느라 기다린다
그릴 것이 갑자기 늘어남 폭발처럼 물체가 한꺼번에 많아진 장면에서 그리기 시간이 는다
서멀 스로틀링 기기가 뜨거워지면 처리 속도를 스스로 낮춰 같은 일이 더 오래 걸린다

표의 원인은 모두 한 프레임에 일이 몰리는 경우입니다. 그래서 드롭을 줄이는 방법도 대개 그 일을 미리 해 두거나 여러 프레임에 나누는 쪽입니다. 셰이더를 시작 화면에서 미리 컴파일해 두는 것이 그런 예입니다.

평균 프레임률이 가리는 드롭

1초에 프레임이 몇 장 나오는지 세는 값이 프레임률입니다. 단위는 FPS(frames per second, 초당 프레임 수)입니다. 많은 게임이 화면 귀퉁이에 이 숫자를 띄웁니다.

1초에 프레임이 55장 나왔다면 평균은 무난해 보입니다. 그런데 그중 한 장이 90밀리초 걸렸다면 화면은 그동안 멈춰 있었습니다. 갱신 시점으로 치면 다섯 번을 놓친 것입니다. 평균은 이 한 번의 멈춤을 다른 54장 속에 묻어 버립니다.

그래서 드롭을 찾을 때는 프레임 시간을 한 장씩 봅니다. 프레임 시간을 짧은 것부터 줄 세우고 99% 지점의 값을 예산과 견줍니다. 이렇게 순위로 보는 값이 백분위수입니다.

백엔드 개발자에게는 익숙한 생각입니다. 평균 응답 시간이 3밀리초여도 드물게 오래 걸린 요청은 꼬리 지연으로 따로 봅니다. 프레임 드롭은 화면에서 보이는 꼬리 지연입니다.

목표를 못 지킨 것과 목표가 낮은 것

프레임 드롭은 목표한 갱신 시점을 못 지킨 것입니다. 처음부터 30 FPS 를 목표로 잡아 60 Hz 모니터의 갱신 두 번마다 한 프레임씩 고르게 내는 게임은 드롭이 아닙니다. 매 프레임이 정해진 때에 나오므로 움직임이 덜 부드러울 뿐 멈칫하지는 않습니다.

게이머들이 「프레임 드랍이 났다」고 할 때는 흔히 프레임률이 한동안 떨어진 것을 가리킵니다. 60 FPS 로 돌던 게임이 복잡한 장면에서 40 FPS 로 떨어지는 경우입니다. 이것은 프레임 드롭이 짧은 시간에 여러 번 이어진 것입니다.

게임 루프가 드롭 뒤를 다루는 방식

드롭이 나면 다음 프레임 앞에는 평소보다 긴 시간이 흘러 있습니다. 게임 루프가 이 긴 시간을 어떻게 다루느냐에 따라 화면 속 물체의 움직임이 달라집니다.

델타 타임은 앞 프레임부터 지금까지 흐른 시간입니다. 이 값을 움직임에 곱하면 드롭이 나도 물체의 속도가 유지됩니다. 대신 한 번에 크게 움직이므로 얇은 벽을 뚫고 지나가는 일이 생길 수 있습니다.

고정 타임스텝은 흐른 시간을 정해진 크기의 조각으로 나눠 계산합니다. 드롭 뒤에는 밀린 조각만큼 계산을 여러 번 돕니다. 계산 결과가 프레임 시간에 흔들리지 않는 것이 이 방식을 쓰는 까닭입니다.

아래 표는 화면 속 물체를 옮기는 세 방식이 갱신 시점 하나를 놓친 뒤 33.3밀리초를 어떻게 다루는지 견준 것입니다.

방식 드롭 뒤 다음 계산 보는 사람에게 보이는 것
물체를 프레임마다 같은 양만큼 옮김 33.3밀리초가 흘렀어도 한 프레임만큼만 옮긴다 게임 속 시간이 느려진다
델타 타임 흐른 33.3밀리초만큼 옮긴다 물체가 한 칸을 건너뛰어 나타난다
고정 타임스텝 16.7밀리초 조각으로 나눠 계산을 두 번 돈다 물체가 한 칸을 건너뛰고, 드롭이 없었을 때와 똑같은 위치·속도가 나온다

다만 밀린 계산 때문에 다음 프레임이 또 늦어질 수 있습니다. 늦어진 만큼 밀린 조각이 더 쌓입니다. 그 계산 때문에 프레임은 더 늦어집니다. 이렇게 드롭이 드롭을 부르며 멈추지 않는 상태가 죽음의 나선입니다. 그래서 고정 타임스텝을 쓰는 루프는 한 프레임에 돌 계산 횟수에 상한을 둡니다.

다른 화면 결함과 가르기

프레임 드롭과 함께 자주 나오는 결함이 셋 있습니다. 화면에 무엇이 보이는지로 가르면 헷갈리지 않습니다.

결함 화면에 보이는 것 언제 나나
프레임 드롭 앞 프레임이 갱신 한 번 더 머문다 수직 동기화가 켜진 채 프레임이 갱신 시점을 놓칠 때
티어링 화면이 가로로 찢어져 위아래가 어긋난다 수직 동기화가 꺼진 채 갱신 도중 프레임이 바뀔 때
스터터링 움직임이 고르지 않고 덜컥거린다 프레임이 화면에 나오는 간격이 들쭉날쭉할 때
입력 지연 버튼을 누른 뒤 화면이 늦게 반응한다 입력이 화면에 나오기까지 기다림이 길 때

스터터링은 프레임 드롭과 가장 가깝습니다. 드롭이 드문드문 반복되면 프레임 간격이 16.7밀리초와 33.3밀리초 사이를 오갑니다. 간격이 고르지 않아 움직임이 덜컥거립니다. 이것이 스터터링으로 느껴집니다.

브라우저에서는 같은 현상을 jank라고 부릅니다. 스크롤이나 애니메이션 도중 자바스크립트가 메인 스레드를 오래 붙잡으면 그 프레임이 갱신 시점을 놓칩니다.

드롭을 줄이는 수단

드롭을 줄이는 길은 크게 둘입니다. 하나는 프레임 하나의 일을 예산 안에 넣는 것입니다. 그릴 것을 덜거나, 오래 걸리는 일을 여러 프레임에 나누거나, 다른 스레드로 옮깁니다.

다른 하나는 화면의 갱신 시점에 기대는 정도를 줄이는 것입니다. 가변 주사율을 쓰면 조금 늦은 프레임이 다음 갱신 시점까지 기다리지 않습니다.

다 그린 프레임을 담아 두는 메모리 칸은 보통 둘입니다. 하나에는 화면에 걸린 프레임이, 다른 하나에는 루프가 그리는 프레임이 들어갑니다. 이 방식이 더블 버퍼링입니다.

더블 버퍼링에서 수직 동기화가 켜져 있으면 다 그린 프레임은 갱신 시점까지 칸을 차지한 채 기다립니다. 그동안 루프는 새로 그릴 빈 칸이 없어 함께 멈춥니다. 그래서 한 번 늦은 프레임은 다음 프레임의 시작까지 늦춥니다.

트리플 버퍼링은 이 칸을 셋으로 늘립니다. 다 그린 프레임이 갱신 시점을 기다리는 동안에도 루프는 셋째 칸에 다음 프레임을 그립니다. 그래서 한 번의 드롭이 뒤 프레임까지 늦추는 일을 줄여 줍니다.

관련 항목

프레임 드롭을 셈하는 속도 지표

프레임 · 프레임률 · 프레임 시간 · 프레임 예산 · 주사율 · 프레임 페이싱 · 백분위수

프레임 드롭과 함께 나오는 화면 결함

티어링 · 스터터링 · 입력 지연 · jank · 저더

프레임 드롭이 생기는 그리기 흐름

게임 루프 · 렌더링 · 렌더 파이프라인 · 델타 타임 · 고정 타임스텝 · 물리 스텝 · 죽음의 나선

프레임 드롭을 일으키는 원인

가비지 컬렉션 · 셰이더 컴파일 · 디스크 입출력 · 서멀 스로틀링 · CPU 바운드 · GPU 바운드

프레임 드롭을 줄이는 화면 동기화 기술

수직 동기화 · 가변 주사율 · 더블 버퍼링 · 트리플 버퍼링 · 버퍼 스왑 · 모니터

프레임 드롭을 줄이는 기법

컬링 · LOD · 해상도 스케일링 · 타임 슬라이싱 · 스레드 · 오브젝트 풀링 · 프로파일러

프레임 드롭과 같은 꼴로 시간을 재는 백엔드 지표

꼬리 지연 · 지연 예산 · 응답 시간 · 서비스 수준 목표

프레임 드롭과 이름이 겹치는 네트워크 용어

패킷 손실 · 이더넷 프레임 · 테일 드롭 · 네트워크 인터페이스 카드

다른 이름: frame drop · dropped frame · dropped frames · 프레임 드랍 · 프레임 누락