2012년 5월 9일 수요일

Java 인자 전달 방식: Call-by-{Value | Reference}?


[이미지 출처 - Google]
C++를 마지막으로 사용해본지 거의 6년이 다 되어가는 지금 Java를 다루면서 잊고 있었던 "Parameter passing in Java: call-by-value vs call-by-reference?"란 주제가 기본을 일깨워 주는 좋은 양식이 되겠다 싶어 내용을 정리해 보려고 합니다.

이 주제는 지금까지 다양한 커뮤니티상에서 오랫동안 논의 되었고, 그로 인해 잘 정리된 문서와 사이트를 어렵지 않게 찾을 수 있어 최대한 많은 참조를 토대로 작성하고자 합니다.

Java 개발자로서 두 매커니즘의 차이에 혼돈을 일으키는 이유는 아마도
  • 서로 다른 인자전달 매커니즘을 가지는 언어를 두루 사용해 본 경험이 없거나,
    • 이 경우는 왜 이 문제가 그토록 많이 논의되었는지 잘 이해하지 못함
  • 경험이 있다 하더라도 정확한 이해없이 흘려버린 경우
등이 있지않나 생각됩니다.(고슬링 옹이 직접 시원하게 말해줬음 좋겠구만 @^^@)

0. 사전지식(Parameter, Argument, Syntax/Semantics, Reference)

메서드나 함수의 인자 전달을 설명 할 때 항상 등장하는 단어가 parameter와 argument입니다. 두 단어 모두 인자를 표현하는 단어지만 구분해서 쓰자면 formal-parameter(형식인자)와 actual-parameter(실인자)로 이해하는 것이 적당합니다. 다음 코드에서 parameter와 argument의 차이를 간단히 보도록 하겠습니다.
public class WhatIsParameter {
    //int형 변수 something은 formal-parameter
    public static void doSomething(int something) {
        System.out.println("something is " + something);
    }
    public static void main(String[] args) {
        int ten = 10;
        doSomething(ten); //ten의 값 10은 actual-parameter
        System.exit(0);
    }
}
Syntax와 semantics는 여기서 간단히 다룰 주제는 아니지만 이해를 돕기위한 것이니 아래 코드 정도만 두 용어로 이해해 봅니다.
//Case1.
int primitiveIntVar = 1024;
//Case2.
String whoAreYou = "It's me! Uncle Bob";
두 문장은 모두 올바른 Java 문법으로 작성되었습니다. Syntax는 말 그대로 언어의 구문규칙을 의미합니다. 그럼 semantics는 뭘까요? 그대로 해석하면 "의미"인데 누구한테 가치가 있는 "의미"일까요? 언어의 semantics는 컴파일러가 구문(syntax)을 분석한 뒤 해석하여 실제로 동작에 사용되는 명령어를 생산해 내는데 사용되는 지침(모델)입니다.

Case1과 Case2의 syntax는 거의 비슷하지만 semantics는 완전히 다릅니다. Case1은 "정수형 변수 primitiveIntVar에 정수 1024를 저장"하는 명령어를 생성해 내라는 의미라면, Case2는 "문자열 객체 It's me! Uncle Bob을 생성하고, String 타입의 변수 whoAreYou에 생성된 객체의 레퍼런스를 저장"하는 명령어를 만들어 내시오라는 의미를 가지고 있습니다.

여기서 한 가지 기억해야 할 점은, primitiveIntVar 변수의 값이 1024인 반면, whoAreYou 참조변수의 값은 "It's me! Uncle Bob"이 아닌 이 객체를 참조 할 수 있는 포인터(pointers) 값 즉, 레퍼런스란 사실입니다.(다음은 Java(SE7) Language Spec.의 4.3.1 Object 파트의 일부 내용입니다.)

"...... The reference values(often just references) are pointers to these objects, and a special null reference, which refers to no object. ......"

요약하면, 메서드의 인자로 값을 전달하는 Java 언어의 매커니즘이 call-by-value semantic인가 call-by-reference semantic인가를 알아보는 것이 본 포스트의 목적입니다.(아..서론이 너무 길었나 ㅠㅠ)

1. 인자전달 방식


