C++ 함수 처음 만들기: 선언과 정의, 값·참조 전달, 오버로딩, 기본 인자 (예제 10개)

이 글의 핵심

C++ 함수에서 초보자가 가장 많이 막히는 지점은 인자를 값으로 받을지 참조로 받을지, 그리고 선언과 정의를 어디에 둘지입니다. 값 전달은 원본을 바꾸지 못하고 큰 객체를 복사하는 비용이 있으며, 참조와 포인터는 원본을 바꿀 수 있는 대신 수명을 신경 써야 합니다. 계산기, 문자열 처리, 배열 정렬 함수를 직접 만들면서 이 선택을 연습해 봅니다.

함수가 필요한 이유

// ❌ 함수 없이 (코드 중복)
int main() {
    int sum1 = 0;
    for (int i = 1; i <= 10; i++) sum1 += i;
    cout << sum1 << endl;
    
    int sum2 = 0;
    for (int i = 1; i <= 100; i++) sum2 += i;
    cout << sum2 << endl;
}

// ✅ 함수 사용 (재사용)
int sum(int n) {
    int result = 0;
    for (int i = 1; i <= n; i++) result += i;
    return result;
}

int main() {
    cout << sum(10) << endl;
    cout << sum(100) << endl;
}

위 코드는 두 버전을 나란히 보여 주려고 main을 두 번 적었을 뿐이니 실제로는 한 가지만 골라 써야 합니다. 함수로 묶는 이유는 단순히 줄 수를 줄이는 것이 아닙니다. 첫 번째 버전에서 합을 구하는 방식을 바꾸고 싶다면(예: 공식 n * (n + 1) / 2로) 반복문 두 개를 모두 찾아 고쳐야 하고, 한 곳만 고치면 두 결과가 서로 다른 방식으로 계산되는 버그가 생깁니다. 함수로 만들면 규칙이 한 곳에만 있고, sum(10)이라는 이름만 보고도 무엇을 하는지 알 수 있으며, 따로 떼어 테스트할 수도 있습니다. “같은 코드를 세 번째 복사하려는 순간”이 함수로 뽑아낼 때라는 경험칙이 많이 쓰입니다.

기본 구조

// 반환타입 함수이름(매개변수) {
//     함수 본문
//     return 반환값;
// }

int add(int a, int b) {
    return a + b;
}

int main() {
    int result = add(3, 5);  // 8
    cout << result << endl;
}

add(3, 5)를 호출하면 3과 5가 매개변수 a와 b에 복사되고, 함수 본문이 실행된 뒤 return의 값이 호출한 자리로 돌아와 result에 들어갑니다. 함수 정의의 a, b를 매개변수(parameter), 호출할 때 넘기는 3, 5를 인자(argument) 라고 구분해서 부릅니다. 매개변수와 함수 안에서 선언한 변수는 함수가 호출될 때 만들어지고 끝나면 사라지는 지역 변수라서, main에서 a를 쓰려고 하면 'a' was not declared in this scope 에러가 납니다.

반환 타입

값 반환

// 정수 반환
int getAge() {
    return 25;
}

// 실수 반환
double getPi() {
    return 3.14159;
}

// 문자열 반환
string getName() {
    return "홍길동";
}

// bool 반환
bool isAdult(int age) {
    return age >= 20;
}

void (반환값 없음)

void printHello() {
    cout << "Hello!" << endl;
    // return 생략 가능
}

void printNumber(int n) {
    cout << "숫자: " << n << endl;
    return;  // 중간에 종료 가능
}

반환값과 return의 규칙

  • 반환 타입이 void가 아니면 모든 실행 경로에서 값을 반환해야 합니다. 일부 경로만 return이 있으면 정의되지 않은 동작(undefined behavior)이 될 수 있으며, 최근 컴파일러는 경고·에러를 냅니다.
  • main의 return 0; 은 생략하면 C++ 표준에 따라 0을 반환한 것으로 간주됩니다. 이 예외는 main에만 적용되고 다른 함수에는 해당하지 않습니다.
  • 구조체나 클래스를 반환하면 복사/이동이 일어날 수 있습니다. 작은 타입은 값 반환이 단순하며, 큰 객체는 이동 의미론과 RVO(반환값 최적화)로 불필요한 복사가 줄어듭니다.
  • 참조/포인터 반환은 “무엇의 수명을 가리키는가”를 반드시 확인하세요. 지역 변수의 주소·참조를 반환하면 댕글링이 됩니다(아래 “자주 발생하는 문제” 참고).
int max(int a, int b) {
    if (a > b) {
        return a;
    }
    return b;  // if의 else 없이도 두 갈래 모두 반환
}

