본문 바로가기

메서드와 매개변수

반복되는 코드를 메서드로 묶는 이유와 메서드를 선언하고 호출하는 방법, 인수가 매개변수로 복사되어 전달되는 방식을 배우고 회원 목록 프로그램을 메서드로 나눕니다.

앞 글의 회원 목록 프로그램은 summary 명령에서 나이 합계를 구했습니다. 여기에 보고서 메뉴를 하나 더 만든다고 해 봅시다. 가장 빠른 방법은 합계를 구하는 반복문을 복사해 붙여 넣는 것입니다. 하지만 복사한 코드는 시간이 지나면서 조금씩 달라지기 쉽고, 그러다 보면 같은 질문에 프로그램이 두 가지 답을 내놓게 됩니다.

이 글에서는 같은 코드를 이름 붙은 묶음, 즉 메서드(method)로 만들어 한곳에서 관리하는 방법을 배웁니다. 메서드를 선언하고 호출하는 문법, 호출할 때 값을 넘겨받는 매개변수, 그리고 그 값이 어떤 방식으로 넘어가는지를 차례로 살펴본 뒤, 앞 글의 회원 목록 프로그램을 메서드로 나눠 봅니다.

같은 계산을 복사하면 생기는 일

아래 프로그램은 메뉴 화면과 보고서에서 각각 나이 합계와 평균을 구합니다. 두 곳의 코드는 거의 같아 보이지만 한 군데가 다릅니다.

lab/DuplicatedSummaryBug.java
public final class DuplicatedSummaryBug {
    public static void main(String[] args) {
        int[] age = {40, 50, 30};
        int size = 3;

        int menuTotal = 0;
        for (int index = 0; index < size; index++) {
            menuTotal += age[index];
        }
        double menuAverage = (double) menuTotal / size;
        System.out.println("menu=" + menuTotal + "/" + menuAverage);

        int reportTotal = 0;
        for (int index = 0; index < size - 1; index++) {
            reportTotal += age[index];
        }
        double reportAverage = (double) reportTotal / size;
        System.out.println("report=" + reportTotal + "/" + reportAverage);
    }
}
잘못된 결과
menu=120/40.0
report=90/30.0

메뉴 쪽은 세 회원의 합계 120과 평균 40.0을 맞게 출력했습니다. 그런데 보고서 쪽은 반복 조건이 index < size - 1로 바뀌어 있어서 마지막 회원의 나이 30을 빠뜨렸고, 합계 90과 평균 30.0을 출력했습니다. 코드를 복사한 뒤 한쪽만 고치면 이런 일이 생깁니다. 지금은 두 군데라서 금방 찾을 수 있지만, 복사본이 열 군데라면 열 군데를 모두 찾아 똑같이 고쳐야 하고 하나라도 놓치면 결과가 어긋납니다.

해결책은 합계를 구하는 코드를 한곳에만 두고, 필요한 곳에서는 그 코드를 이름으로 불러 쓰는 것입니다. 합계 계산을 total이라는 메서드로 옮기면 다음과 같습니다.

src/SharedSummary.java
public final class SharedSummary {
    public static void main(String[] args) {
        int[] age = {40, 50, 30};
        int size = 3;

        int menuTotal = total(age, size);
        System.out.println("menu=" + menuTotal + "/" + (double) menuTotal / size);

        int reportTotal = total(age, size);
        System.out.println("report=" + reportTotal + "/" + (double) reportTotal / size);
    }

    private static int total(int[] age, int size) {
        int result = 0;
        for (int index = 0; index < size; index++) {
            result += age[index];
        }
        return result;
    }
}
실행 결과
menu=120/40.0
report=120/40.0

이제 메뉴와 보고서가 같은 total을 사용하므로 두 결과가 달라질 수 없습니다. 합계 규칙을 바꿔야 할 때도 total 한 곳만 고치면 두 곳에 똑같이 반영됩니다. 반복문이 main에서 빠지면서 main은 '합계를 구해 출력한다'는 흐름만 남아 읽기도 쉬워졌습니다.

메서드 선언 읽기

total 메서드의 첫 줄을 선언부라고 합니다. 선언부에는 이 메서드를 어디서 쓸 수 있는지, 무엇을 받아서 무엇을 돌려주는지가 모두 적혀 있습니다.

private static int total(int[] age, int size) {
    int result = 0;
    for (int index = 0; index < size; index++) {
        result += age[index];
    }
    return result;
}
  • private: 이 메서드는 이 클래스 안에서만 호출할 수 있습니다. 다른 클래스에서 쓸 일이 없는 보조 메서드이므로 쓸 수 있는 범위를 좁혀 둡니다. 접근 범위는 5장의 접근 제어에서 자세히 배웁니다.
  • static: 객체를 만들지 않고 호출할 수 있는 메서드라는 뜻입니다. main도 static이므로, main에서 같은 클래스의 메서드를 이름만으로 부르려면 그 메서드도 static이어야 합니다. 객체와 static의 관계는 4장의 클래스와 6장의 static 메서드에서 다룹니다.
  • int: 반환 타입입니다. 이 메서드가 계산을 마치고 돌려주는 값의 타입이며, 돌려줄 값이 없으면 void라고 씁니다.
  • total: 메서드 이름입니다. 호출할 때 이 이름을 씁니다.
  • (int[] age, int size): 매개변수 목록입니다. 호출하는 쪽에서 넘겨줄 값을 받을 변수를 타입과 함께 선언합니다.

중괄호 안은 메서드 본문입니다. 본문의 마지막 문장 return result;는 계산한 값을 호출한 곳으로 돌려주며 메서드를 끝냅니다. return의 자세한 규칙은 다음 글에서 다룹니다.

static을 빼고 private int total(...)로 선언하면 main에서 호출하는 줄에 non-static method total(int[],int) cannot be referenced from a static context라는 컴파일 오류가 납니다. static이 없는 메서드는 객체를 통해서만 호출할 수 있는데, static으로 선언한 main은 객체 없이 실행되기 때문입니다. 3장에서는 객체를 만들지 않으므로 모든 메서드에 static을 붙입니다.

