본문으로 건너뛰기 C++ Return Statement | '반환문' 가이드

C++ Return Statement | '반환문' 가이드

C++ Return Statement | '반환문' 가이드

이 글의 핵심

C++ Return Statement: "반환문" 가이드. return 기본·반환 타입.

들어가며

return문은 함수 실행을 종료하고 값을 호출자에게 반환합니다. C++에서는 RVO(Return Value Optimization)를 통해 반환 시 불필요한 복사를 제거하여 효율적인 코드를 작성할 수 있습니다.


1. return 기본

return문은 함수의 실행 흐름을 그 자리에서 종료시키고 호출자에게 값을 돌려주는 C++의 기본 제어 구조입니다. 단순히 값을 전달하는 것처럼 보이지만 실제로는 함수 스택 프레임 해제, 지역 객체의 소멸자 호출, 반환값 초기화까지 한 번에 처리되는 지점이라서 정확히 이해하지 못하면 댕글링 레퍼런스나 불필요한 복사 같은 문제로 이어지기 쉽습니다. 아래 예제로 값 반환과 void 반환이라는 두 가지 기본 패턴부터 살펴보겠습니다.

값 반환

함수가 계산한 결과를 호출자에게 넘겨줄 때 가장 흔히 쓰는 방식이 값 반환입니다. add 함수처럼 정수를 그대로 반환하는 경우도 있고, divide 함수처럼 정수 나눗셈의 정밀도 손실을 피하기 위해 static_cast로 타입을 변환한 뒤 반환하는 경우도 있습니다. 값 반환은 호출자가 반환값을 독립적으로 소유하게 되므로, 함수 내부의 지역 변수가 이미 소멸된 뒤에도 안전하게 사용할 수 있다는 점이 핵심입니다.

#include <iostream>

int add(int a, int b) {
    return a + b;  // 값 반환
}

double divide(int a, int b) {
    return static_cast<double>(a) / b;
}

int main() {
    int sum = add(10, 20);
    double result = divide(10, 3);
    
    std::cout << "합: " << sum << std::endl;        // 30
    std::cout << "나눗셈: " << result << std::endl; // 3.33333
    
    return 0;
}

void 반환

값을 반환할 필요가 없는 함수에서는 void를 반환 타입으로 선언하고, return;을 값 없이 사용하거나 아예 생략할 수 있습니다. 실무에서 더 중요한 쓰임새는 processData 예제처럼 조건을 검사해 잘못된 입력이면 함수를 즉시 종료시키는 “조기 반환(early return)” 패턴입니다. 이 패턴을 활용하면 중첩된 if-else 블록 없이도 예외 상황을 함수 초반에 걸러낼 수 있어 코드의 들여쓰기 깊이가 줄고 가독성이 크게 좋아집니다.

#include <iostream>

void printMessage(const std::string& msg) {
    std::cout << msg << std::endl;
    return;  // 생략 가능
}

void processData(int value) {
    if (value < 0) {
        std::cout << "음수는 처리할 수 없습니다" << std::endl;
        return;  // 조기 종료
    }
    
    std::cout << "처리: " << value << std::endl;
}

int main() {
    printMessage("Hello");
    processData(-5);
    processData(10);
    
    return 0;
}

2. 반환 타입

같은 값이라도 어떤 타입으로 반환하느냐에 따라 안전성과 성능이 크게 달라집니다. 이번 절에서는 값, 레퍼런스, 포인터 세 가지 반환 방식을 각각 언제 사용해야 하고 어떤 함정을 조심해야 하는지 코드로 비교합니다.

값 반환

기본 타입뿐 아니라 std::string이나 std::vector 같은 객체도 값으로 반환하는 것이 현대 C++의 권장 방식입니다. “객체를 값으로 반환하면 복사 비용이 크지 않을까”라는 걱정은 뒤에서 다룰 RVO(Return Value Optimization) 덕분에 대부분의 경우 기우로 끝납니다. 컴파일러가 반환값을 호출자의 메모리 공간에 직접 생성해주기 때문에, 아래처럼 리터럴이나 초기화 리스트를 즉석에서 반환해도 실제 복사가 일어나지 않는 경우가 많습니다.

