CodeGym /행동 /C++ SELF /std::optional<T>: “값 있음/없음” — 마법의 -1 대신

std::optional<T>: “값 있음/없음” — 마법의 -1 대신

C++ SELF
레벨 20, 레슨 2
사용 가능

1. “마법 값” 문제

처음 프로그램을 작성할 때는 모든 것이 “간단하게” 보이길 바랍니다: 요소를 찾으면 인덱스를 반환하고, 못 찾으면 -1을 반환하는 식이죠. 몇 줄을 아끼는 작은 트릭처럼 보입니다. 하지만 이 트릭에는 부작용이 있습니다: 암묵적 약속의 세계에 살게 되고, 언젠가는 그 약속을 스스로 잊어버리게 됩니다.

예를 들어 학습용 미니 프로젝트로 콘솔 기반 할 일 목록(가칭 TaskList)을 만든다고 합시다. 우리는 std::vector<Task> tasks;를 가지고 있고, id로 작업을 찾고 싶습니다. 흔히 처음 작성하는 코드는 다음과 같습니다.

#include <vector>
#include <cstddef>

int find_index_by_id_old(const std::vector<int>& ids, int id) {
    for (std::size_t i = 0; i < ids.size(); ++i) {
        if (ids[i] == id) return static_cast<int>(i);
    }
    return -1;
}

코드는 동작하지만, 인덱스를 위해 int로 “바꿔야” 합니다. 보통 vector의 인덱스는 std::size_t이니까요. 그리고 코드 전반에 걸쳐 규칙이 떠돌게 됩니다: “-”-1이 반환되면 찾지 못한 것이다. 만약 그걸 체크하는 것을 잊고 인덱스로 사용하려 들면, 암묵적 형 변환으로 인해 '마이너스 하나가 갑자기 아주 큰 수'가 되는 현상이 발생합니다.

실제 흔한 버그 예시는 다음과 같습니다:

#include <iostream>
#include <vector>

int main() {
    std::vector<int> ids{10, 20, 30};
    int idx = -1; // 예: "찾지 못함"
    std::cout << ids[idx] << '\n'; // UB / crash / "works on my machine"
}

즉 문제는 -1 자체가 "나쁘다"는 것이 아닙니다. 문제는 -1이 정상 결과와 같은 타입의 값이라는 점이며, 컴파일러는 "찾았다/못 찾았다"를 구분해 줄 수 없다는 것입니다.

2. std::optional<T>의 아이디어: "없을 수도 있다"를 솔직하게 말하는 타입

친구에게 "거스름돈 있어?"라고 물었을 때 답은 두 가지입니다: "있다"(그리고 거스름돈을 건네줌) 또는 "없다". 이 둘은 다른 상태이며 "거스름돈 = -1 유로"처럼 표현되지 않습니다. std::optional<T>은 코드에서 같은 정직함을 제공합니다.

std::optional<T>는 타입 T의 0개 또는 1개의 값을 담는 컨테이너입니다.

두 가지 상태를 가질 수 있습니다:

  • 내부에 타입 T의 값이 있다(즉 optional이 비어있지 않음);
  • 값이 없다(즉 optional이 비어있음), 이 상태는 std::nullopt로 표시됩니다.

중요한 생각: 결과의 부재가 타입의 일부가 된다는 점입니다. 더 이상 숨겨진 약속이 아닙니다.

최소한의 "있음/없음" 예:

#include <iostream>
#include <optional>

int main() {
    std::optional<int> a = 10;
    std::optional<int> b = std::nullopt;

    std::cout << static_cast<bool>(a) << '\n'; // 1
    std::cout << static_cast<bool>(b) << '\n'; // 0
}

간단히 말하면: optional<int>는 "어쩌면 int"입니다.

표준 이야기 중 하나: std::optional<T>은 배열 타입 T[]에 대해 만들 수 없습니다 — 예를 들어 optional<int[10]>는 허용되지 않습니다. 학습용 과제에서는 거의 문제되지 않지만, 어떤 타입을 담을 수 있는지에 제한이 있다는 점은 알아두세요.

3. 함수에서 optional 반환하기: "찾음 → 반환, 못 찾음 → nullopt"

프로젝트에 optional을 도입하기 전에 올바른 어조를 잡는 것이 중요합니다: optional은 보통 이전에 "특별한 값"을 반환하던 곳, 즉 "때때로 결과가 없다"는 상황에서 사용됩니다.

vector 검색에서 이 패턴은 고전적입니다. 이제 제대로 해봅시다: 인덱스는 존재하거나 존재하지 않습니다.

