값이 없다는 사실도 타입이 될 수 있다
프로그램을 만들다 보면 "값이 없다"라는 상황을 자주 만납니다. 사용자를 검색했는데 찾지 못할 수도 있고, 설정 파일에 포트 번호가 없을 수도 있습니다. 문자열을 숫자로 바꾸려 했지만 실패할 수도 있습니다.
C 스타일 코드에서는 이런 상황을 보통 특별한 값으로 표현했습니다. 예를 들어 검색에 실패하면 -1을 반환하거나, 포인터가 없으면 nullptr을 반환하거나, 함수의 반환값과 별도로 bool success 같은 플래그를 함께 넘겼습니다. 이런 방식이 항상 나쁜 것은 아니지만, 시간이 지날수록 코드가 쉽게 헷갈립니다.
int findUserId(const std::string& name)
{
// 실패하면 -1을 반환한다고 약속한다.
return -1;
}이 코드는 겉으로 보기에는 간단합니다. 하지만 곧 질문이 생깁니다. 사용자 ID가 정말로 음수가 될 수 없는 값인가요? -1이라는 약속을 호출하는 쪽에서 반드시 기억하고 있을까요? 나중에 다른 개발자가 이 함수를 보고 실패 가능성을 바로 알아차릴 수 있을까요?
현대 C++은 이런 문제를 "타입"으로 드러내는 방향으로 발전했습니다. 값이 없을 수 있으면 std::optional을 사용합니다. 여러 타입 중 하나일 수 있으면 std::variant를 사용합니다. 여러 값을 임시로 묶어 반환하고 싶으면 std::tuple을 사용할 수 있습니다.
이 장에서 배우는 세 도구는 서로 목적이 다릅니다.
std::optional<T>:T타입의 값이 있거나 없을 수 있음을 표현합니다.std::variant<A, B, C>:A,B,C중 정확히 하나의 값을 담을 수 있음을 표현합니다.std::tuple<A, B, C>: 서로 다른 타입의 여러 값을 하나로 묶습니다.
핵심은 단순합니다. 가능한 상태를 주석이나 기억에 맡기지 말고, 타입이 직접 말하게 만드는 것입니다.