#include <string>
#include <vector>

// 기본 타입
int getValue() {
    return 42;
}

// 객체 반환 (RVO)
std::string getName() {
    return "Alice";
}

// 컨테이너 반환 (RVO)
std::vector<int> getNumbers() {
    return {1, 2, 3, 4, 5};
}

레퍼런스 반환

레퍼런스 반환은 복사를 완전히 피하면서 호출자가 함수 내부의 데이터를 직접 읽거나 수정할 수 있게 해줍니다. 다만 이 방식이 안전하려면 반환하는 대상의 수명이 함수 호출이 끝난 뒤에도 유효해야 합니다. 아래 예제에서 operator[]가 반환하는 data[index]Array 객체가 살아있는 한 유효한 멤버 변수이고, getGlobalCounter가 반환하는 counterstatic이라 프로그램이 종료될 때까지 유지되므로 두 경우 모두 안전하게 레퍼런스를 반환할 수 있습니다.

#include <iostream>

class Array {
    int data[10] = {0};
    
public:
    // ✅ 멤버 변수 레퍼런스 반환
    int& operator {
        return data[index];
    }
    
    // ✅ const 레퍼런스 반환
    const int& at(size_t index) const {
        return data[index];
    }
};

// ✅ static 변수 레퍼런스 반환
int& getGlobalCounter() {
    static int counter = 0;
    return counter;
}

int main() {
    Array arr;
    arr[0] = 42;  // 레퍼런스로 수정
    
    int& counter = getGlobalCounter();
    counter++;
    
    std::cout << arr[0] << ", " << counter << std::endl;
    
    return 0;
}

포인터 반환

포인터 반환은 레퍼런스보다 자유도가 높지만 그만큼 수명 관리 책임이 호출자에게 넘어간다는 부담이 있습니다. createInt처럼 new로 동적 할당한 메모리를 반환하면 호출자가 반드시 delete를 호출해야 메모리 누수를 막을 수 있고, getData처럼 멤버 배열의 주소를 반환하면 그 포인터는 객체가 살아있는 동안만 유효합니다. 반대로 getBad처럼 지역 변수 x의 주소를 반환하면 함수가 끝나는 즉시 x는 소멸되므로 반환된 포인터는 댕글링 포인터가 되어 참조 시 정의되지 않은 동작을 일으킵니다. 실무에서는 가능하면 원시 포인터 대신 std::unique_ptr이나 std::shared_ptr을 반환해 소유권과 수명을 명확히 하는 편이 안전합니다.

#include <iostream>

// ✅ 동적 할당 반환
int* createInt(int value) {
    return new int(value);  // 호출자가 delete 책임
}

// ✅ 멤버 포인터 반환
class Container {
    int data[10];
    
public:
    int* getData() {
        return data;
    }
};

// ❌ 지역 변수 포인터 반환
int* getBad() {
    int x = 10;
    return &x;  // 댕글링 포인터!
}

int main() {
    int* ptr = createInt(42);
    std::cout << *ptr << std::endl;
    delete ptr;  // 수동 삭제 필요
    
    return 0;
}

3. RVO (Return Value Optimization)

RVO는 함수가 반환하는 임시 객체를 호출자의 변수 공간에 곧바로 생성해, 복사나 이동 생성자 호출 자체를 생략해버리는 컴파일러 최적화입니다. C++17부터는 특정 조건(순수 임시 객체를 그대로 반환하는 경우)에서 RVO가 언어 표준으로 보장되어, 컴파일러 구현이나 최적화 옵션과 무관하게 항상 적용됩니다. 아래 예제로 RVO가 실제로 복사/이동 생성자 호출을 얼마나 줄여주는지 직접 확인해 보겠습니다.

RVO 기본

