CodeGym /행동 /C++ SELF /operator << for std::ostream — 객체 출력

operator << for std::ostream — 객체 출력

C++ SELF
레벨 49, 레슨 3
사용 가능

1. 객체의 “보기 좋은 출력”이 왜 필요한가

프로그래밍을 막 시작하면 출력은 “보기 좋은 데모용” 정도로 보일 수 있습니다. 하지만 실제로는 cout이 가장 손쉬운 미니 디버거입니다: 코드가 이상하게 동작할 때, 첫 번째 욕구는— 값을 출력해서 누가 잘못했는지 확인하는 것입니다: 당신인지, 사용자인지, 아니면 컴파일러인지(스포일러: 대부분은 당신이지만 컴파일러는 자신이 아니라는 척합니다).

문제는 프로그램에 객체가 등장할 때 생깁니다 — 숫자나 문자열이 아니라 예를 들어 Task, User, Point, Order 같은 것들. 다음처럼 쓰고 싶어집니다:

std::cout << task << '\n';

대신 이렇게 쓰고 싶지 않습니다:

std::cout << task.id() << " " << task.title() << " " << task.isDone() << '\n';

첫째로, 두 번째 방식은 곧 main()을 장부 보고서처럼 만들어 버립니다. 둘째로, 출력 포맷이 열 군데에 중복되기 시작하고 언젠가 그 열 곳이 동일한 객체를 열 가지 다른 방식으로 출력하게 됩니다. 그건 더 이상 디버깅이 아니라 “머리 멍드는 시리즈: 감독판”입니다.

2. 출력 스트림용 operator<<의 구조

스트림과 std::ostream

겉보기에는 “출력”이 단순히 std::cout인 것처럼 보입니다. 사실 std::cout은 데이터를 받아 화면으로 보내는 특정 스트림 객체일 뿐입니다. 그 타입은 std::ostream입니다(정확히는 std::basic_ostream<char>이지만 지금은 그 부분이 필요 없습니다).

중요한 점: operator<<을 오버로드할 때 우리는 “정확히 std::cout에만 출력”하고 싶지 않습니다. 동일한 타입의 어떤 스트림으로든 출력할 수 있어야 합니다.

따라서 올바른 operator<<는 참조로 된 std::ostream&를 받고 내부에서 std::cout을 사용하지 않습니다. 그렇게 하면 객체를 화면, 문자열, 로그 등 요청한 곳으로 출력할 수 있습니다… (지금 당장은 std::cout만 사용하더라도 설계를 처음부터 제대로 하는 것이 좋습니다 — 마치 바로 설거지하는 것처럼).

operator<< 오버로드는 함수다

연산자 오버로드는 특별한 이름을 가진 평범한 함수일 뿐입니다. 컴파일러의 비밀 포털이 아닙니다. 다음과 같이 쓸 때:

std::cout << x;

컴파일러는 적절한 operator<< 오버로드를 찾습니다. 만약 x가 당신의 타입이라면(적절한 오버로드가 있다면) 컴파일러는 당신의 함수를 호출합니다.

여기서 핵심 실무 질문이 나옵니다: 이 함수는 클래스 내부에 있어야 할까요, 아니면 외부에 있어야 할까요? operator<<의 경우 거의 항상 자유 함수(free function)를 선택합니다, 왜냐하면 표현식의 왼쪽 피연산자 std::cout은 스트림이고 스트림은 당신의 클래스가 아니므로 std::ostream에 메소드를 추가할 수 없기 때문입니다.

즉, 클래스 멤버 방식은 이상하게 보이고 반대로 읽히게 됩니다:

x << std::cout; // 기술적으로는 가능하지만 보는 사람이 괴로워합니다

내장 타입처럼 자연스럽게 보이려면 자유 함수를 써야 합니다.

정형화된 시그니처

지금은 단순히 시그니처를 외우는 것이 아니라 그 논리를 이해할 시점입니다. 표준적인 형태는 다음과 같습니다:

std::ostream& operator<<(std::ostream& os, const T& value);

마법을 외우는 사람이 아니라 엔지니어처럼 이것을 분석해 봅시다.

부분 왜 필요한가
std::ostream&
체이닝 os << a << b << c가 작동하려면 연산자는 같은 스트림을 반환해야 합니다
std::ostream& os
우리는 “주어진 그 스트림”에 출력해야 하며, “내가 알아서 정하는 곳”에 출력하면 안 됩니다
const T& value
출력은 객체를 변경하면 안 되고 불필요하게 복사해서도 안 됩니다

이 조각들 중 하나라도 헷갈리면 빌드가 안 되거나 빌드되더라도 사용하기 불편해집니다 (불편한 출력은 Backspace 키가 없는 편한 키보드와 같습니다: 살 수는 있지만 왜 그러냐는 느낌).

왜 std::ostream&를 반환하는가

체이닝 출력이 스트림 출력이 즐거운 이유입니다. 우리는 이렇게 씁니다:

std::cout << "Task: " << task << '\n';

