본문 바로가기
Programming/C++

C++ 언어 기초 (10) - 연산자 다중정의 II

by 롱일스 2020. 9. 3.
반응형

▣ 대입 및 이동 대입 연산자

 ●  대입 연산자( = )란?

  ▷ 묵시적 대입 연산자: 오른쪽 피연산자 데이터 멤버를 왼쪽 피연산자에 그대로 복사

  ▷ 객체에 동적할당된 메모리를 가리키는 포인터가 포함되어 있으면
     -> 얕은 복사로 인해 의도하지 않은 공유 상태의 문제가 발생할 수 있다.
     -> 깊은 복사를 할 수 있게 대입 연산자를 다중정의 해야 한다.

 ●  대입 연산자 다중정의 예시

  ▷ VecF 클래스

VecF& VecF::operator=(const VecF& fv) {  //VecF& 참조로 반환한다.
    if (n != fv.n) {		//벡터의 크기가 다르면
        delete[] arr;		//기존 메모리 반환
        arr = new float[n = fv.n];	//새로 메모리 할당
    }
    
    memcpy(arr, fv.arr, sizeof(float)*n);  //fv.arr을 arr에 데이터 복사
    return *this;
}

 

 ●  이동 대입 연산자 다중정의

  ▷ 이동 대입 연산자

  •    왼쪽 피연산자에 대입할 오른쪽 피연산자가 r-value일 때 사용된다.
  •    대입 후에 오른쪽 피연산자의 내용이 더 이상 필요 없는 상황에 사용한다.
  •    오른쪽 피연산자의 내용을 왼쪽 피연산자로 "이동"해서 불필요한 복사를 안하게 해서 효율성이 높아진다.

 ●  이동 대입 연산자 다중정의 예시

  ▷  VecF 클래스

VecF& VecF::operator=(VecF&& fv) {  //r-value 참조 전달
   delete[] arr;    // 기존 메모리 반환
   n = fv.n;      //오른쪽 피연산자 내용을 이동시킨다
   arr = fv.arr;   //포인터만 복사 (얕은 복사)
   fv.arr = nullptr;   //fv.arr 은 아무것도 안가리키게 한다.
   return *this;
}

 

 ●  대입 & 이동 대입 연산자 활용 예시

  ▷  VFMain3.cpp

int main()
{
    float a[3] = {1,2,3};
    float b[3] = {2,3,4};
    VecF v1(3,a);
    VecF v2(3,b);
    VecF v3(3);
    /* 대입 연산자 */
    v3 = v1;
    cout << v3 << endl;
    /* 이동 대입 연산자 */
    v3 = v1.add(v2);
    cout << v1 << " + " << v2 << " = ";
    cout << v3 << endl;
    return 0;
}
  • v1은 l-value이므로 "v3 = v1"에서 대입 연산자가 동작하고 v3에 v1 값만 복사된다.
  • "v3 = v1.add(v2)"에서 v1.add(v2)는 v3에 값을 전달하고 나면 필요가 없어지는 객체이므로 r-value다. 따라서 v3에 값을 이동하는 이동 대입 연산자를 이용한다.
  • 불필요한 복사를 피하면서 값을 이동만 시켜서 효율을 높일 수 있다. 
  • C++ Shell에서 실행 가능하게 하나의 코드로 합친 파일을 첨부하였다.

MainVecF.cpp
0.00MB
실행 결과

 

●  std::move 함수 활용

  ▷  두 VecF 객체를 교환하는 함수 구현 -1 

void swapVecF(VecF& V1, VecF& v2) {
   VecF tmp(v1);  // 복사 생성자
   v1 = v2;      // 대입 연산자
   v2 = tmp;     // 대입 연산자
}
  • "VecF tmp(v1);"에서 v1은 l-value이므로 복사 생성자가 동작하여 tmp에는 vec1의 데이터가 깊은 복사된다.
  • v2도 l-value이기 때문에 "v1 = v2;"에서 대입 연산자가 동작하고 깊은 복사된다.
  • tmp도 l-value이기 때문에 "v2=tmp;"에서 대입 연산자가 동작한다.
  • 함수를 빠져나오면서 tmp는 쓸모 없어지기 때문에 delete에 의해 메모리가 소멸된다.