#include <cstddef>
#include <optional>
#include <vector>

std::optional<std::size_t> find_index_by_id(const std::vector<int>& ids, int id) {
    for (std::size_t i = 0; i < ids.size(); ++i) {
        if (ids[i] == id) return i;
    }
    return std::nullopt;
}

두 가지 세부사항에 주목하세요.

첫째, 반환 타입은 정확히 std::optional<std::size_t>입니다. 인덱스는 이제 본래 타입을 유지하므로 -1을 억지로 int에 끼워 넣을 필요가 없습니다.

둘째, "못 찾음" 분기에서는 std::nullopt를 반환합니다. 이건 숫자도, "나쁜 인덱스"도, 0도 아니고, 특별한 상태로서 "값 없음"을 나타냅니다.

코드를 문장처럼 읽는다면 이 함수는 말 그대로 이렇게 읽힙니다:
"가능한 인덱스를 반환하라; 찾으면 반환하고, 아니면 부재를 반환하라."

4. optional 사용법: 먼저 검사, 그다음 접근

이제 가장 중요한 부분입니다: optional이 도와주지만 자동으로 모든 문제를 해결해 주지는 않습니다. 프로그램을 완벽하게 무적 상태로 만들어 주진 않아요. 다만 결과 부재를 명시적으로 처리하도록 강제할 뿐입니다.

초보자에게 가장 안전한 스타일은: 먼저 "있나?" 물어보고, 그다음 꺼내라입니다.

보통 다음과 같이 보입니다:

#include <iostream>
#include <optional>
#include <vector>

std::optional<std::size_t> find_index_by_id(const std::vector<int>& ids, int id);

int main() {
    std::vector<int> ids{10, 20, 30};

    auto idx = find_index_by_id(ids, 20);
    if (idx) {
        std::cout << "found at " << *idx << '\n'; // found at 1
    } else {
        std::cout << "not found\n";
    }
}

여기서 *idx는 optional에서 값을 "꺼내라"는 의미입니다. 단, idx가 비어있지 않을 때만 그렇게 할 수 있습니다.

검사 방법 두 가지:

  • if (idx) — 가장 짧고 인기 있는 방법;
  • if (idx.has_value()) — 동일하지만 더 서술적으로 읽힙니다.

연습용으로 이렇게 쓸 수 있습니다:

if (idx.has_value()) {
    std::cout << *idx << '\n';
}

이것이 중요한 이유는: optional이 규율을 강제하기 때문입니다. 확인을 잊으면 코드가 한 달 뒤에 읽는 사람(자신 포함)에게 의심을 불러일으킵니다. -1 방식은 의심을 숨기고 있기 때문에 문제를 발견하기 어렵습니다.

5. TaskList에 optional 적용하기: id로 작업 찾기

이제 프로젝트(할 일 목록)로 돌아가 봅시다. 모델은 다음과 같습니다:

#include <string>

struct Task {
    int id = 0;
    std::string title;
    bool done = false;
};

작업은 이렇게 보관합니다:

#include <vector>

std::vector<Task> tasks;

그리고 id로 작업을 완료 표시할 수 있기를 바랍니다. 먼저 작업의 인덱스를 찾아야 합니다.

함수를 만들어 봅시다:

#include <cstddef>
#include <optional>
#include <vector>

std::optional<std::size_t> find_task_index_by_id(const std::vector<Task>& tasks, int id) {
    for (std::size_t i = 0; i < tasks.size(); ++i) {
        if (tasks[i].id == id) return i;
    }
    return std::nullopt;
}

이제 비즈니스 로직에서의 사용이 매우 읽기 쉽고, 무엇보다 안전합니다:

#include <iostream>
#include <vector>

void mark_done(std::vector<Task>& tasks, int id) {
    auto idx = find_task_index_by_id(tasks, id);
    if (!idx) {
        std::cout << "No task with id=" << id << '\n';
        return;
    }
    tasks[*idx].done = true;
}

초기 return이 얼마나 깔끔하게 동작하는지 보세요: 찾지 못하면 메시지를 출력하고 빠져나와 불필요한 중첩 if을 만들지 않습니다.

"-1 반환" 접근법과 비교하면 아주 많은 종류의 버그가 사라집니다: 더 이상 실수로 -1을 인덱스로 사용할 수 없기 때문입니다. 이제는 "인덱스 있음/없음"만 존재합니다.

6. optional 대 bool + out-매개변수: 초보자에게 무엇이 더 나은가