Widget 클래스는 생성·복사·이동 시점마다 로그를 출력하도록 만들어져 있어, RVO가 적용되는지 여부를 눈으로 확인할 수 있습니다. createWidget 함수는 임시 객체 Widget()을 그 자리에서 바로 반환하는데, 이런 형태를 컴파일러가 쉽게 최적화할 수 있는 “순수 우측값 반환”이라고 부릅니다. main에서 Widget w = createWidget();을 실행해도 “Widget 생성” 로그만 한 번 출력되고 “Widget 복사”나 “Widget 이동” 로그는 전혀 나타나지 않는데, 이는 컴파일러가 w의 메모리 공간에 Widget을 직접 생성했기 때문입니다.

#include <iostream>
#include <string>

class Widget {
public:
    Widget() {
        std::cout << "Widget 생성" << std::endl;
    }
    
    Widget(const Widget&) {
        std::cout << "Widget 복사" << std::endl;
    }
    
    Widget(Widget&&) noexcept {
        std::cout << "Widget 이동" << std::endl;
    }
};

// RVO: 복사/이동 생략
Widget createWidget() {
    return Widget();  // 직접 생성
}

int main() {
    Widget w = createWidget();
    // 출력: "Widget 생성" (복사/이동 없음)
    
    return 0;
}

NRVO (Named RVO)

앞서 본 RVO는 이름 없는 임시 객체를 반환하는 경우였다면, NRVO는 createString 함수의 s처럼 이름이 붙은 지역 변수를 반환하는 경우에 적용되는 최적화입니다. NRVO는 C++17에서도 표준으로 보장되지 않고 “가능하면 적용해도 좋다(permitted)“는 수준의 최적화이지만, GCC·Clang·MSVC 등 주요 컴파일러는 최적화 옵션을 켜면 대부분의 경우 NRVO를 적용해 복사를 생략합니다. 다만 표준이 보장하지 않는다는 점 때문에, 반환 경로가 여러 개이거나 조건에 따라 다른 변수를 반환하는 복잡한 함수에서는 NRVO가 적용되지 않을 수도 있다는 점을 염두에 두어야 합니다.

#include <string>
#include <iostream>

std::string createString() {
    std::string s = "Hello";  // 지역 변수
    return s;  // NRVO (복사 생략 가능)
}

int main() {
    std::string str = createString();
    std::cout << str << std::endl;
    
    return 0;
}

RVO 방해하지 않기

많은 개발자가 “이동이 복사보다 빠르니 반환할 때도 std::move를 붙여주면 더 빠르지 않을까”라고 생각해 return std::move(s);처럼 작성하는 실수를 저지릅니다. 하지만 sstd::move로 감싸는 순간 컴파일러는 이를 더 이상 “이름 있는 지역 변수 그 자체”로 보지 않고 우측값 레퍼런스로 취급하게 되어, NRVO 적용 조건에서 벗어나 오히려 이동 생성자 호출이 강제됩니다. 즉 복사보다는 빠를 수 있어도 RVO/NRVO로 복사·이동을 아예 생략하는 것보다는 항상 느리므로, 지역 변수를 그대로 반환할 때는 std::move 없이 이름만 반환하는 것이 최선입니다.

#include <string>

// ❌ std::move로 RVO 방해
std::string bad() {
    std::string s = "Hello";
    return std::move(s);  // NRVO 방해!
}

// ✅ 그냥 반환 (RVO 적용)
std::string good() {
    std::string s = "Hello";
    return s;  // NRVO 적용
}

4. 다중 반환값

함수가 값을 하나만 반환해야 한다는 제약 때문에, 과거에는 출력 매개변수(포인터나 레퍼런스로 값을 “받아오는” 방식)를 많이 사용했습니다. 하지만 std::pair, std::tuple, std::optional과 구조적 바인딩(structured binding)을 조합하면 여러 값을 하나의 반환값처럼 자연스럽게 묶어서 반환하고, 호출부에서도 가독성 좋게 풀어 쓸 수 있습니다.

std::pair

