안동민 개발노트

본문 시작

메서드의 값 전달

인자에서 매개변수로 복사되는 값과 필드 변경·매개변수 재대입의 차이를 실행으로 확인합니다.

메서드에 객체를 전달하면 객체 자체가 이동할까요, 참조가 전달될까요?

Java를 설명할 때 흔히 “기본형은 값 전달, 객체는 참조 전달”이라고 줄여 말하지만 이 표현은 호출자가 가진 변수를 메서드가 직접 바꿀 수 있다는 오해를 만듭니다.

자바는 메서드를 호출할 때도 변수의 값을 복사합니다.

기본형 인자는 숫자나 불리언 값을 복사하고, 참조형 인자는 참조값을 복사합니다.

매개변수는 호출된 메서드 안에서 새로 생기는 지역 변수입니다.

이 원칙 하나로 필드 변경은 호출자에게 보이지만 매개변수 재대입은 보이지 않는 이유를 설명할 수 있습니다.


기본형 인수 전달

다음 프로그램의 결과를 먼저 예측해 보세요.

src/members/ch04/v7/PrimitiveParameter.java
public class PrimitiveParameter {
    public static void main(String[] args) {
        int age = 30;
        increase(age);
        System.out.println("main=" + age);
    }

    static void increase(int value) {
        value += 10;
        System.out.println("method=" + value);
    }
}
method=40
main=30

increase(age)를 호출하는 순간 age의 값 30이 매개변수 value에 복사됩니다.

메서드 안의 value += 10은 지역 변수 value만 40으로 바꿉니다.

호출자의 age와 연결되어 있지 않습니다.

호출자의 값을 바꾸려면 결과를 반환하고 다시 대입해야 합니다.

age = increased(age);

static int increased(int value) {
    return value + 10;
}

반환값도 메서드에서 호출자 쪽으로 복사되는 값입니다.

increase의 지역 값 변경과 increased의 반환값 대입

두 경우 모두 호출자의 age가 30인 상태에서 독립적으로 시작합니다. void increase는 지역 변수 value만 40으로 바꾸고 호출자의 age는 30을 유지합니다. int increased는 value + 10의 계산 결과 40을 반환하고, age = increased(age)의 대입이 호출자의 age를 40으로 바꿉니다.

두 경우 모두 age = 30에서 독립적으로 시작합니다.

왼쪽은 PrimitiveParameter 원문, 오른쪽은 본문의 반환값 대안 조각
비교 지점void increase(int value)int increased(int value)
호출문increase(age);age = increased(age);
호출 시 복사age = 30에서 value = 30age = 30에서 value = 30
메서드 안value += 10;으로 지역 값이 40return value + 10;의 계산 결과가 40
반환값없음 · void40 · int
호출문 실행 후age = 30 유지반환값을 다시 대입해 age = 40
PrimitiveParameter · void increase
increase(age);
호출 시 숫자 30을 지역 변수 value에 복사합니다.
value += 10;은 value만 40으로 바꿉니다.
반환값은 없고 호출자의 age는 30입니다.
본문 대안 조각 · int increased
age = increased(age);
호출 시 숫자 30을 지역 변수 value에 복사합니다.
return value + 10;이 계산한 40을 반환합니다.
호출문이 그 반환값을 다시 대입하므로 age는 40입니다.

increased는 value 자체를 재대입하지 않습니다. 호출자의 age가 바뀌는 지점은 반환값을 받는 age = … 대입입니다.


참조형 인수 전달

이번에는 Member를 전달합니다.

src/members/ch04/v7/ReferenceParameter.java
public class ReferenceParameter {
    public static void main(String[] args) {
        Member member = new Member();
        member.email = "method@example.com";
        member.age = 30;
        member.active = false;

        activate(member);

        System.out.println(member.email);
        System.out.println(member.active);
    }

    static void activate(Member target) {
        target.active = true;
    }
}
method@example.com
true

호출 전 member에 x101이 들어 있다면 activate의 target에도 x101이 복사됩니다.

두 변수는 서로 다른 지역 변수지만 같은 인스턴스에 도달합니다.

target.active = true는 target 변수를 수정한 것이 아니라 x101 객체의 필드를 수정했으므로 호출 뒤 member.active로 읽어도 true입니다.


매개변수 재대입

참조값이 복사된다는 사실을 확인하기 가장 좋은 반례는 메서드 안에서 매개변수를 새 객체로 바꾸는 것입니다.

src/members/ch04/v7/ReassignParameter.java
public class ReassignParameter {
    public static void main(String[] args) {
        Member member = new Member();
        member.email = "original@example.com";

        replace(member);

        System.out.println(member.email);
    }

    static void replace(Member target) {
        target = new Member();
        target.email = "temporary@example.com";
    }
}
original@example.com

replace를 시작할 때는 member와 target이 모두 x101을 가리킵니다.

target = new Member()가 새 객체 x202를 만들고 지역 변수 target에 새 참조를 넣습니다.

호출자의 member 변수에는 대입하지 않았으므로 여전히 x101입니다.

