Java 21

메서드 오버로딩과 반환의 비밀

같은 이름을 다시 쓰는 것이 왜 허용되는지, 반환형만 다르면 되는지, return은 언제 필요한지 헷갈린다.

근거 · 교안 p142-p146

메서드 오버로딩과 반환의 비밀 대표 이미지

1장: 같은 이름, 다른 일? 오버로딩의 진짜 조건

1. 같은 이름, 다른 일? 오버로딩의 진짜 조건

솔라는 노트북 화면 한쪽에 띄워놓은 예제 코드를 보며 미간을 찌푸렸다. 화면에는 System.out.println()이라는 익숙한 코드가 여러 줄 나열되어 있었다. 그런데 어딘가 이상했다. 어떤 줄은 괄호 안에 문자열을 넣고, 어떤 줄은 숫자를, 또 다른 줄은 아예 아무것도 넣지 않았다.

“언니, 이거 좀 이상하지 않아?”

솔라의 부름에 옆에서 책을 읽던 루나가 고개를 돌렸다.

“계속 봤던 코드인데, 오늘따라 이상하게 보여. println이라는 메서드 이름이 여기저기서 똑같이 쓰이잖아. 우리는 변수 이름도 한 공간 안에서 똑같이 만들면 안 된다고 배웠는데, 왜 메서드는 괜찮은 거지? 컴퓨터가 헷갈리지 않나?”

솔라는 마치 코드 속의 비밀을 처음 발견한 사람처럼 흥분과 의심이 뒤섞인 목소리로 말했다. 분명히 ‘이름’은 대상을 유일하게 식별하기 위한 것이라고 생각했는데, 눈앞의 코드는 그 믿음을 정면으로 반박하고 있었다.

루나는 솔라가 가리키는 화면을 잠시 들여다보더니, 자신의 노트북을 열어 빈 코드 편집기를 띄웠다.

“솔라 네 말대로라면, 컴퓨터가 저걸 보고 누구를 불러야 할지 몰라 혼란에 빠져야겠네. 그런데도 프로그램은 아주 잘 돌아가고 있지.”

“그러니까! 왜 그런 건지 모르겠어. 분명 같은 이름인데.”

“그럼 우리가 직접 판을 짜서 컴퓨터를 한번 떠볼까? 컴퓨터가 정말 이름만으로 판단하는지, 아니면 다른 뭔가를 더 보는지.”

루나는 Greeter라는 이름의 클래스를 만들었다. 그리고 그 안에 코드를 한 줄 입력했다.

class Greeter {
    void greet() {
        System.out.println("안녕하세요!");
    }
}

“자, greet라는 이름의 인사하는 메서드. 이제 이 아래에 똑같은 이름으로 메서드를 하나 더 만들어 봐. 대신 이번엔 손님의 이름을 받아서 인사하는 거야.”

솔라는 의심스러운 눈초리로 루나를 보다가, 키보드 위로 손을 가져갔다. 똑같은 이름의 메서드를 또 만들면 당장이라도 빨간 줄이 그어지며 오류가 날 것만 같았다. 솔라는 조심스럽게 코드를 추가했다.

class Greeter {
    void greet() {
        System.out.println("안녕하세요!");
    }

    void greet(String name) {
        System.out.println("안녕하세요, " + name + "님!");
    }
}

그런데 아무 일도 일어나지 않았다. 편집기는 조용했다.

“어라? 오류가 안 나네.”

“하나만 더 해보자. 이번엔 이름과 함께 몇 번째 방문인지도 알려주는 거야.”

솔라는 이제 좀 더 대담하게 키보드를 두드렸다.

class Greeter {
    void greet() {
        System.out.println("안녕하세요!");
    }

    void greet(String name) {
        System.out.println("안녕하세요, " + name + "님!");
    }

    void greet(String name, int visitCount) {
        System.out.println(name + "님, " + visitCount + "번째 방문을 환영합니다!");
    }
}