두 개의 값을 함께 반환해야 할 때 가장 간단한 선택지가 std::pair입니다. 아래 divide 함수는 나눗셈 성공 여부(bool)와 결과값(int)을 한 번에 반환해, 0으로 나누는 예외 상황을 예외(exception) 없이도 명확하게 표현합니다. C++17의 구조적 바인딩(auto [success, result] = divide(10, 2);)을 사용하면 pair.first, pair.second처럼 접근하지 않고도 의미 있는 이름으로 각 값을 바로 꺼내 쓸 수 있어 코드 가독성이 크게 좋아집니다.

#include <utility>
#include <iostream>

std::pair<bool, int> divide(int a, int b) {
    if (b == 0) {
        return {false, 0};
    }
    return {true, a / b};
}

int main() {
    auto [success, result] = divide(10, 2);
    
    if (success) {
        std::cout << "결과: " << result << std::endl;
    } else {
        std::cout << "0으로 나눌 수 없음" << std::endl;
    }
    
    return 0;
}

std::tuple

세 개 이상의 값을 반환해야 한다면 std::pair로는 부족합니다. std::tuple은 임의 개수, 임의 타입의 값을 하나로 묶을 수 있어 getUserInfo처럼 나이·이름·키를 한 번에 반환하는 함수를 자연스럽게 작성할 수 있게 해줍니다. 다만 std::get<0>(t)처럼 인덱스로 접근하면 어떤 값이 몇 번째인지 헷갈리기 쉬우므로, 구조적 바인딩으로 auto [age, name, height] = getUserInfo();와 같이 의미 있는 변수명으로 즉시 분해해서 쓰는 것이 실무에서 훨씬 안전한 패턴입니다.

#include <tuple>
#include <iostream>
#include <string>

std::tuple<int, std::string, double> getUserInfo() {
    return {25, "홍길동", 175.5};
}

int main() {
    auto [age, name, height] = getUserInfo();
    
    std::cout << "이름: " << name << std::endl;
    std::cout << "나이: " << age << std::endl;
    std::cout << "키: " << height << std::endl;
    
    return 0;
}

std::optional (C++17)

“값이 있을 수도, 없을 수도 있는” 상황을 표현할 때는 std::pair<bool, T>보다 std::optional<T>가 의도를 훨씬 명확하게 드러냅니다. parseInt 함수는 문자열 파싱이 실패할 수 있는 상황을 std::nullopt로 표현하고, 성공하면 파싱된 정수를 그대로 담아 반환합니다. 호출부에서는 has_value()로 성공 여부를 확인한 뒤 value()로 꺼내거나, value_or(0)처럼 실패 시 사용할 기본값을 지정해 예외 처리 없이도 안전하게 실패 케이스를 다룰 수 있습니다.

#include <optional>
#include <iostream>
#include <string>

std::optional<int> parseInt(const std::string& str) {
    try {
        return std::stoi(str);
    } catch (...) {
        return std::nullopt;
    }
}

int main() {
    auto result = parseInt("123");
    
    if (result.has_value()) {
        std::cout << "값: " << result.value() << std::endl;
    } else {
        std::cout << "파싱 실패" << std::endl;
    }
    
    // 또는
    int value = parseInt("123").value_or(0);
    
    return 0;
}

5. 자주 발생하는 문제

return문과 관련된 버그는 컴파일은 통과하지만 런타임에 미묘하게 잘못 동작하는 경우가 많아 디버깅 난이도가 높습니다. 아래 네 가지는 실무에서 가장 자주 마주치는 패턴이므로, 각각 왜 문제인지와 어떻게 고치는지를 순서대로 짚어보겠습니다.

문제 1: 지역 변수 레퍼런스 반환

