C++ 함수 오버로딩: 오버로드 해석 순서와 모호한 호출이 생기는 경우
이 글의 핵심
C++ 함수 오버로딩이 컴파일 타임에 어떻게 해석되는지, 왜 반환 타입만으로는 오버로딩할 수 없는지를 출력·검색·생성자 오버로딩 예제로 정리합니다. 오버로드 해석 규칙, const 오버로딩, 모호한 호출 함정까지 다룹니다.
함수 오버로딩이란?
같은 이름, 다른 매개변수를 가진 여러 함수
C가 이름 하나에 함수 하나만 대응시켰던 것과 달리, C++는 같은 이름의 함수를 매개변수 목록(타입, 개수)만 다르게 여러 개 정의할 수 있게 합니다 — 이것이 가능한 이유는 컴파일러가 소스 코드 수준의 함수 이름을 컴파일된 심볼 이름으로 바꿀 때, 매개변수 타입 정보까지 그 심볼 이름에 함께 인코딩하기 때문입니다(이 인코딩 과정이 뒤에서 다룰 “Name Mangling”입니다). 그래서 add(int, int)와 add(double, double)은 소스 코드에서는 같은 이름을 공유하지만, 링커 입장에서는 서로 완전히 다른 심볼을 가리키는 별개의 함수입니다. add(1, 2)처럼 호출하면 컴파일러가 인자의 타입을 보고 그 타입에 가장 정확히 일치하는 오버로드를 컴파일 타임에 결정하므로, 런타임에는 어떤 오버로드가 선택되었는지 판단하는 추가 비용이 전혀 들지 않습니다 — 이것이 가상 함수(런타임 다형성)와 오버로딩(컴파일 타임 다형성)의 근본적인 차이입니다.
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() {
std::cout << add(1, 2) << std::endl; // 3 (int 버전)
std::cout << add(1.5, 2.5) << std::endl; // 4.0 (double 버전)
std::cout << add(1, 2, 3) << std::endl; // 6 (3개 인자 버전)
}
오버로딩 규칙
오버로딩이 유효하려면 컴파일러가 호출 지점의 인자만 보고 어느 함수를 부를지 모호함 없이 결정할 수 있어야 합니다 — 매개변수 개수나 타입이 다르면 이 판단이 항상 가능하지만, 반환 타입만 다르다면 불가능합니다. int func(int x)와 double func(int x)를 동시에 허용한다고 상상해 보면 이유가 명확해집니다 — func(5);처럼 반환값을 아예 쓰지 않는 호출에서는 컴파일러가 어느 쪽을 실행해야 할지 판단할 근거가 인자 쪽에는 전혀 없고, 오직 “호출 결과를 어떤 타입으로 쓰려는가”라는 문맥에 의존해야 하는데 C++는 함수 호출 표현식을 그런 식으로 해석하지 않습니다. const int*와 int*가 서로 다른 오버로드로 허용되는 것은, const 여부가 “이 매개변수를 통해 무엇을 할 수 있는가”라는 실질적인 타입 정보의 일부이기 때문입니다.
// ✅ 매개변수 개수 다름
void func(int x);
void func(int x, int y);
// ✅ 매개변수 타입 다름
void func(int x);
void func(double x);
// ✅ const 다름
void func(int* ptr);
void func(const int* ptr);
// ❌ 반환 타입만 다름 (오버로딩 불가)
int func(int x);
// double func(int x); // 에러
실전 예시
예시 1: 출력 함수
print라는 이름 하나로 정수, 실수, 문자열, 벡터를 모두 다룰 수 있게 하면, 호출자는 매번 “정수는 printInt, 문자열은 printString”처럼 타입별 함수 이름을 기억할 필요 없이 “무엇을 출력하고 싶은가”만 생각하면 됩니다. 이는 std::cout의 << 연산자가 정수든 문자열이든 커스텀 타입이든 같은 문법으로 동작하는 것과 정확히 같은 원리(연산자 자체도 오버로딩된 함수입니다)이며, 오버로딩이 “타입에 따라 다른 구현이 필요하지만 호출자에게는 하나의 개념적 동작으로 보여야 하는” 상황에서 API를 얼마나 단순하게 만들 수 있는지 보여주는 가장 직관적인 예입니다.
#include <iostream>
#include <vector>
void print(int x) {
std::cout << "int: " << x << std::endl;
}
void print(double x) {
std::cout << "double: " << x << std::endl;
}
void print(const std::string& s) {
std::cout << "string: " << s << std::endl;
}
void print(const std::vector<int>& vec) {
std::cout << "vector: ";
for (int x : vec) {
std::cout << x << " ";
}
std::cout << std::endl;
}
int main() {
print(42);
print(3.14);
print(std::string("Hello"));
print(std::vector<int>{1, 2, 3});
}
예시 2: 최대값 함수
이 예시는 오버로딩과 템플릿이 같은 문제(여러 타입/형태에 대해 “최댓값을 구한다”)를 서로 다른 방식으로 해결하며 공존할 수 있음을 보여줍니다 — max(int, int)와 max(double, double)처럼 정확히 두 개의 흔한 경우는 개별 오버로드로 명시적으로 정의하고, 임의의 타입을 담은 벡터를 다루는 max(const vector<T>&)는 템플릿으로 일반화한 것입니다. 세 번째 오버로드 max(int, int, int)가 내부적으로 앞의 두 인자짜리 max를 재귀적으로 호출해 구현되는 것도 실용적인 패턴입니다 — 세 값 비교 로직을 새로 작성하는 대신, 이미 검증된 두 값 비교 로직을 재사용해 코드 중복과 버그 가능성을 동시에 줄입니다. max(nums)가 어느 오버로드로 갈지는 nums의 타입(vector<int>)이 오직 템플릿 버전의 매개변수 패턴과만 일치하므로 모호함 없이 결정됩니다.
반대로 max(10, 2.5)처럼 타입을 섞으면 컴파일이 실패합니다. max(int, int)는 두 번째 인자를, max(double, double)은 첫 번째 인자를 변환해야 하는데, 각 후보가 서로 다른 인자에서 더 나은 탓에 어느 쪽도 “모든 인자에서 같거나 더 나은” 후보가 되지 못하기 때문입니다(call of overloaded 'max(int, double)' is ambiguous). 또 max라는 이름 자체도 조심해야 합니다. using namespace std;가 있으면 std::max 템플릿이 후보에 끼어들고, Windows에서 <windows.h>를 포함하면 max가 매크로로 정의되어 있어 함수 정의부터 이상한 컴파일 에러가 납니다. 후자는 #define NOMINMAX를 <windows.h> 앞에 두거나 (max)(a, b)처럼 괄호로 감싸 피합니다.
int max(int a, int b) {
return a > b ? a : b;
}
double max(double a, double b) {
return a > b ? a : b;
}
int max(int a, int b, int c) {
return max(max(a, b), c);
}
template<typename T>
T max(const std::vector<T>& vec) {
if (vec.empty()) {
throw std::invalid_argument("빈 벡터");
}
T maxVal = vec[0];
for (const auto& val : vec) {
if (val > maxVal) {
maxVal = val;
}
}
return maxVal;
}
int main() {
std::cout << max(10, 20) << std::endl;
std::cout << max(1.5, 2.5) << std::endl;
std::cout << max(1, 2, 3) << std::endl;
std::vector<int> nums = {5, 2, 8, 1, 9};
std::cout << max(nums) << std::endl;
}
예시 3: 생성자 오버로딩
생성자는 이름이 항상 클래스 이름으로 고정되어 있으므로(String), 서로 다른 방식으로 객체를 초기화하고 싶다면 오버로딩이 사실상 유일한 방법입니다 — String(), String(const char*), String(char, size_t), String(const String&)는 모두 “String을 만든다”는 같은 목적을 갖지만 그 초기 데이터를 얻는 경로가 서로 다릅니다. 컴파일러는 String s2("Hello")처럼 호출 시 넘긴 인자의 개수와 타입을 보고 이 네 생성자 중 정확히 하나를 선택하며, String s4(s2)가 복사 생성자로 가는 것도 인자 타입(const String&)이 그 시그니처와 정확히 일치하기 때문입니다 — 이것이 실제로 std::string, std::vector 같은 표준 라이브러리 타입들이 여러 개의 생성자 오버로드를 제공하는 것과 동일한 설계 원리입니다.
예제는 생성자 오버로딩만 보여 주기 위해 단순화했으므로 그대로 쓰면 안 되는 부분이 있습니다. 소멸자와 복사 생성자를 직접 정의했으면서 복사 대입 연산자를 정의하지 않아(3의 규칙 위반), s1 = s2;를 하는 순간 기본 대입이 포인터만 복사해 두 객체가 같은 버퍼를 delete[]하는 이중 해제가 일어납니다. 또 기본 생성자로 만든 s1을 복사하면 strcpy(data, other.data)가 널 포인터를 읽습니다. 한 가지 더, String(const char*)처럼 인자 하나짜리 생성자는 암시적 변환으로도 쓰여서 String s = "Hello";나 void f(String)에 f("abc")가 그대로 통과합니다. 의도하지 않은 변환이 오버로드 해석에 끼어드는 것을 막으려면 explicit을 붙입니다. String(char, size_t)는 String s3(10, '*')처럼 인자 순서를 바꿔 호출해도 둘 다 정수 계열이라 조용히 컴파일되어, 문자 코드 10(개행)을 42개 만드는 버그가 되기 쉽다는 점도 오버로드 설계에서 흔한 함정입니다.
class String {
private:
char* data;
size_t length;
public:
// 기본 생성자
String() : data(nullptr), length(0) {}
// C 문자열
String(const char* str) {
length = strlen(str);
data = new char[length + 1];
strcpy(data, str);
}
// 반복 문자
String(char c, size_t count) {
length = count;
data = new char[length + 1];
memset(data, c, length);
data[length] = '\0';
}
// 복사 생성자
String(const String& other) {
length = other.length;
data = new char[length + 1];
strcpy(data, other.data);
}
~String() {
delete[] data;
}
};
int main() {
String s1; // 기본 생성자
String s2("Hello"); // C 문자열
String s3('*', 10); // 반복 문자
String s4(s2); // 복사 생성자
}
예시 4: 검색 함수
세 번째 find 오버로드(template<typename T, typename Predicate>)가 앞의 두 개(vector<int>, vector<string>)를 사실상 포괄한다는 점이 흥미롭습니다 — “값과 같은지 비교”라는 조건도 결국 “이 조건을 만족하는가”라는 술어(predicate)의 특수한 경우일 뿐이므로, find(numbers, [](int x){ return x == 3; })처럼 람다를 쓰면 두 번째 오버로드 없이도 정수 검색을 표현할 수 있습니다. 그럼에도 값 검색 전용 오버로드를 별도로 남겨둔 것은 순전히 사용 편의성 때문입니다 — find(numbers, 3)처럼 값을 직접 넘기는 편이 매번 람다를 작성하는 것보다 훨씬 간결하고 읽기 쉬우므로, “일반적인 경우를 위한 간단한 오버로드”와 “모든 경우를 포괄하는 일반화된 템플릿”을 함께 제공하는 것이 실무에서 API를 설계하는 흔한 방식입니다.
#include <vector>
#include <string>
// 정수 검색
int find(const std::vector<int>& vec, int target) {
for (size_t i = 0; i < vec.size(); i++) {
if (vec[i] == target) {
return i;
}
}
return -1;
}
// 문자열 검색
int find(const std::vector<std::string>& vec, const std::string& target) {
for (size_t i = 0; i < vec.size(); i++) {
if (vec[i] == target) {
return i;
}
}
return -1;
}
// 조건 검색
template<typename T, typename Predicate>
int find(const std::vector<T>& vec, Predicate pred) {
for (size_t i = 0; i < vec.size(); i++) {
if (pred(vec[i])) {
return i;
}
}
return -1;
}
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5};
std::cout << find(numbers, 3) << std::endl; // 2
std::vector<std::string> words = {"apple", "banana", "cherry"};
std::cout << find(words, std::string("banana")) << std::endl; // 1
std::cout << find(numbers, [](int x) { return x > 3; }) << std::endl; // 3
}
const 오버로딩
operator[]를 const/non-const 두 버전으로 나누는 것은 표준 라이브러리 컨테이너(std::vector, std::map 등) 전반에서 반복되는 관용구입니다 — non-const 객체를 통해 접근할 때는 그 원소를 수정할 수 있어야 하므로 int&(수정 가능한 참조)를 반환하고, const Array&처럼 상수 객체를 통해 접근할 때는 그 원소를 읽기만 할 수 있어야 하므로 const int&를 반환합니다. 이 두 버전이 별도의 오버로드로 존재하는 이유는 컴파일러가 arr[0] = 10처럼 대입 가능한 문맥에서 실수로 상수 객체의 원소를 수정하려는 시도를 컴파일 타임에 차단하기 위함입니다 — const Array&를 통해서는 애초에 non-const operator[]가 오버로드 후보에 들어오지 않으므로, constArr[0] = 10처럼 상수를 통해 값을 바꾸려는 코드는 컴파일 자체가 되지 않습니다.
class Array {
private:
int data[10];
public:
// non-const 버전
int& operator[](size_t index) {
return data[index];
}
// const 버전
const int& operator[](size_t index) const {
return data[index];
}
};
int main() {
Array arr;
arr[0] = 10; // non-const 버전
const Array& constArr = arr;
int x = constArr[0]; // const 버전
}
자주 발생하는 문제
문제 1: 모호한 호출
long은 int(정수 변환)로도, double(부동소수점-정수 변환)로도 변환될 수 있는데, 표준의 오버로드 해석 규칙은 두 경로를 모두 같은 “변환(Conversion)” 등급으로 취급합니다. 어느 쪽도 다른 쪽보다 명백히 “더 나은” 변환이 아니므로, 컴파일러는 어느 오버로드를 골라야 할지 결정할 근거가 없어 컴파일 에러(모호성)로 알려줍니다. 흔히 float을 넘기는 경우도 모호하다고 오해하는데, float → double은 표준이 부동소수점 승격(promotion) 으로 분류해 float → int 변환보다 등급이 높으므로 func(10.0f)는 모호하지 않고 func(double)이 선택됩니다. 이는 컴파일러의 결함이 아니라 오히려 안전장치입니다 — 만약 컴파일러가 임의로 하나를 골라 조용히 컴파일해 버렸다면, 개발자가 의도하지 않은 오버로드가 선택되어도 알아챌 방법이 없었을 것입니다. static_cast로 원하는 타입을 명시하면 그 변환 자체가 “정확히 일치”로 격상되어 모호성이 사라집니다.
void func(int x) {
std::cout << "int" << std::endl;
}
void func(double x) {
std::cout << "double" << std::endl;
}
int main() {
func(10); // int
func(3.14); // double
func(10.0f); // double (float -> double은 승격이라 더 나은 후보)
// func(10L); // 모호함! long -> int? long -> double? (둘 다 변환 등급)
func(static_cast<int>(10L)); // int
func(static_cast<double>(10L)); // double
}
문제 2: 기본 인자와 충돌
func(int x, int y = 0)의 기본 인자는 “이 함수는 인자 하나만 넘겨도 호출할 수 있다”는 뜻이므로, 결과적으로 func(int x)와 func(int x, int y = 0)은 인자 하나로 호출되는 경우에 한해 정확히 같은 형태(func(int))로 겹쳐 보이게 됩니다. func(10)을 호출하면 컴파일러는 이 둘 중 어느 것을 의도했는지 판단할 방법이 없어 모호성 에러를 냅니다 — 기본 인자는 오버로드 해석에 참여하기 전에 이미 “이 함수가 몇 개의 인자로 호출될 수 있는가”를 결정해버리므로, 오버로딩과 기본 인자를 함께 쓸 때는 그 결과로 생기는 호출 형태들이 서로 겹치지 않는지 항상 미리 확인해야 합니다. 겹칠 위험이 있다면 아예 서로 다른 이름의 함수로 분리하는 편이 모호성 문제 자체를 원천 차단합니다.
// ❌ 모호함
void func(int x) {
std::cout << "1개 인자" << std::endl;
}
void func(int x, int y = 0) {
std::cout << "2개 인자" << std::endl;
}
int main() {
// func(10); // 에러: 모호함
func(10, 20); // OK
}
// ✅ 다른 이름 사용
void func1(int x);
void func2(int x, int y = 0);
문제 3: 포인터 vs 배열
함수 매개변수 자리에 쓰인 배열 타입(int arr[])은 컴파일러에 의해 항상 포인터 타입(int*)으로 조용히 변환(“배열의 포인터로의 붕괴”, array-to-pointer decay)됩니다 — 이는 배열 자체를 함수 인자로 값 전달하는 것이 불가능하기 때문에 C에서부터 이어져 온 규칙입니다. 그 결과 func(int* ptr)와 func(int arr[])는 소스 코드에서는 다르게 보여도 컴파일러가 실제로 처리하는 함수 시그니처는 완전히 동일하며, 이는 오버로딩이 아니라 같은 함수를 두 번 정의하려는 시도로 취급되어 재정의 에러가 됩니다. 이 규칙을 알면 함수 매개변수에서 int arr[]와 int* arr를 같은 것으로 여기고 어느 표기를 쓸지는 순전히 가독성 선호의 문제로 접근할 수 있습니다 — 참조로 배열을 받는 int (&arr)[5]처럼 크기를 고정하는 문법을 쓰면 이 붕괴를 피하고 실제 배열 오버로딩을 만들 수 있지만, 이는 훨씬 드물게 쓰이는 고급 기법입니다.
void func(int* ptr) {
std::cout << "포인터" << std::endl;
}
void func(int arr[]) {
std::cout << "배열" << std::endl;
}
// 에러: 같은 시그니처 (배열은 포인터로 decay)
문제 4: 반환 타입
이는 이 글 서두의 “오버로딩 규칙”에서 이미 다룬 제약이 실제 컴파일 에러로 나타나는 지점입니다 — 매개변수가 완전히 동일하다면(getValue()처럼 인자가 아예 없는 경우 포함) 반환 타입만으로는 오버로드 해석의 근거가 될 수 없으므로 애초에 컴파일러가 이를 서로 다른 함수로 인정하지 않습니다. getIntValue()/getDoubleValue()처럼 이름 자체에 반환 타입을 반영해 구분하는 것이 실무에서 가장 흔한 회피책이며, 만약 호출 문맥(대입 받는 변수의 타입 등)에 따라 자동으로 적절한 타입을 반환하고 싶다면 함수 템플릿에 반환 타입을 명시적 템플릿 인자로 지정하는 방식(getValue<double>())을 대신 검토할 수 있습니다.
// ❌ 반환 타입만 다름 (오버로딩 불가)
int getValue() {
return 42;
}
// double getValue() { // 에러
// return 3.14;
// }
// ✅ 다른 이름 사용
int getIntValue() {
return 42;
}
double getDoubleValue() {
return 3.14;
}
오버로드 해결 규칙
컴파일러가 오버로드를 고를 때는 표준이 정의한 변환 등급을 순서대로 적용해 “가장 적은 변환으로 맞는” 후보를 찾습니다 — 정확히 일치(변환 없음)가 가장 우선이고, 정수 승격(char/short → int처럼 정보 손실 없이 더 큰 정수 타입으로)이 그 다음, 그리고 사용자 정의 변환(암시적 생성자를 통한 변환)이 가장 나중입니다. func(c)가 func(int)로 가는 이유는 char에서 int로의 승격이 표준이 “저렴한” 변환으로 분류하는 축에 속하기 때문이며, func("hello")가 string 오버로드로 가는 것은 const char*에서 std::string으로의 변환이 string의 암시적 생성자를 거치는 사용자 정의 변환이기 때문입니다 — const char*는 int나 double로 바뀔 방법이 없으므로, 사용자 정의 변환이 필요한 string 오버로드가 유일한 후보가 됩니다. 이 순서를 알면 “왜 이 인자가 저 오버로드로 가는가”를 코드만 보고도 예측할 수 있습니다.
이 등급 순서 때문에 생기는 유명한 함정이 있습니다. 여기에 void func(bool b) 오버로드를 하나 추가하면 func("hello")는 bool 버전으로 갑니다. 포인터에서 bool로의 변환은 표준 변환이라 사용자 정의 변환(std::string 생성자)보다 등급이 높기 때문입니다. 컴파일 에러도 경고도 없이 문자열이 true로 처리되는 버그라서, 로깅 함수에 log(bool)과 log(const std::string&)를 함께 두었다가 문자열 리터럴 로그가 전부 “true”로 찍히는 식으로 나타납니다. const char* 오버로드를 따로 두거나, C++17의 std::string_view를 받게 하거나, void func(const char*) = delete;로 의도를 명시하면 막을 수 있습니다.
오버로드 해석보다 먼저 일어나는 이름 찾기(name lookup)도 알아 둬야 합니다. 컴파일러는 가장 안쪽 스코프에서 그 이름을 찾으면 바깥 스코프는 더 보지 않습니다. 그래서 파생 클래스에서 func(double) 하나만 정의해도 기반 클래스의 func(int), func(const std::string&)가 모두 가려져 derived.func("hi")가 컴파일되지 않습니다. 파생 클래스에 using Base::func;를 넣어 기반 클래스의 오버로드들을 다시 후보로 끌어와야 합니다.
void func(int x) { std::cout << "int" << std::endl; }
void func(double x) { std::cout << "double" << std::endl; }
void func(const std::string& s) { std::cout << "string" << std::endl; }
int main() {
func(10); // 정확히 일치: int
func(3.14); // 정확히 일치: double
func("hello"); // 변환: const char* -> string
char c = 'A';
func(c); // 승격: char -> int
short s = 10;
func(s); // 승격: short -> int
}
FAQ
Q1: 오버로딩과 템플릿 중 무엇을 써야 하나요?
A: 타입마다 구현이 달라야 하면 오버로딩, 같은 로직을 여러 타입에 적용하려면 템플릿이 자연스럽습니다. 둘을 섞을 때는 비템플릿 함수가 같은 조건이면 템플릿보다 우선한다는 규칙을 기억하세요. 템플릿이 “모든 타입”을 받아 버려서 의도한 비템플릿 오버로드가 무시되는 경우(예: template<class T> void f(T&&)가 f(const std::string&)보다 비const 인자에 더 잘 맞는 경우)가 흔하므로, C++20 requires로 템플릿의 적용 범위를 좁히는 편이 안전합니다.
Q2: 오버로딩은 성능에 영향을 주나요?
A: 어떤 함수를 부를지는 컴파일 타임에 결정되므로 호출 비용은 일반 함수와 같습니다. 다만 의도하지 않은 오버로드가 선택되면 불필요한 임시 객체가 생길 수 있습니다. 예를 들어 const std::string& 오버로드에 문자열 리터럴을 넘기면 호출마다 std::string 임시 객체가 만들어지므로, 자주 호출되는 함수라면 std::string_view를 받는 편이 낫습니다.
Q3: 어떤 오버로드가 선택되는지 확인하는 방법이 있나요?
A: 모호하거나 후보가 없으면 컴파일러가 후보 목록과 각각이 탈락한 이유(candidate function not viable: no known conversion from ...)를 출력해 주므로, 에러 메시지의 “candidate” 줄을 읽는 것이 가장 빠릅니다. 컴파일은 되는데 의심스러울 때는 각 오버로드에 static_assert(false) 대신 일시적으로 = delete를 붙여 보거나, 디버거에서 해당 호출로 한 단계 들어가 보면 확실합니다.
Q4: 오버로드와 기본 인자 중 무엇을 쓰나요?
A: 동작은 같고 값만 선택적으로 바꾸고 싶다면 기본 인자가 간단합니다. 인자 유무에 따라 동작 자체가 달라진다면 오버로드가 명확합니다. 둘을 같은 이름에 섞으면 본문의 “기본 인자와 충돌”처럼 호출 형태가 겹치기 쉽고, 기본 인자는 선언에 붙어 있어 헤더를 바꾸면 모든 호출부를 다시 컴파일해야 한다는 점도 고려 대상입니다.