return을 만나면 함수는 그 자리에서 즉시 끝나므로 a > b일 때 두 번째 return b;는 실행되지 않습니다. 이 성질을 이용하면 else로 감싸지 않아도 되고, 조건에 맞지 않는 경우를 먼저 처리하고 빠져나가는 조기 반환으로 중첩을 줄일 수 있습니다. 참고로 max라는 이름은 표준 라이브러리의 std::max와 같아서, using namespace std;를 쓴 파일에서 max(1, 2)를 호출하면 컴파일러가 둘 중 무엇을 쓸지 정하지 못해 call of overloaded 'max(int, int)' is ambiguous 에러가 날 수 있습니다. 이런 충돌이 using namespace std;를 넓게 쓰지 말라는 조언이 나오는 이유이며, 연습 코드에서는 myMax처럼 이름을 바꾸면 피할 수 있습니다.

매개변수

값 전달 (Call by Value)

void increment(int x) {
    x++;  // 복사본만 변경
}

int main() {
    int a = 10;
    increment(a);
    cout << a;  // 10 (변경 안 됨)
}

값 전달에서 x는 a의 복사본입니다. 함수 안에서 x를 바꿔도 main의 a와는 메모리가 달라서 아무 영향이 없습니다. 이것은 단점이 아니라 장점이기도 합니다. 함수가 호출한 쪽의 변수를 몰래 바꿀 수 없으므로 코드를 읽을 때 “이 함수를 불러도 a는 그대로”라고 확신할 수 있습니다. 대신 vector나 string처럼 큰 객체를 값으로 넘기면 호출할 때마다 전체가 복사된다는 비용이 있습니다.

참조 전달 (Call by Reference)

void increment(int& x) {  // & 추가
    x++;  // 원본 변경
}

int main() {
    int a = 10;
    increment(a);
    cout << a;  // 11 (변경됨!)
}

int& x의 x는 복사본이 아니라 a의 다른 이름(별칭) 입니다. 그래서 x++가 곧 a++가 됩니다. 참조 매개변수에는 반드시 이름이 있는 변수를 넘겨야 하며, increment(10)이나 increment(a + 1)처럼 임시 값을 넘기면 cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int' 에러가 납니다. 바꿀 대상이 없는 값을 “바꾸겠다”는 함수에 넘기는 것이 논리적으로 맞지 않기 때문입니다. 한 가지 단점은 호출하는 코드 increment(a)만 봐서는 a가 바뀌는지 알 수 없다는 점입니다. 그래서 함수 이름으로 의도를 드러내거나, 값을 바꿔서 돌려주는 방식(a = incremented(a))을 선호하는 사람도 많습니다.

포인터 전달

void swap(int* a, int* b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}

int main() {
    int x = 10, y = 20;
    swap(&x, &y);
    cout << x << ", " << y;  // 20, 10
}

포인터 전달은 C 언어에서 쓰던 방식으로, 호출할 때 &x처럼 주소를 넘기므로 호출 코드만 봐도 “이 변수가 바뀔 수 있다”는 것이 드러납니다. 함수 안에서는 *a처럼 역참조해서 원본에 접근합니다. 참조와 달리 포인터는 nullptr일 수 있으므로 “값이 없을 수도 있는 선택적 인자”를 표현할 수 있지만, 그만큼 함수 안에서 if (a == nullptr) 검사를 해야 하고 빠뜨리면 크래시가 납니다. C++에서는 원본을 바꾸는 용도라면 참조가 기본이고, 포인터는 nullptr의 의미가 필요하거나 C 라이브러리와 연결할 때 주로 씁니다. 표준 라이브러리에 이미 std::swap이 있으므로 실제 코드에서는 직접 만들 필요가 없습니다.

값·참조·포인터 전달 한눈에

방식문법원본 수정복사 비용언제 쓰나
값void f(int x)불가작은 타입은 저렴기본 타입, 불변 의도
참조void f(int& x)가능없음(별칭)원본 수정, 큰 객체
const 참조void f(const int& x)불가없음읽기만, 큰 객체·문자열
포인터void f(int* x)nullptr 체크 가능주소만 복사선택적 인자, C API 호환

참조는 반드시 유효한 객체에 연결되어야 하며, 포인터는 nullptr일 수 있으므로 함수 안에서 검사하는 습관이 좋습니다.

표에서 가장 자주 쓰게 될 줄은 const 참조입니다. void print(const string& s)는 복사 없이 원본을 받으면서도 함수가 원본을 바꿀 수 없다고 컴파일러가 보장하고, 참조와 달리 print("hello")처럼 임시 값도 받을 수 있습니다. 어떤 방식을 고를지 헷갈린다면 “int, double, char 같은 작은 기본 타입은 값으로, string·vector·구조체처럼 큰 타입은 const&로, 원본을 바꿔야 할 때만 &로”라는 규칙에서 시작하면 대부분 맞습니다. int를 const int&로 받는 것은 오히려 주소를 통한 간접 접근이 생길 수 있어 이득이 없습니다.

기본 매개변수

void greet(string name = "손님") {
    cout << "안녕하세요, " << name << "님!" << endl;
}

int main() {
    greet("홍길동");  // 안녕하세요, 홍길동님!
    greet();          // 안녕하세요, 손님님!
}