optional이 도입되기 전에는 많은 함수가 "bool을 반환해 성공/실패를 알리고, 결과는 레퍼런스(out 파라미터)에 담는다" 방식으로 작성되었습니다. 이 스타일은 동작하며 오래된 C++ 코드에서 자주 볼 수 있습니다. 하지만 초보자에게는 "결과가 어디 있지? 왜 반환값에 없지?"라는 의문을 불러일으켜 셜록 홈즈식 트릭처럼 보일 수 있습니다.

예: 오래된 스타일:

#include <cstddef>
#include <vector>

bool find_task_index_by_id_old(const std::vector<Task>& tasks, int id, std::size_t& outIndex) {
    for (std::size_t i = 0; i < tasks.size(); ++i) {
        if (tasks[i].id == id) { outIndex = i; return true; }
    }
    return false;
}

같은 의미를 optional로 표현하면:

#include <cstddef>
#include <optional>
#include <vector>

std::optional<std::size_t> find_task_index_by_id(const std::vector<Task>& tasks, int id) {
    for (std::size_t i = 0; i < tasks.size(); ++i) {
        if (tasks[i].id == id) return i;
    }
    return std::nullopt;
}

왜 optional이 지금 같은 레벨의 학습자에게 더 쉬운가요?

optional을 쓰면 "결과"가 독자가 기대하는 장소, 즉 return에 존재합니다. 반면 bool 방식은 여러 값을 반환해야 하는 경우나 해당 API에서 관례적으로 사용될 때 유용합니다. 하지만 "인덱스는 있거나 없다"라는 문제에는 optional이 직관적으로 표현해 줍니다.

간단 비교 표:

접근 방식 반환형 "못 찾음" 인코딩 일반적 위험
“마법 값”
int
-1
체크를 잊어 이상한 인덱스를 얻음
bool + out
bool
false
bool 검사를 잊고 out에 쓰레기가 들어있을 수 있음
optional
optional<size_t>
nullopt
*opt를 검사 없이 역참조함

즉 위험은 어디에나 있지만, optional의 위험은 보통 코드에서 가장 명확하게 드러나며 눈으로 쉽게 잡아낼 수 있습니다: "정말로 비어있지 않은지 확인했나?"

7. 파싱에 대한 optional 사용: "숫자 성공/실패"를 마법 없이 표현

입력, 문자열, 문자열→숫자 변환(예: stoi)을 이미 다뤘습니다. 변환이 항상 성공하는 것은 아니며, 문자열이 숫자가 아닌 경우도 정상적인 흐름일 수 있습니다. 이는 치명적 오류가 아니라 단순한 분기입니다.

그래서 파서를 만들어 optional<int>을 반환하면 편리합니다: 숫자가 성공하면 값이 있고, 아니면 값이 없습니다.

부호(+, -), 공백, 오버플로우를 제외한 간단한 비음수 정수 파서를 만들어 보겠습니다 — 학습용 버전임을 솔직히 밝힙니다.

#include <optional>
#include <string>

std::optional<int> parse_non_negative_int(const std::string& s) {
    if (s.empty()) return std::nullopt;

    int value = 0;
    for (char c : s) {
        if (c < '0' || c > '9') return std::nullopt;
        value = value * 10 + (c - '0');
    }
    return value;
}

이제 사용은 사람에게 친근하게 보입니다:

#include <iostream>
#include <string>

int main() {
    std::string text;
    std::getline(std::cin, text);

    auto n = parse_non_negative_int(text);
    if (!n) {
        std::cout << "Not a number\n";
        return 0;
    }
    std::cout << "n=" << *n << '\n';
}

optional의 장점은 무엇인가요?

"숫자가 아니다"는 것이 "-1을 반환하지만 그 값이 허용될 수도 있다" 같은 혼란을 만들지 않는다는 점입니다. 단순히 "결과가 없음"일 뿐입니다. 타입이 우리에게 그 사실을 생각하게 합니다.

8. optional의 유용한 뉘앙스

value_or: 기본값이 의미일 때와 임시방편일 때

때로는 프로그램 논리상 "값이 없으면 기본값을 쓴다"가 자연스러울 수 있습니다. 예를 들어, 사용자가 표시할 작업 수 제한을 입력하지 않으면 기본값으로 10을 보여줄 수 있습니다.

이럴 때 optional에는 value_or(default)라는 편리한 메서드가 있습니다 — "값이 있으면 그 값을, 없으면 default를 반환"합니다.

중요한 점: value_or는 "성공적으로 찾음"을 의미하지 않습니다. 대신 "내가 기본값으로 동의한다"는 의미입니다.

예:

#include <iostream>
#include <optional>

int main() {
    std::optional<int> maybeLimit = std::nullopt;

    int limit = maybeLimit.value_or(10);
    std::cout << "limit=" << limit << '\n'; // limit=10
}

TaskList에서 예를 들면, list 명령의 경우 사용자가 "몇 개 보여줄지"를 입력하지 않았다면 기본값으로 돌아가는 것이 적절할 수 있습니다. 반면 삭제를 위한 id라면 기본값은 위험합니다: "기본값으로 삭제"는 공포 영화의 시작처럼 들립니다.

따라서 value_or는 강력하지만, 기본값이 논리적으로 안전한 상황에서만 사용해야 합니다.

optional이 필요 없는 경우(오히려 해로울 때)

optional을 시도해 보면 모든 것을 optional로 만들고 싶어질 수 있습니다: "나이도 optional, 이름도 optional, 삶 전체가 optional". 실제로 이렇게 하면 코드가 검사들의 수렁으로 빠집니다.

결과가 항상 존재한다면 optional은 불필요합니다. 예를 들어 함수 int add(int a, int b)는 optional<int>를 반환할 필요가 없습니다 — 반환이 항상 있으니까요.

오류의 원인이 중요하다면, optional은 충분하지 않을 수 있습니다: optional은 단지 "있음/없음"만 알릴 뿐입니다. 오늘은 기본 규율을 배우는 중이므로 괜찮지만, "없음의 이유"가 필요할 때는 다른 설계가 필요합니다.

또한 optional은 std::vector나 string의 대체물이 아니며, 세심함의 대체품도 아닙니다. 단지 계약을 더 정직하게 만드는 편리한 도구일 뿐입니다.

9. std::optional 작업 시 흔한 실수

실수 1: 검사 없이 역참조하기 (*opt를 빈 optional에서 사용).
초보자가 가장 자주 하는 실수입니다: "확실히 값이 있을 거야"라고 가정하는 것. 확신은 좋지만 프로그램은 그 확신을 공유하지 않습니다. 이 단계에서의 올바른 스타일은 먼저 if (opt)로 확인한 다음 그 블록 안에서만 *opt를 사용하는 것입니다. 만약 그 분기가 불가능하다고 확신한다면, 검색/파싱 로직을 재검증하는 것이 낫습니다.

실수 2: optional 내부에 "마법 값"을 반환하기.
가끔 함수가 optional<int>를 반환하지만 오류 시 -1을 반환하는 혼합 형태를 볼 수 있습니다. 이것은 optional의 의미를 무너뜨립니다: 부재는 std::nullopt로 표현되어야 하고, 박스 안의 특수한 숫자가 되어서는 안 됩니다.

실수 3: "값 없음"과 "기본값"을 혼동하기.
std::nullopt는 "값 없음"을 의미합니다. 반면 value_or(0)는 "없으면 0을 대체값으로 사용"이라는 의미입니다. 만약 이후에 0을 "실패"의 신호로 해석하기 시작하면, 당신은 다시 마법 값의 세계로 돌아가는 셈입니다 — 다만 이제는 기본값으로 위장한 상태입니다.

실수 4: 결과의 복사를 피하려고 optional에 레퍼런스를 넣으려는 시도.
초보자들은 종종 "복사하고 싶지 않다"며 optional<T&> 같은 것을 원합니다. 표준 std::optional은 이렇게 직접 사용할 수 없습니다(레퍼런스는 특별한 취급이 필요). 이 레벨에서는 optional<size_t>처럼 인덱스를 반환하고, 그 인덱스로 vector에서 작업하는 것이 더 간단하고 올바른 접근입니다.

실수 5: 결과가 항상 존재하는 곳에 optional을 남발해 끝없는 검사로 코드가 가득 차는 경우.
함수의 결과가 보장되어 있거나 입력이 위에서 이미 검증되었다면 optional은 불필요한 잡음을 추가할 뿐입니다. optional은 약이지만, 매일 아침 이유 없이 복용하면 가독성을 해칠 수 있습니다.

1
과제
C++ SELF, 레벨 20, 레슨 2
잠금
마이너스 없이 검색
마이너스 없이 검색
1
과제
C++ SELF, 레벨 20, 레슨 2
잠금
마이너스 없는 쿠폰
마이너스 없는 쿠폰
1
과제
C++ SELF, 레벨 20, 레슨 2
잠금
리미트 미리보기
리미트 미리보기
1
과제
C++ SELF, 레벨 20, 레슨 2
잠금
가격 업데이트
가격 업데이트
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION