컨텐츠 바로가기

모바일 게임 최적화의 정석 - 메모리 편

http://littles.egloos.com/3448229

이전 글

모바일 게임의 성공적인 출시를 위해서는 매우 한정된 자원 내에서 최대한의 앱 성능과 멀티태스킹을 지원하기 위한 기기와 OS의 특성을 이해하고, 그에 맞추어 메모리 관리 정책을 구현하는 것이 반드시 필요하다.
본 글에서는 용량, 속도, 안정성이라는 3개의 카테고리에 관계된 메모리 최적화 전략들을 몇 가지 정리하고자 한다.
  1. 메모리 사용량 최적화
  2. 메모리 사용 속도 최적화
  3. 메모리 접근 안정성 최적화
다행히 iOS나 안드로이드 등의 주요 모바일 OS들이 제공하는 메모리 관리 기법이 데스크탑 OS와 크게 다르지 않기 때문에 대체로 사용되는 최적화 기법들 역시 PC 등의 플랫폼과 유사하나, 메모리의 크기/CPU의 성능/다양한 하드웨어 스펙트럼 등의 이유로 인해 특별히 신경써야 할 사항들이 다수 있다.

    메모리 사용량 최적화
    게임 개발자 관점에서 모든 게이밍 하드웨어의 가용 메모리는 늘 충분하지 않다. 사양이 낮은 모바일 기기들 역시 고유한 문제점들이 있는데, 특히나 매우 다양한 모바일 기기들이 시장에 존재하기 때문에 게임 프로그래머가 예상치 못했던 다양한 암초들이 잠재되어 있다.
    현재 일반 사용자들이 주로 사용하는 모바일 기기들은 256MB ~ 2G 정도의 메인 메모리를 장착하고 있으며, OS가 실제로 허용하는 단일 앱의 가용 메모리는 100MB ~ 300MB 수준이다. (기기별로, OS 버전별로, 사용 상황별로 천차만별) OS가 많은 메모리를 사용하도록 허용한다 해도 앱이 적은 양의 메모리를 사용할수록 사용자가 멀티태스킹을 수행해도 게임과 다른 앱들이 강제 종료되지 않고 살아있을 확률이 높아진다.
    다양한 기기에서 최상의 경험을 제공하기 위한 메모리 사용량 최적화의 원칙은 아래와 같이 몇 가지 방법으로 정리될 수 있다.
    • 꼭 필요한 것들만 메모리에 적재
    필요한 것만 로드하는 정책은 너무나 당연한 이야기이지만, 게임의 요구사항이 자주 바뀌는 환경에서는 전체 게임 데이터에서 작은 일부만을 로드하도록 제어하는것이 쉽지 않은 경우도 많다. 또한, 게임이 데이터를 자주 로드함에 의해 유저의 게임플레이가 방해된다면 가능한 많은 데이터를 메모리에 적재하고자 하는 유혹을 떨치기 어렵다.
    필요한 것만 게임플레이 방해없이 로드하기 위한 기술적인 필수 요소로는 필요한 데이터의 판별과 적절한 로드 방법 등이 있다. 게임의 각 순간에 필요한 최소한의 데이터 집합(working set)을 분류하여, 어느 시점에 어느 데이터를 로드할지 계획해 두어야 한다. 그런데 다행히도 게임에서 메모리 사용량의 대부분을 사용하는 것은 일반적으로 이미지 등의 그래픽 데이터이다. 따라서 이러한 특수한 종류의 데이터에 대한 on-demand 로드 시스템을 구현해 둔다면 게임의 로드 단위를 제어하기 쉬워진다. 로드 단위의 제어 형태는 게임마다 매우 다를 수 있지만, 게임에서 가장 큰 용량을 차지하는 이미지가 필요로 하는 순간에 로드하는 시스템만으로도 대부분의 메모리 사용량 제어 문제는 해결 가능하다. 좀 더 나아간다면, 메쉬/텍스처/애니메이션/사운드 등 "단위 리소스"를 추상화하고, 각 단위 리소스가 필요로 하는 순간에 존재 여부를 체크해서 로드할 수 있는 중앙 집중화된 이른바 리소스 관리자(resource manager)를 구현해 둔다면, 여기에 추후에 다양한 메모리/로드 단계 기능을 붙일 수 있다. 단, 게임의 흐름을 끊지 않는 로딩을 추가로 고려한다면 스테이지 단위로 필요할 것으로 예상되는 리소스들을 로딩화면 단게에서 미리 리소스 관리자에 요청하거나 on-demand로 스트리밍 로딩을 구현할 수 있다. 그런데 다행히도 대부분 모바일 기기에서는 플래쉬 메모리로부터 데이터들을 읽어들이므로 과거의 PC보다는 로딩 속도가 체감상 빠르기 때문에 일부 리소스를 필요로 하는 그 순간에 로드하더라도 큰 문제가 되지 않을 수 있다. (단, 일부 저사양 기기의 경우 플래쉬 메모리의 전송속도가 10MB/s 정도로 HDD보다 느림)
    일단 메모리로 로드된 리소스가 더 이상 필요하지 않을 때 메모리에서 삭제하기 위해서는 필요없는 리소스를 판별하는 절차가 필요하다. 단순하면서도 강력한 방법으로는 리소스 관리자가 각 리소스에 대한 요청/해제의 reference count를 관리하여, 각 리소스의 reference count가 0이 될 때 자동으로 삭제하는 방법을 생각할 수 있으며, 좀 더 리소스 관리자 주도적인 방법으로는 주기적으로 게임 내에서 사용되는 모든 객체를 순회하면서 현재 사용중인 리소스와 로드되어 있는 리소스를 비교하는 방법(garbage collection) 등을 생각할 수 있다.
    reference count나 garbage collection의 세부적인 구현방법에는 다양한 접근 방법이 있는데, 그 분량이 매우 방대하므로 여기에서는 구체적으로 설명하지는 않는다. reference count의 구현에 대해서는 smart pointer, intrusive pointer 등의 자료를 참고하되, 멀티쓰레딩이 사용되는 환경에서는 counter와 smart pointer 객체에 대한 동기화(synchronization) 전략이 매우 중요하다는 점을 유의해야 한다. 
    • 메모리 내 데이터의 압축
    메모리 내에 상주하고 있는 데이터라 할지라도 매우 빠른 속도로 압축을 해제할 수만 있다면 압축기법의 사용으로 한번에 로드되는 데이터의 양을 몇 배는 증가시킬 수 있다.
    가장 쉬운 부분은 GPU에 의해 직접 지원되는 텍스처 압축일 것이다. 텍스처 압축은 1/4 ~ 1/8 정도의 압축률을 보이며, 압축의 사용에 의해 렌더링이 오히려 빨라지는 경우도 많기 때문에 반드시 사용해야 할 기법이라고 할 수 있다. 텍스처 압축의 구체적인 이슈들에 대해서는 이전에 본인이 작성한 글인 모바일 게임 최적화의 정석 - 텍스처 압축 편 을 참고하면 된다.
    그 외에 대부분의 게임에서 많은 메모리를 차지하는 부분은 애니메이션이다. 이미 다양한 애니메이션 압축 기법들이 보편적으로 사용되고 있으며, 그 예를 들자면 데이터 양자화(quantization)/bit수 줄이기, 중요하지 않은 key 삭제하기(trivial key removal)과 같은 직관적인 방법부터, wavelet 압축과 같이 수학적인 모델을 사용하는 방법 등이 있다.
    직관적인 방법들은 적은 CPU 부담을 주면서도 쉽게 구현할 수 있기 때문에 우선적으로 고려되어야 한다.
    데이터의 정밀도를 낮춰서 양자화 시키면 각 애니메이션 정보의 bit수를 줄일 수 있으므로 전체 애니메이션 데이터의 크기가 작아진다. 데이터를 사용할 때에는 사칙연산과 bit 연산만으로 본래의 값을 복원할 수 있기 때문에 속도도 매우 빠르다. 중요하지 않은 key 삭제 기법의 경우에는 애니메이션의 수행에 거의 눈에 띄지 않는 key들을 삭제하는 간단한 전처리 과정을 도입하게 되며, 게임 내에서 애니메이션을 재생하는 방법은 전혀 달라지는 점이 없다는 잇점이 있다. 안 중요한 key를 판별하는 방법에도 다양한 접근법이 있으나, 간단한 예로는 모든 인접한 3개의 key A,B,C를 대상으로 하여 만약 key B가 A~C를 보간한 결과와 거의 유사하다면(threshold 이내) key B를 삭제하는 것을 생각할 수 있다.
    이러한 압축 기법들은 애니메이션 외에도 다양한 종류의 데이터에 적용될 수 있으므로, 게임 내에서 큰 용량을 차지하는 데이터부터 적용을 고려함이 좋을 것이다.
    • OS에 의해 메모리 부족이 보고될 때의 불필요한 메모리 사용의 해제 또는 최악의 경우를 위한 비상대책
    게임이 100MB 이상의 메모리를 사용한다면 간혹 발생하는 기기의 메모리 부족 상황은 피할 수 없다. iOS, 안드로이드 모두 이러한 상황에는 각 앱이 메모리의 일부를 해제하도록 요구하며, 그 이후에도 메모리가 부족하다면 앱들이 강제 종료된다. 따라서, 메모리 사용량이 적을수록, 그리고 기기의 메모리가 부족할 때 최대한 많은 메모리를 해제할수록 게임 앱이 살아남을 가능성이 높아진다. 유저가 다른 앱들을 사용하다가 게임으로 돌아왔을 때 게임이 종료되어 처음부터 다시 시작된다면 게임의 흥미도가 떨어질 수 있으므로, 게임이 제대로 실행되기 위해서 그리고 멀티태스킹 환경에서 훌륭한 게임플레이를 제공하기 위해서는 메모리 부족 상황에 대한 대책이 필요하다.
    쉽게 다시 로드될 수 있거나 다시 계산될 수 있는 데이터를 해제하는 것이 원칙인데, 이를 좀 더 세분화하여 Level-Of-Detail을 고려한 부분적인 unload도 대안이 될 수 있다. 텍스처의 경우라면 메모리가 부족할 때 일단 고해상도 텍스처를 해제하고 추후에 저해상도 버전을 로드하거나, 중요하지 않은 텍스처와 사운드 데이터 등을 해제하고 게임에서 아예 표현하지 않는 방법을 생각할 수 있다.
    특히, 유저가 게임을 플레이하고 있는 상황에서 메모리 부족 경고가 발생한다면 현재 플레이 중인 게임이 강제 종료될 수 있는 급박한 상황이므로, 게임 플레이에 중요치 않은 모든 데이터를 해제할 수 있는 설계가 필요하다. 즉, 게임의 리소스 관리자 수준에서 심각도에 따라 단계적으로 해제할 데이터를 분류해야 하며, 일부 데이터가 메모리에 존재하지 않더라도 게임 플레이가 가능하고 리소스가 필요할 때에 해당 리소스만 다시 로드할 수 있는 시스템의 구현이 필요하다.


    메모리 사용 속도 최적화
    메모리 접근에 대한 속도를 높이기 위한 방법에는 다양한 접근법이 있으나, 여기에서는 주로 사용되는 몇 가지를 소개한다.
    • 캐쉬의 고려 - 데이터에 대한 선형 접근
    CPU에서 메모리의 데이터를 접근할 때, 데이터가 L1 cache에 의해 즉시 사용 가능한 경우와 L2 cache에 의해 접근 가능한 경우, 그리고 cache에서 벗어나 있어 메모리 칩으로부터 가져와야 하는 경우들은 각각 몇 배의 성능 차이를 보인다. L1 cache로 부터 가져오는 가장 효율적인 경우에 비교하여 메모리 칩으로부터 가져오는 경우는 100배 정도의 성능 차이가 날 수도 있다.
    프로그래머의 관점에서 cache 기능이 효율적으로 작동하게 할 수 있는 기본적인 기법은 가능하면 작은 크기의 데이터를 사용하고, 데이터가 선형으로(순차적으로) 접근되게 하는 것이다.
    작은 크기의 데이터를 사용하기 위해서는 위의 메모리 사용량 최적화 부분에서 언급된 내용들과 더불어, 각 데이터의 실제 용도에 맞는 정밀도의 bit 수를 사용하고 data packing등의 기법을 사용할 필요가 있다. 예를 들어, 4개의 byte를 데이터가 필요하다면 32bit 단일 항목 안에 합쳐서 사용하고, bool 타입의 데이터들은 bitfield를 사용하는 등의 기법을 통해 데이터의 크기를 비약적으로 줄일 수 있다.
    데이터가 순차적으로 접근되게 하기 위해서는 자료구조를 설계하는 시점에 이를 고려해야 한다. 예를 들어, tree 구조의 경우 코드에서 각 노드를 억세스하는 순서대로 노드들을 메모리상에 순차적으로 배치하고, 2D 배열이라면 메모리상의 순서와 코드에서의 순환(iteration) 순서가 동일하게 하고, 불가피한 경우가 아니라면 linked list 대신에 배열을 사용하는 것을 생각할 수 있다.
    본인이 작성한 코드의 cache 사용 호율성을 확인하고 싶다면, ARM CPU에서 작동하는 툴인 NVidia의 Tegra System Profiler나 ARM의 Streamline Analyzer 등이 있다.
    • 불필요한 메모리 복사 회피
    저사양 CPU에서 메모리 복사는 매우 비싸다. 그런데 게임의 구조적인 문제로 인해 메모리 복사가 다수 일어나는 경우들도 많지만, 대부분의 경우 메모리 복사를 고려하지 않은 비효율적인 코딩에 의해 발생하곤 한다.
    예를 들어, 다음의 예제 함수들은 그 선언 자체가 많은 양의 메모리 복사가 일어날 것임을 암시한다.
    void SetMatrix( Matrix4x4 matrix ); // 호출시 최소 64 bytes의 메모리 복사
    void SetName( std::string name ); // name의 길이에 따라 수 십 bytes의 메모리 복사
    BigObject GetObject(); // BigObject의 크기만큼 메모리 복사

    최초 코딩 당시에는 별 생각없이 작성되었겠으나 프로파일러 상에서 위와 같은 함수들이 유난히 느린 것을 발견하게 될 것이다.
    이러한 문제들은 object 자체를 전달하는 대신에 포인터 혹은 참조 포인터(reference pointer) 형식으로 전달하는 것만으로도 대부분 해결되니, 이 문제가 발견만 된다면 쉽게 해결할 수 있다.
    눈에는 쉽게 띄지 않으나, 프로그래머들이 자주 실수하는 메모리 복사 병목의 대표적인 예에는 vector(array)의 사용이 있다. 예를 들어 아래의 코드는 매우 느릴 소지가 있다.
    std::vector<BigObject> objects;
    ...
    objects.push_back( aObject );
    만약에 위의 push_back이 자주 호출된다면 vector인 objects 요소들의 크기가 증가되어야 할 때마다 메모리 할당->복사->해제가 이루어지므로 예상치 못한 성능문제가 발생한다. BigObject의 크기가 클수록 문제는 더욱 심각해 질 것이다. vector를 생성할 때에 예상되는 크기를 미리 지정하거나, std::vector<BigObject*> 형식으로 사용하는 것이 쉬운 해결책이 될 수 있다. (그러나 포인터 타입에 대한 vector는 cache 효율성이 낮다)
    • 메모리 할당/해제 횟수 최소화
    C로 작성된 코드의 경우 new/delete 구문 자체는 다른 시스템 호출에 비해 많이 느린편은 아니다. 그러나 여전히 OS 내부적으로 상당히 많은 연산을 필요로 하므로 new/delete의 사용을 최소화 하는 것이 좋다.
    극단적인 경우라면 게임의 로딩시에 필요로 되는 모든 메모리를 한번에 new 한 후에 자체 메모리 관리자를 통해 각 용도별로 쪼개서 쓰는 기법들도 다수 존재한다. 이 접근법의 경우에는 게임의 모드 코드에 대해서 자체 메모리 관리자를 경유하도록 해야하는 등 코드의 복잡도가 크게 증가하지만, 멀티쓰레딩과 잦은 할당이 필요한 경우에에도 고성능 튜닝이 가능하다는 점에서는 많은 잇점이 있다.
    자체 메모리 관리자를 사용하지 않는 경우에도 몇가지 간단한 주의만으로도 메모리 할당/해제의 병목들을 피할 수 있다.
    * 임시로 사용하는 작은 버퍼의 경우에는 new를 사용하기 보다는 지역변수인 array로 선언하여 stack에 할당되게 할 것
    * vector 등 동적으로 크기 변경 가능한 오브젝트 사용시 예상되는 크기 힌트를 지정해 줄 것
    * std::string과 같이 내부적으로 메모리 할당이 필요한 객제 사용시 객체간 복사를 최소화 할 것. 아래와 같은 코드는 피하고, 차라리 sprintf를 사용하는게 낫다.
    std::string newString = std::string("hello ") + "2014";

    추가로, 안드로이드용 Java 코드의 경우 모든 메모리는 garbage collector에 의해 관리된다. 메모리 할당이 많이 일어나서(총 사용량 기준) garbage collector가 자주 작동할수록 전체적인 게임의 성능은 느려지게 된다. Logcat에서 "GC freed ..." 메세지가 자주 보인다면 문제가 있는 것으로 생각하면 된다. 특히, 이미지 관련 객체들은 내부적으로 큰 메모리 할당을 수행하므로, garbage collector가 자주 작동하는 것을 방지하기 위해서는 삭제하지 않고 계속 사용할 버퍼 용도의 객체를 남겨두는 것이 좋을 때가 있다.
    • 메모리 위치 정렬 (alignment)
    CPU의 종류에 따라 데이터가 메모리 어느 주소에 위치하느냐에 따라 접근 성능이 달라진다. 최신의 ARM CPU들은 데이터(변수 타입)의 메모리상 위치가 그 크기와 동일한 alignment를 가져야만 최적의 성능을 발휘한다. 예를 들어, int/float 등 32bit 값들은 메모리 주소가 4의 배수이어야 하며, double/long long등 64bit 값들은 메모리 주소가 8의 배수이어야 한다.
    iOS나 안드로이드의 컴파일 모두 C코드에서 struct의 멤버로 각 데이터 타입을 정의하여 사용하면 기본으로 위의 규칙에 맞추어 선언이 되므로 대부분의 경우는 크게 신경 쓸 필요가 없다.
    그러나 텍스처 데이터 로드의 경우처럼 메모리에 큰 버퍼를 일괄 할당한 후에 이 주소로부터 단위 데이터들을 읽고 쓰는 경우라면, 정렬 규칙을 한 번 되새겨서 그에 의한 페널티가 있는지 생각해 볼 필요가 있다.


    메모리 접근 안정성 최적화
    C언어와 같이 게임이 할당하는 메모리의 해제를 직접 관리해야 하는 환경이라면 메모리 할당과 해제의 실수로 인한 안정성 문제는 어려우면서도 반드시 해결되어야만 한다.
    버그가 발견될 때마다 추적하여 그 원인을 수정하는 것에 더불어, 시스템적으로 그러한 버그를 방지하거나 문제를 쉽게 발견할 수 있는 방법들을 제공한다면 게임 코드의 품질 향상에 큰 도움이 된다. (크래쉬가 더 이상 두렵지 않을지도 모른다)
    • 메모리 할당/해제의 자동화
    위에서 이미 언급된 리소스 관리자의 개념을 좀 더 일반화하여 모든 메모리 할당에 적용한다면 Java나 Objective-C와 같이 관리된 환경과 유사한 메모리 관리의 자동화를 구현할 수 있다.
    모든 class/struct 타입에 reference count를 도입하거나, 모든 new 구문을 위한 new operator overload를 정의한다면 할당된 메모리/객체의 추적이 가능해진다. 직접 구현한 메모리/리소스 관리 시스템 하에서는 메모리 해제가 필요한 시점을 직접 제어할 수 있고, 실수에 의한 메모리 누수를 방지할 수 있다. 또한, 아래에서 언급할 오류 검출용 정보 삽입이 쉬워진다.
    Garbage collector를 직접 구현하고자 한다면 reference들을 추적하는 문제가 핵심이 될 것이다. 각 struct/class 내에 reference(pointer) 멤버의 주소들을 Garbage collector가 모두 알고 있어야 하므로 어느 정도의 reflection 시스템이 필요하고, reflection 정보의 관리 문제가 Gargbage collection 자체보다 어려울 수 있으므로 이 접근법은 신중히 택해야 한다. 혹은 Boehm GC와 같은 오픈소스 라이브러리의 사용을 고려할수도 있다.

    • 메모리 검증용 툴의 사용
    만약 게임 코드가 윈도우 환경에서도 테스트 가능하다면 Microsoft의 무료 툴인 Application Verifier 등을 통해 메모리 접근의 문제점을 쉽게 검증할 수 있다.
    이 툴은 OS에서 제공되는 검증 기능들을 제어하여 애플리케이션을 실행하므로, 실행 속도가 매우 빠르고 비주얼스튜디오의 디버그 창에 상세한 정보들을 출력해 준다. 다만 작은 메모리 할당이 매우 많이 일어나는 경우에는 Application Verifier 자체의 메모리 부담이 커져서 실행에 어려움이 있을 수 있다.
    그 외의 상용 툴들은 대부분 윈도우 플랫폼에서만 작동하며, iOS를 위해서는 XCode의 instrument 툴이 아주 간단한 정보를 제공할 수 있다. 안드로이드의 경우에는 native 코드에 대한 검증이 가능한 툴은 아직 발견하지 못했다.

    • 오류의 빠른 발견
    게임 코드에 실수가 있을 때 문제가 있다는 사실을 가급적 빨리 알아챌 수 있다면 추후에 추적이 매우 어려운 상황을 맞게 되는 것보다 훨씬 싸게 고칠 수 있다.
    비단 메모리 관리에만 해당되는 내용은 아니지만, 코드에서 assert 구문을 적극적으로 사용할수록 오류의 존재를 빨리 확인할 수 있다. 주요 함수마다 시작과 끝 부분에 함수 작성 의도상 당연히 만족해야 할것으로 생각되는 pre-condition과 post-condition을 assert 구문으로 삽입해두면 코드에 의도치 않은 버그가 생겼을 때 빨리 잡을 수 있다. 예를 들어 Visual C의 std 컨테이너들은 내부적으로 상당히 많은 양의 assert를 구현하고 있어, 쓰레드 동기화 문제로 인한 데이터 오류가 생겼을 때 쉽게 찾아낼 수 있다. 게임 코드도 같은 전략에 의해 오류들을 자동으로 검증할 수 있을 것이다. assert에 의해 검증되지 않은 버그가 발견될 때마다 이에 해당하는 assert 구문을 추가한다면 점차 자기 방어형으로 진화하는 코드를 구축할 수 있다.
    다른 오류 검출 기법의 예로는 메모리가 할당된 직후 또는 해제하기 직전에 식별 가능한 데이터 시퀀스로 채워서 이 값을 검증하는 assert를 삽입해 두거나, 나중에 크래쉬가 발생한 상황에서 해당 메모리가 어떤 상태인지 판별하는 것이다. new operator를 override 했다면, new 안에서 할당된 메모리를 반환하기 직전에 0xbaadf00d와 같은 특수한 값으로 채워둘 수 있다. delete의 경우 해제하기 직전에 0xdeadbeef와 같은 값으로 채워 두고, 메모리를 실제로 사용할 코드에서 접근한 메모리의 값을 위의 비정상 상태 값들과 비교하는 assert를 삽입해 둘 수 있을 것이다. Windows의 경우 디버깅 환경에서 이와 유사한 기능이 기본 제공되나, 디버그 모드를 위한 특별한 기능이 없는 OS를 위해서는 자체적으로 간단한 구현을 고려할만 하다.


    프로파일러!
    메모리 사용의 문제는 게임 플레이 테스트만으로는 쉽게 눈에 띄지 않으며, 문제를 한 번 수정했다 해도 개발이 추가로 이루어짐에 따라 유사한 문제가 다시 발생하기 쉽다.
    메모리 문제의 수정만큼이나 중요한 것은 메모리 문제를 쉽게 인지할 수 있는 시스템을 게임 내에 넣어두고 개발이 이루어지는 동안에 주기적으로 검증하는 것이다. 게임 내 프로파일러에 의해 메모리 사용 현황을 쉽게 파악할수록 메모리 문제로 씨름하는 시간은 줄어든다. 좋은 프로파일러를 구현할수록 프로파일러 구현 시간은 오래 걸리겠지만 그에 의해 줄어드는 디버깅 시간의 이득은 훨씬 크다.

    게임 내에 구현할 수 있는 메모리 프로파일러 기능의 예시를 아래와 같이 몇가지 들 수 있다.
    * 카테고리별 메모리 사용량
    * 메모리를 많이 사용중인 항목들(리소스) 목록
    * 메모리 단편화 현황
    * 시간에 따른 메모리 사용량 그래프
    * 모든 메모리 할당에 대한 히스토리 로그
    * 시간당 메모리 할당량/횟수

    웹페이지나 GDC 등에서 위의 기능들에 대해 설명된 자료들이 이미 많이 존재하므로 그것들을 참고하면 더욱 구체적인 정보를 얻을 수 있다.


    덧글|신고