bad 함수는 함수 안에서 생성한 지역 변수 s를 레퍼런스로 반환합니다. 문제는 s가 함수 스택 프레임에 존재하는 객체라서 함수가 끝나는 순간 소멸자가 호출되어 메모리가 해제된다는 점입니다. 반환된 레퍼런스는 이미 사라진 메모리를 가리키는 댕글링 레퍼런스가 되고, 이를 참조하면 컴파일 에러 없이 조용히 정의되지 않은 동작(undefined behavior)이 발생합니다. good 함수처럼 값으로 반환하면 RVO가 적용되어 성능 손해도 없이 안전하게 값을 전달할 수 있습니다.

#include <iostream>

// ❌ 댕글링 레퍼런스
const std::string& bad() {
    std::string s = "Hello";
    return s;  // s는 함수 종료 시 소멸!
}

// ✅ 값 반환 (RVO 적용)
std::string good() {
    std::string s = "Hello";
    return s;  // 안전
}

int main() {
    // const std::string& ref = bad();  // 정의되지 않은 동작!
    std::string str = good();  // 안전
    
    std::cout << str << std::endl;
    
    return 0;
}

문제 2: 모든 경로에서 반환

int 반환 타입인 함수는 실행되는 모든 경로에서 반드시 값을 반환해야 합니다. bad 함수는 flagfalse일 때 if 블록을 빠져나온 뒤 아무런 return도 만나지 못한 채 함수가 끝나버리는데, 이 경우 반환값은 정의되지 않은 쓰레기 값이 되어 호출부에서 예측할 수 없는 버그로 이어집니다. 대부분의 컴파일러는 -Wreturn-type 경고를 통해 이런 실수를 잡아주므로, 경고를 무시하지 말고 good 함수처럼 else 경로에도 명시적으로 return을 추가하는 습관을 들여야 합니다.

#include <iostream>

// ❌ 반환 누락
int bad(bool flag) {
    if (flag) {
        return 10;
    }
    // 반환 누락! (경고)
}

// ✅ 모든 경로에서 반환
int good(bool flag) {
    if (flag) {
        return 10;
    }
    return 0;  // else 경로
}

int main() {
    std::cout << good(true) << std::endl;
    std::cout << good(false) << std::endl;
    
    return 0;
}

문제 3: 반환 타입 불일치

선언된 반환 타입과 실제로 반환하는 표현식의 타입이 다르면, C++는 가능한 경우 암묵적 형변환을 시도합니다. bad 함수는 int를 반환하도록 선언했지만 실제로는 const char* 문자열 리터럴을 반환하고 있어, 포인터 값이 정수로 변환되는 의미 없는 암묵적 변환이 일어나고 대부분의 컴파일러가 경고를 출력합니다. 의도한 타입이 문자열이라면 good 함수처럼 반환 타입을 std::string으로 명시하거나, autoGood처럼 auto로 컴파일러가 실제 반환식으로부터 타입을 추론하게 맡기는 것이 안전합니다.

// ❌ 타입 불일치
int bad() {
    return "Hello";  // const char* -> int (경고)
}

// ✅ 올바른 타입
std::string good() {
    return "Hello";
}

// ✅ auto 타입 추론
auto autoGood() {
    return "Hello";  // const char* 추론
}

문제 4: 불필요한 std::move

앞서 3절에서 다룬 RVO 방해 문제는 실무 코드 리뷰에서 반복적으로 지적되는 사항이라 별도로 짚을 가치가 있습니다. 지역 변수를 함수 밖으로 반환할 때 “이동을 명시하면 더 빠르지 않을까”라는 직관으로 std::move를 붙이는 경우가 많지만, 이는 컴파일러가 NRVO로 복사·이동을 통째로 생략할 기회를 차단하는 역효과를 냅니다. 정적 분석 도구인 clang-tidy도 performance-no-automatic-move 규칙으로 이런 패턴을 잡아낼 정도로 흔한 안티패턴이므로, 지역 변수를 그대로 반환하는 상황에서는 std::move 없이 이름만 반환하는 것이 항상 더 낫습니다.

#include <string>

// ❌ std::move로 RVO 방해
std::string bad() {
    std::string s = "Hello";
    return std::move(s);  // 불필요!
}

// ✅ 그냥 반환
std::string good() {
    std::string s = "Hello";
    return s;  // RVO 적용
}