int main()
   float a[3] = {1, 2, 3};
   float b[3] = {2, 3, 4};
   VecF vec1(3, a), vec2(3, b);
   swapVecF(vec1, vec2);
   ...
}

 

 ▷  std::move 함수

  • 인수로 전달되는 객체의 r-value 참조를 반환한다.
VecF tmp = std::move(v1);
  • v1의 r-value 참조를 구해서 tmp를 초기화한다.
  • 이동 생성자를 이용해서 tmp를 생성한다.
v1 = std::move(v2);
  • v2의 r-value 참조를 구해서 v1에 대입한다.
  • 이동 대입 연산자가 실행된다.

  ▷  std::move 함수 활용

void swapVecF(VecF& v1, VecF& V2) {
   VecF tmp = move(v1);
   v1 = move(v2);
   v2 = move(tmp);
}
int main() {
   float a[3] = {1, 2, 3};
   float b[3] = {2, 3, 4};
   VecF vec1(3, a), vec2(3, b);
   swapVecF(vec1, vec2);
   ...
}

 

  • VecF tmp = move(v1);

이동 생성자에 의해 tmp가 vec1의 arr을 가리킨다.

이동 생성자에 의해 tmp가 vec1의 arr을 가리킨다.

move 함수가 종료되면서 vec1의 arr은 아무것도 가리키지 않는다.

move 함수가 종료되면서 vec1의 arr은 아무것도 가리키지 않는다

  • v1 = move(v2);

이동 대입 연산자에 의해 vec1은 vec2의 데이터를 가리킨다.

이동 대입 연산자에 의해 vec1은 vec2의 데이터를 가리킨다.

move 함수가 종료되면서 vec2의 arr은 아무것도 가리키지 않는다.

move 함수가 종료되면서 vec2의 arr은 아무것도 가리키지 않는다.

  • v2 = move(tmp);

이동 대입 연산자에 의해 vec2는 tmp의 데이터를 가리킨다.

이동 대입 연산자에 의해 vec2는 tmp의 데이터를 가리킨다.

move 함수가 종료되면서 tmp의 arr은 아무것도 가리키지 않는다.

move 함수가 종료되면서 tmp의 arr은 아무것도 가리키지 않는다.

swapVecF 함수가 끝나면서 소멸자에 의해 tmp 객체가 소멸된다.

swapVecF 함수가 끝나면서 소멸자에 의해 tmp 객체가 소멸된다.

  ☞ 결국엔 vec1과 vec2가 가리키는 데이터가 교환된다. 교환 과정에서 arr 메모리의 내용을 복사하지 않고 포인터를 통해 객체의 내용만 교환했기 때문에 효율적이다. 이동 대입 연산자의 효과다.

 

 

▣ [ ] 연산자 다중정의

 ●  예시 - SafeIntArray 클래스

  ▷ 요구사항

  • 배열처럼 지정된 개수의 int 값을 저장할 수 있다.
    SafeIntArray a(10); --> int값 10개를 저장하는 객체
  • 각각의 값들은 0번부터 시작하는 일련번호를 첨자로 지정하여 접근한다.
    a[5] = 10; --> 6번째 위치에 10을 저장
  • 첨자가 지정된 범위를 벗어나면 오류 메시지를 출력하고 프로그램 종료한다.
    cout << a[11];

  ▷ [ ] 연산자 

  • 배열의 첨자를 지정하는 이항 연산자
  • 피연산자: 배열과 첨자

  ▷ 데이터 저장을 위해 사용하는 [ ] 연산자

   SafeIntArray a(10);
   a[5] = 10;

int& SafeIntArray::operator[](int i){
   .... *this
}
  • int& : a[5]에 값 10을 넣기 위해 참조를 반환해야 한다.
  • *this: a[ ]
  • int i:  a[5]에서 5에 해당된다.

  ▷ const 객체를 위한 [ ] 연산자

   데이터를 읽기만 할 수 있도록 [ ] 연산자를 정의함

void f(const SafeIntArray& x) {     //상수 참조 -> x의 값을 수정하면 안된다.
   for (int i=0; i < x.size(); i++)  //size: 배열의 크기
     cout << x[i] << endl;
}
int& SafeIntArray::operator[](int i) {
   ...
}

객체 x는 상수참조 [ ] 연산자는 일반멤버로 정의되어 있다. [ ]는 x의 값을 수정할 수 있는 연산자라는 뜻이다. 그래서 오류가 발생한다.

