C++ stringstream | 문자열 파싱·변환·포맷팅

C++ stringstream | 문자열 파싱·변환·포맷팅

이 글의 핵심

C++ stringstream 완벽 가이드. istringstream으로 문자열 파싱, ostringstream으로 포맷팅, stringstream으로 타입 변환, CSV 파싱, 숫자↔문자열 변환, setw·setprecision 포맷 조정, 실전 문자열 처리 패턴을 상세히 설명합니다.

들어가며: 문자열을 숫자로 바꾸다가 크래시

“123abc”를 int로 변환하려다 실패했다

사용자 입력을 받아서 숫자로 변환하는 코드를 작성했습니다. 하지만 잘못된 입력이 들어오면 프로그램이 죽었습니다.

문제의 코드:

std::string input = "123abc";
int value = std::stoi(input);  // ❌ 예외 발생!

위 코드 설명: stoi는 숫자가 아닌 문자가 나오면 예외를 던지고, “123abc”처럼 앞부분만 숫자인 경우 123만 추출하는 것도 지원하지 않습니다. 사용자 입력처럼 잘못된 값이 자주 들어오는 경로에서는 stringstream으로 >> 후 성공 여부를 확인하는 편이 안전합니다.

원인:

  • stoi는 변환 실패 시 예외 던짐
  • 부분 변환 불가 (123만 추출 못 함)
  • 에러 처리 복잡

stringstream(문자열을 메모리 상의 스트림처럼 읽고 쓰게 해 주는 클래스)은 “문자열을 스트림처럼 읽고 쓰는” 도구라서, >>로 타입별로 끊어 읽거나 부분만 파싱할 수 있습니다. 예외 대신 if (ss >> value)로 성공 여부를 확인할 수 있어서, 사용자 입력·로그 파싱처럼 실패가 자주 나는 경로에서 안전하게 쓸 수 있습니다.
정리: “한 줄에 숫자랑 문자가 섞여 있을 때” 앞부분만 숫자로 읽고 나머지는 그대로 두는 식의 파싱에는 std::stringstream이 편하고, 단순히 정수 하나만 필요하면 std::stoi+try-catch나 C++17 std::from_chars(문자열→숫자 변환을 예외 없이, 오류는 반환값으로 알리는 함수)도 선택지입니다.

해결 후 (아래는 복사해 붙여넣은 뒤 g++ -std=c++17 -o sstream_parse sstream_parse.cpp && ./sstream_parse 로 실행 가능):

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o sstream_parse sstream_parse.cpp && ./sstream_parse
#include <sstream>
#include <iostream>
#include <string>

int main() {
    std::string input = "123abc";
    std::stringstream ss(input);
    int value;
    if (ss >> value) {
        std::cout << "Parsed: " << value << "\n";  // 123
        std::string rest;
        ss >> rest;
        std::cout << "Remaining: " << rest << "\n";  // abc
    } else {
        std::cout << "Parse failed\n";
    }
    return 0;
}

위 코드 설명: stringstream에 문자열을 넣고 ss >> value로 읽으면, 공백·숫자가 아닌 문자 전까지만 파싱해 123을 얻습니다. 실패 시 ss는 fail 상태가 되므로 if (ss >> value)로 성공 여부를 확인할 수 있고, 나머지 “abc”는 ss >> rest로 따로 읽을 수 있습니다.

실행 결과: Parsed: 123Remaining: abc 가 각각 한 줄씩 출력됩니다.

이 글을 읽으면:

  • stringstream으로 문자열을 파싱할 수 있습니다.
  • 타입 변환을 안전하게 할 수 있습니다.
  • 문자열을 조립하고 포맷팅할 수 있습니다.
  • 실전에서 유용한 문자열 처리 패턴을 익힐 수 있습니다.

목차

  1. stringstream 기초
  2. 문자열 파싱
  3. 문자열 조립
  4. 포맷팅
  5. 매니퓰레이터 완전 가이드
  6. 사용자 정의 타입 포맷팅
  7. C++20 std::format
  8. 자주 발생하는 문제와 해결법
  9. 성능 최적화 팁
  10. 프로덕션 패턴
  11. 실전 패턴

문제 시나리오: 실제로 겪는 stringstream 문제들

시나리오 1: 로그 파일 파싱에서 숫자가 0으로 나온다

로그 형식: "2024-03-10 14:23:45 [INFO] User 12345 connected"

문제: ss >> timestamp >> level >> userId로 파싱했는데 userId가 항상 0이다.

