본문으로 건너뛰기
HY Devlog
뒤로 가기

Content가 늘어나는 방향을 정하는 건 Anchor가 아니라 Pivot이다

인벤토리를 Scroll View로 만들다가 방법을 찾던 중 스크랩한 강좌다. 2021.3 LTS 기준 연재물의 한 편이고, 스크롤바를 치우고 Horizontal Layout Group과 Content Size Fitter를 Content에 붙여 가로/세로 스크롤을 만드는 절차를 사진과 표로 따라간다. 따라 하면 실제로 만들어진다. 입문 자료로 잘 쓰여 있다.

대조해보니 설정값은 맞는데 설명이 비어 있는 자리가 몇 군데 있었다. 그리고 한 군데는 적힌 설정이 다른 설정에 먹힌다.

가장 큰 것은 Content에 대한 지시다. 강좌는 이렇게 적는다.

Anchor X 와 Y 의 Min = 0, Max = 1 로 설정

Content Size Fitter 의 Horizontal Fit 속성을 Preferred Size

두 줄이 같은 축을 두고 싸운다. uGUI의 Auto Layout 문서가 그 축에 무슨 일이 일어나는지 적어뒀다.

a Content Size Fitter which has the Horizontal Fit property set to Minimum or Preferred will drive the width of the Rect Transform on the same Game Object.

The width will appear as read-only

그리고 정작 Content가 어느 방향으로 늘어나는지를 정하는 속성은 강좌에 한 번도 나오지 않는다. Pivot이다.

확인한 것은 다섯 가지다. Content Size Fitter가 가져간 축의 값은 driven이 되고 씬에 저장되지도 않는다, 늘어나는 방향은 Pivot이 정한다, 스크롤바 참조를 None으로 두는 것과 Viewport를 넓히는 것은 다른 설정이다, 무엇이 내용을 잘라내는지가 강좌에 없다, 그리고 수직 편의 “다른 사항”에 수평 편과 같은 값이 적혀 있다.

그리고 계기 쪽으로 한 가지 더. 인벤토리는 한 축 목록이 아니라 격자이고, uGUI 매뉴얼에 그 조합의 이름 붙은 조합법이 따로 있다. 강좌의 한 축 레시피로는 닿지 않는다.

이 블로그에 Control Child Size를 켜면 크기를 묻는 상대가 바뀐다가 있다. 그쪽은 레이아웃 그룹이 자식의 크기와 좌표를 가져가는 이야기였다. 이 글은 한 겹 위다 — 레이아웃 그룹을 얹은 Content 자신이 누구에게 크기를 빼앗기는지, 그리고 그게 Scroll View의 구조와 어떻게 맞물리는지. 다만 다른 글로 넘기지는 않는다. 필요한 설명과 예제 코드는 여기서 다시 전부 싣는다.

확인 시점은 2026-10-09이고, 기준은 uGUI 2.7.0이다.

목차

목차

Content Size Fitter가 가져간 축은 driven이 된다

먼저 Content Size Fitter가 무엇인지부터. 문서의 첫 문장이 정체를 밝힌다.

The Content Size Fitter functions as a layout controller that controls the size of its own layout element.

레이아웃 컨트롤러다. 자기 자신의 크기를 통제한다. Horizontal Fit의 값은 넷이다.

Do not drive the width based on the layout element.

Drive the width based on the minimum width of the layout element.

Drive the width based on the preferred width of the layout element.

Ensures that the width of the layout element stays within the minimum and maximum bounds.

Unconstrained · Min Size · Preferred Size · Clamped 순서다. 설명을 읽는 기준은 drive라는 단어다. 둘은 drive하고 둘은 안 한다.

값그 축을 drive하나
Unconstrained아니다 (“Do not drive”)
Min Size한다
Preferred Size한다
Clamped아니다 — 범위만 제한한다

강좌가 지정한 Preferred Size가 drive하는 쪽이다. 그리고 Clamped는 이 글을 쓰면서 처음 제대로 읽은 값인데, 크기를 가져가지 않으면서 상·하한만 걸어주는 유일한 선택지다. 목록 항목처럼 “너무 작아지지도 너무 커지지도 않게”가 필요한 자리에 맞는다.

drive된 축이 어떻게 되는지는 Auto Layout 문서에 있다.