메서드는 클래스 안에, 다른 메서드의 바깥에 선언합니다. main 안에 total을 선언하면 illegal start of expression 컴파일 오류가 납니다. 클래스 안에서 메서드를 적는 순서는 상관없으므로 total을 main보다 아래에 두어도 됩니다.

메서드를 호출하면 일어나는 일

메서드는 선언만 해서는 실행되지 않습니다. total(age, size)처럼 이름 뒤에 괄호와 값을 붙여 호출(call)해야 본문이 실행됩니다. SharedSummary의 int menuTotal = total(age, size); 한 줄이 실행되는 과정을 단계별로 나누면 다음과 같습니다.

단계일어나는 일
1괄호 안의 age와 size의 값을 읽습니다. age의 값은 배열을 가리키는 참조이고, size의 값은 3입니다.
2total의 매개변수 age와 size가 새로 만들어지고, 1단계의 값으로 채워집니다.
3main은 이 줄에서 기다리고, total의 본문이 실행됩니다. result는 40, 90, 120으로 바뀝니다.
4return result;가 120을 돌려주며 total이 끝납니다.
5main으로 돌아와 total(age, size) 자리에 120이 들어가고, menuTotal에 120이 대입됩니다.

5단계에서 보듯 값을 돌려주는 메서드의 호출은 그 자체가 하나의 값처럼 쓰입니다. 그래서 int menuTotal = total(age, size);처럼 변수에 대입할 수도 있고, System.out.println(total(age, size));처럼 다른 메서드의 인수로 바로 넘길 수도 있습니다.

인수와 매개변수

호출할 때 괄호 안에 쓰는 값을 인수(argument)라고 하고, 그 값을 받으려고 선언부에 적은 변수를 매개변수(parameter)라고 합니다. 인수는 매개변수에 이름이 아니라 순서대로 짝지어집니다. 첫 번째 인수는 첫 번째 매개변수로, 두 번째 인수는 두 번째 매개변수로 들어갑니다. 다음 예제에서 main이 넘기는 배열의 이름은 first와 second이고 total의 매개변수 이름은 values이지만, 아무 문제 없이 동작합니다.

src/MethodParameters.java
public final class MethodParameters {
    public static void main(String[] args) {
        int[] first = {20, 30};
        int[] second = {40, 50, 60};

        System.out.println(total(first, first.length));
        System.out.println(total(second, 2));
    }

    private static int total(int[] values, int size) {
        int result = 0;
        for (int index = 0; index < size; index++) {
            result += values[index];
        }
        return result;
    }
}
실행 결과
50
90

같은 메서드가 서로 다른 배열과 범위에서 동작했습니다. 첫 번째 호출은 first와 그 길이 2를 넘겨 20 + 30 = 50을 받습니다. 두 번째 호출은 길이 3인 second를 넘기면서 size로 2를 주었으므로 앞의 두 원소만 더해 40 + 50 = 90을 받습니다. 앞 글에서 배열의 용량과 실제 회원 수를 구분했던 것처럼, total도 배열 전체가 아니라 넘겨받은 size만큼만 더합니다.

인수의 개수나 타입이 매개변수 목록과 맞지 않으면 컴파일되지 않습니다. 첫 번째 호출을 total(first)로 바꾸면 다음 오류가 납니다.

컴파일 오류
src/MethodParameters.java:6: error: method total in class MethodParameters cannot be applied to given types;
        System.out.println(total(first));
                           ^
  required: int[],int
  found:    int[]
  reason: actual and formal argument lists differ in length
1 error

required는 메서드가 요구하는 매개변수 타입 목록이고, found는 실제로 넘긴 인수의 타입입니다. int[]와 int 두 개가 필요한데 int[] 하나만 넘겼다는 뜻입니다.

매개변수가 필요한 이유

main에 이미 age와 size가 있는데 굳이 넘겨야 하는지 궁금할 수 있습니다. 매개변수를 없애고 total()의 본문에서 main의 age와 size를 바로 읽으려고 하면 cannot find symbol 컴파일 오류가 납니다. 메서드 안에서 선언한 변수, 즉 지역 변수(local variable)는 그 메서드 안에서만 쓸 수 있기 때문입니다. main의 age는 main의 지역 변수이고, total 안에서는 보이지 않습니다. 매개변수도 지역 변수처럼 그 메서드 안에서만 쓸 수 있습니다.

그래서 메서드가 일하는 데 필요한 값은 매개변수로 받아야 합니다. 번거로워 보여도 장점이 큽니다. total은 어떤 배열이든 받아서 더할 수 있고, 결과가 넘겨받은 인수에만 달려 있으므로 작은 배열을 직접 넣어 결과를 확인해 보기도 쉽습니다.

인수는 복사되어 전달된다

메서드 안에서 매개변수를 바꾸면 호출한 쪽의 변수도 바뀔까요? 아래 예제는 int 하나와 배열 하나를 넘긴 뒤, 메서드 안에서 둘 다 바꿔 봅니다.

src/ParameterEffects.java
import java.util.Arrays;

public final class ParameterEffects {
    public static void main(String[] args) {
        int size = 1;
        int[] age = {40, 0};

        change(size, age);

        System.out.println("size=" + size);
        System.out.println("age=" + Arrays.toString(age));
    }

    private static void change(int size, int[] age) {
        size++;
        age[0] = 55;
    }
}
실행 결과
size=1
age=[55, 0]

Arrays.toString은 배열의 원소를 [55, 0] 같은 문자열로 만들어 주는 표준 라이브러리 메서드입니다. 결과를 보면 size는 그대로 1인데 배열의 첫 원소는 55로 바뀌었습니다. 같은 메서드 안에서 둘 다 바꿨는데 왜 결과가 다를까요?

