Programming 

씬 불러오기

 

작성 기준 버전 :: 2019.2

 

[이 포스트의 내용은 유튜브 영상으로도 시청하실 수 있습니다]

 

유니티 엔진의 씬은 게임의 맵이나 레벨 개념에 해당한다. 그리고 대부분의 게임은 하나보다 많은 맵이나 레벨, 즉 씬으로 구성된다. 그래서 게임에서 다른 씬으로 넘어가기 위해서는 씬과 씬 사이를 이동하기 위해서 다른 씬을 불러오는 방법을 알아야 한다.

 

씬 세팅하기

 

 

먼저 프로젝트를 제일 처음 생성했을 때 있는 씬에 큐브 오브젝트를 생성하고 카메라 앞 왼쪽 화면에 보이게 적당히 배치한 다음 씬을 저장한다. [Ctrl + S] 단축키를 누르면 간단하게 씬의 변경 내용을 저장할 수 있다.

 

그리고 프로젝트 뷰의 Scenes 폴더 아래에 지금 로드되어 있는 씬인 Sample Scene의 이름을 Scene1로 변경한다.

 

그 다음에는 이 Scene1에서 이동하게 될 새로운 씬을 생성하자.

 

 

프로젝트 뷰에 우클릭하고 [Create > Scene] 항목을 선택하면 새로운 씬을 생성할 수 있다. 새로 생성한 씬의 이름은 Scene2로 한다.

 

 

이렇게 생성한 씬 애셋을 더블 클릭하면 하이어라키에 열려있던 씬이 Scene1에서 Scene2로 바뀐다.

 

 

그리고 Scene2에는 Shpere 게임 오브젝트를 생성해서 카메라 앞 오른쪽 화면에 보이게 배치한다. 이렇게 하면 Scene1에 있다가 Scene2를 불러오면 화면 앞에 있는 게임 오브젝트의 모양이 바뀌면서 씬이 바뀌었다는 것을 명확하게 알 수 있을 것이다.

 

Scene2를 저장하고 다시 Scene1로 돌아간다.

 

SceneMover 스크립트 작성하기

 

그 다음에는 SceneMover라는 이름으로 C# 스크립트를 생성하자.

 

using UnityEngine.SceneManagement;

 

제일 먼저 스크립트의 상단에 using UnityEngine.SceneManagement; 라는 코드를 작성한다. 이 코드는 개발자가 스크립트 에디터에게 "SceneMover.cs 파일에서 UnityEngine.SceneManagement 네임스페이스에 들어있는 씬과 관련된 기능을 사용하겠다"라고 알려주는 역할을 한다.

 

참고로 네임스페이스라는 것은 C#에서 특정한 기능을 묶어두는 데 주로 사용된다.

 

여기 UnityEngine.SceneManagement 네임스페이스에서는 유니티 엔진의 씬과 관련된 기능들이 담겨있다.

 

public class SceneMover : MonoBehaviour

{

    void Update()

    {

        if(Input.GetKeyDown(KeyCode.Space))

        {

            SceneManager.LoadScene("Scene2");

        }

    }

}

 

네임스페이스 선언이 끝나면 Update 함수에서 스페이스 키를 입력을 받았을 때, SceneManager.LoadScene 함수를 호출하게 만든다. 이 함수로 다른 씬을 불러올 수 있다. 

 

매개변수에 불러오고자하는 씬의 이름이나 번호를 넣으면 된다. 우리는 Scene1에서 Scene2로 이동할 생각이기 때문에 Scene2라는 이름을 넣어주면 된다.

 

코드를 저장하고 에디터로 돌아가서 게임 오브젝트를 하나 만들고 거기에 SceneMover 컴포넌트를 추가한다.

 

 

그리고 플레이 버튼을 누르고 스페이스 키를 눌러보면 Scene2라는 씬이 빌드 세팅에 추가되지 않았거나 애셋 번들로부터 불러올 수 없어서 로드할 수 없다는 로그가 나온다.

 

유니티에서 씬을 불러오기 위해서는 씬이 저장된 애셋 번들이 있든지 아니면 씬이 빌드 세팅에 추가되어 있어야 한다.

 

빌드 세팅에 씬 추가하기

 

씬을 빌드 세팅에 추가 시켜보자.

 

 

우선 상단 메뉴바에서 [File > Build Settings...] 항목을 선택하면 빌드 세팅 창이 열린다.

 

 

창 위쪽에 비어있는 Scenes In Build 칸을 볼 수 있는데 여기에 추가된 씬들은 게임에 포함되어 빌드된다.

 

만들어둔 Scene1과 Scene2를 Scenes In Build 칸에 끌어다 놓으면 Scenes In Build 칸에 추가한 씬이 표시될 것이다.

 

빌드 세팅 창을 끄고 다시 플레이를 시킨 뒤, 스페이스 버튼을 눌러보면 Scene1에서 Scene2로 이동하면서 Cube 오브젝트가 사라지고 Sphere 오브젝트가 나타난다.

 

이게 바로 제일 기본적인 씬 이동 방법이다.

 

Additive로 씬 불러오기

 

첫 번째 방법은 씬을 완전히 이동하는 방법이었다면 이번에는 기존의 씬을 남겨둔 상태로 새로운 씬을 겹쳐서 불러오는 방법을 알아보자.

 

public class SceneMover : MonoBehaviour

{

    void Update()

    {

        if(Input.GetKeyDown(KeyCode.Space))

        {

            SceneManager.LoadScene("Scene2", LoadSceneMode.Additive);

        }

    }

}

 

매개변수 "Scene2"뒤에 콤마(,)를 찍으면 두 번째 매개변수로 넣을 수 있는 옵션이 나타나는데, 형식이 LoadSceneMode 열거형인 것을 알 수 있다.

 

LoadSceneMode 모드에는 Single과 Addtive, 두 가지가 있는데 우선 Single은 기본 옵션으로 앞에서 구현한 것처럼 씬을 불러오면 이전 씬은 완전히 사라지고 새로운 씬으로 바뀌는 옵션이다. 그리고 Additive는 앞의 씬을 남겨두고 거기에 얹어서 새로운 씬을 불러오는 것이다.

 

두 번째 매개변수에 LoadSceneMode.Additive를 넣어준 뒤 코드를 저장하고 에디터로 돌아가서 게임을 플레이 시킨 다음에 스페이스 키를 누르면 Scene1이 남아있는 상태로 Scene2가 불러와지는 것을 볼 수 있다.

 

다만 콘솔 창을 보면 로그가 굉장히 많이 발생하고 씬을 불러오기 전보다 매우 밝은 것을 알 수 있는데, 이것은 씬 안에 소리를 감지하는 Audio Listener라는 컴포넌트와 Directional Light가 두 개가 있어서 발생하는 문제이다. 그리고 메인 카메라도 중복으로 두 개가 있는 상태이다.

 

이런 문제들 때문에 Addtive로 불러올 씬에는 불필요한 카메라나 Directional Light를 만들지 않는 것이 좋다.

 

비동기 방식 씬 불러오기

 

지금까지 사용한 LoadScene 함수는 동기 방식 함수이다. 이게 무슨 의미인가하면 LoadScene 함수로 씬을 호출하면 씬을 불러오는 과정이 끝날 때까지 다른 일을 아무 것도 하지 못한다는 뜻이다.

 

이 예시처럼 씬에 고작 오브젝트가 몇 개만 있는 게임이라면 LoadScene 함수만으로 씬을 불러와도 되겠지만 최신 게임들처럼 씬 하나에 엄청나게 많은 오브젝트들이 들어있는 무거운 게임이라면 씬을 불러오는 긴 시간동안 아무 것도 하지 못하게 될 것이다.

 

다른 씬을 불러오는 도중에 팁을 보여준다든지 플레이어에게 미니 게임을 할 수 있게 한다든지 해서 씬이 불러와지는 시간에 플레이어가 지루함을 느끼지 않도록 하려면 다른 작업을 처리할 수 있어야 한다. 거기에 필요한게 비동기 방식 씬 불러오기이다.

 

public class SceneMover : MonoBehaviour

{

    void Update()

    {

        if(Input.GetKeyDown(KeyCode.Space))

        {

            StartCoroutine(LoadSceneCoroutine());

        }

    }

 

    IEnumerator LoadSceneCoroutine()

    {

        yield return SceneManager.LoadSceneAsync("Scene2");

    }

}

 

LoadSceneAsync 함수를 사용하면 함수가 씬을 불러오는 도중에 다른 작업을 처리할 수 있게 된다.

 

단, 이 함수를 제대로 사용하려면 코루틴과 함께 사용해야 한다.

 

코드를 저장하고 에디터로 돌아가서 게임을 플레이 시킨 다음 스페이스 키를 누르면 사실 눈에 띄는 변화는 없다.

 

비동기 씬 로딩이 효과가 있으려면 그만큼 불러올 씬이 무겁고 불러올게 많아야 의미가 있다.

 

비동기 씬 불러오기 방식을 응용하는 방법은 아래의 포스트에서 확인할 수 있다.

 

[로딩 화면 구현하기(UI 방식)]

 

[로딩 화면 구현하기(로딩 씬 방식)]

반응형

Programming 

코루틴(Coroutine) 다루기

 

작성 기준 버전 :: 2019.2

 

[이 포스트는 유튜브 영상을 통해서도 시청하실 수 있습니다]

 

Update 함수는 게임 오브젝트가 활성화된 상태에서 매 프레임 호출되어 수행된다. 그래서 유니티 엔진으로 게임을 만들 때는 대부분의 게임 동작을 Update 함수에서 작동하도록 구현한다.

 

그런데 Update 함수는 멈추지 않고 계속해서 동작하는 함수이기 때문에 여기서 일시적으로 돌아가는 서브 동작을 구현하는 것과 어떤 다른 동작이 처리되는 것을 기다리는 기능을 구현하기는 매우 까다롭다.

 

그리고 Update 함수에서 해당 기능을 구현하기 어렵지 않다고 하더라도, 잠시 돌아가는 기능을 Update 함수에 모두 구현하는 것은 비대한 몸집의 Update 함수를 만들어 내서 나중에 게임을 유지보수하는 것이 매우 어려워지는 결과를 낳게 된다.

 

이렇게 한 컴포넌트 내에서 Update 함수와 따로 일시적으로 돌아가는 서브 동작을 구현하거나, 어떤 다른 작업이 처리되는 것을 기다리는 기능을 구현하는데 쓰이는 것이 바로 코루틴이다.

 

Update로 구현한 공격 딜레이

 

코루틴이 필요할 법한 간단한 예시를 들기 위해서 스페이스 키를 누르면 캐릭터가 공격했다고 가정하고 딜레이를 줘서 그 시간 동안에는 다시 공격을 하지 못하게 만드는 기능을 만들어 보자.

 

public class Attacker : MonoBehaviour

{

    public bool isDelay;

    public float delayTime = 2f;

 

    float timer = 0f;

    void Update()

    {

        if (Input.GetKeyDown(KeyCode.Space))

        {

            if (!isDelay)

            {

                isDelay = true;

                Debug.Log("Attack");

            }

            else

            {

                Debug.Log("Delay");

            }

        }

 

        // 업데이트로 구현한 공격 딜레이

        if (isDelay)

        {

            timer += Time.deltaTime;

            if (timer >= delayTime)

            {

                timer = 0f;

                isDelay = false;

            }

        }

    }

}

 

위와 같이 코드를 작성하고 플레이 해보면, 처음 스페이스 키를 누르면 "Attack" 로그가 나오지만, 딜레이가 지나기 전에 다시 누르면 "Delay" 로그가 표시된다.

 

그리고 로그가 지난 이후에 스페이스 키를 눌러야 "Attack" 로그가 출력된다.

 

간단한 기능이라 구현이 그리 어렵지는 않았지만, 앞에서 이야기한 것과 같이 게임 기능이 계속해서 추가될 때마다 "공격 딜레이가 발생했을 때만" 동작하는 코드 같은 일시적 동작 코드가 Update 함수에 계속해서 늘어나면, Update 함수가 비대화되고 유지보수가 어려워진다.

 

코루틴으로 구현한 공격 딜레이

 

똑같은 기능을 이번에는 코루틴으로 구현해보자.

 

// 코루틴으로 구현한 공격 딜레이

IEnumerator CountAttackDelay()

{

    yield return new WaitForSeconds(delayTime);

    isDelay = false;

}

 

먼저 코루틴을 사용하기 위해서는 코루틴 함수를 만들어야 한다.

 

코루틴 함수를 만드는 방법은 간단하게 반환형만 IEnumerator로 만들어주면 된다.

 

그리고 yield return이란 코드를 작성해주면 된다. 이것은 코루틴에서 동작하는 제어권을 유니티에 다시 돌려준다는 뜻이다. 이 yield return 지점에 도착하면 코루틴은 반환 타입으로 정의한 만큼 코드 동작을 중지하고 제어권을 유니티에 돌려준다. 그리고 반환 타입의 조건이 충족되면 이 다음 줄부터 다시 코루틴이 동작한다.

 

코루틴이 제어권을 얼마나 양보할 지 정하는 반환 타입에는 여러 가지가 있다.

 

// 한 프레임 기다림

yield return null;

// 게임 시간으로 1초 기다림(time scale에 영향받음)

yield return new WaitForSeconds(1f);

// 실제 시간으로 1초 기다림(time scale에 영향받지 않음)

yield return new WaitForSecondsRealtime(1f);

// 다음 FixedUpdate 끝날 때까지 기다림

yield return new WaitForFixedUpdate();

// 다음 프레임의 Update와 모든 렌더링이 끝날 때까지 기다림

yield return new WaitForEndOfFrame();

 

코루틴을 모두 작성하고 나면 Update 함수를 아래와 같이 수정하면 된다.

 

void Update()

{

    if (Input.GetKeyDown(KeyCode.Space))

    {

        if (!isDelay)

        {

            isDelay = true;

            Debug.Log("Attack");

            StartCoroutine(CountAttackDelay());

        }

        else

        {

            Debug.Log("Delay");

        }

    }

 

    // 업데이트로 구현한 공격 딜레이

    //if (isDelay)

    //{

    //    timer += Time.deltaTime;

    //    if (timer >= delayTime)

    //    {

    //        timer = 0f;

    //        isDelay = false;

    //    }

    //}

}

 

코루틴 함수는 실행할 때 일반 함수처럼 호출하는 것이 아니라, StartCoroutine 함수를 이용해서 호출해야 한다.

 

코루틴으로 공격 딜레이를 구현하면 Update 함수에서 전부 구현하는 것보다 훨씬 쉽고 간단하게 똑같은 기능을 구현할 수 있다.

 

코루틴과 관련된 포스트

 

코루틴과 관련하여 추가로 확인할 만한 포스트는 아래와 같다.

 

[코루틴 내부에서 무한 루프를 사용할 때 주의점]

 

[코루틴의 호출 시점에 대한 주의점]

 

[커스텀으로 yield return 조건 만들기]

 

반응형
  1. psh 2021.11.15 02:40

    베르님 감사합니당!

Resources 

프로젝트 뷰에서 리소스 바로 가져오기

 

작성 기준 버전 :: 2019.2

 

[유튜브 영상으로 본 포스트의 내용을 시청하실 수 있습니다.]

 

 

유니티 엔진에 아직 숙달되지 않은 개발자들은 초반에 작업할 때, 위의 이미지와 같이 프로젝트 뷰에서 직접 인스펙터 뷰의 프로퍼티에 드래그&드롭으로 리소스를 할당해주는 방식을 사용할 것이다.

 

이렇게 드래그&드롭으로 리소스를 할당하는 방법은 매우 직관적이고 간편한 방법이지만, 지금처럼 하나의 오브젝트가 아닌 10개의 오브젝트를 세팅해야한다면 어떨까? 

 

이렇게 네 종류의 리소스를 하나의 게임 오브젝트에 끼워넣는 작업에 네 번의 드래그&드롭 작업을 했으니 10개의 게임 오브젝트에 세팅 작업을 하려면 무려 40번의 드래그&드롭 작업을 해야한다.

 

거기에 만약 작업할 게임 오브젝트의 수가 리그 오브 레전드의 챔피언 숫자처럼 100개가 넘어간다면 400번이 넘는 반복 작업을 해야할 것이다.

 

생각만 해도 아득해지고 손목이 아파오는 작업이다.

 

그리고 이렇게 프로젝트 뷰에서 인스펙터 뷰의 프로퍼티로 끌어다가 할당하는 작업은 일종의 정적인 할당에 가깝기 때문에 처음부터 끝까지 수동으로 사람의 손을 거쳐야만 해서 생각보다 많은 작업 시간을 소모하게 만든다.

 

그래서 이런 반복 작업에 대해서 프로그래머들이 말하는게 있다.

 

"10번만 반복해야해도 자동화시켜라."

 

하지만 지금처럼 손으로 끌어다 놓는 방법은 어떻게 해도 자동화가 불가능할 것이다.

 

그럼 이 문제를 해결하기 위해서 프로젝트에 포함된 애셋을 프로젝트 뷰에서 가져오는 방법을 알아야 한다.

 

 

프로젝트 뷰에 Resources라는 이름의 폴더를 만들고 아까 게임 오브젝트에 할당해줬던 애셋들을 생성한 폴더 안으로 이동시킨다.

 

void Start()

{

    prefab = Resources.Load<GameObject>("prefab");

    material = Resources.Load<Material>("material");

    texture2D = Resources.Load<Texture2D>("texture");

    textAsset = Resources.Load<TextAsset>("text");

}

 

Resources.Load 함수를 사용하면 Resources 폴더 안에 들어있는 애셋들을 스크립트에서 불러와서 사용할 수 있게 된다. 그리고 <> 괄호 안에는 게임 오브젝트는 GameObject, 머티리얼은 Material, 텍스쳐는 Texture2D, 텍스트 파일은 TextAsset과 같이 해당 애셋의 타입에 맞는 클래스 이름을 넣어주면 된다.

 

그리고 매개변수로는 Resources 폴더 아래에 있는 애셋의 경로를 넣어주면 되는데, 지금의 예시에서는 애셋들이 폴더 바로 아래에 있어서 애셋 파일의 이름만 넣어주면 된다. 예를 들어서 만약 애셋 종류별로 폴더를 나눠서 정리하기로 해서 texture 애셋을 Texture 폴더 안에 넣기로 했다면, "Texture/texture"로 경로를 적어주면 된다.

 

 

코드를 모두 작성하면 자동으로 할당되는지 테스트 해보기 위해서 이미 모든 프로퍼티가 할당되어 있는 컴포넌트를 삭제하고 새로 컴포넌트를 붙여서 프로퍼티를 비워준다.

 

그 다음 플레이 버튼을 눌러서 게임을 시작해보면 플레이가 시작되는 순간에 비어있던 프로퍼티들이 Resources 폴더 안에서 불러온 애셋들로 채워지는 것을 볼 수 있다.

 