6. 실전 예제: 에러 처리

지금까지 배운 값 반환, RVO, std::optional을 실제 파일 읽기 코드에 적용해 보면 return문 설계가 API 사용성에 얼마나 큰 영향을 주는지 체감할 수 있습니다. 아래 FileReader 클래스는 같은 파일 읽기 기능을 std::optional을 이용한 방식과 예외를 이용한 방식 두 가지로 구현해 비교합니다. readFile은 파일 열기에 실패해도 프로그램을 중단시키지 않고 std::nullopt를 반환해 호출자가 if (content.has_value())로 자연스럽게 실패를 처리하게 하는 반면, readFileOrThrowstd::runtime_error를 던져 호출자가 try-catch로 감싸도록 강제합니다. 실패가 흔히 발생하고 호출부에서 매번 확인해야 하는 상황(파일 없음, 파싱 실패 등)에는 optional이, 프로그램 흐름을 중단해야 할 정도로 심각한 오류(설정 파일 손상, 복구 불가능한 상태)에는 예외가 더 적합합니다.

#include <optional>
#include <string>
#include <iostream>
#include <fstream>

class FileReader {
public:
    // 성공/실패를 명확히 반환
    std::optional<std::string> readFile(const std::string& path) {
        std::ifstream file(path);
        
        if (!file.is_open()) {
            return std::nullopt;  // 실패
        }
        
        std::string content;
        std::string line;
        
        while (std::getline(file, line)) {
            content += line + "\n";
        }
        
        return content;  // 성공
    }
    
    // 예외로 에러 처리
    std::string readFileOrThrow(const std::string& path) {
        std::ifstream file(path);
        
        if (!file.is_open()) {
            throw std::runtime_error("파일 열기 실패: " + path);
        }
        
        std::string content;
        std::string line;
        
        while (std::getline(file, line)) {
            content += line + "\n";
        }
        
        return content;
    }
};

int main() {
    FileReader reader;
    
    // optional 사용
    auto content = reader.readFile("data.txt");
    if (content.has_value()) {
        std::cout << "파일 내용:\n" << content.value() << std::endl;
    } else {
        std::cout << "파일 읽기 실패" << std::endl;
    }
    
    // 예외 사용
    try {
        std::string content2 = reader.readFileOrThrow("data.txt");
        std::cout << content2 << std::endl;
    } catch (const std::exception& e) {
        std::cerr << "에러: " << e.what() << std::endl;
    }
    
    return 0;
}

정리

핵심 요약

  1. return: 함수 종료 및 값 반환
  2. 값 반환: 복사 또는 이동 (RVO로 최적화)
  3. 레퍼런스 반환: 멤버/static 변수만 안전
  4. 포인터 반환: 수명 관리 주의
  5. RVO: 반환값 복사 생략 (C++17 보장)
  6. 다중 반환: std::pair, std::tuple, std::optional

반환 타입 선택 가이드

상황권장 반환 타입이유
기본 타입복사 비용 낮음
객체RVO 적용
멤버 변수 수정레퍼런스직접 수정 가능
실패 가능std::optional명확한 실패 표현
여러 값std::tuple구조화된 반환
에러 상세예외에러 정보 전달

실전 팁

안전성:

  • 지역 변수는 값으로 반환 (레퍼런스 금지)
  • 모든 경로에서 반환 (-Wreturn-type 경고 확인)
  • 포인터 반환 시 수명 문서화

성능:

  • RVO를 신뢰하고 값으로 반환
  • std::move 사용 자제 (RVO 방해)
  • 큰 객체도 값 반환 (RVO 적용)

가독성:

  • std::optional로 실패 명확히 표현
  • std::tuple로 여러 값 반환
  • C++17 Structured Binding 활용

다음 단계


관련 글


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. Everything about C++ Return Statement : from basic concepts to practical applications.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

C++, return, function, RVO, 반환 등으로 검색하시면 이 글이 도움이 됩니다.