call-by-value와 call-by-reference semantic의 정의를 알아보기 위해 Wikipedia를 방문했습니다.(두 semantics의 정의안에 "오해의 소지"와 "정답의 근거"가 있다고 판단되기에 짚고 넘어갑니다.)
Call-by-value
In call-by-value, the argument expression is evaluated, and the resulting value is bound to the corresponding variable in the function (frequently by copying the value into a new memory region). If the function or procedure is able to assign values to its parameters, only its local copy is assigned — that is, anything passed into a function call is unchanged in the caller's scope when the function returns.
위 내용에 따르면, call-by-value 매커니즘은 함수로 인자를 전달할 때 전달 될 argument expression의 결과(actual-parameter)를 대응되는 함수의 변수(formal-parameter)로 복사하며, 복사된 값은 함수내에서 지역적으로 사용되는 local value라는 특징을 가지고 있습니다. 그리고 caller는 인자값을 복사 방식으로 넘겨 주었으므로, callee 내에서 어떤 작업을 하더라도 caller는 영향을 받지 않습니다. Call-by-value의 대표적인 예제 코드가 C언어에서 포인터를 설명할 때 등장하는 swap 함수입니다.
void swap(int first, int second) {
    int tmp = first;
    first = second;
    second = tmp;
}
int main(int argc, char** argv) {
    int x = 10, y = 20;
    swap(x, y);
    printf("x = %d, y = %d\n", x, y);
    return 0;
}
x = 10, y = 20

위 코드에서 main 함수가 swap 함수를 호출할 때 swap 함수가 필요로 하는 x와 y의 값을 먼저 계산하고 그 값(10과 20)을 swap 함수로 복사해서 넘겨줍니다. swap 함수는 함수가 실행 될 때 스택에 first, second, tmp 세 임시 저장공간을 만들었다가 전달 받은 10과 20을 각각 first와 second에 저장한 후 함수내 코드를 실행합니다. swap 함수는 종료할 때 임시로 생성했던 모든 공간을 삭제하므로 first, second, tmp로 작업했던 내용은 모두 사라지게 되고, main 함수의 x와 y는 그대로 10과 20을 유지하고 있습니다. 요약하면 first와 second 그리고 x와 y는 아무런 관련이 없는 독립적인 변수들입니다.

그럼 call-by-reference에 대한 정의를 살펴보도록 합시다.
Call-by-reference
In call-by-reference evaluation(also referred to as pass-by-reference), a function receives an implicit reference to a variable used as argument, rather than a copy of its value. This typically means that the function can modify (i.e. assign to) the variable used as argument—something that will be seen by its caller.
Call-by-reference는 인자로 사용될 변수의 묵시적인 레퍼런스를 함수로 전달하며, 그것이 변수의 값은 아니다라고 되있습니다. 이해를 돕기 위해 이전 swap 함수의 예를 다시 보면, 만약 call-by-reference로 swap 함수에 뭔가를 전달한다면 x와 y가 가지고 있는 값(10, 20)이 아닌 x, y 변수자체에 대한 레퍼런스를 전달해야 합니다. C언어는 포인터로 call-by-reference를 구현할 수 있습니다.
void swap(int* first, int* second) {
    int tmp = *first;
    *first = *second;
    *second = tmp;
}
int main(int argc, char** argv) {
    int x = 10, y = 20;
    swap(&x, &y);
    printf("x = %d, y = %d\n", x, y);
    return 0;
}
x = 20, y = 10
위 코드에서는 x와 y가 가진 값을 swap 함수로 전달 한 것이 아닌, x와 y 자체의 레퍼런스(주소 값)를 전달했습니다. 마지막으로 다음 C코드를 통해 두 내용을 정리해 봅시다. 아래 코드에서 (1)(2)(3)은 각각 call-by-value/call-by-reference일까요?
void modify(int p, int* q, int* r) {
    p = 27;  //(1)
    *q = 27; //(2)
    *r = 27; //(3)
}
해답은 (1)의 경우, call-by-value이고, (2)(3)은 함수를 호출한 caller가 어떤 값을 actual-parameter로 넘겨주었는지 확인해 보아야 알 수 있습니다. 만약 caller가 다음과 같다면
int main(int argc, char** argv) {
    int a = 1;
    int b = 1;
    int x = 1;
    int* c = &x;
    modify(a, &b, c);   // a is passed by value, b is passed by reference by creating a pointer,
                        // c is a pointer passed by value
    return 0;
}
b는 call-by-reference로 전달된 것이고, c는 call-by-value로 전달된 것입니다. 단 c의 경우 c 자체의 레퍼런스가 아닌 c가 저장하고 있는 값(x에 대한 레퍼런스 값)을 넘겨준 경우입니다. 결과적으로 함수호출 후 caller에서 변경되는 변수는 b와 x 두 개입니다.

