1. 공백, 토큰과 따옴표
인터랙티브 프로그램을 이전에 작성해 본 적이 있다면 이런 경험이 있을 것입니다: “상품 이름으로 milk chocolate을 입력했는데 프로그램은 milk만 읽었다.” 이는 컴파일러의 버그나 마법이 아닙니다. 이것은 operator>>의 정직한 규칙입니다: 기본적으로 이 연산자는 스트림을 공백 문자(스페이스, 탭, 줄바꿈)로 분할합니다. 숫자에는 보통 잘 맞지만, ‘이름’, ‘메시지’, ‘주소’ 같은 필드에는 문제가 됩니다.
간단한 CLI(콘솔 메뉴)를 만든다고 가정해 봅시다. 사용자 명령은 다음과 같습니다:
- add milk 2
- add milk chocolate 2
두번째 경우 형식이 애매해집니다: milk는 이름인가요? 혹은 이름의 일부인가요? 혹은 다른 명령인가요? 스트림 파서는 “추측”하지 못합니다 — 그냥 토큰을 읽을 뿐입니다.
텍스트 포맷 세계에서의 고전적 해결책: 필드에 공백이 포함될 수 있으면 그 필드를 따옴표로 감싼다. 그러면 계약이 명확해집니다:
- 명령: 공백이 없는 단어
- 이름: 따옴표로 감싼 문자열, 내부에 공백 허용
- 수량: 숫자
즉: add "milk chocolate" 2.
이제 이를 편하게 읽는 법을 배워야 합니다. 문자열을 수동으로 자르고, 첫 번째 따옴표와 마지막 따옴표를 찾고, 부분 문자열을 꺼내는 방법도 있지만… 오늘은 더 간단합니다: 표준 라이브러리에 이 작업을 맡깁니다.
2. std::quoted: 무엇이고 어디에 포함되는가
C++에서 std::fixed, std::setprecision, std::setw 같은 것을 보면 — 이것들은 스트림 매니퓰레이터입니다: 입출력 동작을 바꾸는 작은 객체들입니다. std::quoted도 같은 계열의 도구입니다.
기술적으로 std::quoted는 <<와 >> 체인에 삽입할 수 있는 매니퓰레이터입니다.
- 출력할 때는 문자열을 따옴표로 감싸고 특수문자(보통 따옴표와 역슬래시)를 이스케이프합니다.
- 입력할 때는 따옴표로 묶인 문자열을 하나의 논리적 토큰으로 읽어 외부 따옴표를 제거하고 이스케이프를 올바르게 처리합니다.
std::quoted를 사용하려면 헤더가 필요합니다:
#include <iomanip> // std::quoted
물론 스트림들도 필요합니다:
#include <iostream>
#include <sstream> // 문자열에서 읽을 때 istringstream 사용
#include <string>
간단한 존재 확인:
#include <iomanip>
#include <iostream>
#include <string>
int main() {
std::string s = "hello world";
std::cout << std::quoted(s) << '\n'; // "hello world"
}
문자열에 공백이 없더라도 std::quoted는 여전히 따옴표를 추가합니다. 이는 정상 동작입니다: 명시적으로 quoted 형식으로 출력하라고 한 것입니다.
3. 출력: 문자열을 안전하게 만들기
텍스트 포맷을 설계할 때 읽는 것뿐 아니라 같은 포맷으로 생성하는 것도 할 줄 알아야 합니다. 이때 std::quoted가 특히 유용합니다: «쓰기 ↔ 읽기»의 대칭을 제공합니다. 문자열을 따옴표 형식으로 기록하고 나중에 다시 읽으면(합리적인 제한 내에서) 같은 값을 얻습니다.
간단한 예: 공백이 있는 문자열.
#include <iomanip>
#include <iostream>
#include <string>
int main() {
std::string item = "milk chocolate";
std::cout << std::quoted(item) << '\n'; // "milk chocolate"
}
이제 더 중요한 예: 내부에 따옴표가 있는 문자열. 예를 들어 Bob said "hi" 같은 이름이 있으면 이스케이프 처리가 없으면 포맷이 깨집니다: 어느 따옴표가 필드의 시작/끝인지 명확하지 않기 때문입니다.
std::quoted는 출력 시 따옴표를 역슬래시로 이스케이프합니다.
#include <iomanip>
#include <iostream>
#include <string>
int main() {
std::string s = "Bob said \"hi\"";
std::cout << std::quoted(s) << '\n'; // "Bob said \"hi\""
}
여기서 중요한 생각이 생깁니다(나중에 다시 다룹니다): 이 예에는 두 가지 다른 이스케이프 수준이 있습니다. C++ 코드 내부에서는 문자열 리터럴 규칙 때문에 \"를 썼습니다. 반면 std::quoted는 데이터 포맷 규칙 때문에 \"를 출력합니다. 형태는 비슷하지만 의미가 다릅니다.
명령어나 로그를 위해 문자열을 하나의 행으로 조립할 때는 std::ostringstream를 쓰면 편리합니다:
#include <iomanip>
#include <sstream>
#include <string>
int main() {
std::string name = "milk chocolate";
int count = 3;
std::ostringstream oss;
oss << "add " << std::quoted(name) << ' ' << count;
// oss.str() == add "milk chocolate" 3
}
아직 파일에 저장하는 것은 다루지 않지만(다음 강의 주제), 이미 올바른 포맷 문자열을 만드는 법을 배웠습니다.
4. 입력: quoted 문자열을 하나의 토큰으로 읽기
실용적인 부분: "milk chocolate"을 두 개의 단어 milk와 chocolate로 나누지 않고 하나의 필드로 취급하려면 어떻게 해야 할까요?
방법은 간단합니다: 스트림에서 평소처럼 읽되, 문자열에 대해서는 std::quoted(str)를 사용합니다.
#include <iomanip>
#include <iostream>
#include <sstream>
#include <string>
int main() {
std::string line = "add \"milk chocolate\" 3";
std::istringstream iss(line);
std::string cmd;
std::string name;
int count{};
if (iss >> cmd >> std::quoted(name) >> count) {
std::cout << cmd << " | " << name << " | " << count << '\n';
// add | milk chocolate | 3
} else {
std::cout << "bad format\n";
}
}
여기서 습관화해야 할 점: 읽기가 성공했는지 확인하기 전까지는 값을 사용하지 마세요. 초보자들은 종종 iss >> cmd >> std::quoted(name) >> count;을 쓴 뒤 입력이 잘못되어도 name을 출력합니다. 스트림이 실패하면 상태가 fail가 되고 변수들은 옛값이나 부분적인 값으로 남을 수 있어 예기치 못한 결과가 발생합니다.
또한 중요한 점: std::quoted(name)는 스트림에 실제로 따옴표로 감싸진 문자열(기본은 큰따옴표)이 등장할 것으로 기대합니다. 따옴표가 없다면 읽기는 계약대로 동작하지 않을 수 있습니다. 이는 바람직합니다: 포맷이 깨졌다면 우리는 형식 오류를 명확히 표시해야 합니다.
5. 두 수준의 이스케이프: 데이터와 C++ 코드
지금부터는 반쯤 이해한 듯한 표정이 나오는 구간입니다. 괜찮습니다: 실제로 두 개의 층이 있고, 처음엔 머릿속에서 섞이기 쉽습니다.
첫 번째 층 — C++ 문자열 리터럴 내에서의 이스케이프. 소스 코드에서 쓰는 규칙입니다:
- "\n" — 한 개의 줄바꿈 문자
- "\"" — 한 개의 따옴표
- "\\ " — 한 개의 역슬래시(그리고 공백)
두 번째 층 — 파싱하는 데이터 포맷 내부의 이스케이프입니다. 예를 들어 quoted 포맷에서는 문자열 내부의 따옴표는 \"처럼 기록되어야 합니다. 그렇지 않으면 문자열이 일찍 닫힐 수 있습니다.
데이터에 따옴표가 들어있는 예를 보겠습니다.
데이터 (사용자가 CLI에 입력할 수 있는 형태):
add "milk \"dark\"" 1
즉 상품 이름은 milk "dark"입니다.
하지만 이 문자열을 테스트용으로 C++ 리터럴에 넣으려면 C++ 규칙에 따라 추가로 이스케이프해야 합니다:
#include <iomanip>
#include <iostream>
#include <sstream>
#include <string>
int main() {
std::string line = "add \"milk \\\"dark\\\"\" 1";
std::istringstream iss(line);
std::string cmd;
std::string name;
int count{};
if (iss >> cmd >> std::quoted(name) >> count) {
std::cout << name << '\n'; // milk "dark"
}
}
주의 깊게 보면 C++ 코드에서는 \\\"를 썼습니다. 이는 데이터 문자열 안에는 \"가 들어가도록 하겠다는 의미입니다. 그리고 이 \"는 quoted 문자열 내부의 따옴표를 의미합니다.
현실에서는 이런 문자열들이 보통 소스가 아니라 사용자 입력/파일/네트워크에서 들어오므로 이스케이프 수준은 보통 하나입니다(데이터 포맷 수준).
또한 std::quoted는 읽을 때 외부 따옴표를 제거하고 내부 이스케이프를 해제하므로, 결과 변수 name에는 이미 “정리된” 문자열이 들어옵니다.
6. 미니 CLI «쇼핑 목록»
std::quoted가 강의 속의 흥미로운 도구로만 끝나지 않도록 작은 콘솔 앱에 통합해 보겠습니다. 여기서는 struct를 사용하지 않으므로 간단한 병렬 저장소를 사용합니다: 이름을 위한 vector<string>와 수량을 위한 vector<int>. 완벽한 설계는 아니지만 현재 목표는 파싱이지 데이터 모델 디자인이 아닙니다.
저장과 출력부터 시작합니다:
#include <iostream>
#include <string>
#include <vector>
void printList(const std::vector<std::string>& names,
const std::vector<int>& counts) {
for (std::size_t i = 0; i < names.size(); ++i) {
std::cout << i << ": " << names[i] << " x" << counts[i] << '\n';
}
}
이제 add 명령 파서를 만듭니다. 전체 라인을 받아 명령어, quoted 이름, 수량을 추출하려고 시도합니다.
#include <iomanip>
#include <sstream>
#include <string>
bool parseAdd(const std::string& line, std::string& name, int& count) {
std::istringstream iss(line);
std::string cmd;
if (!(iss >> cmd >> std::quoted(name) >> count)) return false;
if (cmd != "add") return false;
std::string extra;
return !(iss >> extra); // 추가 토큰이 없어야 함
}
마지막 검사를 주목하세요. 사용자가 add "milk" 2 blah처럼 입력하면 우리는 형식 오류로 처리합니다. 때로는 ‘best effort’가 유용하지만 학습용 파서에서는 엄격 모드가 디버깅을 더 쉽게 합니다.
이제 명령 처리기입니다. 상품이 없으면 새 항목을 추가하고, 이미 있으면 수량을 더합니다. 검색을 위한 작은 함수를 만듭니다:
#include <string>
#include <vector>
int findIndex(const std::vector<std::string>& names, const std::string& name) {
for (std::size_t i = 0; i < names.size(); ++i) {
if (names[i] == name) return static_cast<int>(i);
}
return -1;
}
그리고 적용합니다:
#include <string>
#include <vector>
void addItem(std::vector<std::string>& names, std::vector<int>& counts,
const std::string& name, int count) {
int idx = findIndex(names, name);
if (idx == -1) {
names.push_back(name);
counts.push_back(count);
} else {
counts[static_cast<std::size_t>(idx)] += count;
}
}
이제 명령 읽기 루프를 구성합니다. 명령은 따옴표 내부에 공백이 있을 수 있으므로 전체 라인을 std::getline으로 읽습니다.
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> names;
std::vector<int> counts;
std::string line;
while (std::getline(std::cin, line)) {
if (line == "exit") break;
std::string name;
int count{};
if (parseAdd(line, name, count)) {
addItem(names, counts, name, count);
std::cout << "ok\n";
} else if (line == "list") {
printList(names, counts);
} else {
std::cout << "unknown or bad format\n";
}
}
}
이제 포맷은 실제로 동작합니다:
- add "milk chocolate" 3
- add "milk \"dark\"" 1 (사용자가 데이터 안에서 따옴표를 이스케이프할 수 있는 경우)
- list
- exit
무엇보다도, 이름에 공백이 있어도 하나의 필드로 파싱된다는 점입니다.
7. 포맷은 계약이다
이 시점에서 중요한 점은 사용자가 무엇을 의도했는지 ‘추측’하려 들지 말고 우리가 무엇을 유효하다고 간주하는지 명확히 정의하는 것입니다. std::quoted는 엄격한 도구입니다: 혼란을 치료하지는 않지만 포맷에 대한 합의를 유지하도록 도와줍니다.
우리의 add 명령 계약을 작은 표로 정리해봅시다:
| 필드 | 예 | 읽는 법 | 설명 |
|---|---|---|---|
| 명령 | |
|
공백 없음, 한 단어 |
| 이름 | |
|
반드시 따옴표로 감싸야 하며 내부 공백 허용 |
| 수량 | |
|
정수 |
따라서 문자열 add milk chocolate 3은 우리에게는 유효하지 않습니다 — 의미상 이해할 수 있더라도 계약상 이름은 따옴표로 감싸여야 합니다. 이는 프로그램이 텔레파시를 하지 않도록 하는 좋은 규칙입니다.
처리를 머릿속에서 작은 흐름도로 표현하면 디버깅이 훨씬 쉬워집니다.
flowchart TD
A[입력 라인 받음] --> B[iss >> cmd]
B --> C{cmd == 'add'?}
C -- 아니오 --> X[다른 명령]
C -- 예 --> D["iss >> quoted(name)"]
D --> E[iss >> count]
E --> F{읽기 성공?}
F -- 아니오 --> Y[bad format]
F -- 예 --> G{추가 토큰이 있는가?}
G -- 예 --> Y
G -- 아니오 --> H["addItem(name, count)"]
이렇게 프로세스를 그릴 수 있으면 파서 디버깅 속도가 훨씬 빨라집니다.
다른 따옴표와 이스케이프 문자
때로는 데이터 포맷이 큰따옴표 대신 작은따옴표를 사용하거나, 이스케이프 문자를 \이 아닌 다른 문자로 정할 수 있습니다. std::quoted는 이러한 옵션을 제공합니다: 따옴표 문자와 이스케이프 문자를 인수로 받을 수 있습니다.
형태는 다음과 같습니다:
std::quoted(str, quote_char, escape_char)
예: 작은따옴표 '로 묶인 문자열을 읽기:
#include <iomanip>
#include <iostream>
#include <sstream>
#include <string>
int main() {
std::string line = "add 'milk chocolate' 2";
std::istringstream iss(line);
std::string cmd;
std::string name;
int count{};
if (iss >> cmd >> std::quoted(name, '\'', '\\') >> count) {
std::cout << name << '\n'; // milk chocolate
}
}
이것이 유용한 이유는 기존 데이터가 그 형식을 이미 사용하고 있어서 사용자를 다시 학습시키는 것보다 받아들이는 쪽이 더 쉬울 때입니다. 단, 지원 가능한 포맷을 늘릴수록 처리해야 할 예외 케이스가 늘어나므로 주의해야 합니다.
또한 이스케이프 문자를 바꾸면 데이터 내부에서 따옴표를 어떻게 이스케이프할지 달라지므로, 사실상 미니 포맷의 “언어”를 바꾸는 것과 같습니다.
8. std::quoted 사용 시 흔한 실수
실수 #1: <iomanip>를 포함하는 것을 잊음.
가장 단순하고 안타까운 상황입니다: std::quoted(name)를 쓰는데 컴파일러가 이를 모른다고 합니다. 직관적으로는 <sstream>나 <iostream>를 탓하고 싶지만, quoted는 매니퓰레이터이므로 정확히 <iomanip>에 정의되어 있습니다.
실수 #2: std::quoted가 따옴표 없이도 알아서 처리할 것이라 기대함.
필드에 공백이 포함될 수 있다면 따옴표는 포맷의 일부입니다. std::quoted는 따옴표가 없으면 문자열의 끝을 추측하지 않습니다. 따라서 add milk chocolate 3는 ‘거의 맞음’이 아니라 다른 포맷입니다.
실수 #3: 데이터의 이스케이프와 C++ 리터럴의 이스케이프를 혼동함.
테스트를 위해 std::string line = "..."; 같은 코드를 작성할 때 C++ 규칙에 따라 역슬래시와 따옴표를 추가로 이스케이프해야 합니다. 이 때문에 std::quoted가 이상한 시퀀스를 요구한다고 느낄 수 있지만, 실제 사용자 입력에는 데이터 포맷 수준의 이스케이프만 존재합니다.
실수 #4: 읽기 성공 여부를 확인하지 않고 변수를 사용함.
흔한 로직은 “내가 올바르게 입력했으니 프로그램도 읽었을 것이다”입니다. 하지만 공백, 빈 줄, 누락된 따옴표, 숫자 대신 문자 등이 있으면 스트림은 fail 상태가 되고 변수들은 부분적으로 채워질 수 있습니다. 규칙은 간단합니다: 먼저 if (iss >> ...)로 확인한 다음 결과를 사용하세요.
실수 #5: 라인 끝을 검사하지 않아 쓰레기를 받아들임.
모든 필드를 읽은 뒤 스트림이 끝났는지 확인하지 않으면 add "milk" 2 blah 같은 입력을 유효하다고 받아들이고 blah를 조용히 무시하게 됩니다. 때로는 허용할 수 있지만 대개는 사용자가 추가 매개변수를 입력했는데 프로그램이 무시하는 상황을 만들기 때문에 문제가 됩니다.
GO TO FULL VERSION