모두의 코드
씹어먹는 C ++ - <11. C++ 에서 예외 처리>

작성일 : 2018-09-17 이 글은 79178 번 읽혔습니다.

이번 강좌에서는

  • C++ 에서의 예외 처리

  • throw, try, catch

안녕하세요 여러분! 오래 간만에 인사 드립니다. 이번 강좌에서는 C++ 에서 예외 처리를 어떠한 방식으로 하는지에 대해 알아보도록 하겠습니다.

예외란?

우리가 이상적인 세상에서 살고 있다면, 그 어떤 예외적인 상황도 없을 것입니다. 프로그램 혹은 라이브러리 사용자들은 언제나 올바른 값을 입력값으로 줄 것이고, 컴퓨터 역시 무한한 자원을 사용할 수 있어서 어떠한 상황에서도 데이터들을 정상적으로 처리할 수 있을 것입니다.

하지만, 안타깝게도 이 세상은 그리 녹록하지 않습니다. 사람들은 실수를 하기 마련이고, 컴퓨터 역시 언제나 프로그램에 필요한 자원을 제공할 수 있는 것이 아닙니다. 예를 들어서 아래와 같은 vector 의 사용 예시를 살펴봅시다.

std::vector<int> v(3);  // 크기가 3 인 벡터 만듦
std::cout << v.at(4);   // ??

위 경우, 크기가 3 인 vector 를 만들었지만 4 번째 원소를 요청하고 있습니다. 위와 같은 코드는 문법 상 아무 문제가 없는 코드이지만, 막상 실행하게 되면 오류가 발생하게 됩니다.

다른 예로 아래와 같이 큰 메모리를 할당하는 경우를 생각해봅시다.

std::vector<int> v(1000000000);
// ?

여러분이 사용하는 대부분의 시스템의 경우 위와 같이 큰 메모리를 할당할 수 없습니다. 따라서, 위 코드 역시 문법 상 틀린 것이 없는 코드 이지만, 실제로 실행해보면 오류가 발생하게 됩니다.

이렇게 정상적인 상황에서 벗어난 모든 예외적인 상황들을 예외(exception) 이라고 부릅니다.

기존의 예외 처리 방식

C 언어에서는 언어 차원에서 제공하는 예외 처리 방식이라는 것이 딱히 따로 존재하지 않았습니다. 따라서 아래와 같이, 어떤 작업을 실행한 뒤에 그 결과값을 확인하는 방식으로 처리하였습니다. 예를 들어서 아래 malloc 으로 메모리를 동적으로 할당하는 경우를 생각해봅시다.

char *c = (char *)malloc(1000000000);
if (c == NULL) {
  printf("메모리 할당 오류!");
  return;
}

malloc 의 경우 메모리 할당 실패시에 NULL 을 리턴하므로, 위와 같이 cNULL 인지 확인함으로써 예외적인 상황을 처리할 수 있었습니다.

하지만 이러한 방식으로 예외를 처리한다면, 함수가 깊어지면 깊어질 수 록 꽤나 귀찮게 됩니다. 예를 들어서 아래와 같은 예시를 살펴보세요.

bool func1(int *addr) {
  if (func2(addr)) {
    // Do something
  }
  return false;
}
bool func2(int *addr) {
  if (func3(addr)) {
    // Do something
  }
  return false;
}
bool func3(int *addr) {
  addr = (int *)malloc(100000000);
  if (addr == NULL) return false;
  return true;
}
int main() {
  int *addr;
  if (func1(addr)) {
    // 잘 처리됨
  } else {
    // 오류 발생
  }
}

위 코드의 경우 func3 에서 '예외가 발생할 수 있는 작업' 을 수행하는데, 만약에 예외가 발생하게 된다면 false 를 리턴하게 되고, 잘 처리 되었다면 true 를 리턴합니다.

여기까지는 좋은데, 문제는 이 func3func2 에서 호출되고, 다시 func2func1 에서 호출되고, func1main 에서 호출된다는 점입니다. 만약에 main 의 입장에서 func3 에서 문제가 발생했을 때 이를 캐치하기 위해서는, 각각의 함수들에서 처리 결과를 모두 리턴해야 할 것입니다.