결과는 마찬가지였다. 같은 greet라는 이름을 가진 메서드가 세 개나 생겼지만 코드는 평온했다.

“이제 이 세 친구를 각각 불러보자. 컴퓨터가 어떻게 반응하는지 봐.”

솔라는 main 메서드 안에서 세 종류의 greet를 차례로 호출했다.

public static void main(String[] args) {
    Greeter greeter = new Greeter();

    greeter.greet();
    greeter.greet("솔라");
    greeter.greet("솔라", 3);
}

실행 버튼을 누르자, 콘솔 창에 거짓말처럼 세 가지 다른 인사말이 순서대로 찍혔다.

안녕하세요!
안녕하세요, 솔라님!
솔라님, 3번째 방문을 환영합니다!

솔라는 잠시 콘솔 창과 코드를 번갈아 보았다. “와… 된다. 호출할 때 내가 괄호 안에 뭘 넣어주느냐에 따라서 자기가 알아서 맞는 걸 찾아가네.”

마치 세 명의 동명이인이 있는데, 한 명은 그냥 부르고, 다른 한 명은 성을 붙여 부르고, 마지막 한 명은 직함까지 붙여 부르면 각자 알아듣는 것과 비슷했다. 컴퓨터는 단순히 greet라는 이름만 듣고 움직이는 게 아니었다.

“맞아. 컴퓨터는 메서드를 찾을 때 이름만 보지 않아.”

루나가 솔라의 발견을 정리해주었다.

“컴퓨터에게 메서드의 신분증은 ‘이름’과 ‘괄호 안에 들어오는 재료 목록’을 합친 거야. 이 재료 목록, 즉 매개변수의 개수, 타입, 그리고 순서까지 전부 포함해서 말이지. 프로그래밍 세계에서는 이 고유한 조합을 **메서드 시그니처(method signature)**라고 불러.”

“메서드 시그니처…” 솔라는 새로운 단어를 입안에서 굴려 보았다. “그럼 이름이 같아도 시그니처가 다르면, 그냥 완전히 다른 메서드라고 생각하는 거구나.”

“정확해. 그래서 교재에 나온 ‘메서드 오버로딩은 같은 이름의 메서드를 매개변수 차이로 여러 개 정의하는 것’이라는 문장의 진짜 의미는, ‘시그니처를 다르게 해서 같은 이름의 메서드를 여러 개 만드는 것’이었던 거지.”

이제야 println의 비밀이 풀리는 기분이었다. println(String s), println(int i), println()은 이름은 같지만 시그니처가 모두 다르기 때문에 공존할 수 있었던 것이다. 컴퓨터는 호출하는 쪽에서 넘겨주는 값의 모양을 보고 가장 알맞은 시그니처를 가진 메서드를 골라 실행했을 뿐이다.

그때 솔라의 머릿속에 새로운 질문이 떠올랐다. 오버로딩의 규칙을 조금 더 시험해보고 싶은 장난기가 발동했다.

“언니, 그러면 매개변수는 똑같이 두고, 메서드가 일을 끝내고 돌려주는 값, 그러니까 반환형만 다르게 하는 건 어때? 그것도 시그니처의 일부로 쳐주지 않을까? 예를 들어 하나는 그냥 인사만 하고(void), 다른 하나는 인사말을 문자열(String)로 만들어서 돌려주는 거지. 이것도 분명 다른 거니까, 오버로딩이 되지 않을까?“

2장: 반환형은 왜 오버로딩의 기준이 될 수 없을까?

솔라의 질문은 단순한 호기심이 아니었다. 그것은 방금 배운 ‘시그니처’라는 규칙의 경계를 시험해보려는 구체적인 가설이었다. 루나는 말없이 고개를 끄덕이며, 솔라가 직접 그 가설을 증명할 수 있도록 노트북 화면을 향해 턱짓했다.