자바는 메서드를 호출할 때 인수의 값을 복사해서 매개변수에 넣습니다. size의 값은 숫자 1이므로, change의 매개변수 size는 1을 복사해 받은 별개의 변수입니다. change 안의 size++는 이 복사본을 2로 만들 뿐 main의 size에는 닿지 않습니다. 한편 배열과 인덱스에서 보았듯이 배열 변수에 들어 있는 값은 배열 자체가 아니라 배열을 가리키는 참조입니다. 그래서 복사되는 것도 배열이 아니라 참조이고, 결국 main의 age와 change의 age는 같은 배열 하나를 가리키게 됩니다. age[0] = 55는 그 공유된 배열의 원소를 바꾸므로, main에서 배열을 읽어도 55가 보입니다.

기본형 값도 배열 참조 값도 복사되지만, 같은 배열 원소의 변경은 보인다

호출자 size=1과 배열 참조값을 복사한 뒤 피호출자의 size++는 지역값만 바꾸고 age[0]=55는 공유 배열을 바꿉니다. 호출 전·내부 변경·반환 후 상태의 차이가 핵심입니다.

ParameterEffects에서 change의 두 대입 직후, 반환 전 상태. main의 size는 1, change의 size는 2이며 두 age 참조는 동일한 [55,0] 배열을 가리킨다. 반환 후 main은 size=1과 age=[55,0]을 관찰한다.

정수 1과 배열 참조 값이 매개변수에 각각 복사됩니다. 별도 기본형 지역 변수와 동일한 배열의 참조를 구분하며 물리 스택이나 힙 주소를 나타내지 않습니다.

MEMBER · STATE CONTRACT

숫자 값과 배열 참조값이 각각 복사됩니다

change의 수정 직후, 반환 전 상태를 봅니다. main의 size는1, change의 size는2이며 두 age 참조는 같은 배열의 변경된 원소를 봅니다.

ParameterEffects · change의 두 수정 직후, 반환 전 스냅샷입니다. 시작 값은 size=1, age=[40,0]입니다.

인수 값 복사와 동일 배열의 공유 호출자의 정수 1과 배열 참조 값은 change 매개변수로 복사된다. 두 수정 직후 main size는 1, change size는 2이고 두 age 참조는 하나의 [55,0] 배열을 가리킨다. 1과 배열 참조값 복사 age 참조 age 참조 호출자 main size = 1 age → 같은 배열 호출자의 지역 size는 그대로 change(size, age) 피호출자 change size = 2 복사된 1에서 size++ age → 같은 배열 age[0] = 55 실행 후 동일한 int[] 배열 55 index 0 0 index 1
숫자 값과 배열 참조값이 각각 복사됩니다 값/참조값 각각복사후snapshotmain size1/change size2;두age는공유배열55/0,void복귀후mainsize1유지 호출자 main의 지역 범위 size=1 age · 참조값 호출자의 size는 그대로 반환 전 스냅샷 피호출자 change의 범위 size=2 복사된1에서 size++ age · 같은 참조값 age[0]=55 실행 뒤 배열 자체 복제 아님 호출 때 1·참조값 복사 동일 int[] 객체 하나 [55,0] index0=55 · index1=0 void 반환 후 main 관찰 size=1 · age=[55,0] 실선: 현재 배열 참조 점선: 호출 때 완료된 값 복사
호출 때 복사
정수 1과 배열을 가리키는 참조 값이 각각 매개변수에 복사됩니다. 배열 자체가 복제되는 것은 아닙니다.
반환 전 · main
호출자의 지역 size=1은 그대로이고 age는 같은 배열을 가리킵니다.
반환 전 · change
size++로 별도 매개변수가 2가 됩니다. age[0]=55는 그 참조가 가리키는 배열의 원소를 바꿉니다.
공유하는 배열 하나
두 age 참조가 가리키는 값은 [55, 0]입니다. 원소 변경은 호출자에게도 보입니다.

반환 후 main의 관찰: size=1 · age=[55, 0]

change는 void입니다. 반환값으로 호출자 size를 갱신하지 않으며, 반환 뒤에는 change의 매개변수 수명이 끝납니다.

  • 호출 때 완료된 인수 값 복사
  • 현재 같은 배열을 가리키는 참조

모든 인수는 값으로 전달됩니다. 상자는 지역 변수의 소유 범위이며 특정 JVM의 물리 스택·힙 주소를 나타내지 않습니다.

이 예의 조건

  • 모든 인수는 값으로 복사한다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
전체 읽기 정보기본형 값도 배열 참조 값도 복사되지만, 같은 배열 원소의 변경은 보인다 ParameterEffects에서 change의 두 대입 직후, 반환 전 상태. main의 size는 1, change의 size는 2이며 두 age 참조는 동일한 [55,0] 배열을 가리킨다. 반환 후 main은 size=1과 age=[55,0]을 관찰한다. 정수 1과 배열 참조 값이 매개변수에 각각 복사됩니다. 별도 기본형 지역 변수와 동일한 배열의 참조를 구분하며 물리 스택이나 힙 주소를 나타내지 않습니다. MEMBER · STATE CONTRACT 숫자 값과 배열 참조값이 각각 복사됩니다 change의 수정 직후, 반환 전 상태를 봅니다. main의 size는 1, change의 size는 2이며 두 age 참조는 같은 배열의 변경된 원소를 봅니다. ParameterEffects · change의 두 수정 직후, 반환 전 스냅샷입니다. 시작 값은 size=1, age=[40,0] 입니다. 호출 때 복사 정수 1 과 배열을 가리키는 참조 값이 각각 매개변수에 복사됩니다. 배열 자체가 복제되는 것은 아닙니다. 반환 전 · main 호출자의 지역 size=1 은 그대로이고 age 는 같은 배열을 가리킵니다. 반환 전 · change size++ 로 별도 매개변수가 2가 됩니다. age[0]=55 는 그 참조가 가리키는 배열의 원소를 바꿉니다. 공유하는 배열 하나 두 age 참조가 가리키는 값은 [55, 0] 입니다. 원소 변경은 호출자에게도 보입니다. 반환 후 main의 관찰: size=1 · age=[55, 0] change 는 void 입니다. 반환값으로 호출자 size를 갱신하지 않으며, 반환 뒤에는 change의 매개변수 수명이 끝납니다. 호출 때 완료된 인수 값 복사 현재 같은 배열을 가리키는 참조 모든 인수는 값으로 전달됩니다. 상자는 지역 변수의 소유 범위이며 특정 JVM의 물리 스택·힙 주소를 나타내지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
호출 준비

