모든 std::thread 객체는 합류 가능 상태에거나 합류 불가능 상태이다.

 

◾ 기본 생성된 std::thread

◾ 다른 std::thread 객체로 이동된 후의 std::thread 객체

◾ join에 의해 합류된 std::thread

◾ detach에 의해 탈착된 std::thread

 

위 4가지는 합류 불가능한 std::thread 객체이다.

std::thread의 합류 가능성이 중요한 이유 중 하나는, 합류 가능한 스레드의 소멸자가 호출되면 프로그램 실행이 종료되기 때문이다.

 

예를 들어 필터링 함수 filter와 최댓값 maxVal을 매개변수로 받는 함수 doWork에서 필터링 수행과 조건들의 만족 여부를 점검하는 데에 시간이 오래 걸린다면 두 작업을 동시에 실행하는 것이 합리적이다.

작업 기반 설계를 적용하는 것이 좋지만 필터링을 수행하는 스레드의 우선순위를 설정해야 하기 때문에 스레드의 네이티브 핸들을 받기 위해 어쩔 수 없이 스레드 기반 설계를 해야 한다.

 

constexpr auto tenMillion = 10000000;

bool doWork(std::function<bool(int)> filter, int maxVal = tenMillion) {
    std::vector<int> goodVals; // 필터를 통과한 값들
    
    std::thread t([&filter, maxVal, &goodVals]() {
                      for (auto i = 0; i <= maxVal; ++i) {
                          if (filter(i)) goodVals.push_back(i); }
                  });
    auto nh = t.native_handle(); // 네이티브 핸들을 이용해서 t의 우선순위 설정
    ...
    
    if (conditionAreSatisfied()) {
        t.join();
        performComputation(goodVals);
        return true; // 계산 수행 완료
    }
    
    return false; // 계산 미수행
}

일단 t를 실행시킨 뒤에 우선순위를 설정하는 것은 소 잃고 외양간 고치는 격이기 때문에 순서가 맞지 않다. 이 부분은 넘어가더라도 conditionAreSatisfied 함수가 true를 반환한다면 별 문제가 없지만 false를 반환하거나 예외를 던지면 t의 소멸자가 호출되는데, t는 여전히 합류 가능 상태이므로 프로그램 실행이 종료된다.

 

대체 왜 std::thread의 소멸자가 프로그램을 종료시키는가 하면 암묵적 join이나 암묵적 detach는 상황이 더 나쁘게 흘러가기 때문이다.

 

◾ 암묵적 join

std::thread의 소멸자가 바탕 비동기 실행 스레드의 완료를 기다리게 한다.

추적하기 어려운 성능 이상들이 나타날 수 있다.

 

◾ 암묵적 detach

std::thread의 소멸자가 std::thread 객체와 바탕 실행 스레드 사이의 연결을 끊는다.

암묵적 join보다 더 심각한 이슈가 발생할 수 있다.

 

예를 들어 goodVals는 지역 변수인데 doWork 함수가 종료되면 호출 스택이 pop 되어 프로그램 실행의 흐름이 doWork의 호출 지점 다음으로 이동되지만 t는 doWork의 호출 지점에서 계속 실행된다.

다음 함수 f가 실행되는 도중 t가 비동기적으로 실행되며 goodVals에 대해 push_back을 호출하게 되는데 goodVals가 차지하던 메모리는 이미 f의 스택 프레임에 존재하게 된다.

t가 push_back을 호출하면 f 입장에서는 자기가 소유중인 스택 프레임의 메모리가 갑자기 변해버리는 기현상을 겪게 되는 셈이다.

 

합류 가능 스레드를 파괴했을 때의 결과가 매우 절망적이기 때문에 파괴를 아예 금지하기로 정했고 그에 따라 합류 가능 스레드를 파괴하면 프로그램이 종료되도록 한 것이다.

 

따라서 std::thread 객체를 모든 경로에서 합류 불가능으로 만들어야 한다. 범위 바깥으로 나가는 모든 경로에서 어떤 동작이 반드시 수행되어야 한다고 할 때, 그 동작을 지역 객체의 소멸자 안에 넣는 것이 일반적이고 이를 RAII(Resource Acquisition Is Initilization) 객체라고 부른다. 핵심은 초기화가 아닌 파괴에 있다.

 

표준 컨테이너나 스마트 포인터는 RAII 클래스이지만 std::thread 객체에 대한 표준 RAII 클래스는 없다.

다만 작성하는 것이 어렵지는 않다.

 

class ThreadRAII {
public:
    enum class DtorAction { join, detach };
    
    ThreadRAII(std::thread&& t, DtorAction a) : action(a), t(std::move(t)) {}
    
    ~ThreadRAII {
        if (t.joinable()) {
            if (action == DtorAction::join) {
                t.join();
            }
            else {
                t.detach();
            }
        }
    }
    
    std::thread& get() { return t; }
    
private:
    DtorAction action;
    std::thread t;
};

생성자는 std::thread의 오른값만 받고 std::thread 객체는 마지막으로 선언하는 것이 좋다. 그래야 모든 멤버가 성공적으로 초기화 되고 안전하게 접근할 수 있다.

 

또한 소멸자에서 t에 대해 멤버 함수를 호출하기 전에 합류 가능인지 점검한다.

 

if (t.joinable()) {
    if (action == DtorAction::join) { // 혹시 이 사이에 race condition이 발생하면?
        t.join();
    }
    else {
        t.detach();
    }
}

joinable과 join 또는 detach의 호출 사이에 다른 스레드가 t를 합류 불가능하게 만들면 경쟁 조건이 발생하지 않을까 걱정될수도 있는데 다행히도 합류 가능한 std::thread 객체는 오직 멤버 함수 호출(join, detach) 또는 이동 연산에 의해서만 합류 불가능한 상태로 변경될 수 있다.

 

bool doWork(std::function<bool(int)> filter, int maxVal = tenMillion) {
    std::vector<int> goodVals;
    
    ThreadRAII t([&filter, maxVal, &goodVals]() {
                      for (auto i = 0; i <= maxVal; ++i) {
                          if (filter(i)) goodVals.push_back(i); }
                  }),
                  ThreadRAII::DtorAction::join);
    auto nh = t.get().native_handle();
    ...
    
    if (conditionAreSatisfied()) {
        t.get().join();
        performComputation(goodVals);
        return true;
    }
    
    return false;
}

join이 디버깅하기 어려운 성능 이상을 유발할 수 있다고 했지만 다른 두 선택지가 미정의 행동과 프로그램 종료이기 때문에 join이 상대적으로 나은 선택지가 되어버린다.

그런데 나중에 다루겠지만 성능 이상뿐만 아니라 프로그램이 멈추는 문제까지 발생할 수 있는데 이런 종류의 문제에 대해 제대로 된 해결책은 비동기적으로 실행중인 람다에게 일찍 반환하라고 알려주는 것이다.(interruptible thread)

 

ThreadRAII는 소멸자를 선언했기 때문에 이동 연산이 작성되지 않으므로 직접 작성해주어야 한다. 다만 자동으로 작성되는 이동 연산으로도 충분하기 때문에 default로 요청하면 된다.

 

◾ 모든 경로에서 std::thread를 합류 불가능으로 만들어라.

◾ 소멸 시 join 방식은 디버깅하기 어려운 성능 이상으로 이어질 수 있다.

◾ 소멸 시 detach 방식은 디버깅하기 어려운 미정의 행동으로 이어질 수 있다.

◾ 멤버 변수 목록에서 std::thread 객체를 마지막에 선언하라.

일반적으로 std::async를 호출해서 어떤 함수나 함수 객체를 실행한다는 것은 그 함수를 비동기적으로 실행하겠다는 의도가 깔려 있다.

하지만 항상 그런 의미일 필요는 없고 std::async 호출은 함수를 어떤 시동 방침에 따라 실행한다는 좀 더 일반적인 의미를 가진다.

 

◾ std::launch::async

넘겨진 함수(f)는 반드시 비동기적(다른 스레드)으로 실행된다.

 

◾ std::launch::deferred

넘겨진 함수(f)는 std::async가 돌려준 future 객체에 대해 get이나 wait가 호출될 때에만 실행될 수 있다.

get이나 wait가 호출될 때까지 지연되며, 호출 시 f는 동기적으로 실행된다.

즉, 호출자는 f가 실행 종료될 때까지 차단되며 get이나 wait가 호출되지 않으면 f는 절대 실행되지 않는다.

 

std::async의 기본 시동 방침은 두 방침을 OR 연산자로 결합한 것이다.

 

auto fut1 = std::async(f);

auto fut2 = std::async(std::launch::async | std::launch::deferred, f);

위 둘은 완전히 같은 의미이다. 두 방침을 모두 가지고 있기 때문에 유연성을 가진다.

그런데 그 유연성 때문에 기본 방침으로 f를 실행하면 실행 시점의 예측이 불가능해진다.

 

auto fut = std::async(f);

예를 들어 위의 문장이 스레드 t에서 실행된다고 했을 때,

 

◾ f는 지연 실행될 수도 있으므로, f가 t와 동시에 실행될지 예측하는 것이 불가능하다.

f가 fut에 대해 비동기적으로 실행될지 예측하는 것이 불가능하다.

◾ fut에 대한 get이나 wait 호출이 일어난다는 보장이 없을 수도 있으므로, f가 반드시 실행될 것인지 예측하는 것이 불가능하다.

 

이런 유연성은 만료 시한이 있는 wait 기반 루프에도 영향을 미친다. 지연된 작업에 대해 wait_for나 wait_until을 호출하면 std::future_status::deferred라는 값이 반환되기 때문이다.

 

using namespace std::literals;

void f() {
    std::this_thread::sleep_for(1s);
}

auto fut = std::async(f);

while(fut.wait_for(100ms) != std::future_status::ready) {
    ...
}

f가 지연된다면 fut.wait_for는 항상 std::future_status::deferred를 반환하고 두 값은 절대 같지 않기 때문에 루프를 영원히 돌게된다. 심지어 이런 버그는 부하가 아주 많이 걸리지 않는 이상 드러나지 않아서 발견하기도 어려울 수 있다.

 

생각보다 이런 문제의 해결책은 간단한데, std::async 호출이 돌려준 future 객체를 이용해서 해당 작업이 지연되었는지 점검하고 만약 지연되었다면 시간 만료 기반 루프에 진입시키지 않으면 된다.

실제로 작업 지연여부를 직접 알아내는 방법은 없지만 wait_for 같은 시간 만료 기반 함수를 호출해서 반환값이 std::future_status::deferred인지만 확인하면 된다.

 

auto fut = std::async(f);

if (fut.wait_for(0s) == std::future_status::deferred) {
    // fut에 wait나 get을 적용해서 f를 동기적으로 호출한다
}
else {
    while (fut.wait_for(100ms) != std::future_status::ready) {
        ...
    }
}

 

어떤 작업에 대해 기본 시동 방침과 함께 std::async를 사용하는 것은 다음 조건들이 모두 성립할 때에만 적합하다.

 

◾ 작업이 get이나 wait를 호출하는 스레드와 반드시 동시적으로 실행되어야 하는 것은 아니다.

◾ 여러 스레드 중 어떤 스레드의 thread_local 변수들을 읽고 쓰는지가 중요하지 않다.

◾ std::async가 돌려준 future 객체에 대해 get이나 wait가 반드시 호출된다는 보장이 있거나, 작업이 전혀 실행되지 않아도 괜찮다.

◾ 작업이 지연된 상태일 수도 있다는 점이 wait_for나 wait_until을 사용하는 코드에 반영되어 있다.

 

위 조건중 하나라도 충족하지 않는다면 비동기적으로 실행하도록 강제할 필요가 있다.

 

auto fut = std::async(std::launch::async, f);

그런데 매번 시동 방침을 지정하는 것은 번거롭다.

 

template<typename F, typename... Ts> // C++11
inline std::future<typename std::result_of<F(Ts...)>::type> reallyAsync(F&& f, Ts&&... params) {
    return std::async(std::launch::async, std::forward<F>(f), std::forward<Ts>(params)...);
}

template<typename F, typename... Ts> // C++14
inline auto reallyAsync(F&& f, Ts&&... params) {
    return std::async(std::launch::async, std::forward<F>(f), std::forward<Ts>(params)...);
}

간단하게 함수 템플릿을 만들어서 사용하면 편하게 사용할 수 있다.

 

◾ std::async의 기본 시동 방침은 작업의 비동기적 실행과 동기적 실행을 모두 허용한다.

◾ 그러나 이러한 유연성 때문에 thread_local 접근의 불확실성이 발생하고, 작업이 절대로 실행되지 않을 수도 있고, 시간 만료 기반 wait호출에 대한 프로그램 논리에도 영향이 미친다.

◾ 작업을 반드시 비동기적으로 실행해야 한다면 std::launch::async를 지정하라.

함수를 비동기적으로 실행하는 방법은 크게 두가지로 나뉜다.

 

int doAsyncWork();

std::thread t(doAsyncWork); // 스레드(thread) 기반 프로그래밍

auto fut = std::async(doAsyncWork); // 작업(task) 기반 프로그래밍

스레드 객체에 함수를 넘겨서 실행하는 스레드 기반 프로그래밍과 std::async에 함수 객체를 넘기는 방법이다.

대체로 작업 기반 접근방식이 스레드 기반 접근방식보다 우월하다.

 

일단 스레드 기반 프로그래밍은 넘겨준 함수의 반환값을 돌려받을 수 없지만 작업 기반 접근방식은 반환값을 돌려받을 수 있다. 작업 기반 접근방식이 반환값을 돌려받을 수 있는 이유는 std::async가 돌려주는 future객체에 get이라는 함수가 존재하기 때문에 가능하다. 게다가 넘겨준 함수가 예외를 방출한다면 더욱 더 중요하다. get을 통해 예외에도 접근할 수 있기 때문이다.

반면에 스레드 기반 프로그래밍에서는 넘겨준 함수가 예외를 던지면 std::terminate 호출을 통해 프로그램이 죽게된다.

 

또한 스레드 기반 프로그래밍과 작업 기반 프로그래밍의 좀 더 근본적인 차이는 작업 기반 프로그래밍의 더 높은 추상화에 있다.

 

일단 스레드라는 용어의 의미들을 짚고 넘어가보자.

 

◾ 실제 계산을 수행하는 스레드를 뜻하는 하드웨어 스레드

◾ OS가 하드웨어 스레드들에서 실행되는 모든 프로세서와 일정을 관리하는 데 사용하는 소프트웨어 스레드

◾ C++ 표준 라이브러리의 std::thread

 

소프트웨어 스레드는 한정적인 자원이기 때문에 시스템이 제공할 수 있는 스레드보다 더 많은 스레드를 생성하려고 하면 std::system_error 예외가 발생한다. 설령 함수가 noexcept라고 해도 예외가 발생한다.

 

소프트웨어 스레드가 부족한 상황을 처리하는데에도 std::async가 유용하게 쓰일 수 있다.

스레드 기반 프로그래밍으로 처리하려면 연쇄적인 문제가 계속 발생하기 때문에 쉽지 않다.

하지만 이런 여러가지 문제들을 std::async에 떠넘기면 매우 편리해진다.

 

auto fut = std::async(doAsyncWork);

스레드 관리의 책임을 C++ 표준 라이브러리 구현자로 떠넘겨버린다.

이러면 가용 스레드 부족에 의해 예외를 받을 가능성이 크게 줄어든다.

std::thread나 std::async나 둘 다 스레드를 요청하는데 무슨 차이가 있길래 스레드 부족 예외를 받을 가능성이 크게 줄어들까?

 

그 이유는 std::async가 상황에 따라 스레드를 생성하지 않을 수도 있기 때문이다. 새로운 스레드를 생성하는 대신 get이나 wait을 호출하는 스레드에서 실행하라고 스케줄러에게 요청할 수 있다. 이 때 스케줄러는 상황에 맞춰 선택하게 된다.

 

다만 위와 같은 함수의 결과가 필요한 스레드에서 실행하는 기법은 부하 불균형 문제가 여전히 발생할 수 있다. 아예 사라지는 것은 아니고 대체로 스케줄러가 전반적인 상황을 더 잘 알고있을 가능성이 높을 뿐이다.

또한 실행중인 스레드 중 반응성이 좋아야 하는 스레드가 어떤 것인지도 알 수 없기 때문에 std::launch::async라는 시동 방침(launch policy)을 std::async에 넘겨주는 것이 좋다.

 

 

스레드 기반 프로그래밍에 비해 작업 기반 프로그래밍은 스레드를 일일이 관리해야 하는 수고로움이 없다. 또한 비동기적으로 실행된 함수의 결과를 자연스럽게 조회할 수 있는 수단 역시 존재한다.

다만 스레드를 직접 다루는 것이 적합한 경우도 존재한다.

 

◾ 바탕 스레드 적용 라이브러리의 API에 접근해야 하는 경우

◾ 응용 프로그램의 스레드 사용량을 최적화해야 하는, 그리고 할 수 있어야 하는 경우

◾ C++ 동시성 API가 제공하는 것 이상의 스레드 적용 기술을 구현해야 하는 경우

 

위의 경우들은 흔치 않은 경우라서 대부분은 작업 기반 설계를 사용하는 것이 바람직하다.

 

◾ std::thread API에서는 비동기적으로 실행된 함수의 반환값을 직접 얻을 수 없으며, 만일 그런 함수가 예외를 던지면 프로그램이 종료된다.

◾ 스레드 기반 프로그래밍에서는 스레드 고갈, 과다구독(oversubscription), 부하 균형화, 새 플랫폼으로의 적응을 독자가 직접 처리해야 한다.

◾ std::async와 기본 시동 방침을 이용한 작업 기반 프로그래밍은 그런 대부분의 문제를 알아서 처리해준다.

C++11에서는 람다가 거의 항상 std::bind보다 나은 선택이고 C++14에서는 거의 항상 수준이 아니라 무조건 좋은 선택이다. 게다가 std::bind보다 람다가 가독성이 더 좋다.

그 이유를 알아보도록 하자.

 

using Time = std::chrono::steady_clock::time_point;
enum class Sound { Beep, Siren, Whistle };
using Duration = std::chrono::steady_clock::duration;

void setAlarm(Time t, Sound s, Duration d); // t시간 뒤에 소리 s를 d만큼 출력

일정 시간 뒤에 알람이 울리는 함수가 있다.

 

auto setSoundL = [](Sound s) {
    using namespace std::chrono;
    setAlarm(steady_clock::now() + hours(1), s, seconds(30));
};

1시간 뒤에 30초간 울리는 알람을 실행하지만 경보음은 미리 지정되지 않아서 setAlarm에 대해 소리만 전달해주는 인터페이스를 람다로 작성하면 위와 같다.

 

auto setSoundL = [](Sound s) {
    using namespace std::chrono;
    using namespace std::literals;
    
    setAlarm(steady_clock::now() + 1h, s, 30s);
};

덧붙여서 C++14는 C++11의 사용자 정의 리터럴 기능에 기초하는 표준 접미사들을 이용하여 코드를 더 간결하게 만들 수 있다.

 

using namespace std::chrono;
using namespace std::literals;
using namespace std::placeholders;

auto setSoundB = std::bind(setAlarm, steady_clock::now() + 1h, _1, 30s);

바인드로 작성하면 위와 같은 코드가 된다. 그런데 잘 작동하지 않는다.

람다식에 있는 setAlarm의 평가식(steady_clock::now() + 1h)은 setAlarm이 호출되는 시점에 평가되기 때문에 setAlarm 호출 1시간 뒤 알람이 울리지만 std::bind는 바인드 객체가 생성될 때 평가되어서 setAlarm의 호출이 아닌 std::bind를 호출하는 시점을 기준으로 1시간 뒤 알람이 울린다.

 

이 문제를 바로잡으려면 바인드 객체가 생성되는 시점이 아니라 실제 setAlarm을 호출하는 시점까지 평가를 지연시켜야 한다. 그러기 위해서는 std::bind안에 두 개의 함수 호출을 포함시켜야 한다.

 

using namespace std::chrono;
using namespace std::literals;
using namespace std::placeholders;

auto setSoundB = std::bind(setAlarm,
                            std::bind(std::plus<steady_clock::time_point>(), // C++14, std::plus<>()
                                      std::bind(steady_clock::now), hours(1)),
                            _1,
                            seconds(30));

현재 시간과 1시간을 더하는 바인드 객체를 매개변수로 넘겨주면 setAlarm이 호출되는 시점으로 지연평가된다.

어찌저찌 해결된것처럼 보이지만 setAlarm 함수를 오버로딩 하게되면 또 문제가 생긴다.

 

람다는 전혀 문제 없이 컴파일러가 어떤 함수를 호출할지 선택할 수 있지만 std::bind는 함수의 이름만 넘겨받기 때문에 어떤 버전을 호출해야 하는지 선택하지 못해서 컴파일 에러가 발생한다.

 

using SetAlarm3ParamType = void(*)(Time t, Sound s, Duration d);

auto setSoundB = 
    std::bind(static_cast<SetAlarm3ParmType>(setAlarm),
              std::bind(std::plus<>(),
                        std::bind(steady_clock::now),
                        1h),
              _1,
              30s);

이런 경우 적절한 함수 포인터로 캐스팅하여 전달해주면 중의성이 해소되어 정상적으로 컴파일 된다.

 

그런데 이제는 성능상의 차이가 생기기 시작한다.

 

setSoundL(Sound::Siren);
setSoundB(Sound::Siren);

람다 안의 setAlarm은 인라인화될 가능성이 크지만 바인드 객체에서 setAlarm의 호출은 setAlarm의 함수 포인터를 통해 이루어지므로 인라인화될 가능성이 낮다.

 

이번에 다룬 상황에서는 setAlarm 호출 하나만 이루어지지만 좀 더 복잡한 코드가 되면 람다의 장점이 더욱 두드러진다.

 

// 람다
auto betweenL = [lowVal, highVal](const auto& val){ return lowval <= val && cal <= highVal; };

// std::bind
using namespace placeholders;

auto betweenB = 
    std::bind(std::logical_and<>(),
              std::bind(std::less_equal<>(), lowVal, _1),
              std::bind(std::less_equal<>(), _1, highVal));

만약 C++11이라면 템플릿 매개변수가 죄다 지정되어야 해서 코드가 더 복잡해진다. 람다 역시 auto 매개변수를 받지 못하기 때문에 타입 지정이 필요하지만 애초에 코드 길이가 차이가 난다.

 

여담으로 std::bind에 전달되는 placeholders가 아닌 매개변수들은 값으로 저장될까 아니면 참조로 저장될까?

답은 "값으로 전달된다" 이다. 이건 std::bind의 작동 방식을 알고 있어야만 알 수 있는 정보이다. std::bind의 호출 구문으로는 알아낼 수 없다.

하지만 람다는 캡쳐 구문에 명시되어 있기 때문에 한눈에 알 수 있다.

 

C++14에 들어서는 람다가 std::bind보다 무조건 좋고 C++11의 경우라도 대부분 람다가 좋지만 이동 캡쳐나 보편 참조가 적용된 함수의 경우에는 std::bind의 사용이 정당해진다.

 

◾ std::bind를 사용하는 것보다 람다가 더 읽기 쉽고 표현력이 좋다. 그리고 더 효율적일 수 있다.

◾ C++14가 아닌 C++11에서는 이동 캡쳐를 구현하거나 객체를 템플릿화된 함수 호출 연산자에 묶으려 할 때 std::bind가 유용할 수 있다.

auto f = [](auto x){ return normalize(x); };

C++14부터는 초기화 캡쳐뿐만 아니라 매개변수로 auto를 사용하는 일반화 람다도 지원한다.

 

class 컴파일러가_만든_어떤_클래스_이름 {
public:
    template<typename T>
    auto operator()(T x) const { return normalize(x); }
};

컴파일러에 의해 만들어진 클로저 클래스의 operator()는 위와 같은 모습일 것이다.

만약 normalize가 왼값과 오른값을 다른 방식으로 처리한다면 이 람다는 항상 왼값을 전달하기 때문에 제대로 작성되지 않은 것이다.

제대로 전달하려면 완벽 전달을 사용해야 한다.

 

auto f = [](auto&& x){ return normalize(std::forward<???>(x)); };

5장에서 완벽 전달을 다룰때는 함수 템플릿 안에서 다룬거라 forward<T>를 사용했는데 람다는 어떻게 해야할까?

정답은 decltype이다. 왼값이 전달되었다면 decltype(x)는 왼값 참조가 산출되고 오른값이 전달되었다면 decltype(x)는 오른값 참조가 산출된다.

 

auto f = [](auto&& x){ return normalize(std::forward<decltype(x)>(x)); };

auto f = [](auto&&... xs ){ return normalize(std::forward<decltype(xs)>(xs)...); };

C++14의 람다는 가변 인수를 지원하기 때문에 임의 개수의 매개변수들도 완벽 전달이 가능해진다.

 

◾ std::forward를 통해서 전달할 auto&& 매개변수에는 decltype을 사용하라.

+ Recent posts