C++ 기초: 첫 프로그램, 타입, 참조, std::string

C++ 강좌 1. Apple clang 21 로 직접 실행: 5분 만에 첫 컴파일, cout·printf·std::print 속도 실측, 참조와 포인터, const, {} 초기화 축소 변환 에러.

시리즈: C++ 강좌 1/6 (초급). C++ 가 처음인 분, 또는 C 나 Java 는 조금 써 봤는데 회사에서 C++ 코드를 읽게 된 분을 위한 글입니다. 다 읽으면 C++ 프로그램을 빌드해 실행하고, cout·printf·std::print 를 골라 쓰고, 참조·포인터·const 를 설명하고, 중괄호 초기화가 내는 축소 변환 에러를 읽을 수 있습니다.

게임 엔진, 증권 시스템, 브라우저 부품, 모바일 앱의 네이티브 부분까지, 새로 들어간 팀의 바닥이 C++ 인 경우는 흔합니다. 언어는 크지만 첫 주에 걸려 넘어지는 곳은 거의 정해져 있습니다. 소수점이 조용히 사라지고, 부호 있는 값과 없는 값의 비교가 거꾸로 나오고, + 로 이어 붙인 줄 알았던 문자열이 포인터 연산이 됩니다. 이 글은 그 하나하나를 실제 컴파일러로 돌려서 진짜 메시지를 보여 줍니다. 모든 결과는 Apple Silicon Mac(M1 Pro), macOS 26, Apple clang 21.0.0 + libc++ 에서 직접 실행한 것이고, 출력 속 경로는 줄였습니다.

백준이나 프로그래머스로 코딩 테스트를 준비해 봤다면 C++ 답안 맨 위에 붙이는 ios::sync_with_stdio(false); cin.tie(nullptr); 를 기억할 겁니다. 그 "주문"이 지금 이 환경에서 정말 효과가 있는지도 뒤에서 실측합니다. C 를 안다면 컴파일 과정과 printf 서식은 C 강좌 1 을 먼저 보고, 겹치는 부분은 건너뛰어도 됩니다.

5분 만에 첫 C++ 프로그램

Mac 에서는 Xcode Command Line Tools 에 컴파일러가 들어 있습니다. C++ 명령은 clang 이 아니라 clang++ 입니다.

$ clang++ --version
Apple clang version 21.0.0 (clang-2100.4.1.1)
Target: arm64-apple-darwin25.6.0

hello.cpp 를 만듭니다.

#include <iostream>

int main() {
    std::cout << "Hello, C++!\n";
    return 0;
}

#include <iostream> 은 표준 라이브러리의 입출력 스트림을 불러옵니다. std:: 는 네임스페이스라는 접두어로, 표준 이름과 내 이름이 부딪히지 않게 해 줍니다. std::cout 이 표준 출력이고 << 로 값을 흘려 넣습니다.

$ clang++ -std=c++23 -Wall -Wextra -pedantic -o hello hello.cpp
$ ./hello
Hello, C++!

-std=c++23 은 언어 판을 고르는 옵션이고, 반드시 붙이세요. 안 붙이면 Apple clang 21 은 C++14 로 컴파일했습니다. __cplusplus 를 찍으니 201402L 이었고, C++17 기능(구조적 바인딩)을 쓰자 warning: decomposition declarations are a C++17 extension [-Wc++17-extensions] 가 나왔습니다. -Wall -Wextra -pedantic 은 초보 버그 대부분을 잡는 경고를 한꺼번에 켭니다.

첫날 반드시 보는 에러 두 가지

std:: 를 빠뜨렸을 때:

nostd.cpp:3:5: error: use of undeclared identifier 'cout'; did you mean 'std::cout'?
    3 |     cout << "Hello\n";
      |     ^~~~
      |     std::cout

clang++ 대신 clang 으로 빌드했을 때. 컴파일은 되지만 마지막 링커(내 코드와 라이브러리를 잇는 단계)가 C++ 표준 라이브러리를 못 찾아서 이런 에러가 쏟아집니다.

Undefined symbols for architecture arm64:
  "std::__1::ios_base::getloc() const", referenced from:
  ...