기본 인자가 여러 개일 때 오른쪽부터 기본값이 채워집니다. 중간만 생략할 수는 없습니다.

void draw(int x, int y, int color = 0, int width = 1);
// draw(10, 20);        // OK: color=0, width=1
// draw(10);            // 에러: y가 없음

선언과 정의를 나눌 때 기본값은 보통 헤더의 선언에만 둡니다. .cpp 정의에 다시 쓰면 중복 정의로 에러가 날 수 있습니다(한 번만 지정).

기본 인자는 호출하는 쪽에서 채워진다는 점을 이해하면 규칙이 자연스럽습니다. greet()를 컴파일할 때 컴파일러가 그 자리에서 보이는 선언을 보고 greet("손님")으로 바꿔 넣기 때문에, 기본값은 호출하는 모든 파일이 볼 수 있는 헤더에 있어야 합니다. 같은 이유로 기본값을 바꾸면 그 함수를 호출하는 모든 파일을 다시 컴파일해야 새 값이 반영됩니다. 기본 인자와 오버로딩을 함께 쓰면 void f(int a, int b = 0);과 void f(int a);처럼 f(1) 호출이 어느 쪽인지 정할 수 없는 모호성이 생기므로, 둘 중 하나만 쓰는 편이 안전합니다.

함수 오버로딩

// 같은 이름, 다른 매개변수
// 변수 선언 및 초기화
int add(int a, int b) {
    return a + b;
}

double add(double a, double b) {
    return a + b;
}

int add(int a, int b, int c) {
    return a + b + c;
}

int main() {
    cout << add(1, 2);        // int 버전
    cout << add(1.5, 2.5);    // double 버전
    cout << add(1, 2, 3);     // 3개 매개변수 버전
}

컴파일러는 호출할 때 넘긴 인자의 타입과 개수를 보고 가장 잘 맞는 add를 고릅니다(오버로드 해석). 세 함수가 링크 단계에서도 서로 다른 함수로 구분되는 것은 컴파일러가 매개변수 타입을 이름에 인코딩하는 이름 맹글링(name mangling) 덕분입니다. 반환 타입만 다른 두 오버로드는 같은 매개변수면 오버로딩할 수 없습니다(호출 시 어떤 함수인지 결정할 수 없음). add(1, 2.5)처럼 int와 double을 섞어 넘기면 두 버전 모두 인자 하나씩 변환이 필요해서 “모호한 호출” 에러가 나는데, 이 경우는 아래 “문제 2”에서 자세히 다룹니다. 하는 일이 같고 타입만 다른 함수를 여러 개 복사하게 된다면 나중에 배울 함수 템플릿이 더 나은 도구입니다.

inline 함수

inline은 “이 함수 호출을 호출 비용 없이 본문으로 펼쳐 넣어도 좋다”는 힌트입니다. 최종적으로 인라인 여부는 컴파일러가 결정합니다.

inline int square(int x) {
    return x * x;
}

// 헤더에 짧은 함수를 두고 여러 .cpp에서 포함할 때 자주 사용
// (ODR 위반 없이 같은 정의를 공유하려면 inline이 필요한 경우가 많음)

일반 함수와의 차이(요지):

  • 짧고 자주 호출되는 함수에 유리할 수 있습니다.
  • 헤더에 구현을 두는 헤더 전용 함수 패턴과 함께 쓰이기도 합니다.
  • 디버깅 시 스택 트레이스에 함수 이름이 안 보일 수 있습니다(최적화 수준에 따름).

C++17의 inline 변수는 다른 주제이지만, “여러 번 정의돼도 링커가 하나로 합친다”는 점에서 비슷한 철학이 있습니다.

재귀 함수

// 팩토리얼
int factorial(int n) {
    if (n <= 1) return 1;  // 종료 조건
    return n * factorial(n - 1);
}