std::optional: 있을 수도 있고 없을 수도 있는 값
std::optional은 C++17에서 표준 라이브러리에 들어온 타입입니다. 사용하려면 <optional> 헤더가 필요합니다.
#include <iostream>
#include <optional>
#include <string>
#include <vector>
std::optional<std::string> findNameById(
const std::vector<std::string>& names,
std::size_t id)
{
if (id < names.size()) {
return names[id];
}
return std::nullopt;
}
int main()
{
std::vector<std::string> names { "Jin", "Sora", "Min" };
auto result = findNameById(names, 1);
if (result) {
std::cout << "찾은 이름: " << *result << std::endl;
} else {
std::cout << "이름을 찾지 못했습니다." << std::endl;
}
}std::optional<std::string>은 "문자열이 있을 수도 있고 없을 수도 있다"라는 뜻입니다. 값이 있으면 std::string처럼 꺼내 쓸 수 있고, 값이 없으면 std::nullopt 상태가 됩니다. 여기서 nullopt는 "선택된 값이 없음"을 나타내는 특별한 표준 값입니다.
이 함수의 좋은 점은 반환 타입만 봐도 실패 가능성을 알 수 있다는 것입니다.
std::optional<std::string> findNameById(...);이 선언은 호출자에게 조용히 말합니다. "이 함수는 이름을 줄 수도 있지만, 못 줄 수도 있어. 그러니 확인하고 써."
optional 값을 안전하게 사용하는 법
std::optional을 사용할 때 가장 중요한 규칙은 값이 있는지 확인한 뒤에 꺼내는 것입니다.
가장 흔한 패턴은 if 문입니다.
std::optional<int> score = 95;
if (score.has_value()) {
std::cout << "점수: " << score.value() << std::endl;
}has_value()는 값이 들어 있는지 확인합니다. 같은 의미로 if (score)처럼 쓸 수도 있습니다.
if (score) {
std::cout << "점수: " << *score << std::endl;
}*score는 optional 안의 값을 꺼냅니다. 포인터를 역참조하는 문법과 비슷하지만, 이것은 포인터가 아닙니다. std::optional<int> 객체 안에 int가 직접 들어 있거나 들어 있지 않은 상태라고 이해하면 됩니다.
값이 없을 때 기본값을 쓰고 싶다면 value_or가 편리합니다.
std::optional<int> port;
int actualPort = port.value_or(8080);
std::cout << "사용할 포트: " << actualPort << std::endl;port에 값이 있으면 그 값을 사용하고, 없으면 8080을 사용합니다. 설정 파일을 읽을 때 자주 쓰는 방식입니다.
반대로 value()는 조심해야 합니다.
std::optional<int> value;
std::cout << value.value() << std::endl; // 위험값이 없는 optional에 대해 value()를 호출하면 예외가 발생합니다. 따라서 value()는 이미 값이 있다는 사실이 분명한 곳에서만 사용하는 편이 좋습니다. 입문 단계에서는 if (opt)로 확인하고 *opt로 꺼내는 패턴을 먼저 익히면 충분합니다.
if (value) {
std::cout << *value << std::endl;
}optional은 nullptr의 대체품인가?
std::optional을 배우면 이런 생각이 들 수 있습니다. "그럼 이제 nullptr 대신 전부 optional을 쓰면 되나?" 답은 아닙니다.
std::optional<T>은 값 자체가 있을 수도 없을 수도 있을 때 어울립니다. 예를 들어 검색 결과, 설정값, 파싱 결과처럼 "성공하면 값이 있고 실패하면 값이 없음"을 표현하기 좋습니다.
std::optional<int> parsePositiveNumber(const std::string& text)
{
if (text.empty()) {
return std::nullopt;
}
int result = 0;
for (char ch : text) {
if (ch < '0' || ch > '9') {
return std::nullopt;
}
result = result * 10 + (ch - '0');
}
if (result <= 0) {
return std::nullopt;
}
return result;
}반면 어떤 객체를 가리키기만 하고 소유하지 않을 때는 포인터가 더 자연스러울 수 있습니다. 특히 "없을 수도 있는 외부 객체를 가리킨다"는 뜻이라면 T* 또는 스마트 포인터가 더 적절할 수 있습니다. 소유권이 있으면 std::unique_ptr이나 std::shared_ptr을 고민해야 하고, 단순히 값의 부재를 표현하려면 std::optional을 고민해야 합니다.
또한 함수 매개변수에 optional을 무조건 쓰는 것도 좋은 습관은 아닙니다.
void printName(std::optional<std::string> name);이 함수는 이름이 없을 수도 있다는 것을 표현합니다. 하지만 대부분의 경우 이름이 반드시 필요하다면 그냥 다음처럼 쓰는 편이 낫습니다.
void printName(const std::string& name);타입은 가능성을 넓히기도 하고 좁히기도 합니다. 필요 없는 가능성을 열어 두면 호출하는 쪽과 구현하는 쪽 모두 더 많은 경우를 생각해야 합니다. 좋은 타입 설계란 "일어날 수 있는 상태는 표현하고, 일어나면 안 되는 상태는 표현하지 못하게 하는 것"입니다.
optional로 검색 실패 표현하기
간단한 학생 검색 예제를 보겠습니다. 예전 방식이라면 찾지 못했을 때 인덱스 -1을 반환할 수 있습니다. 하지만 인덱스 타입으로 std::size_t를 쓰면 음수를 표현할 수 없고, -1 같은 특수 값은 점점 불편해집니다.
std::optional을 사용하면 검색 성공과 실패를 자연스럽게 표현할 수 있습니다.
#include <iostream>
#include <optional>
#include <string>
#include <vector>
struct Student {
int id;
std::string name;
};
std::optional<Student> findStudentById(
const std::vector<Student>& students,
int id)
{
for (const auto& student : students) {
if (student.id == id) {
return student;
}
}
return std::nullopt;
}
int main()
{
std::vector<Student> students {
{ 1, "Jin" },
{ 2, "Sora" },
{ 3, "Min" }
};
auto found = findStudentById(students, 2);
if (found) {
std::cout << found->name << " 학생을 찾았습니다." << std::endl;
} else {
std::cout << "학생을 찾지 못했습니다." << std::endl;
}
}여기서 found->name이라는 문법이 나왔습니다. optional은 값이 있을 때 포인터처럼 ->로 멤버에 접근할 수 있습니다. 단, 이 문법 역시 값이 있다는 확인 뒤에 사용해야 합니다.
이 예제에서는 Student를 복사해서 반환합니다. 학생 객체가 크거나 원본을 직접 수정해야 한다면 다른 설계를 고민해야 합니다. 예를 들어 인덱스를 반환하거나, 포인터를 반환하거나, 검색과 수정 책임을 가진 클래스를 만들 수 있습니다. 중요한 것은 "찾지 못할 수 있다"는 사실을 반환 타입에 분명하게 남기는 것입니다.
std::variant: 여러 타입 중 하나
std::optional은 값이 있거나 없는 경우를 다룹니다. 그런데 실제 프로그램에서는 "A일 수도 있고 B일 수도 있고 C일 수도 있는 값"이 필요할 때가 있습니다.
예를 들어 게임 이벤트를 생각해 봅시다. 이벤트는 플레이어 이동일 수도 있고, 공격일 수도 있고, 대화일 수도 있습니다. 세 이벤트는 서로 다른 데이터를 가집니다.
struct MoveEvent {
int x;
int y;
};
struct AttackEvent {
int targetId;
int damage;
};
struct TalkEvent {
std::string message;
};이 중 하나를 담는 타입을 만들고 싶다면 std::variant를 사용할 수 있습니다.
#include <iostream>
#include <string>
#include <variant>
struct MoveEvent {
int x;
int y;
};
struct AttackEvent {
int targetId;
int damage;
};
struct TalkEvent {
std::string message;
};
using GameEvent = std::variant<MoveEvent, AttackEvent, TalkEvent>;
void handleEvent(const GameEvent& event)
{
if (std::holds_alternative<MoveEvent>(event)) {
const auto& move = std::get<MoveEvent>(event);
std::cout << "이동: " << move.x << ", " << move.y << std::endl;
} else if (std::holds_alternative<AttackEvent>(event)) {
const auto& attack = std::get<AttackEvent>(event);
std::cout << "공격: 대상 " << attack.targetId
<< ", 피해 " << attack.damage << std::endl;
} else if (std::holds_alternative<TalkEvent>(event)) {
const auto& talk = std::get<TalkEvent>(event);
std::cout << "대화: " << talk.message << std::endl;
}
}
int main()
{
GameEvent event = AttackEvent { 42, 10 };
handleEvent(event);
}GameEvent는 MoveEvent, AttackEvent, TalkEvent 중 하나만 담을 수 있습니다. 동시에 두 개를 담을 수는 없습니다. 이 점이 중요합니다. 타입 자체가 가능한 상태를 제한합니다.
예전 방식이라면 이벤트 종류를 나타내는 enum과 여러 데이터를 한 구조체에 한꺼번에 넣었을 수 있습니다.
struct BadEvent {
int type;
int x;
int y;
int targetId;
int damage;
std::string message;
};이 구조체는 모든 이벤트 데이터를 다 가지고 있습니다. 이동 이벤트라면 targetId와 damage는 의미가 없고, 공격 이벤트라면 message는 의미가 없습니다. 의미 없는 필드가 많아질수록 잘못된 조합이 생깁니다.
std::variant는 이런 잘못된 조합을 줄여 줍니다. 이동 이벤트라면 이동 데이터만 있고, 공격 이벤트라면 공격 데이터만 있습니다.
std::visit으로 타입별 처리하기
std::variant를 더 C++답게 처리하는 방법은 std::visit입니다. std::visit은 variant 안에 현재 들어 있는 타입에 맞는 함수를 호출합니다.
#include <iostream>
#include <string>
#include <variant>
struct MoveEvent {
int x;
int y;
};
struct AttackEvent {
int targetId;
int damage;
};
struct TalkEvent {
std::string message;
};
using GameEvent = std::variant<MoveEvent, AttackEvent, TalkEvent>;
void handleEvent(const GameEvent& event)
{
std::visit([](const auto& e) {
using T = std::decay_t<decltype(e)>;
if constexpr (std::is_same_v<T, MoveEvent>) {
std::cout << "이동: " << e.x << ", " << e.y << std::endl;
} else if constexpr (std::is_same_v<T, AttackEvent>) {
std::cout << "공격: 대상 " << e.targetId
<< ", 피해 " << e.damage << std::endl;
} else if constexpr (std::is_same_v<T, TalkEvent>) {
std::cout << "대화: " << e.message << std::endl;
}
}, event);
}여기에는 아직 낯선 문법이 조금 있습니다. auto를 매개변수로 받는 람다는 어떤 타입이 들어오든 받을 수 있는 함수 객체를 만듭니다. if constexpr는 컴파일 시간에 조건을 판단해서 필요한 코드만 남깁니다. std::decay_t<decltype(e)>는 참조와 const 같은 장식을 걷어 내고 실제 타입을 비교하기 위해 사용했습니다.
처음 보면 조금 복잡하지만, 큰 흐름은 단순합니다.
variant 안에 현재 들어 있는 값을 꺼냅니다.
그 값의 실제 타입에 맞는 코드가 선택됩니다.
선택되지 않은 타입의 코드는 컴파일 과정에서 제외됩니다.
실무 코드에서는 다음과 같은 overloaded 도우미를 자주 사용합니다.
template <typename... Ts>
struct Overloaded : Ts... {
using Ts::operator()...;
};
template <typename... Ts>
Overloaded(Ts...) -> Overloaded<Ts...>;이 도우미를 사용하면 타입별 처리를 더 읽기 쉽게 쓸 수 있습니다.
void handleEvent(const GameEvent& event)
{
std::visit(Overloaded {
[](const MoveEvent& e) {
std::cout << "이동: " << e.x << ", " << e.y << std::endl;
},
[](const AttackEvent& e) {
std::cout << "공격: 대상 " << e.targetId
<< ", 피해 " << e.damage << std::endl;
},
[](const TalkEvent& e) {
std::cout << "대화: " << e.message << std::endl;
}
}, event);
}각 람다가 하나의 타입을 담당합니다. 마치 "이 타입이면 이 함수를 호출해 줘"라고 등록해 두는 느낌입니다. std::variant와 std::visit을 함께 쓰면 enum으로 종류를 검사하고 수동으로 필드를 해석하는 코드보다 실수를 줄일 수 있습니다.
get, get_if, holds_alternative
std::variant에서 값을 꺼내는 방법은 여러 가지가 있습니다. 상황에 따라 선택하면 됩니다.
먼저 std::holds_alternative<T>는 현재 variant가 T 타입을 담고 있는지 확인합니다.
if (std::holds_alternative<AttackEvent>(event)) {
const auto& attack = std::get<AttackEvent>(event);
std::cout << attack.damage << std::endl;
}std::get<T>는 해당 타입의 값을 꺼냅니다. 하지만 현재 들어 있는 타입이 T가 아니면 예외가 발생합니다. 그래서 보통 holds_alternative로 확인한 뒤 사용합니다.
더 안전한 방식으로 std::get_if<T>도 있습니다.
if (const auto* attack = std::get_if<AttackEvent>(&event)) {
std::cout << "피해량: " << attack->damage << std::endl;
}get_if는 성공하면 해당 값의 포인터를 반환하고, 실패하면 nullptr을 반환합니다. 여기서 포인터는 소유권을 뜻하지 않습니다. variant 안의 값을 잠깐 가리키는 관찰용 포인터입니다.
일반적인 기준은 다음과 같습니다.
간단히 한두 타입만 확인하면
holds_alternative나get_if가 편합니다.모든 타입을 빠짐없이 처리해야 하면
std::visit이 더 좋습니다.타입별 동작이 많아질수록 별도의 함수나 클래스로 분리하는 편이 좋습니다.
특히 "variant에 가능한 모든 타입을 처리해야 하는가?"라는 질문이 중요합니다. 가능하면 모든 경우를 한곳에서 명시적으로 처리하세요. 상태가 추가되었는데 처리 코드가 빠지는 실수를 줄일 수 있습니다.
optional과 variant의 차이
std::optional과 std::variant는 둘 다 "상태를 타입으로 표현한다"는 점에서 비슷해 보입니다. 하지만 쓰임새는 다릅니다.
std::optional<T>는 T 또는 없음입니다.
std::optional<int> age;이 타입은 "나이가 있을 수도 있고 없을 수도 있다"라고 말합니다.
std::variant<A, B, C>는 A, B, C 중 하나입니다.
std::variant<int, std::string> value;이 타입은 "정수일 수도 있고 문자열일 수도 있다"라고 말합니다.
실패 이유가 중요하지 않고 값의 부재만 중요하면 optional이 적절합니다. 반대로 실패 이유나 여러 성공 형태를 구분해야 한다면 variant가 더 어울립니다.
예를 들어 사용자 입력을 숫자로 바꾸는 함수가 있다고 합시다. 실패하면 그냥 실패라고만 알면 되는 경우에는 optional이면 충분합니다.
std::optional<int> parseNumber(const std::string& text);하지만 실패 이유를 구분하고 싶다면 variant를 사용할 수 있습니다.
struct EmptyInput {};
struct InvalidCharacter {
char ch;
};
struct ParsedNumber {
int value;
};
using ParseResult = std::variant<ParsedNumber, EmptyInput, InvalidCharacter>;이제 결과는 세 가지 중 하나입니다. 숫자 파싱에 성공했거나, 입력이 비었거나, 잘못된 문자가 들어 있습니다. 호출자는 이 세 상태를 구분해서 처리할 수 있습니다.
void printParseResult(const ParseResult& result)
{
std::visit(Overloaded {
[](const ParsedNumber& r) {
std::cout << "숫자: " << r.value << std::endl;
},
[](const EmptyInput&) {
std::cout << "입력이 비어 있습니다." << std::endl;
},
[](const InvalidCharacter& e) {
std::cout << "잘못된 문자: " << e.ch << std::endl;
}
}, result);
}이 방식은 단순한 bool success보다 훨씬 많은 정보를 타입에 담습니다.
std::tuple: 여러 값을 한 번에 묶기
std::tuple은 여러 값을 하나로 묶는 타입입니다. <tuple> 헤더가 필요합니다. 이미 C++에는 std::pair가 있습니다. pair가 두 값을 묶는다면, tuple은 두 개 이상도 묶을 수 있습니다.
#include <iostream>
#include <string>
#include <tuple>
std::tuple<std::string, int, bool> getUserInfo()
{
return { "Jin", 28, true };
}
int main()
{
auto info = getUserInfo();
std::cout << std::get<0>(info) << std::endl;
std::cout << std::get<1>(info) << std::endl;
std::cout << std::get<2>(info) << std::endl;
}std::get<0>은 첫 번째 값, std::get<1>은 두 번째 값, std::get<2>는 세 번째 값을 꺼냅니다. 인덱스는 0부터 시작합니다.
하지만 이 코드는 읽기 좋지 않습니다. 0, 1, 2가 무엇을 뜻하는지 코드를 읽는 사람이 외워야 합니다. 그래서 tuple은 구조적 바인딩과 함께 사용할 때 훨씬 보기 좋아집니다.
int main()
{
auto [name, age, active] = getUserInfo();
std::cout << "이름: " << name << std::endl;
std::cout << "나이: " << age << std::endl;
std::cout << "활성 상태: " << active << std::endl;
}구조적 바인딩을 사용하면 tuple 안의 값에 이름을 붙여 받을 수 있습니다. C++17 이후 코드에서 여러 반환값을 다룰 때 자주 보이는 문법입니다.
tuple과 struct 중 무엇을 써야 할까?
std::tuple은 편리하지만 남용하면 코드가 금방 흐려집니다.
std::tuple<std::string, int, bool> user;이 타입만 보고는 세 값이 무엇을 뜻하는지 알기 어렵습니다. 문자열은 이름일까요, 이메일일까요? 정수는 나이일까요, ID일까요? bool은 활성 상태일까요, 관리자 여부일까요?
이럴 때는 구조체가 더 좋습니다.
struct UserInfo {
std::string name;
int age;
bool active;
};
UserInfo getUserInfo()
{
return { "Jin", 28, true };
}구조체는 각 필드에 이름이 있습니다. 타입 자체에도 UserInfo라는 의미가 있습니다. 시간이 지나도 읽기 쉽고, 필드가 늘어나도 의도가 비교적 잘 유지됩니다.
그렇다면 tuple은 언제 쓰면 좋을까요? tuple은 주로 임시적인 묶음에 어울립니다. 함수 안에서 잠깐 여러 값을 묶어 정렬하거나, 작은 유틸리티 함수에서 짧게 반환하거나, 제네릭 코드에서 타입 목록처럼 사용할 때 좋습니다.
std::tuple<int, int> divide(int a, int b)
{
return { a / b, a % b };
}
int main()
{
auto [quotient, remainder] = divide(17, 5);
std::cout << "몫: " << quotient << std::endl;
std::cout << "나머지: " << remainder << std::endl;
}몫과 나머지를 반환하는 작은 함수에서는 tuple이 간단하고 자연스럽습니다. 하지만 이 값들이 프로그램의 여러 곳에서 중요한 개념으로 쓰인다면 구조체를 만드는 편이 더 좋습니다.
struct DivisionResult {
int quotient;
int remainder;
};기준은 명확합니다. 묶음 자체에 이름을 붙일 가치가 있으면 구조체를 쓰세요. 잠깐 지나가는 값이면 tuple을 고려하세요.
여러 값을 반환하는 함수 설계
C++에서 여러 값을 반환하는 방법은 다양합니다. 포인터나 참조 매개변수로 결과를 채우는 방식도 있고, 구조체를 반환하는 방식도 있고, tuple을 반환하는 방식도 있습니다.
예전 스타일의 코드는 다음처럼 작성되곤 했습니다.
bool parseUser(const std::string& line,
std::string& name,
int& age)
{
// 파싱 성공 여부를 bool로 반환하고,
// 실제 결과는 참조 매개변수에 넣는다.
return false;
}이 방식은 동작은 하지만 호출부가 조금 복잡합니다.
std::string name;
int age = 0;
if (parseUser(line, name, age)) {
std::cout << name << ", " << age << std::endl;
}현대 C++에서는 결과를 하나의 값으로 만들어 반환하는 방식을 먼저 생각하는 편이 좋습니다.
struct UserInfo {
std::string name;
int age;
};
std::optional<UserInfo> parseUser(const std::string& line)
{
// 실패하면 std::nullopt
// 성공하면 UserInfo 반환
return UserInfo { "Jin", 28 };
}이 함수의 반환 타입은 많은 정보를 담고 있습니다. 성공하면 UserInfo가 있고, 실패하면 값이 없습니다. 호출부도 자연스럽습니다.
auto user = parseUser(line);
if (user) {
std::cout << user->name << ", " << user->age << std::endl;
}여기서 optional과 구조체가 함께 쓰였습니다. 구조체는 성공 결과의 의미를 표현하고, optional은 성공 또는 실패 상태를 표현합니다. 이런 조합이 현대 C++에서 매우 흔합니다.
타입으로 잘못된 상태를 막기
이 장의 핵심은 optional, variant, tuple이라는 세 이름을 외우는 것이 아닙니다. 더 중요한 것은 "타입으로 상태를 설계한다"는 사고방식입니다.
나쁜 설계를 하나 보겠습니다.
struct LoginResult {
bool success;
std::string userName;
std::string errorMessage;
};겉보기에는 괜찮아 보입니다. 하지만 가능한 상태를 생각해 보면 애매합니다. success가 true인데 errorMessage가 들어 있으면 어떻게 해야 할까요? success가 false인데 userName이 비어 있지 않으면 잘못된 상태일까요? 이런 구조는 "성공 정보"와 "실패 정보"가 동시에 존재할 수 있습니다.
타입을 조금 더 분명하게 만들 수 있습니다.
struct LoginSuccess {
std::string userName;
};
struct LoginFailure {
std::string message;
};
using LoginResult = std::variant<LoginSuccess, LoginFailure>;이제 로그인 결과는 성공 또는 실패 중 하나입니다. 동시에 둘 다일 수 없습니다. 성공이면 사용자 이름이 있고, 실패이면 오류 메시지가 있습니다. 타입이 가능한 상태를 제한합니다.
void printLoginResult(const LoginResult& result)
{
std::visit(Overloaded {
[](const LoginSuccess& success) {
std::cout << "환영합니다, " << success.userName << "님!" << std::endl;
},
[](const LoginFailure& failure) {
std::cout << "로그인 실패: " << failure.message << std::endl;
}
}, result);
}이런 설계는 런타임 오류를 줄입니다. 런타임 오류란 프로그램이 실행되는 중에 드러나는 오류입니다. 반대로 타입으로 상태를 잘 표현하면 많은 실수를 컴파일 단계에서 발견할 수 있습니다. 컴파일러는 피곤함을 모릅니다. 가능한 한 컴파일러가 더 많이 확인하게 만드는 것이 좋은 C++ 코드의 방향입니다.
작은 예제: 설정값 읽기
간단한 설정 시스템을 생각해 봅시다. 설정에는 창 너비, 창 높이, 사용자 이름이 있을 수 있습니다. 어떤 설정은 없을 수도 있으므로 std::optional이 어울립니다.
#include <iostream>
#include <optional>
#include <string>
struct AppConfig {
std::optional<int> width;
std::optional<int> height;
std::optional<std::string> userName;
};
void printConfig(const AppConfig& config)
{
int width = config.width.value_or(1280);
int height = config.height.value_or(720);
std::string userName = config.userName.value_or("guest");
std::cout << "창 크기: " << width << " x " << height << std::endl;
std::cout << "사용자: " << userName << std::endl;
}
int main()
{
AppConfig config;
config.width = 1920;
config.userName = "Jin";
printConfig(config);
}height는 설정하지 않았습니다. 하지만 value_or(720) 덕분에 기본값을 자연스럽게 적용합니다. 값이 없을 수 있는 필드가 타입으로 드러나므로, 코드를 읽는 사람도 "이 설정은 없을 수 있구나"라고 바로 알 수 있습니다.
이번에는 설정값 하나가 여러 타입 중 하나일 수 있다고 해 봅시다. 어떤 설정은 정수이고, 어떤 설정은 문자열이고, 어떤 설정은 참/거짓입니다. 이럴 때는 std::variant가 어울립니다.
#include <iostream>
#include <string>
#include <variant>
#include <vector>
using ConfigValue = std::variant<int, bool, std::string>;
struct ConfigEntry {
std::string key;
ConfigValue value;
};
void printValue(const ConfigValue& value)
{
std::visit(Overloaded {
[](int v) {
std::cout << v;
},
[](bool v) {
std::cout << (v ? "true" : "false");
},
[](const std::string& v) {
std::cout << v;
}
}, value);
}
int main()
{
std::vector<ConfigEntry> entries {
{ "width", 1280 },
{ "fullscreen", false },
{ "user", std::string { "Jin" } }
};
for (const auto& entry : entries) {
std::cout << entry.key << " = ";
printValue(entry.value);
std::cout << std::endl;
}
}이 예제에서 ConfigValue는 정수, bool, 문자열 중 하나입니다. 설정값이 가능한 타입이 명확히 제한되어 있습니다. 아무 타입이나 들어갈 수 있는 것이 아니라, 우리가 허용한 타입만 들어갈 수 있습니다.
세 타입을 선택하는 기준
optional, variant, tuple은 모두 유용하지만, 아무 곳에나 쓰면 오히려 코드가 복잡해집니다. 선택 기준을 정리해 봅시다.
값이 없을 수 있다면 std::optional<T>를 먼저 생각하세요. 검색 실패, 선택되지 않은 값, 설정 파일에 없는 항목처럼 "없음"이 자연스러운 상황에 잘 맞습니다.
여러 형태 중 하나라면 std::variant<A, B, C>를 생각하세요. 이벤트, 명령, 결과 상태, 설정값처럼 가능한 타입이 정해져 있고 그중 하나만 유효해야 할 때 어울립니다.
여러 값을 잠깐 묶어야 한다면 std::tuple<A, B, C>를 사용할 수 있습니다. 다만 묶음에 의미 있는 이름이 필요하거나 오래 유지될 데이터라면 구조체를 만드는 편이 더 좋습니다.
이 셋은 포인터, 플래그, 특수 숫자를 완전히 없애는 마법 도구가 아닙니다. 하지만 많은 경우 더 안전하고 읽기 좋은 선택지를 제공합니다.
현대 C++의 방향
C++은 성능을 중요하게 생각하는 언어입니다. 그래서 초보자는 C++을 배울 때 종종 "어떻게 하면 더 빠르게 만들까?"에만 집중합니다. 하지만 현대 C++에서 성능만큼 중요한 것이 있습니다. 바로 잘못된 상태를 만들기 어렵게 설계하는 것입니다.
std::optional은 값의 부재를 숨기지 않습니다. std::variant는 가능한 선택지를 타입 목록으로 드러냅니다. std::tuple은 여러 값을 하나의 반환값으로 다룰 수 있게 해 줍니다. 여기에 구조체, enum class, 스마트 포인터, RAII까지 함께 사용하면 코드의 의도가 점점 더 타입에 새겨집니다.
좋은 C++ 코드는 주석이 없어도 많은 것을 말합니다.
std::optional<User> findUser(int id);이 선언은 사용자를 못 찾을 수 있다고 말합니다.
using Command = std::variant<AddTask, RemoveTask, Quit>;이 선언은 명령이 세 종류 중 하나라고 말합니다.
struct WindowSize {
int width;
int height;
};이 선언은 두 정수가 단순한 숫자가 아니라 창 크기라고 말합니다.
타입은 컴파일러와 개발자가 함께 읽는 문장입니다. 현대 C++을 잘 쓴다는 것은 더 복잡한 문법을 많이 아는 것이 아니라, 프로그램의 가능한 상태를 더 정확한 타입으로 표현할 줄 아는 것입니다.
다음에 코드를 작성하다가 nullptr, -1, bool success, 의미 없는 빈 문자열을 실패 표시로 쓰고 싶어지는 순간이 오면 잠깐 멈춰 보세요. 이 상태를 타입으로 표현할 수 있을까요? 그 질문 하나가 코드를 훨씬 안전하고 읽기 좋게 바꿉니다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.