위 코드는 예외가 func3 에서만 발생해서 간단하였지만, 만약에 func2 도 어떤 다른 작업을 해서 예외를 발생시킬 수 있다면 어떻게 해야 할까요? 상당히 골치 아픈 일입니다.

하지만 다행이도 C++ 에서는 위와 같은 불편한 예외 처리 방식을 획기적으로 해결시켰습니다.

예외 발생시키기 - throw

C 언어에서는 예외가 발생했을 때, 다른 값을 리턴하는 것으로 예외를 처리하였지만, C++ 에서는 예외가 발생하였다는 사실을 명시적으로 나타낼 수 있습니다. 바로 throw 문을 사용하면 됩니다.

예를 들어서 아래와 같이 매우 간단한 vector 클래스를 생각해봅시다.

template <typename T>
class Vector {
 public:
  Vector(size_t size) : size_(size) {
    data_ = new T[size_];
    for (int i = 0; i < size_; i++) {
      data_[i] = 3;
    }
  }
  const T& at(size_t index) const {
    if (index >= size_) {
      throw out_of_range("vector 의 index 가 범위를 초과하였습니다.");
    }
    return data_[index];
  }
  ~Vector() { delete[] data_; }

 private:
  T* data_;
  size_t size_;
};

만들어진 vector 의 요청한 위치에 있는 원소를 리턴하는 함수인 at 함수를 생각해봅시다

인자로 전달된 index 가 범위 이내라면, 간단하게 data[index] 를 리턴하면 되겠지만, 범위 밖이라면 어떻게 해야 할까요?

문제는 at 함수가 const T& 를 리턴하기 때문에, 따로 '오류 메세지' 를 리턴할 수 없다는 점입니다. 하지만 C++ 에서는 다음과 같이 예외가 발생하였음을 명시적으로 알릴 수 있습니다.

// 생략 ...
const T& at(size_t index) const {
  if (index >= size) {
    // 예외를 발생시킨다!
    throw std::out_of_range("vector 의 index 가 범위를 초과하였습니다.");
  }
  return data[index];
}
// 생략 ...
}
;

먼저, 예외를 발생시키는 부분을 자세히 살펴보겠습니다.

throw std::out_of_range("vector 의 index 가 범위를 초과하였습니다.");

C++ 에는 예외를 던지고 싶다면, throw 로 예외로 전달하고 싶은 객체를 써주면 됩니다. 예외로 아무 객체나 던져도 상관 없지만, C++ 표준 라이브러리에는 이미 여러가지 종류의 예외들이 정의되어 있어서 이를 활용하는 것도 좋습니다. 예를 들어서, 위 경우 out_of_range 객체를 throw 합니다. C++ 표준에는 out_of_range 외에도 overflow_error, length_error, runtime_error 등등 여러가지가 정의되어 있고 표준 라이브러리에서 활용되고 있습니다.

이렇게 예외를 throw 하게 되면, throw 한 위치에서 즉시 함수가 종료되고, 예외 처리하는 부분까지 점프하게 됩니다. 따라서 throw 밑에 있는 모든 문장은 실행되지 않습니다. 한 가지 중요한 점은 이렇게 함수에서 예외 처리하는 부분에 도달하기 까지 함수를 빠져나가면서, stack 에 생성되었던 객체들을 빠짐없이 소멸시켜 준다는 점 입니다. 따라서 예외가 발생하여도 사용하고 있는 자원들을 제대로 소멸시킬 수 있습니다! (소멸자만 제대로 작성하였다면)

예외 처리 하기 - try 와 catch

그렇다면 이렇게 발생한 에외를 어떻게 처리할까요?

#include <iostream>
#include <stdexcept>

template <typename T>
class Vector {
 public:
  Vector(size_t size) : size_(size) {
    data_ = new T[size_];
    for (int i = 0; i < size_; i++) {
      data_[i] = 3;
    }
  }
  const T& at(size_t index) const {
    if (index >= size_) {
      throw std::out_of_range("vector 의 index 가 범위를 초과하였습니다.");
    }
    return data_[index];
  }
  ~Vector() { delete[] data_; }

