로그를 백만 줄 쓰는 코드에서 std::endl을 '\n'으로 바꾸기만 했는데 실행 시간이 수십 배 줄어든다. std::endl은 줄바꿈과 함께 버퍼를 비우는(flush) 조작자이고, flush는 매번 시스템 호출로 이어진다. std::cout에는 이 밖에도 C stdio와의 동기화, std::cin과의 연결(tie), 한 번 설정하면 계속 유지되는 서식 상태 같은 숨은 동작이 있다. 이 글에서는 g++ 13.3과 strace로 cout의 버퍼링 동작을 확인하고, 조작자, 사용자 타입 출력, C++20 std::format까지 정리한다.
이 시리즈의 다른 글
- C++ 고급 문법 (10) — 함수 포인터(Function Pointer)
- C++ 고급 문법 (11) — 연산자 오버로딩(Operator Overloading)
- C++ 고급 문법 (13) — 스마트 포인터(Smart Pointer)
- C++ 고급 문법 (14) — 형변환(Conversion)
endl과 ‘\n’
#include <chrono>
#include <cstdio>
#include <cstring>
#include <fstream>
int main(int argc, char** argv)
{
bool use_endl = argc > 1 && std::strcmp(argv[1], "endl") == 0;
std::ofstream out("out.txt");
auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < 1'000'000; ++i) {
if (use_endl) out << i << std::endl; // '\n' + flush
else out << i << '\n';
}
out.close();
auto ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
std::printf("%-5s %8.1f ms\n", use_endl ? "endl" : "\\n", ms);
}$ g++ -std=c++20 -Wall -O2 -o endl endl.cpp
$ ./endl nl
\n 28.2 ms
$ ./endl endl
endl 740.0 ms
$ strace -c -e trace=write,writev ./endl nl 2>&1 | grep -E 'calls|write'
% time seconds usecs/call calls errors syscall
99.78 0.008052 9 840 writev
0.22 0.000018 9 2 write
$ strace -c -e trace=write,writev ./endl endl 2>&1 | grep -E 'calls|write'
% time seconds usecs/call calls errors syscall
100.00 9.453643 9 1000001 write
'\n'은 버퍼가 찰 때만 writev로 내보내 시스템 호출이 842번이고, std::endl은 줄마다 write를 불러 100만 번이다. 즉시 보여야 하는 출력이 아니면 '\n'을 쓰고, 필요한 지점에서만 std::flush를 부른다.
sync_with_stdio
기본적으로 cout은 C의 stdout과 출력 순서를 맞추도록 동기화되어 있다. 끄면 cout이 자체 버퍼를 쓰게 되어 printf와 순서가 뒤섞인다.
#include <cstdio>
#include <iostream>
int main()
{
std::ios::sync_with_stdio(false); // C stdio와 버퍼 공유를 끈다 (빨라지지만 순서 보장이 사라짐)
std::cout << "1: cout\n";
std::printf("2: printf\n");
std::cout << "3: cout\n";
std::printf("4: printf\n");
}$ g++ -std=c++20 -Wall -o sy sync.cpp
$ ./sy
1: cout
3: cout
2: printf
4: printf
$ ./sy | cat
1: cout
3: cout
2: printf
4: printf
#include <chrono>
#include <cstdio>
#include <cstring>
#include <iostream>
int main(int argc, char** argv)
{
bool nosync = argc > 1 && std::strcmp(argv[1], "nosync") == 0;
if (nosync) std::ios::sync_with_stdio(false);
auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < 5'000'000; ++i) std::cout << i << '\n';
auto ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
std::fprintf(stderr, "%-7s %7.1f ms\n", nosync ? "nosync" : "sync", ms);
}$ g++ -std=c++20 -Wall -O2 -o sb syncbench.cpp
$ ./sb > /dev/null
sync 146.6 ms
$ ./sb nosync > /dev/null
nosync 118.2 ms
이 환경에서 500만 줄 기준 차이는 15~25% 정도였다(여러 번 실행해 135ms 대 115ms 안팎). 순서가 섞여도 되는 경우, 즉 printf와 섞어 쓰지 않을 때만 끈다.
cin.tie
#include <iostream>
#include <string>
int main(int argc, char**)
{
if (argc > 1) std::cin.tie(nullptr); // cout과 cin의 연결을 끊는다
std::cout << "name? "; // 개행도 flush도 없다
std::string name;
std::cin >> name; // 기본값: 읽기 전에 cout을 flush
std::cout << "hi " << name << '\n';
}$ g++ -std=c++20 -Wall -o tie tie.cpp
$ echo kim | strace -e trace=read,write ./tie 2>&1 >/dev/null | grep -E '^(read\(0|write\(1)'
write(1, "name? ", 6) = 6
read(0, "kim\n", 4096) = 4
write(1, "hi kim\n", 7) = 7
$ echo kim | strace -e trace=read,write ./tie untie 2>&1 >/dev/null | grep -E '^(read\(0|write\(1)'
read(0, "kim\n", 4096) = 4
write(1, "name? hi kim\n", 13) = 13
기본 상태에서는 cin이 읽기 전에 cout을 flush해서 프롬프트가 먼저 나간다. tie(nullptr)로 끊으면 프롬프트가 입력 이후로 밀린다.
| 스트림 | 대상 | 버퍼링 | tie |
|---|---|---|---|
std::cout | stdout | 버퍼링 | 없음 |
std::cin | stdin | 버퍼링 | cout |
std::cerr | stderr | unitbuf (출력마다 flush) | cout |
std::clog | stderr | 버퍼링 | 없음 |
조작자와 서식 상태
#include <iomanip>
#include <iostream>
int main()
{
double pi = 3.14159265;
std::cout << std::fixed << std::setprecision(2) << pi << ' ' << 2.0 << '\n'; // 유지됨(sticky)
std::cout << std::setw(8) << 42 << '|' << 42 << "|\n"; // setw는 한 번만
std::cout << std::hex << std::showbase << 255 << ' ' << 16 << '\n'; // hex도 유지됨
std::cout << 10 << '\n'; // 여전히 16진수
std::cout << std::dec << std::boolalpha << 10 << ' ' << true << '\n';
}$ g++ -std=c++20 -Wall -o mp manip.cpp
$ ./mp
3.14 2.00
42|42|
0xff 0x10
0xa
10 true
setw만 다음 출력 한 번에 적용되고, fixed·setprecision·hex 등은 스트림 상태로 남는다. 함수 안에서 서식을 바꿨다면 copyfmt로 저장했다가 복원한다.
#include <iomanip>
#include <iostream>
void dump_hex(std::ostream& os, unsigned v)
{
std::ios state(nullptr);
state.copyfmt(os); // 호출자 서식 저장
os << std::hex << std::setfill('0') << std::setw(8) << v << '\n';
os.copyfmt(state); // 복원
}
int main()
{
dump_hex(std::cout, 0xBEEF);
std::cout << 255 << '\n'; // 10진수로 돌아와 있다
}$ g++ -std=c++20 -Wall -o rs restore.cpp
$ ./rs
0000beef
255
사용자 타입 출력
#include <iomanip>
#include <iostream>
#include <sstream>
struct Point { double x, y; };
// 스트림 출력: 비멤버, 스트림 참조를 반환해 체이닝
std::ostream& operator<<(std::ostream& os, const Point& p)
{
return os << '(' << p.x << ", " << p.y << ')';
}
// 사용자 정의 조작자(manipulator): ostream&를 받는 함수
std::ostream& tab(std::ostream& os) { return os << '\t'; }
int main()
{
Point p{1.5, -2};
std::cout << "p=" << p << tab << "end\n";
std::cout << std::setw(12) << p << "|\n"; // setw는 첫 출력 '(' 에만 적용된다
std::ostringstream ss; // 한 번에 만들어 폭 적용
ss << p;
std::cout << std::setw(12) << ss.str() << "|\n";
}$ g++ -std=c++20 -Wall -o cu custom.cpp
$ ./cu
p=(1.5, -2) end
(1.5, -2)|
(1.5, -2)|
operator<< 안에서 여러 번 나눠 출력하므로 setw는 첫 조각 '('에만 걸린다. 폭을 전체에 적용하려면 문자열로 먼저 만든다.
C++20 std::format
#include <format>
#include <iostream>
struct Point { double x, y; };
template <>
struct std::formatter<Point> : std::formatter<std::string> {
auto format(const Point& p, std::format_context& ctx) const
{
return std::formatter<std::string>::format(std::format("({}, {})", p.x, p.y), ctx);
}
};
int main()
{
std::cout << std::format("{:>8.2f}|{:#x}|{}\n", 3.14159, 255, true); // 서식이 호출마다 독립
std::cout << std::format("[{:>12}]\n", Point{1.5, -2}); // 폭이 전체에 적용
}$ g++ -std=c++20 -Wall -o fm fmt.cpp
$ ./fm
3.14|0xff|true
[ (1.5, -2)]
std::format은 서식이 호출마다 독립적이라 상태가 남지 않고, formatter 특수화로 사용자 타입에도 폭 지정이 그대로 적용된다. 출력까지 한 번에 하는 C++23 std::print는 GCC 14부터 지원한다.
주의사항
| 상황 | 문제 | 대응 |
|---|---|---|
반복문에서 std::endl | 줄마다 시스템 호출 | '\n', 필요한 곳만 std::flush |
sync_with_stdio(false) 후 printf 혼용 | 출력 순서 뒤섞임 | 한쪽만 사용 |
cin.tie(nullptr) 후 프롬프트 출력 | 프롬프트가 입력 뒤에 나옴 | 프롬프트 뒤에 std::flush |
함수에서 hex·precision 변경 | 호출자 출력 서식이 바뀜 | copyfmt로 저장·복원, 또는 std::format |
| 크래시 직전 로그 | 버퍼에 남아 사라짐 | 진단 로그는 std::cerr |
마무리
std::endl은 flush를 포함하므로 백만 줄에서 시스템 호출이 842번에서 100만 번으로 늘고 26배 느려졌다.sync_with_stdio와tie는 출력 순서를 보장하는 장치이며, 끄면 빨라지는 대신 순서가 바뀐다.- 스트림 서식은 상태로 남으므로, 새 코드에서는
std::format이 다루기 쉽다.