모두의 코드
씹어먹는 C ++ - <13 - 2. 자원을 공유할 때 - shared_ptr 와 weak_ptr>

작성일 : 2018-12-21

이번 강좌에서는

에 대해 다룹니다.

안녕하세요 여러분! 지난 강좌에서는 객체를 유일하게 소유하는 스마트 포인터인 unique_ptr 에 대해서 다루어 보았습니다. 대부분의 경우 하나의 자원은 한 개의 스마트 포인터에 의해 소유되는 것이 바람직 하고, 나머지 접근은 (소유가 아닌) 그냥 일반 포인터로 처리하면 됩니다.

하지만, 때에 따라서는 여러 개의 스마트 포인터가 하나의 객체를 같이 소유 해야 하는 경우가 발생합니다. 예를 들어서 여러 객체에서 하나의 자원을 사용하고자 합니다. 후에 자원을 해제하기 위해서는 이 자원을 사용하는 모든 객체들이 소멸되야 하는데, 어떤 객체가 먼저 소멸되는지 알 수 없기 때문에 이 자원 역시 어느 타이밍에 해제 시켜야 할 지 알 수 없게 됩니다.

따라서 이 경우, 좀더 스마트 한 포인터가 있어서, 특정 자원을 몇 개의 객체에서 가리키는지를 추적한 다음에, 그 수가 0 이 되야만 비로소 해제를 시켜주는 방식의 포인터가 필요합니다.

shared_ptr

shared_ptr 은 앞서 이야기한 방식을 정확히 수행하는 스마트 포인터 입니다. 기존에 유일하게 객체를 소유하는 unique_ptr 와는 다르게, shared_ptr 로 객체를 가리킬 경우, 다른 shared_ptr 역시 그 객체를 가리킬 수 있습니다. 예를 들어서;

shared_ptr<A> p1(new A());
shared_ptr<A> p2(p1);  // p2 역시 생성된 객체 A 를 가리킨다.

// 반면에 unique_ptr 의 경우
unique_ptr<A> p1(new A());
unique_ptr<A> p2(p1);  // 컴파일 오류!

p1p2 의 경우 같이 동일한 객체인 A() 를 가리키지만, unique_ptr 의 경우 유일한 소유권만 인정되므로 컴파일 오류가 발생하게 됩니다.

위 그림과 같이 shared_ptr 는 같은 객체를 가리킬 수 있습니다. 이를 위해서는, 앞서 말했듯이, 몇 개의 shared_ptr 들이 원래 객체를 가리키는지 알아야만 합니다. 이를 참조 개수 (reference count) 라고 하는데, 참조 개수가 0 이 되어야 가리키고 있는 객체를 해제할 수 있겠지요.

caption=p1 과 p2 의 참조 카운트는 2 이다.
p1 과 p2 의 참조 카운트는 2 이다.

위 그림의 경우 p1p2 가 같은 객체를 가리키고 있으므로, 참조 개수가 2 가 됩니다.

한번 아래 예제를 살펴보실까요.

#include <iostream>
#include <memory>
#include <vector>
using namespace std;

class A {
  int *data;

 public:
  A() {
    data = new int[100];
    cout << "자원을 획득함!" << endl;
  }

  ~A() {
    cout << "소멸자 호출!" << endl;
    delete[] data;
  }
};

int main() {
  vector<shared_ptr<A>> vec;

  vec.push_back(shared_ptr<A>(new A()));
  vec.push_back(shared_ptr<A>(vec[0]));
  vec.push_back(shared_ptr<A>(vec[1]));

  // 벡터의 첫번째 원소를 소멸 시킨다.
  cout << "첫 번째 소멸!" << endl;
  vec.erase(vec.begin());

  // 그 다음 원소를 소멸 시킨다.
  cout << "다음 원소 소멸!" << endl;
  vec.erase(vec.begin());

  // 마지막 원소 소멸
  cout << "마지막 원소 소멸!" << endl;
  vec.erase(vec.begin());

  cout << "프로그램 종료!" << endl;
}

성공적으로 컴파일 하였다면

자원을 획득함!
첫 번째 소멸!
다음 원소 소멸!
마지막 원소 소멸!
소멸자 호출!
프로그램 종료!

와 같이 나옵니다.