사실 이 Resources 폴더는 유니티 엔진의 예약 폴더라는 것으로 Resources 클래스를 이용하면 유니티 프로젝트에 포함된 Resources라는 이름의 폴더에 접근할 수 있게 해주는 기능이다.

 

 

이 Resources 폴더가 어디에 있든 이렇게 A 폴더 아래에 있든 B 폴더 아래에 있든 Resources.Load 함수를 사용하면 언제든지 그 아래에 있는 애셋을 가지고 올 수 있다. 게다가 A 폴더와 B 폴더, 둘 다 Resources 폴더를 가지고 있어도 무리없이 하나의 Resources 폴더처럼 동작한다.

 
이렇게 편리하게 프로젝트 안에 있는 애셋을 불러올 수 있는 기능이지만, 한 가지 명심해야할 점이 있다. Resources 폴더에 들어있는 애셋들은 게임이 실행되면 무조건 메모리에 적재된다.
 
그렇기 때문에 게임이 과도한 메모리를 소모하는 문제를 발생시키고 싶지 않다면, 비교적으로 메모리를 적게 소모하면서 빠르고 손쉽게 가져와야하는 애셋만을 Resources 폴더에 넣어둘 것을 권장한다.

 

반응형
  1. 료용 2020.03.17 16:37 신고

    오랜만이시네요 베르님~ 이렇게 하는게 getcomponent 하는거보다 빠를까요?

    예전에 프리팹관련으로 저렇게 쓰려다가 실패했던경험이 ㅋㅋㅋ 클래스 이름에 안맞게해서 그랫나봐요

    • wergia 2020.03.18 13:21 신고

      오랜만입니다 료용님!
      GetComponent와 Resources.Load 함수는 용도가 다릅니다.
      GetComponent는 게임 오브젝트에 부착된 컴포넌트를 가져오는 용도이고
      Resources.Load는 프로젝트의 Resources 폴더 아래에 있는 애셋을 가져오는 함수입니다.
      다만, 본문에서도 언급했듯이 Resources 폴더에 넣어둔 애셋들을 프로그램이 실행되면 무조건 메모리에 적재되기 때문에 과다한 양의 애셋을 넣어두면 메모리 부족 문제를 야기할 수 있기 때문에, 애셋이 필요할 때 메모리에 로드했다가, 필요없어지면 메모리에서 언로드할 수 있는 애셋 번들을 사용하는 것입니다.

    • 료용 2020.03.18 20:38 신고

      질문을 너무 허접하게했습니다 제가 ㅠㅠ

      그러니까 미리 배열이나 오브젝트를 만들어서 넣어두는거랑(예를들면 Sprite나 Image같은것) 저렇게 폴더에서 가져오는거랑 속도차이가 심하게날까요?

    • wergia 2020.03.19 10:42 신고

      둘 다 메모리에 적재되기 때문에, 속도 차이는 크지 않을 겁니다.
      하지만, 프로퍼티에 미리 넣어두는 것은 씬 안에 너무 많이 할당되어 있으면 씬을 불러올때 과부하가 걸릴 것이고
      Resources 폴더에 너무 많은 애셋을 넣어두는 것은 빌드된 게임 크기가 너무 커지고 , 게임 실행 시간이 길어질 수 있습니다.
      둘 다 메모리 문제도 발생할 겁니다.

    • 료용 2020.03.19 23:15 신고

      결국 상황에 맞게써야되는거군요

읽기 좋은 코드를 위한 간단한 원칙

 

 

 

프로그래머들 사이에선 이런 농담들이 있다.

 

이 코드가 무슨 코드인지는 오직 신과 나만이 안다.

그리고 이제는 오직 신만이 아신다.

 

이게 무슨 의미인가 하면, 작업할 당시에는 내가 아는 코드였지만 시간이 지나면 자신도 본인의 코드를 이해하지 못하게 되는 경우가 있다는 뜻이다. 혼자서 하는 작업도 이럴진데, 타인과 하는 작업은 어떨까? 사람마다 코드 작성 타입은 각양각색이라 다른 사람의 코드를 읽어내기가 몇 배는 더 힘들어진다.

 

특히 혼자서 프로그래밍을 공부하던 사람이 다른 학생와 조별 프로그래밍 과제를 한다거나, 다른 개발자나 협업을 하게 되었을 때 자신의 평소 코딩 스타일과 다른 스타일의 코드를 만나게 되면 격렬한 동공지진을 일으키게 된다. 거기에 주석까지 없다면 혼란은 가중된다.

 

그럼 이제 읽기 좋은 코드를 만들기 위한 고민을 시작해보자.

 

 

코드 블럭 중괄호 스타일 { }

 

프로그래머들 사이에서 가장 뜨겁고 격렬한 이슈인 중괄호 문제부터 들어가보자. 중괄호 스타일 문제는 해외에서는 SVN[각주:1]에 코드 전체가 커밋되었길래 봤더니 중괄호 스타일을 전부 바꾸고 커밋했더라 "작업자를 찾아서 가만두지 않겠다" 라는 밈이 나올 정도이다.

 

이 문제는 크게 두 가지 계파로 나누어지는데 다음과 같다.

 

조건문이나 반복문 아래로 중괄호를 내리는 스타일 :

 

if (bCondition)
{
    // Todo
}
else
{
    // Todo
}

for (int i = 0; i < length; i++)
{
    // Todo
}

while (bCondition)
{
    // Todo
}

 

조건문이나 반복문 옆으로 중괄호를 붙이는 스타일 :

 

if (bCondition) {
    // Todo
} else {
    // Todo
}

for (int i = 0; i < length; i++) {
    // Todo
}

while (bCondition) {
    // Todo
}

 

아래로 중괄호를 내리는 스타일의 경우, 줄 수는 늘어나지만 코드 블럭의 시작과 끝을 명확히 알 수 있다는 장점이 있고, 옆으로 붙이는 스타일은 코드 시작과 끝을 명확하게 보기는 어렵지만 코드의 라인 수가 줄어서 한 눈에 더 많은 코드를 볼 수 있다는 장점이 있다.

 

사실 이 문제는 어떤 것이 옳다라고는 단정내려버리기 어려운 문제이지만, 딱 한 가지 나쁜 경우가 있다. 바로 이 두 가지 스타일을 섞어서 쓰는 것이다. 이 두 가지 스타일을 섞어서 쓰는 경우, 개발자들이 코드 블럭의 시작점을 쉽게 놓치게 되는 경우가 상습적으로 발생할 것이다. 이런 경우가 자주 발생한다면, 조건문이나 반복문 내부에 코드를 넣으려다 밖에다 써버린다든지, 그 반대의 경우가 쉽게 발생하고, 두 스타일을 사용하는 모든 개발자가 함께 고통받는 헬코딩이 열린다.

 

그렇기 때문에 이 중괄호 스타일 문제는 일반적으로 팀 내에서 다수를 차지하는 파의 스타일을 따라가든지, 아니면 프로젝트 관리자가 익숙한 스타일을 따라가게 된다.

 

조건문에 관련해서 중괄호 스타일 문제는 하나 더 있다. 그것은 조건문 내부 코드가 한 줄일 때, 중괄호를 생략하는 스타일이다.

 

if (bCondition)
{
    // Todo
}
else
{
    // Todo
}
if (bCondition)
    // Todo
else
    // Todo

 

컴파일러는 조건문 내부 코드가 한 줄일 때, 중괄호를 생략하는 것을 허용하는데, 다른 스타일은 대부분 취향에 따라 갈리는 것이라 팀의 규칙에 따라 정해진 스타일을 따라가기를 권하지만 이 스타일만큼은 반드시 지양하라고 권하고 싶다.

 

그 판단의 근거는 한 줄 짜리 조건문을 치면서 중괄호 { } 두 번을 안치고 "라인 두 줄을 아꼈다.", "코드 치는 속도가 더 빠르다"는 자그마한 이점을 취하기에는 그로 인해서 발생할 문제의 리스크가 훨씬 크기 때문이다. 이런 스타일은 조건문 내부에 코드를 추가할 일이 발생하면 생략했던 중괄호를 다시 추가해야할 뿐만 아니라, 중괄호를 추가하면서 라인을 이리저리 재정렬해야하는 번거로움이 발생하고, 정말로 기초적인 실수지만 조건문 블럭 안에 들어가야할 코드를 블럭 밖으로 빼버리게 만드는 실수를 일으키게 만드는 경우가 잦다.

 

그리고 이 문제는 신텍스 에러가 아닌 논리적인 버그를 발생시킬 확률이 매우 높으며 발견하기가 굉장히 까다로울 확률이 높다. 앞서 이야기 했듯이 조건문 블럭 안에 들어가야할 코드를 블럭 밖으로 빼버리는 실수는 간단한 실수지만, 인간은 무언가 문제를 찾을 때, 자신이 당연한 곳에서 실수하지 않을 것이라는 가정을 하는 경향이 강하기 때문에 문제를 찾으려고 코드를 훑는 와중에 이 "간단한 실수"를 몇 번이나 스쳐보고 지나갈 확률이 높다. 그리고 문제를 찾다 찾다 못찾아서 코드를 한 줄씩 검토하면서 나아갈 때 결국에 이 문제를 마주치게 된다.

 

 

네이밍 스타일

 

두 번째 이슈는 바로 변수와 함수, 클래스 등의 네이밍 스타일이다. 변수와 함수의 이름은 간단하게는 각 변수와 함수의 구별을 넘어서 무엇을 담는 변수인지, 무슨 일을 처리하는 함수인지를 알려주는 역할을 한다. 프로그래머는 개발을 진행하면서 무수히 많은 변수와 함수, 클래스 등을 만들며, 그 이름을 정해야 한다. 오죽하면 프로그래머의 가장 큰 고민이 변수 이름을 짓는 것이라는 말이 나왔겠는가?

 