int SafeIntArray::operator[](int i) const{
   ...
}

참조를 그대로 반환하면 상수라는 처리에 위반되도록 값이 바뀔 수 있으므로, 참조를 반환하지 말고 int형으로 반환한다. 하지만 데이터의 양이 많을 때는 상수참조형을 반환해도 된다.

  ▷ SafeIntArray.h

#include <iostream>
class SafeIntArray {
  int limit;   //원소 개수
  int *arr;    // 데이터 저장공간
public:
  SafeIntArray(int n) : limit(n) {
    arr = new int[n];    //공간 할당
  }
  
  ~SafeIntArray() {
    delete[] arr;
  }
  
  int size() const { return limit; }
  
  //i번 원소 반환하는 멤버함수(int형 참조)
  int& operator[](int i) {  //첨자의 범위가 올바른지 확인
    if (i < 0 || i >= limit) {
      std::cout << "첨자가 범위를 벗어나서 프로그램이 종료됩니다.";
      exit(EXIT_FAILURE);  //EXIT_FAILURE은 return값을 1로 반환하며 프로그램이 종료되게 만든다.
    } 
    return arr[i];  //i번 원소 반환
  }
  
  int operator[](int i) {  //상수 반환형식
    if (i < 0 || i >= limit) {
      std::cout << "첨자가 범위를 벗어나서 프로그램이 종료됩니다.";
      exit(EXIT_FAILURE);  
    } 
    return arr[i];  //i번 원소 반환
  }
  
};

 

 ▷ SafeIntArray.h

#include <iostream>
#include "SafeIntArray.h"
using namespace std;

int main()
{
   SafeIntArray a(10);   //10개의 원소를 갖는 객체 생성
   
   for (int i=0; i<10; i++)
     a[i] = i;
   cout << a[5] << endl;    //올바른 범위의 원소 접근
   cout << a[12] << endl;   // 범위를 벗어난 접근
   return 0;
}

 

 

▣ 문자열 클래스

 ●  C 스타일 문자열

  ▷ 문자열 저장하기

  •   문자열의 끝을 알리기 위해 널(NULL) 문자 ('\0')을 사용한다. --> Null-terminated string
  •   문자열을 저장하기 위한 char형 배열

  • 문자열 리터럴

문자열 리터럴이기 때문에 그냥 문자 포인터로는 가리킬 수 없고, 상수 문자 포인터로 가리키게 할 수 있다.

 

  ▷ C 스타일 문자열 처리 함수(헤더파일: #include <cstring>)

  •  문자열 길이 구하기 (strlen 함수)
size_t strlen(const char* str);
n = strlen("abcde");  // n = 5

 

  • MS Visual C++에서는 보안 개선 함수를 제공한다.
size_t strnlen_s(const char* str, size_t nE1);  
//size_t nE1으로 문자열 버퍼 크기를 지정해서 이 크기를 넘어가면 에러가 발생하도록 한다.

char str[10] = "abcde";
n = strnlen_s(str, 10);

 

  • 문자열 복사 (strcpy 함수)
char* strcpy(char* strDestination, const char* strSource);

char sample[20] = "HELLO";
strcpy(sample, "ICE");

문자열을 복사하면 복사한 문자열의 NULL(\0) 문자를 만나면 문자열의 끝이라고 인식하기 때문에 ICE만 출력되고 뒤에 O는 출력되지 않는다.

+) 대입 명령으로 배열에 문자열 리터럴을 넣는 것을 불가능하다.

sample = "ICE";  //불가능

sample은 배열의 이름이고 포인터를 저장하는 건데 포인터의 값을 직접적으로 바꾸는 것은 불가능하다.

  • MS Visual C++의 보안 개선 함수
errno_t strcpy_s(char* strD, size_t nE1, const char* strS);
//목적지의 크기를 미리 size_t nE1로 지정해서 그 크기를 넘어가면 에러가 발생하게 한다.

 

 

  •  문자열 연결하기 (strcat 함수)
char* strcat(char* strDestination, const char* strSource);

char sample[20] = "HELLO";
strcar(sample, "ICE");

문자열을 연결했을 때 전체 문자열 크기가 원래 할당된 크기를 넘어서지 않았는지 꼭 확인해야 한다.

  • MS Visual C++의 보안 개선 함수
