본문 바로가기
Java

Call by Value vs Call by Reference

by 정권이 내 2026. 3. 9.

💡 왜 우리는 값과 참조의 전달 방식을 알아야 할까?

메서드를 만들고, 그 메서드에 변수를 인자로 넘겨주는 과정 속에서, 가끔 예상치 못한 버그와 마주치곤 합니다. 분명히 데이터를 넘겨서 가공했는데 원본 데이터가 바뀌어버리거나, 반대로 값이 바뀌길 기대하고 넘겼는데 원본은 그대로인 당황스러운 상황 말이죠.

이러한 문제의 근본적인 원인은 바로 프로그래밍 언어가 변수를 메서드에 전달하는 방식, 즉 Call by Value(값에 의한 호출)Call by Reference(참조에 의한 호출)의 차이를 정확히 이해하지 못했기 때문에 발생합니다.

현대적인 백엔드 아키텍처에서는 수많은 객체들이 계층을 넘나들며 데이터를 주고받습니다. 내가 넘긴 이 변수가 메모리 상에서 어떻게 동작하는지 완벽하게 통제하지 못한다면 데이터 무결성 훼손으로 이어질 수 있습니다.

특히 멀티 스레드 환경에서 공유 객체의 상태가 의도치 않게 변경된다면, 추적하기조차 힘든 치명적인 장애를 유발하게 됩니다.

🤦‍♂️ 자주 발생하는 실수

개발을 막 시작했을 때, 혹은 다른 언어를 쓰다가 자바로 넘어왔을 때 가장 많이 겪는 혼란스러운 시행착오 상황을 떠올려 봅시다.

대표적인 예시는 바로 두 변수의 값을 교환하는 Swap 메서드를 만들 때입니다. C 언어나 C++에서 포인터나 레퍼런스를 사용해 두 변수의 값을 성공적으로 바꿨던 기억을 가지고, 자바에서도 똑같이 코드를 작성해 봅니다.

public class SwapTest {
    public static void main(String[] args) {
        int a = 10;
        int b = 20;

        System.out.println("Swap 호출 전: a=" + a + ", b=" + b);
        swap(a, b);
        System.out.println("Swap 호출 후: a=" + a + ", b=" + b);
    }

    public static void swap(int x, int y) {
        int temp = x;
        x = y;
        y = temp;
        System.out.println("메서드 내부: x=" + x + ", y=" + y);
    }
}

두 개의 정수형 변수를 Swap 메서드에 넘기고, 메서드 내부에서 임시 변수를 사용해 두 값을 교환합니다. 메서드 내부에서 로그를 찍어보면 분명히 값이 바뀌어 있습니다. "아싸, 성공이다!"를 외치고 메서드를 호출한 메인 로직으로 돌아와 변수를 확인하면 원본 변수의 값은 바뀌지 않고 그대로입니다.

이때 첫 번째 멘붕이 찾아옵니다. "왜 내가 넘긴 인자가 바뀌지 않았지?"

이후 경험이 조금 더 쌓이고 객체 지향 프로그래밍에 익숙해지면서, 이번에는 사용자 정보를 담고 있는 User 객체를 특정 비즈니스 로직을 처리하는 메서드에 넘기게 됩니다. 메서드 내부에서 User 객체의 이름을 변경하고, 포인트를 적립해 줬습니다. 그리고 메인 로직으로 돌아와 보니 원본 User 객체의 이름과 포인트가 전부 변경되어 있는 것을 발견합니다.

public class UserTest {
    public static void main(String[] args) {
        User myUser = new User("김개발", 1000);

        System.out.println("로직 실행 전: " + myUser.name + ", " + myUser.point);
        updateUser(myUser);
        System.out.println("로직 실행 후: " + myUser.name + ", " + myUser.point);
    }

    public static void updateUser(User user) {
        user.name = "김시니어";
        user.point += 500;
    }
}

class User {
    String name;
    int point;

    public User(String name, int point) {
        this.name = name;
        this.point = point;
    }
}

메인 로직으로 돌아와 보니 원본 User 객체의 이름과 포인트가 변경되어 있는 것을 발견합니다. 여기서 착각하는것이 "자바는 기본 타입은 Call by Value로 동작하고, 객체는 Call by Reference로 동작하는구나!"

많은 개발자분들이 위와 같이 자바의 동작 방식을 절반의 정답으로 오해하고 있습니다. 객체의 상태가 변경되는 현상을 보고 단순히 참조에 의한 호출이라고 착각하게 되는 것이죠.

하지만 메서드 내부에서 파라미터로 받은 객체 자체에 new 키워드를 사용하여 아예 새로운 객체를 재할당 하는 코드를 작성해 보면, 다시 한번 원본 객체는 아무런 영향을 받지 않는다는 것을 알게 됩니다. Call by Reference라면 파라미터에 새로운 객체를 덮어씌웠을 때 원본 변수도 새로운 객체를 바라보아야 하는데 말입니다.

이러한 시행착오들은 단순히 문법을 외운다고 해결되지 않습니다. 변수가 메모리에 어떻게 할당되고, 메서드를 호출할 때 그 메모리 주소값들이 어떻게 복사되고 전달되는지, JVM의 Stack 영역과 Heap 영역의 역할을 정확히 알아야 이 착각에서 벗어날수 있습니다.

🚀 핵심 : 값과 참조, 그리고 자바의 비밀

컴퓨터 과학에서 함수에 인자를 전달하는 방식은 크게 두 가지 파벌로 나뉩니다. 이 둘의 차이를 메모리 관점에서 명확히 이해하는 것이 오늘의 목표입니다.

📦 Call by Value (값에 의한 호출)

Call by Value는 아주 정직하고 보수적인 방식입니다. 메서드를 호출할 때 파라미터로 넘겨주는 변수의 값(Value) 그 자체를 복사하여 전달합니다.

제가 아주 중요한 레시피가 적힌 노트(원본 변수)를 가지고 있습니다. 친구가 그 레시피를 빌려달라고 합니다. 저는 제 소중한 원본 노트를 통째로 넘겨주는 대신, 복사기에 노트를 넣고 똑같이 복사본을 하나 만들어서 친구에게 줍니다.

친구는 그 복사본 레시피를 메서드 내부에서 마음대로 수정할 수 있습니다. 하지만 친구가 복사본에 무슨 짓을 하든 간에, 제 손에 있는 원본 내용은 절대 바뀌지 않습니다. 원본과 복사본은 완전히 독립적인 존재이기 때문입니다.

프로그래밍 메모리 관점에서 보면, 메서드가 호출될 때 JVM의 Stack 메모리 영역에는 해당 메서드를 위한 새로운 프레임이 생성됩니다. 이때 인자로 전달된 변수의 실제 값이 새로운 메모리 공간에 똑같이 복사되어 할당됩니다.

따라서 호출된 메서드 내부에서 값을 변경하더라도, 호출한 쪽의 원본 변수가 위치한 메모리 공간에는 접근도 할 수 없으므로 원본 데이터는 보존됩니다.

🔗 Call by Reference (참조에 의한 호출)

반면에 Call by Reference는 조금 더 과감하고 직접적인 방식입니다. 메서드를 호출할 때 변수의 값을 복사하는 것이 아니라, 변수가 저장되어 있는 실제 메모리 주소(Reference)를 직접 전달합니다.

이번에는 레시피를 빌려달라고 할 때, 복사본 대신, 원본 노트의 위치를 알려주고 친구가 원본 노트를 수정한다면 원본의 내용이 바뀌게 됩니다.

