STL 자료구조의 구현부를 탐색하던 중, 아래와 같은 코드를 보았다.

_EXPORT_STD template <class _It>
concept bidirectional_iterator = forward_iterator<_It> && derived_from<_Iter_concept<_It>, bidirectional_iterator_tag>
    && requires(_It __i) {
        { --__i } -> same_as<_It&>;
        { __i-- } -> same_as<_It>;
    };

 

다른건 그렇다고 쳐도, concept와 require라는 키워드를 처음보아서 이에 대해 찾아보았다.

먼저, 이를 이해하기 위해선 템플릿에 대해 알아야 한다.

 

템플릿이란, 하나의 기능을 다양한 자료형에 대응할 수 있도록 만들어주는 매우 편리한 기능이다. 하지만, 템플릿에는 한가지 단점이 있는데 잘못된 자료형으로 인한 오류를 쉽게 탐지할 수 없다는 것이다. 상황에 따라 컴파일 타임에 오류가 탐지되지 않기도 하고 오류가 탐지되더라도 그 오류 메세지가 말도안되게 복잡하고 까다로운 상황이 자주 발생한다.

 

즉, 모든 자료형에 대응할 수 있도록 만들어진 문법이지만 프로그래머 입장에선 그 자율성이 다소 부담이 되기도 한다. 그래서 C++에선 템플릿의 매개변수를 제약하는 방법을 제공해준다. 예를 들어, 특정 클래스를 상속받은 자료형만 템플릿 매개변수에 대입할 수 있다든가  하는 방법 말이다.

 

아래의 코드를 보자.

class Test
{
public:
    Test(int _Value)
    {
        Value = _Value;
    }

    void operator+=(const Test& _Other)
    {
        Value += _Other.Value;
    }

private:
    int Value = 0;
};

template<typename T1, typename T2> 
void Add(T1& _Left, T2& _Right)
{
    _Left += _Right;
}

int main()
{
    Test Test1(2);
    Test Test2(5);

    Add(Test1, Test2);

    return 0;
}

 

Add함수는 템플릿 인자로 들어온 두 값중 첫번째 인자에 두번째 인자를 += 으로 연산하고 있다.

그리고 main함수에선 Test의 두 인스턴스를 만든 뒤에 Add 함수의 인자로 대입하여 함수를 호출하였다.

이는 당연히 문제가 없다. 왜냐하면 Test 클래스엔 += 연산자가 오버로딩이 되어있기 때문이다.

 

하지만, 만약 오버로딩이 되어있지 않다면? 컴파일 에러가 발생하고 만다. 즉, 위의 경우엔 += 연산자가 오버로딩 되어있는 클래스만 템플릿 매개변수로 사용되어야 한다는 것이다.

 

그렇다면, 우리는 먼저 아래와 같은 방법을 사용할 수 있을 것이다.

class Parent
{
    virtual void operator+=(Parent& _Other) = 0;
};

이렇게 += 연산자 오버로딩이 순수 가상함수로 선언된 객체를 만들고 이 객체를 상속받은 객체만 Add 함수의 매개변수로 사용 가능하도록 만드는 것이다. 그렇게 되면 적어도 Add 함수에서 사용되는 객체에 += 연산자가 오버로딩이 되어있지 않을 가능성은 없어지기 때문이다.

 

C++ 11에선 아래와 같은 방법으로 이를 제약할 수 있다.

template<typename T1, typename T2, 
    typename = typename std::enable_if<std::is_base_of<Parent, T1>::value && std::is_base_of<Parent, T2>::value>::type>
void Add(T1& _Left, T2& _Right)
{
    _Left += _Right;
}

enable_if 안의 식이 false라면 컴파일 오류가 발생하고 true라면 정상적으로 컴파일이 된다.

std::is_base_of는 첫번째 매개변수가 두 번째 매개변수의 상위 클래스인지를 검사하는 식이다. 

즉, T1과 T2가 모두 parent를 상속받고 있어야만 정상적으로 컴파일이 되는 것이다.

 

하지만, 위의 문법은 너무 길다. C++ 14에선 아래와 같이 줄일 수 있다고 한다.

template<typename T1, typename T2, 
    typename = std::enable_if_t<std::is_base_of_v<Parent, T1> && std::is_base_of_v<Parent, T2>>>
void Add(T1& _Left, T2& _Right)
{
    _Left += _Right;
}

std::enable_if 대신 std::enable_if_t를 사용하여 typename을 생략하였고, 끝의 ::type도 생략되었다.

또한, std::is_base_of 대신 std::is_base_of_v를 사용하여 끝의 ::value도 생략해주었다.

 

하지만, 이 역시나 너무 길고 가독성도 안좋고 불편하다. 그래서 생겨난 것이 concept와 Requires라고 한다.

(concept와 requires는 C++ 20부터 사용 가능하다고 한다.)

template<typename T>
concept ParentDerived = std::is_base_of_v<Parent, T>;

template<typename T1, typename T2> 
    requires ParentDerived<T1> && ParentDerived<T2>
void Add(T1& _Left, T2& _Right)
{
    _Left += _Right;
}

매크로 정의를 하듯이 concept 뒤에 이름과 정의를 대입해주고, 이를 템플릿 함수에서 requires로 사용하면 된다.

ParentDerived는 std::is_base_of_v<Parent, T>로 정의가 되어있기 때문에, ParentDerived<T1> std::is_base_of_v<Parent, T1>이 되는 것이다. 앞에서 보앗던 것과 동일하게 requires뒤의 구문이 true가 되어야만 정상적으로 컴파일이 된다.

 

concept으로 정의해놓은 대상은 다양한 템플릿 함수에 무한정 사용할 수 있다. (물론 똑같은 이름으로 여러개가 정의되어있으면 안된다.)

 

아래와 같이 Requres는 함수 이름 뒤에 적어도 된다.

template<typename T1, typename T2> 
void Add(T1& _Left, T2& _Right) requires ParentDerived<T1>&& ParentDerived<T2>
{
    _Left += _Right;
}

 

심지어 아래와 같이 사용도 가능하다고 한다.

void Add(ParentDerived auto _Left, ParentDerived auto _Right)
{
    _Left += _Right;
}

template<typename T1, typename T2> 를 빼고, concept로만 함수를 정의한 것이다.

 

이런식으로 템플릿 매개변수를 제약하는 방법을 알아보았다.

이를 전문용어(?)로 SFINAE 라고 부른다고 한다. (Substitution Failure Is Not An Error)

 

특정 자료형을 템플릿 함수에서 사용할 수 없을 때, 이 것이 컴파일 오류로 발생하도록 냅두는 것이 아니라 템플릿 생성이 무시되도록 한다는 의미인 듯 하다. 실제로 사용해보면 조건에 맞지 않는 자료형을 대입했을 때 템플릿 매개변수 오류가 아니라 오버로딩된 함수를 찾을 수 없다는 오류가 뜬다.

 

(템플릿 생성이 무시되어버리기 때문에 무시무시한 오류 메세지를 보지 않아도 된다. 조건에 맞게 오버로딩만 잘 만들어 두면 정상작동한다.)  

 

지금은 concept와 requires에 대해서만 알아보았지만, SFINAE에 대해서 조금 더 깊게 알아봐야겠다.

3D Game Engine Programming | Helping you build your dream game engine (3dgep.com)

 

3D Game Engine Programming

Helping you build the next generation of 3D game engines

www.3dgep.com

 

3D 프로그래밍 관련 내용이 많다.

시간 날 때 조금씩 읽어보면 이런저런 도움이 될듯?? 

기존에는 커스텀예외객체만을 생성해놓고 try, catch는 모두 자료구조의 함수 안에서 실행하도록 했지만, 이젠 try catch를 호출하는 static 클래스를 만든 뒤, 자료구조에선 해당 함수를 호출하도록 하였다.

 

#pragma once
#include <cassert>
#include "CustomException.h"

enum class ExceptionType
{
    OutOfRange,
    EmptyContainer,
};

class ExceptionFunction
{
public:
    static void CheckException(bool _IsOccurred, bool _DoAssert, const char* _ClassName, ExceptionType _ExceptionType)
    {
        try
        {
            if (_IsOccurred == true)
            {
                ThrowException(_ClassName, _ExceptionType);
            }
        }
        catch (std::exception& _Error)
        {
            std::cerr << _Error.what() << std::endl;
            assert(_DoAssert);
        }
    }

private:
    static void ThrowException(const char* _ClassName, ExceptionType _ExceptionType)
    {
        switch (_ExceptionType)
        {
        case ExceptionType::OutOfRange:
            throw CustomException::OutOfLange(_ClassName);
            break;
        case ExceptionType::EmptyContainer:
            throw CustomException::EmptyContainer(_ClassName);
            break;
        }
    }
};

 

이렇게, 외부에선 CheckException 함수를 호출하여 파라미터를 잘 대입해준다면, 예외 발생 여부를 검사하고 그에 맞는 메세지를 출력하고 예외 객체를 던지고 assert하는 것까지 모두 해당 함수 내부에서 처리하도록 하였다.

virtual const bool& At(size_t _Index) 
{	
    try
    {
        if (_Index >= MySize)
        {
            throw CustomException::OutOfLange();
        }
    }
    catch (std::exception& _Error)
    {
        std::cerr << _Error.what() << std::endl;
        assert(false);
    }

    return MyElements[_Index];
}

위는 기존의 At 코드인데, 이 코드는 아래와 같이 바뀌었다.

virtual const bool& At(size_t _Index) 
{
    ExceptionFunction::CheckException(_Index >= MySize, true, typeid(*this).name(), ExceptionType::OutOfRange);
    return MyElements[_Index];
}

그리고, List도 일부 기능을 구현하였다.

먼저, List클래스 내부에서만 사용할 수 있는 Node 중첩 클래스를 선언하였고, 이를 통해 원소들이 연결되도록 하였다.

 

List는 Head와 Tail을 보유하고 있고, 더미노드를 생성하여 각각 더미노드를 가리키도록 하였다.

즉 List의 가장 앞의 원소는 Head의 다음 원소가 되는 셈이며, 가장 뒤의 원소는 Tail의 앞의 원소가 되는 것이다.

 

아래는 구현된 코드 전문이다.

#pragma once
#include "ExceptionFunction.h"

template <typename DataType>
class List
{
    class Node;
public:
    List()
    {
        CreateDummyNode();
    }

    List(size_t _Size)
    {
        CreateDummyNode();
        
        for (int i = 0; i < _Size; i++)
        {
            Push_Back(DataType());
        }
    }

    List(size_t _Size, const DataType& _Data)
    {
        CreateDummyNode();

        for (int i = 0; i < _Size; i++)
        {
            Push_Back(_Data);
        }
    }

    List(size_t _Size, DataType&& _Data)
    {
        CreateDummyNode();

        for (int i = 0; i < _Size; i++)
        {
            Push_Back(_Data);
        }
    }

public:
    void Push_Back(const DataType& _Data)
    {
        Node* NewNode = new Node();
        NewNode->DataPtr = _Data;

        Node* CurBackNode = Tail->PrevNode;
        CurBackNode->NextNode = NewNode;

        NewNode->PrevNode = CurBackNode;
        NewNode->NextNode = Tail;

        Tail->PrevNode = NewNode;

        MySize++;
    }

    void Push_Back(DataType&& _Data)
    {
        Node* NewNode = new Node();
        NewNode->DataPtr = _Data;

        Node* CurBackNode = Tail->PrevNode;
        CurBackNode->NextNode = NewNode;

        NewNode->PrevNode = CurBackNode;
        NewNode->NextNode = Tail;

        Tail->PrevNode = NewNode;

        MySize++;
    }

    const DataType& Front()
    {
        ExceptionFunction::CheckException(Head->NextNode == nullptr, true, typeid(*this).name(), ExceptionType::OutOfRange);
        return Head->NextNode->DataPtr;
    }

    const DataType& Back()
    {
        ExceptionFunction::CheckException(Tail->PrevNode == nullptr, true, typeid(*this).name(), ExceptionType::OutOfRange);
        return Tail->PrevNode->DataPtr;
    }

private:
    class Node
    {
    public:
        void operator=(const Node* _Node)
        {
            DataPtr = _Node->DataPtr;
            PrevNode = _Node->PrevNode;
            NextNode = _Node->NextNode;
        }

        const Node* operator++()
        {
            *this = NextNode;
            return *this;
        }

        const Node operator++(int)
        {
            Node ReturnNode = NextNode;
            ++(*this);
            return ReturnNode;
        }

        const Node* operator--()
        {
            *this = PrevNode;
            return *this;
        }

        const Node* operator--(int)
        {
            Node ReturnNode = PrevNode;
            --(*this);
            return ReturnNode;
        }

        DataType* DataPtr = nullptr;
        Node* PrevNode = nullptr;
        Node* NextNode = nullptr;
    };

private:
    void CreateDummyNode()
    {
        Node* DummyHead = new Node();
        Node* DummyTail = new Node();

        Head = DummyHead;
        Tail = DummyTail;

        DummyTail->PrevNode = Head;
        DummyHead->NextNode = Tail;
    }

private:
    Node* Head = nullptr;
    Node* Tail = nullptr;
    
    size_t MySize = 0;
};

 

기존에는 이터레이터가 벡터에 중첩클래스로 종속되어 있었고 이를 이용하는 형식이었는데, STL의 구조를 면밀히 살펴보니 Iterator은 완전히 별개의 클래스고 자료구조는 여기에 데이터를 담아 반환해줄 뿐인 것 같았다.

 

그래서 이터레이터를 아예 별개의 클래스로 분리해주었다.

이터레이터의 종류는 InputIterator, OutputIterator, forwardIterator, BiDirectionalIterator, RandomAccessIterator로 총 5가지가 있다고 하는데, 난 일단 벡터에서 사용할 RandomAccessIterator만 구현하였다.

template <typename DataType>
class RandomAccessIterator
{
public:
    RandomAccessIterator()
    {
    }

    RandomAccessIterator(const DataType* _DataPtr)
    {
        DataPtr = const_cast<DataType*>(_DataPtr);
    }

    RandomAccessIterator(const RandomAccessIterator& _Other)
    {
        DataPtr = _Other.DataPtr;
    }

    RandomAccessIterator& operator=(const RandomAccessIterator& _Other)
    {
        DataPtr = _Other.DataPtr;
        return *this;
    }

    RandomAccessIterator& operator=(RandomAccessIterator&& _Other) noexcept
    {
        DataPtr = _Other.DataPtr;
        _Other.DataPtr = nullptr;

        return *this;
    }

    bool operator==(const RandomAccessIterator& _Other)
    {
        return (DataPtr == _Other.DataPtr);
    }

    bool operator!=(const RandomAccessIterator& _Other)
    {
        return !(*this == _Other);
    }
    
    RandomAccessIterator& operator++()
    {
        (DataPtr)++;
        return *this;
    }

    RandomAccessIterator operator++(int)
    {
        RandomAccessIterator ReturnIter(*this);
        ++(*this);
        return ReturnIter;
    }

    RandomAccessIterator& operator--()
    {
        (DataPtr)--;
        return *this;
    }

    RandomAccessIterator operator--(int)
    {
        RandomAccessIterator ReturnIter(*this);
        --(*this);
        return ReturnIter;
    }
     
    RandomAccessIterator& operator+(int _Offset)
    {
        DataPtr += _Offset;
        return *this;
    }

    RandomAccessIterator& operator-(int _Offset)
    {
        DataPtr -= _Offset;
        return *this;
    }

    DataType& operator*()
    {
        return *(DataPtr);
    }

    void Debug()
    {
        std::cout << *(DataPtr);
    }

private:
    DataType* DataPtr = nullptr;
};

bool을 제외한 자료형에 대해서 위와 같이 선언해주었다. 기존의 이터레이터와 정의는 완전히 동일하다.

따로 수정을 안해도 될 것 같아서 그대로 외부로 꺼내기만 했는데, 이는 추후 테스트를 좀 해봐야 할 듯 하다.

 

bool타입이 문제가 좀 많았다. 먼저, *연산자를 사용하면 bool값을 반환해야 하는 동시에 *연산자로 반환된 데이터에 =연산자를 통해 값을 바꿀 수도 있어야 했다. 아무래도 bool 타입은 비트단위로 관리되다 보니 일반적인 포인터로 구현하는 것이 불가능했는데, 이를 중첩클래스를 활용해 해결하였다. 먼저 코드 전문을 보자.

template<>
class RandomAccessIterator<bool>
{
    class BitReference;

public:
    RandomAccessIterator()
    {
    }

    RandomAccessIterator(unsigned int* _DataPtr, size_t _BitIndex)
    {
        DataPtr = _DataPtr;
        BitIndex = _BitIndex;
    }

    RandomAccessIterator(const RandomAccessIterator& _Other)
    {
        DataPtr = _Other.DataPtr;
        BitIndex = _Other.BitIndex;
    }

    RandomAccessIterator& operator=(const RandomAccessIterator& _Other)
    {
        DataPtr = _Other.DataPtr;
        BitIndex = _Other.BitIndex;

        return *this;
    }

    RandomAccessIterator& operator=(RandomAccessIterator&& _Other) noexcept
    {
        DataPtr = _Other.DataPtr;
        BitIndex = _Other.BitIndex;
        _Other.DataPtr = nullptr;

        return *this;
    }

    bool operator==(const RandomAccessIterator& _Other)
    {
        return (DataPtr == _Other.DataPtr && BitIndex == _Other.BitIndex);
    }

    bool operator!=(const RandomAccessIterator& _Other)
    {
        return !(*this == _Other);
    }

    RandomAccessIterator& operator++()
    {
        BitIndex++;

        if (BitIndex >= 32)
        {
            BitIndex = 0;
            DataPtr++;
        }

        return *this;
    }

    RandomAccessIterator operator++(int)
    {
        RandomAccessIterator ReturnIter(*this);
        ++(*this);
        return ReturnIter;
    }

    RandomAccessIterator& operator--()
    {
        BitIndex--;

        if (BitIndex < 0)
        {
            BitIndex = 31;
            DataPtr--;
        }

        return *this;
    }

    RandomAccessIterator operator--(int)
    {
        RandomAccessIterator ReturnIter(*this);
        --(*this);
        return ReturnIter;
    }

    RandomAccessIterator operator+(int _Offset)
    {
        size_t ReturnBitIndex = BitIndex + _Offset;
        unsigned int* ReturnDataPtr = DataPtr;

        while (ReturnBitIndex >= 32)
        {
            ReturnBitIndex -= 32;
            ReturnDataPtr++;
        }

        return RandomAccessIterator(ReturnDataPtr, ReturnBitIndex);
    }

