C++ · 심층 가이드
C++ 기초 문법 완전 정리
변수 선언과 조건 분기, 반복문, 컨테이너와 타입 변환까지 C++ 기본 문법을 다루면서 값 복사와 참조, 정수 승격 같은 언어 고유의 함정을 함께 짚습니다.
C++의 기초 문법은 다른 언어와 표기가 비슷해서 오히려 오해를 부릅니다. 대입이 값 복사라는 점, 변수마다 수명이 스코프에 묶여 있다는 점, 같은 산술 연산이 타입에 따라 다른 결과를 내는 점이 대표적입니다. 다른 언어를 먼저 배운 사람이 자주 걸리는 지점도 여기입니다. 문법을 외우는 것보다 각 줄에서 값이 복사되는지 참조가 넘어가는지를 매번 짚는 습관이 이 단계에서 훨씬 큰 차이를 만듭니다.
앞쪽 절반은 값과 판단을 다룹니다. 숫자 변수 선언과 문자열 상수 선언에서 타입과 상수성을 잡고, if else 분기와 삼항 연산자 사용으로 같은 판단을 두 가지 형태로 써 봅니다. 뒤쪽 절반은 묶음을 다루는데, 배열 생성과 순회에서 시작해 맵 또는 객체 생성과 정렬 기초로 이어지면서 표준 라이브러리 컨테이너와 알고리즘의 관계가 드러납니다. 타입 변환은 앞의 모든 주제와 얽히므로 마지막에 다시 읽으면 이해가 붙습니다.
가장 조용히 사고를 내는 것은 부호 있는 정수와 없는 정수를 섞는 경우입니다. 컨테이너의 크기는 부호 없는 타입으로 나오기 때문에, 이를 int와 비교하면 int 쪽이 부호 없는 타입으로 변환됩니다. 비어 있는 컨테이너에서 크기에 1을 뺀 값은 음수가 아니라 매우 큰 수가 되어, 반복문이 즉시 끝나야 할 자리에서 폭주합니다. 인덱스 순회 대신 범위 기반 for를 쓰면 이 계열의 문제를 대부분 피할 수 있습니다. 또 지역 변수를 초기화 없이 선언하면 값이 정해지지 않은 상태라, 실행할 때마다 다른 결과가 나오거나 디버그 빌드에서만 정상 동작하는 현상이 생깁니다.
01숫자 변수 선언
int 변수 count에 1을 넣고 삼항 연산자로 5와 비교해 문자열을 고릅니다. 값을 담고 조건에 따라 분기하는 가장 기본적인 조합입니다. int는 표현 범위를 넘어서면 부호 있는 정수 오버플로가 되어 정의되지 않은 동작이 되므로, 큰 값을 다룰 때는 long long이나 <cstdint>의 고정 폭 타입을 고릅니다.
C++ code
// 숫자 변수 선언
#include <iostream>
int main() {
int count = 1;
std::cout << (count > 5 ? "big" : "small") << "\n";
}빌드에 -fsanitize=undefined를 켜 두면 이런 정수 연산이 표현 범위를 넘는 순간을 실행 중에 바로 잡아내 위치까지 알려 줍니다.
int count;처럼 초기화 없이 선언한 지역 변수를 읽으면 값이 쓰레기일 뿐 아니라 정의되지 않은 동작이므로, 선언과 동시에 값을 주어야 합니다.
02문자열 상수 선언
이 코드는 std::vector<int>를 중괄호 목록으로 초기화하고 std::sort로 정렬한 뒤 맨 앞 값을 출력합니다. 값을 모아 두고 순서를 정한 다음 첫 항목을 보는 흐름입니다. front()는 비어 있는 컨테이너에 대해 검사를 하지 않아 그대로 정의되지 않은 동작이 되므로, 크기가 보장되지 않는다면 empty()를 먼저 확인해야 합니다.
C++ code
// 문자열 상수 선언
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> values = {3, 1, 4, 2};
std::sort(values.begin(), values.end());
std::cout << values.front() << "\n";
}std::sort는 원소를 실제로 옮겨 담으므로, 정렬 대상이 큰 객체라면 인덱스나 포인터만 정렬하고 실제 배열은 그대로 두는 편이 훨씬 저렴합니다.
front() 대신 values[0]을 써도 빈 컨테이너를 확인하지 않는 것은 마찬가지이고, at(0)만이 범위를 벗어날 때 예외를 던집니다.
03불리언 플래그 만들기
count가 3이라 조건 count > 5는 거짓이 되고 small이 출력됩니다. 참과 거짓 두 갈래만 있는 판단을 값 하나로 표현할 때의 형태입니다. C++에서 조건식은 bool로 변환되어 0이 아닌 정수는 모두 참이 되므로, 비교 결과를 정수 변수에 담아 넘기기보다 bool로 선언해 의도를 드러내는 편이 낫습니다.
C++ code
// 불리언 플래그 만들기
#include <iostream>
int main() {
int count = 3;
std::cout << (count > 5 ? "big" : "small") << "\n";
}bool 변수 이름을 is_ready나 has_next처럼 지으면 조건문에서 부정을 붙였을 때도 뜻이 그대로 읽혀 분기를 잘못 짜는 일이 줄어듭니다.
if (count = 3)처럼 비교 대신 대입을 적으면 조건이 항상 참이 되어 조용히 통과하므로, 컴파일 경고를 켜 두지 않으면 찾기 어려운 버그가 됩니다.
04문자열 길이 구하기
{3, 1, 4, 4}처럼 같은 값이 섞인 목록을 정렬하고 첫 원소를 찍습니다. 컬렉션의 크기나 최솟값을 확인하기 전에 자료를 정돈해 두는 습관을 보여 줍니다. 참고로 std::sort는 같은 값의 상대 순서를 유지하지 않으므로 그 순서가 의미를 가진다면 std::stable_sort를 써야 합니다.
C++ code
// 문자열 길이 구하기
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> values = {3, 1, 4, 4};
std::sort(values.begin(), values.end());
std::cout << values.front() << "\n";
}std::stable_sort는 추가 메모리를 쓸 수 있는 대신 같은 값의 순서를 지키며, 여러 기준으로 차례차례 정렬해 나갈 때는 이 성질이 반드시 필요합니다.
정렬 전에 얻어 둔 반복자나 참조는 원소가 자리를 옮기면서 다른 값을 가리키게 되므로, 정렬 뒤에는 위치를 다시 구해야 합니다.
05문자열 정리하기
값 5는 조건 count > 5를 만족하지 않아 small이 나옵니다. 경계값에서 어느 쪽으로 갈라지는지 눈으로 확인하는 형태라, 조건을 손볼 때 기준으로 삼기 좋습니다. >와 >=의 차이가 그대로 결과를 바꾸며, 부호 없는 타입과 섞어 비교하면 int가 unsigned로 변환되어 음수가 큰 값으로 뒤집히니 주의가 필요합니다.
C++ code
// 문자열 정리하기
#include <iostream>
int main() {
int count = 5;
std::cout << (count > 5 ? "big" : "small") << "\n";
}경계 조건은 count > 5와 count >= 5를 각각 5로 시험해 보면 어느 쪽이 의도한 규칙인지 한 번에 드러납니다.
count < values.size()처럼 int와 size_t를 비교하면 음수가 거대한 양수로 바뀌어 조건이 뒤집히므로, 크기는 부호 없는 타입끼리 비교해야 합니다.
06숫자 합계 계산
정렬 후 values.front()를 출력하므로 화면에는 목록에서 가장 작은 값이 나타납니다. 합계나 최솟값 같은 요약값을 얻기 전에 자료를 다루는 기본 절차입니다. 최솟값만 필요하다면 전체를 정렬하는 대신 선형 시간인 std::min_element가 낫고, 합계는 <numeric>의 std::accumulate로 구합니다.
C++ code
// 숫자 합계 계산
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> values = {3, 1, 4, 6};
std::sort(values.begin(), values.end());
std::cout << values.front() << "\n";
}std::min_element는 값이 아니라 반복자를 돌려주므로 역참조해서 써야 하고, 빈 범위에서는 끝 반복자를 주므로 먼저 비교해 두어야 합니다.
std::accumulate의 초기값을 0으로 두고 큰 정수를 더하면 int로 누산되어 범위를 넘으므로, 합계에는 0LL을 넘겨야 합니다.
07나머지 연산 확인
정수 변수와 비교 연산, 그리고 삼항 연산자가 한 줄에 모여 있습니다. 나머지나 배수 판정처럼 산술 결과로 갈래를 나눌 때도 같은 뼈대를 씁니다. C++의 %는 음수 피연산자에서 결과의 부호가 피제수를 따르므로 -7 % 3은 2가 아니라 -1이며, 짝수 판정처럼 부호가 섞이는 검사에서는 이 점이 자주 걸립니다.
C++ code
// 나머지 연산 확인
#include <iostream>
int main() {
int count = 7;
std::cout << (count > 5 ? "big" : "small") << "\n";
}2의 거듭제곱으로 나눈 나머지는 value % 8 대신 value & 7로도 구할 수 있지만, 음수에서는 결과가 달라지므로 부호 없는 타입에서만 안전합니다.
홀수 판정을 value % 2 == 1로 적으면 음의 홀수에서 나머지가 -1이라 거짓이 되므로, != 0으로 비교해야 부호와 무관하게 맞습니다.
08if else 분기
벡터를 정렬하고 첫 값을 출력하는 짧은 흐름이지만, std::sort 내부에는 이미 수많은 조건 분기가 들어 있습니다. 직접 if를 늘어놓는 대신 알고리즘에 맡기는 쪽을 보여 줍니다. std::sort는 임의 접근 반복자를 요구하므로 std::list에는 쓸 수 없고, 그 경우 멤버 함수인 list::sort를 불러야 합니다.
C++ code
// if else 분기
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> values = {3, 1, 4, 8};
std::sort(values.begin(), values.end());
std::cout << values.front() << "\n";
}std::list의 sort는 노드 연결만 바꾸므로 원소를 옮기지 않고, 정렬 중에도 기존 반복자가 가리키던 원소가 그대로 유지됩니다.
이미 정렬된 입력인지 확인하려면 std::is_sorted를 먼저 불러야 하며, std::sort는 정렬 여부와 무관하게 항상 전체 범위를 훑습니다.
09switch 또는 match 사용
값 하나를 놓고 조건에 따라 두 결과 중 하나를 고르는 코드입니다. 갈래가 늘어나면 이 자리에 switch가 들어오게 됩니다. switch는 정수와 열거형에만 쓸 수 있어 std::string에는 적용되지 않고, case 끝에 break를 빠뜨리면 다음 절로 그대로 흘러 들어가므로 의도한 경우 [[fallthrough]]로 표시합니다.
C++ code
// switch 또는 match 사용
#include <iostream>
int main() {
int count = 9;
std::cout << (count > 5 ? "big" : "small") << "\n";
}enum class를 switch로 다룰 때 default 절을 일부러 빼 두면, 나중에 값이 추가됐을 때 컴파일러가 처리되지 않은 항목을 경고로 알려 줍니다.
case 안에서 변수를 초기화하며 선언하면 그 초기화를 건너뛰는 경로가 생겨 컴파일 오류가 나므로, 중괄호로 범위를 따로 만들어 주어야 합니다.
10null 기본값 처리
리터럴 목록으로 std::vector를 채우면 원소가 없는 상태를 거치지 않으므로, 이 코드에서 front()는 안전합니다. 값이 비었을 수 있는 상황을 다루려면 그 자체를 타입으로 표현하는 편이 좋습니다. C++17의 std::optional은 값의 유무를 담고 value_or로 대체값을 지정할 수 있어, 특정 숫자를 없음의 표시로 쓰는 관행을 대체합니다.
C++ code
// null 기본값 처리
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> values = {3, 1, 4, 10};
std::sort(values.begin(), values.end());
std::cout << values.front() << "\n";
}std::optional은 값을 힙이 아니라 자기 안에 담으므로 추가 할당이 없고, 대신 객체 크기가 값 하나보다 커진다는 대가를 치릅니다.
비어 있는 optional에 operator*를 쓰면 검사 없이 정의되지 않은 동작이 되고, value()만 std::bad_optional_access를 던집니다.
11삼항 연산자 사용
출력문 안에서 count > 5를 평가해 두 문자열 중 하나를 고르는데, 이때 조건 연산자의 두 갈래는 공통 타입으로 맞춰집니다. 짧은 분기를 식 하나로 줄일 때 쓰는 형태입니다. 두 갈래의 타입이 서로 변환되지 않으면 컴파일 오류가 나므로, int와 const char*를 섞어 두는 식의 작성은 통하지 않습니다.
C++ code
// 삼항 연산자 사용
#include <iostream>
int main() {
int count = 11;
std::cout << (count > 5 ? "big" : "small") << "\n";
}조건 연산자는 값을 만들어 내는 식이라 const 변수를 초기화하는 자리에 그대로 쓸 수 있고, if로 같은 일을 하려면 변수를 먼저 비워 두어야 합니다.
삼항 연산자를 여러 겹으로 중첩하면 어느 조건이 어느 갈래에 붙는지 눈에 보이지 않아, 조건 하나를 고칠 때 엉뚱한 쪽을 건드리기 쉽습니다.
12for 반복문
std::sort는 컨테이너 전체를 도는 반복을 대신해 주며, 여기서는 시작과 끝 반복자를 넘겨 범위를 지정합니다. 손으로 인덱스를 세는 반복문을 알고리즘으로 바꾸는 출발점입니다. C++20부터는 std::ranges::sort(values)처럼 컨테이너를 통째로 넘길 수 있어 반복자 쌍을 잘못 조합하는 실수를 줄일 수 있습니다.
C++ code
// for 반복문
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> values = {3, 1, 4, 12};
std::sort(values.begin(), values.end());
std::cout << values.front() << "\n";
}범위 기반 for에서 원소를 수정하지 않을 때는 const auto&로 받아야 복사가 생기지 않으며, auto로 받으면 원소마다 사본이 만들어집니다.
인덱스 반복문에서 i <= values.size()로 적으면 마지막에 범위를 한 칸 넘겨 읽게 되고, 이 접근은 아무 검사 없이 그대로 통과합니다.
13while 누적 계산
변수 하나와 비교식으로 이루어져, 반복문 안에서 누적값을 검사할 때 쓰는 판단부와 같은 모양입니다. 조건이 언제 뒤집히는지가 종료 여부를 결정합니다. while로 누적할 때는 갱신 문장을 빠뜨리면 조건이 영원히 참으로 남으므로, 값이 매 반복마다 실제로 변하는지를 조건과 함께 확인해야 합니다.
C++ code
// while 누적 계산
#include <iostream>
int main() {
int count = 13;
std::cout << (count > 5 ? "big" : "small") << "\n";
}종료 조건이 복잡할 때는 반복 횟수 상한을 따로 두고 넘으면 빠져나오게 해서, 무한 루프가 프로그램 전체를 멈추는 상황을 막을 수 있습니다.
while 조건에서 부동소수 값을 !=로 비교하면 오차 때문에 정확히 같아지는 순간이 오지 않아 루프가 끝나지 않을 수 있습니다.
14break 와 continue
정렬 뒤 첫 원소만 출력하므로 뒤쪽 값들은 화면에 나오지 않습니다. 조건을 만족하는 지점에서 처리를 멈추는 흐름과 성격이 같습니다. 반복문을 직접 쓰는 대신 std::find_if는 첫 일치에서 멈추고 std::any_of는 조건을 만족하는 순간 탐색을 끝내므로, break가 하던 역할을 알고리즘 이름으로 드러낼 수 있습니다.
C++ code
// break 와 continue
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> values = {3, 1, 4, 14};
std::sort(values.begin(), values.end());
std::cout << values.front() << "\n";
}중첩 반복문에서 바깥까지 빠져나오려면 break를 두 번 쓰는 대신 그 부분을 함수로 떼어 return하는 편이 흐름이 훨씬 분명해집니다.
continue는 for에서는 증가식을 실행하지만 while에서는 건너뛰므로, 갱신 문장을 본문 끝에만 두면 그대로 무한 루프가 됩니다.
15배열 생성과 순회
정수 변수를 선언하고 그 값을 조건에 넣어 결과를 출력하는 한 흐름입니다. 원소 하나를 다루는 이 처리를 여러 개로 늘리면 배열이나 벡터 순회가 됩니다. C 스타일 배열은 함수에 넘기는 순간 포인터로 붕괴해 길이 정보를 잃으므로, 크기가 고정이면 std::array를, 가변이면 std::vector를 골라 size()를 유지하는 편이 낫습니다.
C++ code
// 배열 생성과 순회
#include <iostream>
int main() {
int count = 15;
std::cout << (count > 5 ? "big" : "small") << "\n";
}std::array는 크기가 타입에 들어 있어 size()가 컴파일 시점 상수이고, 힙 할당 없이 스택에 그대로 놓입니다.
함수 매개변수에 int arr[10]이라고 적어도 실제로는 포인터로 받으므로, sizeof로 길이를 구하면 원소 수가 아니라 포인터 크기가 나옵니다.
16맵 또는 객체 생성
std::vector<int>는 값을 순서대로 늘어놓는 구조라 위치로만 접근합니다. 이름표를 붙여 값을 찾고 싶으면 다른 컨테이너가 필요합니다. std::map은 키를 정렬해 두고 조회가 로그 시간이며, std::unordered_map은 해시를 써서 평균 상수 시간이지만 순회 순서가 정해지지 않는다는 차이가 있습니다.
C++ code
// 맵 또는 객체 생성
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> values = {3, 1, 4, 16};
std::sort(values.begin(), values.end());
std::cout << values.front() << "\n";
}std::map은 원소를 삽입해도 기존 반복자가 그대로 유효하지만, std::unordered_map은 재해시가 일어나면 반복자가 무효가 된다는 차이가 있습니다.
std::unordered_map에 사용자 정의 키를 쓰려면 해시 함수와 상등 비교를 함께 제공해야 하며, 하나만 만들면 컴파일 단계에서 막힙니다.
17정렬 기초
값과 조건 비교만으로 이루어진 코드로, 정렬이 결국 원소 둘을 비교하는 규칙 위에 세워진다는 점을 보여 줍니다. 비교 기준을 바꾸면 정렬 결과도 바뀝니다. std::sort에 넘기는 비교 함수는 엄격 약순서를 만족해야 하며, <=처럼 같은 값에 참을 돌려주는 비교를 쓰면 범위를 벗어난 접근으로 이어질 수 있습니다.
C++ code
// 정렬 기초
#include <iostream>
int main() {
int count = 17;
std::cout << (count > 5 ? "big" : "small") << "\n";
}여러 필드를 기준으로 줄을 세울 때는 비교자 안에서 std::tie로 두 묶음을 만들어 비교하면 순서 규칙을 실수 없이 적을 수 있습니다.
비교자에서 인자를 const 참조로 받지 않으면 비교 횟수만큼 원소가 복사되어, 큰 객체에서는 정렬 자체보다 그 복사가 더 오래 걸립니다.
18튜플 또는 페어 사용
여기서는 int 하나만 담기지만, 서로 다른 두 값을 한 덩어리로 옮겨야 할 때가 있습니다. 그럴 때 쓰는 것이 std::pair와 std::tuple입니다. C++17의 구조적 바인딩을 쓰면 auto [a, b] = p;처럼 이름을 붙여 꺼낼 수 있어, first와 second가 무엇을 뜻하는지 헷갈릴 일이 줄어듭니다.
C++ code
// 튜플 또는 페어 사용
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> values = {3, 1, 4, 18};
std::sort(values.begin(), values.end());
std::cout << values.front() << "\n";
}구조적 바인딩은 참조로도 받을 수 있어 auto& [key, value]로 맵을 순회하면 원소를 복사하지 않고 값을 바로 고칠 수 있습니다.
필드가 셋 이상이면 std::tuple의 위치 번호가 무엇을 뜻하는지 알 수 없게 되므로, 이름 있는 구조체로 바꾸는 편이 유지보수에 낫습니다.
19타입 변환
벡터의 원소는 모두 int이고, std::cout이 이를 사람이 읽는 문자열로 바꿔 출력합니다. 타입을 옮겨 담는 일은 이렇게 출력 지점에서 조용히 일어나기도 합니다. 반대로 실수를 정수로 바꾸면 반올림이 아니라 소수부를 잘라 버리며, 안전한 문자열 변환에는 C++17의 std::from_chars가 예외 없이 오류 코드를 돌려줍니다.
C++ code
// 타입 변환
#include <iostream>
int main() {
int count = 19;
std::cout << (count > 5 ? "big" : "small") << "\n";
}static_cast는 변환 의도를 컴파일러에게 알려 주므로, C 스타일 캐스트와 달리 const를 벗기는 위험한 변환은 아예 거부합니다.
double을 int로 바꿀 때 원래 값이 int 범위를 벗어나면 단순한 잘림이 아니라 정의되지 않은 동작이므로 범위 확인이 먼저입니다.
20날짜 문자열 만들기
정수 목록을 정렬해 첫 값을 문자열로 출력하는 코드로, 값을 사람이 읽을 형태로 바꾸는 마지막 단계만 담고 있습니다. 시각을 다루는 코드도 결국 같은 자리에서 형식을 정합니다. C++20 전에는 날짜 서식에 <iomanip>의 put_time이 필요했지만, C++20의 <chrono>는 year_month_day와 std::format으로 직접 다룰 수 있게 되었습니다.
C++ code
// 날짜 문자열 만들기
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> values = {3, 1, 4, 20};
std::sort(values.begin(), values.end());
std::cout << values.front() << "\n";
}std::format은 자리표시자와 인자 타입을 컴파일 시점에 맞춰 보므로, 서식 문자열이 어긋나면 실행 전에 오류로 걸러집니다.
std::localtime은 내부 정적 버퍼를 돌려주어 스레드 안전하지 않고 다음 호출이 이전 결과를 덮어쓰므로, 값을 곧바로 복사해 두어야 합니다.
정리하며
- 지역 변수는 선언과 동시에 초기화해 미정의 값으로 인한 간헐적 오동작을 막습니다
- 컨테이너 크기는 부호 없는 타입이므로 int와 비교하거나 빼는 연산에 주의합니다
- 인덱스 순회보다 범위 기반 for를 기본으로 쓰면 경계 오류가 크게 줄어듭니다
- 대입은 기본이 값 복사이므로 큰 객체를 넘길 때는 참조 전달 여부를 의식합니다
더 깊이 들어가고 싶다면 C++ 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.