폴더블 기기 화면 접힘·펼침 중 렌더 타깃과 UI 레이아웃을 안전하게 재구성하는 방법

폴더블 기기 접힘·펼침 중 발생하는 화면 깜빡임, 검은 화면, 잘린 UI의 원인을 분석하고 Unity·Unreal에서 렌더 타깃과 Safe Area 레이아웃을 안정적으로 재구성하는 이벤트 정규화·디바운스·프레임 경계 교체 패턴을 설명합니다.

✓ 복사 완료!
챕터게임 UI/UX 디자인
난이도중급
상태● 검토 완료
대상 엔진
UnityUnreal Engine
최종 업데이트
폴더블 기기 화면 접힘·펼침 중 렌더 타깃과 UI 레이아웃을 안전하게 재구성하는 방법

핵심 요약

폴더블 기기에서는 접힘 이벤트가 한 번만 발생한다고 가정하면 안 됩니다. 화면 크기, 시스템 인셋, 힌지 영역, 방향 변경 이벤트가 짧은 시간에 여러 번 들어올 수 있으므로 원시 이벤트를 GeometrySnapshot으로 정규화한 뒤 안정화 구간을 거쳐 한 번만 커밋해야 합니다.

렌더 타깃은 이벤트 수신 즉시 파괴·재생성하지 말고 기존 타깃을 유지한 채 다음 프레임 경계에서 새 타깃으로 교체합니다. UI는 물리 화면 크기보다 Safe Area, 힌지 가림 영역, 멀티 패널 분할 정보를 기준으로 다시 배치해야 깜빡임과 잘림을 줄일 수 있습니다.

폴더블 화면 접힘·펼침에서 어떤 문제가 발생하는가?

일반적인 회전은 보통 가로·세로 방향과 해상도가 바뀌는 이벤트로 처리할 수 있습니다. 반면 폴더블 기기는 다음 변화가 동시에 일어날 수 있습니다.

변화발생 가능한 영향
외부 화면과 내부 화면 전환픽셀 해상도와 종횡비 변경
힌지 각도 변화화면 일부 가림 또는 두 패널 분리
시스템 바 위치 변경Safe Area와 입력 영역 변경
Android 구성 변경Activity 재생성 또는 Surface 재생성
GPU Surface 교체렌더 타깃 연결 해제와 검은 프레임

렌더 타깃을 너무 일찍 해제하면 카메라, RawImage, 후처리 패스가 이미 파괴된 텍스처를 참조할 수 있습니다. 반대로 UI 레이아웃만 다시 계산하고 렌더 타깃을 그대로 두면 화면 비율이 맞지 않아 늘어나거나 잘린 화면이 나타납니다.

폴더블 상태는 화면 방향만으로 판단하지 않는 것이 중요합니다. Android의 Jetpack WindowManager를 사용하는 경우 WindowLayoutInfo와 FoldingFeature에서 다음 값을 받아 게임 엔진에 전달하는 구조가 안전합니다.

  • 화면 경계와 실제 픽셀 크기
  • FoldingFeature.state
  • occlusionType
  • isSeparating
  • 힌지 또는 접힘 영역의 Rect
  • 시스템 바와 디스플레이 컷아웃 인셋

폴더블 화면 상태별 렌더 타깃과 세이프 에어리어 재구성 흐름

안전한 재구성을 위해 이벤트를 어떻게 정규화할까?

엔진마다 이벤트 이름은 다르지만 처리 단계는 동일하게 구성할 수 있습니다.

  1. Android 네이티브 플러그인 또는 엔진 이벤트에서 원시 상태를 받습니다.
  2. 상태를 하나의 GeometrySnapshot으로 변환합니다.
  3. 짧은 안정화 시간 동안 동일한 후보 상태가 유지되는지 확인합니다.
  4. 렌더 타깃과 UI를 게임 스레드에서 프레임 경계에 맞춰 교체합니다.
  5. 최신 요청이 아니면 이전 요청의 결과를 폐기합니다.
flowchart TD
    A[원시 접힘·펼침 이벤트] --> B[GeometrySnapshot 생성]
    B --> C{안정화 시간 동안 동일한가?}
    C -- 아니오 --> B
    C -- 예 --> D[렌더 타깃 교체]
    D --> E[Safe Area와 UI 앵커 재계산]
    E --> F[입력 영역과 카메라 투영 갱신]