원인: [INFO]는 공백으로 구분된 토큰이 아니라 [로 시작하는 하나의 토큰이다. >>[INFO] 전체를 읽어서 int 변환에 실패하고, 스트림이 fail 상태가 된다.

// ❌ 잘못된 파싱
std::istringstream iss("2024-03-10 14:23:45 [INFO] User 12345 connected");
std::string timestamp, level;
int userId;
iss >> timestamp >> level >> userId;  // level이 "[INFO]"가 되고, userId는 0

시나리오 2: 포맷 플래그가 다음 출력에 영향

// ❌ 의도치 않은 결과
std::ostringstream oss;
oss << std::hex << 255;  // "ff"
oss << std::showbase << 256;  // "0x100" - hex가 아직 적용됨!

원인: hex, dec, oct, fixed, scientific 등은 한 번 설정하면 계속 유지된다. 다음 출력 전에 std::decstd::defaultfloat로 초기화해야 한다.

시나리오 3: stringstream 재사용 시 이전 데이터가 남는다

std::stringstream ss;
ss << "Hello";
std::string s1;
ss >> s1;  // s1 = "Hello"

ss << "World";  // "Hello" 뒤에 "World"가 붙음!
std::string s2;
ss >> s2;  // s2 = "World" (읽기 위치가 끝에 있어서)

원인: str()로 내용을 바꾸지 않고 <<만 하면 기존 버퍼에 이어 붙는다. 재사용 시 ss.clear(); ss.str("");로 초기화해야 한다.


1. stringstream 기초

stringstream이란?

**std::stringstream**은 메모리 안의 문자열을 스트림처럼 다룹니다. <<로 숫자·문자열을 이어 붙이면 내부 버퍼에 쌓이고, **str()**로 최종 std::string을 꺼낼 수 있습니다. std::cout처럼 쓰되 출력 대상이 화면이 아니라 문자열이라서, “문자열을 조립할 때” sprintf 대신 타입 안전하게 쓸 수 있습니다.

#include <sstream>

// 문자열을 스트림처럼 다룸
std::stringstream ss;
ss << "Hello " << 123 << " " << 3.14;

std::string result = ss.str();
std::cout << result << "\n";  // "Hello 123 3.14"

위 코드 설명: stringstream에 <<로 문자열·숫자를 이어 붙이면 메모리 버퍼에 쌓이고, str()로 최종 std::string을 꺼냅니다. cout처럼 쓰되 출력 대상이 문자열이라 sprintf 없이 타입 안전하게 문자열을 조립할 수 있습니다.

특징:

  • 메모리 내 문자열 스트림
  • << 연산자로 쓰기
  • >> 연산자로 읽기
  • str() 메서드로 문자열 추출

stringstream 클래스 관계도

flowchart TB
    subgraph "스트림 계층"
        ios[basic_ios]
        istream[basic_istream]
        ostream[basic_ostream]
        iostream[basic_iostream]
    end
    
    subgraph "stringstream 계층"
        istringstream[istringstream]
        ostringstream[ostringstream]
        stringstream[stringstream]
    end
    
    ios --> istream
    ios --> ostream
    istream --> iostream
    ostream --> iostream
    istream --> istringstream
    ostream --> ostringstream
    iostream --> stringstream
    
    style istringstream fill:#e1f5fe
    style ostringstream fill:#fff3e0
    style stringstream fill:#e8f5e9

위 다이어그램 설명: istringstream은 읽기 전용, ostringstream은 쓰기 전용, stringstream은 읽기·쓰기 모두 가능하다. 모두 basic_ios를 상속해 fail(), eof(), clear() 등의 상태 메서드를 공유한다.

istringstream vs ostringstream

**istringstream**은 이미 있는 문자열에서만 읽기(>>)가 가능하고, **ostringstream**은 쓰기(<<)만 해서 문자열을 만든 뒤 str()로 꺼냅니다. **stringstream**은 읽기·쓰기 둘 다 가능해서, 같은 스트림에 쓰고 나서 다시 읽는 패턴(예: 포맷 후 파싱)에 씁니다. 용도에 맞춰 골라 쓰면 의도가 드러나고 실수도 줄어듭니다.

// 읽기 전용
std::istringstream iss("123 456");
int a, b;
iss >> a >> b;

// 쓰기 전용
std::ostringstream oss;
oss << "Result: " << 42;
std::string result = oss.str();

// 읽기/쓰기
std::stringstream ss;
ss << "Data";
std::string data;
ss >> data;

위 코드 설명: istringstream은 생성자에 넘긴 문자열에서만 >>로 읽고, ostringstream은 <<로만 쓰다가 str()로 결과 문자열을 얻습니다. stringstream은 읽기·쓰기 모두 가능해 같은 버퍼에 썼다가 다시 읽는 패턴에 씁니다.


2. 문자열 파싱

기본 타입 추출

std::string input = "42 3.14 hello";
std::stringstream ss(input);

int i;
double d;
std::string s;

ss >> i >> d >> s;

std::cout << "int: " << i << "\n";      // 42
std::cout << "double: " << d << "\n";   // 3.14
std::cout << "string: " << s << "\n";   // hello

위 코드 설명: >>는 공백으로 구분된 토큰을 타입에 맞게 추출합니다. int·double·string 순서로 선언했으므로 “42”, “3.14”, “hello”가 각각 i, d, s에 들어갑니다. 같은 스트림을 여러 번 >>로 읽으면 순서대로 소비됩니다.

안전한 변환

template <typename T>
bool tryParse(const std::string& str, T& result) {
    std::stringstream ss(str);
    ss >> result;
    
    // 변환 성공 && 전체 문자열 소비
    return !ss.fail() && ss.eof();
}

int main() {
    int value;
    
    if (tryParse("123", value)) {
        std::cout << "Parsed: " << value << "\n";
    }
    
    if (!tryParse("123abc", value)) {
        std::cout << "Parse failed\n";
    }
}

위 코드 설명: stringstream에 str을 넣고 >> result로 읽은 뒤, fail()이 아니고 eof()이면 “전체 문자열이 해당 타입 하나로 정확히 변환된” 경우입니다. “123abc”는 숫자 뒤에 문자가 남으므로 eof()가 아니어서 false가 됩니다.

공백으로 구분된 데이터

std::string line = "10 20 30 40 50";
std::stringstream ss(line);

std::vector<int> numbers;
int num;
while (ss >> num) {
    numbers.push_back(num);
}

// numbers: {10, 20, 30, 40, 50}

위 코드 설명: while (ss >> num)은 읽기가 성공하는 동안만 루프를 돌아, 공백으로 구분된 정수를 끝까지 벡터에 넣습니다. 스트림이 끝나거나 타입이 맞지 않으면 루프가 끝납니다.

CSV 파싱

std::vector<std::string> parseCSV(const std::string& line) {
    std::vector<std::string> result;
    std::stringstream ss(line);
    std::string cell;
    
    while (std::getline(ss, cell, ',')) {
        result.push_back(cell);
    }
    
    return result;
}

int main() {
    std::string csv = "Alice,25,Engineer";
    auto fields = parseCSV(csv);
    
    // fields: {"Alice", "25", "Engineer"}
}

위 코드 설명: getline(ss, cell, ’,‘)는 구분자 ’,’ 전까지를 cell에 넣고, 루프로 반복하면 한 줄을 쉼표 기준으로 나눈 벡터를 얻습니다. CSV 한 줄을 필드 배열로 바꿀 때 쓰는 기본 패턴입니다.

복잡한 파싱

struct Person {
    std::string name;
    int age;
    double salary;
};

Person parsePerson(const std::string& str) {
    // 형식: "Name:Alice Age:25 Salary:50000.5"
    std::stringstream ss(str);
    Person p;
    
    std::string token;
    while (ss >> token) {
        size_t pos = token.find(':');
        if (pos == std::string::npos) continue;
        
        std::string key = token.substr(0, pos);
        std::string value = token.substr(pos + 1);
        
        if (key == "Name") {
            p.name = value;
        } else if (key == "Age") {
            p.age = std::stoi(value);
        } else if (key == "Salary") {
            p.salary = std::stod(value);
        }
    }
    
    return p;
}

위 코드 설명: ss >> token으로 “Name:Alice” 같은 토큰을 하나씩 읽고, find(’:‘)로 키와 값을 나눈 뒤 key가 “Name”/“Age”/“Salary”일 때마다 value를 해당 타입으로 변환해 구조체에 넣습니다. 키=값 형태가 반복되는 문자열 파싱 패턴입니다.


3. 문자열 조립

기본 조립

std::ostringstream oss;

oss << "User: " << "Alice" << "\n";
oss << "Age: " << 25 << "\n";
oss << "Score: " << 95.5 << "\n";

std::string result = oss.str();

위 코드 설명: ostringstream에 <<로 여러 줄을 이어 붙이고, str()로 한 번에 문자열을 꺼냅니다. cout에 쓰던 것처럼 포맷만 바꿔 쓰면 되고, 재할당 없이 버퍼가 커지므로 반복 연결보다 효율적일 수 있습니다.

반복문에서 조립

std::vector<int> numbers = {1, 2, 3, 4, 5};
std::ostringstream oss;

oss << "[";
for (size_t i = 0; i < numbers.size(); ++i) {
    if (i > 0) oss << ", ";
    oss << numbers[i];
}
oss << "]";

std::string result = oss.str();  // "[1, 2, 3, 4, 5]"

위 코드 설명: [ 로 시작해 숫자들을 ”, “로 구분해 붙이고 ] 로 닫습니다. 첫 항목만 쉼표 없이 넣기 위해 first 플래그로 구분하는 흔한 패턴입니다.

SQL 쿼리 생성

std::string buildQuery(const std::string& table, 
                       const std::map<std::string, std::string>& conditions) {
    std::ostringstream oss;
    oss << "SELECT * FROM " << table << " WHERE ";
    
    bool first = true;
    for (const auto& [key, value] : conditions) {
        if (!first) oss << " AND ";
        oss << key << " = '" << value << "'";
        first = false;
    }
    
    return oss.str();
}

int main() {
    std::map<std::string, std::string> cond = {
        {"name", "Alice"},
        {"age", "25"}
    };
    
    std::string query = buildQuery("users", cond);
    // "SELECT * FROM users WHERE name = 'Alice' AND age = '25'"
}

위 코드 설명: “SELECT * FROM ” + table + ” WHERE ” 에 이어서, map의 각 key=value를 ” AND “로 연결해 조건 절을 만듭니다. first로 첫 조건만 구분해 앞에 ” AND “가 붙지 않게 합니다. 동적 쿼리 조립에 ostringstream이 자주 쓰입니다.


4. 포맷팅

정수 포맷

#include <iomanip>

std::ostringstream oss;

// 너비 지정
oss << std::setw(5) << 42;  // "   42"

// 0으로 채우기
oss << std::setfill('0') << std::setw(5) << 42;  // "00042"

// 16진수
oss << std::hex << 255;  // "ff"
oss << std::showbase << std::hex << 255;  // "0xff"

// 8진수
oss << std::oct << 64;  // "100"

위 코드 설명: setw(n)은 최소 너비, setfill(c)는 빈 자리 채울 문자, hex/oct는 진법을 바꿉니다. showbase면 0x, 0 같은 접두사가 붙습니다. iomanip과 플래그로 정수 포맷을 제어할 수 있습니다.

실수 포맷

std::ostringstream oss;

double pi = 3.14159265359;

// 고정 소수점
oss << std::fixed << std::setprecision(2) << pi;  // "3.14"

// 과학적 표기법
oss << std::scientific << pi;  // "3.14e+00"

// 기본 (자동 선택)
oss << std::defaultfloat << pi;  // "3.14159"

위 코드 설명: fixed는 고정 소수점(setprecision이 소수 자리 수), scientific은 지수 표기, defaultfloat는 원래대로 자동 선택입니다. 포맷 플래그는 한 번 설정하면 다음 출력까지 유지됩니다.

정렬

std::ostringstream oss;

// 왼쪽 정렬
oss << std::left << std::setw(10) << "Name" << "Age\n";
oss << std::left << std::setw(10) << "Alice" << 25 << "\n";

// 오른쪽 정렬
oss << std::right << std::setw(10) << "Total" << 100 << "\n";

// 결과:
// Name      Age
// Alice     25
//     Total100

위 코드 설명: left/right로 정렬 방향을 정하고 setw로 열 너비를 맞춥니다. left면 왼쪽 정렬, right면 오른쪽 정렬이라 숫자나 제목을 열 맞춰 출력할 때 씁니다.

테이블 포맷

void printTable(const std::vector<std::tuple<std::string, int, double>>& data) {
    std::ostringstream oss;
    
    // 헤더
    oss << std::left << std::setw(15) << "Name"
        << std::right << std::setw(10) << "Age"
        << std::right << std::setw(15) << "Salary" << "\n";
    oss << std::string(40, '-') << "\n";
    
    // 데이터
    for (const auto& [name, age, salary] : data) {
        oss << std::left << std::setw(15) << name
            << std::right << std::setw(10) << age
            << std::right << std::setw(15) << std::fixed 
            << std::setprecision(2) << salary << "\n";
    }
    
    std::cout << oss.str();
}

int main() {
    std::vector<std::tuple<std::string, int, double>> data = {
        {"Alice", 25, 50000.50},
        {"Bob", 30, 60000.75},
        {"Charlie", 28, 55000.00}
    };
    
    printTable(data);
}

위 코드 설명: 헤더 행에 setw로 열 너비를 맞추고, 구분선을 string(40,’-‘)으로 넣은 뒤, 각 행을 left/right와 setw로 같은 너비로 출력합니다. fixed와 setprecision으로 소수 자리도 맞추면 표 형태 문자열을 만들 수 있습니다.


5. 매니퓰레이터 완전 가이드

<iomanip><ios>의 모든 주요 매니퓰레이터를 한눈에 정리합니다.

정수 매니퓰레이터

#include <iomanip>
#include <sstream>
#include <iostream>

int main() {
    std::ostringstream oss;
    int n = 42;
    
    // 진법: dec(10), hex(16), oct(8)
    oss << std::dec << n;   // "42"
    oss.str(""); oss << std::hex << n;   // "2a"
    oss.str(""); oss << std::oct << n;   // "52"
    
    // 접두사: showbase (0x, 0)
    oss.str(""); oss << std::showbase << std::hex << 255;  // "0xff"
    
    // 대문자: uppercase
    oss.str(""); oss << std::uppercase << std::hex << 255;  // "0XFF"
    
    // 너비·채우기: setw, setfill (setw는 다음 출력에만 적용!)
    oss.str(""); oss << std::setw(6) << std::setfill('0') << 42;  // "000042"
    
    // noshowbase, nouppercase로 초기화
    oss.str(""); oss << std::noshowbase << std::nouppercase << std::dec;
    
    std::cout << oss.str() << "\n";
    return 0;
}

실수 매니퓰레이터

#include <iomanip>
#include <sstream>

void demonstrateFloatManipulators() {
    std::ostringstream oss;
    double pi = 3.14159265359;
    
    // fixed: 고정 소수점, setprecision = 소수 자릿수
    oss << std::fixed << std::setprecision(2) << pi;  // "3.14"
    
    // scientific: 지수 표기
    oss.str(""); oss << std::scientific << std::setprecision(2) << pi;  // "3.14e+00"
    
    // defaultfloat: 자동 선택 (원래대로)
    oss.str(""); oss << std::defaultfloat << std::setprecision(6) << pi;  // "3.14159"
    
    // showpoint: 소수점 항상 표시
    oss.str(""); oss << std::showpoint << 42.0;  // "42.000000"
}

정렬·부호 매니퓰레이터

// left, right, internal
oss << std::left << std::setw(10) << "Name";   // "Name      "
oss << std::right << std::setw(10) << 42;      // "        42"
oss << std::internal << std::setw(10) << -42;  // "-       42"

// showpos: 양수에 + 표시
oss << std::showpos << 42;  // "+42"
oss << std::noshowpos;      // 초기화

매니퓰레이터 적용 순서 주의

// setfill은 setw보다 먼저 써도 됨 (상태로 유지)
// setw는 "다음 한 번" 출력에만 적용
oss << std::setfill('-') << std::setw(8) << 42;  // "------42"
oss << 100;  // "100" (setw 적용 안 됨! setfill은 적용됨)

6. 사용자 정의 타입 포맷팅

operator<<operator>>를 오버로드하면 stringstream으로 사용자 정의 타입을 직렬화·역직렬화할 수 있다.

기본 operator<< 오버로드

#include <sstream>
#include <string>
#include <iostream>

struct Point {
    double x, y;
    
    // ostream에 출력 (cout, ostringstream 모두 사용 가능)
    friend std::ostream& operator<<(std::ostream& os, const Point& p) {
        return os << "(" << p.x << ", " << p.y << ")";
    }
    
    // istream에서 입력
    friend std::istream& operator>>(std::istream& is, Point& p) {
        char open, comma, close;
        if (is >> open >> p.x >> comma >> p.y >> close &&
            open == '(' && comma == ',' && close == ')') {
            return is;
        }
        is.setstate(std::ios::failbit);
        return is;
    }
};

int main() {
    Point p{3.14, 2.71};
    std::ostringstream oss;
    oss << "Point: " << p;  // "Point: (3.14, 2.71)"
    std::cout << oss.str() << "\n";
    
    std::istringstream iss("(1.0, 2.0)");
    Point p2;
    if (iss >> p2) {
        std::cout << "Parsed: " << p2.x << ", " << p2.y << "\n";
    }
    return 0;
}

enum과 stringstream

enum class LogLevel { Debug, Info, Warning, Error };

std::ostream& operator<<(std::ostream& os, LogLevel level) {
    switch (level) {
        case LogLevel::Debug:   return os << "DEBUG";
        case LogLevel::Info:    return os << "INFO";
        case LogLevel::Warning: return os << "WARNING";
        case LogLevel::Error:   return os << "ERROR";
        default: return os << "UNKNOWN";
    }
}

std::istream& operator>>(std::istream& is, LogLevel& level) {
    std::string s;
    if (is >> s) {
        if (s == "DEBUG")   level = LogLevel::Debug;
        else if (s == "INFO") level = LogLevel::Info;
        else if (s == "WARNING") level = LogLevel::Warning;
        else if (s == "ERROR") level = LogLevel::Error;
        else is.setstate(std::ios::failbit);
    }
    return is;
}

포맷 옵션을 가진 커스텀 출력

struct Money {
    long long cents;
    
    friend std::ostream& operator<<(std::ostream& os, const Money& m) {
        // fixed, setprecision과 함께 사용
        return os << std::fixed << std::setprecision(2) 
                  << (m.cents / 100.0) << " USD";
    }
};

7. C++20 std::format

C++20부터 std::format이 도입되어, Python 스타일의 포맷 문자열로 타입 안전하게 문자열을 만들 수 있다. stringstream보다 가독성성능 면에서 유리한 경우가 많다.

기본 사용법

#include <format>
#include <string>
#include <iostream>

int main() {
    // g++ -std=c++20 필요
    std::string s = std::format("Hello, {}!", "World");  // "Hello, World!"
    std::cout << s << "\n";
    
    int a = 42;
    double b = 3.14;
    std::string s2 = std::format("a={}, b={:.2f}", a, b);  // "a=42, b=3.14"
    std::cout << s2 << "\n";
    
    return 0;
}

format vs stringstream 비교

// stringstream
std::ostringstream oss;
oss << "User " << name << " (ID: " << std::setw(6) << std::setfill('0') 
    << id << ") logged in";
std::string msg = oss.str();

// C++20 format (동일 결과)
std::string msg = std::format("User {} (ID: {:06d}) logged in", name, id);

format 지정자

// 정수: d(10진수), x(16진수), o(8진수), b(2진수)
std::format("{:d}", 255);   // "255"
std::format("{:x}", 255);   // "ff"
std::format("{:06x}", 255); // "0000ff"

// 실수: f(고정), e(지수), g(자동)
std::format("{:.2f}", 3.14159);  // "3.14"
std::format("{:.2e}", 3.14159);  // "3.14e+00"

// 정렬·채우기
std::format("{:<10}", "left");   // "left      "
std::format("{:>10}", "right");  // "     right"
std::format("{:*^10}", "mid");   // "*mid**"

format이 stringstream보다 나은 경우

상황stringstreamstd::format
로케일 독립복잡기본 로케일 독립
포맷 가독성setw, setfill 등 여러 줄한 줄 "{:06d}"
성능동적 할당·스트림 오버헤드컴파일 타임 포맷 검사, 더 적은 할당
타입 안전성런타임 오류 가능컴파일 타임 검사

주의: std::format은 C++20 이상 필요. MSVC 2019 16.10+, GCC 13+, Clang 14+에서 지원.


8. 자주 발생하는 문제와 해결법

문제 1: eof() 체크 없이 파싱 후 추가 읽기

// ❌ 잘못된 코드
std::istringstream iss("123");
int a, b;
iss >> a >> b;  // b는 0, 읽기 실패
if (!iss.fail()) {  // b 읽기 실패했지만 fail()은 아직 false일 수 있음
    // ...
}

해결: eof()로 전체 소비 여부 확인, 또는 fail() 체크

// ✅ 올바른 코드
std::istringstream iss("123");
int a, b;
iss >> a >> b;
if (iss && iss.eof()) {
    // a, b 모두 정상 파싱되고 전체 문자열 소비됨
}

문제 2: getline과 >> 혼용 시 줄바꿈 처리

// ❌ 의도치 않은 빈 줄
std::istringstream iss("42\nhello");
int n;
std::string s;
iss >> n;        // "42" 읽음, '\n'은 버퍼에 남음
std::getline(iss, s);  // s = "" (줄바꿈만 읽음!)

해결: >>iss.ignore() 또는 getline으로 빈 줄 스킵

// ✅ 올바른 코드
iss >> n;
iss.ignore(1, '\n');  // 또는 std::getline(iss, dummy);
std::getline(iss, s);  // s = "hello"

문제 3: CSV 셀에 쉼표가 포함된 경우

입력: "Alice,\"Engineer, Senior\",25"
단순 getline(ss, cell, ',')는 "Engineer, Senior"를 제대로 못 나눔

해결: 따옴표 감싼 필드는 getline으로 한 번에 읽은 뒤, 수동으로 따옴표를 제거하는 로직을 추가한다. 또는 전문 CSV 라이브러리(libcsv 등) 사용을 고려한다.

문제 4: clear() 없이 str()만 호출

// ❌ fail 상태가 남아 있음
std::stringstream ss("abc");
int n;
ss >> n;  // 실패, ss.fail() == true
ss.str("123");  // 내용만 바꿈
ss >> n;  // 여전히 실패! (fail 비트가 남아 있음)

해결: clear()로 상태 초기화

// ✅ 올바른 코드
ss.clear();
ss.str("123");
ss >> n;  // 성공

문제 5: 포맷 플래그 누수

// ❌ 다음 코드에 hex가 영향
void logHex(int n) {
    std::ostringstream oss;
    oss << std::hex << n;
    return oss.str();
}
// 이후 다른 ostringstream에서 dec 기대했는데 hex로 출력됨 (같은 스트림 재사용 시)

해결: 함수 내 로컬 스트림 사용(이미 그렇게 함), 또는 사용 후 std::dec로 복원

// ✅ 스트림을 로컬로 쓰면 다른 코드에 영향 없음
// 공유 스트림을 쓴다면: oss << std::dec;

9. 성능 최적화 팁

1. reserve로 사전 할당

// ostringstream은 내부적으로 버퍼를 확장함
// 반복 조립 시 str() 호출 횟수 줄이기
std::ostringstream oss;
oss << "prefix";
for (int i = 0; i < 1000; ++i) {
    oss << "," << i;  // 내부 버퍼가 필요 시 자동 확장
}
std::string result = oss.str();

: 매우 큰 문자열을 만들 때는 std::string::reserve로 미리 공간을 잡고 +=로 붙이는 것도 고려. 단, stringstream이 타입 변환·포맷팅을 한 번에 처리해 주므로 편의성과 성능을 trade-off해서 선택.

2. from_chars로 순수 숫자 변환

// 숫자→문자열, 문자열→숫자만 필요할 때
#include <charconv>

std::string intToString(int n) {
    char buf[32];
    auto [ptr, ec] = std::to_chars(buf, buf + sizeof(buf), n);
    return std::string(buf, ptr);
}

bool stringToInt(const std::string& s, int& out) {
    int value;
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
    return ec == std::errc{} && ptr == s.data() + s.size();
}

장점: 예외 없음, stringstream보다 빠름. 단점: 포맷팅(소수점, 정렬 등)은 직접 구현해야 함.

3. stringstream 재사용 시 clear + str

// ❌ 매번 새로 생성 (할당 비용)
for (const auto& line : lines) {
    std::istringstream iss(line);
    // 파싱...
}

// ✅ 하나 재사용
std::istringstream iss;
for (const auto& line : lines) {
    iss.clear();
    iss.str(line);
    // 파싱...
}

4. C++20 format 사용

// 성능·가독성 모두 유리
auto msg = std::format("{}: {} ({} bytes)", timestamp, level, size);

10. 프로덕션 패턴

패턴 1: 스레드 안전 로그 버퍼

#include <sstream>
#include <mutex>
#include <iostream>

class ThreadSafeLogger {
    std::ostringstream buffer;
    std::mutex mtx;
    
public:
    template <typename T>
    ThreadSafeLogger& operator<<(const T& value) {
        std::lock_guard lock(mtx);
        buffer << value;
        return *this;
    }
    
    ~ThreadSafeLogger() {
        std::lock_guard lock(mtx);
        std::cout << "[LOG] " << buffer.str() << "\n";
    }
};

패턴 2: 에러 메시지 빌더

class ErrorBuilder {
    std::ostringstream oss;
public:
    ErrorBuilder& context(const std::string& ctx) {
        oss << "[" << ctx << "] ";
        return *this;
    }
    ErrorBuilder& code(int c) {
        oss << "code=" << c << " ";
        return *this;
    }
    ErrorBuilder& detail(const std::string& d) {
        oss << d;
        return *this;
    }
    std::string build() { return oss.str(); }
};

// 사용: throw std::runtime_error(ErrorBuilder().context("DB").code(1001).detail("connection failed").build());

패턴 3: 설정 파일 파싱 (키=값)

std::map<std::string, std::string> parseConfig(const std::string& content) {
    std::map<std::string, std::string> config;
    std::istringstream iss(content);
    std::string line;
    while (std::getline(iss, line)) {
        if (line.empty() || line[0] == '#') continue;
        size_t pos = line.find('=');
        if (pos != std::string::npos)
            config[line.substr(0, pos)] = line.substr(pos + 1);
    }
    return config;
}

패턴 4: 버전 문자열 파싱

struct Version {
    int major, minor, patch;
    friend std::istream& operator>>(std::istream& is, Version& v) {
        char dot;
        return is >> v.major >> dot >> v.minor >> dot >> v.patch;
    }
};
// "1.2.3" -> Version{1,2,3}

11. 실전 패턴

패턴 1: 문자열 분할

std::vector<std::string> split(const std::string& str, char delimiter) {
    std::vector<std::string> result;
    std::stringstream ss(str);
    std::string token;
    
    while (std::getline(ss, token, delimiter)) {
        result.push_back(token);
    }
    
    return result;
}

int main() {
    std::string path = "/home/user/documents/file.txt";
    auto parts = split(path, '/');
    // {"", "home", "user", "documents", "file.txt"}
}

위 코드 설명: getline(ss, token, delimiter)로 구분자 전까지를 token에 넣고, 루프로 반복해 벡터에 넣습니다. ’/‘로 split하면 경로가 “home”, “user”, “documents”, “file.txt”처럼 나뉘고, 맨 앞 빈 문자열은 첫 문자가 구분자일 때 나옵니다.

패턴 2: 문자열 조인

template <typename Container>
std::string join(const Container& items, const std::string& delimiter) {
    std::ostringstream oss;
    bool first = true;
    
    for (const auto& item : items) {
        if (!first) oss << delimiter;
        oss << item;
        first = false;
    }
    
    return oss.str();
}

int main() {
    std::vector<std::string> words = {"Hello", "World", "C++"};
    std::string result = join(words, ", ");
    // "Hello, World, C++"
}

위 코드 설명: 첫 항목만 구분자 없이 넣고, 그 다음부터는 delimiter를 앞에 붙여서 이어 붙입니다. ostringstream으로 조립한 뒤 str()로 반환하면 vector를 “Hello, World, C++” 같은 하나의 문자열로 만들 수 있습니다.

패턴 3: 타입 변환 헬퍼

template <typename T>
std::string toString(const T& value) {
    std::ostringstream oss;
    oss << value;
    return oss.str();
}

template <typename T>
T fromString(const std::string& str) {
    std::istringstream iss(str);
    T value;
    iss >> value;
    return value;
}

int main() {
    int num = 42;
    std::string str = toString(num);  // "42"
    
    int parsed = fromString<int>("123");  // 123
    double d = fromString<double>("3.14");  // 3.14
}

위 코드 설명: toString은 ostringstream에 <<로 넣어 str()로 반환하고, fromString은 istringstream에서 >>로 읽어 반환합니다. operator<<를 지원하는 타입이면 어떤 타입이든 문자열과 상호 변환할 수 있는 헬퍼입니다.

패턴 4: 로그 메시지 생성

class Logger {
    std::ostringstream buffer;
    
public:
    template <typename T>
    Logger& operator<<(const T& value) {
        buffer << value;
        return *this;
    }
    
    ~Logger() {
        std::cout << "[LOG] " << buffer.str() << "\n";
    }
};

int main() {
    Logger() << "User " << "Alice" << " logged in at " << 1234567890;
    // [LOG] User Alice logged in at 1234567890
}

위 코드 설명: Logger 임시 객체에 <<로 메시지를 이어 붙이면 buffer에 쌓이고, 소멸자에서 “[LOG] ” + buffer.str()을 cout에 출력합니다. 스트림처럼 썼다가 한 번에 로그로 남기는 RAII 패턴입니다.

패턴 5: 명령줄 파싱

std::map<std::string, std::string> parseArgs(const std::string& cmdline) {
    std::map<std::string, std::string> args;
    std::stringstream ss(cmdline);
    std::string token;
    
    while (ss >> token) {
        if (token.starts_with("--")) {
            size_t pos = token.find('=');
            if (pos != std::string::npos) {
                std::string key = token.substr(2, pos - 2);
                std::string value = token.substr(pos + 1);
                args[key] = value;
            } else {
                args[token.substr(2)] = "true";
            }
        }
    }
    
    return args;
}

int main() {
    std::string cmd = "--host=localhost --port=8080 --debug";
    auto args = parseArgs(cmd);
    
    // args: {{"host", "localhost"}, {"port", "8080"}, {"debug", "true"}}
}

위 코드 설명: 공백으로 토큰을 나누고, — 로 시작하면 key=value 형태인지 확인해 ’=’ 위치로 자르고, = 가 없으면 값은 “true”로 둡니다. —host=localhost —debug 같은 명령줄을 map으로 파싱하는 패턴입니다.

패턴 6: JSON 간단 생성

class JsonBuilder {
    std::ostringstream oss;
    bool first = true;
    
public:
    JsonBuilder() { oss << "{"; }
    
    JsonBuilder& add(const std::string& key, const std::string& value) {
        if (!first) oss << ",";
        oss << "\"" << key << "\":\"" << value << "\"";
        first = false;
        return *this;
    }
    
    JsonBuilder& add(const std::string& key, int value) {
        if (!first) oss << ",";
        oss << "\"" << key << "\":" << value;
        first = false;
        return *this;
    }
    
    std::string build() {
        oss << "}";
        return oss.str();
    }
};

int main() {
    std::string json = JsonBuilder()
        .add("name", "Alice")
        .add("age", 25)
        .add("city", "Seoul")
        .build();
    
    // {"name":"Alice","age":25,"city":"Seoul"}
}

위 코드 설명: add(key, value)나 add(key, int)로 키-값을 이어 붙이고, 첫 항목만 쉼표 없이 넣기 위해 first 플래그를 씁니다. build()에서 } 로 닫고 str()로 반환해 간단한 JSON 객체 문자열을 만듭니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ optional·variant·any | “nullptr 체크 지겹다” C++17 타입 안전 처리
  • C++ 컴파일 타임 최적화 | constexpr·PCH·모듈·ccache·Unity 빌드 [#15-3]
  • C++ 로깅·Assertion | 프로덕션 간헐적 크래시, 로그 없이 재현 불가일 때

이 글에서 다루는 키워드 (관련 검색어)

C++ stringstream, istringstream ostringstream, 문자열 파싱, 포맷팅, setw setprecision, CSV 파싱 등으로 검색하시면 이 글이 도움이 됩니다.

정리

항목용도
stringstream읽기/쓰기
istringstream읽기 전용
ostringstream쓰기 전용
>>파싱
<<조립
str()문자열 추출
setw()너비 지정
setprecision()소수점 자릿수

핵심 원칙:

  1. 파싱은 istringstream>>
  2. 조립은 ostringstream<<
  3. 안전한 변환은 실패 체크
  4. 포맷팅은 <iomanip> 활용
  5. 재사용 시 clear() + str("")

구현 체크리스트

  • 파싱 시 fail() 또는 eof() 체크
  • getline>> 혼용 시 ignore() 처리
  • stringstream 재사용 시 clear() + str("")
  • 포맷 플래그 사용 후 dec/defaultfloat 등으로 복원
  • C++20 이상이면 std::format 검토
  • 성능 중요 시 std::from_chars/std::to_chars 고려

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. 문자열 파싱(로그, CSV, 설정 파일), 타입 변환(숫자↔문자열), 포맷팅(테이블, 로그 메시지), SQL/JSON 조립 등에 활용합니다. C++20 이상이면 std::format도 함께 검토하세요.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

한 줄 요약: stringstream으로 문자열 파싱·포맷팅을 타입 안전하게 할 수 있습니다. 다음으로 auto와 decltype(#12-1)를 읽어보면 좋습니다.

이전 글: C++ 실전 가이드 #11-2: 바이너리 파일과 직렬화

다음 글: C++ 실전 가이드 #12-1: auto와 decltype


관련 글

  • C++ 파일 입출력 | ifstream·ofstream으로
  • C++ 문자열 기초 완벽 가이드 | std::string·C 문자열·string_view와 실전 패턴
  • C++ 바이너리 직렬화 |
  • C++ 문자열 알고리즘 완벽 가이드 | split·join·trim·replace·정규식 [실전]
  • C++ 람다 기초 완벽 가이드 | 캡처·mutable·제네릭 람다와 실전 패턴