3. 게임을 위한 소프트웨어 엔지니어링 기초 - 1

이관중·2023년 7월 28일

3.1 C++ 개념과 올바른 사용법

프로그래머라면 최소한 2개의 고급high-level 언어를 학습하고, 일정 수준의 어셈블리 언어 프로그래밍을 익혀 놓는 것이 좋다.
객체지향 프로그래밍 개념 전반에 대해 알아보고 특히 C++에 주의를 집중하자.

3.1.1 객체지향 프로그래밍에 대한 간략한 개념

3.1.1.1 클래스와 객체

  • 클래스class란 여러 개의 값과 그에 대한 행위들의 모음인 포괄적 개념이다.
  • eg) 스누피는 개 클래스의 한 인스턴스

3.1.1.2 캡슐화(encapsulation)

  • 객체가 정해진 인터페이스만 공개하고 객체 내부의 상태와 상세한 구현 사항을 숨기는 것
  • 인터페이스만 이해하면 클래스 내부 구현을 신경쓰지 않아도 됨

3.1.1.3 상속

  • 어떤 클래스가 이미 존재하는 클래스를 확장하는 것
  • 상속은 트리 형태의 계층을 이루게 된다.
  • is-a 관계 (eg: 원은 도형이다)

다중 상속

  • 일부 프로그래밍 언어가 지원
  • 한 클래스가 부모 클래스를 여러 개 가질 수 있는 것
  • 코드를 혼란스럽게, 구현을 힘들게 하기 쉬움 (트리 구조가 아닌 그래프 구조까지 확장될 수 있기때문)
  • '다이아몬드 문제' 참고
  • 많은 C++ 프로그래머가 다중 상속을 아예 사용하지 않거나 제한적인 경우에만 사용 ('mix-in 클래스' 참고)

3.1.1.4 다형성

  • 프로그래밍 언어에서 서로 다른 타입의 객체들을 하나의 공통 인터페이스로 다룰 수 있는 기능
  • C++에서는 주로 가상virtual함수를 이용

3.1.1.5 합성과 집합

  • 합성composition이란 서로 영향을 주고받는 여러 객체를 이용해 복잡한 일을 해결하는 것
  • 'has-a'관계나 'uses-a'관계를 형성 (엄밀하게 'has-a'는 합성, 'uses-a'는 집합aggreation 이라고 한다)
  • eg) the space ship has an engine, the engine has a fuel tank
  • eg) GUI 인터페이스를 설계할 때
    • 상속사용: Window is-a Rectangle - (X)
    • 합성/집합사용: Window has-a Rectangle, Window uses-a Rectangle - (O)더 유연하고 캡슐화에 유리하다

3.1.1.6 디자인 패턴

같은 문제가 반복 발생 -> 문제를 해결하는 데 많은 프로그래머가 유사한 방법 사용 -> '디자인 패턴'

예시:

  • 싱글턴singleton : 어떤 클래스의 객체가 오직 하나(싱글턴 인스턴스)만 있게 보장하고 그에 대한 포인터를 제공
  • 반복자iterator : 어떤 집합의 세부적인 구현을 몰라도 집합의 각 요소에 접근하는 효율적인 방법 제공
  • 추상화 팩토리abstractor factory : 서로 연관되거나 의존적인 클래스들의 계열을 만들 때 구현 클래스를 저장하지 않아도 되는 인터페이스를 제공

게임 업계에서도 렌더링에서 충돌, 애니메이션, 오디오 등 다양한 부분에 대한 해법이 있고, 이 책이 3D게임 엔진 디자인에 대한 고차원적 디자인 패턴을 설명한다고 할 수 있다.

관리인과 RAII

  • 리소스 획득과 초기화 동시에 하기RAII, Resource Acquisition is Initialization
  • 매우 유용하다
  • 리소스(파일, 동적 할당 메모리, 뮤텍스 락 등)의 획득acquisition과 반환release이 각각 클래스의 생성자, 파괴자의 묶이는 것.
  • 클래스의 로컬 인스턴스를 만들면 자원 획득, 범위를 벗어나면 자연스럽게 반환.
  • 너티독에서는 이런 클래스를 관리인janinor라고 불렀다
class AllocJanitor
{
public:
    explicit AllocJanitor(mem::Context context)
    {
        mem::PushAllocator(context);
    }
    ~AllocJanitor()
    {
        mem::PopAllocator();
    }
};