    RandomAccessIterator operator-(int _Offset)
    {
        size_t ReturnBitIndex = BitIndex - _Offset;
        unsigned int* ReturnDataPtr = DataPtr;

        while (ReturnBitIndex >= 32)
        {
            ReturnBitIndex += 32;
            ReturnDataPtr--;
        }

        return RandomAccessIterator(ReturnDataPtr, ReturnBitIndex);
    }

    BitReference operator*()
    {
        return BitReference(DataPtr, BitIndex);
    }

    void Debug()
    {
        std::cout << *(DataPtr);
    }

private:
    class BitReference
    {
    public:
        BitReference(const unsigned int* _DataPtr, size_t _BitIndex)
        {
            DataPtr = const_cast<unsigned int*>(_DataPtr);
            BitIndex = _BitIndex;
        }

        operator bool() const
        {
            return (*DataPtr) & (1 << BitIndex);
        }


        void operator=(bool _Value)
        {
            if (_Value == true)
            {
                (*DataPtr) |= (1 << BitIndex);
            }
            else
            {
                (*DataPtr) &= ~(1 << BitIndex);
            }
        }

    private:
        unsigned int* DataPtr = nullptr;
        size_t BitIndex = 0;
    };

private:
    unsigned int* DataPtr = nullptr;
    size_t BitIndex = 0;
};

먼저, *연산자를 호출하면 BitReference라는 클래스를 생성하여 이를 반환하도록 하였다. 그리고 BitReference 클래스 내부에 = 연산자를 오버로딩하여 이를 통해 값을 바꿀 수 있도록 하였다.

 

또한, *연산자를 통해 bool 값도 반환받을 수 있어야 하기 때문에 BitReference 클래스에 bool타입으로의 캐스팅 연산자를 오버로딩하여 값을 받을 수 있도록 하였다.

먼저, vector은 bool타입에 대해 특수화되어있다.

 

하지만, vector은 bool타입이라고 하더라도 동일한 인터페이스를 보유해야 한다.

그러므로, 상속을 통해 멤버함수들을 순수 가상함수로 선언해주었다.

template<typename DataType>
class VectorBase
{
public:
	class Iterator;

public:
	virtual void Reserve(const size_t _Capacity) = 0;
	virtual void Resize(const size_t _Size) = 0;
	virtual void Resize(const size_t _Size, const DataType& _Data) = 0;
	virtual void Resize(const size_t _Size, DataType&& _Data) = 0;
	
	virtual void Push_Back(const DataType& _Data) = 0;
	virtual void Push_Back(DataType&& _Data) = 0;
	virtual void Pop_back() = 0;
	
	virtual void Insert(const Iterator& _Where, const DataType& _Value) = 0;
	virtual void Clear() = 0;
	
	virtual const DataType& At(size_t _Index) = 0;
	virtual const DataType& Front() = 0;
	virtual const DataType& Back() = 0;
};

 

공통으로 구현해야 하는 함수들을 VectorBase라는 함수에 선언하였고, 이 클래스를 상속받도록 하였다.

또한, Iterator 클래스를 Vector이 아닌 VectorBase 내부로 이동시켜주었다.

 

이로 인해 템플릿 클래스의 중첩 클래스인 Iterator을 컴파일러가 혼동할 수 있으므로, Vector에는 아래의 구문을 추가해주었다.

using Iterator = typename VectorBase<DataType>::Iterator;

위의 구문으로 인해, 사용이 편해진 건 덤이다.  

 

그리고 이를 기반으로, bool 타입을 제외한 Vector의 멤버함수에 몇 가지 함수를 추가로 구현해주었다.

 

const DataType& Front() override
{
	return MyElements[0];
}

const DataType& Back() override
{
	return MyElements[MySize - 1];
}

void Clear() override
{
    MySize = 0;
    EndPtr = BeginPtr + 1;
}

void Pop_back() override
{
    if (MySize > 0)
    {
    	MySize--;
    	EndPtr--;
    }
}

void Insert(const Iterator& _Where, const DataType& _Value) override
{
    if (MySize + 1 >= MyCapacity)
    {
        Reserve(MyCapacity * 2);
    }

    Iterator EndIter = End();

    while (_Where != EndIter)
    {
        *EndIter = *(EndIter - 1);
    }
}

 

이제, Vector<bool> 타입에 대해 함수를 모두 구현해주면 Vector는 일단은 마무리이다.

디버깅을 위해 커스텀 예외처리 클래스를 만들었다. 물론 C++에서 제공하는 예외처리 클래스가 존재하지만, 그냥 만들어보았다. 별 것도 아니고 그냥 코드 몇 줄 적으면 되는거지만 아무튼 만들어보는게 도움이 될 것 같았다.

#pragma once
#include <exception>

class CustomException
{
public:
    class OutOfLange : public std::exception
    {
    public:
        OutOfLange()
        {

        }

        OutOfLange(const char* _ClassName)
        {
            ClassName = _ClassName;
        }

        const char* what() const noexcept override
        {
            static std::string ExMsg = "Exception is detected : OutOfLange\nClassName : " + ClassName;
            return ExMsg.c_str();
        }

    private:
        std::string ClassName = "";
    };
};

 

먼저, CustomException 클래스를 만들었고, 그 안에 예외처리 클래스를 넣어두었다.

CustomException은 일종의 네임스페이스처럼 사용되는 셈이다.

 

그 안에는 먼저, 범위를 초과할 때 사용될 OutOfLange 클래스를 만들었다. 해당 클래스는 std::excpetion 클래스를 상속받고 있으며 What 함수를 오버라이딩하였다.

 

생성자에서 클래스의 typeid를 받아 이를 저장하고, 예외처리 메세지에 사용하는 구조로 만들어주었다.

 

이 예외처리를 기반으로 Vector클래스 내부에 At함수를 만들었다.

 

DataType operator[](size_t _Index)
{
    return *(BeginPtr + _Index);
}

먼저, [] 연산자는 이렇게 예외처리없이 포인터에 접근하도록 하였다. 그렇기 때문에 사이즈를 벗어난 값이 인자로 들어오면 무슨 일이 발생할 지 예측할 수 없다.

 

DataType At(size_t _Index)
{
    try 
    {
        if (_Index >= MySize)
        {
            throw CustomException::OutOfLange(typeid(*this).name());
        }
    }
    catch (std::exception& _Error)
    {
        std::cerr << _Error.what() << std::endl;
    }

    return *(BeginPtr + _Index);
}

At함수에는 이처럼 예외처리를 해주었다.

인자가 벡터의 원소개수보다 크거나 같다면 예외를 반환하도록 하였다.