 private:
  T* data_;
  size_t size_;
};
int main() {
  Vector<int> vec(3);

  int index, data = 0;
  std::cin >> index;

  try {
    data = vec.at(index);
  } catch (std::out_of_range& e) {
    std::cout << "예외 발생 ! " << e.what() << std::endl;
  }
  // 예외가 발생하지 않았다면 3을 이 출력되고, 예외가 발생하였다면 원래 data 에
  // 들어가 있던 0 이 출력된다.
  std::cout << "읽은 데이터 : " << data << std::endl;
}

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

1 입력 시에 읽은 데이터로 3 이 나오고, 4 입력 시에 예외가 발생하며 읽은 데이터로 0 이 나온다.

와 같이 나옵니다.

위에서도 볼 수 있듯이, 범위에 벗어난 값 (위 경우 3 이상) 을 입력하게 되었다면, 범위를 초과하였다는 메세지를 볼 수 있습니다. 그렇다면, 예외가 어떤 식으로 처리되었는지 살펴봅시다.

try {
  data = vec.at(index);
}

먼저 try 부분 입니다. try 안에서 무언가 예외가 발생할만한 코드가 실행 됩니다. 만약에 예외가 발생하지 않았다면 마지 try .. catch 부분이 없는 것과 동일하게 실행 됩니다. data 에는 vecindex 번째 값이 들어가고 밑에 있는 catch 문은 무시 됩니다.

반면에 예외가 발생할 경우 이야기가 달라집니다. 예외가 발생하게되면, 그 즉시 stack 에 생성된 모든 객체들의 소멸자들이 호출되고, 가장 가까운 catch 문으로 점프합니다. 따라서, 위 경우

if (index >= size_) {
  throw std::out_of_range("vector 의 index 가 범위를 초과하였습니다.");
}

throw 다음으로 실행되는 문장이 바로

catch (std::out_of_range& e) {
  std::cout << "예외 발생 ! " << e.what() << std::endl;
}

catch 부분이 됩니다. 여기서 catch 문은 throw 된 예외를 받는 부분인데, 어떤 예외를 받냐면, catch 문 안에 정의된 예외의 꼴에 맞는 객체를 받게 됩니다. 우리의 Vector 의 경우 out_of_rangethrow 하였는데, 위 catch 문이 out_of_range 를 받으므로, 잘 받을 수 있습니다.

out_of_range 클래스는 아주 간단한데, 그냥 내부에 발생엔 예외에 관한 내용을 저장하는 문자열 필드가 달랑 하나 있고 이 역시 what() 함수로 그 값을 들여다 볼 수 있습니다. 위 경우 우리가 전달한 문장인 'vectorindex가 범위를 초과하였습니다' 가 나오게 됩니다.

스택 풀기 (stack unwinding)

앞서 throw 를 하게 된다면, 가장 가까운 catch 로 점프한다고 하였습니다. 이 말의 뜻이 무엇인지 아래 예제로 살펴봅시다.

#include <iostream>
#include <stdexcept>

class Resource {
 public:
  Resource(int id) : id_(id) {}
  ~Resource() { std::cout << "리소스 해제 : " << id_ << std::endl; }

 private:
  int id_;
};

int func3() {
  Resource r(3);
  throw std::runtime_error("Exception from 3!\n");
}
int func2() {
  Resource r(2);
  func3();
  std::cout << "실행 안됨!" << std::endl;
  return 0;
}
int func1() {
  Resource r(1);
  func2();
  std::cout << "실행 안됨!" << std::endl;
  return 0;
}

int main() {
  try {
    func1();
  } catch (std::exception& e) {
    std::cout << "Exception : " << e.what();
  }
}

성공적으로 실행하였으면

실행 결과

리소스 해제 : 3
리소스 해제 : 2
리소스 해제 : 1
Exception : Exception from 3!

와 같이 나옵니다.

먼저 살펴보아야할 부분으로,

int func3() {
  Resource r(3);
  throw std::runtime_error("Exception from 3!\n");
}

에서 보시다시피, func3 함수에서 예외를 발생시키고 있습니다. 그런데, 이 func3func2 가 호출하고, func2func1 이 호출하고, 마지막으로 func1main 에서 호출됩니다.

앞에서 말했듯이 예외가 발생하게 되면 가장 가까운 catch 에서 예외를 받는다고 하였습니다. 그런데, func1, 2 모두 예외를 받는 catch 구문이 없습니다. 따라서, 가장 가까운 catch 부분은, main 함수에 있는 catch 구문이 되고, 실제로도 예외가 main 함수에까지 잘 전달되어서 출력되었습니다.