각 operator<<가 스트림에 대한 참조를 반환해서 다음 <<가 같은 스트림에 계속 쓸 수 있기 때문에 작동합니다. 마음속으로는 이것이 컨베이어처럼 보입니다: 스트림에 조각들을 넣으면 스트림이 하나의 출력으로 합쳐 줍니다.

작은 다이어그램으로 생각해볼 수 있습니다:

flowchart LR
    A["std::cout"] -->|"문자열 Task: 출력"| B["같은 스트림"]
    B -->|"변수 task 출력"| C["같은 스트림"]
    C -->|"줄바꿈 출력"| D["같은 스트림"]

만약 연산자가 void를 반환했다면 체이닝이 깨지고 여러 줄로 출력해야 했을 것입니다. 가끔 괜찮을 수 있지만 대부분은 불필요한 번거로움입니다.

왜 const T&로 받는가

값으로 객체를 넘기면 복사가 발생합니다. int에서는 무시할 수 있지만 내부에 std::string 같은 것을 가진 타입에서는 비용이 커집니다. 또한 출력은 보통 현재 객체를 살펴보는 행위이지 “그 객체의 사진 복사본”을 보는 것이 아닙니다(복사도 비용이 듭니다).

더욱이 출력은 거의 항상 “관찰”이지 “변경”이 아닙니다. 따라서 const T&는 최적화이자 규약입니다: «보지만 변경하지 않는다».

3. 실전 예: Task와 할 일 트래커

추상적인 Point/Person 대신 교육용 애플리케이션 라인을 이어가 보겠습니다: 간단한 콘솔 기반 할 일 트래커. 우리는 이미 Task 클래스가 있고 불변식으로 “id는 음수가 아니다”와 깔끔한 게터들이 있다고 가정합니다.

최소한의 클래스 구현을 만들어 보겠습니다:

#include <string>
#include <utility>

class Task {
public:
    Task(int id, std::string title)
        : id_(id), title_(std::move(title)) {}

    int id() const { return id_; }
    const std::string& title() const { return title_; }
    bool is_done() const { return done_; }

private:
    int id_{0};
    std::string title_;
    bool done_{false};
};

주의할 점: 게터 title()이 const std::string&을 반환합니다. 이것은 앞서 논의한 내용과 일치합니다: 불필요한 문자열 복사를 하지 않고 외부에서 title_을 직접 변경할 수 없게 합니다.

공개 인터페이스를 통한 operator<< 작성

이제 출력을 추가해 보겠습니다. 가장 간단하고 “정직한” 방법은 공개 인터페이스를 이용하는 것입니다:

#include <ostream>

std::ostream& operator<<(std::ostream& os, const Task& t) {
    os << "Task{id=" << t.id()
       << ", title=\"" << t.title()
       << "\", done=" << t.is_done() << "}";
    return os;
}

네, 우리는 \"를 사용했습니다 — 제목(title)을 따옴표로 감싸 출력하고 싶었기 때문입니다. 작은 차이지만 출력 가독성이 좋아집니다: 특히 제목에 공백이 있을 때 어디서 문자열이 시작하고 끝나는지 분명해집니다.

main()에서 확인하기

이제 실제로 동작하는지 보고 싶을 것입니다. 확인 코드는 아주 짧습니다:

#include <iostream>

int main() {
    Task t{1, "Buy milk"};
    std::cout << "Created: " << t << '\n';
}

정상적으로 구현되었다면 출력은 대략 다음과 같습니다:

// Created: Task{id=1, title="Buy milk", done=0}

여기서 done=0인 이유는 기본적으로 bool이 0/1로 출력되기 때문입니다. 문제는 없지만 더 친절하게 보이게 할 수도 있습니다.

출력 형식은 계약이다

타입에 operator<<을 추가하면 동료와 미래의 자신에게 객체가 예측 가능한 방식으로 출력된다는 약속을 하는 셈입니다. 따라서 형식을 잘 선택하고 지키는 것이 중요합니다.

작고 중요한 점: 연산자 내부에 줄바꿈을 출력하지 마세요. 가끔 이렇게 쓰고 싶을 수 있습니다:

os << "Task{...}\n";

하지만 이러면 유연성이 깨집니다. 호출자는 객체를 줄바꿈 없이 출력하거나, 문자열 중간에 출력하거나, 여러 객체를 한 줄에 출력하고 싶을 수 있습니다. 줄바꿈은 전체 출력 문자열을 구성하는 쪽의 책임입니다.

“사람 친화적인 bool”이 필요하다면 출력하는 쪽에서 std::boolalpha를 사용하세요:

#include <iostream>

int main() {
    Task t{1, "Buy milk"};
    std::cout << std::boolalpha << t << '\n';
}

이제 출력은 다음과 같습니다:

// Task{id=1, title="Buy milk", done=false}

할 일 컨테이너 출력

operator<<의 진짜 마법은 단일 객체가 아니라 여러 객체일 때 드러납니다. 예를 들어 std::vector<Task>에 할 일이 있고 그것들을 모두 출력하고 싶다면요.

연산자를 정의하면 루프가 훨씬 깔끔해집니다:

#include <iostream>
#include <vector>

int main() {
    std::vector<Task> tasks = { {1, "Buy milk"}, {2, "Learn C++"} };

    for (const Task& t : tasks) {
        std::cout << t << '\n';
    }
}