// 사용
void f()
{
    // 다른 일...
    
    // 임시 버퍼를 단일 프레임(single-frame) 할당자에서 생성
    {
        AllocJanitor janitor(mem::Context::kSingleFrame);
        
        U8* pByteBuffer = new U8[SIZE];
        float* pFloatBuffer = new float[SIZE];
        
        // 버퍼를 사용...
        
        // (Note: 단일 프레임 할당자에서 메모리를
        // 가져왔기 때문에 해제할 필요가 없음)
    }   // 관리인이 범위를 벗어나면 할당자를 빼낸다
    
    // 다른 일...
}

참고: https://en.cppreference.com/w/cpp/language/raii

3.1.2 C++ 표준화

표준화 역사에 대해서는 자세한 설명을 생략한다.

3.1.2.1 더 읽을거리

3.1.2.2 언어의 어떤 기능을 쓸 것인가?

C++에 추가된 모든 새로운 기능을 엔진이나 게임에 써서는 안된다.

완전한 기능 지원 부족

  • 최신 기능들이 컴파일러에서 제대로 지원되지 않을 수도 있다.

표준 간 전환 비용

  • 코드베이스를 한 표준에서 다른 표준으로 전환하려면 비용이 든다.
  • 사용할 표준 정한 후 어느 기간동안 유지해야 함.
  • 언차티드 4, 언차티드: 잃어버린 유산은 C++98표준으로 짰다.

위험 그리고 게인

모든 기능이 좋은 것은 아니다.

  • 좋은 경우: nullptr 는 이견이 없을 정도로 좋다
  • 장단이 있음: auto 키워드
    • 코드가 난잡해지고 타입을 알기 어려워 질 수 있음
    • 너티독의 경우, 반복자를 선언할 경우, 다른 대안이 없는 경우, 코드 가독성이 좋아지는 경우에만 사용.
  • 게임에선 쓰지마: 템플릿 메타프로그래밍metaprogramming
    • 가독성 떨어짐
    • 이식성이 떨어지거나 없음
    • 프로그래머가 이해하기 어려워짐 (많은 노력이 듦)
    • eg) Andrei Alexandrescu의 Loki 라이브러리
    • 너티독에서는 런타임 엔진 코드에 템플릿 메타프로그래밍을 금지함

3.1.3 코딩 규칙: 필요한 이유와 적용 정도

코딩 스탠다드에 관해서는 논쟁이 많다.
다만, 적어도 최소한의 코딩 규칙이라도 적용하면 좋다는 것은 맞다.

필요한 이유

  1. 특정 규칙을 준수하면 코드를 읽고 이해하기 쉬워지며 유지 비용도 적게 든다.
  2. 프로그래머가 터무니없는 실수를 하지 않게 도와준다.

코딩 규칙을 정할 때 가장 중요한 것들

  • 인터페이스를 제일 중시할 것
    • 인터페이스(.h 파일)은 간결하고 단순하며 최소한의 것만 포함해야 함. 이해하기 쉬워야되고 주석을 잘 달아야 한다.
  • 이름을 잘 지을 것
    • 클래스나 함수, 변수의 목적에 맞는 가장 직관적인 이름을 지어라.
  • 전역 네임스페이스를 깔끔하게 유지할 것
    • 네임 스페이스와 이름에 붙이는 접두사 등을 사용해 다른 라이브러리의 이름과 충돌하지 않게
    • #define문을 이용할 때 주의. (전처리기는 네임스페이스 무시하기 때문)
  • 널리 알려진 C++ 사용법을 따를 것
    • 이펙티브 C++ 시리즈 스캇 마이어스
    • 이펙티브 STL
    • 등 서적 참고
  • 코딩 규칙은 일관돼야 할 것
    • 규칙은 일관되어야 한다.
    • 이미 만들어진 코드를 고친다면 그 코드의 규칙을 철저히 따라야 한다.
  • 오류를 스스로 드러내는 코드를 작성할 것

3.2 에러 감지와 처리

3.2.1 에러의 종류

  • 사용자 에러: 사용자가 뭔가 잘못해서 발생
  • 프로그래머 에러: 프로그램의 잘못된 버그 때문에 발생

사용자들은 프로그램이 사용자의 오류를 문제없이 처리할 수 있기를 바란다.
다른 프로그래머가 사용자 입장이 될 수도 있다.
사용자 에러와 프로그래머 에러의 구분은 상황에 따라 다르다는 뜻.

3.2.2 에러 처리

3.2.2.1 플레이어 에러 처리

  • 게임 플레이어가 사용자일 경우
  • 에러는 게임 내용에 맞게 처리되는 게 자연스럽다.
  • eg) 총알 없는데 무기 재장전 요구하면 게임이 튕기는게 아니라 음향, 애니메이션을 보여줘서 상황 알려줌

3.2.2.2 개발자 에러 처리

  • 아티스트, 애니메이터, 게임 디자이너 등, 게임을 만드는 다른 사람이 사용자일 경우
  • 두 가지 의견이 대립
    • 잘못된 게임 자원을 가능하면 빨리 발견해 고치는 것이 중요
      • 단 한가지라도 잘못된 게임 자원 발견하면 게임 실행 안되게
      • 처음부터 완벽하기는 힘들다.
    • 엔진이 어떤 문제에도 동작해야한다.
      • 게임 엔진이 비대해짐, 최종 출시시에도 잘못된채로 출시될 가능성 높음
  • 적절한 균형을 찾는것이 중요

3.2.2.3 프로그래머 에러 처리

  • Assertion 시스템: 에러 감지 코드를 직접 소스코드 곳곳에 넣고 여기 걸릴 경우 프로그램 멈춰버리게 하는것 (가장 좋은 방법)
  • 모든 에러를 다루는데 assertion 사용할 수는 없다.
  • Assertion과 자연스런 에러 처리를 각각 어디에 사용할지 감 익혀야 한다.

3.2.3 에러 감지와 에러 처리 구현

3.2.3.1 에러 리턴 코드

  • 맨 처음 에러를 감지한 함수에서 특정한 에러 코드를 리너테하게 하는 방법
  • 참/거짓(bool) 값으로 성공과 실패를 나타낼 수도, 불가능한 값을 리턴하는 경우도 있음
  • 더 좋은 방법은 enumerated value 중 하나를 리턴해서 성공, 실패를 나타내게 하는 설계.
  • 해당 함수 호출한 함수에서 리턴되는 에러 코드 보고 적절히 대응
    • 처리 또는 우회해서 계속 실행
    • 자기 호출한 함수에 에러코드 전달
  • 문제점: 에러를 처음 감지한 함수가 그 에러를 처리할 수 있는 함수와 전혀 연관 없을 수도 있음

3.2.3.2 에러 처리

  • 예외exception 던지기.
  • 예외 처리exception handling는 C++가 지원하는 강력한 기능
  • 어떤 함수가 처리할지 전혀 신경 쓰지 않고도 에러 전달할 수 있음
    1. 예외가 던져지면 예외 객체라는 자료구조에 연관있는 정보들을 넣는다.
    2. 콜 스택을 자동으로 펼쳐 try-catch 블록에 도달할 때 까지 계속
    3. try-catch 블록을 찾으면 예외 객체를 처리할 수 있는 catch 블록을 찾는다.
    4. 맞는 블록을 찾으면 그 catch 블록 안의 코드가 실행된다.
    5. 스택이 펼쳐지면서 변수들의 파괴자가 자동으로 호출됨.

에러 처리의 문제점

  • 성능 저해
    • try-catch 블록 포함하는 모든 함수의 스택 프레임에 stack unwinding을 위한 별도 정보 추가 필요
    • 단 한 부분에서만 예외 처리 사용해도 프로그램 전체에 예외 처리를 사용하게 설정해야 함
  • 예외 처리 사용하는 라이브러리를 사용하면서 게임 코드는 예외처리를 사용하지 않으려면 이것들을 격리해야함
    • API 호출을 새로운 함수로 감싸고, 이 함수들이 구현된 번역 단위에는 예외 처리를 활성화
    • 이 함수들은 발생할 수 있는 모든 예외에 대해 try/catch블록으로 감싸 처리하고 그 결과를 에러 코드로 리턴
    • 이렇게 구현된 라이브러리를 게임 코드와 링크하면 게임 코드는 예외 처리를 꺼도 됨.
  • 성능문제보다 더 중요한 건, 예외exception가 어떻게 보면 goto 문과 다를게 없다는 것
    • 조엘 스폴스키는 예외가 goto보다 더 나쁘다고 함. (코드 식별이 어렵기 때문에)
    • 예외를 던지지도 않고, 처리하지도 않는 함수라도 콜 스택에서 이런 함수들 사이에 끼면 스택 펼치기 과정에 관여할 수 있음.
    • 스택 펼치기 과정 또한 완벽하지 않음
    • 안정적인 소프트웨어를 짜는 게 어려워 짐
  • 비용 문제
    • 이론상 현대적 예외 처리 프레임워크는 에러가 발생하지 않는 한 런타임 부하가 없어야 하지만 사실 꼭 그렇지는 않음
  • 게임 엔진 전체적으로 예외 처리를 끄는 많은 게임 개발사
    • 너티독
    • EA
    • Midway
    • InSomniac Games