위 예제의 경우 shared_ptr 를 원소로 가지는 벡터 vec 을 정의한 후, vec[0], vec[1], vec[2] 가 모두 같은 A 객체를 가리키는 shared_ptr 를 생성하였습니다.

// 벡터의 첫번째 원소를 소멸 시킨다.
cout << "첫 번째 소멸!" << endl;
vec.erase(vec.begin());

// 그 다음 원소를 소멸 시킨다.
cout << "다음 원소 소멸!" << endl;
vec.erase(vec.begin());

// 마지막 원소 소멸
cout << "마지막 원소 소멸!" << endl;
vec.erase(vec.begin());

그 다음에 위 부분에서, vec 의 첫 번째 원소 부터 차례대로 지워나갔는데, unique_ptr 와는 다르게 shared_ptr 의 경우 객체를 가리키는 모든 스마트 포인터 들이 소멸되어야만 객체를 파괴하기 때문에, 처음 두 번의 erase 에서는 아무것도 하지 않다가 마지막의 erase 에서 비로소 A 의 소멸자를 호출하는 것을 볼 수 있습니다.

즉 참조 개수가 처음에는 3 이 였다가, 2, 1, 0 순으로 줄어들게 되겠지요.

현재 shared_ptr 의 참조 개수가 몇 개 인지는 use_count 함수를 통해 알 수 있습니다. 예를 들어서

shared_ptr<A> p1(new A());
shared_ptr<A> p2(p1);  // p2 역시 생성된 객체 A 를 가리킨다.

cout << p1.use_count();  // 2
cout << p2.use_count();  // 2

와 같이 출력 되겠지요.

그렇다면 퀴즈 하나! 위에서도 보시다시피 개개의 shared_ptr 들은 참조 개수가 몇 개 인지 알고 있어야만 합니다. 이 경우 어떻게 하면 같은 객체를 가리키는 shared_ptr 끼리 동기화를 시킬 수 있을까요?

만약에, shared_ptr 내부에 참조 개수를 저장한다면 아래와 같은 문제가 생길 수 있습니다. 만약에 아래와 같이 한 개의 shared_ptr 가 추가적으로 해당 객체를 가리킨다면 어떨까요?

shared_ptr<A> p3(p2);

와 같이 말이지요. 그렇다면 여차저차 해서 p2 의 참조 카운트 개수는 증가시킬 수 있다고 해도, p1 에 저장되어 있는 참조 개수를 건드릴 수 없습니다. 즉 아래와 같은 상황이 발생하겠지요.

caption=p1 의 참조 카운트를 바꿀 수 없다
p1 의 참조 카운트를 바꿀 수 없다

따라서 이와 같은 문제를 방지하기 위해 처음으로 실제 객체를 가리키는 shared_ptr제어 블록(control block) 을 동적으로 할당한 후, shared_ptr 들이 이 제어 블록에 필요한 정보를 공유하는 방식으로 구현됩니다. 아래 그림과 같이 말이지요.

caption=p1, p2, p3 가 공통된 제어 블록을 공유한다
p1, p2, p3 가 공통된 제어 블록을 공유한다

shared_ptr 는 복사 생성할 때 마다 해당 제어 블록의 위치만 공유하면 되고, shared_ptr 가 소멸할 때 마다 제어 블록의 참조 개수를 하나 줄이고, 생성할 때 마다 하나 늘리는 방식으로 작동할 것입니다.

make_shared 로 생성하자

앞서 shared_ptr 를 처음 생성할 때 아래와 같이 하였습니다.

shared_ptr<A> p1(new A());

하지만 사실 이는 바람직한 shared_ptr 의 생성 방법은 아닙니다. 왜냐하면 일단 A 를 생성하기 위해서 동적 할당이 한 번 일어나야 하고, 그 다음 shared_ptr 의 제어 블록 역시 동적으로 할당 해야 하기 때문이지요. 즉 두 번의 동적 할당이 발생해야 합니다.

동적 할당은 상당히 비싼 연산 입니다. 어차피 동적 할당을 두 번 할 것 이라는 것을 알고 있다면, 아예 두 개 합친 크기로 한 번 할당 하는 것이 훨씬 빠릅니다.

shared_ptr<A> p1 = make_shared<A>();

make_shared 함수는 A 의 생성자의 인자들을 받아서 이를 통해 객체 A 와 shared_ptr 의 제어 블록 까지 한 번에 동적 할당 한 후에 만들어진 shared_ptr 을 리턴합니다.