절대 하지 말아야할 네이밍 스타일

 

중괄호를 다루는 법은 일반적으로 기호에 가까운 것이라 절대 금지한다라고 할 만한 방법이 크게 없지한 이름 짓기에서는 반드시 금지할 게 있다.

 

무성의한 네이밍

 

int i;
float f;
string a, b, c;
void foo();
bool function();

 

변수나 함수의 이름을 무성의하게 대충 짓는 것은 최악의 행위이다. 앞에서 이야기 했듯이 변수나 함수의 이름은 그 변수가 어떤 값을 담을 것인지, 그 함수가 어떤 작업을 처리할 것인지를 알려주는 역할을 한다. 하지만 대충 지어진 이름은 이 변수나 함수가 어떤 역할을 할 것인지를 명확하게 알 수 없게 만들기 때문에 코드의 흐름을 파악하기 위해서 모든 코드와 주석을 일일이 읽어야만 되게 만들어버린다. 그 코드를 작성한 본인이라고 하더라도 시간이 지나면 코드의 흐름을 까먹게 될 확률이 매우 높기 때문에 유지보수가 어려워지게 만든다. 

 

그리고 기억하라 int i, int j가 허용되는 곳은 반복문의 인덱스로 사용되는 임시 변수뿐이다.

 

혼란스러운 네이밍

 

bool isCantMove;

 

뜻이 모호하거나 혼란스러운 네이밍 역시 피해야 한다. 특히 bool이나 boolean 같은 논리 변수에서 이러한 혼란을 피해야 하는데, 쉽게 혼란이 발생하는 경우는 논리 변수로 부정을 정의하는 경우이다. 위의 bool 변수의 경우 is Can't Move, 움직이지 못하는가? 를 정의하는데 이를 조건문에서 구현할 때는 아래와 같이 된다.

 

if (isCantMove)
{
    // To do
}

if (!isCantMove)
{
    // To do
}

 

이를 해석해보면 위 조건문은 "움직이지 못하는가?"이고 아래 조건문은 "움직이지 못하지 않는가?"가 된다. 이런 식으로 부정 조건을 정의하면 사람이 해석하는 과정에서 실수가 발생할 수 있다.

 

bool isMoveable;

 

이런 문제를 발생시키지 않기 위해서는 예시처럼 is Moveable, 움직일 수 있는가? 라고 정의하는 게 좋으며, 이는 조건문으로 :

 

if (isMoveable)
{
    // To do
}

if (!isMoveable)
{
    // To do
}

 

와 같이 구현되며, "움직일 수 있는가?", "움직이지 못하는가?"와 같이 자연스럽게 해석된다.

 

한글리쉬 네이밍

 

일부 개발자들은 아는 영어 단어가 많지 않거나 필요한 단어를 찾기 힘들다는 이유로 그냥 한국어 단어를 발음이 나는대로 영어로 적어서 변수를 만든다.

 

string juso;
int oNuelNalJja;
string yoil;

 

이러한 네이밍은 뭔가 굉장히 난독화된 코드처럼 보이며, 코드를 읽는 사람이 발음해보기 전에는 무슨 변수인지 알기가 어렵다. 한글 로마자 표기는 생각보다 읽기 어렵고 불편한 방식이다. 차라리 무슨 단어를 써야될지 모르겠다면 시간을 조금 더 써서 영어사전을 뒤져봐라.

 

권장하는 네이밍

 

변수나 함수, 클래스의 이름을 명확하게 짓는 것만으로도 코드의 가독성은 상당히 올라가며 유지보수 역시 쉬워진다.

 

변수 이름

 

변수 이름을 작성할 때는 기본적으로 명사를 사용한다.

 

int num;

 

숫자를 세는 변수를 예로 들어보자. 이러한 변수에는 num이라는 이름이 쉽게 붙여진다.

 

int count;

 

갯수를 세는 변수라면 조금 더 명확하게 count라는 이름을 써보자.

 

int itemCount;

 

여기서 더 명확하게 무엇의 갯수를 세는 변수인가?를 추가하면 itemCount가 된다.

 

public class Item
{
    public int itemCount;
}

 

하지만 역으로 멤버 변수일 때는 또 다르다. Item 클래스에 같은 아이템을 여러 개 가질 수 있다고 했을 때, 같은 아이템의 갯수를 표현하는 itemCount 변수는 어떤가? 적절해보이는가?

 

Item item = new Item();
ShowItemCount(item.itemCount);

 

하지만 실제로 itemCount 변수를 사용할 때는 item.itemCount로 이름이 중복 표현된다.

 

public class Item
{
    public int count;
}

Item item = new Item();
ShowItemCount(item.count);

 

이럴 때는 count라는 이름만 써줘도 무엇의 갯수인지 명확하게 표현된다.

 

List<Item> itemList = new List<Item>();
itemList.Count;

 

이와 같은 맥락으로 C#에서 리스트를 사용할 때도 리스트 안에 들어있는 요소의 갯수를 반환받을 때, Count라고 하지 ListInElementsCount라고 일일이 길게 변수이름을 정하지 않는 것과 같다.

 

함수 이름

 

함수는 기본적으로 어떤 일을 처리하는 행위이기 때문에 함수가 처리하고자 하는 행위를 이름으로 만드는 것이 기본이다. 이름을 짓는 방법은 동사, 동사+명사, 동사+부연 설명 혹은 반대로 명사+동사, 부연 설명+동사 방식으로 지어진다. 이른바 두괄식이냐 미괄식이냐 하는 것인데, 대부분 영미권에서는 동사가 앞으로 오는 두괄식을 선호한다. 선호 이전에도 두괄식이 이 함수가 어떤 행위를 하는지 빠르게 알 수 있기 때문에 보통은 자주 사용된다.

 

void Run(); // 동사 : 실행한다
void MoveToDestination(); // 동사 + 부연설명 : 이동한다 + 목적지로
void AttackEnemy(); // 동사 + 명사 : 공격한다 + 적을

 

클래스 이름

 

클래스 이름 역시 그 클래스 주로 하는 행위에 따라 지어지며 주로 명사로 이름을 짓는다. 게임에서 플레이어를 컨트롤 하는 클래스라면 PlayerController, 입력을 관리하는 클래스라면 InputManager와 같은 방식이다.

 

public class SendData { }

 

다만, 이런 방식으로 클래스가 처리하는 일을 이름으로 짓다보면 실수로 클래스의 이름을 함수 형식으로 짓는 경우들이 있다. 위의 예시처럼 데이터를 보내는 클래스를 정의하려고 할 때, "이 클래스는 데이터를 보내는 역할을 하니까 SendData로 지어야겠다"라고 하는 경우다. SendData라는 이름은 동사+명사의 형태로 클래스 작명법보다는 함수 작명법에 가까운데 클래스는 행위가 아닌 행하는 객체이기 때문에 이러한 동사+명사의 작명법이 어울리지 않으며, 함수와 헷갈릴 가능성이 크다.

 

public class DataSender { }

 

그렇기 때문에 데이터를 보내는 클래스의 이름을 정의하고자 할 때는 위의 예시처럼 이름을 명사화해서 DataSender, 데이터 전송자와 같이 네이밍해주는 것이 좋다. 

 

 

이름 표기법

 

대표적인 이름 표기법

 

중괄호를 다루는 방법에도 여러 가지 방법이 있듯이 변수, 함수, 클래스 이름을 표기하는데도 여러 가지 방법이 있다. 그 중에 대표적인 표기법으로는 카멜 표기법, 파스칼 표기법, 스네이크 표기법이 있다.

 

카멜 표기법(Camel Casing)

 

int itemCount;
float moveDirection;
string errorMessage;

 

카멜 표기법은 변수명으로 사용되는 여러 단어 중에 제일 첫 단어는 소문자로, 그 뒤로 새 단어가 등장할 때마다 그 단어의 첫 문자는 대문자로 표기하는 방법이다. 새로운 단어가 나타날 때마다 대문자가 튀어오르는 모양이 낙타의 등 모양 같다고 해서 카멜 표기법이라고 부른다. 이 방법은 새로운 대문자가 나타날 때마다 끊어 읽으면 되기 때문에 상당히 가독성이 좋은 편에 해당한다.

 

파스칼 표기법(Pascal Casing)

 

int ItemCount;
float MoveDirection;
string ErrorMessage;

 

파스칼 표기법은 카멜 표기법과 비슷하지만 제일 첫 단어의 첫 문자 역시 대문자로 표기한다. 가독성 자체는 카멜 표기법과 비슷하다.

 

스네이크 표기법(Snake Casing)

 

int item_count;
float move_direction;
string error_message;

 

스네이크 표기법은 새 단어마다 언더바( _ )를 삽입하는 형식의 표기법이다. 팟홀 표기법(Pothole Casing)이라고도 불린다. 가독성은 좋은 편에 속하지만 이름이 길어질 수록 넣어야 하는 언더바가 늘어날 뿐만 아니라 언더바를 치는 과정 역시 매우 불편한 면이 많다.

 

그 외의...

 

int nItemCount;
float fMoveDirection;
string strErrorMessage;

 

대표적인 위 세 가지 표기법 이 외에도 여러 가지 표기법이 존재하는데, 변수의 타입을 선행표기하는 헝가리안 표기법(Hungarian Casing)이 있고,

 

int itemcount;
float movedirection;
string errormessage;

 