// 피보나치
int fibonacci(int n) {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

int main() {
    cout << factorial(5);   // 120
    cout << fibonacci(10);  // 55
}

재귀 함수는 반드시 종료 조건이 있어야 하고, 매 호출마다 그 조건에 가까워져야 합니다. 종료 조건이 없거나 factorial(n + 1)처럼 멀어지면 호출이 끝없이 쌓이다가 스택 메모리가 바닥나 Segmentation fault(Windows에서는 Stack overflow)로 프로그램이 죽습니다. 종료 조건이 있어도 입력이 수십만 단위로 크면 같은 일이 생길 수 있으므로, 깊이가 입력에 비례하는 재귀는 반복문으로 바꾸는 것을 고려해야 합니다.

두 예제에는 각각 알아 둘 한계가 있습니다. factorial은 int 기준으로 13!부터 범위를 넘어 오버플로가 나므로(12! = 479,001,600이 한계), 큰 값을 다루려면 long long을 써도 20!까지가 최대입니다. fibonacci는 코드가 수학 정의와 똑같아서 이해하기 좋지만, fibonacci(n-1)과 fibonacci(n-2)가 같은 값을 반복해서 다시 계산하기 때문에 호출 횟수가 n에 대해 지수적으로 늘어납니다. fibonacci(40)만 해도 눈에 띄게 오래 걸리는데, 이미 계산한 값을 배열에 저장해 두는 메모이제이션이나 반복문으로 바꾸면 선형 시간에 끝납니다.

자주 하는 실수

실수 1: 선언 전 호출

// ❌ 컴파일 에러
int main() {
    add(1, 2);  // add를 모름!
}

int add(int a, int b) {
    return a + b;
}

// ✅ 해결법 1: 순서 변경
int add(int a, int b) {
    return a + b;
}

int main() {
    add(1, 2);
}

// ✅ 해결법 2: 함수 선언 (프로토타입)
int add(int a, int b);  // 선언

int main() {
    add(1, 2);
}

int add(int a, int b) {  // 정의
    return a + b;
}

C++ 컴파일러는 파일을 위에서 아래로 한 번 읽으면서, 이름을 사용하는 시점에 그 이름이 이미 선언되어 있어야 합니다. 첫 번째 코드에서 GCC는 'add' was not declared in this scope라는 에러를 냅니다. 함수 선언(프로토타입)은 “이런 모양의 함수가 어딘가에 있다”는 약속만 먼저 알려 주는 것이고, 본문은 나중에 나와도 되고 다른 파일에 있어도 됩니다. 프로젝트가 커지면 이 선언들을 헤더 파일(.h)에 모아 두고 여러 .cpp에서 #include해서 쓰는데, 선언만 있고 정의가 어디에도 없으면 컴파일은 통과하고 링크 단계에서 undefined reference to 'add(int, int)' 에러가 납니다.

실수 2: 반환값 없음

// ❌ 경고 발생
int getAge() {
    int age = 25;
    // return 없음!
}

// ✅ 올바른 코드
int getAge() {
    return 25;
}

C++에서 void가 아닌 함수가 return 없이 끝나면 정의되지 않은 동작입니다. 컴파일러에 따라 경고만 내고 컴파일을 통과시키기 때문에 더 위험합니다. 디버그 빌드에서는 우연히 레지스터에 남아 있던 값이 반환되어 멀쩡해 보이다가, 최적화 빌드에서는 전혀 다른 값이 나오거나 프로그램이 이상하게 동작하는 식입니다. GCC/Clang에서는 -Wall로 control reaches end of non-void function 경고를 볼 수 있으니, 경고를 켜고 이 경고는 반드시 에러로 취급하는 것이 좋습니다(-Werror=return-type).

실수 3: 참조 vs 값 혼동

// 값 전달 (비효율)
void process(vector<int> v) {  // 전체 복사!
    // ...
}

// 참조 전달 (효율적)
void process(vector<int>& v) {  // 복사 안함
    // ...
}

// const 참조 (읽기 전용)
void print(const vector<int>& v) {
    // v 수정 불가
}

원소가 백만 개인 vector<int>를 값으로 넘기면 호출할 때마다 약 4MB를 새로 할당하고 복사합니다. 반복문 안에서 이런 함수를 호출하면 프로그램이 눈에 띄게 느려지는데, 코드만 보면 복사가 일어난다는 사실이 드러나지 않아 원인을 찾기 어렵습니다. 그래서 큰 객체는 기본적으로 const&로 받고, 함수가 내용을 바꿔야 할 때만 &를 씁니다. 반대로 함수 안에서 어차피 복사본을 만들어 수정할 거라면(아래 예시 2의 toUpperCase(string str)처럼) 값으로 받는 것이 오히려 자연스럽습니다.

실전 예시

예시 1: 계산기 함수 모음

#include <iostream>
#include <cmath>
using namespace std;

// 사칙연산 함수들
double add(double a, double b) {
    return a + b;
}

double subtract(double a, double b) {
    return a - b;
}

double multiply(double a, double b) {
    return a * b;
}

double divide(double a, double b) {
    if (b == 0) {
        cout << "오류: 0으로 나눌 수 없습니다" << endl;
        return 0;
    }
    return a / b;
}

// 고급 연산
double power(double base, int exp) {
    return pow(base, exp);
}

double squareRoot(double num) {
    if (num < 0) {
        cout << "오류: 음수의 제곱근은 계산할 수 없습니다" << endl;
        return 0;
    }
    return sqrt(num);
}

// 통계 함수
double average(double arr[], int size) {
    double sum = 0;
    for (int i = 0; i < size; i++) {
        sum += arr[i];
    }
    return sum / size;
}

double findMax(double arr[], int size) {
    double maxVal = arr[0];
    for (int i = 1; i < size; i++) {
        if (arr[i] > maxVal) {
            maxVal = arr[i];
        }
    }
    return maxVal;
}

// 메뉴 출력
void printMenu() {
    cout << "\n=== 계산기 ===" << endl;
    cout << "1. 덧셈" << endl;
    cout << "2. 뺄셈" << endl;
    cout << "3. 곱셈" << endl;
    cout << "4. 나눗셈" << endl;
    cout << "5. 거듭제곱" << endl;
    cout << "6. 제곱근" << endl;
    cout << "0. 종료" << endl;
    cout << "선택: ";
}

int main() {
    while (true) {
        printMenu();
        
        int choice;
        cin >> choice;
        
        if (choice == 0) break;
        
        double a, b;
        switch (choice) {
            case 1:
                cout << "두 수 입력: ";
                cin >> a >> b;
                cout << "결과: " << add(a, b) << endl;
                break;
            case 2:
                cout << "두 수 입력: ";
                cin >> a >> b;
                cout << "결과: " << subtract(a, b) << endl;
                break;
            case 3:
                cout << "두 수 입력: ";
                cin >> a >> b;
                cout << "결과: " << multiply(a, b) << endl;
                break;
            case 4:
                cout << "두 수 입력: ";
                cin >> a >> b;
                cout << "결과: " << divide(a, b) << endl;
                break;
            case 5:
                int exp;
                cout << "밑과 지수 입력: ";
                cin >> a >> exp;
                cout << "결과: " << power(a, exp) << endl;
                break;
            case 6:
                cout << "수 입력: ";
                cin >> a;
                cout << "결과: " << squareRoot(a) << endl;
                break;
            default:
                cout << "잘못된 선택입니다" << endl;
        }
    }
    
    return 0;
}

설명: 다양한 계산 기능을 함수로 분리한 계산기 프로그램입니다. 각 기능을 독립적인 함수로 만들어 재사용성과 유지보수성을 높였습니다.

main은 메뉴를 보여 주고 입력을 받아 알맞은 함수를 부르는 흐름만 담당하고, 실제 계산은 각 함수가 맡습니다. 이렇게 나누면 계산 규칙을 바꿀 때 main을 건드릴 필요가 없습니다. 다만 divide와 squareRoot의 에러 처리 방식은 개선할 여지가 있습니다. 오류일 때 메시지를 출력하고 0을 반환하면, 호출한 쪽은 “정말 결과가 0인 경우”(divide(0, 5))와 “오류라서 0인 경우”를 구분할 수 없습니다. 또 계산 함수가 직접 cout에 출력하면 이 함수를 GUI 프로그램이나 테스트 코드에서 재사용하기 어렵습니다. 실무에서는 예외를 던지거나 std::optional<double>을 반환해서 “값이 없음”을 타입으로 표현하고, 메시지 출력은 호출한 쪽에 맡기는 편이 좋습니다. average와 findMax는 size가 0이면 각각 0으로 나누기와 범위 밖 접근이 되므로, 빈 배열을 넘기지 않는다는 전제를 주석으로 남기거나 검사를 추가해야 합니다. 배열 매개변수 double arr[]는 사실 포인터(double* arr)로 받는 것이라 함수 안에서 sizeof(arr)로 길이를 구할 수 없고, 그래서 size를 따로 넘기는 것입니다.

예시 2: 문자열 처리 유틸리티 함수

#include <iostream>
#include <string>
#include <cctype>
using namespace std;

// 문자열을 대문자로 변환
string toUpperCase(string str) {
    for (char& c : str) {
        c = toupper(c);
    }
    return str;
}

// 문자열을 소문자로 변환
string toLowerCase(string str) {
    for (char& c : str) {
        c = tolower(c);
    }
    return str;
}

// 문자열 뒤집기
string reverse(string str) {
    string result = "";
    for (int i = str.length() - 1; i >= 0; i--) {
        result += str[i];
    }
    return result;
}

// 회문(palindrome) 체크
bool isPalindrome(const string& str) {
    int left = 0;
    int right = str.length() - 1;
    
    while (left < right) {
        if (tolower(str[left]) != tolower(str[right])) {
            return false;
        }
        left++;
        right--;
    }
    return true;
}

// 문자 개수 세기
int countChar(const string& str, char target) {
    int count = 0;
    for (char c : str) {
        if (tolower(c) == tolower(target)) {
            count++;
        }
    }
    return count;
}

// 단어 개수 세기
int countWords(const string& str) {
    int count = 0;
    bool inWord = false;
    
    for (char c : str) {
        if (isspace(c)) {
            inWord = false;
        } else if (!inWord) {
            inWord = true;
            count++;
        }
    }
    return count;
}

// 문자열에서 공백 제거
string removeSpaces(string str) {
    string result = "";
    for (char c : str) {
        if (!isspace(c)) {
            result += c;
        }
    }
    return result;
}

int main() {
    string text = "Hello World";
    
    cout << "원본: " << text << endl;
    cout << "대문자: " << toUpperCase(text) << endl;
    cout << "소문자: " << toLowerCase(text) << endl;
    cout << "뒤집기: " << reverse(text) << endl;
    cout << "단어 수: " << countWords(text) << endl;
    cout << "'l' 개수: " << countChar(text, 'l') << endl;
    
    string palindrome = "level";
    cout << "\n'" << palindrome << "'는 회문? " 
         << (isPalindrome(palindrome) ? "예" : "아니오") << endl;
    
    string withSpaces = "H e l l o";
    cout << "공백 제거: '" << withSpaces << "' -> '" 
         << removeSpaces(withSpaces) << "'" << endl;
    
    return 0;
}

설명: 문자열을 처리하는 다양한 유틸리티 함수들입니다. 실무에서 자주 사용하는 문자열 변환, 검증, 분석 기능을 함수로 구현했습니다.

매개변수 선언을 눈여겨보면 앞에서 배운 전달 방식이 목적에 따라 나뉘어 있습니다. toUpperCase(string str)는 값으로 받아서 복사본을 고친 뒤 돌려주므로 원본 text는 그대로 남고, isPalindrome(const string& str)은 읽기만 하므로 const&로 받아 복사를 피합니다. for (char& c : str)의 &가 빠지면 c가 각 문자의 복사본이 되어 대문자로 바꿔도 str에는 반영되지 않는다는 점도 흔히 하는 실수입니다.

toupper, tolower, isspace는 C 라이브러리 함수라서 인자가 unsigned char 범위의 값이어야 합니다. 한글처럼 UTF-8로 인코딩된 문자는 바이트 값이 128 이상이라 char가 부호 있는 타입인 환경에서는 음수로 전달되어 정의되지 않은 동작이 되고, MSVC 디버그 빌드에서는 assertion 창이 뜨기도 합니다. toupper(static_cast<unsigned char>(c))처럼 변환해서 넘기는 것이 안전합니다. 또 이 함수들은 바이트 단위로 동작하므로 한글 문자열을 reverse하면 멀티바이트 문자가 깨지고, countChar로 한글 글자를 세는 것도 불가능합니다. 영어 이외의 텍스트를 제대로 다루려면 ICU 같은 유니코드 라이브러리가 필요합니다.

예시 3: 배열 정렬 및 검색 함수

#include <iostream>
using namespace std;

// 배열 출력
void printArray(int arr[], int size, const string& label) {
    cout << label << ": ";
    for (int i = 0; i < size; i++) {
        cout << arr[i] << " ";
    }
    cout << endl;
}

// 버블 정렬
void bubbleSort(int arr[], int size) {
    for (int i = 0; i < size - 1; i++) {
        for (int j = 0; j < size - i - 1; j++) {
            if (arr[j] > arr[j + 1]) {
                // swap
                int temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
            }
        }
    }
}

// 선택 정렬
void selectionSort(int arr[], int size) {
    for (int i = 0; i < size - 1; i++) {
        int minIdx = i;
        for (int j = i + 1; j < size; j++) {
            if (arr[j] < arr[minIdx]) {
                minIdx = j;
            }
        }
        // swap
        int temp = arr[i];
        arr[i] = arr[minIdx];
        arr[minIdx] = temp;
    }
}

// 선형 검색
int linearSearch(int arr[], int size, int target) {
    for (int i = 0; i < size; i++) {
        if (arr[i] == target) {
            return i;  // 인덱스 반환
        }
    }
    return -1;  // 못 찾음
}

// 이진 검색 (정렬된 배열에서만 사용)
int binarySearch(int arr[], int size, int target) {
    int left = 0;
    int right = size - 1;
    
    while (left <= right) {
        int mid = left + (right - left) / 2;
        
        if (arr[mid] == target) {
            return mid;
        } else if (arr[mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    return -1;
}

// 배열 복사
void copyArray(int source[], int dest[], int size) {
    for (int i = 0; i < size; i++) {
        dest[i] = source[i];
    }
}

// 배열 역순
void reverseArray(int arr[], int size) {
    int left = 0;
    int right = size - 1;
    
    while (left < right) {
        int temp = arr[left];
        arr[left] = arr[right];
        arr[right] = temp;
        left++;
        right--;
    }
}

int main() {
    int arr[] = {64, 34, 25, 12, 22, 11, 90};
    int size = sizeof(arr) / sizeof(arr[0]);
    
    printArray(arr, size, "원본 배열");
    
    // 정렬 테스트
    int arr1[7];
    copyArray(arr, arr1, size);
    bubbleSort(arr1, size);
    printArray(arr1, size, "버블 정렬");
    
    int arr2[7];
    copyArray(arr, arr2, size);
    selectionSort(arr2, size);
    printArray(arr2, size, "선택 정렬");
    
    // 검색 테스트
    int target = 25;
    int idx = linearSearch(arr, size, target);
    if (idx != -1) {
        cout << "\n선형 검색: " << target << "을(를) 인덱스 " << idx << "에서 찾음" << endl;
    }
    
    // 정렬된 배열에서 이진 검색
    idx = binarySearch(arr1, size, target);
    if (idx != -1) {
        cout << "이진 검색: " << target << "을(를) 인덱스 " << idx << "에서 찾음" << endl;
    }
    
    // 배열 역순
    reverseArray(arr, size);
    printArray(arr, size, "\n역순 배열");
    
    return 0;
}

설명: 배열을 다루는 기본적인 알고리즘 함수들입니다. 정렬, 검색, 복사, 역순 등 자주 사용하는 기능을 함수로 구현하여 코드 재사용성을 높였습니다.

bubbleSort(arr1, size)를 호출한 뒤 main의 arr1이 정렬되어 있는 이유는, 배열을 넘기면 배열 전체가 복사되는 것이 아니라 첫 원소의 주소가 전달되기 때문입니다. 그래서 배열 매개변수는 값 전달처럼 보여도 원본을 바꿉니다. 정렬 테스트마다 copyArray로 복사본을 만든 것도 이 때문이며, 원본 arr는 선형 검색과 역순 테스트에 그대로 쓰입니다. binarySearch를 정렬된 arr1에만 쓰는 것은 이진 탐색이 정렬된 배열을 전제로 하기 때문이고, mid = left + (right - left) / 2는 (left + right) / 2에서 두 값을 더할 때 생길 수 있는 오버플로를 피하는 관용적인 쓰기 방식입니다.

학습용으로 정렬을 직접 짜 보는 것은 의미가 있지만, 실제 코드에서는 <algorithm>의 std::sort, std::binary_search, std::reverse, std::copy를 쓰는 것이 맞습니다. 버블 정렬과 선택 정렬은 O(n²)라 원소가 수만 개만 되어도 느려지고, std::sort는 O(n log n)을 보장합니다. 또 int arr1[7];처럼 크기를 숫자로 적어 두면 원본 배열에 원소를 추가했을 때 복사본이 넘치는 버그가 생기므로, std::vector나 std::array를 쓰면 크기를 함께 들고 다닐 수 있어 size 매개변수를 따로 넘길 필요도 없어집니다.

자주 발생하는 문제

문제 1: 지역 변수의 주소 반환

증상: 함수에서 반환한 포인터/참조 사용 시 쓰레기 값 또는 크래시

원인: 함수 종료 시 지역 변수가 소멸되는데 그 주소를 반환

해결법:

// ❌ 잘못된 코드 (댕글링 포인터)
int* createNumber() {
    int num = 10;
    return &num;  // num은 함수 종료 시 소멸!
}

int main() {
    int* ptr = createNumber();
    cout << *ptr;  // 정의되지 않은 동작: 10이 나올 수도, 쓰레기 값이나 크래시일 수도 있음
}

// ❌ 잘못된 코드 (댕글링 참조)
int& getNumber() {
    int num = 10;
    return num;  // num은 함수 종료 시 소멸!
}

// ✅ 올바른 코드 (방법 1: 값 반환)
int createNumber() {
    int num = 10;
    return num;  // 값 복사
}

// ✅ 올바른 코드 (방법 2: 동적 할당)
int* createNumber() {
    int* num = new int(10);
    return num;  // 동적 할당된 메모리는 유지됨
}

int main() {
    int* ptr = createNumber();
    cout << *ptr;  // OK
    delete ptr;  // 반드시 해제
}

// ✅ 올바른 코드 (방법 3: static 변수)
int& getNumber() {
    static int num = 10;  // static은 프로그램 종료까지 유지
    return num;
}

지역 변수는 함수가 끝나면 사라지는 스택 영역에 있으므로, 그 주소를 들고 나오면 이미 반납된 메모리를 가리키는 댕글링 포인터가 됩니다. 이 버그가 까다로운 이유는 곧바로 크래시가 나지 않는 경우가 많다는 점입니다. 반환 직후에는 그 메모리가 아직 덮어쓰이지 않아 10이 그대로 출력되다가, 다른 함수를 한 번 호출한 뒤에는 엉뚱한 값으로 바뀝니다. GCC/Clang은 address of local variable 'num' returned 경고를 주므로 경고를 켜 두면 대부분 잡을 수 있습니다.

세 가지 해결책은 성격이 다릅니다. 값 반환이 가장 단순하고 안전하며, 큰 객체라도 컴파일러의 반환값 최적화(RVO)와 이동 덕분에 대부분 복사 비용이 없습니다. new로 동적 할당하면 수명은 해결되지만 호출한 쪽이 delete를 책임져야 해서 빠뜨리면 메모리 누수가 되므로, 요즘 C++에서는 std::unique_ptr<int>를 반환하는 것이 정석입니다. static 변수는 모든 호출이 같은 변수 하나를 공유한다는 점에 주의해야 합니다. 한 곳에서 getNumber() = 20;으로 바꾸면 다른 곳에서 호출한 결과도 20이 되고, 여러 스레드가 동시에 쓰면 데이터 경쟁이 생깁니다.

문제 2: 함수 오버로딩 모호성

증상: 함수 호출 시 “ambiguous call” 컴파일 에러

원인: 여러 오버로드 함수가 매개변수 타입 변환으로 모두 매칭됨

해결법:

// ❌ 문제가 있는 코드
void print(int x) {
    cout << "int: " << x << endl;
}

void print(double x) {
    cout << "double: " << x << endl;
}

void print(long x) {
    cout << "long: " << x << endl;
}

int main() {
    print(10);    // OK: int
    print(10.5);  // OK: double
    print(10L);   // OK: long
    
    // print(10u);  // 에러! unsigned → int, long, double 모두 같은 등급의 '변환'
}

// ✅ 올바른 코드 (명시적 타입 지정)
int main() {
    print(static_cast<long>(10u));  // long으로 명시
}

// ✅ 올바른 코드 (unsigned 오버로드 추가)
void print(unsigned int x) {
    cout << "unsigned: " << x << endl;
}

오버로드 해석은 인자를 각 후보로 바꾸는 비용을 등급으로 비교합니다. 타입이 정확히 같으면 가장 좋고, 그다음이 승격(char→int, float→double처럼 손실 없는 기본 확장), 그다음이 일반 변환(int→double, unsigned→long 등)입니다. 그래서 print(10.0f)는 float→double 승격이 가능한 print(double)이 선택되어 모호하지 않습니다(이 예제의 이전 버전은 이 경우를 에러로 잘못 설명하고 있었습니다). 반면 print(10u)는 int, long, double 세 후보 모두 같은 등급의 변환이 필요해서 우열을 가릴 수 없고, GCC는 call of overloaded 'print(unsigned int)' is ambiguous와 함께 후보 목록을 보여 줍니다.

모호성 에러는 오버로드를 추가할 때보다 나중에 다른 사람이 새로운 타입으로 호출할 때 드러나는 경우가 많습니다. 특히 0이나 NULL을 넘길 때 print(int)와 print(char*)가 모두 후보가 되는 문제가 유명한데, 포인터를 뜻한다면 nullptr을 쓰는 것이 해결책입니다. 오버로드를 여러 개 만들기 전에 “정말 타입마다 동작이 달라야 하는가”를 먼저 생각해 보고, 동작이 같다면 템플릿 하나로 쓰는 편이 모호성을 줄입니다.

문제 3: 기본 매개변수와 함수 선언 분리

증상: 기본 매개변수가 작동하지 않음

원인: 기본값은 선언부에만 써야 하는데 정의부에 씀

해결법:

// ❌ 잘못된 코드
// 헤더 파일 (.h)
void greet(string name);

// 구현 파일 (.cpp)
void greet(string name = "손님") {  // 이 .cpp 안에서만 기본값이 보임
                                     // 다른 .cpp에서 greet()를 호출하면 에러
    cout << "안녕하세요, " << name << "님" << endl;
}

// ✅ 올바른 코드
// 헤더 파일 (.h)
void greet(string name = "손님");  // 기본값은 선언부에

// 구현 파일 (.cpp)
void greet(string name) {  // 정의부에는 기본값 없음
    cout << "안녕하세요, " << name << "님" << endl;
}

// ❌ 잘못된 코드 (중복 기본값)
void greet(string name = "손님");  // 선언

void greet(string name = "손님") {  // 에러! 중복
    cout << "안녕하세요, " << name << "님" << endl;
}

// ✅ 올바른 코드 (선언과 정의가 같은 파일)
void greet(string name = "손님") {
    cout << "안녕하세요, " << name << "님" << endl;
}

첫 번째 잘못된 코드는 문법적으로는 허용되지만, 헤더만 include한 다른 .cpp 파일은 기본값의 존재를 모르기 때문에 그곳에서 greet()를 호출하면 too few arguments to function 'void greet(std::string)' 에러가 납니다. 같은 파일 안에서는 되고 다른 파일에서는 안 되니 “기본 매개변수가 작동하지 않는다”는 증상으로 나타나는 것입니다. 두 번째 경우처럼 선언과 정의 모두에 기본값을 적으면 GCC는 default argument given for parameter 1 of 'void greet(std::string)' 에러를 냅니다. 규칙은 하나로 정리됩니다. 기본값은 호출하는 모든 곳에서 보이는 첫 선언에 한 번만 적습니다.

함수 호출 비용에 대해

“함수로 나누면 호출 비용 때문에 느려지지 않을까”라는 걱정은 초보 단계에서 대부분 무시해도 됩니다. 최적화 빌드(-O2)에서 컴파일러는 add나 square처럼 짧은 함수를 호출하는 자리에 본문을 직접 펼쳐 넣는 인라이닝을 알아서 하므로, 호출 비용 자체가 사라지는 경우가 많습니다. 성능에 실제로 영향을 주는 것은 호출 횟수보다 호출할 때마다 무엇이 복사되는가와 알고리즘의 복잡도입니다. 큰 객체를 값으로 넘기는 함수, 앞의 fibonacci처럼 같은 계산을 반복하는 재귀, O(n²) 정렬이 훨씬 큰 차이를 만듭니다. 그래서 “큰 객체는 const&로, 같은 계산은 한 번만, 표준 라이브러리 알고리즘을 우선”이라는 세 가지만 지켜도 함수 설계에서 생기는 성능 문제는 대부분 피할 수 있습니다.

같이 보면 좋은 글