기록하며 노세

값 형식의 배열, 객체 참조 배열, 그리고 Instantiate

늙어서노세 2024. 2. 1. 18:13

우리는 보통 프로그래밍을 할 때, 배열을 일정 크기만큼 생성하면 곧이 곧대로 그 배열의 크기만큼 메모리도 사용하는 줄 알게 된다. 예를 들면,

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ArrayComponent : MonoBehaviour
{

    void Start()
    {
        int[] values = new int[5];
    }
}

이렇게 작성하게 되면 int의 크기인 4Bytes가 5개 생성이 되는 줄 아는 것은 어찌 보면 당연하다. 하지만 이는 약간의 오류가 있다. 배열이라고 한다면, 배열은 그대로 크기만큼 메모리를 차지하는 것이 아니라, values라는 값은 참조 변수까지 생성하게 된다. 즉, int[] values는 values라는 참조 변수를 생성하고, = new int[5];에서 5개의 정수를 저장하는 배열을 생성한 것이다. 위에서 말한 정수형 배열은 '값 형식의 배열'에 해당한다.

 

그러면 객체 참조 배열이란 무슨 뜻일까? 위의 부분에서 = new int[5]; 부분에서 객체 형식의 배열과 차이가 있다. 객체 형식의 배열을 예로 들어보면,

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ArrayComponent : MonoBehaviour
{
    void Start()
    {
        GameObject[] gameObjects = new GameObject[5];
    }
}

이렇게 값 형식의 배열과 비슷한 코드로 객체 참조 배열을 생성하였다. 이 부분을 분석해보자.

먼저 GameObject[] gameObjects에서는 값 형식의 배열과 마찬가지로 gameObject는 참조 변수를 뜻한다. 이후 new GameObject[5];에서는, 값 형식의 배열은 5개의 정수를 저장하는 배열을 생성하는 코드였지만 객체 참조 배열은 각 배열마다 하나의 객체를 참조하는 참조 변수의 배열을 생성한 것이다. 즉, 배열을 생성하기엔 아직 한 가지 단계가 부족한 것이다.

 

우리는 값 형식의 배열을 생성한 후, 초기화를 진행한다. 생성한 직후 바로 초기화를 하거나, read하기 직전 값을 어딘가로부터 받아와 초기화를 하거나 그 방법은 다양하다. 하지만 그러한 값 형식의 배열과는 다르게, 이 객체 참조 배열은 각 배열이 가리키는 주소들을 '객체화' 시켜주어야 한다. 금일 학습한 코드의 일부를 아래에 다시 작성해보았다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GameObjectArray1 : MonoBehaviour
{
    [SerializeField] private Material colorMaterial;

    [SerializeField] private GameObject colorCubePrefab;

    // ColorCube 객체 참조 배열의 참조 변수
    [SerializeField] private ColorCube[] colorCubes;

    [SerializeField] private int cubeCount;

    void Start()
    {
        // * ColorCube 객체의 참조 배열 생성 (객체 배열 아님)
        colorCubes = new ColorCube[cubeCount];

        // 생성된 ColorCube 객체의 참조 배열의 요소를 접근하여 큐브 색상 변경 메소드를 호출함.
        for (int i = 0; i < cubeCount; i++)
            colorCubes[i].SetColor(colorMaterial);
    }
}

위의 코드는 colorCubes라는 참조 변수를 생성하고, colorCubes = new ColorCube[cubeCount];를 작성하여 참조 변수의 배열을 생성하였다. 하지만 한 단계가 빠진 채로 메소드 자체로 의미가 추정되는 SetColor() 메소드를 사용하고 있다. 이런 경우에는 nullReferenceException 에러가 발생하게 된다. 이를 한번 수정하여 에러가 나지 않게 해보자.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GameObjectArray1 : MonoBehaviour
{
    [SerializeField] private Material colorMaterial;

    [SerializeField] private GameObject colorCubePrefab;

    // ColorCube 객체 참조 배열의 참조 변수
    [SerializeField] private ColorCube[] colorCubes;

    [SerializeField] private int cubeCount;

    void Start()
	{
    // * ColorCube 객체의 참조 배열 생성 (객체 배열 아님)
    colorCubes = new ColorCube[cubeCount];

    // 생성된 ColorCube 객체의 참조 배열의 요소를 접근하여 큐브 색상 변경 메소드를 호출함.
    for (int i = 0; i < cubeCount; i++)
    {
        // ColorCube 참조 배열[인덱스번째] 변수에 ColorCube 객체를 생성하여 참조값을 저장시킴
        ColorCube cube = new ColorCube();
        colorCubes[i] = cube;
        colorCubes[i].SetColor(colorMaterial);
    }
}
}

Start() 메소드의 변화점을 유심히 살펴보자. for문에서 ColorCube cube = new ColorCube()라는 독립적인 객체를 생성하여 ColorCubes[] 참조 배열에 해당하는 객체인 cube를 연결해 주었다. 즉 참조해 주었다. 이후 SetColor()라는 Cube에 색상을 입히는 메소드를 실행한 것을 확인할 수 있다. 이렇게 수정한다면 언급했던 nullReferenceException 에러가 발생하지 않는 것을 확인할 수 있다.

 

이러한 배열의 패턴을 응용하여, 동적 배열과 정적 배열을 활용한 유니티 개발을 할 수 있다. 일단 동적과 정적의 정의는 간단하게 설명하면, 동적은 프로그램이 시작되는 순간과 끝나는 순간의 전체 갯수가 달라지는 경우이고 (프로그램 실행 중에 전체 갯수가 변하는 경우), 정적은 시작할 때 갯수가 고정되어 변하지 않는 경우라고 말할 수 있다. 위에서 작성된 코드에서, 동적 배열의 특성을 나타내는 부분은 바로 cubeCount 부분이다. [SerializeField]로 유니티의 Inspector 창에서 값을 유동적으로 기입하고, 이로 인해 배열이 프로그램 실행 시에 동적으로 크기가 결정되게 되는 것이다.

 

오늘 학습했던 내용들을 종합하여 작성한 아래 코드는, 3D GameObject인 colorCube 5개를 생성하고 5개의 colorCube 내부에 있는 객체들을 복사하여 불러온 후 색상을 변경하여 보여주는 GameObjectArray1.cs코드이다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GameObjectArray1 : MonoBehaviour
{
    [SerializeField] private Material colorMaterial;

    [SerializeField] private GameObject colorCubePrefab;

    // ColorCube 객체 참조 배열의 참조 변수
    [SerializeField] private ColorCube[] colorCubes; // 1

    [SerializeField] private int cubeCount;

    // Start is called before the first frame update
    void Start()
    {
        // * ColorCube 객체의 참조 배열 생성 (객체 배열 아님)
        colorCubes = new ColorCube[cubeCount]; // 2
        
        // 생성된 ColorCube 객체의 참조 벼열의 요소를 접근하여 큐브 색상 변경 메소드를 호출함
        for (int i=0; i< cubeCount; i++)
        {
            // 일반적인 배열 요소 객체 생성 방법
            // ColorCube 참조배열[인덱스번째] 변수에 ColorCube 객체를 생성하여 참조값을 저장 시킴
            //ColorCube cube = new ColorCube(); // 3
            //colorCubes[i] = cube;
            //colorCubes[i].SetColor(colorMaterial);

            float z = i * 2;
            Vector3 pos = new Vector3(-2f, 0f, z);

            // 유니티에서 스크립트 컴포넌트를 생성하기 위해 Instantiate 메소드를 사용해 게임오브젝트를 생성해야 함

            // -> ColorCube 게임오브젝트가 생성된다는건 내부적으로 아래 객체들이 모두 생성된다는 것을 의미
            // // : Transform 객체, GameObject 객체, MeshFilter 객체, MeshRenderer 객체, BoxCollider 객체, ColorCube 객체
            GameObject cube = Instantiate(colorCubePrefab, pos, Quaternion.identity, transform);
            // Object.GetComponent<컴포넌트타입>() : 게임오브젝트의 특정 컴포넌트만을 참조할 때 사용하는 메소드
            ColorCube colorCube = cube.GetComponent<ColorCube>();
            colorCubes[i] = colorCube; // ColorCube 객체 참조 배열 요소에 ColorCube 객체의 참조값을 저장 시킴
            colorCubes[i].SetColor(colorMaterial); // 참조 배열에 참조 시킨 ColorCube 객에의 색상 설정 메소드를 실행 시킴
        }
    }
}

대략적으로 이 코드에 대해서 설명하면, 먼저 유니티에 colorCube라는 3D Cube GameObject를 5개 생성하였다. 이후 5개 중 하나의 Cube GameObject를 Inspector 창의 colorCubePrefab에 적용시킨다. 차후 Start() 메소드에서 이 복제된 Prefab을 for문 내에서 Instantiate 메소드로 Component들을 복제하여 나타낼 것이다.