후보 상태가 안정적인지 판단하는 시간은 보통 80~200ms 범위에서 시작할 수 있습니다. 특정 값이 모든 기기에서 정답은 아니므로 접힘 애니메이션과 프레임 시간을 측정해 조정해야 합니다.

렌더 타깃의 크기는 화면 전체가 아니라 게임이 실제로 그려질 영역을 기준으로 계산합니다.

Wrt=clamp(Wsafe×s,Wmin,Wmax)W_{rt} = clamp(W_{safe} × s, W_{min}, W_{max})

여기서 s는 렌더 스케일입니다. 고해상도 내부 화면에서 무조건 전체 해상도로 렌더링하면 접힘 전환 순간에 메모리 사용량과 GPU 작업량이 급증할 수 있으므로 모바일 프로필별 상한을 두는 편이 좋습니다.

Unity에서 렌더 타깃을 언제 교체해야 하는가?

Unity 2022.3 LTS 기준으로 Android의 실제 폴더블 상태는 네이티브 플러그인에서 받고 C# 레이어에는 정규화된 구조체만 전달하는 방식을 권장합니다. Screen.width, Screen.height, Screen.safeArea를 매 프레임 직접 읽어 상태를 판단하기보다 네이티브 이벤트와 엔진 값을 함께 검증해야 합니다.

다음 코드는 이벤트를 디바운스하고 WaitForEndOfFrame 이후 렌더 타깃을 교체하는 단순한 예입니다. FoldLayoutApplier는 프로젝트의 Canvas와 앵커를 갱신하는 컴포넌트로 대체합니다.

using System.Collections;
using UnityEngine;
using UnityEngine.UI;

public readonly struct DisplayGeometry
{
    public readonly int PixelWidth;
    public readonly int PixelHeight;
    public readonly RectInt SafeArea;
    public readonly RectInt Occlusion;
    public readonly bool IsSeparating;

    public DisplayGeometry(
        int pixelWidth,
        int pixelHeight,
        RectInt safeArea,
        RectInt occlusion,
        bool isSeparating)
    {
        PixelWidth = pixelWidth;
        PixelHeight = pixelHeight;
        SafeArea = safeArea;
        Occlusion = occlusion;
        IsSeparating = isSeparating;
    }
}

public sealed class FoldableDisplayCoordinator : MonoBehaviour
{
    [SerializeField] private Camera targetCamera;
    [SerializeField] private RawImage preview;
    [SerializeField] private FoldLayoutApplier layoutApplier;

    private DisplayGeometry pendingGeometry;
    private int pendingVersion;
    private Coroutine rebuildRoutine;

    public void OnGeometryChanged(DisplayGeometry geometry)
    {
        pendingGeometry = geometry;
        pendingVersion++;

        if (rebuildRoutine == null)
            rebuildRoutine = StartCoroutine(RebuildWhenStable());
    }

    private IEnumerator RebuildWhenStable()
    {
        while (true)
        {
            int observedVersion = pendingVersion;
            float deadline = Time.realtimeSinceStartup + 0.12f;

            while (Time.realtimeSinceStartup < deadline)
            {
                yield return null;

                if (observedVersion != pendingVersion)
                    break;
            }

            if (observedVersion != pendingVersion)
                continue;

            yield return new WaitForEndOfFrame();

            if (observedVersion != pendingVersion)
                continue;

            ApplyGeometry(pendingGeometry);
            break;
        }

        rebuildRoutine = null;
    }

    private void ApplyGeometry(DisplayGeometry geometry)
    {
        int width = Mathf.Max(1, geometry.PixelWidth);
        int height = Mathf.Max(1, geometry.PixelHeight);

        var descriptor = new RenderTextureDescriptor(
            width,
            height,
            RenderTextureFormat.ARGB32,
            24)
        {
            msaaSamples = 1,
            useMipMap = false,
            autoGenerateMips = false
        };

        var nextTarget = new RenderTexture(descriptor)
        {
            name = "FoldRenderTarget"
        };
        nextTarget.Create();

        RenderTexture previousTarget = targetCamera.targetTexture;
        targetCamera.targetTexture = nextTarget;

        if (preview != null)
            preview.texture = nextTarget;

        layoutApplier.Apply(geometry, width, height);

        if (previousTarget != null)
        {
            previousTarget.Release();
            Destroy(previousTarget);
        }
    }
}