main의 size와 배열 참조값을 인수로 제공합니다.

  • main.size: 1
  • 배열: [40,0]
  • 호출: change(size, age)
  • 숫자 1과 같은 배열을 가리키는 참조값이 복사된다.
피호출자 변경

지역 size를 증가시키고 공유 배열을 씁니다.

  • change.size: 2
  • main.size: 1
  • 배열: [55,0]
  • 지역 숫자는 별개지만 복사한 참조는 같은 배열에 도달한다.
복귀 후

change 지역 변수가 끝나고 main이 결과를 읽습니다.

  • main.size: 1
  • age: [55,0]
  • 호출자 변수 재대입 없이 공유 대상의 변경만 보인다.

기본형 값도 배열 참조 값도 복사되지만, 같은 배열 원소의 변경은 보인다

호출자 size=1과 배열 참조값을 복사한 뒤 피호출자의 size++는 지역값만 바꾸고 age[0]=55는 공유 배열을 바꿉니다. 호출 전·내부 변경·반환 후 상태의 차이가 핵심입니다.

ParameterEffects에서 change의 두 대입 직후, 반환 전 상태. main의 size는 1, change의 size는 2이며 두 age 참조는 동일한 [55,0] 배열을 가리킨다. 반환 후 main은 size=1과 age=[55,0]을 관찰한다.

정수 1과 배열 참조 값이 매개변수에 각각 복사됩니다. 별도 기본형 지역 변수와 동일한 배열의 참조를 구분하며 물리 스택이나 힙 주소를 나타내지 않습니다.

MEMBER · STATE CONTRACT

숫자 값과 배열 참조값이 각각 복사됩니다

change의 수정 직후, 반환 전 상태를 봅니다. main의 size는1, change의 size는2이며 두 age 참조는 같은 배열의 변경된 원소를 봅니다.

ParameterEffects · change의 두 수정 직후, 반환 전 스냅샷입니다. 시작 값은 size=1, age=[40,0]입니다.

인수 값 복사와 동일 배열의 공유 호출자의 정수 1과 배열 참조 값은 change 매개변수로 복사된다. 두 수정 직후 main size는 1, change size는 2이고 두 age 참조는 하나의 [55,0] 배열을 가리킨다. 1과 배열 참조값 복사 age 참조 age 참조 호출자 main size = 1 age → 같은 배열 호출자의 지역 size는 그대로 change(size, age) 피호출자 change size = 2 복사된 1에서 size++ age → 같은 배열 age[0] = 55 실행 후 동일한 int[] 배열 55 index 0 0 index 1
숫자 값과 배열 참조값이 각각 복사됩니다 값/참조값 각각복사후snapshotmain size1/change size2;두age는공유배열55/0,void복귀후mainsize1유지 호출자 main의 지역 범위 size=1 age · 참조값 호출자의 size는 그대로 반환 전 스냅샷 피호출자 change의 범위 size=2 복사된1에서 size++ age · 같은 참조값 age[0]=55 실행 뒤 배열 자체 복제 아님 호출 때 1·참조값 복사 동일 int[] 객체 하나 [55,0] index0=55 · index1=0 void 반환 후 main 관찰 size=1 · age=[55,0] 실선: 현재 배열 참조 점선: 호출 때 완료된 값 복사
호출 때 복사
정수 1과 배열을 가리키는 참조 값이 각각 매개변수에 복사됩니다. 배열 자체가 복제되는 것은 아닙니다.
반환 전 · main
호출자의 지역 size=1은 그대로이고 age는 같은 배열을 가리킵니다.
반환 전 · change
size++로 별도 매개변수가 2가 됩니다. age[0]=55는 그 참조가 가리키는 배열의 원소를 바꿉니다.
공유하는 배열 하나
두 age 참조가 가리키는 값은 [55, 0]입니다. 원소 변경은 호출자에게도 보입니다.

반환 후 main의 관찰: size=1 · age=[55, 0]

change는 void입니다. 반환값으로 호출자 size를 갱신하지 않으며, 반환 뒤에는 change의 매개변수 수명이 끝납니다.

  • 호출 때 완료된 인수 값 복사
  • 현재 같은 배열을 가리키는 참조

모든 인수는 값으로 전달됩니다. 상자는 지역 변수의 소유 범위이며 특정 JVM의 물리 스택·힙 주소를 나타내지 않습니다.

이 예의 조건

  • 모든 인수는 값으로 복사한다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