솔라는 다시 Greeter 클래스로 돌아갔다. 매개변수가 없는 greet() 메서드는 이미 존재했다. 솔라의 가설대로라면, 매개변수는 똑같이 ()로 비워두고 반환형만 다른 메서드를 추가할 수 있어야 했다. 그녀는 기존의 void greet() 메서드 아래에 새로운 코드를 자신 있게 입력했다. 하나는 그냥 인사하고 끝나는 메서드, 다른 하나는 인사말을 문자열로 돌려주는 메서드. 분명히 하는 일이 달랐다.

class Greeter {
    void greet() {
        System.out.println("안녕하세요!");
    }

    // ... 다른 오버로딩된 greet 메서드들

    String greet() { // 반환형만 String으로 변경
        return "안녕하세요!";
    }
}

엔터 키를 누르는 순간, 솔라의 예상과 달리 코드 편집기는 즉시 경고 신호를 보냈다. String greet()라는 새 메서드 이름 아래에 붉은 밑줄이 그어졌다. 마우스를 가져가자 차가운 오류 메시지가 나타났다.

'greet()' is already defined in 'Greeter'

솔라는 눈을 비볐다. “말도 안 돼. 하나는 void고 이건 String인데! 엄연히 다르잖아! 왜 똑같다고 하는 거야?”

분명히 하나는 반환값이 없고, 다른 하나는 문자열을 반환했다. 솔라의 눈에는 명백한 차이점이었지만, 컴퓨터는 둘을 구별하지 못하고 같은 메서드가 이미 정의되어 있다고 불평하고 있었다. 방금 전 매개변수를 바꿨을 때는 너그럽게 받아들여 주던 컴퓨터가 이번에는 완고하게 고개를 저었다.

“컴퓨터가 깐깐하게 구네.” 솔라가 불퉁하게 말했다.

“컴퓨터의 입장이 돼서 생각해볼까?” 루나가 조용히 말했다. “솔라, 네가 지금 만든 두 메서드를 호출하는 코드를 main에 작성해 봐. 어떻게 부를 거지?”

“어떻게 부르긴, 그냥 greeter.greet()라고 부르면 되지.” 솔라는 대수롭지 않게 답하며 main 메서드로 이동했다.

“자, 여기. greeter.greet(); 이렇게.” 솔라가 코드를 타이핑하며 말했다.

루나는 그 코드를 가리키며 물었다. “바로 그 지점이야. greeter.greet(); 이 한 줄만 보고, 컴퓨터는 우리가 방금 만든 두 개의 greet() 메서드 중에 어떤 것을 실행해야 할지 알 수 있을까? 화면에 그냥 인사말을 출력하는 void greet()? 아니면 문자열 ‘안녕하세요!’를 반환하는 String greet()?”

솔라는 잠시 말문이 막혔다. greeter.greet()라는 호출 코드만 봐서는, 호출하는 사람이 반환값을 받아서 사용하려는지, 아니면 그냥 무시할 것인지 전혀 알 수 없었다. 값을 반환하는 메서드를 호출하고 그 반환값을 사용하지 않는 것은 문법적으로 아무런 문제가 없기 때문이다.

“아…”

탄식이 흘러나왔다.

“호출하는 코드 모양이… 완전히 똑같구나. 매개변수가 없으니까 둘 다 greet()이고. 컴퓨터 입장에서는 내가 반환값을 받을지 안 받을지 미리 알 수가 없으니, 누구를 불러야 할지 결정할 수가 없는 거네.”

“바로 그거야.” 루나가 확인해주었다. “컴퓨터는 메서드를 호출하는 그 순간, 오직 메서드 이름과 괄호 안에 전달된 인자들의 모양, 즉 시그니처만을 보고 어떤 메서드를 실행할지 결정해. 반환형은 그 결정이 끝난 후에 메서드가 호출자에게 돌려주는 ‘결과물’에 대한 약속일 뿐, 호출 시점에서 메서드를 구별하는 ‘주소’의 일부가 아니야.”