위 경우 A 의 생성자에 인자가 없어서 make_shared 에 아무 것도 전달하지 않았지만, 만약에 A 의 생성자에 인자가 있다면 make_shared 에 인자로 전달해 주면 됩니다. (그리고 make_shared 가 A 의 생성자에 완벽한 전달을 해주겠지요!)

shared_ptr 생성 시 주의 할 점

shared_ptr 은 인자로 주소값이 전달된다면, 마치 자기가 해당 객체를 첫번째로 소유하는 shared_ptr 인 것 마냥 행동합니다. 예를 들어서

A* a = new A();
shared_ptr<A> pa1(a);
shared_ptr<A> pa2(a);

를 하게 된다면 아래와 같이 이 두 개의 제어 블록이 따로 생성됩니다.

따라서 위와 같이 각각의 제어 블록들은, 다른 제어 블록들의 존재를 모르고 참조 개수를 1 로 설정하게 되겠지요. 만약에 pa1 이 소멸된다면, 참조 카운트가 0 이 되어서 자신이 가리키는 객체 A 를 소멸시켜 버립니다. pa2 가 아직 가리키고 있는데도 말이지요!

물론 pa2 의 참조 카운트는 계속 1 이기 때문에 자신이 가리키는 객체가 살아 있을 것이라 생각할 것입니다. 설사 운 좋게도 pa2 를 사용하지 않아도, pa2 가 소멸되면 참조 개수가 0 으로 떨어지고 자신이 가리키고 있는 (이미 해제된) 객체를 소멸시키기 때문에 오류가 발생합니다.

아래 예제를 보면 쉽게 알 수 있습니다.

#include <iostream>
#include <memory>
using namespace std;

class A {
  int* data;

 public:
  A() {
    data = new int[100];
    cout << "자원을 획득함!" << endl;
  }

  ~A() {
    cout << "소멸자 호출!" << endl;
    delete[] data;
  }
};

int main() {
  A* a = new A();

  shared_ptr<A> pa1(a);
  shared_ptr<A> pa2(a);

  cout << pa1.use_count() << endl;
  cout << pa2.use_count() << endl;
}

성공적으로 컴파일 하였다면

자원을 획득함!
1
1
소멸자 호출!
소멸자 호출!
test(37272,0x11254d5c0) malloc: *** error for object 0x4000000000000000: pointer being freed was not allocated
test(37272,0x11254d5c0) malloc: *** set a breakpoint in malloc_error_break to debug
[1]    37272 abort      ./test

위와 같이 소멸자가 두 번 호출되면서 오류가 나게 됩니다. 오류 내용 역시, 이미 해제한 메모리를 또 해제 한다는 뜻이네요.

이와 같은 상황을 방지하려면 shared_ptr 를 주소값을 통해서 생성하는 것을 지양해야 합니다.

하지만, 어쩔 수 없는 상황도 있습니다. 바로 객체 내부에서 자기 자신을 가리키는 shared_ptr 를 만들 때 를 생각해봅시다.

#include <iostream>
#include <memory>
using namespace std;

class A {
  int *data;

 public:
  A() {
    data = new int[100];
    cout << "자원을 획득함!" << endl;
  }

  ~A() {
    cout << "소멸자 호출!" << endl;
    delete[] data;
  }

  shared_ptr<A> get_shared_ptr() { return shared_ptr<A>(this); }
};

int main() {
  shared_ptr<A> pa1 = make_shared<A>();
  shared_ptr<A> pa2 = pa1->get_shared_ptr();

  cout << pa1.use_count() << endl;
  cout << pa2.use_count() << endl;
}

성공적으로 컴파일 하였다면

자원을 획득함!
1
1
소멸자 호출!
소멸자 호출!
test(38479,0x10e0945c0) malloc: *** error for object 0x7fa1e0e02700: pointer being freed was not allocated
test(38479,0x10e0945c0) malloc: *** set a breakpoint in malloc_error_break to debug
[1]    38479 abort      ./test

위와 같이 이전과 같은 이유로 오류가 발생하게 됩니다. get_shared_ptr 함수에서 shared_ptr 을 생성할 때, 이미 자기 자신을 가리키는 shared_ptr 가 있다는 사실을 모른채 새로운 제어 블록을 생성하기 때문입니다.