전체 읽기 정보기본형 값도 배열 참조 값도 복사되지만, 같은 배열 원소의 변경은 보인다 ParameterEffects에서 change의 두 대입 직후, 반환 전 상태. main의 size는 1, change의 size는 2이며 두 age 참조는 동일한 [55,0] 배열을 가리킨다. 반환 후 main은 size=1과 age=[55,0]을 관찰한다. 정수 1과 배열 참조 값이 매개변수에 각각 복사됩니다. 별도 기본형 지역 변수와 동일한 배열의 참조를 구분하며 물리 스택이나 힙 주소를 나타내지 않습니다. MEMBER · STATE CONTRACT 숫자 값과 배열 참조값이 각각 복사됩니다 change의 수정 직후, 반환 전 상태를 봅니다. main의 size는 1, change의 size는 2이며 두 age 참조는 같은 배열의 변경된 원소를 봅니다. ParameterEffects · change의 두 수정 직후, 반환 전 스냅샷입니다. 시작 값은 size=1, age=[40,0] 입니다. 호출 때 복사 정수 1 과 배열을 가리키는 참조 값이 각각 매개변수에 복사됩니다. 배열 자체가 복제되는 것은 아닙니다. 반환 전 · main 호출자의 지역 size=1 은 그대로이고 age 는 같은 배열을 가리킵니다. 반환 전 · change size++ 로 별도 매개변수가 2가 됩니다. age[0]=55 는 그 참조가 가리키는 배열의 원소를 바꿉니다. 공유하는 배열 하나 두 age 참조가 가리키는 값은 [55, 0] 입니다. 원소 변경은 호출자에게도 보입니다. 반환 후 main의 관찰: size=1 · age=[55, 0] change 는 void 입니다. 반환값으로 호출자 size를 갱신하지 않으며, 반환 뒤에는 change의 매개변수 수명이 끝납니다. 호출 때 완료된 인수 값 복사 현재 같은 배열을 가리키는 참조 모든 인수는 값으로 전달됩니다. 상자는 지역 변수의 소유 범위이며 특정 JVM의 물리 스택·힙 주소를 나타내지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
호출 준비

main의 size와 배열 참조값을 인수로 제공합니다.

  • main.size: 1
  • 배열: [40,0]
  • 호출: change(size, age)
  • 숫자 1과 같은 배열을 가리키는 참조값이 복사된다.
피호출자 변경

지역 size를 증가시키고 공유 배열을 씁니다.

  • change.size: 2
  • main.size: 1
  • 배열: [55,0]
  • 지역 숫자는 별개지만 복사한 참조는 같은 배열에 도달한다.
복귀 후

change 지역 변수가 끝나고 main이 결과를 읽습니다.

  • main.size: 1
  • age: [55,0]
  • 호출자 변수 재대입 없이 공유 대상의 변경만 보인다.

이 동작을 흔히 '기본형은 값으로, 배열은 참조로 전달된다'고 외우는데, 정확한 표현이 아니라서 오히려 헷갈립니다. 자바는 모든 인수를 값으로 전달합니다. 다만 배열을 넘길 때 복사되는 값이 마침 같은 배열을 가리키는 참조일 뿐입니다. 이렇게 기억하면 다음의 '더 깊이'에서 보는 경우도 자연스럽게 설명됩니다.

더 깊이: 매개변수에 새 배열을 대입하면

'배열은 참조로 전달된다'는 말이 틀린 이유는 매개변수에 새 배열을 대입해 보면 드러납니다. 다음 reset은 받은 배열의 칸을 바꾸는 줄, 매개변수에 새 배열을 대입하는 줄, 다시 칸을 바꾸는 줄로 이루어져 있습니다. main이 출력하는 두 값이 각각 어느 줄의 영향을 받을지 예상해 보세요.

src/ReassignParameter.java
public final class ReassignParameter {
    public static void main(String[] args) {
        int[] age = {40, 50};

        reset(age);

        System.out.println(age[0] + ", " + age[1]);
    }

    private static void reset(int[] age) {
        age[0] = 0;
        age = new int[] {9, 9};
        age[1] = 0;
    }
}
실행 결과
0, 50

reset의 첫 줄 age[0] = 0은 main과 공유하는 배열을 바꾸므로 main에서도 보입니다. 하지만 둘째 줄은 매개변수 age에 새 배열 {9, 9}의 참조를 대입합니다. 매개변수는 복사본이므로 이 대입은 reset의 age만 새 배열로 돌려놓을 뿐이고, main의 age는 여전히 원래 배열을 가리킵니다. 그래서 셋째 줄 age[1] = 0은 새 배열을 바꾸고, main이 보는 원래 배열의 두 번째 원소는 50 그대로입니다.

만약 자바가 C++의 참조 매개변수처럼 변수 자체를 넘기는 참조 전달(pass by reference) 방식이었다면, 둘째 줄의 대입이 main의 변수까지 바꿔 9, 0이 출력되었을 것입니다. Java 언어 명세(JLS §8.4.1)는 메서드가 호출될 때 인수 식의 값으로 새로 만든 매개변수 변수를 초기화한다고 정하고 있고, 이것이 값 전달(pass by value)입니다. 객체를 인수로 넘길 때도 똑같은 규칙이 적용되며, 이는 4장의 메서드 호출과 값 전달에서 다시 다룹니다.

검사하는 메서드와 출력하는 메서드 나누기

메서드는 중복을 없앨 때만 쓰는 것이 아닙니다. 한 번만 쓰는 코드라도 '하는 일' 단위로 나눠 이름을 붙이면 프로그램을 읽기가 쉬워집니다. 아래 예제는 회원 정보가 올바른지 검사하는 isValid와 회원 한 명을 출력하는 printMember를 따로 만듭니다.

src/MemberRegistryMethods.java
public final class MemberRegistryMethods {
    public static void main(String[] args) {
        String[] emails = {"[email protected]", "", "[email protected]"};
        int[] age = {40, 30, 50};

        for (int index = 0; index < emails.length; index++) {
            if (isValid(emails[index], age[index])) {
                printMember(index, emails[index], age[index]);
            } else {
                System.out.println("rejected index=" + index);
            }
        }
    }

    private static boolean isValid(String email, int age) {
        return email != null && !email.isBlank() && age >= 14 && age <= 120;
    }

    private static void printMember(int index, String email, int age) {
        System.out.println((index + 1) + ". " + email + "=" + age);
    }
}
실행 결과
1. [email protected]=40
rejected index=1
3. [email protected]=50

isValid는 반환 타입이 boolean이므로 검사 결과를 참이나 거짓으로 돌려줍니다. 이메일이 null이 아니고, 공백만 있는 문자열도 아니며, 나이가 14 이상 120 이하일 때만 참입니다. isBlank()는 문자열이 비어 있거나 공백 문자로만 이루어져 있으면 참을 돌려주는 String의 메서드입니다.

