C++/C++ : Study

8. 연산자 오버로딩 (5) - Ostream 객체

더블유제이플로어 2025. 6. 16. 00:44

Stream Insertion and Extraction Overloading

◆  스트림 삽입 및 추출 연산자 오버로딩

    Point p1{ 10,20 };
    Point p2{ 30,40 };
    p1.showPosition(); // [10,20]
    p2.showPosition(); // [30,40]
    // * 복잡한 멤버 함수 대신
    cout << p1 << endl; // [10,20]
    cout << p2 << endl; // [30,40]
    // * 기본 자료형처럼 stream 출력 가능하도록 한다.

    Point p3;
    cin >> p3; // 50 60
    // * 기본 자료형처럼 stream 입력도 가능하도록 한다.

🔹 기존 방식: 출력 전용 함수 사용

지금까지 객체의 좌표 값을 확인하려면 ShowPosition()과 같은 멤버 함수를 따로 정의하여 호출했다.
이 방식은 기본적인 접근이지만 매번 함수를 호출해야 하고, std::cout과 직접 연결되지 않아 불편하다.

🔹 개선된 방식: cout, cin을 객체와 연결

std::cout은 내부적으로 operator<<를 오버로딩하여 다양한 타입(int, float, string 등)을 출력할 수 있도록 구현되어 있다.
따라서 사용자 정의 타입도 마찬가지로 << 연산자를 오버로딩하면 std::cout << myObject; 와 같은 형태로 출력이 가능하다.
마찬가지로 std::cin을 사용하려면 >> 연산자를 오버로딩하면 된다

Point p1{10, 20};
std::cout << p1; // 좌표 출력
std::cin >> p3;  // x, y 좌표 입력

.이처럼 <<, >> 연산자를 오버로딩하면 사용성과 가독성이 동시에 향상된다.

핵심 정리

사용자 정의 클래스에서 << 와 >> 연산자를 오버로딩하면 std::cout, std::cin을 통해 객체 정보를 직관적으로 출력하거나 입력받을 수 있다. 실무에서는 별도의 출력 함수보다 이 방식이 훨씬 널리 사용된다. 특히 디버깅, 로깅, 테스트 코드 작성 시 일관된 입출력 방식은 협업의 효율을 크게 높인다.

❓정말 멤버 함수 사용하는 것보다 cin, cout 연산자 오버로딩이 협업에서 더 편하고 자주 사용하는 방식인가?

✔ 답변:

그렇다. 실제 협업 및 실무에서는 다음과 같은 이유로
cin, cout에 대응하는 연산자 오버로딩 방식이 훨씬 더 선호된다.

  1. 일관된 출력 포맷 제공
    → 모든 객체가 같은 스타일로 출력되기 때문에 디버깅이나 로그 확인이 쉽다.
  2. 표준 라이브러리 및 STL과 호환성
    → std::vector<Point> 와 같은 컨테이너에 담긴 객체도 std::copy(..., std::ostream_iterator<Point>(...)) 등으로 바로 출력 가능하다.
  3. 테스트 및 디버깅의 효율성
    → 개발 중간에 값 확인을 위해 std::cout << obj; 만으로 빠르게 확인할 수 있다.
  4. 직관적 문법 지원
    → 함수 호출 방식보다 연산자 방식이 간단하고 자연스러워 협업 시 읽고 쓰기 모두 편리하다.
  5. 입출력 포맷 커스터마이징이 용이
    → 소수점 자리수, 출력 형태(괄호 포함 여부 등)를 쉽게 제어할 수 있다.

중요한 Part이다.

Ostream

◆  cout 에 대한 간단한 이해

   ●  << 은 cout 객체 (ostream 클래스) 의 오버로딩 된 연산자이다.

class MyOstream
{
public:
    void operator<<(int val)
    {
        printf("%d", val);
    }
};
 /* 아주 간단한 버전의 cout 객체 생성을 위한 MyOstream 클래스 */

