Successfully reported this slideshow.
We use your LinkedIn profile and activity data to personalize ads and to show you more relevant ads. You can change your ad preferences anytime.

[TechDays Korea 2015] 녹슨 C++ 코드에 모던 C++로 기름칠하기

기존에 작성해 놓은 C++ 코드에 모던 C++를 적용하기는 쉽지 않습니다. 막상 개선하려고 마음먹었다고 해도, 어디서부터 바꿔야 할 지 막막하기만 합니다. 이 세션에서는 기존 C++ 코드에서 모던 C++를 적용해 프로그램의 구조와 성능을 개선하는 방법에 대해서 설명합니다. 그리고 기존 C++ 코드에 모던 C++를 적용할 때 주의해야 될 점에 대해서도 살펴봅니다.

  • Login to see the comments

[TechDays Korea 2015] 녹슨 C++ 코드에 모던 C++로 기름칠하기

  1. 1. 옥찬호 / 넥슨 (NEXON KOREA), Visual C++ MVP 녹슨 C++ 코드에 모던 C++로 기름칠하기
  2. 2. 시작하기 전에… • 모던 C++이란 C++11/14를 말합니다. • C++11/14을 통한 개선 뿐만 아니라 기존 C++을 통한 개선 방법도 함께 포함합니다. • 모던 C++을 모르는 분들을 위해 최대한 쉽게 설명합니다. • 예제 코드가 많은 부분을 차지합니다.
  3. 3. 녹슨 C++ 코드란?
  4. 4. int _output( FILE* stream, char const* format, va_list arguments ) { // ... }
  5. 5. #ifdef _UNICODE int _woutput ( #else /* _UNICODE */ int _output ( #endif /* _UNICODE */ FILE* stream, _TCHAR const* format, va_list arguments ) { // ... }
  6. 6. #ifdef _UNICODE #ifdef POSITIONAL_PARAMETERS int _woutput_p ( #else /* POSITIONAL_PARAMETERS */ int _woutput ( #endif /* POSITIONAL_PARAMETERS */ #else /* _UNICODE */ #ifdef POSITIONAL_PARAMETERS int _output_p ( #else /* POSITIONAL_PARAMETERS */ int _output ( #endif /* POSITIONAL_PARAMETERS */ #endif /* _UNICODE */ FILE* stream, _TCHAR const* format, va_list arguments ) { ... }
  7. 7. IFileDialog *pfd = NULL; HRESULT hr = CoCreateInstance(CLSID_FileOpenDialog, NULL, CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&pfd)); if (SUCCEEDED(hr)) { IFileDialogEvents *pfde = NULL; hr = CDialogEventHandler_CreateInstance(IID_PPV_ARGS(&pfde)); if (SUCCEEDED(hr)) { DWORD dwCookie; hr = pfd->Advise(pfde, &dwCookie); if (SUCCEEDED(hr)) { DWORD dwFlags; hr = pfd->GetOptions(&dwFlags); if (SUCCEEDED(hr)) { hr = pfd->SetOptions(dwFlags | FOS_FORCEFILESYSTEM); if (SUCCEEDED(hr)) { hr = pfd->SetFileTypes(ARRAYSIZE(c_rgSaveTypes), c_rgSaveTypes); if (SUCCEEDED(hr)) { hr = pfd->SetFileTypeIndex(INDEX_WORDDOC); if (SUCCEEDED(hr)) { hr = pfd->SetDefaultExtension(L"doc;docx"); if (SUCCEEDED(hr)) {
  8. 8. 고치고 싶다… 하지만 • 이미 고치기엔 길어져버린 코드 • 어디서부터 손을 써야 할 지 모름 • 코드는 점점 산으로… • 아 귀찮다… ㅁㄴㅇㄹ
  9. 9. 어디에 기름칠을 해볼까? • 전처리기 • 리소스 관리 • 함수 • 타입, 반복문 • 기타 등등…
  10. 10. 전처리기
  11. 11. 조건부 컴파일 • #if, #ifdef, #ifndef, #elif, #else, … • 많이 쓸수록 복잡해진다. • 많이 쓸수록 이해하기 어렵다. • 많이 쓸수록 유지보수하기 어렵다.
  12. 12. #ifdef _UNICODE int _woutput ( #else /* _UNICODE */ int _output ( #endif /* _UNICODE */ FILE* stream, _TCHAR const* format, va_list arguments ) { // ... }
  13. 13. template <typename T> static int common_output( FILE* stream, T const* format, va_list arguments ) { // ... } int _output(FILE* stream, char const* format, va_list const arguments) { return common_output(stream, format, arguments); } int _woutput(FILE* stream, wchar_t const* format, va_list const arguments) { return common_output(stream, format, arguments); }
  14. 14. // Check windows #if _WIN32 || _WIN64 #if _WIN64 #define ENVIRONMENT64 #else #define ENVIRONMENT32 #endif #endif // Check GCC #if __GNUC__ #if __x86_64__ || __ppc64__ #define ENVIRONMENT64 #else #define ENVIRONMENT32 #endif #endif
  15. 15. 케이스 바이 케이스 • 타입에 따른 조건부 컴파일은 함수 템플릿을 통해 개선한다. • 하지만 #ifdef를 사용해야 되는 경우도 있다. • 32비트 vs 64비트 코드 • DEBUG 모드 vs Non-DEBUG 모드 • 컴파일러, 플랫폼, 언어에 따라 다른 코드 • 반드시 사용해야 된다면, 코드를 단순화하는 것이 좋다. • 중첩 #ifdef를 피하고, 함수의 일부를 조건부 컴파일에 넣지 않도록 한다.
  16. 16. 매크로 • #define … • 변수 대신 사용하는 매크로 : #define RED 1 • 함수 대신 사용하는 매크로 : #define SQUARE(x) ((x) * (x)) • 수많은 문제를 일으키는 장본인 • 컴파일러가 타입에 대한 정보를 갖기 전에 계산됨 • 필요 이상으로 많이 사용
  17. 17. 변수 대신 사용하는 매크로
  18. 18. #define red 0 #define orange 1 #define yellow 2 #define green 3 #define blue 4 #define purple 5 #define hot_pink 6 void f() { unsigned orange = 0xff9900; } warning C4091: '' : ignored on left of 'unsigned int' when no variable is declared error C2143: syntax error : missing ';' before 'constant' error C2106: '=' : left operand must be l-value
  19. 19. #define red 0 #define orange 1 #define yellow 2 #define green 3 #define blue 4 #define purple 5 #define hot_pink 6 void f() { unsigned 2 = 0xff00ff; } warning C4091: '' : ignored on left of 'unsigned int' when no variable is declared error C2143: syntax error : missing ';' before 'constant' error C2106: '=' : left operand must be l-value
  20. 20. #define RED 0 #define ORANGE 1 #define YELLOW 2 #define GREEN 3 #define BLUE 4 #define PURPLE 5 #define HOT_PINK 6 void g(int color); // valid values are 0 through 6 void f() { g(HOT_PINK); // Ok g(9000); // Not ok, but compiler can’t tell }
  21. 21. enum color_type { red = 0, orange = 1, yellow = 2, green = 3, blue = 4, purple = 5, hot_pink = 6 };
  22. 22. enum color_type { red, orange, yellow, green, blue, purple, hot_pink }; void g(color_type color); void f() { g(hot_pink); // Ok g(9000); // Not ok, compiler will report error } error C2664: 'void g(color_type)' : cannot convert argument 1 from 'int' to 'color_type'
  23. 23. enum color_type { red, orange, yellow, green, blue, purple, hot_pink }; void f() { int x = red; // Ugh int x = red + orange; // Double ugh }
  24. 24. enum color_type { red, orange, yellow, green, blue, purple, hot_pink }; enum traffic_light_state { red, yellow, green }; error C2365: 'red' : redefinition; previous definition was 'enumerator‘ error C2365: 'yellow' : redefinition; previous definition was 'enumerator‘ error C2365: 'green' : redefinition; previous definition was 'enumerator'
  25. 25. 열거체의 문제점 • 묵시적인 int 변환 • 열거체의 타입을 명시하지 못함 • 이상한 범위 적용 → 열거체 클래스(enum class)의 등장!
  26. 26. enum class color_type { red, orange, yellow, green, blue, purple, hot_pink }; void g(color_type color); void f() { g(color_type::red); }
  27. 27. enum class color_type { red, orange, yellow, green, blue, purple, hot_pink }; void g(color_type color); void f() { int x = color_type::hot_pink; } error C2440: 'initializing' : cannot convert from 'color_type' to 'int'
  28. 28. enum class color_type { red, orange, yellow, green, blue, purple, hot_pink }; void g(color_type color); void f() { int x = static_cast<int>(color_type::hot_pink); }
  29. 29. 열거체 클래스를 사용하자 • 묵시적인 int 변환 → 명시적인 int 변환 • 열거체의 타입을 명시하지 못함 → 타입 명시 가능 • 이상한 범위 적용 → 범위 지정 연산자를 통해 구분
  30. 30. 함수 대신 사용하는 매크로
  31. 31. #define make_char_lowercase(c) ((c) = (((c) >= 'A') && ((c) <= 'Z')) ? ((c) - 'A' + 'a') : (c)) void make_string_lowercase(char* s) { while (make_char_lowercase(*s++)) ; }
  32. 32. #define make_char_lowercase(c) ((c) = (((c) >= 'A') && ((c) <= 'Z')) ? ((c) - 'A' + 'a') : (c)) void make_string_lowercase(char* s) { while (((*s++) = (((*s++) >= 'A') && ((*s++) <= 'Z')) ? ((*s++) - 'A' + 'a') : (*s++))) ; }
  33. 33. // Old, ugly macro implementation: #define make_char_lowercase(c) ((c) = (((c) >= 'A') && ((c) <= 'Z')) ? ((c) - 'A' + 'a') : (c)) // New, better function implementation: inline char make_char_lowercase(char& c) { if (c > 'A' && c < 'Z') { c = c - 'A' + 'a'; } return c; }
  34. 34. 열거체, 함수를 사용하자 • 변수 대신 사용하는 매크로에는 열거체를 사용하자. • 열거체에서 발생할 수 있는 문제는 enum class로 해결할 수 있다. • 열거체 대신 ‘static const’ 변수를 사용하는 방법도 있다. • 함수 대신 사용하는 매크로에는 함수를 사용하자. • 읽기 쉽고, 유지보수하기 쉽고, 디버깅하기 쉽다. • 성능에 따른 오버헤드도 없다.
  35. 35. 리소스 관리
  36. 36. IFileDialog *pfd = NULL; HRESULT hr = CoCreateInstance(CLSID_FileOpenDialog, NULL, CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&pfd)); if (SUCCEEDED(hr)) { IFileDialogEvents *pfde = NULL; hr = CDialogEventHandler_CreateInstance(IID_PPV_ARGS(&pfde)); if (SUCCEEDED(hr)) { DWORD dwCookie; hr = pfd->Advise(pfde, &dwCookie); if (SUCCEEDED(hr)) { DWORD dwFlags; hr = pfd->GetOptions(&dwFlags); if (SUCCEEDED(hr)) { hr = pfd->SetOptions(dwFlags | FOS_FORCEFILESYSTEM); if (SUCCEEDED(hr)) { hr = pfd->SetFileTypes(ARRAYSIZE(c_rgSaveTypes), c_rgSaveTypes); if (SUCCEEDED(hr)) { hr = pfd->SetFileTypeIndex(INDEX_WORDDOC); if (SUCCEEDED(hr)) { hr = pfd->SetDefaultExtension(L"doc;docx"); if (SUCCEEDED(hr)) {
  37. 37. IFileDialog *pfd = NULL; HRESULT hr = CoCreateInstance(CLSID_FileOpenDialog, NULL, IID_PPV_ARGS(&pfd)); if (FAILED(hr)) return hr; IFileDialogEvents *pfde = NULL; hr = CDialogEventHandler_CreateInstance(IID_PPV_ARGS(&pfde)); if (FAILED(hr)) return hr; DWORD dwCookie; hr = pfd->Advise(pfde, &dwCookie); if (FAILED(hr)) return hr; DWORD dwFlags; hr = pfd->GetOptions(&dwFlags); if (FAILED(hr)) return hr;
  38. 38. } psiResult->Release(); } } } } } } } pfd->Unadvise(dwCookie); } pfde->Release(); } pfd->Release(); } return hr;
  39. 39. void ExampleWithoutRAII() { std::FILE* file_handle = std::fopen("logfile.txt", "w+"); if (file_handle == nullptr) throw std::runtime_error("File couldn't open!"); try { if (std::fputs("Hello, Log File!", file_handle) == EOF) throw std::runtime_error("File couldn't write!"); // continue writing to logfile.txt ... do not return // prematurely, as cleanup happens at the end of this function } catch (...) { std::fclose(file_handle); throw; } std::fclose(file_handle); }
  40. 40. RAII • 자원 획득은 초기화다 (Resource Acquisition Is Initialization) • 객체의 생성에 맞춰 메모리와 시스템 리소스를 자동으로 할당 • 객체의 소멸에 맞춰 메모리와 시스템 리소스를 자동으로 해제 → 생성자 안에서 리소스를 할당하고, 소멸자에서 리소스를 해제
  41. 41. void ExampleWithRAII() { // open file (acquire resource) File logFile("logfile.txt"); logFile.Write("Hello, Log File!"); // continue writing to logfile.txt ... } File::File(const char* filename) : m_file_handle(std::fopen(filename, "w+")) { if (m_file_handle == NULL) throw openError(); } File::~File() { std::fclose(m_file_handle); }
  42. 42. void ExampleWithRAII() { // open file (acquire resource) File* logFile = new File("logfile.txt"); logFile->Write("Hello, Log File!"); // continue writing to logfile.txt ... } File::File(const char* filename) : m_file_handle(std::fopen(filename, "w+")) { if (m_file_handle == NULL) throw openError(); } File::~File() { std::fclose(m_file_handle); }
  43. 43. 다시 발생하는 문제 • 파일 입출력과 관련한 예외 처리를 간편하게 하기 위해 File 클래스를 만들어 생성자와 소멸자로 처리했다. • 하지만, 정작 File 클래스를 동적으로 할당하는 경우 소멸자가 호출되지 않아 파일을 닫지 않는 문제가 발생한다. • 좋은 방법이 없을까? → 스마트 포인터(Smart Pointer)의 등장!
  44. 44. 스마트 포인터 • 좀 더 똑똑한 포인터 • 스마트 포인터를 사용하면 명시적으로 해제할 필요가 없다. • 사용하는 이유 • 적은 버그, 자동 청소, 자동 초기화 • Dangling 포인터 발생 X, Exception 안전 • 효율성
  45. 45. void ExampleWithRAII() { // open file (acquire resource) std::unique_ptr<File> logFile = std::make_unique<File>("logfile.txt"); logFile->Write("Hello, Log File!"); // continue writing to logfile.txt ... } File::File(const char* filename) : m_file_handle(std::fopen(filename, "w+")) { if (m_file_handle == NULL) throw openError(); } File::~File() { std::fclose(m_file_handle); }
  46. 46. 스마트 포인터의 종류 • 경우에 따라 여러 종류의 스마트 포인터를 사용할 수 있다. • shared_ptr : 객체의 소유권을 복사할 수 있는 포인터 (여러 shared_ptr 객체가 같은 포인터 객체를 가리킬 수 있음) • unique_ptr : 객체의 소유권을 복사할 수 없는 포인터 (하나의 unique_ptr 객체만이 하나의 포인터 객체를 가리킬 수 있음)
  47. 47. std::unique_ptr ptrA Song 개체 ptrA Song 개체 ptrB auto ptrA = std::make_unique<Song>(L"Diana Krall", L"The Look of Love"); auto ptrB = std::move(ptrA);
  48. 48. std::unique_ptr<Song> SongFactory(const std::wstring& artist, const std::wstring& title) { // Implicit move operation into the variable that stores the result. return std::make_unique<Song>(artist, title); } void MakeSongs() { // Create a new unique_ptr with a new object. auto song = std::make_unique<Song>(L"Mr. Children", L"Namonaki Uta"); // Use the unique_ptr. std::vector<std::wstring> titles = { song->title }; // Move raw pointer from one unique_ptr to another. std::unique_ptr<Song> song2 = std::move(song); // Obtain unique_ptr from function that returns by value. auto song3 = SongFactory(L"Michael Jackson", L"Beat It"); }
  49. 49. std::shared_ptr MyClass 제어 블록 참조 개수 = 1 개체에 대한 포인터 제어 블록에 대한 포인터 p1
  50. 50. std::shared_ptr MyClass 제어 블록 참조 개수 = 2 개체에 대한 포인터 제어 블록에 대한 포인터 p1 개체에 대한 포인터 제어 블록에 대한 포인터 p2
  51. 51. // Use make_shared function when possible. auto sp1 = std::make_shared<Song>(L"The Beatles", L"Im Happy Just to Dance With You"); // Ok, but slightly less efficient. // Note: Using new expression as constructor argument // creates no named variable for other code to access. std::shared_ptr<Song> sp2(new Song(L"Lady Gaga", L"Just Dance")); // When initialization must be separate from declaration, e.g. class members, // initialize with nullptr to make your programming intent explicit. std::shared_ptr<Song> sp5(nullptr); //Equivalent to: shared_ptr<Song> sp5; //... sp5 = std::make_shared<Song>(L"Elton John", L"I'm Still Standing");
  52. 52. 리소스 관리는 스마트 포인터로 • RAII를 사용하자! • 읽고, 쓰고, 유지보수하기 쉽다. • 자원 관리에 대한 걱정을 할 필요가 없다. • C++ 코드 품질을 향상시키는 가장 쉬운 방법! • 기왕이면 스마트 포인터로! • shared_ptr • unique_ptr
  53. 53. 함수
  54. 54. std::vector<int>::const_iterator iter = cardinal.begin(); std::vector<int>::const_iterator iter_end = cardinal.end(); int total_elements = 1; while (iter != iter_end) { total_elements *= *iter; ++iter; }
  55. 55. template <typename T> struct product { product(T& storage) : value(storage) {} template<typename V> void operator()(V& v) { value *= v; } T& value; }; std::vector<int> cardinal; int total_elements = 1; for_each(cardinal.begin(), cardinal.end(), product<int>(total_elements));
  56. 56. int total_elements = 1; for_each(cardinal.begin(), cardinal.end(), [&total_elements](int i) { total_elements *= i; });
  57. 57. struct mod { mod(int m) : modulus(m) {} int operator()(int v) { return v % modulus; } int modulus; }; int my_mod = 8; std::transform(in.begin(), in.end(), out.begin(), mod(my_mod)); int my_mod = 8; transform(in.begin(), in.end(), out.begin(), [my_mod](int v) -> int { return v % my_mod; }); Functor Lambda Expression
  58. 58. 람다식 [my_mod] (int v) -> int { return v % my_mod; } 개시자 (Introducer Capture) 인자 (Arguments) 반환 타입 (Return Type) 함수의 몸통 (Statement)
  59. 59. int x = 10, y = 20; [] {}; // capture 하지 않음 [x] (int arg) { return x; }; // value(Copy) capture x [=] { return x; }; // value(Copy) capture all [&] { return y; }; // reference capture all [&, x] { return y; }; // reference capture all except x [=, &y] { return x; }; // value(Copy) capture all except y [this] { return this->something; }; // this capture [=, x] {}; // error [&, &x] {}; // error [=, this] {}; // error [x, x] {}; // error
  60. 60. 1 2 2 void fa(int x, function<void(void)> f) { ++x; f(); } void fb(int x, function<void(int)> f) { ++x; f(x); } void fc(int &x, function<void(void)> f) { ++x; f(); } int x = 1; fa(x, [x] { cout << x << endl; }); fb(x, [](int x) { cout << x << endl; }); fc(x, [&x] { cout << x << endl; });
  61. 61. WNDCLASSEX wcex; wcex.lpfnWndProc= [](HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) -> LRESULT { switch (message) { case WM_COMMAND: EnumWindows([](HWND hwnd, LPARAM lParam) -> BOOL { char szText[256]; GetWindowTextA(hwnd, szText, 256); cout << szText << endl; return TRUE; }, 0);
  62. 62. HANDLE hT = CreateThread(NULL, 0, [](LPVOID lpThreadParameter) -> DWORD { for (int i = 0; i < 1000; i++) { this_thread::sleep_for(milliseconds{ 10 }); cout << i << endl; } return 0; }, NULL, 0, NULL);
  63. 63. 람다식을 사용하자 • 짧고, 간결하고, while 문과 같은 행사 코드 없이 깔끔하게 작성할 수 있다. • 수십줄의 코드를 1~2줄로 간추릴 수 있다. • Functor, Callback Function을 대체해서 사용할 수 있다. • 반복적으로 사용하는 함수가 아니라면 람다식을 사용하자!
  64. 64. 간단하게 적용 가능한 기능들
  65. 65. auto 키워드 • 컴파일 타임에 타입을 추론해 어떤 타입인지 결정한다. • 컴파일 타임에 추론이 불가능하다면, 오류가 발생한다. std::vector<std::tuple<std::string, int, double>> vStudents; for (std::vector<std::tuple<std::string, int, double>>::iterator iter = vStudents.begin(); iter != vStudents.end(); ++iter) { … } std::vector<std::tuple<std::string, int, double>> vStudents; for (auto iter = vStudents.begin(); iter != vStudents.end(); ++iter) { … }
  66. 66. 범위 기반 for문 int arr[] = { 1, 2, 3, 4, 5 }; for (int i = 0; i < 5; ++i) std::cout << arr[i] << std::endl; return 0; } int arr[] = { 1, 2, 3, 4, 5 }; for (auto& i : arr) std::cout << i << std::endl; return 0; }
  67. 67. 정리 // circle and shape are user-defined types circle* p = new circle(42); vector<shape*> v = load_shapes(); for (vector<circle*>::iterator i = v.begin(); i != v.end(); ++i) { if (*i && **i == *p) cout << **i << " is a matchn"; } for (vector<circle*>::iterator i = v.begin(); i != v.end(); ++i) { delete *i; // not exception safe } delete p;
  68. 68. 정리 // circle and shape are user-defined types auto p = make_shared<circle>(42); vector<shared_ptr<shape>> v = load_shapes(); for_each(begin(v), end(v), [&](const shared_ptr<shape>& s) { if (s && *s == *p) cout << *s << " is a matchn"; });
  69. 69. 정리 • 대체할 수 있는 조건부 컴파일은 템플릿으로 기름칠! • 매크로는 가급적 사용하지 말고 열거체와 함수로 기름칠! • 리소스 관리에는 RAII, 기왕이면 스마트 포인터로 기름칠! • 일회성으로 사용하는 함수는 람다식으로 기름칠! • 복잡한 타입에는 auto로 기름칠! • 반복 횟수에 고통받지 말고 범위 기반 for문으로 기름칠!
  70. 70. 정리 • 모던 C++을 통해 대체할 수 있는 코드는 많습니다! (하지만 제한된 시간으로 인해 …) • 다음 사이트에서 모던 C++ 예제 코드를 확인하실 수 있습니다. http://www.github.com/utilForever/ModernCpp • C++ 핵심 가이드라인 • 영문 : https://github.com/isocpp/CppCoreGuidelines • 한글 : https://github.com/CppKorea/CppCoreGuidelines
  71. 71. Quiz
  72. 72. #include <iostream> #include <memory> #include <vector> class C { public: void foo() { std::cout << "A"; } void foo() const { std::cout << "B"; } }; struct S { std::vector<C> v; std::unique_ptr<C> u; C* const p; S() : v(1), u(new C()), p(u.get()) {} }; Quiz #1 int main() { S s; const S& r = s; s.v[0].foo(); s.u->foo(); s.p->foo(); r.v[0].foo(); r.u->foo(); r.p->foo(); } AAABAA
  73. 73. #include <iostream> struct A { A() { std::cout << "A"; } A(const A& a) { std::cout << "B"; } virtual void f() { std::cout << "C"; } }; int main() { A a[2]; for (auto x : a) { x.f(); } } Quiz #2 AABCBC
  74. 74. #include <iostream> struct A { A() { std::cout << "A"; } A(const A& a) { std::cout << "B"; } virtual void f() { std::cout << "C"; } }; int main() { A a[2]; for (auto& x : a) { x.f(); } } Quiz #3 AACC
  75. 75. C++ Korea • http://www.facebook.com/groups/cppkorea http://www.github.com/cppkorea
  76. 76. 감사합니다. • MSDN Forum http://aka.ms/msdnforum • TechNet Forum http://aka.ms/technetforum
  77. 77. http://aka.ms/td2015_again TechDays Korea 2015에서 놓치신 세션은 Microsoft 기술 동영상 커뮤니티 Channel 9에서 추후에 다시 보실 수 있습니다.

×