조건의 순서에도 이유가 있습니다. &&는 왼쪽이 거짓이면 오른쪽을 계산하지 않습니다. 그래서 email이 null이면 email != null에서 바로 거짓이 결정되고 email.isBlank()는 실행되지 않습니다. 순서를 바꿔 email.isBlank()를 먼저 쓰면, email이 null일 때 없는 문자열의 메서드를 호출하게 되어 NullPointerException이 발생합니다.

출력을 보면 인덱스 0과 2의 회원은 검사를 통과해 printMember가 출력했고, 인덱스 1은 이메일이 빈 문자열이라 거절되었습니다. 출력 번호가 1, 3으로 건너뛴 것은 printMember가 배열에서의 원래 위치를 번호로 쓰기 때문입니다.

두 메서드는 각자 한 가지 일만 합니다. isValid는 판단만 하고 아무것도 출력하지 않으며, printMember는 이미 통과한 회원을 보여 주기만 합니다. 거절되었을 때 무엇을 할지는 main이 정합니다. 이렇게 나눠 두면 바뀌는 이유도 하나씩 갈립니다. 가입 나이 기준이 바뀌면 isValid만, 출력 형식이 바뀌면 printMember만 고치면 됩니다. 만약 isValid가 직접 거절 메시지까지 출력한다면, 메시지 없이 유효한 회원 수만 세고 싶은 곳에서는 isValid를 쓸 수 없게 됩니다. 메서드가 짧아서 좋은 것이 아니라 하는 일이 하나여서 좋은 것입니다.

회원 목록 프로그램을 메서드로 나누기

이제 앞 글의 회원 목록 기능을 메서드로 나눠 보겠습니다. 회원 추가는 add, 목록 출력은 printEntries, 합계와 평균 출력은 printSummary, 합계 계산은 total이 맡습니다.

src/ModularMemberRegistry.java
public final class ModularMemberRegistry {
    public static void main(String[] args) {
        String[] emails = new String[3];
        int[] age = new int[3];
        int size = 0;

        size = add(emails, age, size, "[email protected]", 40);
        size = add(emails, age, size, "[email protected]", 50);
        size = add(emails, age, size, "", 30);

        printEntries(emails, age, size);
        printSummary(age, size);
    }

    private static int add(
            String[] emails,
            int[] age,
            int size,
            String email,
            int memberAge
    ) {
        if (size >= emails.length || size >= age.length) {
            System.out.println("full");
            return size;
        }
        if (email == null || email.isBlank() || memberAge < 14 || memberAge > 120) {
            System.out.println("invalid");
            return size;
        }

        emails[size] = email;
        age[size] = memberAge;
        System.out.println("added " + email);
        return size + 1;
    }

    private static void printEntries(String[] emails, int[] age, int size) {
        for (int index = 0; index < size; index++) {
            System.out.println((index + 1) + ". " + emails[index] + "=" + age[index]);
        }
    }

    private static void printSummary(int[] age, int size) {
        int total = total(age, size);
        double average = size == 0 ? 0.0 : (double) total / size;
        System.out.println("total=" + total + ", average=" + average);
    }

    private static int total(int[] age, int size) {
        int result = 0;
        for (int index = 0; index < size; index++) {
            result += age[index];
        }
        return result;
    }
}
실행 결과
added [email protected]
added [email protected]
invalid
1. [email protected]=40
2. [email protected]=50
total=90, average=45.0

add는 매개변수가 다섯 개라서 한 줄에 모두 쓰면 너무 길어지므로 줄을 나눠 적었습니다. 본문은 앞 글에서 정한 순서 그대로입니다. 먼저 자리가 남았는지 확인하고, 이메일과 나이를 검사한 뒤, 두 배열의 size번 칸에 씁니다. 검사에서 거절되면 아무것도 쓰지 않고 return size;로 원래 회원 수를 돌려줍니다.

여기서 핵심은 add가 늘어난 회원 수를 직접 바꾸지 않고 반환값으로 돌려준다는 점입니다. 앞 절에서 보았듯이 size는 int이므로 add에는 그 값의 복사본이 들어갑니다. add 안에서 size++를 해도 main의 size는 그대로입니다. 그래서 add는 저장에 성공하면 size + 1을, 거절하면 원래 size를 돌려주고, main이 size = add(...)로 그 값을 다시 받아 둡니다. main의 size는 첫 호출 뒤 1, 두 번째 호출 뒤 2가 되고, 세 번째 호출은 이메일이 빈 문자열이라 invalid를 출력하고 2를 그대로 돌려줍니다. 반면 배열은 참조가 복사되므로 add가 쓴 이메일과 나이는 main의 배열에 그대로 남습니다.

ModularMemberRegistry의 add, printEntries, printSummary, total 메서드를 입력·반환·배열 변경과 출력으로 비교한다. add의 새 size는 호출자가 반환값을 재대입해서 받는다.

같은 배열 상태를 사용하는 메서드도 변경과 조회, 반환과 출력의 책임이 다릅니다. 실제 ModularMemberRegistry 구현에 있는 네 메서드만 비교합니다.

MEMBER · STATE CONTRACT

같은 배열을 사용해도 반환·출력·변경 책임은 다릅니다

add는 새 개수를 반환하고, 조회 메서드는 저장 범위를 읽습니다. 호출자는 size = add(...)로 반환된 개수를 다시 받아야 합니다.

ModularMemberRegistry · 입력, 반환, 배열 변경과 콘솔 출력을 구분
메서드필요한 입력반환상태 변경과 출력
add

emails · age · size

email · memberAge

int

성공: size+1

거절: 기존 size

성공: 같은 index에 두 배열을 쓰고 added 출력

거절: 배열 쓰기 없이 full 또는 invalid 출력