메모리 관점에서 설명하자면, 호출된 메서드는 새로운 메모리 공간에 값을 복사하지 않고 원본 변수가 위치한 메모리 주소를 그대로 가리키는 포인터를 넘겨받습니다. 따라서 메서드 내부에서 전달받은 파라미터를 조작하는 것은 메모리 주소를 찾아가 원본 데이터를 수정하는 것과 같습니다. 이는 데이터의 변경이 빠르고 메모리 낭비가 없다는 장점이 있지만, 어디서 원본 데이터가 변경되었는지 추적하기 어려워 부작용(Side Effect)을 발생시키는 단점도 존재합니다.

☕ Java는 어느 쪽일까?

자바는 무조건, 예외 없이, 100% Call by Value 방식으로만 동작합니다.

"네? 아까 객체를 넘기면 값이 바뀐다고 했잖아요!"라고 반문하실 수 있습니다. 맞습니다. 이것이 바로 자바 개발자들을 혼란에 빠뜨리는 착시 현상입니다.

Call by Value로 동작한다는 것을 이해하기 위해서는 자바의 데이터 타입과 JVM 메모리 구조를 연결 지어 생각해야 합니다.

Java의 데이터 타입과 메모리 저장 방식

1. 데이터 타입의 분류

  • 기본 타입 (Primitive Type): int, double, boolean, char
  • 참조 타입 (Reference Type): String, 배열(Array), 개발자가 만든 클래스(객체) 등

2. 메모리 저장 방식

데이터가 메모리에 쌓이는 방식은 타입(기본/참조)과 변수가 선언된 위치에 따라 다르게 동작합니다.

A. 메서드 내부에서 선언된 경우 (지역 변수)

  • 기본 타입: 실제 데이터 값을 JVM의 Stack 영역에 직접 저장합니다.
  • 참조 타입: 실제 데이터(객체 본체)는 Heap 영역에 생성하고, Stack 영역에는 그 Heap 영역을 가리키는 메모리 주소값(참조값)만 저장합니다.

B. 클래스 내부에서 선언된 경우 (인스턴스 변수 / 필드)

  • 기본 타입이든 참조 타입이든 관계없이, new 키워드를 통해 객체가 생성될 때 객체의 일부분으로서 모두 Heap 영역에 함께 저장됩니다.
  • 이때 Stack 영역의 변수는 이 인스턴스 변수들 각각의 주소가 아닌, 이들을 모두 품고 있는 '객체 전체의 시작 주소' 하나만 가지고 있습니다.

자바는 Call by Value라고 했고 변수 안에 들어있는 값을 복사해서 넘긴다는 뜻입니다. 기본 타입을 넘길 때는 Stack 변수 안에 있는 10이나 20 같은 실제 숫자가 복사되어 넘어가기 때문에 원본에 영향이 없습니다.

참조 타입을 넘길 때는 Stack 변수 안에 있는 Heap 영역을 가리키는 메모리 주소값이 복사되어 넘어갑니다. 즉, 객체 자체를 참조로 넘기는 것(Call by Reference)이 아니라, 객체의 메모리 주소라는 값을 복사해서 넘기는 것(Call by Value)입니다.

코드를 통해 이 착시 현상을 완벽하게 뜯어보겠습니다.

public class PassingExample {

    public static void main(String[] args) {
        int primitiveNumber = 10;
        System.out.println("메서드 호출 전 (기본 타입): " + primitiveNumber);
        modifyPrimitive(primitiveNumber);
        System.out.println("메서드 호출 후 (기본 타입): " + primitiveNumber); // 10 유지됨

        MyObject referenceObject = new MyObject(100);
        System.out.println("메서드 호출 전 (객체 상태): " + referenceObject.value);
        modifyObjectState(referenceObject);
        System.out.println("메서드 호출 후 (객체 상태): " + referenceObject.value); // 999로 변경됨! (착시의 원인)

        System.out.println("메서드 호출 전 (객체 자체): " + referenceObject.value);
        reassignObject(referenceObject);
        System.out.println("메서드 호출 후 (객체 자체): " + referenceObject.value); // 여전히 999! 새 객체로 덮어씌워지지 않음!
    }

    // 1번 케이스: 기본 타입 전달
    public static void modifyPrimitive(int number) {
        number = 999; 
    }