ld: symbol(s) not found for architecture arm64
clang: error: linker command failed with exit code 1 (use -v to see invocation)

Hello world 가 std::__1 이 들어간 Undefined symbols 로 실패하면 먼저 입력한 명령을 확인하세요. clang++ 는 -lc++ 를 알아서 붙여 줍니다.

출력: cout, printf, std::print

C++ 는 C 의 printf 를 물려받고 스트림을 더했습니다. C++23 에는 Python 처럼 {} 를 쓰는 std::print 도 들어왔습니다. 이 환경에서는 셋 다 동작합니다.

#include <cstdio>
#include <iostream>
#include <print>

int main() {
    double price = 19.99;
    int qty = 3;
    std::printf("%d x %.2f\n", qty, price);           // C style: format must match types
    std::cout << qty << " x " << price << '\n';        // stream: type picked by the compiler
    std::print("{} x {:.2f}\n", qty, price);           // C++23: type-safe and formatted
}

세 줄 모두 3 x 19.99 를 찍습니다. 초보에게 중요한 차이는 타입 안전성입니다. printf 는 서식이 틀려도 (-Wformat 경고와 함께) 컴파일되고 엉뚱한 값을 냅니다. cout 과 std::print 는 타입을 보고 컴파일러가 변환을 고릅니다. std::format(C++20) 은 같은 방식으로 std::string 을 만듭니다. 둘 다 새 모드에서만 됩니다. -std=c++20 에서는 std::print 가 error: no member named 'print' in namespace 'std', -std=c++17 에서는 std::format 이 같은 에러였습니다.

std::endl 은 "C++ 의 줄바꿈"이 아니다

std::endl 은 '\n' 을 쓰고 flush 까지 합니다. 버퍼에 쌓인 글자를 운영체제로 밀어내는 일입니다. 터미널에서는 차이가 안 보이지만 파일로 쓰면 비쌉니다. 정수 100 만 개를 한 줄씩 파일에 쓰고(./outbench MODE > out.txt), -O2, 각 7 회 중앙값을 냈습니다.

방법 중앙값
std::printf("%d\n", i) 67.9 ms
std::cout << i << '\n' 92.4 ms
위와 같음, std::ios::sync_with_stdio(false) 뒤 92.4 ms
std::print("{}\n", i) (C++23) 541.6 ms
std::cout << i << std::endl 1646.7 ms
막대그래프. printf 는 67.9 밀리초. std::cout 과 개행 문자는 92.4, sync_with_stdio(false) 를 붙여도 92.4. C++23 의 std::print 는 541.6. 줄마다 flush 하는 std::endl 을 쓴 std::cout 은 1646.7 밀리초로, 개행 문자보다 약 18 배 느리다.
정수 100 만 개를 한 줄씩 파일에 쓰는 시간. 7 회 중앙값, -O2, Apple clang 21 + libc++, Apple Silicon Mac(M1 Pro).

의외였던 점이 세 가지입니다.

  • std::endl 은 '\n' 보다 18 배 느렸습니다. '\n' 을 쓰고, 정말 지금 보여야 할 때(긴 계산 직전 등)만 std::cout.flush() 를 한 번 부르세요.
  • 코테 단골 sync_with_stdio(false) 는 이 libc++ 에서 아무것도 바꾸지 못했습니다. 이 측정은 파일 출력만 본 것이라 cin 입력 속도(코테에서 이 주문이 노리는 쪽)는 따로 재야 합니다. 다른 라이브러리에서 잰 옛 조언을 그대로 가져올 수는 없다는 점만은 분명합니다.
  • flush 가 없는 것 중에서는 std::print 가 가장 느렸습니다. libc++ 의 <print> 헤더를 읽어 보면 호출할 때마다 출력 대상이 터미널인지 확인한 뒤 씁니다. 서식 처리 자체는 빨라서, std::format 으로 문자열을 만들고 fputs 하는 방법은 같은 출력에 약 62 ms 였습니다. 이것은 이 libc++ 의 성질이지 std::print 일반의 이야기는 아닙니다. GCC 의 libstdc++ 나 MSVC 는 돌려 보지 않았습니다.

평소 코드라면 cout 과 '\n', 또는 std::print 로 충분합니다. 수백만 줄을 찍을 때만 가장 빠른 방법을 고르면 됩니다.

