프로그램이 커질수록 같은 코드가 반복되고 하나의 main() 메서드가 길어지기 쉽다. 이 문제를 해결하는 핵심 도구가 메서드(method) 다.
메서드는 특정 작업을 이름 붙인 코드 블록으로 분리하고 필요할 때 호출할 수 있게 해준다.
다음 계산을 여러 곳에서 반복한다고 생각해보자.
int total = price * quantity;
int deliveryFee = total >= 50000 ? 0 : 3000;
int payment = total + deliveryFee;
같은 로직을 여러 곳에 복사하면 변경할 때 모든 코드를 찾아 수정해야 한다.
메서드로 분리할 수 있다.
static int calculatePayment(int price, int quantity) {
int total = price * quantity;
int deliveryFee = total >= 50000 ? 0 : 3000;
return total + deliveryFee;
}
사용하는 쪽에서는 의도가 훨씬 선명하다.
int payment = calculatePayment(15000, 4);
static int add(int a, int b) {
int result = a + b;
return result;
}
구성 요소를 나눠보면 다음과 같다.
static int add(int a, int b)
↑ ↑ ↑
반환형 이름 매개변수
그리고 { } 안이 메서드 본문이다.
static int add(int a, int b) {
return a + b;
}
여기서 a, b는 매개변수(parameter) 다.
호출할 때 넘기는 실제 값은 인수(argument) 다.
int result = add(10, 20);
여기서 10, 20이 인수다.
반환 타입이 int라면 메서드는 int 값을 반환해야 한다.
static int multiply(int a, int b) {
return a * b;
}
호출 결과를 변수에 저장할 수 있다.
int result = multiply(4, 5);
void 메서드반환할 값이 없다면 void를 사용한다.
static void printWelcomeMessage() {
System.out.println("환영합니다.");
}
호출:
printWelcomeMessage();
return은 메서드를 즉시 종료한다static void printUser(int age) {
if (age < 0) {
return;
}
System.out.println("age=" + age);
}
age < 0이면 return에서 메서드 실행이 끝난다.
이 패턴은 잘못된 조건을 초기에 빠르게 종료하는 early return으로 자주 활용된다.
메서드를 이해할 때 매우 중요한 원칙이다.
static void changeNumber(int number) {
number = 100;
}
public static void main(String[] args) {
int value = 10;
changeNumber(value);
System.out.println(value);
}
출력:
10
왜 100이 아닐까?
호출 시 value 변수 자체를 넘기는 것이 아니라 현재 값 10이 복사되어 number에 전달되기 때문이다.
value = 10
↓ 값 복사
number = 10
메서드 안에서 number를 바꿔도 원본 지역 변수 value는 바뀌지 않는다.
변경 결과가 필요하다면 반환값을 받아야 한다.
static int changeNumber(int number) {
return 100;
}
value = changeNumber(value);
Java는 객체를 전달할 때도 참조값 자체가 복사되어 전달된다. 따라서 Java는 항상 pass-by-value 방식이라고 이해하는 것이 정확하다.
호출 인수의 타입이 매개변수보다 작은 범위라면 자동 형변환이 가능한 경우가 있다.
static void printNumber(long value) {
System.out.println(value);
}
int count = 10;
printNumber(count);
int 값이 long으로 자동 변환된다.
반대로 long 값을 int 매개변수에 자동으로 넘길 수는 없다.
메서드 이름이 같아도 매개변수 구성이 다르면 여러 메서드를 정의할 수 있다.
static int add(int a, int b) {
return a + b;
}
static double add(double a, double b) {
return a + b;
}
호출 시 전달한 인수 타입에 맞는 메서드가 선택된다.
System.out.println(add(1, 2));
System.out.println(add(1.5, 2.5));
오버로딩에서 중요한 것은 메서드 이름 + 매개변수 타입/순서다.
반환 타입만 다르게 만드는 것은 오버로딩이 아니다.
// 컴파일 불가
static int find() {
return 1;
}
static double find() {
return 1.0;
}
호출하는 쪽에서 어떤 메서드를 선택해야 하는지 구분할 수 없기 때문이다.
긴 코드 안에 입금과 출금 로직이 섞여 있다고 가정해보자.
if (menu == 1) {
// 입금 처리 코드 20줄
} else if (menu == 2) {
// 출금 처리 코드 25줄
}
메서드로 나누면 상위 코드가 프로그램의 흐름을 설명하게 된다.
if (menu == 1) {
balance = deposit(balance, amount);
} else if (menu == 2) {
balance = withdraw(balance, amount);
}
메서드를 잘 나누는 것은 단순히 코드 줄 수를 줄이는 작업이 아니다.
코드에 이름을 붙여 의도를 드러내는 작업이다.
좋지 않은 예:
static int process(int a, int b) {
// ...
}
더 나은 예:
static int calculateOrderTotal(int unitPrice, int quantity) {
// ...
}
호출부만 보더라도 무엇을 하는지 이해할 수 있어야 한다.
static int calculateTotal(int price, int quantity) {
return price * quantity;
}
이 코드를 부분별로 나누면 다음과 같다.
static 메서드 관련 제어자
int 반환 타입
calculateTotal 메서드 이름
int price, int quantity 매개변수
{ ... } 메서드 본문
입문 단계의 main()에서 바로 호출하기 위해 static 메서드를 주로 사용하지만, 객체지향을 배우면 인스턴스 메서드와 static 메서드의 차이를 본격적으로 다루게 된다.
static String getStatus(int stock) {
if (stock > 0) {
return "AVAILABLE";
}
// return이 없다면 컴파일 오류
}
반환 타입이 String인데 어떤 실행 경로에서는 값 없이 끝날 수 있기 때문이다.
static String getStatus(int stock) {
if (stock > 0) {
return "AVAILABLE";
}
return "SOLD_OUT";
}
컴파일러가 실행 전에 이런 실수를 잡아준다는 점은 정적 타입 언어의 장점 중 하나다.
return은 값을 돌려주는 기능뿐 아니라 현재 메서드를 즉시 종료한다.
static void processOrder(int quantity) {
if (quantity <= 0) {
System.out.println("잘못된 수량");
return;
}
System.out.println("주문 처리 시작");
}
오류 조건을 먼저 처리하고 빠져나오면 정상 흐름의 중첩이 줄어드는 경우가 많다.
static void changeFirst(int[] values) {
values[0] = 999;
}
호출하면 원본 배열의 요소가 바뀐다.
int[] numbers = {1, 2, 3};
changeFirst(numbers);
System.out.println(numbers[0]); // 999
이것만 보면 참조를 "그대로 전달"한다고 느낄 수 있지만 Java의 규칙은 여전히 값 전달(pass-by-value) 이다.
복사되는 값이 배열 객체 자체가 아니라 배열을 가리키는 참조값이기 때문이다. 두 참조가 같은 배열을 가리키므로 메서드 내부에서 요소를 변경하면 동일한 객체가 바뀐다.
반면 매개변수 자체에 새 배열을 대입해도 호출자의 변수가 가리키는 대상은 바뀌지 않는다.
static void replace(int[] values) {
values = new int[]{100, 200};
}
int[] numbers = {1, 2, 3};
replace(numbers);
System.out.println(numbers[0]); // 1
이 차이가 Java의 값 전달 규칙을 이해하는 핵심이다.
다음 두 메서드는 만들 수 없다.
static int find(int id) {
return id;
}
// 컴파일 오류
static String find(int id) {
return String.valueOf(id);
}
호출부가 다음처럼 생겼을 때 어떤 메서드를 고를지 결정할 수 없기 때문이다.
find(10);
오버로딩을 구분하는 핵심은 메서드 이름 + 매개변수 타입/개수/순서다.
static void print(int value) {
System.out.println("int: " + value);
}
static void print(double value) {
System.out.println("double: " + value);
}
print(10); // int 버전
print(10.5); // double 버전
정확히 맞는 타입이 있으면 그 메서드가 우선한다.
만약 int 버전이 없다면 10은 double로 자동 변환될 수 있으므로 double 버전이 호출될 수 있다.
오버로딩이 많아질수록 호출 규칙을 머릿속으로 계산하기 어려워진다. 편의 기능이라고 해서 무조건 많이 만드는 것보다 읽는 사람이 어떤 메서드가 선택되는지 쉽게 알 수 있게 설계하는 것이 중요하다.
int subtotal = calculateSubtotal(price, quantity);
int discount = calculateDiscount(subtotal, memberGrade);
int payment = calculatePayment(subtotal, discount);
printReceipt(payment);
각 메서드 내부 구현을 당장 읽지 않아도 프로그램이 무엇을 하는지 파악할 수 있다.
좋은 메서드 분리는 단순히 코드 줄 수를 줄이는 것이 아니라 복잡한 세부 구현을 이름 뒤에 숨기고 상위 흐름을 선명하게 만드는 추상화다.
void는 반환값이 없는 메서드에 사용한다.return은 값을 반환하면서 메서드를 종료하거나 void 메서드를 조기 종료할 수 있다.