예외와 RAII

  • RAII 패턴과 예외 처리를 함께 사용하는 경우가 자주 있다.
  • 생성자에서 원하는 자원 획득 시도, 실패할 경우 예외 던짐.
  • 생성자가 예외 던지지 않았다면 자원 획득 성공했다는 의미.
  • RAII 패턴을 예외 처리 없이도 쓸 수 있다.
    • 자원 객체를 새로 만들고 상태를 검사하도록 코딩 스탠다드 강화.
    • RAII의 모든 이점은 그대로 챙길 수 있다.
    • 예외를 Assertion으로 대체해서 자원 획득 실패를 알릴 수도 있음.

3.2.3.3 어서션assertion

  • 어서션assertion이란 어떤 표현을 검사하는 코드
  • 표현이 참이면 아무일도 일어나지 않음
  • 거짓인 경우 프로그램 중단, 메시지 출력, 디버거 연결가능하면 디버거 실행
  • 어서션의 비용 때문에 디버그 버전이 아닌경우 제거할 수 있게 구현
    • C에서는 표준 라이브러리 헤더 파일 <assert.h> 를 통해 assert() 매크로 제공
    • assert() 정의는 디버그 빌드에서는 유효, 아닌경우 제거됨.

어서션 구현

  • 구조
    1. if/else 조건 구문
    2. 조건이 거짓인 경우 호출될 함수
    3. 프로그램 실행을 중단하고 디버거가 연결돼 있다면 디버거를 불러낼 어셈블리 코드
  • 통상적 구현
    #if ASSERTIONS_ENABLED
    
    // 디버거를 호출할 인라인 어셈블리 함수를 정의한다.
    // 구현은 각 CPU마다 다를 수 있다.
    #define debugBreak() asm { int 3 }
    
    // 표현을 검사하고 거짓이면 에러 처리한다.
    #define ASSERT(expr) \
        if (expr) { } \
        else \
        { \
            reportAssertionFailure{#expr, \
                __FILE__, \__LINE__); \
            debugBreak(); \
        }
    #else
    
    #define ASSERT(expr)	// 코드가 없어진다.
    
    #endif
    • 맨 바깥 #if/#else/#endif 는 코드에서 assertion을 제거할 수 있게 한다.

    • debugBreak()는 프로그램 실행을 멈추고 디버거가 연결된 경우 디버거에 실행을 넘기는 어셈블리 코드를 부른다. (CPU마다 다르지만 보통 어셈블리 명령어 1개로 구현)

    • ASSERT() 매크로는 else까지 딸린 if 문으로 구현. (다른 if/else 문 안에서도 제약 없이 사용될 수 있게)

    • 잘못된 예)

      // 절대 이렇게 하면 안된다!!!!
      #define ASSERT(expr) if (!(expr)) debugBreak()
      
      void f()
      {
          if (a < 5)
              ASSERT(a >= 0);
          else
              doSomething(a);
      }
      
      // 저렇게 하면, 결국 이런 형태가 되는 것
      void f()
      {
          if (a < 5)
              if (!(a >= 0))
                  debugBreak();
              else	// 잘못된 if에 연결된다!!!
                  doSomething(a);
      }
    • ASSERT() 매크로의 else 구문이 하는 일

      1. 프로그래머에게 뭐가 잘못됐는지 알려주는 메시지 출력 (#expr 부분에서 #이 expr표현을 문자열로 바꿔 메시지 출력)
      2. 그 후 디버거로 실행 넘김
    • FILELINE 매크로의 사용

      • .cpp 파일의 이름과 해당 매크로가 있는 줄 위치를 담는다.
      • 메시지를 뿌려주는 함수에 이 값을 넘김으로써 문제가 발생한 정확한 위치를 출력할 수 있음.
  • 두 가지 어서션 사용하는 방법 (어서션이 성능에 영향을 미치기 때문에)
    • ASSERT(): 모든 빌드에 들어감, 디버그 빌드 아니라도 에러 쉽게 찾아내는 데 씀.
    • SLOW_ASSERT(): 디버그 빌드에서만 활성화, 릴리스 빌드에서는 성능에 영향가는 곳에 사용
  • 중요: 프로그램 안의 버그를 찾는 데만 써야하고 절대 사용자 에러를 찾는 데 써서는 안된다.

컴파일 시의 어서션

  • 어서션의 취약점은 런타임에만 조건 점검이 이루어진다는 것.
  • 컴파일 타임에 조건을 알 수 있는경우가 있음
  • 예시:
    • 반드시 128바이트여야 하는 구조체 정의. 프로그래머가 구조체의 크기를 변경할 경우 컴파일러가 에러 메시지 출력하는 경우

      // C++11 부터 표준라이브러리. static_assert() 매크로 사용
      
      struct NeedsToBe128Bytes
      {
          U32 m_a;
          F32 m-b;
          // etc;
      };
      
      static_assert(sizeof(NeedsToBe128Bytes) == 128, "wrong size");
    • C++11 을 사용하지 않는다면 직접 구현하면 된다. (1개의 enumerator를 갖는 이름 없는 열거형 타입 정의해서)

      // C++11 사용하지 않는 경우 직접 구현
      #define _ASSERT_GLUE(a, b) a ## b
      #define ASSERT_GLUE(a, b) _ASSERT_GLUE(a, b)
      #define STATIC_ASSERT(expr) \
          enum \
          { \
              ASSERT GLUE(g_assert_fail_, __LINE__) \
                  = 1 / (int)(!!(expr)) \
          |
          
      STATIC_ASSERT(sizeof(int) == 4);	// 성공
      STATIC_ASSERT(sizeof(float) == 1);	// 실패
      • 접두사 g_assert_fail과 고유 접미사(이 경우 매크로가 호출되는 라인 넘버)를 접합(glue)해 번역 단위 내에서 고유하게 정의
      • 열거자의 값은 1 / (!!(expr)) 이 된다. 2개의 부정 연산자(!!)를 통해 expr이 Boolean값이 되도록 보장.
      • 이 값을 int로 형변환하게 되면 값이 true냐 flase냐에 따라 각각 1, 0 이 된다.
      • 조건이 참이면 열거자는 1/1, 즉 항상 1이 된다.
      • 조건이 거짓인 경우 열거자에 1/0이 들어가는데, 이것은 컴파일 에러를 낸다.
      • 이러면 에러메시지가 '식이 상수로 계산되지 않았습니다' 등으로 표시된다.
    • 템플릿 특수화를 통해 STATIC_ASSERT() 정의 하는 방법

      • C++11 이상 사용하는지 먼저 점검

      • 만약 그렇다면 이식성을 위해 표준 라이브러리의 static_assert() 사용

      • 그렇지 않다면 직접 구현한 코드 사용.

        // 템플릿 특수화를 통한 방법
        
        #ifdef __cplusplus
            #if __cplusplus >= 201103L
                #define STATIC_ASSERT(expr) \
                    static_assert(expr, \
                                  "static assert failed:" \
                                  #expr)
            #else
                // 템플릿을 선언하고 참(true)인 경우에
                // 해당하는 정의만 추가한다(특수화).
                template<bool> class TStaticAssert;
                template<> class TStaticAssert<true> {};
                
                #define STATIC_ASSERT(expr) \
                    enum \
                    { \
                        ASSERT_GLUE(g_assert_fail_, __LINE__) \
                        = sizeof(TStaticAssert<!!(expr)>) \
                    }
            #endif
        #endif
      • 이 경우가 0으로 나누는 코드보다는 에러 메시지가 조금 더 보기 좋다.

    • 컴파일 타임 어서션 구현은 http://www.pixelbeat.org/programming/gcc/static_assert.html 을 참고하면 도움이 될 것이다.

profile
컴퓨터 그래픽스를 알고싶은 개발자

0개의 댓글