타입, auto, 정수 함정

내장 타입은 C 와 같고, 이 환경에서 int 는 4 바이트, long 과 포인터는 8, double 은 8 입니다. C++ 에서 바로 만나는 추가 사항은 두 가지입니다.

  • bool 타입과 true/false. std::cout 은 std::boolalpha 를 붙이지 않으면 1/0 으로 찍습니다.
  • auto 는 초기값에서 타입을 추론합니다. auto n = 7 / 2; 는 int(값 3), auto x = 7 / 2.0; 는 double(3.5). auto 가 계산을 똑똑하게 만들어 주지는 않습니다. 식의 타입을 그대로 베낄 뿐입니다.

다음 파일은 경고 두 개만 내고 컴파일되며, 하나같이 직관과 다른 결과를 냅니다.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v;                       // empty
    std::cout << "v.size() - 1 = " << v.size() - 1 << '\n';

    int n = -1;
    if (n < v.size()) std::cout << "-1 < 0 ?  yes\n";
    else              std::cout << "-1 < 0 ?  no (!)\n";

    auto big = 100000 * 100000;              // int * int
    std::cout << "big = " << big << '\n';

    float f = 16777216.0f;
    std::cout << "16777216f + 1 == 16777216f ? " << (f + 1.0f == f) << '\n';
}
traps.cpp:12:23: warning: overflow in expression; result is 1'410'065'408 with type 'int' [-Winteger-overflow]
traps.cpp:9:11: warning: comparison of integers of different signs: 'int' and 'size_type' (aka 'unsigned long') [-Wsign-compare]

v.size() - 1 = 18446744073709551615
-1 < 0 ?  no (!)
big = 1410065408
16777216f + 1 == 16777216f ? 1
  • size() 는 부호 없는 타입(size_t, 여기서는 8 바이트)을 돌려줍니다. 부호 없는 값은 0 아래로 못 내려가므로 0 - 1 이 2⁶⁴ − 1 로 돌아갑니다. C++ 반복문에서 가장 흔한 버그입니다(2 강 연습 문제 3 에서 실제로 터뜨립니다).
  • int 와 size_t 를 비교하면 -1 이 거대한 부호 없는 값으로 바뀌어 -1 < 0 이 거짓이 됩니다. C++ 에서는 -Wall 이 -Wsign-compare 를 켜므로 경고가 나옵니다.
  • 100000 * 100000 은 int 로 계산되어 넘칩니다. 왼쪽을 auto 나 long long 으로 해도 이미 늦습니다. 100000LL * 100000 으로 씁니다.
  • float 의 정밀도는 24 비트라서 16,777,216 을 넘으면 모든 정수를 표현하지 못하고, 1 을 더해도 그대로입니다. 기본은 double 입니다.

중괄호 초기화와 축소 변환 에러

C++11 에서 중괄호 초기화 int x{5}; 가 들어왔습니다. 가장 큰 장점은 정보를 잃는 변환, 즉 축소 변환(narrowing) 을 거부한다는 점입니다.

#include <iostream>
int main() {
    double price = 19.99;
    int a = price;          // compiles, a = 19, no warning with -Wall -Wextra
    int b{price};           // error
    int c{3.5};             // error
    char d{300};            // error
    unsigned e{-1};         // error
    std::cout << a << b << c << d << e << '\n';
}
narrow.cpp:5:11: error: type 'double' cannot be narrowed to 'int' in initializer list [-Wc++11-narrowing]
    5 |     int b{price};           // error
      |           ^~~~~
narrow.cpp:5:11: note: insert an explicit cast to silence this issue
      |           static_cast<int>( )
narrow.cpp:7:12: error: constant expression evaluates to 300 which cannot be narrowed to type 'char' [-Wc++11-narrowing]
narrow.cpp:8:16: error: constant expression evaluates to -1 which cannot be narrowed to type 'unsigned int' [-Wc++11-narrowing]
2 warnings and 4 errors generated.

(줄여 둔 경고 두 개는 3.5 가 3 이 되고 300 이 44 가 된다는 내용입니다.) 눈여겨볼 곳은 4 번째 줄 int a = price; 입니다. -Wall -Wextra -pedantic 에서도 아무것도 안 나옵니다. -Wconversion 을 더해야 implicit conversion turns floating-point number into integer: 'double' to 'int' [-Wfloat-conversion] 가 나옵니다. 중괄호는 조용한 버림을 빌드 에러로 바꿉니다. 정말 버리려는 거라면 static_cast<int>(price) 로 써서 의도를 남기세요.

참조, 포인터, const

참조는 이미 있는 객체의 다른 이름이고, 포인터는 주소를 담는 변수입니다. 같은 일을 세 가지로 써 봅니다.

#include <iostream>

void add_bonus_copy(int score) { score += 10; }            // changes a copy
void add_bonus_ref(int& score) { score += 10; }            // changes the caller's int
void add_bonus_ptr(int* score) { if (score) *score += 10; } // may receive nullptr

int main() {
    int s = 50;
    add_bonus_copy(s); std::cout << "copy: " << s << '\n';
    add_bonus_ref(s);  std::cout << "ref:  " << s << '\n';
    add_bonus_ptr(&s); std::cout << "ptr:  " << s << '\n';
    add_bonus_ptr(nullptr);

    int& r = s;
    int other = 1;
    int* p = &s;
    p = &other;   // a pointer can be re-seated
    r = other;    // a reference cannot: this copies 1 into s
    std::cout << "s=" << s << " *p=" << *p << '\n';
}
refs.cpp:3:25: warning: parameter 'score' set but not used [-Wunused-but-set-parameter]
copy: 50
ref:  60
ptr:  70
s=1 *p=1

값 전달 버전이 쓸모없다는 걸 clang 은 이미 알고, "매개변수를 바꾸기만 하고 쓰지 않는다"고 경고합니다. 마지막 줄이 단골 함정입니다. r = other 는 r 이 other 를 가리키게 하는 것이 아니라 s 에 1 을 대입합니다.

const 는 "이 이름으로는 객체를 바꾸지 않는다"는 약속이고 컴파일러가 지키게 합니다. 일부러 다섯 군데를 틀린 파일입니다.

int main() {
    int& r;                 // must be initialized
    const int limit = 10;
    limit = 20;             // const
    int& r2 = limit;        // drops const
    int& r3 = 42;           // non-const ref to temporary
    const int& r4 = 42;     // OK
    int x = 5;
    const int* pc = &x;     // pointer to const int
    *pc = 6;
    return r4 + r2 + r3 + r;
}
referr.cpp:2:10: error: declaration of reference variable 'r' requires an initializer
referr.cpp:4:11: error: cannot assign to variable 'limit' with const-qualified type 'const int'
referr.cpp:5:10: error: binding reference of type 'int' to value of type 'const int' drops 'const' qualifier
referr.cpp:6:10: error: non-const lvalue reference to type 'int' cannot bind to a temporary of type 'int'
referr.cpp:10:9: error: read-only variable is not assignable

네 번째 에러는 기억해 둘 만합니다. int& r3 = 42; 는 안 되지만 const int& r4 = 42; 는 됩니다. const 참조는 임시 객체에 묶일 수 있어서, 함수 매개변수를 const std::string& 로 받으면 호출하는 쪽이 "text" 를 그대로 넘길 수 있습니다.

판단 그림. 첫 질문은 '함수가 호출한 쪽 변수를 바꿔야 하는가'. 아니면 int 나 double 처럼 작은 타입인지 묻고, 그렇다면 값으로, 아니면 const 참조로 넘긴다. 바꿔야 한다면 '없을 수도 있는가'를 묻고, 항상 있다면 참조 int& score, 없을 수도 있다면 포인터 int* score 를 쓰고 nullptr 인지 확인한 뒤 쓴다.
초급 C++ 에서 값을 넘기거나 붙잡을 때 고르는 법. 왼쪽 질문에서 시작한다. 포인터는 '없을 수도 있다'일 때 쓰는 것이지, '호출한 쪽 변수를 바꾸기' 위한 것이 아니다.

"잘 돌아가는" 댕글링 참조

참조는 객체보다 오래 살면 안 됩니다. 다음 코드는 경고와 함께 컴파일됩니다.