그리고 colorCubes라는 객체 배열의 참조 변수를 선언하였다. 마지막으로 선언한 cubeCount 는 동적으로 객체 배열을 선언할 수 있게 하기 위해 SerializeField를 작성하여 Inspector에서 유동적으로 작성할 수 있게 해준 모습을 볼 수 있다.

 

이제 Start() 메소드를 보면, 새롭게 colorCubes 참조 변수에 객체 배열을 cubeCount 만큼 생성해주고, cubeCount만큼 반복문을 실행하여 z 위치만 다르게 적용하여 GameObject를 복제한다. 더불어 ColorCube 객체도 GetComponent로 불러와 ColorCubes[] 배열의 각 객체 배열, 참조 배열이 가리키는 곳을 객체화시킨다. 마지막으로 ColorCube.cs 내에 간략히 작성한 SetColor() 메소드를 통해 임의 지정한 색으로 해당 객체, 즉 복제된 colorCube 내에 존재하는 객체의 색상을 변경한다.

 

우리는 이 코드에서 새롭게 나타난 문법이 있음을 알 수 있다. 바로 Instantiate(colorCubePrefab, pos, Quaternion.identity, transform) 부분이다. 먼저 위에서 설명했듯이, 이 문법은 GameObject를 복사할 때 사용된다. 매개변수들을 정석화해 보면

GameObject gameObject = Instantiate(게임오브젝트 원본, Vector3로 설정한 위치, 생성된 게임 오브젝트의 회전, 생성된 게임 오브젝트의 부모)

로 작성할 수 있다. 이렇게 복제된 GameObject에 GetComponent를 하여 색을 변경할 수 있는 ColorCube.cs 컴포넌트를 불러온 후 SetColor() 메소드를 사용하여 색을 변경하는 것이다. 풀어서 설명을 하면 쉬울 수 있겠지만, 처음 작성할 때는 본인도 '이게 무슨 소리지?' 할 정도로 새로 보는 문법이었다. 하지만 반복하여 생각해보고, 용법을 찾아보니까 금방 이해할 수 있었다.

 

여기까지 동적 배열과 Object 복제를 활용한 GameObjectArray1.cs 작성이었다. 다음으로는 정적 배열을 활용한 GameObjectArray2를 학습하였다. 먼저 코드를 살펴보면,

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GameObjectArray2 : MonoBehaviour
{
    [SerializeField] private Material colorMaterial;

    // 정적으로 정해진 요소의 배열을 생성하고 참조 시켜야 할 경우에는
    // 인스펙터를 이용해 객체 배열의 참조 변수의 코드만 선언해주면됨
    // 실질적인 객체 생성 및 참조는 인스펙터와 계층뷰/씬뷰 등을 이용해 게임오브젝트를 Drag & Drop 형식으로 설정(구현)해 주면 됨
    [SerializeField] private ColorCube[] colorCubes;

    void Start()
    {
        // 인스펙터를 통해 생성 및 참조가 모두 이루어진 객체 배열을 통해 큐브들의 색상을 변경하는 메소드를 실행해 줌
        // * 객체 참조 배열 생성 코드 생략, 생성된 객체 요소를 객체 참조 배열에 참조 시키는 코드 생략
        for (int i=0; i<colorCubes.Length; i++)
        {
            colorCubes[i].SetColor(colorMaterial);
        }        
    }

}

생각보다 간단하다. 간단한 이유가 있는데, 참조 변수인 colorCubes를 생성만 하고 Inspector 창에 나타난 colorCubes 참조 변수에다 Drag & Drop으로 Cube 3D GameObject 5개를 설정하였기 때문이다. 이렇게 GameObjectArray2.cs에서 주석으로 2, 3번으로 표현한 객체 배열 설정, 각 객체 배열 (인덱스)번째에 객체 생성 부분을 Inspector 창의 Drag & Drop으로 건너뛰고 바로 SetColor() 메소드를 사용하는데 에러가 나지 않는 모습을 볼 수 있다.

 

이렇게 객체 배열을 활용하는 것은 경우가 다양하지만, 정적으로 표현할 것인가 동적으로 표현할 것인가에 따라 코드의 복잡도도 다양해지는 것 같다. 후자의 경우를 동적으로 활용할 수 있을 것 같은 느낌도 든다. 여러 다양성을 배열과 함께 표현하여 응용하여 본 유용하고 좋은 시간이었다.