이 문제는 enable_shared_from_this 를 통해 깔끔하게 해결할 수 있습니다.

enable_shared_from_this

우리가 this 를 사용해서 shared_ptr 을 만들고 싶은 클래스가 있다면, enable_shared_from_this 를 상속 받으면 됩니다. 아래 사용 예시를 보실까요.

#include <iostream>
#include <memory>
using namespace std;

class A : public enable_shared_from_this<A> {
  int *data;

 public:
  A() {
    data = new int[100];
    cout << "자원을 획득함!" << endl;
  }

  ~A() {
    cout << "소멸자 호출!" << endl;
    delete[] data;
  }

  shared_ptr<A> get_shared_ptr() { return shared_from_this(); }
};

int main() {
  shared_ptr<A> pa1 = make_shared<A>();
  shared_ptr<A> pa2 = pa1->get_shared_ptr();

  cout << pa1.use_count() << endl;
  cout << pa2.use_count() << endl;
}

성공적으로 컴파일 하였다면

자원을 획득함!
2
2
소멸자 호출!

와 같이 제대로 작동하는 것을 볼 수 있습니다.

enable_shared_from_this 클래스에는 shared_from_this 라는 멤버 함수를 정의하고 있는데, 이 함수는 이미 정의되어 있는 제어 블록을 사용해서 shared_ptr 을 생성합니다.

따라서 이전 처럼 같은 객체에 두 개의 다른 제어 블록이 생성되는 일을 막을 수 있습니다.

한 가지 중요한 점은 shared_from_this 가 잘 작동하기 위해서는 해당 객체의 shared_ptr 가 반드시 먼저 정의되어 있어야만 합니다. 즉 shared_from_this 는 있는 제어 블록을 확인만 할 뿐, 없는 제어 블록을 만들지는 않습니다. 쉽게 말해 아래 코드는 오류가 발생합니다.

A* a = new A();
shared_ptr<A> pa1 = a->get_shared_ptr();

서로 참조하는 shared_ptr

앞서 shared_ptr 는 참조 개수가 0 이 되면 가리키는 객체를 메모리에서 해제 시킨다고 했습니다. 그런데, 객체들을 더이상 사용하지 않는되도 불구하고 참조 개수가 절대로 0 이 될 수 없는 상황이 있습니다. 아래 그림을 살펴보실까요.

caption=이 같은 형태를 순환 참조라고 합니다.
이 같은 형태를 순환 참조라고 합니다.

위 그림의 경우 각 객체는 shared_ptr 를 하나 씩 가지고 있는데, 이 shared_ptr 가 다른 객체를 가리키고 있습니다. 즉 객체 1 의 shared_ptr 은 객체 2 를 가리키고 있고, 객체 2 의 shared_ptr 는 객체 1 을 가리키고 있지요.

만약에 객체 1 이 파괴가 되기 위해서는 객체 1 을 가리키고 있는 shared_ptr 의 참조 개수가 0 이 되어야만 합니다. 즉, 객체 2 가 파괴가 되어야 하겠지요. 하지만 객체 2 가 파괴 되기 위해서는 마찬가지로 객체 2 를 가리키고 있는 shared_ptr 의 참조 개수가 0 이 되어야 하는데, 그러기 위해서는 객체 1 이 파괴되어야만 합니다.

즉 이러지도 저러지도 못하는 상황이 된것입니다.

#include <iostream>
#include <memory>
using namespace std;

class A {
  int *data;
  shared_ptr<A> other;

 public:
  A() {
    data = new int[100];
    cout << "자원을 획득함!" << endl;
  }

  ~A() {
    cout << "소멸자 호출!" << endl;
    delete[] data;
  }

  void set_other(shared_ptr<A> o) { other = o; }
};

int main() {
  shared_ptr<A> pa = make_shared<A>();
  shared_ptr<A> pb = make_shared<A>();

  pa->set_other(pb);
  pb->set_other(pa);
}

성공적으로 컴파일 하였다면

자원을 획득함!
자원을 획득함!

위와 같이 소멸자가 제대로 호출되지 않음을 알 수 있습니다.

이 문제는 shared_ptr 자체에 내재되어 있는 문제이기 때문에 shared_ptr 를 통해서는 이를 해결할 수 없습니다. 이러한 순환 참조 문제를 해결하기 위해 나타난 것이 바로 weak_ptr 입니다.

weak_ptr

우리는 트리 구조를 지원하는 클래스를 만드려고 합니다. 트리 구조라 함은 아래와 가계도와 비슷하다고 생각하시면 됩니다.

즉, 한 개의 노드는 여러개의 자식 노드를 가질 수 있지만, 단 한 개의 부모 노드를 가집니다. 위 그림에서 부모 노드는 자식 노드들을 가리키고 있고 (실선), 자식 노드들은 부모 노드를 가리키고 있습니다 (점선).

위와 같은 형태를 자료 구조로 나타낸다면 어떻게 할 수 있을까요?

class Node {
  vector<shared_ptr<Node>> children;
  /* 어떤 타입이 와야할까? */ parent;

 public:
  Node(){};
  void AddChild(shared_ptr<Node> node) { children.push_back(node); }
};

일단 기본적으로 위와 같은 형태를 취한다고 볼 수 있습니다. 부모가 여러개의 자식 노드들을 가지므로 shared_ptr 들의 벡터로 나타낼 수 있고, 그 노드 역시 부모 노드가 있으므로 부모 노드를 가리키는 포인터를 가집니다.

여기서 질문은 과연 parent 의 타입을 무엇으로 하냐 입니다.

weak_ptr 는 일반 포인터와 shared_ptr 사이에 위치한 스마트 포인터로, 스마트 포인터 처럼 객체를 안전하게 참조할 수 있게 해주지만, shared_ptr 와는 다르게 참조 개수를 늘리지는 않습니다. 이름 그대로 약한 포인터 인것이지요.

따라서 설사 어떤 객체를 weak_ptr 가 가리키고 있다고 하더라도, 다른 shared_ptr 들이 가리키고 있지 않다면 이미 메모리에서 소멸되었을 것입니다.

이 때문에 weak_ptr 자체로는 원래 객체를 참조할 수 없고, 반드시 shared_ptr 로 변환해서 사용해야 합니다. 이 때 가리키고 있는 객체가 이미 소멸되었다면 빈 shared_ptr 로 변환되고, 아닐경우 해당 객체를 가리키는 shared_ptr 로 변환됩니다.

아래 예제를 통해 weak_ptr 을 어떻게 활용할 수 있는지 알아봅시다.

#include <iostream>
#include <memory>
#include <string>
#include <vector>
using namespace std;

class A {
  string s;
  weak_ptr<A> other;

 public:
  A(const string& s) : s(s) { cout << "자원을 획득함!" << endl; }

  ~A() { cout << "소멸자 호출!" << endl; }

  void set_other(weak_ptr<A> o) { other = o; }
  void access_other() {
    shared_ptr<A> o = other.lock();
    if (o) {
      cout << "접근 : " << o->name() << endl;
    } else {
      cout << "이미 소멸됨 ㅠ" << endl;
    }
  }
  string name() { return s; }
};

int main() {
  vector<shared_ptr<A>> vec;
  vec.push_back(make_shared<A>("자원 1"));
  vec.push_back(make_shared<A>("자원 2"));

  vec[0]->set_other(vec[1]);
  vec[1]->set_other(vec[0]);

  // pa 와 pb 의 ref count 는 그대로다.
  cout << "vec[0] ref count : " << vec[0].use_count() << endl;
  cout << "vec[1] ref count : " << vec[1].use_count() << endl;

  // weak_ptr 로 해당 객체 접근하기
  vec[0]->access_other();

  // 벡터 마지막 원소 제거 (vec[1] 소멸)
  vec.pop_back();
  vec[0]->access_other();  // 접근 실패!
}

성공적으로 컴파일 하였다면

자원을 획득함!
자원을 획득함!
vec[0] ref count : 1
vec[1] ref count : 1
접근 : 자원 2
소멸자 호출!
이미 소멸됨 ㅠ
소멸자 호출!

와 같이 나옵니다.

일단 weak_ptr 을 정의하는 부분 부터 살펴봅시다.

vec[0]->set_other(vec[1]);
vec[1]->set_other(vec[0]);

set_other 함수는 weak_ptr<A> 를 인자로 받고 있었는데, 여기에 shared_ptr 을 전달하였습니다. 즉, weak_ptr 는 생성자로 shared_ptr 나 다른 weak_ptr 를 받습니다. 또한 shared_ptr 과는 다르게, 이미 제어 블록이 만들어진 객체만이 의미를 가지기 때문에, 평범한 포인터 주소값으로 weak_ptr 를 생성할 수 는 없습니다.