const std::string& greeting() {
    std::string s = "hello";
    return s;                 // s is destroyed when the function returns
}
dang.cpp:5:12: warning: reference to stack memory associated with local variable 's' returned [-Wreturn-stack-address]

실행 결과가 경고보다 더 많은 걸 가르쳐 줬습니다. 일반 빌드는 빈 줄을 찍었습니다. AddressSanitizer 빌드(-fsanitize=address)는 아무 일 없다는 듯 hello 를 찍었습니다. ASAN_OPTIONS=detect_stack_use_after_return=1 을 주고 나서야 ERROR: AddressSanitizer: stack-use-after-return ... in main dang.cpp:7 로 멈췄습니다. 정의되지 않은 동작은 성공처럼 보일 수 있고, 새니타이저 검사 중에도 기본으로 꺼진 것이 있다는 두 가지 교훈입니다. std::string 은 값으로 돌려줘도 충분히 쌉니다(이유는 4 강).

std::string 과 C 문자열

C 문자열은 끝이 0 인 char 배열이고, std::string 은 자기 메모리를 직접 관리하며 길이를 아는 클래스입니다. 처음 부딪히는 차이를 모았습니다.

a == b (C strings): 0            // compares addresses, not text
strcmp(a, b) == 0 : 1
s1 == s2 (std::string): 1        // compares text
Hello, Mina! size=12
"日本" size() = 6 (bytes, not characters)
sizeof(std::string) = 24
at(10): basic_string             // the exception text libc++ gives

size() 는 바이트 수입니다. UTF-8 에서 한글 한 글자는 3 바이트라서 "한글" 도 6 이 됩니다. 닉네임 글자 수 제한 같은 곳에 size() 를 쓰면 세 배로 셉니다. sizeof(std::string) 은 내용 길이와 상관없이 libc++ 에서 24 바이트이고, 짧은 문자열은 이 24 바이트 안에, 긴 문자열은 힙에 둡니다(바뀌는 지점은 6 강에서 잽니다). C 함수에 넘길 때는 .c_str() 를 씁니다.

리터럴끼리 잇기는 단골 첫 에러입니다.

