자바 메서드 - 자바는 항상 값 전달이다 오버로딩과 가변인자 (자바 초급 5단원)
이 단원에서 배우는 것
4단원의 마지막 예제는 main 하나에 반복문 세 개와 조건문이 통째로 들어 있었다. 연체료 정책이 한 줄 바뀌면 그 덩어리를 헤집어야 하고, 같은 계산을 다른 곳에서 또 쓰려면 복사해야 한다. 이번 단원은 그 계산을 이름 붙은 메서드로 떼어낸다.
그리고 메서드를 배우면 반드시 마주치는 질문이 있다. "메서드 안에서 값을 바꿨는데 왜 밖에 반영이 안 되지?" 이 질문의 답이 이 단원의 핵심이다. 4단원에서 본 배열 대입은 주소만 복사된다는 사실이 여기서 다시 쓰인다.
- 메서드를 선언하고 반환값을 돌려받으며,
void와 값을 반환하는 메서드를 구분한다. - 자바의 매개변수 전달이 언제나 값 복사임을 이해하고, 기본형과 참조형에서 결과가 왜 달라 보이는지 설명한다.
- 오버로딩과 가변인자를 쓰고, 둘이 부딪히는 지점을 피한다.
왜 필요한가
같은 계산을 두 군데에 쓰는 순간 무너진다
연체료 계산은 이미 두 곳에서 필요하다. 목록을 출력할 때 한 번, 합계를 낼 때 한 번. 여기에 "영수증 출력"이 추가되면 세 번이다. 붙여넣기로 늘려 놓으면 정책이 바뀌었을 때 세 군데를 다 고쳐야 하고, 반드시 한 군데를 빠뜨린다. 실무에서 나오는 버그의 상당수가 이 모양이다.
메서드는 그 계산에 이름을 준다. 이름이 생기면 세 가지가 따라온다. (가) 고칠 곳이 한 군데로 줄고, (나) 호출부가 "어떻게 계산하는지"를 몰라도 되며, (다) 3단원에서 라벨을 쓸 뻔했던 중첩 반복을 안쪽만 메서드로 빼서 평평하게 만들 수 있다.
그리고 전달 방식을 모르면 디버깅이 안 된다
아래 두 메서드는 겉모습이 거의 같은데 결과가 정반대다. 이유를 설명하지 못하면, 언젠가 "분명히 값을 바꿨는데" 앞에서 몇 시간을 쓴다.
static void tryReset(int days) { days = 0; }
static void resetAll(int[] days) { days[0] = 0; }
문법과 예제
메서드 선언과 호출
메서드는 [접근제어] [static] 반환타입 이름(매개변수) { ... } 형태다. 접근제어는 8단원에서, static 은 6단원에서 제대로 다룬다. 지금은 main 이 static 이므로 main 에서 바로 부르려면 같이 static 을 붙인다고만 알아 둔다.
public class LateFee {
static int calcLateFee(int overdueDays) {
if (overdueDays <= 0) return 0;
if (overdueDays <= 7) return overdueDays * 100;
return 700 + (overdueDays - 7) * 200;
}
static void printReceipt(String title, int fee) {
System.out.println(title + " : " + fee + "원");
}
public static void main(String[] args) {
System.out.println(calcLateFee(0)); // 0
System.out.println(calcLateFee(5)); // 500
System.out.println(calcLateFee(12)); // 1700
printReceipt("클린 코드", calcLateFee(3)); // 클린 코드 : 300원
}
}
3단원의 if 사슬이 return 세 개로 납작해졌다. return 은 값을 돌려주는 동시에 메서드를 즉시 끝낸다. 그래서 else 가 필요 없다. 이 형태를 조기 반환이라고 부르고, 조건이 늘어날수록 if-else 중첩보다 읽기 쉽다.
void 는 돌려줄 값이 없다는 뜻이다. void 메서드 안에서도 return; 만 써서 중간에 빠져나올 수 있다.
4단원 코드를 메서드로 정리하기
public class LoanReport {
static int calcLateFee(int overdueDays) {
if (overdueDays <= 0) return 0;
if (overdueDays <= 7) return overdueDays * 100;
return 700 + (overdueDays - 7) * 200;
}
static int sumFees(int[] fees) {
int total = 0;
for (int fee : fees) {
total += fee;
}
return total;
}
public static void main(String[] args) {
String[] titles = {"자바의 정석", "이펙티브 자바", "클린 코드"};
int[] overdueDays = {0, 12, 3};
int[] fees = new int[titles.length];
for (int i = 0; i < titles.length; i++) {
fees[i] = calcLateFee(overdueDays[i]);
System.out.println(titles[i] + " : " + fees[i] + "원");
}
System.out.println("합계 : " + sumFees(fees) + "원");
}
}
main 이 "무엇을 하는지"만 남고 "어떻게 하는지"가 사라졌다. 이게 메서드로 나누는 목적이다.
자바는 항상 값 전달이다
자바에는 참조 전달(call by reference)이 없다. 매개변수에는 언제나 인자의 값을 복사한 것이 들어간다. 다만 2단원에서 봤듯 참조형 변수에 들어 있는 값은 주소다. 그래서 주소가 복사되고, 복사된 주소가 가리키는 곳은 원본과 같은 객체다.
import java.util.Arrays;
public class PassByValue {
static void tryReset(int days) {
days = 0; // 복사본만 바뀐다
}
static void resetAll(int[] days) {
for (int i = 0; i < days.length; i++) {
days[i] = 0; // 복사된 주소를 따라가 원본을 고친다
}
}
static void replaceArray(int[] days) {
days = new int[]{9, 9, 9}; // 복사된 주소 변수만 다른 곳을 가리킨다
}
public static void main(String[] args) {
int days = 12;
tryReset(days);
System.out.println("기본형 전달 후: " + days); // 12
int[] arr1 = {1, 2, 3};
resetAll(arr1);
System.out.println("내용 변경: " + Arrays.toString(arr1)); // [0, 0, 0]
int[] arr2 = {1, 2, 3};
replaceArray(arr2);
System.out.println("참조 재대입: " + Arrays.toString(arr2)); // [1, 2, 3]
}
}
기본형 전달 후: 12
내용 변경: [0, 0, 0]
참조 재대입: [1, 2, 3]
세 번째 결과가 결정적이다. 만약 자바가 참조 전달이라면 replaceArray 안에서 새 배열을 대입했을 때 밖의 arr2 도 새 배열을 가리켜야 한다. 그런데 그대로다. 메서드가 받은 것은 원본 변수가 아니라 주소의 사본이기 때문이다.
한 문장으로 정리하면 이렇다. 메서드 안에서 객체의 내용을 고치면 밖에 반영되고, 매개변수 변수 자체에 다른 것을 대입하면 반영되지 않는다.
따라서 자바에서는 두 값을 맞바꾸는 swap(a, b) 메서드를 기본형으로 만들 수 없다. 배열이나 객체에 담아 넘기거나, 값을 반환받아 대입하는 수밖에 없다.
오버로딩
같은 이름의 메서드를 매개변수의 개수나 타입이 다르면 여러 개 둘 수 있다. 하는 일이 사실상 같은데 입력만 다를 때 이름을 통일하기 위한 장치다.
public class Overloading {
static int calcLateFee(int overdueDays) {
if (overdueDays <= 0) return 0;
if (overdueDays <= 7) return overdueDays * 100;
return 700 + (overdueDays - 7) * 200;
}
// 감면율을 받는 버전
static int calcLateFee(int overdueDays, double discountRate) {
int fee = calcLateFee(overdueDays);
return (int) (fee * (1 - discountRate));
}
public static void main(String[] args) {
System.out.println(calcLateFee(12)); // 1700
System.out.println(calcLateFee(12, 0.5)); // 850
}
}
인자가 적은 쪽이 많은 쪽을 부르거나 그 반대로 위임하는 형태가 좋다. 위 코드처럼 계산 로직을 한 군데에만 두면, 정책이 바뀔 때 고칠 곳도 한 군데다.
가변인자
개수가 정해지지 않은 값을 받고 싶을 때 타입... 을 쓴다. 메서드 안에서는 그냥 배열이다.
public class Varargs {
static int sumFees(int... fees) {
int total = 0;
for (int fee : fees) { // fees 는 int[] 다
total += fee;
}
return total;
}
static String report(String memberName, int... overdueDays) {
return memberName + " 건수=" + overdueDays.length
+ " 합계=" + sumFees(overdueDays);
}
public static void main(String[] args) {
System.out.println(sumFees()); // 0
System.out.println(sumFees(100, 200, 300)); // 600
System.out.println(sumFees(new int[]{1, 2})); // 3 배열도 그대로 넘어간다
System.out.println(report("김개발")); // 김개발 건수=0 합계=0
System.out.println(report("김개발", 100, 900)); // 김개발 건수=2 합계=1000
}
}
규칙은 두 개다. 가변인자는 매개변수 목록의 맨 마지막에 하나만 올 수 있다. sumFees(int... fees, String name) 은 컴파일 에러다. 어디까지가 가변인자인지 정할 수 없기 때문이다.
실무에서 자주 틀리는 것
1. 값을 바꿔 주는 메서드를 만들려 한다
가장 흔한 첫 실패다.
static void addOneDay(int days) {
days++;
}
public static void main(String[] args) {
int overdueDays = 12;
addOneDay(overdueDays);
System.out.println(overdueDays); // 12 기대와 다르다
}
고치는 방법은 반환값으로 돌려받는 것이다. "바꾼다"가 아니라 "새 값을 계산해서 준다"로 생각을 바꾼다.
static int addOneDay(int days) {
return days + 1;
}
int overdueDays = 12;
overdueDays = addOneDay(overdueDays); // 대입까지 해야 한다
System.out.println(overdueDays); // 13
반환값을 받아 놓고 대입을 빠뜨리는 것도 같은 계열의 실수다. 7단원에서 title.trim() 을 부르고 대입을 안 해서 공백이 그대로 남는 사례를 다시 본다.
2. 반환 타입만 다르게 해서 오버로딩하려 한다
오버로딩을 구분하는 기준에 반환 타입은 들어가지 않는다. 매개변수의 개수·타입·순서만 본다.
static int calcFee(int days) { return days; }
static double calcFee(int days) { return days; }
// error: method calcFee(int) is already defined
이유는 호출부만 봐서는 어느 쪽인지 정할 수 없기 때문이다. calcFee(3); 처럼 결과를 안 쓰는 호출이 있으면 컴파일러가 고를 근거가 없다. 이럴 때는 이름을 다르게 짓는다. calcFeeAsInt, calcFeeAsDouble 처럼.
3. 오버로딩 선택 규칙을 오해한다
후보가 여럿이면 컴파일러는 형변환이 가장 적게 필요한 쪽을 고른다. 그래서 char 를 넘겼는데 double 버전이 아니라 int 버전이 불린다.
public class Selection {
static String f(int a) { return "int"; }
static String f(long a) { return "long"; }
static String f(double a) { return "double"; }
public static void main(String[] args) {
System.out.println(f(10)); // int
System.out.println(f('A')); // int char -> int 가 가장 가깝다
System.out.println(f(10L)); // long
System.out.println(f(10.0f)); // double float -> double
byte b = 3;
System.out.println(f(b)); // int byte -> short -> int
}
}
실무에서 이게 사고가 되는 자리는 정해져 있다. remove(int index) 와 remove(Object o) 처럼 의미가 완전히 다른 두 메서드를 같은 이름으로 오버로딩한 API 다. 숫자를 넘기면 위치로 지우고 객체를 넘기면 값으로 지운다. 컬렉션을 배우는 중급 단원에서 이 함정을 다시 만난다. 지금 기억할 원칙은 의미가 다르면 오버로딩하지 말고 이름을 다르게 짓는다는 것이다.
4. 가변인자를 오버로딩과 섞는다
가변인자 버전과 고정 인자 버전이 같이 있으면, 둘 다 맞는 호출에서는 고정 인자 쪽이 이긴다. 가변인자는 다른 후보가 하나도 없을 때만 선택된다.
public class VarargsPriority {
static String g(int a, int b) { return "fixed"; }
static String g(int... a) { return "varargs"; }
public static void main(String[] args) {
System.out.println(g(1, 2)); // fixed
System.out.println(g(1, 2, 3)); // varargs
System.out.println(g()); // varargs
}
}
더 나쁜 조합은 아예 컴파일이 안 된다.
static void k(String s, int... a) {}
static void k(String s, int a, int... b) {}
k("x", 1);
// error: reference to k is ambiguous
// both method k(String,int...) and method k(String,int,int...) match
가변인자는 편하지만 오버로딩과 만나면 규칙이 복잡해진다. 한 이름에 가변인자 버전은 하나만 두는 것을 원칙으로 삼으면 대부분 피할 수 있다.
5. 배열을 넘겨 놓고 원본이 안전하다고 생각한다
앞에서 본 resetAll 이 그대로 실무 사고가 된다. 메서드가 받은 배열을 정렬하거나 값을 채우면 호출한 쪽 배열이 바뀐다.
import java.util.Arrays;
static int median(int[] days) {
Arrays.sort(days); // 호출자의 배열을 뒤섞는다
return days[days.length / 2];
}
static int safeMedian(int[] days) {
int[] copy = Arrays.copyOf(days, days.length); // 사본을 만들고 시작한다
Arrays.sort(copy);
return copy[copy.length / 2];
}
남이 준 배열을 고쳐야 한다면 사본부터 뜬다. 이 습관이 6단원에서 객체 필드를 다룰 때 방어적 복사라는 이름으로 다시 나온다.
스스로 확인하기
- 아래 코드의 출력을 예측하라. 그리고 왜 두 결과가 다른지 한 문장으로 설명하라.
static void change(int[] arr) { arr[0] = 100; arr = new int[]{7, 7, 7}; arr[1] = 200; } public static void main(String[] args) { int[] a = {1, 2, 3}; change(a); System.out.println(java.util.Arrays.toString(a)); } - 도서 제목들을 가변인자로 받아
"자바의 정석 | 클린 코드"처럼|로 이어 붙인 문자열을 돌려주는 메서드joinTitles를 쓰라. 인자가 없으면 빈 문자열을 돌려준다. - 다음 두 메서드가 한 클래스에 같이 있을 수 있는가? 있다면
calc(3, 5)는 어느 쪽을 부르는가?static int calc(int a, int b) { return a + b; } static int calc(int a, long b) { return a * 2; }
정답
[100, 2, 3]이다. 첫 줄은 복사된 주소를 따라가 원본 배열의 0번을 고쳤으므로 반영된다. 둘째 줄에서arr이 새 배열을 가리키게 되지만 그건 매개변수 변수(주소의 사본)만 바뀐 것이라 호출자의a와는 무관하고, 셋째 줄의200은 그 새 배열에 들어갔다가 메서드가 끝나면서 버려진다.
구분자를 앞이 아니라 뒤에 붙이면 마지막에static String joinTitles(String... titles) { if (titles.length == 0) return ""; String result = titles[0]; for (int i = 1; i < titles.length; i++) { result = result + " | " + titles[i]; } return result; } // joinTitles() -> "" // joinTitles("자바의 정석", "클린 코드") -> "자바의 정석 | 클린 코드"" | "이 남으므로, 첫 원소를 먼저 넣고 두 번째부터 도는 형태가 안전하다. 7단원에서String.join으로 한 줄에 끝내는 방법을 배운다.- 있을 수 있다. 두 번째 매개변수의 타입이
int와long으로 다르므로 정상적인 오버로딩이다.calc(3, 5)는5가int리터럴이고 형변환이 전혀 필요 없는calc(int, int)쪽이 선택되어8을 돌려준다.calc(3, 5L)로 부르면 두 번째가 선택되어6이 나온다.
다음 단원에서는 지금까지 따로 놀던 titles 배열과 overdueDays 배열을 하나로 묶는다. 제목과 연체일을 한 덩어리로 다루는 Book 클래스를 만들면서 필드·생성자·this·static 을 배운다.