솔라는 붉은 밑줄이 그어진 자신의 코드를 다시 보았다. 이제 그 오류 메시지가 다르게 읽혔다. 그것은 멍청한 컴퓨터의 불평이 아니라, 미래에 발생할 혼란을 미리 막아주는 현명한 경고였다. ‘너, 이렇게 만들면 나중에 내가 누굴 불러야 할지 모르겠어!’라고 외치는 컴파일러의 목소리가 들리는 듯했다.

“그렇구나. 시그니처라는 신분증에는 이름과 매개변수 목록만 적혀있고, 반환형은 적혀있지 않은 거였어.”

솔라는 자신이 추가했던 String greet() 메서드를 지웠다. 붉은 밑줄이 사라지자 코드에 다시 평화가 찾아왔다. 오버로딩의 규칙이 한층 더 명확해지는 순간이었다. 이름이 같아도 매개변수가 다르면 허용. 하지만 매개변수까지 같다면, 반환형이 다르더라도 절대 허용되지 않는다.

그때 솔라의 머릿속에 또 다른 의문이 고개를 들었다. 반환형이 메서드를 구별하는 기준은 아니지만, 분명히 메서드의 중요한 일부였다.

“언니, 그럼 만약 메서드가 값을 돌려주기로 약속했으면, 예를 들어 String을 반환하기로 했으면, 무조건 return을 써서 값을 돌려줘야만 하는 거야? 그 약속을 어기면 어떻게 되는데?“

3장: 메서드의 ‘반환 계약’: return은 왜 필수인가?

솔라의 질문에 답하듯, 루나는 키보드 위에서 손을 움직였다. 잠시 후, Greeter 클래스의 코드에 변화가 생겼다. 원래 반환형이 없던(void) 메서드 하나가 수정되어 있었다.

// ... 이전 코드

void greet(String name) {
    System.out.println("안녕하세요, " + name + "님!");
}

String greet(String name, int visitCount) { // void에서 String으로 변경
    System.out.println(name + "님, " + visitCount + "번째 방문을 환영합니다!");
}

루나가 반환형을 void에서 String으로 바꾸자마자, greet(String name, int visitCount) 메서드의 마지막 중괄호 }에 빨간 밑줄이 그어졌다. 솔라의 질문, 즉 메서드가 약속을 어기면 어떻게 되는지에 대한 답이 눈앞의 코드를 통해 즉각적인 실험 결과로 나타난 것이다. 화면에는 새로운 오류 메시지가 떠 있었다.

missing return statement

“리턴문이 없다고 하네.” 솔라가 중얼거렸다. “분명히 System.out.println으로 환영 메시지를 화면에 보여주는 일은 하고 있는데. 이걸로는 안 되는 건가?”

“그건 메서드가 외부 세상(콘솔)에 말을 거는 행위일 뿐이야.” 루나가 조용히 설명했다. “하지만 이 메서드를 호출한 쪽에게는 아무것도 돌려주지 않고 있어. 메서드 선언부에 String이라고 적는 순간, ‘나는 이 일을 끝내고 반드시 문자열 하나를 들고 돌아가겠습니다’라고 계약서에 도장을 찍은 것과 같아.”

“계약서…”

“응. 그런데 지금 이 메서드는 일을 끝내고 빈손으로 돌아가려 하고 있어. 약속을 어긴 거지. 그래서 컴파일러가 ‘계약 위반이야. 문자열 돌려주기로 했잖아’하고 막아서는 거고.”

솔라는 그제야 이해했다. System.out.println은 메서드 안에서 벌어지는 쇼일 뿐, 메서드를 호출한 주체에게 전달되는 결과물이 아니었다. 계약은 반드시 이행되어야 했다. 솔라는 오류를 해결하기 위해 코드의 마지막 줄에 return문을 추가하기로 했다.

“알았어. 계약을 지키면 될 거 아니야. 그럼… 환영 메시지를 만들어서 돌려주면 되겠지.”

솔라는 코드를 수정했다. 그러나 이번에는 엉뚱한 장난기가 발동했다. 방문 횟수를 나타내는 숫자를 그대로 돌려주면 어떨까?