strerr.cpp:2:40: error: invalid operands to binary expression ('const char[8]' and 'const char[6]')
    2 | int main() { std::string s = "Hello, " + "world"; ...

"Hello, " + name 은 한쪽이 std::string 이라 됩니다. 리터럴 두 개는 그냥 배열입니다. 더 위험한 건 리터럴에 숫자를 더하는 경우로, 이건 컴파일이 됩니다(연습 문제 3).

흔한 실수 → 실제로 일어난 일 → 해결

실수 실제로 본 것 해결
-std= 를 안 붙임 C++14 로 처리, C++17 코드에 경고 항상 -std=c++20 또는 -std=c++23
clang++ 대신 clang Undefined symbols ... std::__1 clang++ 사용
반복문 안의 std::endl 파일 출력 18 배 느림 '\n' 쓰고 마지막에 한 번 flush
빈 v 에서 for (size_t i = v.size() - 1; ...) 18446744073709551615 먼저 empty() 확인, 역순은 안전한 방식으로
int a = price; 조용히 19 int a{price} 는 에러, 의도라면 static_cast<int>
지역 변수의 참조를 반환 빈 줄, ASan 에서는 "잘 됨" 값으로 반환
"Total: " + n 쓰레기 값이나 빈 줄 std::to_string(n) 또는 std::format

솔직한 한계와 버전 메모

  • 실행한 것은 arm64 macOS 26 의 Apple clang 21.0.0 과 libc++(_LIBCPP_VERSION 220106) 뿐입니다. GCC/libstdc++, MSVC, Linux 는 돌려 보지 않았습니다.
  • -std 없이의 기본값은 이 컴파일러에서 C++14(__cplusplus 로 확인). 다른 컴파일러는 기본값이 다릅니다.
  • 중괄호 초기화와 축소 변환 에러는 C++11. auto x{42} 가 initializer_list 가 아니라 int 가 되는 건 C++17 규칙(N3922). std::format 은 C++20, std::print 는 C++23 이고, 여기서는 -std=c++23 일 때만 됐습니다.
  • 출력 시간은 한 대의 기계에서 파일로 쓴 값입니다. 터미널 출력은 모두 느려지고 순위도 바뀔 수 있습니다. 다만 endl 교훈은 어디서나 같습니다. flush 는 진짜 일을 하기 때문입니다.

연습 문제

1. 출력을 예상한 뒤 실행해 보세요.

int a{7 / 2};
double b = 7 / 2;
auto c = 7 / 2.0;
auto d = 'A' + 1;
std::cout << a << ' ' << b << ' ' << c << ' ' << d << '\n';

2. 다음 중 컴파일 에러가 나는 것은 무엇이고 왜인가요? int x{2.0}; / const int k = 5; k++; / int& r; / const int& t = 5;

3. 다음 코드는 컴파일됩니다. 무엇이 찍히고, 어떻게 고치나요?

int total = 42;
std::string s = "Total: " + total;
std::cout << s << '\n';

정답

1. 실제 출력은 3 3 3.5 66. a 와 b 는 둘 다 정수 나눗셈 7 / 2 = 3 입니다(b 는 3.0 이고 cout 은 3 으로 찍습니다). 2.0 으로 나누는 c 만 3.5. 'A' + 1 은 char 가 int 로 승격되므로 d 는 'B' 가 아니라 int 66 입니다.

2. 앞의 세 개가 에러입니다. int x{2.0} 은 값이 정수여도 축소 변환입니다(type 'double' cannot be narrowed to 'int'). k++ 는 const 를 바꿉니다. int& r; 은 초기값이 없습니다. const int& t = 5; 는 const 참조라 임시 객체에 묶일 수 있어 괜찮습니다.

3. clang 이 adding 'int' to a string does not append to the string [-Wstring-plus-int] 라고 경고합니다. 리터럴은 8 글자짜리 배열이고 + 42 는 포인터를 42 바이트 뒤로 옮길 뿐이라, 내 것이 아닌 메모리를 읽습니다. 실행해 보니 빈 줄이 찍혔습니다. -fsanitize=undefined 를 주면 runtime error: index 42 out of bounds for type 'const char[8]'. "Total: " + std::to_string(total) 이나 std::format("Total: {}", total) 로 고치면 둘 다 Total: 42 를 찍습니다.

다음 강의

2 강은 함수, 오버로딩, std::vector 입니다. show(5L) 이 왜 모호한지, vector 를 값으로 넘기면 실제로 얼마나 드는지, push_back 뒤에 죽어 버리는 참조를 다룹니다.

자주 묻는 질문

std::endl 과 '\n' 중 무엇을 써야 하나요?

'\n' 입니다. std::endl 은 줄바꿈 뒤에 스트림을 flush 하므로, 파일로 출력하면 줄마다 시스템 콜이 일어납니다. Apple Silicon Mac 에서 100 만 줄을 파일에 쓰니 '\n' 은 92.4 ms, std::endl 은 1646.7 ms 로 약 18 배 느렸습니다. 지금 바로 보여야 할 때만 명시적으로 flush 하세요.

C++ 에서 참조와 포인터는 무엇이 다른가요?

참조는 이미 있는 객체의 다른 이름입니다. 반드시 초기화해야 하고, null 이 될 수 없고, 나중에 다른 객체를 가리키게 바꿀 수 없습니다(참조에 대입하면 원래 변수에 값이 복사됩니다). 포인터는 주소를 담는 변수라 nullptr 일 수 있고 가리키는 대상을 바꿀 수 있습니다. 반드시 있는 대상이면 참조, 없을 수도 있으면 포인터를 씁니다.

int x{3.5}; 는 왜 컴파일 에러가 나나요?

C++11 의 중괄호 초기화는 축소 변환(정보를 잃는 변환)을 금지하기 때문입니다. clang 21 은 "error: type 'double' cannot be narrowed to 'int' in initializer list [-Wc++11-narrowing]" 을 냅니다. int x = 3.5; 는 통과해서 조용히 3 이 들어갑니다. 조용한 데이터 손실을 빌드 에러로 바꿔 주는 것이 중괄호의 장점입니다.

  • #C++
  • #초급
  • #clang
  • #iostream
  • #참조
  • #std::string

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