이 코드의 핵심은 세 가지입니다.

  • 새 이벤트가 들어오면 이전 재구성 작업의 결과를 무효화합니다.
  • 안정화 시간과 프레임 경계를 모두 통과한 뒤에만 리소스를 교체합니다.
  • 카메라와 UI가 새 타깃을 참조하도록 바꾼 뒤 이전 타깃을 해제합니다.

블러, 리플렉션, 비동기 GPU 읽기처럼 여러 프레임 동안 텍스처를 참조하는 기능이 있다면 이전 타깃을 즉시 Release하지 말고 한두 프레임의 유예 큐에 넣어야 합니다. 또한 RenderTexture.GetTemporary로 만든 타깃과 직접 생성한 타깃을 같은 방식으로 해제하면 안 됩니다. 소유권과 해제 방식을 명확히 분리하세요.

Unity UI Safe Area와 힌지 영역은 어떻게 배치할까?

Screen.safeArea는 시스템 바와 디스플레이 컷아웃을 처리하지만 힌지 가림 영역까지 항상 게임의 원하는 형태로 분할해 주지는 않습니다. FoldingFeature.isSeparating이 참이면 하나의 큰 사각형 안에 UI를 억지로 배치하기보다 두 개의 패널 영역으로 나누는 편이 안전합니다.

권장 규칙은 다음과 같습니다.

  • 힌지가 단순한 주름이고 가리지 않으면 전체 Safe Area를 사용합니다.
  • 힌지가 콘텐츠를 가리면 힌지 양쪽의 두 Rect를 계산합니다.
  • 전투 HUD처럼 중앙 정렬이 중요한 UI는 힌지와 겹치지 않는 패널로 이동합니다.
  • 터치 입력도 시각적 영역과 같은 분할 정보를 사용합니다.
  • 레이아웃 재구성 중에는 버튼의 클릭을 잠시 보류하고 커밋 완료 후 입력을 재개합니다.

레이아웃을 즉시 파괴하고 다시 만드는 대신 앵커, 오프셋, CanvasScaler 기준 해상도만 갱신하면 GC와 레이아웃 비용을 줄일 수 있습니다. 특히 ContentSizeFitter와 중첩된 LayoutGroup이 많다면 폴더블 전환 중 한 프레임에 모든 계층을 재계산하지 않도록 화면별 레이아웃 프리셋을 준비하는 것이 효과적입니다.

Unreal Engine에서는 렌더 타깃과 Slate 레이아웃을 어떻게 동기화할까?

Unreal Engine 5.x에서는 UGameViewportClient의 뷰포트 크기 변경과 Slate의 레이아웃 변경을 같은 커밋 단위로 처리해야 합니다. Android 네이티브 계층에서 받은 FoldingFeature 정보를 게임 스레드로 전달하고 FViewport::GetSizeXY()와 함께 FFoldableGeometry를 구성합니다.

대표적인 처리 순서는 다음과 같습니다.

  1. UGameViewportClient 또는 플랫폼 브리지에서 후보 크기와 인셋을 수집합니다.
  2. FTimerManager 또는 자체 버전 카운터로 100ms 안팎의 안정화 구간을 둡니다.
  3. 게임 스레드에서 UTextureRenderTarget2D의 새 크기와 포맷을 준비합니다.
  4. 카메라와 머티리얼의 참조를 새 타깃으로 바꿉니다.
  5. SafeZone 위젯, CanvasPanelSlot, 입력 라우팅 영역을 갱신합니다.
  6. 렌더 커맨드가 이전 타깃을 더 이상 사용하지 않는 시점에 이전 리소스를 정리합니다.

UTextureRenderTarget2D::InitAutoFormat 또는 InitCustomFormat을 호출하는 동안 여러 액터가 해당 타깃을 동시에 읽고 있다면 프레임 중간에 재초기화하지 않아야 합니다. 장시간 유지되는 렌더 타깃은 이중 버퍼를 사용해 새 타깃을 먼저 준비한 뒤 참조만 교체하는 방법이 더 안전합니다.