The width will appear as read-only

those sizes and positions should not be manually edited at the same time through the Inspector or Scene View.

Such changed values would just get reset by the layout controller on the next layout calculation anyway.

인스펙터에서 읽기 전용으로 바뀌고, 손으로 바꿔도 다음 레이아웃 계산에서 되돌려진다. 한 줄이 더 있다.

the values of driven properties are not saved as part of the Scene

씬에 저장되지도 않는다. 그래서 수평 스크롤에서 Content의 Anchor X를 Min=0, Max=1로 늘려놓는 지시는 — 그 축의 폭을 Content Size Fitter가 가져가므로 — 결과에 남지 않는다. 틀린 설정이 아니라 무효한 설정이다.

정리하면 축마다 주인이 다르다.

축수평 스크롤에서누가 정하나
너비(X)자식이 늘어나는 축Content Size Fitter의 Horizontal Fit
높이(Y)Viewport에 맞춰야 하는 축Anchor 스트레치 (여기서는 Min=0 Max=1이 의미 있다)

수직 스크롤이면 X와 Y가 바뀐다. 강좌가 양축을 한꺼번에 Min=0 Max=1로 설정하라고 적은 것은, 의미가 있는 한 축과 무효한 한 축을 같이 적은 것이다. 결과가 나오니 틀린 절차로 보이지 않는다.

늘어나는 방향은 Pivot이 정한다

그러면 Content Size Fitter가 폭을 1000에서 2000으로 늘릴 때 어느 쪽으로 늘어나는가. 강좌에는 그 답이 없고, 문서에는 있다.

the resizing is around the pivot.

This means that the direction of the resizing can be controlled using the pivot.

구체적인 예까지 적어뒀다.

when the pivot is in the upper left corner, the Content Size Fitter will expand the Rect Transform down and to the right.

피벗이 좌상단이면 오른쪽과 아래로 늘어난다. 이게 Scroll View에서 왜 중요한지는 바로 드러난다.

피벗이 가운데(0.5, 0.5)로 남아 있으면 Content는 중심을 기준으로 양쪽으로 늘어난다. 항목을 추가할 때마다 이미 있던 항목들이 왼쪽으로 밀려나간다. Scene 창에서는 “왼쪽부터 차례대로 생성되는 것”처럼 보일 수 있는데, 그건 Content가 Viewport보다 작을 때의 모습이다. 넘어가는 순간 어긋난다.

강좌의 수평 편 사진이 멀쩡한 결과를 보여주는 것은, 그 프로젝트의 Content 피벗이 이미 맞는 값이었다는 뜻이다. 그 값이 기본값인지 저자가 맞춰둔 것인지는 확인하지 않았다 — 프리팹의 기본값을 문서에서 찾지 못했다. 어느 쪽이든 결론은 같다. 문서가 “방향을 정하는 속성”이라고 적어둔 값이 절차에 한 줄도 없다. 피벗이 맞는 프로젝트에서는 아무 문제가 없고, 틀린 프로젝트에서는 왜 틀렸는지 찾을 단서가 없다.

너비가 먼저, 높이가 나중이다

Auto Layout 문서에 순서가 못 박혀 있다.

As can be seen from the above, the auto layout system evaluates widths first and then evaluates heights afterwards.

Thus, calculated heights may depend on widths, but calculated widths can never depend on heights.

높이는 너비에 의존할 수 있지만 너비는 높이에 의존할 수 없다. 이 한 줄이 수평 목록과 수직 목록을 비대칭으로 만든다.

그래서 “수평과 수직은 축만 바꾸면 같다”는 강좌의 설명은 설정 절차에 대해서는 맞고, 동작에 대해서는 맞지 않는다. 가변 크기 항목을 쓰는 순간 수평 쪽에 먼저 한계가 온다.

인벤토리는 한 축이 아니라 격자다

강좌가 쓰는 것은 Horizontal Layout Group과 Vertical Layout Group이다. 항목을 한 줄로 세우는 컴포넌트들이다. 인벤토리는 한 줄이 아니라 격자이고, 거기에 맞는 컴포넌트가 따로 있다.

The Grid Layout Group component places its child layout elements in a grid.

그리고 이 컴포넌트에는 앞 두 절과 바로 맞물리는 속성이 있다. Constraint다. 설명이 짧은데 목적을 밝힌다.

Constraint the grid to a fixed number of rows or columns to aid the auto layout system.

“to aid the auto layout system” — 레이아웃 시스템을 돕기 위해 행이나 열 수를 고정한다는 것이다. 앞 절에서 본 “너비 먼저, 높이 나중” 때문에 이게 필요하다. 열 수가 고정되어 있으면 폭에서 행 수가 나오고, 행 수에서 높이가 나온다. 열 수가 유동이면 그 연쇄가 끊긴다.

Flexible을 골랐을 때 무슨 일이 생기는지도 적혀 있다.

The grid will attempt to make the row and column count approximately the same.

행과 열 수를 비슷하게 맞추려 한다. 인벤토리에서는 곤란하다. 아이템이 4개면 2×2, 9개면 3×3이 된다. 칸 수에 따라 열 수가 바뀐다. 매뉴얼도 이 선택의 대가를 적어둔다.

will have no control over the specific number of rows and columns

그래서 매뉴얼에는 인벤토리에 해당하는 조합이 이름까지 붙어서 따로 있다. “Fixed width and flexible height”이고, 설명이 이렇다.

where the grid expands vertically as more elements are added

적혀 있는 설정 세 줄을 그대로 옮기면 이렇다.

항목문서가 적은 값
Grid Layout Group Constraint“Fixed Column Count”
Content Size Fitter Horizontal Fit“Preferred Size or Unconstrained”
Content Size Fitter Vertical Fit“Preferred Size”

그리고 조건이 한 줄 붙는다.

If unconstrained Horizontal Fit is used, it’s up to you to give the grid a width that is big enough

to fit the specified column count of cells.

Horizontal Fit을 Unconstrained로 두면 — 즉 폭을 Anchor 스트레치로 Viewport에 맞추면 — 열 수만큼의 칸이 들어갈 폭인지는 내가 보장해야 한다. 칸 크기 × 열 수 + 간격이 Viewport 폭을 넘으면 넘친다.

앞 두 절과 이어 보면 인벤토리 Content의 설정이 전부 정해진다.

축누가 정하나값
너비(X)Anchor 스트레치Min X=0, Max X=1 / Horizontal Fit = Unconstrained
높이(Y)Content Size FitterVertical Fit = Preferred Size
늘어나는 방향PivotY = 1 (위에서 아래로)
첫 칸 위치Start Corner“The corner where the first element is located.” → Upper Left
채우는 순서Start Axis“Horizontal will fill an entire row before a new row is started.”

강좌의 양축 Anchor 지시가 인벤토리에서는 절반만 맞는 이유가 여기서 드러난다. 너비 쪽 Anchor는 의미가 있고(Fit이 Unconstrained니까), 높이 쪽 Anchor는 Vertical Fit에 먹힌다.

스크롤바를 None으로 두는 것과 Viewport를 넓히는 건 다른 설정이다

강좌의 첫 표는 스크롤바를 치우는 절차다. 네 줄로 되어 있다 — ScrollRect의 Horizontal Scrollbar/Vertical Scrollbar를 None, Viewport의 Left/Top/ Right/Bottom을 0, 스크롤바 오브젝트 둘을 비활성화.

첫 줄은 문서가 보장한다. 스크롤바 참조 설명이 이렇다.

Optional reference to a horizontal scrollbar element.

Optional이다. 비워도 되는 자리다. 그런데 두 번째 줄 — Viewport의 오프셋을 손으로 0으로 만드는 것 — 에 해당하는 전용 설정이 따로 있다. 스크롤바마다 Visibility가 있고, 설명이 이렇다.

Whether the scrollbar should automatically be hidden when it isn’t needed, and optionally expand the viewport as well.

그리고 그 옵션 중 하나에 대해 이렇게 적는다.

the viewport is automatically expanded when the scrollbars are hidden

스크롤바가 숨었을 때 Viewport가 자동으로 넓어진다. 강좌가 손으로 하는 일이 이 설정의 문서화된 동작이다.

둘의 차이는 실제로 갈린다.

방법스크롤바항목이 적을 때
참조를 None + 오브젝트 비활성화영구히 없다변화 없음
Auto Hide And Expand Viewport필요할 때만 나온다Viewport가 스크롤바 자리를 먹는다

스크롤바를 아예 안 쓸 작정이면 강좌의 방법이 단순하고 명확하다. 다만 **“스크롤바를 숨기고 싶다”**가 목적이었다면 참조를 끊을 이유가 없다. 끊어두면 나중에 스크롤바를 되살릴 때 참조부터 다시 꽂아야 한다.

참고로 Content와 Viewport도 문서에서는 참조다.

This is a reference to the Rect Transform of the UI element to be scrolled, for example a large image.

Reference to the viewport Rect Transform that is the parent of the content Rect Transform.

계층 구조가 아니라 인스펙터의 칸이다. “Content는 Viewport의 자식 객체입니다”는 프리팹이 그렇게 생겼다는 설명이고, ScrollRect가 그 둘을 찾는 방법은 참조다.

덧붙여, 같은 속성을 API 레퍼런스는 조금 다르게 적는다.

The content that can be scrolled. It should be a child of the GameObject with ScrollRect on it.

매뉴얼은 “Viewport가 Content의 부모”라고 적고, API는 “ScrollRect가 붙은 게임오브젝트의 자식”이라고 적는다. Viewport 자신이 그 오브젝트의 자식이므로 Content는 자손이지 직접 자식이 아니다. 프리팹 구조를 아는 상태에서는 둘 다 같은 말로 읽히지만, 직접 만들 때는 매뉴얼 쪽이 정확하다.

무엇이 잘라내는지가 강좌에 없다

Scroll View의 핵심 동작은 “Viewport 영역만 보이고 나머지는 안 보인다”는 것이다. 강좌도 그걸 길게 설명한다 — Content는 계속 커지지만 보이는 건 Viewport만큼이라고. 맞다. 그런데 그 가리는 일을 하는 컴포넌트가 무엇인지는 적혀 있지 않다. 문서에는 있다.

Usually a Scroll Rect is combined with a Mask in order to create a scroll view, where only the scrollable content inside the Scroll Rect is visible.

The viewport has a Mask component.

ScrollRect는 자르지 않는다. 드래그와 스크롤 위치를 다루고, 잘라내는 것은 Viewport에 붙은 마스크다. 둘이 한 세트로 묶여 있어서 프리팹을 쓰는 동안에는 구분할 필요가 없고, 직접 만들 때 비로소 드러난다. Viewport에 마스크가 없으면 Content가 Scroll View 밖까지 그대로 보인다. 스크롤은 여전히 된다. 그래서 증상이 “스크롤이 안 된다”가 아니라 “다 보인다”로 나타난다.

수직 편의 “다른 사항”에 같은 값이 적혀 있다

수직 스크롤 절은 “거의 동일합니다. 다른 사항은 다음과 같습니다”로 시작하고 네 줄을 든다. 그 첫 줄이 이것이다.

Scroll View 객체의 Width = 1000 / Height = 300 으로 설정합니다.

수평 편의 값과 같다. 수평 편 표에도 Width = 1000 / Height = 300이 적혀 있다. 다른 사항으로 적혀 있는데 다르지 않다.

값 자체도 수직 목록에는 어울리지 않는다. 1000×300은 가로로 길고 세로로 짧은 상자다. 세로로 쌓이는 목록을 그 안에 넣으면 한 번에 보이는 항목이 한두 개고, 좌우로는 공간이 남는다. 수직 편 사진이 그 상태를 보여주고 있다.

이건 복사 과정의 실수로 보인다. 나머지 세 줄(Horizontal 체크해제, Vertical Layout Group, Vertical Fit = Preferred Size)은 정확하다. 수직 목록이라면 Height를 키우고 Width를 줄이는 쪽이 맞다.

Movement Type을 안 건드리면 목록이 튕긴다

강좌가 ScrollRect에서 건드리는 것은 Horizontal/Vertical 체크와 스크롤바 참조뿐이다. 그래서 나머지는 기본값으로 남는데, 그중 하나가 체감에 바로 나타난다. Movement Type이다.

Unrestricted, Elastic or Clamped. Use Elastic or Clamped to force the content to remain within the bounds of the Scroll Rect.

세 값이고, Elastic에 대해 이렇게 적는다.

Elastic mode bounces the content when it reaches the edge of the Scroll Rect

끝에서 튕긴다. 모바일 목록이면 자연스러운 동작이고, 설정 화면의 짧은 목록이면 거슬린다. 튕김의 양은 따로 있다.

This is the amount of bounce used in the elasticity mode.

Elasticity다. 그리고 손을 뗀 뒤 계속 미끄러지는 동작도 설정이다.

When Inertia is set the content will continue to move when the pointer is released after a drag.

When Inertia is set the deceleration rate determines how quickly the contents stop moving.

Inertia와 Deceleration Rate다. 마우스 휠 감도도 있다.

The sensitivity to scroll wheel and track pad scroll events.

강좌가 이 넷을 언급하지 않은 것은 입문 자료로서 합리적인 생략이다. 다만 “만들었는데 느낌이 이상하다”의 답이 전부 이 넷에 있다는 점은 적어둘 만하다. 끝에서 딱 멈추게 하려면 Clamped, 미끄러짐을 없애려면 Inertia 해제다.

어디에 왜 쓰나

동작하는 예제

강좌의 구조를 그대로 쓰되, 위에서 짚은 것들을 코드로 보증한다. 인스펙터에서 빠뜨리기 쉬운 값(피벗, 축별 Fit)을 런타임에 한 번 맞춰주는 형태다.

using UnityEngine;
using UnityEngine.UI;

[RequireComponent(typeof(ScrollRect))]
public class ScrollListBuilder : MonoBehaviour
{
    private const float Spacing = 10f;

    [Header("참조")]
    [Tooltip("Content에 붙는다. 비워두면 ScrollRect.content에서 찾는다.")]
    [SerializeField]
    private RectTransform _content;

    [Tooltip("항목 프리팹. Layout Element가 붙어 있는 편이 안전하다.")]
    [SerializeField]
    private RectTransform _itemPrefab;

    [Header("방향")]
    [Tooltip("켜면 수평 목록, 끄면 수직 목록")]
    [SerializeField]
    private bool _isHorizontal = true;

    private ScrollRect _scrollRect;

    private void Awake()
    {
        if (!TryGetComponent(out _scrollRect))
        {
            Debug.LogError("ScrollRect가 없다.", this);
            return;
        }

        if (_content == null)
        {
            _content = _scrollRect.content;
        }

        ConfigureContent();
    }

    // 강좌에 없는 두 가지를 여기서 맞춘다 — 피벗과 축별 Fit.
    private void ConfigureContent()
    {
        if (_content == null)
        {
            Debug.LogError("Content가 비어 있다.", this);
            return;
        }

        // 문서: "the direction of the resizing can be controlled using
        // the pivot." 수평은 왼쪽(0), 수직은 위(1)에서 시작해야 한다.
        _content.pivot = _isHorizontal
            ? new Vector2(0f, 0.5f)
            : new Vector2(0.5f, 1f);

        ConfigureLayoutGroup();
        ConfigureFitter();

        _scrollRect.horizontal = _isHorizontal;
        _scrollRect.vertical = !_isHorizontal;
    }

    private void ConfigureLayoutGroup()
    {
        // 축에 맞는 그룹만 남긴다. 둘 다 붙어 있으면 서로 덮어쓴다.
        HorizontalOrVerticalLayoutGroup group = _isHorizontal
            ? GetOrAdd<HorizontalLayoutGroup>()
            : (HorizontalOrVerticalLayoutGroup)GetOrAdd<VerticalLayoutGroup>();

        group.spacing = Spacing;
        group.childAlignment = TextAnchor.MiddleCenter;
    }

    private void ConfigureFitter()
    {
        ContentSizeFitter fitter = GetOrAdd<ContentSizeFitter>();

        // 늘어나는 축만 Preferred, 반대 축은 Unconstrained로 둔다.
        // Unconstrained 축은 Anchor 스트레치가 맡는다.
        fitter.horizontalFit = _isHorizontal
            ? ContentSizeFitter.FitMode.PreferredSize
            : ContentSizeFitter.FitMode.Unconstrained;

        fitter.verticalFit = _isHorizontal
            ? ContentSizeFitter.FitMode.Unconstrained
            : ContentSizeFitter.FitMode.PreferredSize;
    }