printEntriesemails · age · sizevoid저장 범위 [0,size)를 읽어 목록 출력 · 배열 변경 없음
printSummaryage · sizevoidtotal을 호출해 합계·평균 출력 · 배열 변경 없음
totalage · sizeint 합계[0,size)만 합산 · 배열 변경 없음 · 출력 없음
같은 배열을 사용해도 반환·출력·변경 책임은 다릅니다 main→add/printEntries/printSummary 독립호출관계,summary→total반환;mainsize=add 재대입0→1→2→2,실제순서는add3회뒤조회 main · 호출자 size=add(...) 재대입 call관계 · 순서표 아님 add → int 성공 두 배열 쓰기 size+1 / 거절 size printEntries → void [0,size) 목록 출력 배열 변경 없음 printSummary → void 합계·평균 출력 배열 변경 없음 합계 요청 total → int [0,size)만 합산 변경·출력 없음 main이 받은 size: 0 → 1 → 2 → 2 세 번째 빈 이메일은 거절 · 두 회원만 조회 실제 main 순서는 add3회 뒤 목록·요약
같은 배열을 사용해도 반환·출력·변경 책임은 다릅니다 main→add/printEntries/printSummary 독립호출관계,summary→total반환;mainsize=add 재대입0→1→2→2,실제순서는add3회뒤조회 main · 호출자 같은 배열과 size 전달 아래는 호출 관계 add → int 성공만 두 배열 쓰기 성공 size+1 / 거절 size printEntries → void [0,size) 목록 출력 배열 변경 없음 printSummary → void 합계·평균 출력 배열 변경 없음 합계 요청 total → int [0,size)만 합산 변경·출력 없음 main이 반환값 재대입 size=add(...) 0 → 1 → 2 → 2 세 번째 빈 이메일 거절

ModularMemberRegistry의 실제 메서드 계약입니다.

add → int
입력은 emails, age, size, email, memberAge. 성공은 두 배열의 같은 index에 기록하고 added를 출력한 뒤 size+1을 반환합니다. full/invalid는 배열 쓰기 없이 해당 상태를 출력하고 기존 size를 반환합니다.
printEntries → void
emails, age, size를 받아 [0,size) 목록을 출력합니다. 배열은 바꾸지 않습니다.
printSummary → void
age, size를 받아 total을 호출하고 합계·평균을 출력합니다. 배열은 바꾸지 않습니다.
total → int 합계
age, size를 받아 [0,size)만 합산합니다. 배열 변경과 출력은 없습니다.

예제의 반환 size: 0 → 1 → 2 → 2 · 세 번째 invalid 요청은 기존 개수를 유지

전제는 같은 길이의 병렬 배열과 유효한 size입니다. 예제의 add는 두 배열 길이를 각각 검사합니다. 기본형 매개변수를 바꾸는 것만으로 호출자 size가 바뀌지는 않으므로 반환값을 다시 받아야 합니다.

그림은 네 메서드가 각각 무엇을 받고, 무엇을 돌려주며, 배열을 바꾸는지를 비교합니다. 배열을 바꾸는 메서드는 add 하나뿐이고, printEntries와 printSummary는 저장된 범위를 읽어 출력만 하며, total은 출력도 하지 않고 합계만 돌려줍니다. printSummary처럼 메서드 안에서 다른 메서드를 부를 수도 있는데, 이렇게 호출이 겹칠 때의 실행 순서는 다음 글에서 따라가 봅니다.

반환값을 받지 않으면

size = add(...)에서 size =를 빠뜨리기 쉽습니다. 반환값을 쓰지 않고 버려도 자바 컴파일러는 경고하지 않으므로, 세 호출을 모두 add(emails, age, size, ...);로만 쓰면 컴파일은 되지만 결과가 이렇게 바뀝니다.

잘못된 결과
added [email protected]
added [email protected]
invalid
total=0, average=0.0

added가 두 번 출력되었는데 목록은 비어 있고 합계는 0입니다. main의 size가 계속 0이었기 때문입니다. 세 호출 모두 size로 0을 받았으므로 두 회원 모두 인덱스 0에 쓰였고, 그 바람에 [email protected]이 [email protected]을 덮어썼습니다. 그리고 printEntries와 printSummary는 size가 0이니 아무 회원도 읽지 않았습니다. 화면에 added가 찍혔다고 해서 main의 상태까지 바뀐 것은 아니라는 점을 기억해 두세요.

두 배열의 길이가 다르다면

add의 첫 검사는 두 배열의 길이를 각각 확인합니다. 두 배열의 길이가 다르다면 짝지어 저장한다는 전제가 이미 깨진 것이므로, 사실은 매번 추가할 때가 아니라 프로그램을 시작할 때 한 번 확인하는 편이 낫습니다. 배열과 인덱스에서 길이가 다르면 IllegalStateException을 던져 즉시 멈추게 했던 방법이 그 예입니다. 더 근본적으로는 이메일과 나이를 하나의 회원 객체로 묶으면 두 배열을 맞출 필요 자체가 사라지는데, 이는 4장의 클래스로 관련 데이터 묶기에서 배웁니다.

언제 메서드로 뽑아낼까

다음과 같은 신호가 보이면 코드 일부를 메서드로 뽑아낼지 검토해 볼 만합니다.

  • 같은 문장 묶음이 두 군데 이상에서 반복됩니다.
  • 코드 묶음에 '입력 검사', '합계 계산'처럼 자연스러운 이름을 붙일 수 있습니다.
  • main 한 화면에 입력, 저장, 집계, 출력이 뒤섞여 전체 흐름이 잘 보이지 않습니다.
  • 규칙 하나가 바뀔 때 여러 곳을 함께 고쳐야 합니다.

메서드를 만들었다고 저절로 좋은 구조가 되지는 않습니다. main의 코드를 통째로 process()라는 메서드로 옮기면 main은 짧아지지만 복잡함은 그대로 자리만 옮긴 것입니다. 이름만 보고 무엇을 하는지 알 수 있어야 메서드로 나눈 의미가 있습니다. total처럼 돌려주는 값을 나타내거나, isValid처럼 참·거짓 질문으로 읽히거나, printEntries처럼 하는 행동을 드러내는 이름이 좋습니다.