errno_t strcat_s(char* strD, size_t nE1, const char* strS);

C 스타일 문자열은 포인터를 이용해서 사용해야 하기 때문에 번거로울 수 있으므로 우리는 문자열 클래스를 만들어서 이용하려고 한다.

 

 ●  MyString 클래스

  ▷ Requirements

  • 문자열을 저장하되 다음의 다중정의된 연산자를 포함하며, 실행시 필요에 따라 저장공간을 늘릴 수 있다.
연산자 기능
= 대입 연산자. C 스타일 문자열이나 MyString 객체를 복사한다.
+ 문자열 연결 연산자. 두 문자열을 연결한 문자열을 구한다.
+= 문자열을 뒤에 추가한다.
==, >, < 관계연산자. 두 문자열의 등호 및 순서를 비교한다.
<< 스트림 출력 연산자. 출력 스트림으로 문자열을 출력한다.
[ ] 문자열 내의 개별 문자 접근

 

  • 생성자와 소멸자 등 멤버 함수 정의
메소드 설명
MyString( ) default 생성자 (public 선언)
MyString(const char* str); 지정된 문자열을 가지고 만드는 생성자 (public 선언)
MyString(const MyString* mstr); 복사 생성자. 동일한 클래스의 객체를 상수 참조로 받음. (public 선언)
MyString(MyString&& mstr); 이동 생성자. r-value 참조로 받음. (public 선언)
MyString(int s); private - 내부용으로 사용
~MyString( ); 소멸자 (public 선언)
int length( ) const; 문자열의 길이를 반환. 상수 멤버함수로 선언.

 

  • 데이터 멤버
데이터 멤버 용도
int len 문자열의 길이를 저장한다. (len<=bufSize)
int bufSize 최대로 저장할 수 있는 문자열의 길이를 저장한다.
char *buf 문자열 저장 공간

 

  ▷ MyString.h

#include <iostream>

class MyString {
    int len;           //문자열 길이
    int bufSize;	  // 저장 가능한 문자열의 길이
    char *buf;        // 문자열 저장할 곳
    MyString(int s);   //생성자(private). 문자열의 크기만 지정해줘서 메모리를 만드는 것까지만 함.
public:
    MyString();     // default 생성자. 빈 문자열 생성.
    MyString(const char *str);   //생성자. 지정된 문자열을 가지고 문자열을 만드는 생성자
    MyString(const MyString &mstr);   //복사 생성자
    MyString(MyString &&mstr);    //이동 생성자
    ~MyString();     //소멸자
    int length() const;   //문자열 길이 반환 함수
    
    //대입 연산자
    MyString& operator=(const MyString &mstr);
    //이동 연산자
    MyString& operator=(MyString &&mstr);
    //문자열 연결 연산자
    MyString operator+(const MyString &mstr) const;
    MyString& operator+=(const MyString &mstr);
    //관계 연산자
    bool operator==(const MyString &mstr) const; // == 
    bool operator>(const MyString &mstr) const; // >
    bool operator<(const MyString &mstr) const; // <
    //개별 문자 접근을 위한 생성자
    char& operator[](int i);
    char operator[](int i) const;
    
    //스트림 출력 연산자를 만들어야 함.
    //cout 객체가 MyString 객체가 아니므로 클래스 외부에 별도로 선언을 해야 한다.
    //외부에서 내부의 데이터에 접근할 수 있도록 friend 연산자 선언
    friend std::ostream& operator<<(std::ostream &os, const MyString &mstr);
};

//스트림 출력 연산자
//좌측: ostream 객체 참조, 우측: 출력할 MyString 객체 참조
inline std::ostream& operator<<(std::ostream &os, const MyString &mstr)
{
    os << mstr.buf;
    return os;
}

 

  ▷ MyString.cpp

#include <iostream>
#include <cstring>
#include "MyString.h"

MyString::MyString(int s) : len(s), bufSize(s){   //클래스 내부에서만 사용됨(private)
    buf = new char[s + 1];  // 길이가 s인 문자 + 널 문자 1개
    buf[s] = '\0';
}

MyString::MyString() : len(0), bufSize(0)  //default 생성자
{
    buf = new char[1];
    buf[0] = '\0';
}