예외 객체는 위에서 만들었던 OutOfLange 클래스를 반환해주었다.

 

Catch에선 OutOfLange에서 오버라이딩한 what함수를 호출하여 에러메세지를 출력하도록 하였다.

벡터의 이터레이터를 구현하였다.

 

처음엔, IteratorBase를 구현하고 이를 모든 자료구조의 Iterator이 상속받도록 하여 Algorithm 함수가 다양한 Iterator에 대해 대응하도록 하고 싶었고, 또 순수가상함수를 이용해 이터레이터간의 공통적인 인터페이스를 구현하고 싶었다.

 

하지만, 공변의 문제로 가상함수화 하기 힘든 연산자들이 몇몇 있었다. 이 때문에 구조를 각 자료구조의 이터레이터에 맞게 Algorithm 함수를 여러개로 오버로딩하는 방식으로 하기로 하였다.

 

일단 벡터의 이터레이터는 아래와 같이 필요한 연산자만 구현해놓았다.

더 필요하다면 추가로 구현할 것이고, 일단은 이정도만 구현해놓았다.

template<typename DataType>
class Vector<DataType>::Iterator
{
public:
	Iterator()
	{
	}

	Iterator(const DataType* _DataPtr)
	{
		DataPtr = const_cast<DataType*>(_DataPtr);
	}

	Iterator(const Iterator& _Other)
	{
		DataPtr = _Other.DataPtr;
	}

	Iterator& operator=(const Iterator& _Other)
	{
		DataPtr = _Other.DataPtr;
		return *this;
	}

	Iterator& operator=(Iterator&& _Other)
	{
		DataPtr = _Other.ataPtr;
		_Other.DataPtr = nullptr;

		return *this;
	}

	bool operator==(const Iterator& _Other)
	{
		return (DataPtr == _Other.DataPtr);
	}

	bool operator!=(const Iterator& _Other)
	{
		return !(*this == _Other);
	}

	Iterator& operator++()
	{
		(DataPtr)++;
		return *this;
	}

	Iterator operator++(int)
	{
		Iterator ReturnIter(*this);
		++(*this);
		return ReturnIter;
	}
	 
	DataType operator*()
	{
		return *(DataPtr);
	}

	void Debug()
	{
		std::cout << *(DataPtr);
	}

private:
	DataType* DataPtr = nullptr;
};

 

벡터의 멤버함수에는 위의 이터레이터를 토대로 Begin과 End를 추가하였다.

Iterator Begin()
{
    return Iterator(BeginPtr);
}

Iterator End()
{
    return Iterator(EndPtr);
}

 

여러 자료구조간의 공통의 인터페이스를 구현하기 위해 상속을 사용할 필요는 있을 것 같은데, 아직은 어떤 식으로 디자인 할 지 감이 잘 안온다.

일단 벡터를 모두 마무리 하고, 리스트까지 만든 다음에 두 클래스의 구조를 비교하면서 고민해봐야 할 듯 하다.

 

C#은 데이터를 쉽고 효율적으로 처리하기 위해 LINQ라는 것을 지원해준다.

SQL 문법을 사용해서 데이터를 간편하게 탐색하고 처리하는 것이다.

쉽게 생각하면 데이터베이스를 다루듯이 C#의 자료구조를 다룬다고 생각하면 된다.

 

먼저, 아래 한 가지 상황을 가정해보자.

int[] Arr = { 1, 7, 3, 2, 8 };

위와 같은 배열에서 5 이상의 원소만 검출하고 싶다고 해보자.

가장 단순하게 우리가 생각할 수 있는 건 아래와 같은 방식일 것이다.

List<int> SelectedNum = new List<int>();

for(int Index = 0; Index < Arr.Length; Index++ )
{
    if (Arr[Index] >= 5)
    {
        SelectedNum.Add(Arr[Index]);
    }
}

for문을 통해 배열은 순회하면서 5 이상의 원소를 모두 걸러내는 것이다.

아주 간단하다.

 

그렇다면, 이번엔 아래 상황을 가정해보자.

struct Student
{
    public Student(string name, int age, int score)
    {
        _name = name;
        _age = age;
        _score = score;
    }

    public string _name; //이름
    public int _age; // 나이
    public int _score; // 성적
}

namespace Test
{
    public class MainClass
    {
        static int Main()
        {
            Student[] Arr = { new Student( "김민수", 13, 80 ), 
                              new Student("최종훈", 18, 90), 
                              new  Student("박상민", 14, 75)};

            return 0;
        }
    }
}

 

이름, 나이, 성적을 필드로 가진 Student 구조체가 있고, 위의 코드처럼 3명의 학생이 있다고 해보자.

여기서, 점수가 80점 이상인 학생의 나이만 검출해서 그 평균값을 구하고 싶다고 해보자.

 

예를 들어, 위의 세 학생중에서 점수가 80점 이상인 학생은 김민수, 최종훈이고 그 둘의 나이는 13살, 18살이다.

두 나이의 평균값은 15.5이므로, 구하고자 하는 값은 15.5가 될 것이다.

 

이를 구하는 가장 단순한 방법은 아래와 같을 것이다.

 Student[] Arr = { new Student( "김민수", 13, 80 ), new Student("최종훈", 18, 90), new  Student("박상민", 14, 75)};

 int AgeSum = 0;
 int NumSelected = 0;

 foreach (Student student in Arr)
 {
     if(student._score >= 80)
     {
         AgeSum += student._age;
         NumSelected++;
     }
 }

 float Answer = (float)AgeSum / NumSelected;

 return 0;

Arr을 순회하면서, 점수가 80점 이상인 친구들의 나이를 AgeSum 이라는 지역변수에 모두 더한 뒤, 점수가 80점 이상인 학생들의 수로 AgeSum을 나누어 평균을 구하는 것이다.

 

첫 번째 예시에 비해, 조건이 다양해지니 조금 복잡해 진 것을 알 수 있다.

 

만약 여기보다 조건이 더 많아진다고 해보자.

예를 들어, 성별, 나이, 재산, 이름, 거주지 등 여러 필드를 가진 구조체를 원소로 보유한 자료구조에서

13살 이상인 여성의 재산 평균을 구한다든가, 경기도에 사는 20세 미만 남성의 재산 총 합을 구한다든가 이런식으로 조건이 많아질수록 식은 점점 복잡해질 것이다.

 

그렇다면, LINQ를 사용하면 어떨까?

위의 두 번째 예시를 LINQ로 한 번 작성해보겠다.

Student[] Arr = { new Student( "김민수", 13, 80 ), new Student("최종훈", 18, 90), new  Student("박상민", 14, 75)};

var Selected = from student in Arr
               where student._score >= 80
               select student._age;

float Answer = (float)Selected.Sum() / Selected.Count();

return 0;

아주 간단해졌다. 사실 위의 예제도 크게 복잡한 것은 아니라서 체감이 크게 안될 수도 있지만, LINQ를 사용하면 데이터를 단순하고 쉽게 처리할 수 있게 된다.

 

중요한 것은 아래의 구문이다.

var Selected = from student in Arr
               where student._score >= 80
               select student._age;

from이란, 자료구조의 원소를 받을 멤버 변수를 선언하는 것이다.

where이란, 자료구조의 원소 중에서 어떤 조건을 만족하는 대상을 식별할 것인지를 명시하는 것이다.

select란, 명시된 대상 중 어떤 값을 검출할 것인지를 정하는 것이다.

 

더 자세히 알아보자.

from student in Arr

Arr의 원소를 student라는 대상에 넣어서 읽겠다는 의미이다.

foreach(Student A in Arr)
{
}

위의 foreach 문과 의미가 같다.

 

where student._score >= 80

이건, _score가 80이 이상인 대상만 식별하겠다는 의미이다.

foreach(Student A in Arr)
{
    if(A._score >= 80)
    {
    }
}

위의 foreach문 내부의 if문과 의미가 같다.

 

select student._age

이는, where문을 만족한 student 중에서 그 _age를 저장하겠다는 의미이다.

List<int> Selected = new List<Student>();

foreach(Student A in Arr)
{
    if(A._score >= 80)
    {
        Selected.Add(A._age);
    }
}

위의 foreach문 내부의 if문 내부에 있는 Add구문과 의미가 동일하다.

 

var Selected = from student in Arr
               where student._score >= 80
               select student._age;

즉, 위의 구문을 정리해보자면 Arr 내부에 있는 원소중 성적이 80 이상인 학생들의 나이를 Selected에 저장하겠다는 의미이다.

float Answer = (float)Selected.Sum() / Selected.Count();

이후, Selected의 Sum 메서드를 통해 멤버함수의 합을 구한 뒤, Selected의 원소 개수로 나누면 구하고자 했던 값을 구할 수 있게 된다.

 

이 외에도, LINQ의 쿼리문은 검출된 대상을 정렬할 수도 있다. 일반적으로 LINQ의 쿼리문은 첫 번째 원소부터 순회하며 데이터를 판별하기 때문에, Selected에는 조건을 만족하는 원소 중 앞에 있는 원소부터 순차적으로 저장될 것이다. 

 

var Selected = from student in Arr
               where student._score >= 80
               orderby student._name
               select student._age;

위의 구문을 보면 아까는 없던 orderby라는 쿼리문이 추가되었다.

이는 정렬 기준을 선택하는 것이다. 위의 구문에선 student._name을 orderby 뒤에 명시했기 때문에, 저장되는 _age는 _name이 사전순으로 앞에 오는 student의 _age가 먼저 저장되도록 정렬될 것이다. 

 

만약, 저장되는 나이를 그대로 오름차순으로 저장하고 싶다면, orderby 뒤에 student._age를 명시하면 된다.

또한, 내림차순으로 정렬하고 싶다면, orderby (정렬기준) 뒤에 descending 을 붙여주면 된다.

var Selected = from student in Arr
               where student._score >= 80
               orderby student._name descending
               select student._age;

위와 같이 descending을 붙이면 내림차순으로 정렬이 된다.

즉 위의 구문을 해석해보면 이와 같다.

 

Arr에 있는 원소중, _score이 80인 대상을 검출한 뒤, 검출된 대상들을 _name을 기준으로 내림차순 정렬하고, 정렬된 상태에서 _age를 순차적으로 검출하겠다는 것이다.

 

이는 매우 기본적인 LINQ 문법이며, 실제로는 이보다 더 다양한 문법이 제공된다.

쿼리문의 종류도 더 있고, 람다함수를 활용해 메서드로 질의하는 방법도 있다.

이는 다음 게시물에서 알아보도록 하자.

'C# > C#' 카테고리의 다른 글

C# - ref, out  (0) 2024.08.13
C# - const, readonly  (0) 2024.08.10
C# - is, as 연산자  (0) 2024.08.07
C# - 클래스, 접근 제한 지정자, 프로퍼티  (0) 2024.07.27
C# - C#의 자료형  (0) 2024.07.24

유니티를 공부하다보면 자연스럽게 접하게 되는 것이 Mono와 il2cpp이다.

둘에 대해 알아보도록 하자.

 

스크립팅 백엔드

스크립팅 백엔드란, 우리가 작성한 스크립트를 실행가능한 언어로 번역하는 것을 의미한다. 쉽게 말하면 컴파일러와 비슷한 의미이다. 다만, 컴파일러보다 조금 더 역할이 많은 넓은 범위라고 생각하면 될 것 같다. 유니티에서 우리가 개발하는 게임에 어떤 스크립팅 백엔드를 사용할 지 설정할 수 있으며 유니티에서 제공하는 두 선택지가 모노와 il2cpp인 것이다.

 

Mono

C#은 기본적으로 .Net Framework 위에서 작동한다. 닷넷 프레임워크는 Windows에서만 실행된다는 한계가 있다. 자마린 사는 이러한 문제점을 극복하고 닷넷 프레임워크 위에서 설계된 어플리케이션이 다양한 플랫폼에서 실행되기를 원했고, 닷넷 프레임워크를 기반으로 크로스 플랫폼을 지원하는 프레임워크를 만들고자 했다고 한다. 그리고 그 프로젝트의 이름이 Mono라고 한다.

 

.Net Framework는 소스가 공개되지 않았기 때문에 mono 프로젝트 초창기엔 자마린과 마이크로 소프트 사이에 여러가지 갈등이 있었다고 한다. 시간이 흐른 후, 마이크로 소프트의 운영 방침이 바뀌면서 .Net Framework의 오픈소스 버전을 새로 만들었고 이 오픈 소스를 기반으로 모노 프로젝트가 지금까지 진행되고 있다고 한다.

 

Unity에서 지원하는 Mono가 바로 이 Mono이다. 다만, 완전히 동일한 것은 아니다. 유니티에서 사용하는 Mono는 자마린의 Mono 프로젝트를 포크(원본을 완전히 복제하는 것)하여 게임 개발에 맞게 최적화 및 추가 기능을 더한 것이라고 한다.

 

Mono는 .Net Framework에 뿌리를 두고 있기 때문에 작동 방식이 매우 유사하다. IL이라는 중간 언어로 컴파일 해놓고 런타임에 기계어로 변환하며 프로그램을 실행한다.

 

il2cpp

Mono는 위에서 말했듯이 IL이라는 중간 언어로 컴파일 한 뒤, 런타임에 기계어로 번역하는 방식으로 프로그램을 실행한다. 하지만 이렇게 프로그램을 실행하게 되면 당연히 그 속도가 느릴 수 밖에 없다. 코드를 완전히 기계어로 번역해놓고 런타임에 추가적인 작업을 하지 않는 cpp에 비해서 말이다. 

 

il2cpp는 이러한 이유로 만들어진 것이다. 게임이라는 것은 최적화가 정말 무지무지 중요한 어플리케이션이다. 무지막지한 그래픽스 렌더링과 물리, 수학 연산을 하면서 1초에 60프레임을 꾸준히 유지해줘야 하니 말이다. 이를 위해 unity에선 스크립팅 백엔드의 추가적인 선택지로 il2cpp를 만들었다. 

 

il2cpp는 우리가 작성한 스크립트를 IL로 컴파일하고 IL을 다시 CPP 코드로 번역한다. 그리고 이 CPP 코드를 다시 컴파일하여 완전히 기계어로 번역된 프로그램을 생성하고 이를 통해 게임을 실행하게 된다. 

 

mono vs il2cpp

위에서 설명한 내용을 이해했다면 두 스크립팅 백엔드의 장단점은 정말 명확하게 알 수 있을 것이다.

 

mono는 편한 대신 성능이 느리다. 컴파일 시간이 상대적으로 매우 짧기 때문에 유연하게 코드를 변경하며 개발을 실행할 수 있다. 반면 런타임 성능이 상대적으로 낮기 때문에 무거운 게임을 만들게 된다면 최적화 부분에서 신경을 정말 많이 써야할 것이다.

 

반면 il2cpp는 컴파일 시간이 상대적으로 매우 긴만큼 개발에 있어 불편함이 따를 것이다. 하지만 그만큼 런타임 성능이 우수하기 때문에 무거운 게임을 만들수록 il2cpp의 이점을 크게 느낄 수 있을 것이다.

'유니티 > 엔진 기초' 카테고리의 다른 글

유니티 기초 - 모노 비헤이비어 (MonoBehaviour)  (0) 2024.08.07
스터디를 진행하며 실시한 모의 면접을 정리한 내용입니다.
겹치는 내용을 고려하지 않고 면접을 실시하기 때문에 일자 별로 겹치는 내용이 많을 수 있습니다.

1. (C++) void*에 대해 설명하라.

더보기

C++에서 자료형이란, 메모리 영역에 저장된 바이너리 데이터를 어떻게 해석하는 지를 규정하는 것입니다. 그렇기 떄문에 일반적인 데이터는 자료형이 반드시 정해져 있어야 하지만, 해당 데이터를 직접적으로 사용하지 않고 저장된 바이너리 데이터 혹은 저장된 주소값을 참조하는 것만 필요한 경우엔 void*를 활용할 수 있습니다.

 

예를 들어 쉐이더에 상수버퍼 데이터를 전달하는 경우, 상수버퍼의 자료형은 GPU 내에서 결정되기 때문에 CPU에선 자료형과 무관한 바이너리 데이터만 전달하면 됩니다. 이러한 특징으로 인해 GPU에 전달할 데이터를 void* 타입으로 캐스팅하여 관리한다면 편하고 일관성있는 데이터 관리 구조를 구축할 수 있게 됩니다.

2. 동시성과 병렬성이란 무엇인가?

더보기

여러 작업이 물리적으로 동시에 처리되는 것을 병렬성이라고 합니다. 멀티코어 CPU의 경우 각 코어당 하나의 작업을 처리할 수 있기 때문에 물리적으로 여러 작업이 동시에 실행될 수 있습니다.

 

반면, 싱글코어의 경우 동시에 하나의 작업만 수행할 수 있기 때문에 여러 작업이 물리적으로 동시에 실행될 수는 없습니다. 이러한 한계를 극복하고 여러 프로세스가 동시에 실행되는 듯한 경험을 사용자에게 제공하기 위해 운영체제는 CPU 스케줄링 기법을 활용하여 여러 프로세스를 초고속으로 번갈아 실행하게 됩니다. 이처럼 실제로는 동시에 실행되고 있지 않지만 동시에 실행되는 것처럼 보이는 것을 동시성이라고 합니다.

3. (C++) 빌드과정에 대해 설명하라.

더보기

가장 먼저 전처리기 과정이 수행됩니다. 전처리기 단계에선 프로그램 실행에 불필요한 주석, 공백 등을 제거하고 매크로에 의해 정의된 변수, 함수 등을 모두 실제 값으로 치환하는 과정을 거칩니다.

또한, include된 헤더파일의 내용을 모두 cpp파일의 상단에 삽입하여 파일을 통합하는 과정을 거칩니다. 

 

이 과정이 끝난 뒤에는 컴파일 단계를 수행하게 됩니다. 컴파일 단계에선 C++로 작성된 코드를 저수준의 어셈블리어로 변환하는 과정을 거칩니다. 코드를 변환하는 과정에서 문법상의 오류를 검출하는 과정을 동시에 거치게 됩니다. 프로그램이 정상적으로 실행될 수 없을 정도의 오류를 발견한다면 그 즉시 빌드를 중단하게 됩니다.

 

컴파일 단계가 끝난 뒤엔 어셈블리 단계를 거치게 됩니다. 이 단계에선 어셈블리어로 변환된 코드를 기계어로 변환하는 과정을 거치게 됩니다. 코드를 기계어로 변환한 뒤엔 object 파일을 생성하게 됩니다.

 

어셈블리 단계를 마친 뒤엔 링킹 과정을 거치게 됩니다. 이 단계에선 여러 개의 오브젝트 파일과 정적 라이브러리를 결합하여 실행할 수 있는 하나의 exe 파일을 생성하게 됩니다.

 

이 4개의 과정이 모두 끝나면 빌드가 종료되며 실행할 수 있는 프로그램이 생성됩니다.

4. 동기와 비동기에 대해 설명하라.

더보기

동기란 이전의 작업이 종료되어야 다음 작업이 수행될 수 있는 것을 의미합니다. 반면, 비동기는 이전 작업의 종료 여부와 관계 없이 다음 작업이 수행될 수 있는 경우를 의미합니다.

 

예를 들어, 단일 스레드 환경에선 이전의 작업이 완료되어야 다음 작업이 수행될 수 있기 때문에 동기적이라고 할 수 있지만 멀티 스레드 환경에서 메인 스레드의 작업과 독립적으로 수행되고 있는 작업이 있다면 이를 비동기적이라고 할 수 있습니다.

5. (C++) \n과 std::endl의 차이에 대해 설명하라.

더보기

\n은 단순히 입력 버퍼에 \n이라는 줄바꿈 문자를 추가하는 것입니다. 문자가 출력될 때, \n을 기점으로 줄바꿈을 수행하여 출력하게 됩니다.

 

std::endl은 기존의 메세지를 모두 출력하고 줄바꿈을 수행한 뒤 입력 버퍼를 비우는 작업도 함께 실행하게 됩니다.

6. (C++) 리터럴 문자열을 char [] 에 저장할 때와 char* 에 저장할 때 차이는?

더보기

char [] 과 같은 배열 형태로 리터럴 문자열을 저장하게 되면, 스택영역에 존재하는 배열 내부에 바이트 단위로 문자열을 나누어 저장하게 됩니다. 

 

반면, char*로 저장하게 되면 리터럴 문자열은 데이터 영역에 저장되고 char*는 리터럴 문자열이 저장된 메모리 주소값을 저장하게 됩니다.

 

데이터 영역에 저장된 문자열을 여러 곳에서 재활용될 수 있는 문자열이기 때문에 함부로 수정하게 되면 위험한 상황이 발생할 수 있습니다. C++은 이러한 위험성을 방지하기 위해 char* 타입으로 리터럴 문자열을 저장하는 것을 허용하지 않고 있으며 반드시 const char* 타입으로 저장하도록 강제하고 있습니다.

7. (C#) 코루틴이란?

더보기

일반적인 함수는 return 문을 수행해야 지역을 탈출하지만, 코루틴 함수는 중간에 함수 실행을 중단한 뒤 나중에 재개하여 사용할 수 있습니다. 입력 대기 시간 등에 코루틴 함수를 활용하여 다른 작업을 수행하도록 설계한다면 프로세스의 성능을 높일 수 있습니다.

8. (C++) 배열과 포인터의 차이에 대해 설명하라.

더보기

포인터란, 특정 메모리 영역의 주소를 저장하는 변수입니다. 저장된 주소값을 통해 메모리 영역에 간접적으로 접근할 수 있게 됩니다. 이를 활용하면 불필요한 데이터의 복사를 방지할 수 있으며, 변수가 선언된 지역 외부에서도 접근할 수 있게 됩니다. 

 

배열은 일렬로 나열된 데이터의 가장 앞 부분을 가리키는 일종의 포인터 변수입니다. 하지만, 가리키는 주소값이 변경될 경우 배열의 원소를 더이상 참조할 수 없기 때문에 가리키는 대상을 변경할 수 없다는 제약이 존재합니다.

9. (C++) SSO란?

더보기

SSO란 std::string의 동적할당으로 인한 오버헤드를 조금이라도 줄이기 위해, 길이가 짧은 문자열은 힙영역이 아닌 스택영역에 저장하도록 하는 최적화 기법입니다.

10. (C++) union이란?

더보기

하나의 데이터를 다양항 방식으로 활용하기 위해 사용하는 문법입니다. union을 활용하면 동일한 4바이트의 데이터라고 하더라도 4개의 char 타입의 원소로 이루어진 배열로 사용할 수도 있고, int 타입 변수로도 사용할 수도 있게 됩니다.

11. (C++) Lvalue와 Rvalue에 대해 설명하라.

더보기

L-Value란 이름이 존재하며, 지역을 벗어나거나 메모리를 해제하기 전까지 소멸되지 않는 값입니다. 반면, R-Value는 이름이 존재하지 않으며 표현식이 끝난 이후 사라지는 임시의 값입니다.

12. (C#) Nullable이란?

더보기

null을 사용할 수 없는 int, char 등의 자료형에 null을 사용할 수 있도록 도와주는 객체입니다. 

13. (C++) operator new 란?

더보기

C++의 동적할당 함수인 new의 경우 메모리를 할당하고 생성자를 호출한 뒤 메모리 주소를 타입캐스팅하여 반환하는 3개의 과정으로 실행됩니다. operator new는 이 중에서 메모리를 할당하는 역할을 담당하는 함수입니다. C언어의 malloc처럼 크기에 맞는 메모리를 할당하여 이를 반환해줍니다. operator new는 의도적으로 재정의하는 것도 가능하다는 특징이 있습니다.

14. 람다 함수란?

더보기

람다 함수란, 이름이 없는 함수를 의미합니다. 일반적인 함수는 미리 선언, 정의를 해놓은 뒤 이를 재활용하는 방식으로 사용됩니다. 반면, 람다 함수는 필요할 때 즉석에서 정의하여 사용할 수 있으며, 함수의 사용이 끝나면 메모리에서 소멸하게 됩니다. 

15. 메시란?

더보기

오브젝트가 화면에 그려지는 위치를 결정짓기 위한 데이터 덩어리입니다. 여러 개의 버텍스로 구성되어 있으며 각 버텍스는 위치,  법선, 색상 등의 정보를 포함할 수 있습니다.

16. is-A 와 has-A에 대해 설명하라.

더보기

두 가지 모두 객체간의 관계를 표현하는 방식입니다. is-A란 대입 연산이 가능한 경우를 의미합니다. 상속 관계에 있는 두 객체에서 하위 객체는 상위 객체에 대입 연산이 가능하기 때문에 is-A 관계라고 할 수 있습니다.

 

반면, has-A 관계는 한 객체가 다른 객체를 소유하고 있는 경우를 의미합니다. 컴포넌트 관계처럼 한 객체가 다른 객체를 멤버 변수로 소유하여 그 기능을 활용할 수 있는 경우 has-A 관계라고 합니다.

+ Recent posts