2. 오해의 소지


Java의 경우를 살펴봅시다. 우선 Java의 메서드 인자전달 방식이 무엇인가를 판단하기 위해 두 매커니즘의 가장 큰 특징을 위주로 검증해 보겠습니다. Call-by-value의 특징은
  • Actual-parameter 의 값을 복사하여 전달하므로 caller는 callee의 작업(?)에 영향을 받지 않는다!!!
입니다. 조금 느슨하게 적은 감이 팍 오지만 다음 코드로 위 내용을 살펴봅시다. 테스트용으로 사용할 Person 클래스입니다.
public class Person {
    private String mName;
    
    public Person(String name) { 
        mName = name; 
    }
    public void setName(String name) { 
        mName = name; 
    }
    @Override
    public String toString() {
        return "Person " + mName;
    }
}
다음은 Person 객체를 넘겨받아 새로운 Person 객체로 변경하는 메서드 호출 코드입니다.
public class CallyByXXXTest {
    public static void assignNewPerson(Person p) {
        p = new Person("Bob");
    }
    public static void main(String[] args) {
        Person sam = new Person("Sam");
        assignNewPerson(sam);
        System.out.println(sam);
        System.exit(0);
    }
}
Person sam
sam 객체를 생성한 후 assignNewPerson 메서드로 전달하고 메서드 종료 후 sam 객체가 bob 객체로 변경되었는지 확인해 봤습니다. 결과는 아무런 변화가 없습니다. 그럼 call-by-value semantic일까요? 다음 코드를 봅시다.
    public static void changePersonName(Person p) {
        p.setName("Bob");
    }
    public static void main(String[] args) {
        Person sam = new Person("Sam");
        changePersonName(sam);
        System.out.println(sam);
        
        System.exit(0);
    }
Person bob
sam 객체의 mName 필드를 변경하는 changePersonName 메서드를 실행한 후 결과를 확인해 보니 변경사항이 발생했습니다. 오해의 소지는 여기서 그리고 용어에서 발생합니다.
  1. 특정 메서드 내에서 전달 받은 객체의 상태를 변경 할 수 있음. 
  2. 참조변수는 임의의 객체에 대한 레퍼런스를 저장하므로 메서드로 전달한 값이 레퍼런스(call-by-reference?)
1의 경우, sam 참조변수가 가리키는  [이름 속성이 "Sam"인 Person 객체]를 [이름 속성이 "Bob"인 새로운 Person 객체]로 변경한 것이 아니라, 단지 이름 속성만 변경했을 뿐입니다.

2의 경우, 전달 된 레퍼런스는 참조변수 sam 자체의 레퍼런스(주소값?)가 아닌 sam이 저장하고 있는 값(이것도 레퍼런스)입니다.
    만약 Java가 call-by-reference 매커니즘을 지원한다면 참조변수 sam 자체의 레퍼런스(주소)를 얻을 수 있는 방법이 있어야 합니다. 그러나 Java는 이 방법을 지원하지 않습니다. 따라서 "참조변수 sam에 저장된 값(다른 객체의 레퍼런스 값)"을 복사하여 formal-parameter p로 넘겨준 call-by-value 매커니즘이 합당해 보입니다.

3. Java를 왜 이렇게 설계했을까?