    // 2번 케이스: 참조 타입 전달 후 내부 상태 변경
    public static void modifyObjectState(MyObject obj) {
        obj.value = 999; 
    }

    // 3번 케이스: 참조 타입 전달 후 새로운 객체 할당
    public static void reassignObject(MyObject obj) {
        obj = new MyObject(555); 
    }
}

class MyObject {
    public int value;
    public MyObject(int value) {
        this.value = value;
    }
}

위 코드를 머릿속에서 JVM 메모리로 그려봅시다.

1번 케이스 (modifyPrimitive)

메인 메서드의 primitiveNumber 지역변수는 10이 할당되있고 이 값을 modifyPrimitive로 넘길 때, 10이라는 값 자체가 복사됩니다. modifyPrimitive 내부의 number 변수는 완전히 새로운 메모리 공간에 10을 복사받고, 그것을 999로 바꿉니다. 원본 primitiveNumber와는 아무 상관이 없는 행동입니다.

2번 케이스 (modifyObjectState)

메인 메서드의 referenceObject 객체는 Stack에 존재하며, Heap에 생성된 MyObject(100)의 주소(예: 0x100)를 값으로 가집니다. modifyObjectState 메서드로 넘길 때, 객체 자체가 넘어가는 것이 아니라 0x100이라는 주소값이 복사되어 넘어갑니다.

이제 modifyObjectState 메서드의 파라미터 obj 역시 0x100이라는 주소값을 가지게 되었습니다. 원본 referenceObject 객체와 파라미터 obj는 서로 다른 Stack 변수이지만, 복사된 주소값이 같기 때문에 Heap 영역의 같은 객체를 바라보고 있는 상태가 된 것입니다.

이 상태에서 obj.value = 999; 를 실행하면 0x100 주소로 찾아가서 값을 바꿔버립니다. 메인 메서드로 돌아와서 원본 referenceObject가 0x100 주소를 확인해보니 당연히 값이 999로 바뀌어 있는 것입니다. 즉, Call by Reference가 아니라, 주소값을 복사하여 전달하는 Call by Value이기 때문에 발생하는 현상입니다.

3번 케이스 (reassignObject)

자바가 Call by Value라는 것을 증명하는 결정적인 코드입니다. 만약 진정한 Call by Reference였다면 원본 변수 자체를 새로운 객체로 바꿔치기할 수 있어야 합니다.

주소값을 복사받은 파라미터 obj에 새로운 객체 new MyObject(555)를 생성하여 할당해 버립니다. 새로운 객체의 주소가 0x200이라고 가정해 봅시다.

이제 파라미터 obj는 0x200이라는 새로운 주소값을 가지게 되었지만 로컬 변수 obj의 값이 변경된 것일 뿐이고 메인 메서드에 있는 원본 변수 referenceObject는 여전히 0x100이라는 주소값을 가지고 있습니다. 따라서 메인 메서드로 돌아와도 원본 객체는 전혀 변함없이 유지되는 것입니다.

결론적으로 자바는 전달받은 주소값을 통해 원본 객체의 내부 상태(필드값)는 변경할 수 있지만, 원본 변수가 가리키는 대상 자체를 다른 객체로 바꿔치기할 수는 없습니다. 주소값을 가지고 있는 변수를 값으로 복사해서 전달했기 때문입니다.

📝 오늘의 3줄 요약

  • Call by Value는 값 자체를 복사하여 넘기고, Call by Reference는 메모리 주소를 직접 넘기는 방식이다.
  • 자바는 무조건 100% Call by Value 방식으로만 동작하며, 결코 Call by Reference 방식을 지원하지 않는다.
  • 자바에서 객체를 넘길 때 원본이 변경되는 것처럼 보이는 이유는, 객체의 메모리 주소값 자체를 복사해서 전달(Call by Value)하여 원본과 동일한 Heap 메모리를 바라보기 때문이다.
반응형

댓글