    public void Build(int itemCount)
    {
        if (_content == null || _itemPrefab == null)
        {
            Debug.LogError("참조가 비어 있다.", this);
            return;
        }

        for (int i = _content.childCount - 1; i >= 0; i--)
        {
            Destroy(_content.GetChild(i).gameObject);
        }

        for (int i = 0; i < itemCount; i++)
        {
            Instantiate(_itemPrefab, _content);
        }

        // 레이아웃은 프레임 끝에 한 번 돌아간다. 지금 크기를 읽거나
        // 스크롤 위치를 맞추려면 강제로 돌려야 한다.
        LayoutRebuilder.ForceRebuildLayoutImmediate(_content);

        ScrollToStart();
    }

    private void ScrollToStart()
    {
        if (_isHorizontal)
        {
            _scrollRect.horizontalNormalizedPosition = 0f;
        }
        else
        {
            // 수직은 1이 위다. 0이 아니다.
            _scrollRect.verticalNormalizedPosition = 1f;
        }
    }

    private T GetOrAdd<T>() where T : Component
    {
        if (_content.TryGetComponent(out T existing))
        {
            return existing;
        }

        return _content.gameObject.AddComponent<T>();
    }
}Scripts/UI/ScrollListBuilder.cs

_content, _itemPrefab, _scrollRect에 ?.를 쓰지 않은 이유가 있다. 전부 UnityEngine.Object이고, 인스펙터에서 비워둔 참조는 null처럼 보이지만 C#의 null이 아닌 상태가 될 수 있다. 그래서 == null 비교를 쓴다. 순수 C# 객체라면 ?.가 맞다.

LayoutRebuilder.ForceRebuildLayoutImmediate가 왜 필요한지, 그리고 레이아웃 갱신이 어느 시점에 도는지는 Control Child Size를 켜면 크기를 묻는 상대가 바뀐다에 적어뒀다. 요약하면 레이아웃 계산은 즉시가 아니라 프레임 끝에 돌고, 방금 만든 목록의 전체 크기를 그 전에 읽으면 이전 값이 나온다.

verticalNormalizedPosition에 1이 위인 것도 짚어둔다. API 레퍼런스가 0 쪽만 정의한다.

The vertical scroll position as a value between 0 and 1, with 0 being at the bottom.

The horizontal scroll position as a value between 0 and 1, with 0 being at the left.

0이 아래이고 0이 왼쪽이다. 그래서 목록을 채운 뒤 맨 위로 올리려면 1을 넣는다. 수평은 0이다.

무엇을 고르나

하려는 것어디서
격자로 쌓이는 인벤토리Grid Layout Group + Constraint = Fixed Column Count
항목이 늘어나는 축의 크기Content Size Fitter의 해당 축 Fit
그 반대 축의 크기Anchor 스트레치 (Fit은 Unconstrained)
늘어나는 방향Content의 Pivot
항목 사이 간격레이아웃 그룹의 Spacing
내용을 Viewport 밖으로 안 보이게Viewport의 Mask
스크롤바를 영구히 없앤다참조를 None + 오브젝트 비활성화
필요할 때만 스크롤바를 보인다스크롤바 Visibility
끝에서 튕기지 않게Movement Type = Clamped
손 뗀 뒤 미끄러지지 않게Inertia 해제

축을 고르는 기준은 이렇게 두면 된다.

가변 크기 항목을 쓸 거라면 수직을 먼저 고려하라. 너비가 먼저, 높이가 나중에 계산되므로 폭에 따라 높이가 달라지는 항목은 수직 목록에서 성립하고 수평 목록에서는 성립하지 않는다.

쓰지 말아야 할 자리

정리


참고

이 글의 출발점이 된 자료는 [유니티 기초] UI 편 - Layout 컴포넌트를 이용한 Scroll View 항목 생성 (레오란다, 2022-12-05)이다. Unity 2021.3 LTS 기준 연재물의 한 편이고, 설정 절차는 원문을 그대로 따라가면서 각 항목을 현행 uGUI 2.7.0 매뉴얼과 API 레퍼런스에 대조했다. 확인 시점은 2026-10-09이다.


이 글 공유하기:

이전 글
에러 페이지가 돌아와도 result는 ConnectionError가 아니다
다음 글
화질을 쥐고 있는 건 Color Format이 아니라 해상도와 Aspect Ratio다