UMG에서는 GetViewportSize만으로 힌지 상태를 추론하지 마세요. 네이티브에서 전달한 가림 영역을 SafeZone 또는 사용자 정의 레이아웃 컨테이너에 주입하고 InvalidateLayoutAndVolatility를 커밋 시점에 한 번 호출하는 구조가 적합합니다. 접힘 애니메이션 중 매 프레임 전체 Slate 트리를 무효화하면 CPU 사용량이 불필요하게 증가합니다.

렌더 타깃 메모리는 어떻게 제한해야 하는가?

렌더 타깃 메모리는 대략 다음처럼 계산할 수 있습니다.

Memory≈Wrt×Hrt×(Bcolor+Bdepth)×samplesMemory ≈ W_{rt} × H_{rt} × (B_{color} + B_{depth}) × samples

RGBA8 컬러 버퍼는 픽셀당 약 4바이트를 사용하며 깊이 버퍼, MSAA, 이중 버퍼를 추가하면 실제 사용량은 더 커집니다. 따라서 다음 방어선을 두는 것이 좋습니다.

  • 접힘 전환 중 동시에 두 개 이상의 고해상도 타깃을 만들지 않습니다.
  • 모바일 등급별 최대 폭·높이와 최대 픽셀 수를 설정합니다.
  • UI 전용 타깃은 필요하지 않다면 깊이 버퍼와 MSAA를 끕니다.
  • 새 타깃 생성에 실패하면 기존 타깃과 저해상도 타깃 중 하나로 폴백합니다.
  • 백그라운드 전환 중에는 재구성을 보류하고 포그라운드 복귀 후 최신 상태만 적용합니다.

접힘·펼침 전환은 어떻게 테스트할까?

실제 기기에서는 다음 상태를 각각 확인해야 합니다.

테스트 상태확인할 항목
완전히 펼친 상태전체 Safe Area, 최대 렌더 해상도
반쯤 접힌 상태힌지 가림, 두 패널 분할, 입력 차단
완전히 접힌 상태외부 화면 전환, Activity와 Surface 재생성
빠른 반복 접힘디바운스, 오래된 요청 폐기, 검은 프레임 여부
시스템 바 표시·숨김인셋 변경과 HUD 재배치
저메모리 상태타깃 생성 실패와 해상도 폴백

로그에는 eventTime, 픽셀 크기, Safe Area, 힌지 영역, 후보 버전, 커밋 버전을 함께 기록하면 간헐적인 검은 화면을 재현하기 쉽습니다. 테스트 자동화에서는 이벤트를 16ms 간격으로 여러 번 주입해도 최종 커밋이 마지막 안정 상태 한 번만 발생하는지 검증해야 합니다.

자주 묻는 질문 (FAQ)

접힘 이벤트가 들어올 때마다 렌더 타깃을 다시 만들어도 되나요?

권장하지 않습니다. 접힘 애니메이션 중 여러 번 호출될 수 있으므로 안정화된 후보만 선택하고 프레임 경계에서 교체해야 합니다.

Safe Area만 갱신하면 힌지 문제도 해결되나요?

항상 그렇지는 않습니다. 힌지가 콘텐츠를 가리는지와 두 화면을 분리하는지 확인한 뒤 가림 영역을 제외하거나 두 패널로 나눠야 합니다.

기존 렌더 타깃을 언제 해제해야 하나요?

카메라와 머티리얼의 참조를 새 타깃으로 바꾼 뒤 해제합니다. 비동기 GPU 작업이나 다중 프레임 참조가 있으면 한두 프레임 유예 큐를 사용하세요.

정리

폴더블 UI 대응의 핵심은 폴더의 물리 상태를 단순한 회전 이벤트로 취급하지 않는 데 있습니다. WindowLayoutInfo 또는 엔진의 뷰포트 이벤트를 하나의 GeometrySnapshot으로 정규화하고 안정화 시간, 버전 검증, 프레임 경계 교체, Safe Area·힌지 기반 레이아웃 갱신을 순서대로 적용하면 접힘·펼침 중 깜빡임과 잘린 UI를 안정적으로 줄일 수 있습니다.