다음 인용문을 통해 이 질문을 정리할까 합니다.
It seems that the designers of Java wanted to make sure nobody confused their object pointers with the evil manipulable pointers of C and C++, so they decided to call them references instead. So Java books usually say that objects are stored as references, leading people to think that objects are passed by reference. They’re not, they’re passed by value, it’s just that the value [on the stack at JVM level] is a pointer.
4. 결론
  • 두 매커니즘의 정리
    • passing value of actual-parameter variable : call-by-value
    • passing reference of actual-parameter variable : call-by-reference
  • Java의 메서드 인자전달 방식은 call-by-value
    • value란? 객체에 대한 포인터 값(레퍼런스) 또는 primitive 타입의 값

댓글 12개:

  1. 감사합니다
    가려운부분을시원하게 긁어주시네요 ㅋㅋ
    공유 좀 할께요...
    http://blog.naver.com/dh8216

    답글삭제
  2. p = new Person("Bob"); 라는게 참조변수 p가 예를들어 0x001 라는 주소값을 가리키고 있다면,
    new 명령어를 사용하면, p 참조변수가 0x002 라는 주소값을 가진 변수를 가리키게 되는거 아닌가요? 당연히 변경이 안될듯한데요.

    답글삭제
  3. 제 블로그에 링크 걸어두겠습니다 감사합니다

    답글삭제
  4. 4. 결론가 잘 이해가 안되는데 추가 설명을 해주실수 있으신가요?

    답글삭제
    답글
    1. 3이요..왜 이렇게 설명했는지 원문을 봐도 잘 이해가 안됩니다 ㅠ

      삭제
    2. 저도 이해하는 데에 조금 시간이 걸렸네요. 결론적으로 자바는 call-by-value를 인자전달방식으로 가지고 있다는 것입니다. assignNewPerson 메쏘드와 changePersonName 메쏘드가 생각과 다르게 동작하는 이유는, 인자로 받은 객체의 레퍼런스가 복사되어서 메쏘드 내에서 local 변수로 쓰이기 때문입니다.
      그래서 assignNewPerson 메쏘드에서 인자로 받은 p가 바뀌지 않은 이유는, p는 객체의 복사된 레퍼런스이기 때문입니다. 그래서 p 자체를 수정하더라도 sam에 영향을 주지 못한것이죠.
      changePersonName 메쏘드의 경우는 p를 객체의 복사된 레퍼런스로 받았습니다. 하지만 레퍼런스 p는 내부에 p.mName을 레퍼런스로 가지고 있습니다. 그래서 p.setName("Bob")을 해줬을 경우에 p 내부에 저장된 mName이 가리키고 있는 값을 바꿔주기 때문에 p 내부의 필드인 mName의 값이 바뀐 것이죠. 이해가 되셨을 지 모르겠네요^^

      삭제
    3. 작성자가 댓글을 삭제했습니다.

      삭제
    4. 복사된 레퍼런스가 중요한게 아니라 키워드 new가 중요한 것 아닌가요 ?
      p= new Person("Bob"); 에서 새로운 메모리 참조를 할당해줬기 때문에 기존의 mname이 바뀌지 않았다 라고 생각됩니다.
      실제로 assignNewPerson 메소드 내에서 syso출력문을 가동해 p의 메모리 위치를 출력했을 때
      syso(sam)과 레퍼런스 값이 다름을 확인할 수 있었습니다
      즉, assignNewPerson메소드의 매개변수에 sam이라는 객체가 들어갈때까진 복사된 레퍼런스를 받아
      같은 장소를 가리키고 있었죠 .
      그러다가 new 명령어를 만나 p는 더 이상 전달된 sam과는 같은 장소를 가리키지 않게 된 것입니다

      삭제
  5. 레퍼런스란 참조를 의미하고 즉 포인터를 의미합니다.
    자바에서 테스트하신 동작은 당연히 콜바이 레퍼런스가 정상 작동한겁니다.
    결론적으로
    public static void assignNewPerson(Person p) {
    p = new Person("Bob");
    // p는 sam의 주소를 가지고 있는 포인터입니다.
    // 다른 주소값을 할당했습니다. sam에게는 아무 영향이 없음이 당연.
    // 레퍼런스를 변경한다고 해서 sam이 영향을 받는건 아닙니다.
    // 레퍼런스를 통해서 sam에 접근이 가능하기에 영향을 받는거죠.
    }

    답글삭제
  6. 작성자가 댓글을 삭제했습니다.

    답글삭제