출력:

// Task{id=1, title="Buy milk", done=0}
// Task{id=2, title="Learn C++", done=0}

연산자가 없었다면 이 루프는 각 출력 지점에서 필드 조각을 이어 붙이는 코드가 되었을 것입니다. 이제 출력 형식이 한 곳에 중앙화되어서 형식을 바꾸면 한 곳만 수정하면 됩니다.

게터가 없을 때: friend operator<<

때로는 모든 필드에 게터를 만들고 싶지 않을 수 있습니다. 예를 들어 내부 구현 세부사항이라 외부가 의존하지 않았으면 하는 경우입니다. 하지만 로그나 진단을 위해 출력은 필요할 수 있습니다.

그럴 때 C++은 연산자를 클래스의 friend로 만들 수 있게 허용합니다. 즉, 그 함수는 클래스의 private 멤버를 들여다볼 수 있습니다. friend는 귀엽게 들리지만 사실 통제된 캡슐화의 구멍입니다 — 용도는 분명히 제한해야 합니다.

예:

#include <ostream>
#include <string>
#include <utility>

class Task {
public:
    Task(int id, std::string title)
        : id_(id), title_(std::move(title)) {}

    friend std::ostream& operator<<(std::ostream& os, const Task& t) {
        return os << "Task{id=" << t.id_ << ", title=\"" << t.title_ << "\"}";
    }

private:
    int id_{0};
    std::string title_;
};

연산자가 클래스 내부에 정의되어 있음을 주목하세요. 이는 허용되는 방식입니다. 그러나 교육용 실무에서는 연산자를 클래스 옆(파일의 아래쪽)에 두는 것이 더 편한 경우가 많습니다. 그래야 클래스가 모든 것을 담은 잡동사니가 되지 않습니다. 여기서는 friend 패턴과 그것이 얼마나 간결할 수 있는지를 보여 주었습니다.

std::ostringstream로 문자열에 출력하기

콘솔에 바로 출력하지 않고 문자열을 받고 싶을 때가 있습니다. 예를 들어 객체의 출력 결과를 비교하거나 메시지를 조합할 때 유용합니다. 이럴 때 문자열 스트림을 사용하면 편리합니다.

이미 std::stringstream을 본 적이 있다면 std::ostringstream는 출력 전용 버전이라고 생각하면 됩니다.

#include <sstream>
#include <string>

std::string to_string(const Task& t) {
    std::ostringstream out;
    out << t;
    return out.str();
}

이제 이렇게 쓸 수 있습니다:

#include <iostream>

int main() {
    Task t{3, "Write report"};
    std::cout << to_string(t) << '\n';
}

이것은 operator<<가 기대한 형식으로 정확히 출력하는지 확인하는 가장 안전한 방법 중 하나입니다.

4. operator<< 작성 시 흔한 실수

실수 1: 연산자 내부에서 std::cout으로 직접 출력하는 것.
“화면에 찍고 싶으니 당연히 std::cout을 쓰자”처럼 보이지만, 그렇게 하면 함수가 특정 스트림에 강하게 결합되어 범용성이 깨집니다. 올바른 연산자는 전달받은 os에 출력해야 하며, 그렇지 않으면 << 표현식의 투명성이 사라집니다.

실수 2: void를 반환하거나 스트림을 값으로 반환하는 것.
void를 반환하면 체이닝이 작동하지 않으며, 스트림을 값으로 반환하면 스트림을 복사하려고 시도하게 됩니다(보통 불가능하고 원하지 않는 일입니다). 정석은 std::ostream&를 반환하는 것입니다.

실수 3: 객체를 값으로 받는 것(const T& 대신).
값 전달은 불필요한 복사를 초래합니다. 단순 타입에서는 무시할 수 있지만 문자열이나 벡터를 포함한 타입에서는 비용이 커집니다. 또한 출력이 객체를 변경하지 않는다는 의미도 흐려집니다.

실수 4: 연산자 내부에서 '\n' 또는 std::endl을 추가하는 것.
그런 연산자는 스스로 줄의 끝을 결정해 버립니다. 결과적으로 복잡한 메시지나 테이블, 한 줄에 여러 객체를 출력하는 경우에 유연성이 사라집니다. 줄바꿈은 호출자 쪽에서 처리해야 합니다.

실수 5: 연산자를 너무 영리하게 만들어 출력이 불안정해지는 것.
연산자가 외부 상태, 임의의 플래그, 숨은 로직에 따라 가변적으로 출력하면 진단 도구로서의 신뢰성이 떨어집니다. 특히 학습 초기에는 하나의 명확한 형식과 최소한의 놀라움이 더 낫습니다.

1
과제
C++ SELF, 레벨 49, 레슨 3
잠금
점 출력
점 출력
1
과제
C++ SELF, 레벨 49, 레슨 3
잠금
전광판 시계
전광판 시계
1
과제
C++ SELF, 레벨 49, 레슨 3
잠금
문자열로서의 책
문자열로서의 책
1
과제
C++ SELF, 레벨 49, 레슨 3
잠금
주문 통일
주문 통일
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION