C++ iostream 다루기: 포매팅 조작자, stringstream 파싱, cin 버퍼와 스트림 실패 상태

이 글의 핵심

fixed나 setprecision 같은 조작자는 한 줄에만 적용되는 옵션이 아니라 스트림에 계속 남는 상태라서, 다른 출력의 형식까지 바꿔 놓기 쉽습니다. 이 글은 이런 상태 플래그와 cin 뒤에 남은 개행, 실패 비트가 켜진 스트림을 재사용할 때의 문제를 짚고, stringstream으로 CSV와 설정 파일을 파싱하는 예제와 파일 I/O 기본을 보여줍니다.

기본 입출력

cin >> x가 C의 scanf("%d", &x)보다 흔히 선호되는 이유는 타입 안전성 때문입니다 — scanf는 형식 문자열(%d)과 실제 인자 타입이 일치하는지 컴파일러가 검사해 주지 않아, 실수로 %d에 double*을 넘겨도 컴파일은 되지만 런타임에 정의되지 않은 동작으로 이어집니다. cin >> x는 operator>>가 x의 실제 타입에 따라 오버로드 해석되므로, 애초에 타입이 다르면 컴파일 에러로 즉시 드러나거나 해당 타입에 맞는 정확한 파싱 로직이 자동으로 선택됩니다. cin >> a >> b처럼 연산자를 연쇄할 수 있는 것도 operator>>가 매번 스트림 자체에 대한 참조를 반환하도록 설계되어 있기 때문이며, 이 체이닝 가능성이 iostream 인터페이스 전반(출력 포매팅 조작자 포함)에 걸쳐 반복되는 핵심 설계 원리입니다.

#include <iostream>
using namespace std;

int main() {
    // 출력
    cout << "Hello World" << endl;
    
    // 입력
    int x;
    cin >> x;
    cout << "입력: " << x << endl;
    
    // 여러 값
    int a, b;
    cin >> a >> b;
    cout << a + b << endl;
}

포매팅

이 조작자(manipulator)들이 특이한 점은 대부분 한 번 설정하면 그 스트림에 계속 적용된다는 것입니다 — fixed, setprecision, hex 같은 것들은 다음에 다른 조작자로 명시적으로 바꾸기 전까지 이후의 모든 출력에 영향을 주는 “상태 플래그”이지, 그 줄 한 번만 적용되는 일회성 옵션이 아닙니다. 그래서 cout << hex << 255 << endl; 다음에 다른 숫자를 그냥 cout << 100으로 출력하면 그 값도 16진수로 나오는데(64), 이는 실수로 겪는 흔한 놀라움입니다. dec로 되돌리는 것을 명시적으로 잊지 않는 습관이나, 특정 값만 임시로 다른 형식으로 출력하고 싶다면 std::format(C++20)처럼 상태를 남기지 않는 방식을 검토하는 것이 이런 혼란을 피하는 방법입니다.

#include <iomanip>

int main() {
    double pi = 3.14159265359;
    
    // 소수점 자릿수
    cout << fixed << setprecision(2) << pi << endl;  // 3.14
    
    // 너비
    cout << setw(10) << 42 << endl;  // "        42"
    
    // 채우기
    cout << setfill('0') << setw(5) << 42 << endl;  // "00042"
    
    // 16진수
    cout << hex << 255 << endl;  // ff
    
    // 8진수
    cout << oct << 64 << endl;  // 100
    
    // 10진수
    cout << dec << 100 << endl;  // 100
}

이 규칙에는 중요한 예외가 하나 있습니다. setw만은 다음 출력 한 번에만 적용되고 바로 0으로 돌아갑니다. 그래서 위의 setw(10) << 42는 42만 10칸에 맞추고, 그 뒤의 endl이나 다음 줄의 값에는 영향을 주지 않습니다. 반면 setfill('0')은 계속 남으므로, 위 예제의 “00042” 이후에 다른 곳에서 setw를 쓰면 공백 대신 0으로 채워집니다. 함수 안에서 스트림 형식을 잠깐 바꿔야 한다면 std::ios_base::fmtflags old = cout.flags();와 cout.precision()으로 기존 상태를 저장했다가 끝에서 되돌리는 것이 안전하고, 라이브러리 함수가 호출하는 쪽의 cout 형식을 바꿔 놓는 것은 흔한 버그의 원인입니다. fixed 없이 setprecision(2)만 쓰면 “소수점 이하 2자리”가 아니라 “유효 숫자 2자리”가 되어 3.14159가 3.1로 출력된다는 점도 자주 헷갈리는 부분입니다.

stringstream

stringstream(그리고 그 특화형인 istringstream, ostringstream)의 핵심 아이디어는 “파일이나 콘솔이 아니라 메모리상의 문자열을 스트림처럼 다룬다”는 것입니다 — 이는 cin/cout에 익숙한 >>/<< 연산자와 조작자(setprecision, hex 등)를 그대로 문자열 파싱·조합에 재사용할 수 있게 해 줍니다. istringstream으로 "123 456 789"에서 세 정수를 뽑아내는 것은 >>가 공백을 구분자로 자동 인식하는 스트림의 기본 동작을 그대로 활용한 것이고, ostringstream으로 여러 타입(문자열, 정수, 실수)을 하나의 문자열로 조합하는 것은 문자열 연결(+)을 반복하는 것보다 타입 변환을 신경 쓸 필요가 없어 더 안전합니다. 이 두 방향(문자열 → 값, 값 → 문자열)이 이후 CSV 파싱, 설정 파일 읽기 등 실전 예시 전반에서 반복해서 쓰이는 기본 도구입니다.

#include <sstream>

int main() {
    // 문자열 → 숫자
    string s = "123 456 789";
    istringstream iss(s);
    
    int a, b, c;
    iss >> a >> b >> c;
    cout << a + b + c << endl;  // 1368
    
    // 숫자 → 문자열
    ostringstream oss;
    oss << "값: " << 42 << ", " << 3.14;
    cout << oss.str() << endl;  // "값: 42, 3.14"
}

istringstream으로 숫자를 파싱할 때는 성공 여부와 남은 문자를 함께 확인해야 합니다. iss >> a는 "12abc"에서 12를 읽고 성공으로 처리하며 "abc"는 스트림에 남겨 둡니다. 입력 전체가 올바른 숫자인지 검증하려면 if (iss >> a && (iss >> std::ws).eof())처럼 끝까지 소비됐는지 확인해야 합니다. 같은 stringstream 객체를 여러 번 재사용할 때는 oss.str("")로 내용을 비우는 것만으로는 부족하고, 이전 파싱에서 켜진 eofbit나 failbit를 clear()로 함께 지워야 다음 파싱이 동작합니다. 성능이 중요한 곳이라면 스트림 객체를 만드는 비용 자체가 작지 않으므로, 단순한 숫자 변환은 C++17의 std::from_chars/std::to_chars가 훨씬 가볍고 locale의 영향도 받지 않습니다.

실전 예시

예시 1: CSV 파싱

이 파서가 스트림을 두 겹으로 쓰는 구조 — 바깥쪽 istringstream으로 전체 CSV를 줄 단위(getline(iss, line))로 나누고, 안쪽 istringstream lineStream으로 각 줄을 다시 쉼표 단위(getline(lineStream, cell, ','))로 나누는 방식 — 는 getline의 세 번째 인자(구분자)를 개행이 아닌 임의의 문자로 지정할 수 있다는 사실을 활용한 것입니다. 이렇게 하면 정규식이나 수동 문자열 순회 없이도 표준 라이브러리 함수 하나로 계층적 구조(줄 → 셀)를 자연스럽게 분해할 수 있습니다. 다만 이 구현은 실제 CSV 스펙의 까다로운 부분(쉼표를 포함한 필드를 감싸는 큰따옴표, 이스케이프된 큰따옴표)은 처리하지 못한다는 한계가 있으며, 이런 엣지 케이스가 실제 데이터에 나타날 가능성이 있다면 전용 CSV 파싱 라이브러리를 쓰는 편이 안전합니다.

#include <sstream>
#include <vector>

vector<vector<string>> parseCSV(const string& csv) {
    vector<vector<string>> result;
    istringstream iss(csv);
    string line;
    
    while (getline(iss, line)) {
        vector<string> row;
        istringstream lineStream(line);
        string cell;
        
        while (getline(lineStream, cell, ',')) {
            row.push_back(cell);
        }
        
        result.push_back(row);
    }
    
    return result;
}

int main() {
    string csv = "Alice,25,90\nBob,30,85\nCharlie,35,95";
    
    auto data = parseCSV(csv);
    
    for (const auto& row : data) {
        for (const auto& cell : row) {
            cout << cell << "\t";
        }
        cout << endl;
    }
}

예시 2: 로그 포매터

(oss << ... << args)라는 표현이 낯설 수 있는데, 이는 C++17의 폴드 표현식(fold expression)으로 가변 인자 템플릿 Args... args의 각 인자를 왼쪽에서 오른쪽으로 순서대로 << 연산자에 연결하는 컴파일 타임 전개입니다 — logger.log("포트: ", 8080)을 호출하면 이 한 줄이 oss << "포트: " << 8080으로 전개되어, 서로 다른 타입(문자열 리터럴과 정수)이 섞인 가변 개수의 인자를 별도의 오버로드나 재귀 없이 한 줄로 처리합니다. ostringstream에 먼저 전부 모아 완성된 문자열을 만든 뒤 마지막에 한 번만 cout으로 내보내는 것도 의도적인 설계입니다 — 만약 타임스탬프와 메시지 조각들을 cout에 직접 여러 번 나눠 쏟아냈다면, 멀티스레드 환경에서 다른 스레드의 로그가 그 사이에 끼어들어 한 줄의 로그가 뒤섞일 위험이 커집니다. 문자열로 완성한 뒤 한 번에 출력하면 섞일 가능성이 크게 줄지만, 표준이 줄 단위 원자성을 보장하는 것은 아닙니다. cout에 대한 동시 쓰기는 데이터 레이스는 아니어도(동기화된 표준 스트림 기준) 문자 단위로 섞일 수 있고, 뒤의 endl도 별도의 호출입니다. 확실한 보장이 필요하면 C++20의 std::osyncstream(std::cout) << ...을 쓰거나 뮤텍스로 출력을 감싸야 합니다. 이 예제의 localtime도 내부 정적 버퍼를 반환하는 스레드 안전하지 않은 함수라서, 여러 스레드에서 로거를 호출한다면 localtime_r/localtime_s로 바꾸는 편이 안전합니다.

#include <sstream>
#include <iomanip>

class Logger {
public:
    template<typename... Args>
    void log(Args... args) {
        ostringstream oss;
        
        // 타임스탬프
        auto now = chrono::system_clock::now();
        auto time = chrono::system_clock::to_time_t(now);
        oss << "[" << put_time(localtime(&time), "%Y-%m-%d %H:%M:%S") << "] ";
        
        // 메시지
        (oss << ... << args);
        
        cout << oss.str() << endl;
    }
};

int main() {
    Logger logger;
    
    logger.log("서버 시작");
    logger.log("포트: ", 8080);
    logger.log("사용자 ", "Alice", " 로그인");
}

예시 3: 테이블 출력

정렬된 표를 만들려면 먼저 각 열에서 가장 긴 문자열의 길이를 알아야 그 열의 너비를 정할 수 있으므로, 이 함수는 데이터를 두 번 순회합니다 — 첫 번째 순회(widths 계산)는 각 열의 최대 너비를 구하고, 두 번째 순회(실제 출력)는 그 너비에 맞춰 setw로 정렬합니다. left를 명시적으로 설정한 것도 중요한데, iostream의 기본 정렬은 오른쪽 정렬(right)이므로 이를 명시하지 않으면 문자열들이 열의 오른쪽 끝에 붙어 표 형태가 어색해집니다. widths[i] + 2처럼 여백을 추가로 더한 것은 열 사이에 최소한의 공백을 두어 값들이 서로 붙어 보이지 않게 하려는 실용적인 선택입니다 — 이런 두 단계(측정 → 정렬 출력) 접근은 고정폭 텍스트 표를 만드는 거의 모든 CLI 도구에서 반복되는 패턴입니다.

void printTable(const vector<vector<string>>& data) {
    // 열 너비 계산
    vector<size_t> widths(data[0].size(), 0);
    
    for (const auto& row : data) {
        for (size_t i = 0; i < row.size(); i++) {
            widths[i] = max(widths[i], row[i].size());
        }
    }
    
    // 출력
    for (const auto& row : data) {
        for (size_t i = 0; i < row.size(); i++) {
            cout << left << setw(widths[i] + 2) << row[i];
        }
        cout << endl;
    }
}

int main() {
    vector<vector<string>> data = {
        {"Name", "Age", "Score"},
        {"Alice", "25", "90"},
        {"Bob", "30", "85"},
        {"Charlie", "35", "95"}
    };

    printTable(data);
}

이 함수는 두 가지 입력을 가정하고 있습니다. data[0]으로 첫 행의 열 개수를 쓰므로 data가 비어 있으면 정의되지 않은 동작이 되고, 어떤 행이 첫 행보다 열이 많으면 widths[i]가 범위를 벗어납니다. 실제 데이터에 쓰려면 앞에서 빈 입력을 걸러 내고 가장 긴 행 기준으로 widths를 늘려 두어야 합니다. 또 setw와 size()는 바이트 수를 기준으로 하므로, 한글처럼 UTF-8에서 한 글자가 3바이트인 문자열이 섞이면 열이 어긋나 보입니다. 터미널에서 한글은 보통 두 칸을 차지하기 때문에 바이트 수도 글자 수도 아닌 “표시 폭”을 계산해야 정확히 맞출 수 있으며, 이 부분은 표준 라이브러리만으로는 해결하기 어렵습니다.

예시 4: 설정 파일 파싱

line.find('#')로 주석 위치를 찾아 그 이후를 잘라내는 처리와, getline(iss, key, '=')로 등호를 구분자 삼아 키·값을 나누는 처리가 이 함수의 핵심입니다 — 앞서 CSV 예시에서 쉼표를 구분자로 썼던 것과 같은 기법을 등호(=)에 그대로 적용한 것이며, getline의 구분자 인자가 임의의 한 문자를 받을 수 있다는 유연성 덕분에 이런 다양한 간이 파일 형식(INI 스타일 설정 파일 등)을 정규식 없이도 손쉽게 처리할 수 있습니다. if (getline(iss, key, '=') && getline(iss, value))처럼 두 getline 호출을 &&로 묶은 것도 눈여겨볼 만합니다 — getline은 성공하면 그 대상 스트림에 대한 참조를 반환하고 스트림은 bool로 변환 가능하므로, 등호가 없는 줄(빈 줄이나 형식이 잘못된 줄)에서는 첫 getline이 실패해 전체 조건이 false가 되어 그 줄을 건너뛰는 안전장치 역할을 합니다.

#include <fstream>
#include <map>

map<string, string> loadConfig(const string& filename) {
    map<string, string> config;
    ifstream file(filename);
    string line;
    
    while (getline(file, line)) {
        // 주석 제거
        size_t commentPos = line.find('#');
        if (commentPos != string::npos) {
            line = line.substr(0, commentPos);
        }
        
        // 공백 제거
        istringstream iss(line);
        string key, value;
        
        if (getline(iss, key, '=') && getline(iss, value)) {
            config[key] = value;
        }
    }
    
    return config;
}

int main() {
    // config.txt:
    // host=localhost
    // port=8080
    // # 주석
    // timeout=30
    
    auto config = loadConfig("config.txt");

    for (const auto& [key, value] : config) {
        cout << key << " = " << value << endl;
    }
}

코드의 “공백 제거” 주석과 달리 이 함수는 실제로 공백을 지우지 않는다는 점에 주의하세요. getline(iss, key, '=')은 등호 앞의 모든 문자를 그대로 가져오므로, 설정 파일에 host = localhost처럼 등호 양쪽에 공백을 두면 키는 "host ", 값은 " localhost"가 되어 config["host"]로 찾을 수 없습니다. 실무에서는 키와 값 양쪽을 find_first_not_of(" \t")/find_last_not_of(" \t")로 다듬는 trim 함수를 거쳐야 합니다. Windows에서 만든 설정 파일을 리눅스에서 읽으면 줄 끝에 \r이 남아 값이 "8080\r"이 되는 문제도 흔하고, 그 값을 stoi로 변환하면 동작하지만 문자열 비교는 실패합니다. 또 파일이 없을 때 이 함수는 빈 맵을 조용히 반환하므로, 아래 “파일 열기 실패” 절처럼 열기 결과를 확인해 오류를 알리는 것이 좋습니다.

파일 I/O

ofstream(출력 전용), ifstream(입력 전용), fstream(양방향)이 별도 클래스로 나뉘어 있는 것은 우연이 아니라, 파일을 여는 목적을 타입 자체로 명시하게 만들어 실수를 줄이려는 설계입니다 — 읽기만 하면 되는 함수에 ofstream을 실수로 넘기는 것 같은 오용은 애초에 타입이 맞지 않아 컴파일되지 않습니다. out << "Hello" << endl;처럼 파일 스트림에도 cout과 완전히 동일한 <</>> 연산자와 조작자가 그대로 적용되는 것은 iostream 라이브러리 설계의 핵심 장점입니다 — 콘솔 출력을 다루던 코드를 거의 그대로 파일 출력에 재사용할 수 있고, 심지어 함수를 ostream&을 매개변수로 받도록 작성하면 cout이든 파일이든 문자열 스트림이든 구분 없이 같은 코드로 처리할 수 있습니다. close()를 명시적으로 호출했지만, 소멸자가 자동으로 파일을 닫아 주므로 스코프를 벗어나는 것만으로도 안전하게 정리되는 RAII 설계라는 점도 기억해 둘 만합니다.

#include <fstream>

// 변수 선언 및 초기화
int main() {
    // 쓰기
    ofstream out("output.txt");
    out << "Hello" << endl;
    out << 42 << endl;
    out.close();
    
    // 읽기
    ifstream in("output.txt");
    string line;
    
    while (getline(in, line)) {
        cout << line << endl;
    }
    
    in.close();
}

조작자

이 조작자들도 앞서 “포매팅” 절에서 설명한 것과 같은 규칙을 따릅니다 — showpos, showbase, boolalpha는 모두 한번 켜면 다음에 명시적으로 끄기 전까지(noshowpos, noboolalpha) 계속 적용되는 스트림 상태 플래그입니다. noboolalpha가 기본값이라는 점이 흥미로운데, C++ 표준 bool은 원래 정수(0/1)로 표현되던 것을 나중에 도입한 타입이라 하위 호환을 위해 기본 출력이 여전히 1/0이며, “true”/“false”라는 사람이 읽기 쉬운 형태를 원하면 boolalpha를 명시적으로 켜야 합니다. 이런 조작자들을 조합해서 쓰는 실전 예로는 로그나 디버그 출력에서 boolalpha로 불리언 값을 읽기 쉽게, showpos로 양수·음수 부호를 항상 명시적으로 드러내는 식이 있습니다.

// 정렬
cout << left << setw(10) << "Left" << endl;
cout << right << setw(10) << "Right" << endl;

// 부호
cout << showpos << 42 << endl;  // +42
cout << noshowpos << 42 << endl;  // 42

// 기수 표시
cout << showbase << hex << 255 << endl;  // 0xff

// 불리언
cout << boolalpha << true << endl;  // true
cout << noboolalpha << true << endl;  // 1

자주 발생하는 문제

문제 1: cin 버퍼

cin >> x는 숫자만 추출하고, 그 숫자 뒤에 입력된 개행 문자(사용자가 엔터를 눌러 남긴 \n)는 스트림 버퍼에 그대로 남겨 둡니다 — >>는 “공백류 문자를 만나면 멈춘다”는 규칙만 따를 뿐, 그 공백류 문자 자체를 소비하지 않습니다. 이후 getline(cin, line)을 호출하면, 이 함수는 사용자의 새 입력을 기다리는 대신 버퍼에 이미 남아있던 그 개행 문자를 곧바로 만나 “빈 줄을 읽었다”고 판단해 즉시 반환해 버립니다. cin.ignore()가 이 문제를 해결하는 이유는, 인자 없이 호출하면 스트림에 남은 문자 하나(바로 그 개행 문자)를 명시적으로 소비해 버퍼를 비워 두기 때문입니다 — >>와 getline을 섞어 쓸 때마다 이 버퍼 잔여물 문제를 마주치므로, 그런 코드 패턴을 쓸 때는 이 한 줄을 습관적으로 끼워 넣는 것이 안전합니다.

// ❌ 버퍼 남음
int x;
cin >> x;
string line;
getline(cin, line);  // 빈 줄 읽음

// ✅ 버퍼 비우기
cin >> x;
cin.ignore();
getline(cin, line);

cin.ignore()는 인자 없이 호출하면 정확히 한 문자만 버리므로, 사용자가 숫자 뒤에 공백을 치고 엔터를 누르면("42 \n") 공백 하나만 지워지고 개행은 남아 같은 문제가 반복됩니다. 줄 끝까지 확실히 버리려면 cin.ignore(numeric_limits<streamsize>::max(), '\n')을 쓰고, 다음 줄 앞의 공백까지 건너뛰어도 된다면 getline(cin >> ws, line)이 가장 간결합니다. 처음 C++로 대화형 입력을 만들 때 대부분 한 번씩 겪는 문제인데, 근본적으로는 >>(토큰 단위)와 getline(줄 단위)을 섞지 않고 모든 입력을 getline으로 한 줄씩 읽은 뒤 istringstream으로 파싱하는 방식으로 통일하면 이런 버퍼 잔여물 문제 자체가 사라집니다.

문제 2: 파일 열기 실패

ifstream file("nonexistent.txt")처럼 존재하지 않는 파일을 여는 시도는 예외를 던지지 않고 조용히 실패합니다 — 생성자는 정상적으로 반환하지만, 그 스트림 객체는 내부적으로 “실패” 상태 플래그가 설정된 채로 만들어집니다. 이 상태를 확인하지 않고 getline을 호출하면, 그 호출 역시 실패 상태에서 아무것도 읽지 못하고 즉시 반환되어 line이 빈 문자열인 채로 루프가 조용히 아무 일도 하지 않고 끝나버립니다 — 크래시도, 에러 메시지도 없이 “왜 파일 내용이 하나도 안 나오지?”라는 혼란만 남습니다. is_open()(또는 if (!file)처럼 스트림 자체를 불리언 문맥에서 검사하는 방식)으로 파일이 실제로 열렸는지 확인하는 습관은 파일 I/O 코드에서 예외 없이 지켜야 할 최소한의 방어입니다.

// ❌ 체크 안함
ifstream file("nonexistent.txt");
string line;
getline(file, line);  // 실패

// ✅ 체크
ifstream file("nonexistent.txt");
if (!file.is_open()) {
    cerr << "파일 열기 실패" << endl;
    return 1;
}

문제 3: 스트림 상태

숫자를 기대하는 자리에 문자열이 입력되면(cin >> x인데 사용자가 “abc”를 입력) >>는 그 값을 읽지 못하고 스트림을 실패 상태(failbit)로 전환합니다 — 문제는 이 실패 상태가 그 이후의 모든 입력 연산에 계속 영향을 미친다는 것입니다: 실패 상태인 스트림에 대한 이후의 >> 호출은 아무것도 읽지 않고 즉시 실패로 반환되므로, 상태를 복구하지 않으면 프로그램이 사실상 그 이후의 모든 입력을 받아들이지 못하는 것처럼 보이는 무한 루프에 빠질 수 있습니다. 복구는 두 단계가 필요합니다 — cin.clear()로 실패 플래그 자체를 지워 스트림을 다시 사용 가능한 상태로 되돌리고, cin.ignore(numeric_limits<streamsize>::max(), '\n')로 잘못 입력된 문자들(“abc”라는 텍스트 자체)을 버퍼에서 실제로 제거해야 합니다 — clear()만 하고 ignore()를 빠뜨리면 상태는 정상으로 보이지만 버퍼에는 여전히 잘못된 텍스트가 남아있어 다음 >>가 다시 즉시 실패하는 결과로 이어집니다.

// 입력 실패 시 스트림 상태 확인
int x;
cin >> x;

if (cin.fail()) {
    cout << "입력 실패" << endl;
    cin.clear();  // 상태 초기화
    cin.ignore(numeric_limits<streamsize>::max(), '\n');  // 버퍼 비우기
}

스트림에는 세 가지 오류 비트가 있고 의미가 다릅니다. failbit는 “원하는 형식으로 읽지 못했다”(숫자 자리에 문자), eofbit는 “입력의 끝에 도달했다”, badbit는 “스트림 자체가 손상되었다”(읽기 오류 등)를 뜻합니다. 그래서 while (!cin.eof()) 형태의 루프는 흔한 버그입니다. 마지막 값을 읽은 뒤에도 eof는 아직 설정되지 않아 루프가 한 번 더 돌고, 그때 읽기가 실패해 마지막 값이 두 번 처리되거나 쓰레기 값이 처리됩니다. 올바른 형태는 while (cin >> x)처럼 읽기 연산 자체를 조건으로 쓰는 것입니다. 입력 끝(Ctrl+D, Ctrl+Z)에 도달한 경우에는 clear()를 해도 더 읽을 데이터가 없으므로, 오류 복구 루프에서는 cin.eof()를 따로 확인해 무한 루프를 막아야 합니다.

FAQ

Q1: cin/cout이 scanf/printf보다 느린 이유는?

A: 기본 설정에서 C++ 스트림은 C 표준 입출력과 버퍼를 동기화하고(sync_with_stdio(true)), cin은 입력 전에 cout을 플러시하도록 묶여 있기(tie) 때문입니다. 대량 입출력을 하는 프로그램에서는 std::ios::sync_with_stdio(false); std::cin.tie(nullptr);로 두 동작을 끄면 대부분의 속도 차이가 사라집니다. 대신 이 설정 이후에는 printf와 cout을 섞어 쓰면 출력 순서가 뒤섞일 수 있습니다.

Q2: endl과 ‘\n’은 언제 구분해야 하나요?

A: endl은 개행 후 버퍼를 플러시하므로, 반복문에서 수만 줄을 출력할 때 쓰면 매 줄 시스템 호출이 일어나 크게 느려집니다. 평소에는 '\n'을 쓰고, 로그처럼 크래시 직전 내용이 반드시 기록되어야 하는 곳에서만 플러시를 명시하는 편이 좋습니다. cerr은 기본적으로 버퍼링되지 않아 매번 바로 출력됩니다.

Q3: stringstream 대신 무엇을 쓸 수 있나요?

A: 문자열 조합은 C++20의 std::format이 형식 지정이 명확하고 스트림 상태를 남기지 않으며, 숫자 변환은 std::to_chars/std::from_chars가 가장 빠르고 locale에 영향받지 않습니다. stringstream은 여러 값을 공백 구분으로 읽거나 사용자 정의 operator<<를 재사용해야 할 때 여전히 편리합니다.

Q4: 바이너리 파일은 어떻게 읽고 쓰나요?

A: std::ios::binary로 열고 read/write로 바이트 단위로 다룹니다. 이 모드를 빠뜨리면 Windows에서는 쓰기 시 \n이 \r\n으로 바뀌고 읽기 시 반대로 변환되어 파일 크기와 내용이 달라집니다. 구조체를 write(reinterpret_cast<const char*>(&s), sizeof(s))로 통째로 쓰는 방식은 패딩과 바이트 순서 때문에 다른 플랫폼과 호환되지 않으므로, 필드별로 직렬화하는 편이 안전합니다.

Q5: 스트림 객체를 재사용하려면?

A: stringstream이라면 ss.str("")로 내용을 비우고 ss.clear()로 오류 비트를 지워야 합니다. 둘 중 하나만 하면 이전 파싱의 eofbit 때문에 다음 읽기가 바로 실패하거나, 이전 내용이 남아 섞입니다. 짧은 범위에서 쓴다면 새 객체를 만드는 편이 실수가 적습니다.

Q6: 파일 스트림 오류를 예외로 받을 수 있나요?

A: file.exceptions(std::ios::failbit | std::ios::badbit);를 설정하면 해당 상태가 될 때 std::ios_base::failure 예외가 발생합니다. 다만 getline 루프가 파일 끝에서 정상적으로 failbit를 켜는 순간에도 예외가 나므로, 루프 기반 읽기와 함께 쓸 때는 badbit만 켜는 편이 다루기 쉽습니다.


같이 보면 좋은 글