그냥 모든 문자를 소문자로 표기하는 플레인 표기법(Plain Casing) 역시 존재한다.

 

일반적인 표기법 사용

 

프로그래밍에서 사용되는 표기법은 종류가 매우 다양하다. 하지만 하나의 표기법을 모든 코드 전체에 적용시키는 경우는 없고, 분류에 따라서 적절하게 여러 가지 표기법을 혼합해 사용하는 경우가 대다수이다. 각 분류에 따라 자주 사용되는 표기법은 아래와 같다.

 

클래스와 함수의 이름 표기

 

public class Monster
{
    public void Attack() { }
    public void Move() { }
    public bool FindEnemy() { }
}

 

클래스와 함수의 이름을 표기할 때는 주로 파스칼 표기법을 사용한다. 

 

변수 이름 표기

 

변수 이름의 표기법은 변수의 종류에 따라서 사용하는 방법이 많다.

 

클래스 멤버 변수

 

// Camel Casing
public class Monster
{
    public float moveSpeed;
    public float attackSpeed;
}

// Pascal Casing
public class Monster
{
    public float MoveSpeed;
    public float AttackSpeed;
}

// m_
public class Monster
{
    public float m_moveSpeed;
    public float m_attackSpeed;
}

// _
public class Monster
{
    public float _MoveSpeed;
    public float _AttackSpeed;
}

 

특히 변수에 관련된 쪽에서 표기법이 굉장히 의견이 분분한 편인데, 기본적으로는 카멜 표기법과 파스칼 표기법이 자주 사용되고, 멤버 변수와 다른 매개 변수나 임시 변수와 구분하기 위해 m_나 _를 앞에 붙이는 경우가 많다.

 

함수 매개 변수

 

// 1
public class Monster
{
    public float moveSpeed;
    public void SetMoveSpeed(float moveSpeed) { this.moveSpeed = moveSpeed; }
}

// 2
public class Monster
{
    public float moveSpeed;
    public void SetMoveSpeed(float movespeed) { moveSpeed = movespeed; }
}

// 3
public class Monster
{
    public float moveSpeed;
    public void SetMoveSpeed(float _movespeed) { moveSpeed = _movespeed; }
}

// 4
public class Monster
{
    public float m_moveSpeed;
    public void SetMoveSpeed(float moveSpeed) { m_moveSpeed = moveSpeed; }
}

// 5
public class Monster
{
    public float m_moveSpeed;
    public void SetMoveSpeed(float a_moveSpeed) { m_moveSpeed = a_moveSpeed; }
}

// 6
public class Monster
{
    public float _moveSpeed;
    public void SetMoveSpeed(float moveSpeed) { _moveSpeed = moveSpeed; }
}

 

함수의 매개 변수의 경우 멤버 변수와 이름이 같아서 덮어씌워지는 경우가 많기 때문에 굉장히 많은 방법이 사용된다. 1번 경우처럼 그냥 같은 타입을 사용하고 this 키워드를 사용하는 방법부터, 매개 변수(argument)임을 명시하기 위해서 a_를 붙이는 방법까지 사용되기도 한다. 멤버 변수 표기법과 겹치지 않는 것이 우선이기 때문에 멤버 변수 표기법을 회피한 표기법을 선택한다.

 

함수의 임시 변수

 

public void ChangeMoveSpeed(float movespeed)
{
    float prevSpeed = moveSpeed;
}

 

함수 안에서 생성되는 임시 변수의 표기법은 비교적 자유로운 형태를 띈다. 네이밍 규칙만 정상적으로 지켜지면 알아보기 쉽고, 멤버 변수나 매개 변수와 이름이 겹치지 않게 이름 짓기 쉽기 때문에 표기법에 크게 연연하지 않고 적당하게 작성되는 편이다.

 

 

주석(Comment)

 

프로그래밍을 처음 배울 때 주석을 습관적으로 달도록 배우는 경우가 많다. 하지만 과한 주석이 오히려 가독성을 해치기도 한다.

 

불필요한 주석 쓰기

 

// 아이템 리스트를 아이템 리스트 길이만큼 순회한다.
for (int i = 0; i < itemList.Count; i++ /*반복마다 i에 1을 더한다.*/)
{
    var item = itemList[i]; // 이번 반복 횟수의 아이템을 가져온다.
    if (item.type == ItemType.Equipment) // 아이템의 타입이 장비라면 ...
    {
        // 내구도에 0.9를 곱한다.
        item.duration *= 0.9f; // 아이템의 내구도를 감소시키기 위해서
    }
}

 

위의 예시 코드를 보라. 한 눈에 훑어보기만 해도 알 수 있을것 같은 코드에 매 라인마다 주석을 달아둠으로써 오히려 읽기가 어려워졌다. 주석을 다는 습관은 좋은 것이지만, 불필요한 주석까지 다는 습관이라면 나쁜 것이다.

 

주석을 다는 경우는 최적화 작업이 진행되어서 로직을 한 눈에 읽는 것이 불가능해졌을 때, 어떤 방식으로 코드가 작동하는지를 설명하기 위해 주석을 추가하는 것과, 해당 지점에서 어떤 작업을 해야하는지 To do를 작성하는 경우, 해당 코드를 수정할 때 다른 작업자가 어떤 작업에 유의해야 하는지 등의 경고를 남기는데 사용하는 것으로 한정하는 것이 좋다.

 

그 이외의 경우에는 주석을 최대한 자제하고 코드를 읽는 것으로 프로그램을 이해할 수 있게 클래스, 변수, 함수 이름을 작성하고 코드의 흐름을 적절히 하는 것이 좋다.

 

이전 코드 남겨놓기

 

프로그래머라면 아마 대부분이 어떤 코드에 대해서 수정사항이 발생했을 때, 그 부분을 완전히 지워버리지 않고 주석 처리만 해놓고 새로운 코드를 작성한 경험이 있을 것이다.

 

public class Aim : MonoBehaviour
{
    [SerializeField]
    private Color aimColor;

    [SerializeField]
    private Image aimImage;
    private Camera mainCam;

    private Animator animator;

    private void Awake()
    {
        animator = GetComponent<Animator>();
    }

    public void AimingStart()
    {
        //StartCoroutine(ShowAim());
        animator.SetBool("isAiming", true);
    }

    //private IEnumerator ShowAim()
    //{
    //    float timer = 0f;
    //    while(timer <= 1)
    //    {
    //        timer += Time.deltaTime;
    //        var alpha = Mathf.Lerp(0f, 0.5f, timer);
    //        aimImage.color = new Color(aimColor.r, aimColor.g, aimColor.b, alpha);
    //        yield return null;
    //    }
    //}

    public void Aiming(Vector3 aimStartPos, Vector3 aimDirection, Vector2 aimHit)
    {
        if (mainCam == null)
        {
            mainCam = Camera.main;
        }
        var dist = Vector2.Distance(aimStartPos, aimHit);
        aimImage.pixelsPerUnitMultiplier = dist * 0.3f;
        aimImage.rectTransform.localScale = new Vector3(aimImage.rectTransform.localScale.x, dist);
        aimImage.rectTransform.position = mainCam.WorldToScreenPoint(aimStartPos);
        aimImage.rectTransform.up = aimDirection;
    }

    public void AimingEnd()
    {
        animator.SetBool("isAiming", false);

        //StopAllCoroutines();
        //StartCoroutine(HideAim());
    }

    //private IEnumerator HideAim()
    //{
    //    float timer = 0f;
    //    while (timer <= 1)
    //    {
    //        yield return null;
    //        timer += Time.deltaTime;
    //        var alpha = Mathf.Lerp(aimColor.a, 0f, timer);
    //        aimImage.color = new Color(aimColor.r, aimColor.g, aimColor.b, alpha);
    //    }
    //}
}

 

바로 이 코드처럼 말이다. 이처럼 띄엄띄엄 이전 버전의 코드를 남겨두게 되면 코드를 한 눈에 읽기 힘들어지고, 특히 여러 버전의 코드가 겹겹이 쌓이게 되면 나중에 복구하려고 하는 시점에는 어떤게 어떤 버전인지도 헷갈리게 된다. 만약 이전 버전으로 되돌아가야할 일이 있다면 버전이 바뀔 때마다 SVN같은 버전 관리 툴에 업데이트를 한 뒤, 차라리 롤백을 하라. 혼자서 하는 작업이라 SVN을 쓰기 귀찮은 상황이라도 차라리 귀찮음을 무릅쓰고 SVN을 쓰는 것이 최선이며 차선은 백업 폴더에 버전 별로 백업을 해두고 원본에서는 지난 코드를 지우는게 최선이다.

 

아스키아트

 

가독성을 해치는 것 외에도 쓸데없이 개발 효율을 낮추는 것들도 있다.

 

// ========================================= //
//  ||   /||   //||==\\ //==\\ ==== ||\      //
//  ||  //||  // ||  || ||      ||  ||\\     //
//  || // || //  ||==// ||  ==  ||  || \\    //
//  ||//  ||//   || \\  ||  ||  ||  ||==\\   //
//  ||/   ||/    ||  \\ \\==// ==== ||   \\  //
// ========================================= //

 

개발을 진행하다보면 프로그램을 개발하는 것보다 주석을 아름답게 꾸미는 것에 더 관심이 많아보이는 개발자들이 있다. 이러한 작업을 아스키아트라고 한다. 위 예시는 아주 간단한 아스키아트이다(더 멋진 아스키아트를 그릴 수 있었으나 웹페이지에 여백이 부족해 그리지는 않겠다). 아름답지 않은가? 지금이라도 당장 코드를 꾸미러 가고 싶지 않은가? 로망이란 멋있지만 쓸모없는 것을 가리킨다.

 

/* ------------------------------------------------------------------- *
 * Code Writer :: WERGIA                                               *
 * Last Modifier :: SOMETHING-WHO                                      *
 * Last Modified :: 2019/11/5                                          *
 * Version :: 1.1                                                      *
 * ------------------------------------------------------------------- */

 

코드 파일의 버전과 작성자 수정일자 등을 표시하는 것은 좋다. 나쁜 것은 끝 라인에 붙은 *들이다. 작성자는 완벽한 사각형을 만들었다고 좋아하겠지만 나중에 버전이 바뀌거나 수정자가 바뀌는 경우, 안의 내용을 수정해야 하는데 이 과정에서 마지막 끝 줄의 *은 엉망진창이 될 것은 필연적인 일이다. 이 라인을 맞추는 작업에 수정자가 쓸데없는 시간을 쏟느니 그냥 저 *들을 지워버리는게 낫다.

 


 

읽기 좋은 코드를 작성하기 위한 방법들은 여러 가지가 있지만 그것들이 지향하고자 하는 목표는 모두 같다. 코드의 가독성을 상승시키고 유지보수를 하기 쉽게 만드는 것이다. 개발자는 하나의 코드 스타일에 너무 매몰되지 않아야하고 팀의 협업 시스템에 맞춰 스타일을 변경할 수 있어야 한다. 그러면서도 실수를 최대한 줄일 수 있는 스타일을 유지해야만 한다.

 

  1. 팀 단위로 프로그래밍 작업할 때, 버전을 관리하기 위한 툴 [본문으로]
반응형
  1. gf0308 2021.12.01 00:59 신고

    덕분에 도움이 많이 됐습니다
    좋은 포스팅 감사합니다

Thread 

스레드 생성 시 반복문의 인덱스를 매개변수로 받을 때


스레드(Thread)를 생성할 때, 반복문으로 여러 개의 스레드를 생성하면서 그 반복문의 인덱스를 매개변수로 전달하는 방법을 쓸 때가 있다.


class ThreadTestProgram

{

    public static int DeviceNum = 10;


    public static void Main(string[] args)

    {

        for (int i = 0; i < DeviceNum; i++)

        {

            new Thread(() => Run(i)).Start();

        }

    }


    public static void Run(int idx)

    {

        // 디바이스 인덱스에 따라서 스레드 별로 각 디바이스와 연결하는 작업...

        Console.WriteLine(idx);

    }

}

 

위의 예시 코드가 바로 그것이다. 여러 개의 디바이스에 연결해서 스레드로 작업을 처리해야 할 때의 코드인데, 스레드 함수에서는 반복문에서 디바이스의 인덱스를 전달받아서 연결하도록 설계된 코드이다.


물론 스레드이기 때문에 실행 순서 자체는 보장할 수 없지만, 적어도 각 스레드가 매개변수의 값으로 0, 1, 2, 3, 4, 5, 6, 7, 8, 9를 전달받는 것을 기대하고 설계된 코드라고 볼 수 있다.


 

하지만 실행결과를 보면 각 스레드가 전달받은 매개변수 값은 1, 2, 3, 4, 5, 5, 6, 8, 8, 10으로 0, 7, 9를 전달받은 스레드는 없고 5와 8을 전달받은 스레드는 두 개씩 있는 엉망진창인 상태인 것을 볼 수 있다.


이 상황이 의미하는 것은 스레드의 매개변수로 넣은 반복문의 인덱스 값이 스레드가 시작되기 전에 변경되면 스레드의 매개변수 값 역시 영향을 받는다는 것이다.


// int i = 0 -> 반복문에 사용될 인덱스 값 설정

for(int i = 0; i < DeviceNum; i++)

{// i < DeviceNum -> 인덱스 값이 반복문 내의 코드 블럭을 실행하기에 유효한지 검사

    new Thread(() => Run(i)).Start(); // 스레드 생성 

    // i 값이 증가하기 전에 스레드가 시작되면 원래 값이 들어간다.

}// i++ 값 증가 // i 값이 증가한 이후에 스레드가 시작되면 i + 1 값이 들어간다.


각 코드 진행 상황에 대한 해설을 달자면 위와 같다. i값이 증가한 이후에 스레드가 시작되는 것이 문제로 스레드가 시작되기 전까지 전달되는 값이 변하지 않을 것에 대한 보장이 필요한 상태이다.


이를 위해서 코드를 다음과 같이 변경해보자.


class ThreadTestProgram

{

    public static int DeviceNum = 10;


    public static void Main(string[] args)

    {

        for (int i = 0; i < DeviceNum; i++)

        {

            int idx = i; // i 값이 바뀌어도 상관없도록 임시 변수에 값을 전달하여 스레드의 매개 변수로 사용

            new Thread(() => Run(idx)).Start();

        }

    }


    public static void Run(int idx)

    {

        // 디바이스 인덱스에 따라서 스레드 별로 각 디바이스와 연결하는 작업...

        Console.WriteLine(idx);

    }

}

 

위의 임시 코드처럼 i의 값을 임시 변수에 전달해서 스레드에 매개변수로 전달하면 i값이 증가해도 idx의 값은 증가하지 않기 때문에 스레드가 실행될 때까지 값이 변조되지 않을 것이다.


 

실제로 코드를 컴파일해보면 실행순서는 섞여있지만 각 스레드가 디바이스 인덱스로 0, 1, 2, 3, 4, 5, 6, 7, 8, 9를 받은 것을 확인할 수 있다.

반응형

Thread 

여러 작업을 동시 처리하기


일반적으로 우리가 사용하는 운영체제(Operation System, OS)은 멀티 태스크를 지원한다. 그 덕분에 우리는 구글에서 자료를 찾으면서, 유튜브에서 강좌를 듣고, 동시에 비주얼 스튜디오에서 작업을 할 수 있으며 그와 동시에 오디오 재생 프로그램을 통해서 음악을 들을 수 있다. 이때 구글과 유튜브에 접속할 수 있게 해주는 브라우저, 코드 작업을 하는 비주얼 스튜디오, 음악을 재생한느 오디오 재생 프로그램이 각각 하나의 프로세스(Process)이다.


또 여기서 이 프로세스는 하나 이상의 스레드(Thread)로 이루어진다. 스레드는 프로세스를 여러 개의 조각으로 나눈 것으로, 한 OS에서 여러 프로세스가 작업하는 것처럼, 한 프로세스에서 여러 스레드가 동시에 작업을 처리할 수 있게 해준다. 방금 앞에서 든 예시 중에 오디오 재생 프로그램을 예시로 들자면, 오디오 프로그램은 하나의 프로세스으로, 그 안에서 여러 스레드로 나뉘어서 한 스레드는 음악을 재생하고, 또 다른 스레드는 가사를 보여주면서 음악 재생 시간에 맞춰서 싱크를 맞추는 등의 방식으로 동시에 여러 가지 작업을 동시에 처리하는 것이다.



스레드 생성/시작하기


그럼 이 스레드를 사용하기 위한 방법을 차근차근 배워보자.


using System.Threading;


스레드에 관련된 기능들은 System.Threading 네임스페이스에 포함되어 있다. System.Threading.* 처럼 일일이 네임스페이스를 입력해서 코드를 작성해줄 수도 있지만 가독성 문제와 작업 효율성을 위해서 using 선언을 해주자.


using System;

using System.Threading;


namespace ThreadTest

{

    class ThreadTestProgram

    {

        public static void Main(string[] args)

        {

            Run(0);

            Run(1);

        }


        public static void Run(int idx)

        {

            Console.WriteLine(string.Format("Run {0} Start"idx));

            for (int i = 0; i < 10; i++)

            {

                Console.WriteLine(string.Format("Run {0} :: {1}"idx, i));

            }

            Console.WriteLine(string.Format("Run {0} End"idx));

        }

    }

}


우선 스레드를 사용하지 않는 경우의 코드를 먼저 확인해보자. 위의 코드는 스레드를 전혀 사용하지 않고 Run() 함수가 두 번 연속 호출된다. 


 

이렇게 스레드를 사용하지 않고 Run() 함수를 두 번 호출하면 모두가 알다시피 코드는 순차적으로 진행해서 첫 번째 Run(0) 함수가 완전히 끝난 후에야 두 번째 Run(1) 함수가 동작한다.


using System;

using System.Threading;


namespace ThreadTest

{

    class ThreadTestProgram

    {

        public static void Main(string[] args)

        {

            Thread thread = new Thread(() => Run(0));

            thread.Start();

            Run(1);

        }


        public static void Run(int idx)

        {

            Console.WriteLine(string.Format("Run {0} Start"idx));

            for (int i = 0; i < 100; i++)

            {

                Console.WriteLine(string.Format("Run {0} :: {1}"idx, i));

            }

            Console.WriteLine(string.Format("Run {0} End"idx));

        }

    }

}


이번에는 스레드를 생성해서 첫 번째 Run(0) 함수를 스레드로 호출하게 했다. 그리고 반복문 10회로는 동시 실행을 판별하기 어려워서 반복 횟수를 100회로 늘렸다.


 

스레드를 사용한 후의 실행결과는 어느 함수가 끝나기 전에 두 함수가 동시에 진행되고 있음을 충분히 알 수 있다.


Thread thread = new Thread(() => Run(0));

thread.Start();

 

스레드를 사용하는 방법은 간단하게 Thread 객체를 생성하고 생성자의 매개변수로 스레드로 돌리고자 하는 함수를 넣어준 뒤 Start() 함수를 호출하면 된다. 스레드를 생성하기만 하고 Start() 함수를 호출하지 않으면 그 스레드는 동작하지 않는다.



스레드 양보하기


위의 스레드 실행 예시 이미지를 보면 스레드가 몇 번의 연산을 처리하고 잠시 다른 스레드에 처리 시간을 넘겨주고 다시 돌려받는 것을 알 수 있다. 스레드 프로그래밍에서는 이런 CPU 점유 상태를 다른 스레드에 언제 얼마동안 양보할 지를 알리는 함수가 있는데 이것이 바로 Thread.Sleep() 함수다.


Thread.Sleep(10);


Thread.Sleep() 함수는 해당 함수를 호출한 스레드가 매개변수의 시간만큼 쉬면서 다른 스레드에 처리 우선권을 양보하게 만든다. 매개변수의 시간 단위는 밀리세컨드(Milisecond)로 1000분의 1초에 해당한다. 즉 위 코드에 적힌 시간으로는 0.001초 동안 다른 스레드에 처리 우선권을 양보한다는 의미이다.


using System;

using System.Threading;


namespace ThreadTest

{

    class ThreadTestProgram

    {

        public static void Main(string[] args)

        {

            Thread thread0 = new Thread(() => Run(0));

            thread0.Start();

            Thread thread1 = new Thread(() => Run(1));

            thread1.Start();

        }


        public static void Run(int idx)

        {

            Console.WriteLine(string.Format("Run {0} Start"idx));

            for (int i = 0; i < 100; i++)

            {

                Console.WriteLine(string.Format("Run {0} :: {1}"idx, i));

                Thread.Sleep(10);

            }

            Console.WriteLine(string.Format("Run {0} End"idx));

        }

    }

}

 

이번에는 Run(0)와 Run(1) 함수를 모두 스레드로 호출했으며 반복문 중간에 Sleep() 함수를 추가했다.


 

이번 실행결과를 보면 Sleep() 함수를 사용하지 않을 때와는 다르게 허용된 시간에 최대한 몰아서 처리하지 않고 필요한 계산만 처리한 뒤에 바로 다른 스레드에게 처리 우선권을 넘기는 것을 확인할 수 있다.





스레드 중단하기


thread.Abort();

thread.Join();


작동 중인 스레드를 중지하는 방법은 두 가지가 있는데 Abort() 함수와 Join() 함수가 그것이다. 이 두 함수의 차이는 다음과 같다.


Abort() :: 함수의 종료를 보장하지 않고 어느 시점이던지 상관 없이 도중에 강제로 중단시킨다.

Join() :: 함수의 종료를 보장하며 스레드가 동작시키는 중인 함수의 끝에 도달하기를 기다린 다음에 스레드를 닫는다.


using System;

using System.Threading;


namespace ThreadTest

{

    class ThreadTestProgram

    {

        public static void Main(string[] args)

        {

            Thread thread0 = new Thread(() => Run(0));

            thread0.Start();

            Thread.Sleep(100);

            thread0.Abort();


            Thread thread1 = new Thread(() => Run(1));

            thread1.Start();

            Thread.Sleep(100);

            thread1.Join();

        }


        public static void Run(int idx)

        {

            Console.WriteLine(string.Format("Run {0} Start"idx));

            for (int i = 0; i < 100; i++)

            {

                Console.WriteLine(string.Format("Run {0} :: {1}"idx, i));

                Thread.Sleep(10);

            }

            Console.WriteLine(string.Format("Run {0} End"idx));

        }

    }

}


thread0은 Abort() 시키고 thread1은 Join() 시키는 코드를 작성한다음 컴파일 해보자.


 

Run(0)는 반복문이 동작하던 도중에 중단되고, Run(1)은 End까지 무사히 호출되고 종료된 것을 확인할 수 있다.


위듸 예시를 통해 알 수 있듯이 Abort() 함수의 경우에는 스레드를 작동 도중에 강제로 종료하기 때문에 스레드 강제 종료가 시스템에 심각한 영향을 끼치지 않는다는 보장이 있을 때만 사용하는 것이 좋다.


class ThreadTestProgram

{

    public static void Main(string[] args)

    {

        Thread thread0 = new Thread(() => Run(0));

        thread0.Start();

        Thread.Sleep(100);

        thread0.Abort();

    }


    public static void Run(int idx)

    {

        try

        {

            int runIdx = idx;

            Console.WriteLine(string.Format("Run {0} Start", runIdx));

            for (int i = 0; i < 100; i++)

            {

                Console.WriteLine(string.Format("Run {0} :: {1}", runIdx, i));

                Thread.Sleep(10);

            }

            Console.WriteLine(string.Format("Run {0} End", runIdx));

        }

        catch (Exception e)

        {

            Console.WriteLine(e);

        }

    }

}


스레드를 Abort() 함수로 강제 종료할 때 해당 스레드 함수에서는 System.Threading.ThreadAbortException이라는 예외를 발생시킨다. 만약 스레드를 Abort() 시켰을 때, 리소스 정리 등의 뒤처리 작업이 필요한 경우라면 반드시 해당 스레드 함수에서 발생하는 ThreadAbortException 예외를 받아서 정리 작업을 진행하는 것이 좋다.



스레드 동기화(Thread Synchronization)


여러 개의 스레드를 두고 작동하는 프로그램의 경우에, 여러 스레드가 자원이나 변수 등을 공유하는 경우가 많다. 다음의 예시를 보자.


class ThreadTestProgram

{

    public class Villige

    {

        public int population = 1000

            

        public void AddVillager()

        {

            population++;


            for(int i = 0; i < population; i++)

            {

                for(int j = 0; j < population; j++)

                {


                }

            }

            // 추가된 주민에게 주민번호 주기

            Console.WriteLine(string.Format("새 주민의 주민번호 :: {0}", population));

        }

    }


    public static void Main(string[] args)

    {

        Villige manager = new Villige();

        for(int i = 0; i < 10; i++)

        {

            new Thread(new ThreadStart(manager.AddVillager)).Start();

        }

    }

}


작은 마을을 키우는 게임을 만든다고 가정했을 때, 마을에 새로운 마을 주민이 태어나거나 새로 들어오면 인구 수를 늘려주고 몇 가지 처리를 한 뒤에 주민번호를 매겨주는 AddVillager() 함수를 구현했다. 그리고 주민번호는 고유한 번호이기 때문에 각 주민 마다 번호가 중복되어서는 안된다고 가정해보자. 이 때 마을 주민이 동시에 추가될 수도 있기 때문에 스레드 처리를 한다.


그런데 플레이 도중에 마을에 10명의 주민이 동시에 추가되었다고 해보자. 그러면 현재까지 1000명의 주민이 있었으니 그 뒤에 추가되는 주민들의 번호는 1001, 1002, 1003, ..., 1009, 1010이 되기를 기대할 것이다.


 

하지만 실행결과는 새 주민들의 주민번호가 중복되어서 발급되어 버렸다. 이러한 문제를 스레드 세이프 하지 않다(Not thread-safe)라고 하는데 이 문제를 해결하기 위해서 필요한 것이 바로 스레드 동기화이다. 스레드 동기화는 하나의 공용된 자원이나 변수에 여러 개의 스레드가 접근할 때, 스레드들이 순서를 지켜서 사용하고 다른 스레드가 사용 중일 때는 사용하지 못하게 만드는 것이다.


class ThreadTestProgram

{

    public class Vilige

    {

        public int population = 1000;


        public object populationLock = new object();


        public void AddHuman()

        {

            lock (populationLock)

            {

                population++;


                for (int i = 0; i < population; i++)

                {

                    for (int j = 0; j < population; j++)

                    {


                    }

                }

                // 추가된 주민에게 주민번호 주기

                Console.WriteLine(string.Format("새 주민의 주민번호 :: {0}", population));

            }

        }

    }


    public static void Main(string[] args)

    {

        Vilige manager = new Vilige();

        for(int i = 0; i < 10; i++)

        {

            new Thread(new ThreadStart(manager.AddHuman)).Start();

        }

    }

}


스레드를 동기화하는 방법은 lock을 사용사는 것이다. 스레드 락을 하기 위한 객체를 하나 만들어서 lock()을 해주면 lock() { } 으로 묶어준 블럭이 한 스레드에서 실행되는 동안에는 같은 객체의 lock으로 묶인 스레드는 멈춘 상태로 해당 코드를 진행하지 못하게 된다.


 

스레드를 lock() 함수로 동기화하여 실행하면 새로 들어온 주민들의 주민번호가 겹치지 않고 정상적으로 매겨지게 된다.


이런 스레드 동기화에도 단점은 있는데 스레드 동기화되는 부분은 동시 처리가 안되고 한 스레드씩 작업을 진행하기 때문에 프로그램의 속도가 느려질 수 있다.


 

그리고 스레드의 동기화 구조가 복잡한 경우라면, 위의 이미지처럼 두 개의 스레드가 두 자원을 사용하려고 할 때, 스레드 1이 자원 1을 사용하며 자원 2가 풀리기를 기다리고 있고 스레드 2가 자원 2를 사용하며 자원 1이 풀리기를 기다려서 두 스레드가 멈춰버리는 데드락(Dead lock, 교착상태)이 발생할 수도 있다.


이렇게 스레드는 동시 처리를 하기에 유용한 방법이지만, 호출 순서를 보장할 수 없고 디버깅이 어려운 구조이기 때문에 잘못 사용할 경우 해결하기 어려운 문제를 발생시키기 쉽다. 그러므로 스레드를 사용할 때는 조심해서 사용해야만 한다.

반응형
  1. 질문충 2020.09.26 22:40

    4스레드짜리 시피유로 24스레드까지 만들어도 돌아가는데 기계적인 부분과는 무관한건가요?

    • wergia 2020.10.20 00:05 신고

      네, CPU 사양으로 표시되는 코어나 스레드 수와는 무관하게 메모리가 허용하는 양만큼 스레드를 만들 수 있다고 하네요.

static 

정적 변수와 정적 함수 그리고 정적 클래스


static 키워드는 변수나 함수, 클래스에 정적 속성을 부여하는 것으로 클래스로부터 객체를 생성하지 않고 변수나 함수를 호출할 수 있도록 해주는 것이다.



정적 변수


public class StaticTestClass

{

    public static int score;

}


정적 변수를 선언하기 위해서는 위의 예시 코드와 같이 static 키워드를 붙여서 변수를 정의하면 된다. 이렇게 선언한 정적 변수는 클래스로부터 객체를 생성하지 않아도 [클래스명.변수이름]의 형식으로 곧바로 사용할 수 있게 된다. 


public class MainClass

{

    public void Main()

    {

        StaticTestClass.score = 10;

    }

}


클래스의 일반 멤버 변수는 클래스의 객체가 생성될 때, 각 객체마다 따로 생기지만, 정적 변수는 해당 클래스가 처음으로 사용되는 때에 한 번만 초기화되어 계속 동일한 메모리를 사용하게 된다.

 

 

도식으로 보면 위의 그림과 같다. 정적 변수를 포함한 클래스 A의 객체를 두 개를 생성하여 각 이름을 object1, object2라고 했을 때, 각 인스턴스에는 정적 변수가 포함되지 않으며, 일반 멤버 변수만 포함된다. 클래스 A의 정적 변수는 클래스 A가 처음 사용되는 시점에 별도의 메모리 공간에 할당된다.


 

생성된 객체에 정적 변수가 포함되지 않는 것은 실제로 객체를 생성해서 멤버 변수를 찾았을 때, 목록에 나오지 않는 것을 보면 확인할 수 있다.



정적 함수


public class StaticTestClass

{

    public static int score;


    public int memberInt;


    public static void StaticFunction()

    {

        score = 10 // static 변수는 호출할 수 있다.

        memberInt = 10// static 함수 내에서 멤버변수는 호출할 수 없다.

    }

}


public class MainClass

{

    public void Main()

    {

        StaticTestClass.score = 10;

        StaticTestClass.StaticFunction();

    }

}


함수를 선언할 때, static 키워드를 붙여서 함수를 정의하면 정적 함수를 만들 수 있다. 이 정적 함수 역시 [클래스명.함수이름]의 형식으로 객체를 생성하지 않고 곧바로 호출할 수 있다.


단, 정적 함수는 객체가 생성되기 전에 호출이 가능하기 때문에, 정적 함수 내에서는 정적 변수가 아닌 일반 멤버 변수를 호출할 수 없다.



정적 클래스


public static class StaticTestClass

{

    public static int score;


    static StaticTestClass()

    {

        score = 10;

    }


    public static void StaticFunction()

    {

        score = 20;

    }

}


정적 클래스는 모든 멤버가 정적 변수 혹은 정적 함수로 이루어진 것으로 객체를 생성할 수 없는 클래스이다. 모든 정적 멤버 변수 및 정적 멤버 함수는 [클래스명.변수이름] 혹은 [클래스명.함수이름]으로 호출된다.


정적 클래스는 정적 생성자를 가질 수 있는데 이 정적 생성자는 public, protected, private 등의 액세스 한정자를 사용할 수 없으며, 매개변수 역시 가질 수 없다.

반응형

C++ 코드 생성자에서 콘텐츠 브라우저의 클래스와 리소스 불러오기

 

작성 기준 버전 :: 4.21.1

 

게임을 제작하는 과정에서 객체를 초기화할 때, 프로젝트에 포함된 다른 클래스나 오브젝트, 리소스를 가져와야하는 경우가 종종 생긴다.

 

 

그런 경우 블루프린트의 이벤트 그래프에서 작업하는 경우라면 위의 이미지와 같이 콘텐츠 브라우저에 있는 리소스나 블루프린트 클래스 등을 곧바로 선택할 수 있지만, C++ 코드에서는 직접 경로를 지정해서 코드를 작성해야 한다.

 

단, C++ 코드에서 직접 경로를 지정해서 리소스나 블루프린트 클래스를 가져올 때, 주의할 점은 리소스나 블루프린트 클래스의 경로나 파일명이 자주 바뀌는 상황을 피하는 게 좋다. 경로를 지정한 이후에 경로가 바뀌지 않을 것이 확실하다면 C++ 코드로 경로를 지정해서 가져오는게 낫겠지만 자주 바뀌는 상황이라면 바뀐 리소스를 불러오는 모든 코드를 일일이 찾아서 수정하고 컴파일하는 문제가 발생한다.

 

그렇기 때문에, 경로나 리소스의 파일명이 자주 바뀔 상황이라면 위의 이미지처럼 블루프린트를 이용해서 초기화를 진행하거나, 별도의 기능을 만들어서 일일이 경로를 지정하고 바꾸는 작업을 자동화시키는 것이 좋다.

 

우선 C++ 코드에서 콘텐츠 브라우저의 리소스나 블루프린트 클래스를 가져오기 위해서는 다음의 헤더를 전처리기로 포함시켜주어야 한다.

 

#include "UObject/ConstructorHelpers.h"

 

ConstructorHelpers는 생성자에 도움을 주는 클래스로 생성자에서 콘텐츠 브라우저의 리소스나 블루프린트 클래스를 불러오는 작업을 도와주는 기능들을 가지고 있다. ConstructorHelpers는 생성자에서 사용되는 기능이기 때문에 생성자 이외의 장소에서 ConstructorHelpers를 사용하려고 시도하면 컴파일 에러가 발생하게 된다.

 

 

C++ 코드에서 블루프린트 클래스 가져오기

 

콘텐츠 브라우저 패널에 Blueprints 폴더 안에 TestBlueprintClass라는 이름의 APawn 클래스를 상속받은 블루프린트 클래스가 있다고 가정할 때, 그것을 C++ 코드에 가져오기 위해서는 다음 예시와 같이 코드를 작성하면 된다.

 

static ConstructorHelpers::FClassFinder<APawn> BPClass(TEXT("/Game/Blueprints/TestBlueprintClass"));
if (BPClass.Succeeded() && BPClass.Class != NULL)
{
    // 가져온 BPClass.Class를 통한 작업
}

 

FString 경로를 통해서 불러오는 것이니 만큼, 오타나 변경된 경로나 파일명으로 인해서, 클래스가 제대로 불러와지지 않는 경우가 발생할 수 있기 때문에, Succeeded() 함수와 Class의 NULL 체크를 통해서 성공적으로 클래스가 불러와졌는지 체크하고 사용해야 한다.

 

클래스 탐색자(Class Finder)는 성공적으로 블루프린트 클래스를 가져온 경우, Class 멤버 변수 안에 TSubclassOf<T> 타입으로 해당 클래스를 가지고 있게 된다. 이것을 이용해서 필요한 작업을 진행하면 된다.

 

 

C++ 코드에서 리소스 가져오기

 

이번에 알아볼 것은 C++ 코드에서 콘텐츠 브라우저 패널의 리소스를 가져오는 과정이다. 리소스의 종류는 여러가지가 될 수 있는데 대표적인 것으로는 스태틱 메시나 텍스처를 예로 들 수 있다.

 

아래의 예시코드는 드롭된 아이템의 메시가 아이템의 종류에 따라서 달라진다는 가정하에 만들어졌다. 콘텐츠 브라우저의 Item/StaticMesh 폴더 안에 SM_Helmet 이라는 이름을 가진 헬멧 모양의 스태틱 메시가 있을 때, FObjectFinder를 통해서 가져올 수 있다.

 

DropItemStaticMeshComponent = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("DropItemMesh"));
RootComponent = DropItemStaticMeshComponent;

 

static ConstructorHelpers::FObjectFinder<UStaticMesh> HelmetStaticMesh(TEXT("/Game/Item/StaticMesh/SM_Helmet"));
if (HelmetStaticMesh.Succeeded() && HelmetStaticMesh.Object != nullptr)
{
    DropItemStaticMeshComponent->SetStaticMesh(HelmetStaticMesh.Object);
}

 

FObjectFinder를 통해서 가져온 오브젝트 역시 Succeeded() 함수와 Object 변수의 null 체크를 통해서 리소스가 제대로 불러와졌는지 체크를 한 뒤 사용해야 한다.

 

 

 

 

C++ 코드에서 C++ 클래스 가져오기

 

C++ 코드에서 블루프린트 클래스가 아닌 직접 작성한 C++ 클래스를 가져와서 사용하고 싶을 수도 있다. 예를 들어 게임 모드 클래스에서 기본 폰이나 기본 플레이어 컨트롤러를 설정하려고 할 때, C++로 작성한 폰 클래스나 플레이어 컨트롤러 클래스를 기반으로 블루프린트 클래스를 생성해서 넣어주는게 아니라 C++ 클래스를 곧바로 코드에서 넣어주고자 한다면 다음 예시 코드와 같이 작성하면 된다.

 

AYourProjectGameMode::AYourProjectGameMode()
{
    PlayerControllerClass = AYourCustomPlayerController::StaticClass();
}

 

StaticClass() 함수를 이용하면 런타임 중에 해당 클래스를 나타내는 UClass를 얻어낼 수 있다.

반응형

+ Recent posts