메서드가 끝나면 지역 변수 target은 사라지고, 다른 참조가 없다면 x202는 더 이상 도달할 수 없습니다.

“객체를 메서드에 넘기면 메서드가 호출자의 변수를 바꿀 수 있다”는 설명은 정확하지 않습니다.

메서드는 공유 객체의 필드를 바꿀 수 있지만 호출자의 지역 변수 자체를 다른 참조로 교체할 수는 없습니다.

공유 필드 수정과 지역 매개변수 재대입의 다른 참조 관계

ReferenceParameter와 ReassignParameter는 별도 프로그램입니다. activate의 target은 호출자의 member와 같은 객체를 참조해 active를 true로 바꿉니다. replace에서는 target만 새 객체를 가리키며, 호출자의 member는 기존 객체를 계속 가리킵니다.

서로 다른 두 프로그램입니다. 둘 다 호출 시 member의 참조값을 지역 매개변수 target에 복사합니다.

ReferenceParameter · target.active = true; 직후, 반환 전

activate 안의 두 참조는 같은 객체를 가리킨다member와 target이 같은 method@example.com, age 30인 객체를 가리킨다. target.active = true가 객체 필드를 바꿔 호출자도 true를 읽는다. membermain의 지역 변수targetactivate의 매개변수같은 Member 객체email = "method@example.com"age = 30active = true
member와 target의 도착지는 같은 객체
email = "method@example.com", age = 30
target.active = true;가 그 객체의 필드를 바꿉니다.

반환 뒤 member.active로 읽어도 true입니다. target의 참조값은 바꾸지 않았습니다.

ReassignParameter · target.email = "temporary@example.com"; 직후, 반환 전

replace의 재대입은 target만 다른 객체로 옮긴다member는 original@example.com인 기존 객체를 계속 참조한다. 새 객체를 대입한 target만 temporary@example.com 객체를 가리킨다. 두 객체의 이메일 필드만 표시한다. membermain의 지역 변수targetreplace의 매개변수호출자가 만든 객체email = "original@example.com"replace가 만든 새 객체email = "temporary@example.com"
member · 호출자가 만든 객체
email = "original@example.com"
호출자의 변수에는 새 참조를 대입하지 않았습니다.
target · replace가 만든 새 객체
target = new Member();로 매개변수만 재대입합니다.
그 새 객체의 email은 "temporary@example.com"입니다.

두 객체의 이메일 필드만 표시했습니다. 반환 뒤 호출자 출력은 original@example.com입니다. target의 수명이 끝나고 다른 참조가 없다면 새 객체에는 도달할 수 없으며, 이것이 즉시 회수를 뜻하지는 않습니다.


초기화 메서드

객체를 여러 개 만들다 보면 필드 대입 코드가 반복됩니다.

Member first = new Member();
first.email = "class@example.com";
first.age = 40;
first.active = false;

Member second = new Member();
second.email = "member2@example.com";
second.age = 35;
second.active = false;

참조형 매개변수가 공유 객체를 바꾼다는 점을 이용해 초기화 메서드를 만들 수 있습니다.

src/members/ch04/v7/MemberInitializer.java
public class MemberInitializer {
    public static void main(String[] args) {
        Member first = new Member();
        init(first, "class@example.com", 40, false);

        Member second = new Member();
        init(second, "member2@example.com", 35, false);

        print(first);
        print(second);
    }

    static void init(
        Member target,
        String email,
        int age,
        boolean active
    ) {
        target.email = email;
        target.age = age;
        target.active = active;
    }

    static void print(Member member) {
        System.out.printf(
            "%s / %d세 / %s%n",
            member.email, member.age, member.active
        );
    }
}

init은 새 객체를 만들지 않습니다.

호출자가 만든 객체의 참조를 받아 필드를 채웁니다.

이 방식은 중복을 줄이지만 호출 전에 빈 객체를 만들어야 하고, 초기화를 빼먹은 객체도 여전히 존재할 수 있습니다.

생성자가 필요한 이유가 여기서 나타나지만, 그 개선은 다음 장의 객체 생명주기에서 다룹니다.


팩터리 메서드

현재 문법만으로도 객체 생성과 필드 초기화를 한 메서드에 모을 수 있습니다.

src/members/ch04/v7/MemberFactory.java
public class MemberFactory {
    public static void main(String[] args) {
        Member member = create("factory@example.com", 45, false);
        print(member);
    }

    static Member create(
        String email,
        int age,
        boolean active
    ) {
        Member created = new Member();
        created.email = email;
        created.age = age;
        created.active = active;
        return created;
    }

    static void print(Member member) {
        System.out.printf(
            "%s / %d세 / %s%n",
            member.email, member.age, member.active
        );
    }
}

create 안의 지역 변수 created는 메서드가 끝나면 사라집니다.

하지만 반환문이 그 변수의 참조값을 호출자에게 복사합니다.

호출자의 member가 같은 객체를 가리키므로 객체는 계속 도달 가능한 상태입니다.