String greet(String name, int visitCount) {
    System.out.println(name + "님, " + visitCount + "번째 방문을 환영합니다!");
    return visitCount; // 문자열(String) 대신 정수(int)를 반환
}

엔터를 누르자마자, return visitCount; 코드에 다시 붉은 밑줄이 생겼다. 이번에는 다른 오류 메시지가 나타났다.

incompatible types: int cannot be converted to String

“이런. 이번엔 타입이 안 맞는다고 하네.” 솔라가 멋쩍게 웃었다. “문자열을 주기로 계약서에 써놓고, 엉뚱하게 숫자 상자를 내민 셈이구나.”

“계약 내용은 아주 구체적이니까.” 루나가 미소 지으며 말했다. “어떤 ‘종류’의 값을 돌려줄지까지 명시되어 있어. 컴파일러는 아주 깐깐한 계약 관리인이라서, 약속을 어기는 걸 절대로 그냥 넘어가 주지 않아.”

솔라는 두 번의 실패를 통해 return의 규칙을 확실히 깨달았다. 반환형이 void가 아닌 메서드는, 첫째, 반드시 return 문을 가져야만 한다. 둘째, return 되는 값은 메서드가 약속한 반환형과 정확히 일치해야만 한다. 이 두 가지는 선택이 아닌 필수였다.

솔라는 코드를 최종적으로 올바르게 수정했다.

String greet(String name, int visitCount) {
    String message = name + "님, " + visitCount + "번째 방문을 환영합니다!";
    System.out.println(message);
    return message;
}

붉은 밑줄이 사라지고 코드는 다시 평온을 되찾았다. 이제 이 메서드는 화면에 메시지를 출력하는 일도 하고, 동시에 호출한 곳에 그 메시지 문자열을 돌려주는 계약도 완벽하게 지키게 되었다.

이제 오버로딩과 반환 규칙에 대한 궁금증이 모두 풀린 기분이었다. 이름이 같아도 매개변수가 다르면 다른 메서드. 반환형은 메서드를 구별하는 기준이 될 수 없음. 그리고 반환형을 약속했다면, 그 계약은 return으로 반드시 지켜야 함.

솔라는 지금까지 만들었던 Greeter 클래스를 쭉 훑어보았다. 이제 그녀는 이 코드들이 어떤 원리로 동작하는지 설명할 수 있었다.

“언니, 그럼 만약에… 인사말을 화면에 출력하지는 않고, 그냥 인사말 문자열만 만들어서 나중에 쓸 수 있게 준비만 해두는 메서드가 필요하다면 어떻게 만들면 될까?”

스스로 질문을 던진 솔라는 잠시 생각하더니, 망설임 없이 새로운 메서드를 추가하기 시작했다. 그녀는 이제 무엇을 해야 할지 정확히 알고 있었다.

class Greeter {
    // ... 기존 greet 메서드들 ...

    String prepareGreeting(String name) {
        return "만나서 반갑습니다, " + name + "님.";
    }
}

“이렇게 만들면 돼.” 솔라가 완성된 코드를 보며 만족스럽게 말했다. “이 prepareGreeting 메서드는 String을 돌려주기로 계약했고, 다른 일은 하지 않고 인사말 문자열만 만들어서 return하고 있어. 이제 이걸 호출하는 쪽에서 이 메시지를 가지고 화면에 출력하든, 파일에 저장하든 마음대로 할 수 있겠지. 이 메서드는 자기 계약만 깔끔하게 지키는 거야.”

더 이상 컴파일러의 붉은 경고 메시지가 두렵지 않았다. 솔라에게 그것은 이제 방해물이 아니라, 자신이 맺은 ‘계약’을 잘 지키고 있는지 확인해주는 든든한 조력자처럼 느껴졌다. 유연하면서도 견고한 코드를 만드는 첫걸음은, 이 작은 약속들을 이해하고 지키는 것에서부터 시작됨을 깨닫는 순간이었다.