그 다음으로 살펴볼 부분은 실제 weak_ptrshared_ptr 로 변환하는 과정 입니다.

void access_other() {
  shared_ptr<A> o = other.lock();
  if (o) {
    cout << "접근 : " << o->name() << endl;
  } else {
    cout << "이미 소멸됨 ㅠ" << endl;
  }
}

앞서 말했듯이 weak_ptr 그 자체로는 원소를 참조할 수 없고, shared_ptr 로 변환해야 한다고 하였습니다. 이 작업은 lock 함수를 통해 수행할 수 있습니다.

weak_ptr 에 정의된 lock 함수는 만일 weak_ptr 가 가리키는 객체가 아직 메모리에서 살아 있다면 (즉 참조 개수가 0 이 아니라면) 해당 객체를 가리키는 shared_ptr 을 반환하고, 이미 해제가 되었다면 아무것도 가리키지 않는 shared_ptr 을 반환 합니다.

shared_ptr<A> o = other.lock();
if (o) {
  cout << "접근 : " << o->name() << endl;
}

참고로 아무것도 가리키지 않는 shared_ptrfalse 로 형변환 되므로 위와 같이 if 문으로 간단히 확인할 수 있습니다.

앞서 제어 블록에는 몇 개의 shared_ptr 가 가리키고 있는지를 나타내는 참조 개수(ref count) 가 있다고 하였습니다. 그리고 참조 개수가 0 이 되면 해당 객체를 메모리에서 해제하는 것도 알고 있지요. 그렇다면 참조 개수가 0 이 될때 제어 블록 역시 메모리에서 해제해야 할까요?

아닙니다. 만약에 가리키는 shared_ptr 은 0 개 지만 아직 weak_ptr 가 남아있다고 해봅시다. 물론 이 상태에서는 이미 객체는 해제 되어 있을 것입니다. 하지만 제어 블록 마저 해제해 버린다면, 제어 블록에서 참조 카운트가 0 이라는 사실을 알 수 없게 됩니다.

즉, 제어 블록을 메모리에서 해제해야 하기 위해서는 이를 가리키는 weak_ptr 역시 0 개여야 합니다. 따라서 제어 블록에는 참조 개수와 더불어 약한 참조 개수 (weak count) 기록하게 됩니다.

자 그럼 이것으로 스마트 포인터 삼형제 (unique_ptr, shared_ptr, weak_ptr) 에 관한 강좌를 마치도록 하겠습니다. 스마트 포인터를 도입함으로써 골치 아픈 메모리 문제를 많이 해결 할 수 있을 것이라 생각합니다.

생각 해보기

문제 1

가계도를 관리하는 라이브러리를 만들어보세요. 기본적으로 다음과 같이 생겼을 것입니다. (난이도 : 상)

class Member {
 private:
  vector<shared_ptr<Member>> children;
  vector<weak_ptr<Member>> parents;
  vector<weak_ptr<Member>> spouse;

 public:
  void AddParent(const shared_ptr<Member>& parent);
  void AddSpouse(const shared_ptr<Member>& spouse);
  void AddChild(const shared_ptr<Member>& child);
};
class FamilyTree {
 private:
  vector<shared_ptr<Member>> entire_family;

 public:
  // 두 사람 사이의 촌수를 계산한다.
  int CalculateChon(Member* mem1, Member* mem2);
};
강좌를 보다가 조금이라도 궁금한 것이나 이상한 점이 있다면 꼭 댓글을 남겨주시기 바랍니다. 그 외에도 강좌에 관련된 것이라면 어떠한 것도 질문해 주셔도 상관 없습니다. 생각해 볼 문제도 정 모르겠다면 댓글을 달아주세요.

현재 여러분이 보신 강좌는 <13 - 2. 자원을 공유할 때 - shared_ptr 와 weak_ptr> 입니다. 이번 강좌의 모든 예제들의 코드를 보지 않고 짤 수준까지 강좌를 읽어 보시기 전까지 다음 강좌로 넘어가지 말아주세요
프로필 사진 없음
댓글에 글쓴이에게 큰 힘이 됩니다