MyString::MyString(const char *str) // 인수로 받은 문자열로 초기화하는 생성자
{
    len = bufSize = strlen(str);   //문자열 길이
    buf = new char[len + 1];       //문자열 저장공간 할당
    strcpy(buf, str);             //문자열 복사
}

//복사 생성자
MyString::MyString(const MyString &mstr) : len(mstr.len), bufSize(mstr.len) 
{
    buf = new char[len + 1];
    strcpy(buf, mstr.buf);
}

//이동 생성자
MyString::MyString(MyString &&mstr) : len(mstr.len), bufSize(mstr.bufSize), buf(mstr.buf)
{ //buf(mstr.buf)포인터만 그대로 복사
    mstr.buf = nullptr;
}

//소멸자
MyString::~MyString()
{
    delete[] buf;
}

//문자열 길이 반환 메소드
int MyString::length() const
{
    return len;
}

//대입 연산자
MyString& MyString::operator=(const MyString &mstr)
{
    if (bufSize < mstr.len){  //문자열 공간이 필요량보다 작으면
        delete[] buf;		  //기존 공간 반환
        len = bufSize = mstr.len;  //새로운 문자열 길이
        buf = new char[len + 1];   //새로운 공간 할당
    }
    else
        len = mstr.len;         //그렇지 않으면
    strcpy(buf, mstr.buf);		//문자열의 길이만 수정
    return *this;
}

//이동 연산자
MyString& MyString::operator=(MyString &&mstr)
{
    delete[] buf;
    len = mstr.len;
    bufSize = mstr.bufSize;
    buf = mstr.buf;        //포인터 복사 (동일한 메모리 가리킴)
    mstr.buf = nullptr;    //원래 mstr이 가리키던 포인터를 없애버림 나중에 아예 사라지게
    return *this;
}

//문자열 연결 연산자
MyString MyString::operator+(const MyString &mstr) const
{
    //문자열을 연결하면 길이가 길어질테니 tmstr이라는 임시 스트링객체를 만들고 
    //자기자신의 길이와 mstr 길이를 더한 길이만큼을 확보할 수 있는 생성자를 정의한다.(private)
    MyString tmstr(len + mstr.len); 
    strcpy(tmstr.buf, buf);
    strcpy(tmstr.buf + len, mstr.buf);  //tmstr.buf + len이 null문자가 있는 위치에 이어붙임
    return tmstr;
}

//+=연산자
MyString& MyString::operator+=(const MyString &mstr)
{
    if (bufSize < len + mstr.len){
        char* tbuf = new char[(bufSize = len + mstr.len) + 1];
        strcpy(tbuf, buf);
        delete[] buf;
        buf = tbuf;
    }
    //(기존길이+복사할문자열)길이를 가지는 buf에서 len위치가 널문자 위치이므로
    //거기에 mstr.buf를 복사해준다.
    strcpy(buf + len, mstr.buf); 
    len += mstr.len;
    return *this;
}

// 관계 연산자 ==
bool MyString::operator==(const MyString &mstr) const
{
    return !strcmp(buf, mstr.buf);
}

// 관계 연산자 >
bool MyString::operator>(const MyString &mstr) const
{
    return strcmp(buf, mstr.buf) > 0;
}

// 관계 연산자  <
bool MyString::operator<(const MyString &mstr) const
{
    return strcmp(buf, mstr.buf) < 0;
}

char& MyString::operator[](int i)
{
    return buf[i];
}

char MyString::operator[](int i) const
{
    return buf[i];
}

대입 형태의 연산자들은 다 그 객체의 참조를 반환하게 선언되어 있다는 것을 유념하자.

"대입 연산자"를 정의했기 때문에 아래와 같은 코드 결과를 보일 수 있다. 

char cstr1[10] = "C string";
char cstr2[10];
char *cstr3;

MyString mstr1("MyString 객체");
MyString mstr2;
cstr2 = cstr1;    //에러!
cstr3 = cstr1;	  //포인터만 복사. 같은 문자열을 가리킴. shallow copy
mstr2 = mstr1;	  //메모리를 복사. 별개의 문자열이다. deep copy

 

  ▷ Main.cpp

#include <iostream>
#include "MyString.h"
using namespace std;