또 한 가지 중요한 점은, 예외가 전파되면서 각 함수들에 정의되어 있던 객체들이 잘 소멸되었다는 점입니다.

먄약에 예외가 발생하지 않았을 경우 어떻게 나오게 되냐면

#include <iostream>
#include <stdexcept>

class Resource {
 public:
  Resource(int id) : id_(id) {}
  ~Resource() { std::cout << "리소스 해제 : " << id_ << std::endl; }

 private:
  int id_;
};

int func3() {
  Resource r(3);
  return 0;
}
int func2() {
  Resource r(2);
  func3();
  std::cout << "실행!" << std::endl;
  return 0;
}
int func1() {
  Resource r(1);
  func2();
  std::cout << "실행!" << std::endl;
  return 0;
}

int main() {
  try {
    func1();
  } catch (std::exception& e) {
    std::cout << "Exception : " << e.what();
  }
}

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

실행 결과

리소스 해제 : 3
실행!
리소스 해제 : 2
실행!
리소스 해제 : 1

와 같이 나옵니다.

위와 비교해보면, 정상적인 상황에서는 객체의 소멸자들은 함수가 종료될 때 호출되므로 "실행!" 이 먼저 출력되고, 그 뒤에 리소스 해제 되었다는 문장이 출력됩니다.

반면에 예외가 전파되는 과정에서는 바로 catch 부분으로 점프 하면서, 각 함수들에 있던 객체들만 해제하기 때문에 리소스 해제 되었다는 것은 정상적으로 출력되지만, 그 "실행 안됨!" 부분은 실행되지 않습니다.

이와 같이 catch 로 점프 하면서 스택 상에서 정의된 객체들을 소멸시키는 과정을 스택 풀기(stack unwinding) 이라고 부릅니다.

주의 사항

예외를 생성자에서 던질 때 주의해야 할 점이 하나 있습니다. 바로 생성자에서 예외가 발생 시에 소멸자가 호출되지 않는다 라는 점입니다. 따라서, 만일 예외를 던지기 이전에 획득한 자원이 있다면 catch 에서 잘 해제시켜 줘야만 합니다.

여러 종류의 예외 받기

앞서 catch 는 여러 종류의 예외들을 받을 수 있다고 하였습니다. 이를 위해선, 한 개의 try 안에 받고자 하는 모든 종류의 예외를 catch 문으로 주렁 주렁 달면 됩니다. 아래 예제를 보실까요.

#include <iostream>
#include <string>

int func(int c) {
  if (c == 1) {
    throw 10;
  } else if (c == 2) {
    throw std::string("hi!");
  } else if (c == 3) {
    throw 'a';
  } else if (c == 4) {
    throw "hello!";
  }
  return 0;
}

int main() {
  int c;
  std::cin >> c;

  try {
    func(c);
  } catch (char x) {
    std::cout << "Char : " << x << std::endl;
  } catch (int x) {
    std::cout << "Int : " << x << std::endl;
  } catch (std::string& s) {
    std::cout << "String : " << s << std::endl;
  } catch (const char* s) {
    std::cout << "String Literal : " << s << std::endl;
  }
}

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

리소스 잘 해제되고 실행도 잘 됨

와 같이 나옵니다.

마치 switch 문 처럼 catch 역시 여러 종류의 throw 된 객체를 모두 받을 수 있습니다. 위 경우,

catch (char x) {
  std::cout << "Char : " << x << std::endl;
}
catch (int x) {
  std::cout << "Int : " << x << std::endl;
}
catch (std::string& s) {
  std::cout << "String : " << s << std::endl;
}
catch (const char* s) {
  std::cout << "String Literal : " << s << std::endl;
}

첫번째 catch 문에서는 char 형 값을, 두 번째에서는 int 형 값을, 세 번째 에서는 string 객체를, 마지막에서는 const char* 형 값을 받게 됩니다. 실제로도 각기 다른 값들을 throw 하였을 때, 작동하는 catch 가 달라지는 것을 확인할 수 있습니다.

또한 한 가지 흥미로운 점은, 기반 클래스와 파생 클래스의 경우 처리하는 방식입니다.

#include <exception>
#include <iostream>

