C++ I/O 병목 줄이기: cin·mmap·io_uring 성능 비교
들어가며: 같은 로직인데 C++만 시간 초과가 나요
알고리즘은 맞는데 시간 초과(TLE, Time Limit Exceeded—제한 시간 안에 프로그램이 끝나지 않아 채점이 실패하는 경우)가 나는 경우, 대부분 입출력(I/O)이 원인입니다. C++의 cin/cout은 기본 설정이 편의성에 맞춰져 있어서, 대량의 입력을 읽을 때 동기화·버퍼·tie 때문에 불필요한 오버헤드가 큽니다. 백준·프로그래머스에서 상단에 한 줄~세 줄만 넣어 주면 같은 코드가 통과하는 경우가 많습니다.
같은 알고리즘인데 C++만 시간 초과: I/O 병목의 원인
백준 10951번(A+B - 4)처럼 EOF까지 정수 쌍을 수십만 줄 읽는 문제에서, 풀이는 O(n)으로 맞는데 기본 설정의 C++가 시간 초과를 받는 일이 흔합니다. 알고리즘이 아니라 입출력 계층이 병목이기 때문입니다. 원인은 크게 세 가지입니다.
문제의 원인 분석
flowchart TB
subgraph slow["느린 I/O (기본 설정)"]
A1["cin n"] --> A2[C 스트림 동기화]
A2 --> A3[cout flush 대기]
A3 --> A4[시스템 콜]
A4 --> A5[실제 읽기]
end
subgraph fast["빠른 I/O (최적화)"]
B1["cin n"] --> B2[버퍼에서 직접]
B2 --> B3[한 번에 읽기]
end
| 원인 | 설명 | 영향 |
|---|---|---|
| C/C++ 스트림 동기화 | 동기화 모드에서는 cin/cout이 자체 버퍼 없이 문자 단위로 C stdio 함수를 호출 | 아래 측정에서 입력 약 6배 차이 |
| cin-cout tie | cin으로 읽을 때마다 cout 버퍼를 flush | 읽고 쓰기를 번갈아 하면 줄마다 출력 시스템 콜 |
| endl 사용 | 매 줄마다 버퍼 flush | 아래 측정에서 100만 줄 출력 약 50배 차이 |
먼저 넣을 두 줄
코딩 테스트용 메인 상단에 아래를 넣으면 됩니다. sync_with_stdio(false)로 C 스트림과의 동기화를 끄고, cin.tie(nullptr)로 “cin 쓸 때마다 cout flush”를 끄면 대량 입출력에서 체감 속도가 크게 올라갑니다. 두 설정 모두 main 시작 직후 한 번만 호출하면 됩니다.
// 복사해 붙여넣은 뒤: g++ -std=c++17 -o io_fast io_fast.cpp && echo "42" | ./io_fast
#include <iostream>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(nullptr); // 또는 cin.tie(NULL);
int n;
cin >> n;
cout << n << '\n';
return 0;
}
echo "42" | ./io_fast로 실행하면 42가 한 줄 출력됩니다. 그리고 줄바꿈은 endl 대신 '\n'을 씁니다.
cout << answer << '\n'; // O
cout << answer << endl; // X (대량 출력 시 느림)
이 두 가지만 지켜도 많은 시간 초과가 사라집니다. 아래에서는 왜 그런지 버퍼와 동기화 관점에서 풀어 씁니다.
sync_with_stdio(false)란?
C++ 스트림과 C 스트림의 동기화
C++에는 두 세트의 입출력이 있습니다.
| 스트림 | 용도 |
|---|---|
cin / cout / cerr | C++ 스트림 (<iostream>) |
stdin / stdout / stderr | C 스트림 (<cstdio>, printf/scanf) |
기본값은 sync_with_stdio(true)입니다. 이때 표준은 C++ 표준 스트림과 C 스트림이 같은 순서로 읽고 쓰도록 보장합니다. 그래서 cout과 printf를 섞어 써도 출력 순서가 뒤섞이지 않습니다. libstdc++는 이를 위해 동기화 모드에서 cin/cout이 자체 버퍼를 두지 않고 문자마다 C의 getc/putc 계열 함수로 넘깁니다. 문자 하나마다 함수 호출과 스트림 잠금이 따라오므로, 대량의 cin/cout만 쓸 때는 이것이 큰 오버헤드가 됩니다.
false로 바꾸면?
ios_base::sync_with_stdio(false); 를 호출하면:
- C++ 스트림이 자기만의 버퍼를 사용합니다.
- C 스트림(
printf/scanf)과 순서 보장이 사라집니다 (섞어 쓰지 말 것). cin/cout만 쓸 때는 버퍼링이 제대로 동작해서 훨씬 빨라집니다. 코딩 테스트에서는 보통cin/cout만 쓰므로, main 시작 시 입출력을 하기 전에 한 번만sync_with_stdio(false)를 호출합니다. 이미 입출력을 한 뒤에 호출하면 동작이 구현 정의입니다.
int main() {
ios_base::sync_with_stdio(false);
int n;
cin >> n;
// ...
}
cin.tie(NULL)이 필요한 이유
”tie”란?
tie는 “한 스트림이 사용되기 전에, 다른 스트림의 버퍼를 먼저 비우도록 묶어 두는 것”입니다.
기본값으로 cin은 cout과 tie되어 있습니다. 즉, cin에서 입력을 받기 전에 “cout 버퍼를 비워라(flush)“가 자동으로 일어납니다.
이렇게 된 이유는 대화형 프로그램을 위해서입니다. 예를 들어:
cout << "이름을 입력하세요: ";
cin >> name; // 사용자가 입력하기 전에 위 문장이 화면에 나와야 함
“이름을 입력하세요: “가 버퍼에만 있고 화면에 안 나온 상태에서 cin이 기다리면, 사용자는 무엇을 입력해야 할지 모릅니다. 따라서 표준은 cin을 쓰기 전에 cout을 자동으로 flush 하도록 tie 해 두었습니다.
코테에서는?
코딩 테스트는 대부분 입력을 한꺼번에 읽고 계산한 뒤 출력하므로 프롬프트를 먼저 보여 줄 필요가 없습니다. 특히 쿼리를 하나 읽고 답을 하나 출력하는 문제에서는 cin >> a;마다 cout 버퍼가 flush되어, 출력 줄마다 시스템 콜이 생깁니다.
tie 해제
cin.tie(nullptr);를 호출하면 cin이 cout과 더 이상 묶이지 않아, 읽을 때마다 출력 버퍼를 비우지 않습니다.
int main() {
ios_base::sync_with_stdio(false);
cin.tie(nullptr);
// ...
}
endl이 느린 이유: 버퍼 플러시
endl과 ‘\n’의 차이
- ‘\n’: “줄바꿈 문자 하나”를 출력 버퍼에 넣습니다. 버퍼가 가득 차거나 프로그램이 정상 종료될 때 등에 한꺼번에 플러시됩니다.
- endl: 줄바꿈을 넣은 뒤 그 즉시 버퍼를 비웁니다(flush). 즉
endl='\n'+flush.
cout << "Hello" << '\n'; // 버퍼에 "Hello\n" 추가
cout << "Hello" << endl; // 버퍼에 "Hello\n" 추가 + 지금 바로 flush
왜 flush가 느릴까?
플러시는 지금까지 버퍼에 모아 둔 데이터를 write 시스템 콜로 OS에 넘기는 작업입니다. 매 줄 flush하면 줄 수만큼 시스템 콜이 생기고, 몇 KB씩 모아서 한 번에 보내는 버퍼링의 이점이 사라집니다. 아래 측정에서는 100만 줄 출력이 약 50배 느려졌고, 출력 줄이 수십만 개인 문제에서는 endl만으로도 시간 초과가 날 수 있습니다.
디버깅할 때만 endl
중간에 출력해서 “지금 여기까지 나왔나?” 확인할 때는 즉시 flush가 유리할 수 있어서 endl이나 cout.flush()를 쓰는 것은 괜찮습니다. 제출용 코드에서는 '\n'으로 바꾸면 됩니다.
scanf, getchar 파싱, 직접 버퍼링: 단계별 입출력 최적화
레벨별 최적화 체크리스트
| 레벨 | 기법 | 적용 난이도 | 효과 (아래 벤치마크 기준) | 사용처 |
|---|---|---|---|---|
| L1 | sync_with_stdio(false) + cin.tie(nullptr) | ★☆☆ | 입력 약 6배, 입출력 교대 시 tie만으로 약 10배 | 코테 필수 |
| L1 | endl 대신 '\n' | ★☆☆ | 100만 줄 출력 약 50배 | 코테 필수 |
| L2 | scanf/printf | ★★☆ | 최적화한 cin보다 느릴 수 있음 (환경 의존) | C 스타일 코드 |
| L2 | fread로 큰 덩어리를 읽어 직접 파싱 | ★★★ | 최적화한 cin보다 약 7배 | 극한 입력 |
| L3 | mmap 파일 읽기 | ★★★ | 페이지 캐시·접근 패턴에 따라 다름 | 대용량 파일 |
| L3 | io_uring 비동기 I/O | ★★★★ | 저장장치·동시 요청 수에 따라 다름 | 프로덕션 서버 |
L2: scanf/printf 활용
sync_with_stdio(false) 이후에는 cin/cout과 섞어 쓰지 마세요. C 스트림만 쓸 때:
#include <cstdio>
int main() {
int n, a, b;
scanf("%d", &n);
for (int i = 0; i < n; ++i) {
scanf("%d %d", &a, &b);
printf("%d\n", a + b);
}
return 0;
}
L2: getchar/putchar 직접 파싱
getchar는 호출마다 스트림 잠금을 거치므로, 정말 빠르게 하려면 아래 직접 버퍼링처럼 fread로 큰 덩어리를 읽거나 Linux에서 getchar_unlocked를 씁니다. getchar의 반환값은 EOF(-1)를 구분해야 하므로 char가 아니라 int로 받아야 합니다.
#include <cstdio>
inline int read_int() {
int x = 0;
int c = getchar();
while (c != EOF && (c < '0' || c > '9')) c = getchar();
while (c >= '0' && c <= '9') {
x = x * 10 + (c - '0');
c = getchar();
}
return x;
}
inline void write_int(int x) {
if (x >= 10) write_int(x / 10);
putchar('0' + x % 10);
}
int main() {
int n = read_int();
for (int i = 0; i < n; ++i) {
int a = read_int(), b = read_int();
write_int(a + b);
putchar('\n');
}
return 0;
}
버퍼 크기 조정 (고급)
#include <iostream>
#include <cstdio>
int main() {
// C stdio 스트림의 버퍼 크기 확대 (해당 스트림에 입출력하기 전에 호출해야 함)
// sync_with_stdio(false) 이후의 cin/cout에는 영향을 주지 않음
std::setvbuf(stdin, nullptr, _IOFBF, 1 << 20); // 1MB
std::setvbuf(stdout, nullptr, _IOFBF, 1 << 20);
// ...
}
L2: 직접 버퍼링 구현 (완전한 예제)
대용량 파일을 청크 단위로 읽어 파싱하는 패턴입니다. read() 호출 횟수를 최소화합니다.
// g++ -std=c++17 -O2 -o buffered_reader buffered_reader.cpp
#include <fcntl.h>
#include <unistd.h>
#include <vector>
class BufferedReader {
static constexpr size_t BUF_SIZE = 64 * 1024; // 64KB
int fd_;
std::vector<char> buf_;
size_t pos_ = 0, len_ = 0;
public:
explicit BufferedReader(int fd) : fd_(fd), buf_(BUF_SIZE) {}
int get() {
if (pos_ >= len_) {
ssize_t n = read(fd_, buf_.data(), BUF_SIZE);
if (n <= 0) return -1; // EOF 또는 오류
len_ = static_cast<size_t>(n);
pos_ = 0;
}
return static_cast<unsigned char>(buf_[pos_++]);
}
int read_int() {
int c = get();
while (c != -1 && c != '-' && (c < '0' || c > '9')) c = get(); // 공백·줄바꿈 건너뛰기
int sgn = 1;
if (c == '-') { sgn = -1; c = get(); }
int x = 0;
while (c >= '0' && c <= '9') { x = x * 10 + (c - '0'); c = get(); }
return x * sgn;
}
};
int main() {
int fd = open("/tmp/input.txt", O_RDONLY);
if (fd < 0) return 1;
BufferedReader reader(fd);
int n = reader.read_int();
for (int i = 0; i < n; ++i) {
int a = reader.read_int(), b = reader.read_int();
// a, b 처리
}
close(fd);
return 0;
}
read()는 64KB씩 호출되므로 1MB 파일이어도 시스템 콜은 약 16번입니다. 표준 스트림도 내부적으로 버퍼링하므로 시스템 콜 횟수 자체는 비슷하지만, cin >> n은 호출마다 로케일·센트리 객체·서식 처리를 거치기 때문에 정수 하나당 비용이 직접 파싱보다 훨씬 큽니다.
L2: 출력 버퍼링 (배치 flush)
대량 출력 시 '\n'만 쓰면 버퍼가 가득 찰 때 자동 flush됩니다. 마지막에 std::cout.flush()로 남은 데이터를 확실히 출력하세요. 극한 최적화가 필요하면 ostringstream으로 모았다가 한 번에 cout에 쓰는 방법도 있습니다.
mmap: 메모리 매핑 I/O
mmap이란?
mmap은 파일을 프로세스의 가상 메모리 공간에 직접 매핑하는 Linux/Unix 시스템 콜입니다. read()/write() 대신 페이지 폴트를 통해 필요할 때만 디스크에서 로드하므로, 대용량 파일 순차 읽기에서 시스템 콜 횟수를 크게 줄입니다.
flowchart LR
subgraph read["read() 방식"]
R1[read 호출] --> R2[커널 버퍼 복사]
R2 --> R3[사용자 버퍼]
end
subgraph mmap[mmap 방식]
M1[mmap 호출] --> M2[가상 주소 매핑]
M2 --> M3[직접 접근]
end
mmap 기본 예제
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstring>
#include <stdexcept>
#include <iostream>
class MmapFile {
public:
MmapFile(const char* path) {
fd_ = open(path, O_RDONLY);
if (fd_ < 0) throw std::runtime_error("open failed");
struct stat st;
if (fstat(fd_, &st) < 0) {
close(fd_);
throw std::runtime_error("fstat failed");
}
size_ = st.st_size;
if (size_ == 0) return;
data_ = static_cast<const char*>(
mmap(nullptr, size_, PROT_READ, MAP_PRIVATE, fd_, 0)
);
if (data_ == MAP_FAILED) {
close(fd_);
throw std::runtime_error("mmap failed");
}
}
~MmapFile() {
if (data_ && data_ != MAP_FAILED) munmap(const_cast<char*>(data_), size_);
if (fd_ >= 0) close(fd_);
}
const char* data() const { return data_; }
size_t size() const { return size_; }
// 복사/이동 금지 (간단화)
MmapFile(const MmapFile&) = delete;
MmapFile& operator=(const MmapFile&) = delete;
private:
int fd_ = -1;
size_t size_ = 0;
const char* data_ = nullptr;
};
int main() {
MmapFile f("/tmp/large_file.txt");
// data()를 포인터처럼 사용 - 추가 복사 없음
for (size_t i = 0; i < f.size(); ++i) {
// f.data()[i] 처리
}
return 0;
}
mmap으로 정수 파싱 (고성능)
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <vector>
#include <cstdio>
std::vector<int> parse_ints_mmap(const char* path) {
std::vector<int> result;
int fd = open(path, O_RDONLY);
if (fd < 0) return result;
struct stat st;
if (fstat(fd, &st) < 0 || st.st_size == 0) { close(fd); return result; } // 길이 0은 mmap이 EINVAL
size_t len = st.st_size;
void* m = mmap(nullptr, len, PROT_READ, MAP_PRIVATE, fd, 0);
close(fd); // 매핑은 fd를 닫아도 유지됨
if (m == MAP_FAILED) return result;
const char* p = static_cast<const char*>(m);
int x = 0, sgn = 1;
bool in_num = false; // x != 0으로 판단하면 숫자 0이 빠짐
for (size_t i = 0; i < len; ++i) {
char c = p[i];
if (c >= '0' && c <= '9') {
x = x * 10 + (c - '0');
in_num = true;
} else {
if (in_num) { result.push_back(x * sgn); x = 0; in_num = false; }
sgn = (c == '-') ? -1 : 1;
}
}
if (in_num) result.push_back(x * sgn);
munmap(m, len);
return result;
}
mmap 쓰기 예제
void write_file_mmap(const char* path, const void* data, size_t len) {
int fd = open(path, O_RDWR | O_CREAT | O_TRUNC, 0644);
ftruncate(fd, len);
void* addr = mmap(nullptr, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
memcpy(addr, data, len);
msync(addr, len, MS_SYNC);
munmap(addr, len);
close(fd);
}
주의: MAP_SHARED 수정은 다른 프로세스에서 보일 수 있습니다. msync로 디스크 반영 시점을 제어합니다.
mmap 주의사항
32비트 프로세스는 가상 주소 공간이 수 GB뿐이라 큰 파일을 통째로 매핑할 수 없고, 64비트에서는 이 제약이 사실상 없습니다. 다른 프로세스가 파일을 수정하는 경우, MAP_SHARED 매핑에는 그 변경이 보이고 MAP_PRIVATE 매핑에 보이는지는 명세상 정해져 있지 않습니다. 매핑은 munmap으로 해제해야 하므로 RAII로 감싸는 것이 안전합니다. 가장 흔한 사고는 SIGBUS입니다. 매핑한 뒤 파일이 잘려(truncate) 매핑 범위가 파일 끝을 넘으면, 그 페이지에 접근하는 순간 SIGBUS로 프로세스가 죽습니다.
io_uring: 비동기 고성능 I/O
io_uring이란?
io_uring은 Linux 5.1에서 도입된 비동기 I/O 인터페이스입니다. 애플리케이션과 커널이 공유하는 제출 큐(SQ)와 완료 큐(CQ) 링 버퍼로 요청과 결과를 주고받으므로, 여러 요청을 한 번의 io_uring_enter 호출로 제출할 수 있습니다. 기존 Linux AIO(libaio)가 사실상 O_DIRECT 파일에서만 비동기로 동작했던 것과 달리 일반 버퍼드 I/O와 소켓도 다룹니다.
flowchart TB
subgraph app[애플리케이션]
SQ[Submission Queue]
CQ[Completion Queue]
end
subgraph kernel[커널]
K[io_uring]
end
SQ -->|제출| K
K -->|완료| CQ
io_uring 기본 예제 (liburing 사용)
// 컴파일: g++ -std=c++17 -o io_uring_demo io_uring_demo.cpp -luring
#include <liburing.h>
#include <fcntl.h>
#include <cstdio>
#include <cstring>
#include <stdexcept>
int main() {
struct io_uring ring;
if (io_uring_queue_init(32, &ring, 0) < 0) {
throw std::runtime_error("io_uring_queue_init failed");
}
int fd = open("/tmp/test.txt", O_RDONLY);
if (fd < 0) {
io_uring_queue_exit(&ring);
throw std::runtime_error("open failed");
}
char buf[4096];
struct io_uring_sqe* sqe = io_uring_get_sqe(&ring);
// 끝에 '\0'을 붙일 자리를 남기려고 sizeof(buf) - 1만 읽음
io_uring_prep_read(sqe, fd, buf, sizeof(buf) - 1, 0);
io_uring_sqe_set_data(sqe, buf);
io_uring_submit(&ring);
struct io_uring_cqe* cqe;
io_uring_wait_cqe(&ring, &cqe);
int ret = cqe->res;
io_uring_cqe_seen(&ring, cqe);
if (ret > 0) {
buf[ret] = '\0';
printf("Read %d bytes: %s\n", ret, buf);
} else if (ret < 0) {
printf("read failed: %s\n", strerror(-ret)); // cqe->res는 음수 errno
}
close(fd);
io_uring_queue_exit(&ring);
return 0;
}
io_uring 이벤트 루프 패턴 (완전한 예제)
여러 파일을 비동기로 동시에 읽으며, 완료된 순서대로 처리하는 패턴입니다.
// g++ -std=c++17 -o io_uring_loop io_uring_loop.cpp -luring
#include <liburing.h>
#include <fcntl.h>
#include <cstdio>
#include <vector>
#include <string>
#include <unistd.h>
void async_read_files(const std::vector<std::string>& paths) {
constexpr unsigned QUEUE_DEPTH = 256;
struct io_uring ring;
if (io_uring_queue_init(QUEUE_DEPTH, &ring, 0) < 0) return;
std::vector<std::vector<char>> buffers(paths.size(), std::vector<char>(4096));
std::vector<int> fds(paths.size(), -1);
size_t submitted = 0;
// 단순화를 위해 큐 깊이만큼만 제출 (더 많으면 완료를 받으며 나눠 제출해야 함)
for (size_t i = 0; i < paths.size() && submitted < QUEUE_DEPTH; ++i) {
fds[i] = open(paths[i].c_str(), O_RDONLY);
if (fds[i] < 0) continue;
struct io_uring_sqe* sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fds[i], buffers[i].data(), 4096, 0);
io_uring_sqe_set_data64(sqe, i);
++submitted;
}
io_uring_submit(&ring);
// 실제로 제출한 개수만큼만 기다려야 함 (paths.size()만큼 기다리면 영원히 대기)
for (size_t i = 0; i < submitted; ++i) {
struct io_uring_cqe* cqe;
io_uring_wait_cqe(&ring, &cqe);
size_t idx = io_uring_cqe_get_data64(cqe);
int ret = cqe->res;
io_uring_cqe_seen(&ring, cqe);
if (ret > 0) printf("File %zu: %d bytes\n", idx, ret);
close(fds[idx]);
}
io_uring_queue_exit(&ring);
}
io_uring_submit이 한 번의 시스템 콜로 여러 요청을 제출하고, io_uring_wait_cqe가 완료를 하나씩 꺼냅니다. 완료 순서는 제출 순서와 다를 수 있으므로, 각 요청에 set_data64로 인덱스를 붙여 두고 완료 쪽에서 어떤 파일의 결과인지 찾습니다. 버퍼는 해당 요청이 완료될 때까지 살아 있어야 합니다.
io_uring 요구사항
io_uring 자체는 Linux 5.1에서 들어왔지만, 위 예제의 IORING_OP_READ(io_uring_prep_read)는 5.6부터 지원되고 io_uring_sqe_set_data64는 liburing 2.2 이상에 있습니다. Ubuntu에서는 apt install liburing-dev로 설치합니다. 컨테이너 런타임의 seccomp 프로필이나 보안 정책에 따라 io_uring 시스템 콜이 막혀 있는 환경도 있으므로 배포 대상에서 먼저 확인해야 합니다. 코딩 테스트 채점 환경에서는 쓸 수 없습니다.
cin/printf 혼용, tie 해제 후 프롬프트, mmap SIGBUS 같은 오류
오류 1: sync_with_stdio(false) 후 cin과 printf 섞어 쓰기
동기화를 끈 뒤 두 계열을 섞으면 출력 순서가 뒤섞입니다. 입력도 마찬가지로, cin이 자기 버퍼로 미리 읽어 간 데이터를 scanf는 볼 수 없어 입력이 건너뛰어집니다.
// ❌ 잘못된 예
int main() {
ios_base::sync_with_stdio(false);
int n;
cin >> n;
printf("n = %d\n", n); // cout과 순서 보장 안 됨!
cout << "done\n";
}
sync_with_stdio(false)를 쓸 때는 cin/cout만 쓰거나 scanf/printf만 씁니다.
// ✅ 올바른 예
int main() {
ios_base::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
cout << "n = " << n << '\n'; // C++ 스트림만 사용
}
오류 2: tie(nullptr) 후 대화형 출력이 안 보임
대화형 프로그램에서 tie를 풀면 “입력하세요” 같은 프롬프트가 화면에 안 나온 채 입력을 기다릴 수 있습니다.
// ❌ 코테가 아닌 대화형 프로그램에서
cin.tie(nullptr);
cout << "숫자 입력: ";
cin >> n; // "숫자 입력:"이 버퍼에만 있고 flush 안 됨
대화형 프로그램에서는 tie를 해제하지 않거나, 출력 후 cout.flush()를 호출합니다.
// ✅ 대화형일 때
cout << "숫자 입력: ";
cout.flush();
cin >> n;
오류 2-1: cin >> n 다음 getline이 빈 문자열을 읽음
숫자를 읽은 뒤 문장 한 줄을 읽으려 했는데 line이 비어 있는 경우입니다.
int n;
std::string line;
std::cin >> n; // 입력 "3\nhello world\n"에서 3만 읽고 '\n'은 남김
std::getline(std::cin, line); // 남은 '\n'까지 읽어서 line == ""
>>는 숫자 뒤의 줄바꿈을 버퍼에 그대로 남기고, getline은 줄바꿈을 만날 때까지 읽으므로 곧바로 빈 줄을 돌려줍니다.
std::cin >> n;
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); // 그 줄의 나머지 전부 버리기 (<limits>)
std::getline(std::cin, line);
// 또는: 앞쪽 공백·줄바꿈을 건너뛰고 읽기
std::getline(std::cin >> std::ws, line);
흔히 보이는 std::cin.ignore();(인자 없음)는 문자 하나만 버리므로, 숫자 뒤에 공백이 붙어 있으면("3 \n") 여전히 빈 줄을 읽습니다. 채점 데이터의 줄 끝 공백은 생각보다 흔해서, 로컬에서는 통과하고 제출하면 틀리는 원인이 됩니다. 반대로 std::ws는 빈 줄 자체가 의미 있는 입력일 때(빈 줄도 한 줄로 세야 할 때) 그 빈 줄까지 건너뛰니 주의합니다.
Windows에서 만든 테스트 파일(CRLF)을 Linux에서 읽으면 getline 결과 끝에 '\r'이 붙는 것도 같은 계열의 함정입니다. 문자열 비교가 전부 실패하는데 출력해 보면 멀쩡해 보여서 찾기 어렵습니다. 줄 단위로 읽는 코드라면 if (!line.empty() && line.back() == '\r') line.pop_back();을 넣어 두면 안전합니다.
오류 3: mmap 후 파일이 truncate되면 SIGBUS
mmap한 파일을 다른 프로세스가 잘라내면(truncate) 접근 시 SIGBUS가 발생합니다. 로그처럼 다른 프로세스가 계속 쓰거나 회전시키는 파일이라면 mmap 대신 read를 쓰는 편이 안전합니다. flock은 협조적(advisory) 잠금이라 모든 쓰기 쪽이 같은 잠금을 지킬 때만 효과가 있고, 꼭 mmap을 써야 한다면 SIGBUS 핸들러로 복구 경로를 두는 방법도 있습니다.
오류 4: io_uring 버퍼를 너무 일찍 해제
요청이 완료되기 전에 버퍼를 해제하면, 커널이 해제된 메모리에 데이터를 쓰는 use-after-free가 됩니다.
// ❌ 잘못된 예
char* buf = new char[4096];
io_uring_prep_read(sqe, fd, buf, 4096, 0);
io_uring_submit(&ring);
delete[] buf; // 아직 read 완료 안 됐을 수 있음!
io_uring_wait_cqe(&ring, &cqe);
완료를 받은 뒤에만 버퍼를 해제합니다.
// ✅ 올바른 예
char* buf = new char[4096];
io_uring_prep_read(sqe, fd, buf, 4096, 0);
io_uring_submit(&ring);
io_uring_wait_cqe(&ring, &cqe);
// cqe->res 확인 후 처리
delete[] buf;
오류 5: scanf 포맷과 타입 불일치
int에 %lld를 쓰거나 long long에 %d를 쓰면 정의되지 않은 동작이며, 보통 잘못된 값이 들어옵니다.
// ❌ 잘못된 예
long long n;
scanf("%d", &n); // UB
타입에 맞는 포맷을 씁니다.
// ✅ 올바른 예
long long n;
scanf("%lld", &n);
오류 6: getchar/read_int에서 음수·공백 처리 누락
앞의 read_int는 부호를 처리하지 않아 -42를 42로 읽습니다. 또 공백 문자만 골라 건너뛰면 Windows에서 만든 입력의 '\r'을 만나 0을 읽고, char로 받으면 EOF를 구분하지 못합니다. 숫자와 -가 아닌 문자는 모두 건너뛰는 편이 안전합니다.
// ✅ 올바른 예
int read_int() {
int x = 0, sgn = 1;
int c = getchar();
while (c != EOF && c != '-' && (c < '0' || c > '9')) c = getchar();
if (c == '-') { sgn = -1; c = getchar(); }
while (c >= '0' && c <= '9') { x = x * 10 + (c - '0'); c = getchar(); }
return x * sgn;
}
오류 7: endl을 디버깅 후 제출 시 그대로 둠
로컬에서는 빠른데 제출하면 TLE가 나는 경우, 디버깅용으로 넣은 endl이 남아 있는 경우가 많습니다.
// ❌ 디버깅용으로 넣었던 endl을 제출 시 그대로 둠
for (int i = 0; i < n; ++i) {
cout << result[i] << endl; // 줄마다 flush: 100만 줄 기준 약 50배 느림 (벤치마크 절)
}
제출 전에 endl을 '\n'으로 일괄 치환합니다. #define endl '\n' 같은 매크로는 std::endl이라고 쓴 곳을 std::'\n'으로 바꿔 컴파일 에러를 내므로 권하지 않습니다.
// ✅ 제출용
for (int i = 0; i < n; ++i) {
cout << result[i] << '\n';
}
정수 100만 개 읽기·100만 줄 쓰기 측정
측정 조건
이전 버전의 이 절에는 출처를 밝히지 않은 표가 있었는데, 같은 사이트의 다른 글과 endl 배수(4배 vs 90배)부터 서로 맞지 않아서 직접 다시 측정한 값으로 바꿨습니다.
- 환경: Windows 11, TDM-GCC(MinGW-w64) 10.3,
-O2 -std=c++17 - 입력: 첫 줄에 N=1,000,000, 이어서 -10⁹~10⁹ 범위 정수 100만 줄(약 10MB), 파일 리다이렉트(
./a.exe < in.txt) - 출력: 정수 100만 줄을 파일로 리다이렉트(
> out.txt) - 시간: 프로그램 안에서
std::chrono::steady_clock으로 입출력 구간만 측정, 두 번씩 실행해 비슷한 값 확인
입력: 정수 100만 개 읽기
| 방식 | 시간 |
|---|---|
cin >> x (기본 설정) | 약 830ms |
scanf("%lld", &x) | 약 365ms |
cin >> x + sync_with_stdio(false) + cin.tie(nullptr) | 약 135ms |
fread로 64KB씩 읽어 직접 파싱 (직접 버퍼링 방식) | 약 18ms |
흥미로운 점은 최적화한 cin이 scanf보다 빨랐다는 것입니다. “scanf가 cin보다 빠르다”는 말은 동기화를 켠 기본 cin과 비교할 때 맞는 이야기이고, 두 줄을 넣은 cin은 이 환경에서 scanf의 약 2.7배였습니다. MinGW의 scanf는 Windows C 런타임을 거치기 때문에 Linux glibc보다 느린 편이라, 채점 서버(대개 Linux)에서는 격차가 더 작거나 뒤집힐 수 있습니다. 확실한 것은 기본 cin이 가장 느리다는 점과, 정말 한계까지 가야 할 때는 직접 파싱이 한 자릿수 배 더 빠르다는 점입니다.
출력: 100만 줄 쓰기
| 방식 | 시간 |
|---|---|
cout << i << endl | 약 2,900ms |
cout << i << endl + sync_with_stdio(false) | 약 2,800ms |
printf("%d\n", i) | 약 133ms |
cout << i << '\n' + sync_with_stdio(false) | 약 113ms |
cout << i << '\n' (기본 설정) | 약 60ms |
endl은 동기화 설정과 무관하게 줄마다 flush하므로 '\n'보다 약 50배 느렸습니다. 동기화를 꺼도 endl의 비용은 거의 그대로라는 점이 핵심입니다. 즉 sync_with_stdio(false)를 넣었다고 endl이 괜찮아지지 않습니다.
반면 이 환경에서는 출력만 할 때 기본 설정의 '\n'이 동기화를 끈 쪽보다 오히려 빨랐습니다. 파일로 리다이렉트된 stdout은 C 런타임이 큰 버퍼로 모아 쓰는데, 동기화를 끄면 libstdc++의 자체 버퍼 경로로 바뀌기 때문으로 보입니다. 출력 전용 벤치마크에서 이런 역전은 구현마다 다르게 나오므로, “sync_with_stdio(false)는 항상 출력을 빠르게 한다”기보다 입력을 빠르게 하고, 출력에서는 endl을 피하는 것이 결정적이라고 이해하는 편이 정확합니다.
입력과 출력을 번갈아 할 때: cin.tie의 효과
정수를 하나 읽고 두 배를 바로 출력하는 루프(100만 회, 둘 다 sync_with_stdio(false), 출력은 '\n')입니다.
| 설정 | 시간 |
|---|---|
cin.tie 기본값 (cout에 묶임) | 약 3,400~3,600ms |
cin.tie(nullptr) | 약 320~440ms |
endl을 한 번도 쓰지 않았는데도 tie가 켜져 있으면 cin을 읽을 때마다 cout이 flush되므로, 결과적으로 endl을 쓴 것과 같은 비용이 듭니다. “읽고 바로 출력하는” 쿼리 처리형 문제에서 '\n'만 쓰고도 TLE가 난다면 cin.tie(nullptr)를 빠뜨린 경우가 많습니다. 이 측정에서 세 설정 중 가장 큰 차이를 만든 것이 이것이었습니다.
mmap과 io_uring은?
위 측정은 Windows에서 했기 때문에 mmap·io_uring 수치는 넣지 않았습니다. 두 방식은 “입력 파싱”보다 디스크에서 큰 파일을 읽어 오는 비용을 줄이는 기법이라, 결과가 파일 크기, 페이지 캐시에 이미 올라와 있는지, 저장장치 종류, 커널 버전에 크게 좌우됩니다. 일반적인 경향은 이렇습니다.
- 파일이 이미 페이지 캐시에 있으면 mmap은 복사 없이 메모리처럼 접근하므로
read보다 빠르거나 비슷합니다. 무작위 접근이 많을수록 유리합니다. - 캐시에 없는 큰 파일을 고속 SSD에서 여러 요청으로 동시에 읽는 경우에는 io_uring이 요청을 모아 제출해 시스템 콜 비용을 줄입니다.
- 코딩 테스트 입력(수 MB, 표준 입력)에서는 둘 다 의미가 거의 없고, 위의
fread직접 파싱으로 충분합니다.
프로덕션에서 도입을 검토한다면 반드시 실제 데이터와 실제 서버에서 read/mmap/io_uring을 같은 조건으로 측정해 보고 결정해야 합니다.
RAII mmap, io_uring 풀, 멀티스레드 파일 파싱
패턴 1: RAII로 mmap 관리
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <memory>
struct MmapDeleter {
size_t len;
void operator()(void* p) const {
if (p && p != MAP_FAILED) munmap(p, len);
}
};
using MmapPtr = std::unique_ptr<void, MmapDeleter>;
MmapPtr map_file(const char* path, size_t& out_size) {
int fd = open(path, O_RDONLY);
if (fd < 0) return nullptr;
struct stat st;
if (fstat(fd, &st) < 0) { close(fd); return nullptr; }
out_size = st.st_size;
void* p = mmap(nullptr, out_size, PROT_READ, MAP_PRIVATE, fd, 0);
close(fd);
if (p == MAP_FAILED) return nullptr;
return MmapPtr(p, MmapDeleter{out_size});
}
패턴 2: 스레드별 io_uring
io_uring의 제출 큐는 여러 스레드가 동시에 쓰면 안전하지 않으므로, 고성능 서버에서는 스레드마다 ring을 하나씩 두고 각 스레드가 자기 ring으로만 요청을 제출하는 구성이 흔합니다. 이렇게 하면 ring 접근에 잠금이 필요 없습니다.
// 의사 코드
thread_local io_uring tls_ring;
void init_thread() {
io_uring_queue_init(1024, &tls_ring, 0);
}
void handle_request(int fd) {
// tls_ring으로 비동기 read/write
}
패턴 4: 버퍼 풀 재사용
// io_uring에서 버퍼 풀 사용
struct BufferPool {
std::vector<std::unique_ptr<char[]>> pool;
std::vector<bool> in_use;
int acquire() {
for (size_t i = 0; i < in_use.size(); ++i) {
if (!in_use[i]) { in_use[i] = true; return i; }
}
pool.push_back(std::make_unique<char[]>(4096));
in_use.push_back(true);
return pool.size() - 1;
}
void release(int idx) { in_use[idx] = false; }
};
패턴 5: 코테용 Fast I/O 헤더
// fast_io.hpp
#pragma once
#include <iostream>
struct FastIO {
FastIO() { std::ios_base::sync_with_stdio(false); std::cin.tie(nullptr); }
};
inline FastIO fast_io_init; // C++17 inline 변수: 여러 번역 단위에서 포함해도 정의가 하나
헤더에 FastIO _fast_io;처럼 일반 전역 변수를 두면 두 개 이상의 .cpp에서 포함할 때 다중 정의 링크 에러가 나고, 밑줄로 시작하는 전역 이름은 구현용으로 예약되어 있습니다. 코딩 테스트처럼 파일이 하나라면 그냥 main 첫 줄에 두 줄을 쓰는 편이 가장 단순합니다.
패턴 6: 대용량 파일 멀티스레드 파싱
mmap으로 매핑한 영역을 워커 스레드가 청크별로 나눠 파싱합니다. 단순히 바이트 수로 나누면 경계에서 숫자나 줄이 잘리므로, 각 경계를 다음 줄바꿈 뒤로 밀어 줄 단위로 나눠야 합니다. parse_chunk는 [begin, end) 구간의 줄들을 처리하는 함수입니다.
#include <algorithm>
#include <thread>
#include <vector>
void process_large_file(const char* path) {
size_t size = 0;
MmapPtr mapping = map_file(path, size);
if (!mapping) return;
const char* p = static_cast<const char*>(mapping.get());
size_t n = std::max(1u, std::thread::hardware_concurrency()); // 0을 반환할 수 있음
std::vector<size_t> cut(n + 1, size);
cut[0] = 0;
for (size_t i = 1; i < n; ++i) {
size_t pos = std::max(cut[i - 1], size * i / n);
while (pos < size && p[pos] != '\n') ++pos; // 줄 경계로 맞춤
cut[i] = (pos < size) ? pos + 1 : size;
}
std::vector<std::thread> workers;
for (size_t i = 0; i < n; ++i) {
workers.emplace_back([p, b = cut[i], e = cut[i + 1]] { parse_chunk(p + b, p + e); });
}
for (auto& w : workers) w.join();
}
패턴 7: I/O 방식 선택 가이드
| 상황 | 권장 방식 |
|---|---|
| 코딩 테스트 일반 | sync+tie+‘\n’ |
| 코딩 테스트 극한 입력 | fread로 읽어 직접 파싱 |
| 수 MB~수 GB 파일, 반복·무작위 접근 | mmap |
| 캐시에 없는 대용량 파일·다수 동시 요청 | io_uring |
| 단순 스크립트/유틸 | 기본 cin/cout |
백준/프로그래머스 템플릿
최소 템플릿
#include <iostream>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
// ...
cout << answer << '\n';
return 0;
}
여러 줄 출력할 때
for (int i = 0; i < n; ++i) {
cout << result[i] << '\n'; // endl 쓰지 않기
}
한 줄에 공백으로 구분해 출력
cout << a << ' ' << b << ' ' << c << '\n';
입력이 많을 때
sync_with_stdio(false) + cin.tie(nullptr) 만 해도 대부분 해결됩니다. 그래도 부족하면:
scanf/printf를 쓰는 방법도 있습니다 (C 스트림은 별도로 버퍼링됨). 단,sync_with_stdio(false)이후에는cin/cout과 섞어 쓰지 마세요.
scanf/printf vs cin/cout 한눈에
| 항목 | cin / cout | scanf / printf |
|---|---|---|
| 속도 | 기본 설정은 느림. 위 최적화 후에는 위 측정에서 scanf보다 빨랐음 | 기본 cin보다는 빠름, 구현에 따라 차이 큼 |
| 형식 | 타입 안전, >>/<< 오버로드 | 형식 문자열(%d, %lld 등) 직접 지정 |
| 섞기 | sync_with_stdio(false) 이후엔 C 스트림과 섞지 말 것 | 같은 C 스트림이면 순서 보장 |
| 코테 | 최적화 두 줄 + '\n' 쓰면 충분한 경우 많음 | 극한 입력량에서 선택지 |
같이 보면 좋은 글
- C++ 파일 I/O 방식 비교
- C++ 문자열 파싱이 병목일 때
- C++ 코테용 STL 컨테이너/알고리즘 시간복잡도 치트시트 [#32-3]
- C++ 메모리 풀 구현 비교
- C++ 코딩테스트 팁
다음 글: [C++ 코테 압축 #32-2] C++ 문자열(String) 처리 영혼까지 끌어모으기 이전 글: [C++ 실전 가이드 #31-3] 데이터베이스 연동: SQLite와 PostgreSQL