반대로 한두 줄마다 메서드로 쪼개면 호출을 따라다니느라 오히려 전체 흐름을 놓치게 됩니다. 중복을 없앨 수 있는지, 그리고 이름 붙일 만한 한 가지 일인지를 기준으로 나누세요.

정리

  • 메서드는 이름을 붙인 코드 묶음입니다. 같은 계산을 한곳에 두면 복사본끼리 결과가 어긋나는 일이 사라지고, 고칠 곳도 하나로 줄어듭니다.
  • 선언부는 접근 범위(private), static 여부, 반환 타입, 이름, 매개변수 목록으로 이루어집니다. 메서드는 호출해야 실행되고, 값을 돌려주는 호출은 그 값처럼 쓰입니다.
  • 인수는 순서대로 매개변수에 짝지어집니다. 메서드의 지역 변수는 그 메서드 안에서만 보이므로, 필요한 값은 매개변수로 받습니다.
  • 자바는 인수의 값을 복사해 전달합니다. int 매개변수를 바꿔도 호출한 쪽은 그대로이고, 배열은 참조가 복사되어 같은 배열의 원소 변경이 호출한 쪽에도 보입니다.
  • 호출한 쪽의 int 상태를 바꿔야 하면 새 값을 반환하고, 호출한 쪽이 size = add(...)처럼 다시 대입합니다.

연습 문제

1. 최소 나이 이상인 회원의 나이 합계

나이 배열, size, 최소 나이를 받아 최소 나이 이상인 회원의 나이만 더해 돌려주는 qualifiedTotal 메서드를 작성하세요. size가 0보다 작거나 배열 길이보다 크면, 또는 최소 나이가 음수이면 IllegalArgumentException을 던지게 합니다.

해설 보기
src/QualifiedTotalMethod.java
public final class QualifiedTotalMethod {
    public static void main(String[] args) {
        int[] age = {20, 45, 30, 60};

        System.out.println(qualifiedTotal(age, 4, 30));
        System.out.println(qualifiedTotal(age, 3, 40));
    }

    private static int qualifiedTotal(int[] age, int size, int minimum) {
        if (size < 0 || size > age.length || minimum < 0) {
            throw new IllegalArgumentException("invalid range");
        }

        int total = 0;
        for (int index = 0; index < size; index++) {
            if (age[index] >= minimum) {
                total += age[index];
            }
        }
        return total;
    }
}
실행 결과
135
45

첫 번째 호출은 네 회원 가운데 30 이상인 45, 30, 60을 더해 135를 돌려줍니다. 두 번째 호출은 size가 3이므로 앞의 세 회원 20, 45, 30만 보고, 그중 40 이상인 45만 더합니다.

throw new IllegalArgumentException(...)은 '메서드에 잘못된 인수가 들어왔다'는 뜻의 예외를 던져 실행을 중단하는 문장입니다. 첫 번째 호출을 qualifiedTotal(age, 5, 30)으로 바꿔 실행하면 첫 줄이 출력되기 전에 다음과 같이 멈춥니다.

실행 결과 (size가 5일 때)
Exception in thread "main" java.lang.IllegalArgumentException: invalid range
	at QualifiedTotalMethod.qualifiedTotal(QualifiedTotalMethod.java:11)
	at QualifiedTotalMethod.main(QualifiedTotalMethod.java:5)

qualifiedTotal(age, 4, -1)처럼 최소 나이가 음수일 때도 같은 예외가 납니다. 이 예제는 예외를 잡지 않으므로 한 번 실패하면 그 뒤의 문장은 실행되지 않습니다. 두 경우를 비교하려면 호출을 하나씩 바꿔 따로 실행하세요. 이처럼 메서드 첫머리에서 매개변수가 허용 범위인지 확인해 두면, 그 아래의 반복문은 범위가 올바르다는 전제 아래 단순하게 쓸 수 있습니다.

2. 두 값을 맞바꾸는 메서드

다음 프로그램의 출력을 실행하기 전에 예상해 보세요. 두 swap 메서드는 이름이 같지만 매개변수 목록이 달라서 함께 선언할 수 있습니다. 이렇게 이름이 같은 메서드를 여러 개 두는 오버로딩은 메서드 오버로딩과 자동 형변환에서 자세히 배웁니다.

src/SwapDemo.java
public final class SwapDemo {
    public static void main(String[] args) {
        int first = 40;
        int second = 50;
        swap(first, second);
        System.out.println(first + " " + second);

        int[] age = {40, 50};
        swap(age, 0, 1);
        System.out.println(age[0] + " " + age[1]);
    }

    private static void swap(int left, int right) {
        int temp = left;
        left = right;
        right = temp;
    }

    private static void swap(int[] values, int i, int j) {
        int temp = values[i];
        values[i] = values[j];
        values[j] = temp;
    }
}
해설 보기
실행 결과
40 50
50 40

첫 번째 swap은 first와 second의 값 40과 50을 복사해 받은 매개변수 left와 right를 맞바꿉니다. 메서드가 끝나면 이 복사본은 사라지고 main의 두 변수는 그대로라서 40 50이 출력됩니다. 두 번째 swap은 배열의 참조를 복사해 받았으므로 values와 main의 age가 같은 배열을 가리킵니다. 그 배열의 두 원소를 맞바꾸었으니 main에서도 50 40이 보입니다.

자바에는 int 변수 두 개를 받아 호출한 쪽의 변수끼리 값을 바꾸는 메서드를 만들 방법이 없습니다. 바꾼 결과가 호출한 쪽에 남으려면 배열처럼 공유되는 대상을 바꾸거나, 새 값을 반환하고 호출한 쪽이 대입해야 합니다.

다음 글

printSummary는 total을 호출하고, total이 끝나야 printSummary가 다음 줄로 넘어갑니다. 다음 글에서는 이렇게 메서드가 메서드를 부를 때 실행이 어떤 순서로 오가는지, 그리고 return이 정확히 어떤 일을 하는지 살펴봅니다.