class Parent : public std::exception {
 public:
  virtual const char* what() const noexcept override { return "Parent!\n"; }
};

class Child : public Parent {
 public:
  const char* what() const noexcept override { return "Child!\n"; }
};

int func(int c) {
  if (c == 1) {
    throw Parent();
  } else if (c == 2) {
    throw Child();
  }
  return 0;
}

int main() {
  int c;
  std::cin >> c;

  try {
    func(c);
  } catch (Parent& p) {
    std::cout << "Parent Catch!" << std::endl;
    std::cout << p.what();
  } catch (Child& c) {
    std::cout << "Child Catch!" << std::endl;
    std::cout << c.what();
  }
}

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

Child 도 Parent 에서 catch 됨

와 같이 나옵니다.

이번에는 경우에 따라서 ParentChild 클래스 객체를 리턴합니다. Parent 클래스 객체를 throw 하였을 때에는 예상했던데로 Parent 를 받는 catch 문이 실행되어서 "Parent Catch!" 가 출력되었습니다.

반면에 Child 객체를 throw 하였을 때에는 예상과는 다르게, Child 를 받는 catch 문이 아닌, Parent 를 받는 catch 문이 실행되어서 이 역시 "Parent Catch!" 가 출력되었습니다.

이와 같은 일이 발생한 이유는, catch 문의 경우 가장 먼저 대입될 수 있는 객체를 받는데;

Parent& p = Child();

는 가능하기 때문에 Parent catch 가 먼저 받아버리는 것입니다. 따라서, 위와 같은 문제를 방지하기 위해서는 언제나 Parent catchChild catch 보다 뒤에 써주는 것이 좋습니다. 왜냐하면 이를 통해서 Child 객체가 Parent catch 에 들어가는 것을 막을 수 있고,

Child &c = Parent();  // 오류

위는 성립되지 않기 때문에 Child catchParent 객체가 들어가지도 않습니다. 실제로 예를 보면;

#include <exception>
#include <iostream>

class Parent : public std::exception {
 public:
 
  // what 은 std::exception 에 정의된 함수로, 이 예외가 무엇인지 설명하는 문자열을
  // 리턴하는 함수 입니다.
  virtual const char* what() const noexcept override { return "Parent!\n"; }
};

class Child : public Parent {
 public:
  const char* what() const noexcept override { return "Child!\n"; }
};

int func(int c) {
  if (c == 1) {
    throw Parent();
  } else if (c == 2) {
    throw Child();
  }
  return 0;
}

int main() {
  int c;
  std::cin >> c;

  try {
    func(c);
  } catch (Child& c) {
    std::cout << "Child Catch!" << std::endl;
    std::cout << c.what();
  } catch (Parent& p) {
    std::cout << "Parent Catch!" << std::endl;
    std::cout << p.what();
  }
}

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

Child 가 제대로 처리됨

와 같이 잘 처리됨을 알 수 있습니다.

주의 사항

일반적으로 예외 객체는 std::exception 을 상속 받는 것이 좋습니다. 왜냐하면 표준 라이브러리의 유용한 함수들(nested_exception 등) 을 사용할 수 있기 때문이지요.

모든 예외 받기

만약에 어떤 예외를 throw 하였는데, 이를 받는 catch 가 없다면 어떻게 될까요?

#include <iostream>
#include <stdexcept>

int func() { throw std::runtime_error("error"); }

int main() {
  try {
    func();
  } catch (int i) {
    std::cout << "Catch int : " << i;
  }
}

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

프로그램 비정상적으로 종료!

와 같이 runtime_error 예외를 발생시키며 프로그램이 비정상적으로 종료되었다고 뜨게 됩니다. 따라서, 언제나 예외를 던지는 코드가 있다면 적절하게 받아내는 것이 중요합니다. 하지만, 때로는 예외 객체 하나 하나 처리할 필요 없이 그냥 나머지 전부다! 라고 쓰고 싶을 때가 있습니다. 마치 switch 문의 default 이나 if-else 문에서 마지막 else 와 같이 말입니다.

재미있게도 try .. catch 문에서도 이를 잘 지원합니다.

#include <iostream>
#include <stdexcept>

int func(int c) {
  if (c == 1) {
    throw 1;
  } else if (c == 2) {
    throw "hi";
  } else if (c == 3) {
    throw std::runtime_error("error");
  }
  return 0;
}