create의 지역 변수 종료와 반환된 객체의 도달 가능성

MemberFactory의 create가 만든 객체 하나를 두 시점에서 표시합니다. 반환 전에는 created가, 반환값 대입 후에는 main의 member가 같은 객체를 참조합니다. 두 시점 모두 email은 factory@example.com, age는 45, active는 false입니다. 지역 변수 created의 수명이 끝나도 그 객체의 소멸을 뜻하지는 않습니다.

MemberFactory · create("factory@example.com", 45, false)

아래 두 시점은 new 한 번으로 만든 같은 객체를 다시 표시한 것입니다.

return 전후에 같은 객체를 가리키는 지역 변수return created 직전에는 create의 created가 객체를 참조한다. create 반환값 대입 직후에는 main의 member가 같은 객체를 참조한다. 객체 필드는 두 시점 모두 factory@example.com, 45, false이다. ① return created; 직전② main의 반환값 대입 직후 · print 호출 전createdcreate의 지역 변수membermain의 지역 변수create에서 만든 객체email = "factory@example.com"age = 45active = false반환 전과 같은 객체email = "factory@example.com"age = 45active = false
① return created; 직전
create의 지역 변수 created가 새로 만든 객체를 참조합니다.
email = "factory@example.com", age = 45, active = false
② main의 반환값 대입 직후 · print 호출 전
created의 참조값을 반환받아 member에 복사했습니다.
member는 새로 복제한 객체가 아니라 위와 같은 객체를 가리킵니다.
필드는 그대로 "factory@example.com", 45, false입니다.

create가 끝나면 지역 변수 created의 수명은 끝납니다. 호출자의 member가 같은 객체를 참조하므로 그 객체에는 계속 도달할 수 있습니다.

지역 변수의 수명과 객체의 수명은 같지 않습니다.

객체는 특정 지역 변수에 소속되는 것이 아니라 도달 가능한 참조가 있는 동안 사용될 수 있습니다.


변경 메서드의 이름

참조형 매개변수를 받는 메서드는 전달된 객체를 읽기만 할 수도 있고 필드를 바꿀 수도 있습니다.

static void print(Member member) { ... }    // 읽기
static void activate(Member member) { ... } // 변경

컴파일러가 이 차이를 메서드 시그니처만으로 강제하지는 않습니다.

따라서 이름, 문서, 객체 설계로 변경 여부를 드러내야 합니다.

print가 몰래 active = true로 바꾼다면 호출자는 출력만 요청했다고 생각했다가 예상하지 못한 상태 변경을 만납니다.

현재의 Member는 필드 접근 제한도 없고 객체가 스스로 행동하지도 않는 단순 데이터 묶음입니다.

이후 접근 제어와 캡슐화를 배우면 변경 책임을 객체 안으로 옮길 수 있습니다.

지금은 호출 경계에서 어떤 값이 복사되는지 확실히 보는 것이 우선입니다.


연습 문제

다음 두 메서드를 구현하고 차이를 설명하세요.

  1. int nextAge(int current)는 다음 나이를 반환합니다.
  2. void celebrateBirthday(Member member)는 전달된 회원의 나이를 1 증가시킵니다.
  3. 현재 나이가 120이면 두 메서드 모두 IllegalStateException을 던집니다.
  4. replace(Member member)에서 매개변수에 새 객체를 대입해도 호출자 변수가 바뀌지 않는 실행 사례를 추가합니다.
해설 보기
src/members/ch04/exercise/BirthdayParameter.java
public class BirthdayParameter {
    public static void main(String[] args) {
        int age = 30;
        age = nextAge(age);
        System.out.println("number=" + age);

        Member member = new Member();
        member.email = "method@example.com";
        member.age = 30;
        celebrateBirthday(member);
        System.out.println("member=" + member.age);

        replace(member);
        System.out.println("after replace=" + member.email);
    }

    static int nextAge(int current) {
        requireBirthdayAllowed(current);
        return current + 1;
    }

    static void celebrateBirthday(Member member) {
        requireBirthdayAllowed(member.age);
        member.age++;
    }

    static void replace(Member member) {
        member = new Member();
        member.email = "replacement@example.com";
    }

    static void requireBirthdayAllowed(int currentAge) {
        if (currentAge >= 120) {
            throw new IllegalStateException("maximum age");
        }
    }
}
number=31
member=31
after replace=method@example.com

숫자 버전은 계산 결과를 반환하므로 호출자가 다시 대입해야 합니다.

객체 버전은 복사된 참조로 공유 객체의 필드를 바꿉니다.

replace는 지역 매개변수만 새 참조로 바꾸므로 호출자의 member.email은 그대로입니다.


메서드 호출도 대입과 똑같이 값을 복사합니다.

이 규칙을 알면 필드 변경, 재대입, 반환 참조를 하나의 흐름으로 설명할 수 있습니다.

다음 절에서는 참조값이 아예 없음을 나타내는 null과 점 연산이 만날 때 왜 실행이 중단되는지 조사합니다.