int main()
{
	cout << 123; // cout.operator<<(123);
    cout.operator<<(123);
    MyOstream mycout;
    mycout << 123;
    mycout.operator<<(123);
    return 0;
}
/* 위에서 만든 클래스 객체 (mycout) 을 사용한 콘솔 출력 */

✅ 스트림 삽입 연산자와 cout의 동작 원리

🔹 cout이란 무엇인가?

cout은 std::ostream 클래스에 정의된 전역 객체이며,
표준 출력(콘솔)으로 데이터를 보내는 데 사용된다.
즉, std::cout << 123; 구문은
실제로는 std::cout.operator<<(123);으로 해석된다.

이처럼 << 연산자는 오버로딩된 함수이며,
기본형 타입뿐만 아니라 사용자 정의 객체에도 사용할 수 있다.

🔹 << 연산자의 정체: 스트림 삽입 연산자

std::cout << 123;

위 문장에서 << 연산자는 cout 객체의 멤버 함수 operator<<()이다.
이 연산자를 스트림 삽입 연산자(stream insertion operator) 라고 부른다.

🔹 직접 구현한 간단한 MyOstream 클래스 예시

class MyOstream {
public:
    void operator<<(int val) {
        printf("%d", val);
    }
};

위처럼 operator<<()를 정의하면 다음과 같은 코드가 가능하다:

MyOstream mycout;
mycout << 123;             // mycout.operator<<(123);
mycout.operator<<(123);    // 위와 같은 의미

즉, 우리가 mycout << 123;이라고 쓰면 컴파일러는
mycout.operator<<(123);으로 해석한다.
이것이 바로 << 연산자 오버로딩의 기본 원리이다.

❓<<의 정확한 명칭이 스트림 삽입 연산자 맞아?

답변:
맞다. <<는 출력 방향 기준으로 보면 "삽입"하는 의미이기 때문에
**"stream insertion operator (스트림 삽입 연산자)"**라고 부른다.
반대로 >>는 **"stream extraction operator (스트림 추출 연산자)"**이다.

❓printf("%d", val);는 값을 출력하는 명령문 맞지?

답변:
맞다. printf("%d", val);는 C의 표준 출력 함수로,
정수 값을 화면에 출력하는 명령이다.
%d는 int형 값을 의미하고, 뒤에 오는 val이 실제로 출력된다.

❓“operator<<을 반대방향으로 생각해보면”이 무슨 뜻이야?

답변:
교수님의 말은 문법적으로 

mycout << 10;

이렇게 써 있지만, 실제로는

mycout.operator<<(10);

으로 왼쪽 객체의 함수로 호출되는 구조라는 점을 강조하는 것이다.
즉, << 기호는 시각적으로는 오른쪽 값이 왼쪽으로 ‘들어가는’ 것처럼 보여도,
코드 내부적으로는 왼쪽 객체가 operator<< 함수를 실행한다는 의미이다.

❓namespace 설명 중 mystd::mycout 얘기하다가 “이건 뒤에 가서 해야 한다”며 넘어갔는데 왜 그런걸까?

답변:
namespace mystd { ... } 는 사용자 정의 네임스페이스이고,
그 안에 정의된 MyOstream 클래스는 mystd::MyOstream으로 접근해야 한다.

예를 들어:

namespace mystd {
    class MyOstream {
    public:
        void operator<<(int val) {
            printf("%d", val);
        }
    };
}

mystd::MyOstream mycout;
mycout << 10;

이렇게 하면 잘 동작하지만,
namespace와 using 선언, ADL (Argument-Dependent Lookup), 함수 검색 범위
복잡한 네임스페이스 관련 규칙이 얽히기 때문에
초보자 강의에서는 여기까지만 설명하고 넘어간 것이다.

즉, 교수님이 “지금은 넘어가자”고 한 이유는
namespace의 작동 원리는 따로 다뤄야 하는 심화 주제이기 때문이다.

✅ 요약 문장

std::cout은 ostream 클래스에 정의된 전역 객체이며,
<< 연산자는 멤버 함수 operator<<()가 오버로딩된 것이다.
따라서 cout << 123;은 내부적으로 cout.operator<<(123);으로 해석된다.
직접 구현한 클래스에서도 동일한 방식으로 operator<<()를 오버로딩하면, 사용자 정의 출력 객체를 만들 수 있다.
 << 연산자는 스트림 삽입 연산자(stream insertion operator) 라고 부르며,
반대로 >>  스트림 추출 연산자(stream extraction operator) 이다.

✅ namespace와 ADL에 대한 간단한 이해 (기초용 설명)

🧩 우리가 만든 MyOstream에 네임스페이스를 붙이는 이유는?

namespace mystd {
    class MyOstream {
    public:
        void operator<<(int val) {
            printf("%d", val);
        }
    };
}
  • 이렇게 작성하면 MyOstream 클래스는 mystd라는 이름공간(namespace) 안에 들어간다.
  • 따라서 사용할 때는 mystd::MyOstream이라고 명시해야 한다.
mystd::MyOstream mycout;
mycout << 10;

💡 그럼 왜 굳이 이름공간을 쓰는 걸까?

  • C++에서는 전역에 너무 많은 이름이 생기면 이름 충돌(name conflict) 문제가 발생할 수 있다.
  • namespace는 이름을 구분해서 충돌을 피하도록 해주는 장치다.
  • 마치 같은 이름의 클래스라도 서로 다른 폴더에 있는 것처럼 구분하는 것과 비슷하다.

❓ ADL (Argument-Dependent Lookup)이 뭔가요? (지금은 대략만)

  • C++에서 어떤 함수를 호출할지 찾을 때,
    그 함수의 인자(Argument)가 속한 네임스페이스까지 고려해서 함수 정의를 찾는 걸 ADL이라고 한다.
  • 지금 단계에서는 “컴파일러가 어디서 함수를 찾는지에 관한 규칙 중 하나” 정도로만 알고 있으면 된다.

✅ 지금은 핵심만 이해하면 된다

namespace mystd는 이름공간을 지정한 것이다.
mystd::MyOstream은 네임스페이스를 포함한 전체 이름이다.
cout도 사실은 std라는 이름공간에 있는 ostream 객체이다.
그래서 우리가 항상 std::cout이라고 쓰는 것이다.

📌 요약 문장

cout은 std라는 이름공간에 정의된 전역 객체이다.
사용자가 만든 출력 객체를 namespace로 감싸는 것도 가능하며,
이렇게 하면 mystd::MyOstream처럼 네임스페이스를 포함한 형태로 사용할 수 있다.
지금 단계에서는 네임스페이스가 “이름 충돌을 막기 위한 구분자”라는 정도만 이해하면 된다.
더 나아가 함수 검색 범위를 결정하는 규칙 중 하나가 ADL인데,
이는 추후 더 깊이 있는 문법에서 학습하게 될 것이다.

중요한 Part이다.

Stram Insertion and Extraction Overloading

◆  스트림 삽입 연산자 오버로딩, 구현 

   ●  ostream 의 참조자를 반환하여 chain insertion 이 가능하도록 구현해야 한다.
        ☞     참조자를 반환하지 않으면 cout << p1 << p2 와 같은 연산 불가
        ☞     또한 기본적으로 cout 객체는 복사 불가

    friend ostream& operator<<(ostream& os, const Point& rhs)
    {
        os << "[" << rhs.xPos << "," << rhs.yPos << "]";
        return os;
    }

   ●  전역 함수로 선언 필요하다.
        ☞     멤버 함수로 선언하면 아래와 같이 사용해야 한다.

p1 << cout; // p1.operator<<(cout);

✅ 스트림 삽입 연산자 <<의 오버로딩 구현

🔹 MyOstream은 예시일 뿐이다

C++ 표준에서는 std::cout을 사용하며,
우리가 원하는 것은 mycout이 아니라 std::cout << p1; 형태로
객체(Point)를 직접 출력하는 것이다.

이를 가능하게 하려면 컴파일러가 아래 둘 중 하나로 해석할 수 있어야 한다.

  1. cout.operator<<(p1) → std::ostream의 멤버 함수
  2. operator<<(cout, p1) → 전역 함수

🔹 왜 전역 함수로 구현해야 하나?

std::ostream은 표준 라이브러리 클래스이므로
직접 수정하거나 멤버 함수를 추가할 수 없다.
(기술적으로는 확장 가능하더라도 좋지 않은 방식이며, 실무에서도 사용하지 않는다.)

따라서 오버로딩을 하려면 전역 함수로 operator<<를 정의해야 한다

std::ostream& operator<<(std::ostream& os, const Point& rhs);

🔹 ostream은 복사할 수 없는 객체이다

std::cout은 전역 객체이면서 복사 생성자와 대입 연산자가 삭제된 타입이다.
따라서 ostream을 값으로 받는 인자는 허용되지 않는다.

즉, 다음과 같은 함수는 불가능하다:

std::ostream operator<<(std::ostream os, const Point& rhs); // ❌ 복사 시도

반면 참조자로 받으면 복사 없이 포인터처럼 사용할 수 있으므로 유효하다:

std::ostream& operator<<(std::ostream& os, const Point& rhs); // ✅ 가능

🔹 실제 오버로딩 함수 구현 예시

class Point {
private:
    int xPos;
    int yPos;

public:
    Point(int x = 0, int y = 0) : xPos{x}, yPos{y} {}

    // friend 함수 선언
    friend std::ostream& operator<<(std::ostream& os, const Point& rhs);
};

// 전역 함수 정의
std::ostream& operator<<(std::ostream& os, const Point& rhs) {
    os << "[" << rhs.xPos << "," << rhs.yPos << "]";
    return os;
}
  • std::ostream&을 반환하면 cout << p1 << p2; 같은 체이닝도 가능하다.
  • const Point& rhs로 받아야 p1이 변경되지 않고 복사도 방지된다.

❓왜 std::cout은 복사가 안 되는지 궁금해

✔ 답변:

std::cout은 전역적으로 선언된 std::ostream 클래스의 객체이다.
이 객체는 복사를 허용하지 않도록 설계되어 있다.

이유는 다음과 같다:

  1. 전역 리소스 보호
    cout은 표준 출력 스트림이기 때문에 여러 복사본이 생기면
    출력이 어디로 가야 하는지 컴파일러가 알 수 없다.
  2. 복사 생성자와 복사 대입 연산자가 삭제되어 있다
    내부 구현에서 ostream(const ostream&) = delete;
    ostream& operator=(const ostream&) = delete;로 되어 있음.
  3. 싱글톤처럼 관리되는 자원
    하나의 콘솔 출력 버퍼만 사용되도록 강제하기 위함이다.

그래서 오직 참조(&) 형태로만 전달 가능하며, 복사를 시도하면 컴파일 오류가 발생한다.

📌  요약 문장

std::cout은 ostream 클래스의 전역 객체이며, 복사 생성자와 대입 연산자가 삭제된 타입이다.
따라서 출력을 위한 연산자 오버로딩은 반드시 std::ostream&을 참조자로 전달하는 방식으로 구현해야 한다.
또한 cout.operator<<(p1) 형태의 멤버 함수 방식은 표준 클래스를 수정해야 하므로 사용하지 않고,
operator<<(cout, p1) 형태의 전역 함수 오버로딩 방식이 일반적이다.
이때 Point 클래스의 private 멤버에 접근하기 위해 friend 선언을 추가하는 것이 일반적인 패턴이다.

https://youtu.be/IWN93YZdBvY?si=aMz2dUF6PwQ9_bdU

와 이제 강의가 이해가 간다. 내가 C++ 을 잘 몰랐구나... 이제 깨달아서 다행이다.