int main() {
  int c;
  std::cin >> c;

  try {
    func(c);
  } catch (int e) {
    std::cout << "Catch int : " << e << std::endl;
  } catch (...) {
    std::cout << "Default Catch!" << std::endl;
  }
}

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

예외가 제대로 처리됨

와 같이 나옵니다.

마지막 catch(...) 에서 try 안에서 발생한 모든 예외들을 받게 됩니다. 당연히도, 어떠한 에외도 다 받을 수 있기 때문에 특정한 타입을 찝어서 객체에 대입 시킬 수 는 없겠지요.

주의 사항

템플릿으로 정의되는 클래스의 경우 어떠한 방식으로 템플릿이 인스턴스화 되냐에 따라서 던지는 예외의 종류가 달라질 수 있습니다. 이 때문에 해당 객체의 catch 에서는 모든 예외 객체를 고려해야 합니다.

예외를 발생시키지 않는 함수 - noexcept

만약에 어떤 함수가 예외를 발생시키지 않는다면 noexcept 를 통해 명시할 수 있습니다.

int foo() noexcept {}

foo 함수의 경우 예외를 발생시키지 않으므로 위와 같이 함수 정의 옆에 noexcept 를 넣음으로써 나타낼 수 있습니다. 참고로, 함수에 noexcept 키워드를 붙였다고 해서, 함수가 예외를 절대로 던지지 않는다는 것은 아닙니다. 실제로

#include <iostream>

int foo() noexcept {}

int bar(int x) noexcept { throw 1; }

int main() { foo(); }

이라고 해도 (경고는 뜨지만) 문제 없이 컴파일 합니다. 즉 컴파일러는 noexcept 키워드가 붙은 함수가 이 친구는 예외를 발생시키지 않는구나 라고 곧이곧대로 믿고, 그대로 컴파일 하게 됩니다.

대신 noexcept 로 명시된 함수가 예외를 발생시키게 된다면 예외가 제대로 처리되지 않고 프로그램이 종료됩니다. 예를 들어서

#include <iostream>

int foo() noexcept {}

int bar() noexcept { throw 1; }

int main() {
  foo();
  try {
    bar();
  } catch (int x) {
    std::cout << "Error : " << x << std::endl;
  }
}

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

실행 결과

terminate called after throwing an instance of 'int'
[1]    4331 abort (core dumped)  ./test

와 같이 catch 문에서 예외가 제대로 처리되지 않고 프로그램이 종료됨을 알 수 있습니다.

그렇다면 이 noexcept 키워드를 왜 붙이는 것일까요? 이는 단순히 프로그래머가 컴파일러에게 주는 힌트라고 생각하시면 됩니다. 컴파일러가 어떤 함수가 절대로 예외를 발생시키지 않는다는 사실을 안다면, 여러가지 추가적인 최적화를 수행할 수 있습니다.

주의 사항

C++ 11 에서 부터 소멸자들은 기본적으로 noexcept 입니다. 절대로 소멸자에서 예외를 던지면 안됩니다.

자 그럼 이것으로 C++ 에서의 예외 처리에 관한 강좌를 마치도록 하겠습니다. C++ 스타일 예외 처리를 통해 좀 더 안정적인 프로그램을 만들 수 있습니다!

생각 해보기

문제 1

C++ 표준 라이브러리에 자주 사용할만한 예외 객체들이 정의가 되어있습니다. 여기 를 참고해서 읽어보세요!

강좌를 보다가 조금이라도 궁금한 것이나 이상한 점이 있다면 꼭 댓글을 남겨주시기 바랍니다. 그 외에도 강좌에 관련된 것이라면 어떠한 것도 질문해 주셔도 상관 없습니다. 생각해 볼 문제도 정 모르겠다면 댓글을 달아주세요.

현재 여러분이 보신 강좌는 <씹어먹는 C ++ - <11. C++ 에서 예외 처리>> 입니다. 이번 강좌의 모든 예제들의 코드를 보지 않고 짤 수준까지 강좌를 읽어 보시기 전까지 다음 강좌로 넘어가지 말아주세요
댓글이 21 개 있습니다!
프로필 사진 없음
강좌에 관련 없이 궁금한 내용은 여기를 사용해주세요

    댓글을 불러오는 중입니다..