int main()
{
    MyString str1("MyString class");
    MyString str2("Object Oriented ");
    MyString str3;
    
    cout << str1 << endl;     
    str3 = "Programming";     //대입연산자 이용 -> 묵시적 형 변환 ->임시객체 생성-> r-value-> 이동 대입
    cout << str3 << "'s length of the string is: ";  
    cout << str3.length() << endl;          
    str1 = str2;            //l-value -> 대입 연산자 -> 문자열 복사
    cout << str1 << endl;
    str1 = str2 + str3;   //문자열 연결 연산자 -> 더해진 객체 r-value -> 이동 대입 연산자
    cout << str1 << endl;
    MyString str4(str3);
    cout << str4 << endl;
    str2 += "Programming";
    cout << str2 << endl;
    str2[6] = '-';
    cout << str2 << endl;
    return 0;
}

  ▷ 출력 결과

 

 ●  string 클래스

  ▷ C++ 표준 라이브러리에는 string이라는 문자열을 저장할 수 있는 클래스를 제공한다. 

  ▷ basic_string<char>라는 표준 C++ 라이브러리의 클래스 템플릿으로 선언된 것이다.

  • 헤더파일: #include <string>
  • 연산자: [ ], +, =, +=, ==, !=, >, >=, <, <=, 스트림 입출력(<<, >>) 등
  • 멤버함수: length, append, find, c_str 등
  • 함수: stoi, stod, to_string, swap, getline 등
    string to integer, string to double, ...

 

 

▣ 자료형 변환

 ●  묵시적 형 변환

  ▷ MyString 클래스의 묵시적 형 변환에 대해 더 알아보자.

str3 = "Programming";

위와 같이 대입을 하려면, 대입 연산자와 이동 대입 연산자를 사용할 것이다.

MyString& operator=(const MyString &mstr); //대입 연산자
MyString& operator=(MyString &&mstr);   //이동 대입 연산자

두 대입 연산자를 사용하기 위해서는 MyString 참조를 받아야 하는데, str3 = "Programming"은 두 가지 모두에 해당되지 않는다. 그래서 두 연산자를 사용할 수 없다.

이 데이터를 가지고 MyString 객체로 형변환을 해줄 수 있는 생성자가 존재하는 지를 본다.

MyString(const char *str);

이런 생성자가 있기 때문에 "Programming"을 MyString 객체로 묵시적 형 변환을 해준다.

여기서, 묵시적 형 변환을 통해 임시로 만들어준 객체가 되기 때문에 r-value에 해당되므로 이동 대입 연산자를 통해서 대입 연산이 동작한다. 

 

 ●  형 변환 연산자를 정의하는 위치

  (1) 값을 제공하는 송신 측 클래스에서 정의하는 방법

  • 값을 받는 수신 측 클래스의 이름으로 연산자를 정의한다.
  • 예) MyString 클래스의 객체를 C 스타일 문자열로 변환하는 것
class MyString {
    ....
    operator char*() const{
       char *pt = new char[length()+1];
       strcpy(pt, buf);
       return pt;
    }
};

 

  (2) 값을 받는 수신 측 클래스에서 정의하는 방법

  •  송신 측 클래스의 객체를 인수로 갖는 1 인수 생성자를 정의한다.
  •  예) MyString(const char* str);
  • 문제점: 송신 측 클래스의 private 멤버를 액세스해야 변환이 가능한 경우

    --> 변환 대상 송신 측 클래스의 private 멤버를 접근할 수 있는 멤버함수가 송신 측 클래스에 정의되어 있어야 한다. 아니면, 송신 측 클래스에 수식 측 클래스의 멤버함수를 친구(friend) 클래스로 정의를 해준다.

 

  ▷ 생성자를 이용한 묵시적 형 변환 금지 상황에선 어떻게?

  • explicit으로 선언된 생성자는 묵시적으로 사용할 수 없다.
class MyString {
   ...
   explicit MyString(const char *str); //명시적으로만 사용해라!
   ...
};

// 실행
MyString str;
str = "Programming"; //에러 발생

이런 경우에는 아래와 같은 방법으로 객체를 만들어서 이동 대입이 될 수 있게 해야 한다.

// 해결방법 1
str = MyString("Programming"};

// 해결방법 2 
str = static_cast<MyString>("Programming");

객체를 따로 만들어주거나, static_cast를 이용해서 강제 형 변환을 해줘야 한다.

728x90
반응형