1. 시작하며: 많은 자바 개발자가 겪는 혼란
자바(Java) 면접이나 실무 개발 과정에서 단골로 등장하는 논쟁거리가 있습니다. 바로 "자바의 인자 전달 방식은 Pass by Value(값에 의한 전달)인가, Pass by Reference(참조에 의한 전달)인가?" 하는 질문입니다.
초보 개발자들은 종종 메서드에 객체를 넘긴 후, 메서드 내부에서 해당 객체의 상태를 변경(object.setName("new"))하면 원본 객체의 상태도 함께 변경되는 것을 보고 "자바는 객체를 Pass by Reference로 전달한다"고 오해하곤 합니다. 얼핏 보면 원본 데이터가 수정되었으니 참조(주소) 자체가 통째로 넘어간 것처럼 보이기 때문입니다.
하지만 결론부터 말씀드리자면, 자바는 원시 타입(Primitive type)이든 참조 타입(Reference type)이든 관계없이 오직 'Pass by Value' 방식만을 사용합니다. 이 글에서는 운영체제(OS)의 메모리 관리 방식과 컴퓨터 과학의 근본적인 원리를 통해, 왜 자바가 철저하게 '값에 의한 전달'을 고집하는지, 그리고 객체를 넘길 때 내부적으로 정확히 어떤 일이 벌어지는지 깊이 있게 파헤쳐 보겠습니다.
2. 컴퓨터 과학의 핵심: 인자 전달 방식의 엄밀한 정의
자바의 동작 원리를 이해하려면 먼저 컴퓨터 과학에서 정의하는 두 가지 인자 전달 방식의 엄밀한 차이를 알아야 합니다.
값에 의한 전달 (Pass by Value)
함수를 호출할 때 전달되는 변수의 **'값(Value)을 복사'**하여 함수의 인자로 전달하는 방식입니다. 호출된 함수 내부에서 복사된 인자의 값을 아무리 변경해도, 호출한 쪽(Caller)의 원본 변수에는 전혀 영향을 주지 않습니다. 이 방식은 원본 데이터를 안전하게 보호할 수 있다는 장점이 있습니다.
참조에 의한 전달 (Pass by Reference)
함수를 호출할 때 변수의 값이 아닌, 변수가 저장된 '메모리 주소(Reference/Pointer) 자체'를 직접 전달하는 방식입니다.
이해를 돕기 위해 하위 수준의 네트워크 프로그래밍 구조를 살펴보겠습니다. C 언어 기반의 소켓(Socket) 통신 API에서는 프로세스와 커널(Kernel) 간에 데이터를 주고받을 때 포인터를 통한 '참조에 의한 전달'이 필수적으로 사용됩니다[1, 2].
예를 들어 `accept`나 `recvfrom` 함수를 호출할 때, 프로세스는 소켓 주소 구조체의 길이를 담은 변수의 메모리 주소(포인터)를 커널에 넘깁니다[2]. 커널은 함수 실행을 마친 뒤, 자신이 실제로 기록한 데이터의 크기를 프로세스가 넘겨준 메모리 주소에 직접 덮어써서(Overwrite) 반환합니다[2]. 이처럼 호출된 측(커널)이 호출한 측(프로세스)의 원본 변수 자체를 다른 값이나 다른 주소로 완전히 바꿔버릴 수 있는 것이 진정한 의미의 'Pass by Reference'이며, 이를 네트워크 프로그래밍에서는 값-결과 인자(Value-Result Argument)라고 부르기도 합니다[2].
3. 운영체제와 JVM의 메모리 구조: 스택(Stack)과 힙(Heap)
자바가 객체를 어떻게 전달하는지 정확히 이해하려면, 자바 가상 머신(JVM)과 운영체제가 메모리를 어떻게 다루는지 알아야 합니다. 자바의 메모리 구조 중 이 논의에서 가장 중요한 두 영역은 스택(Stack)과 힙(Heap)입니다.
1. 스택 프레임 (Stack Frame): 스레드(Thread)가 생성될 때마다 스레드 전용 스택 메모리가 할당됩니다. 메서드가 호출될 때마다 해당 메서드의 실행 정보를 담은 '스택 프레임'이 생성되며, 여기에는 지역 변수(Local variables)와 매개변수(Parameters)가 저장됩니다. 이 스택 영역은 다른 스레드가 절대 접근할 수 없는 고립된 공간입니다[3].
2. 힙 영역 (Heap Memory): 프로그램 실행 중 new 키워드를 통해 동적으로 생성된 실제 객체(Object) 데이터가 머무는 공간입니다. 힙은 모든 스레드가 공유할 수 있는 거대한 메모리 풀입니다.
자바에서 MyObject obj = new MyObject();를 선언하면, 실제 데이터 덩어리(인스턴스)는 힙 메모리에 생성됩니다. 그리고 스택 메모리에는 `obj`라는 이름의 지역 변수가 생성되는데, 이 지역 변수 안에는 힙 메모리에 생성된 객체의 '메모리 주소값(예: 0x1234)'이 저장됩니다.
4. 자바(Java)가 'Pass by Value'인 결정적 이유
자바가 객체를 파라미터로 넘길 때 벌어지는 일은 엄밀히 말해 "스택에 저장된 참조 변수의 '주소값' 자체를 복사하여 넘기는 것(Pass by Value of Reference)"입니다. 코드를 통해 명확히 증명해 보겠습니다.
public class MemoryTest {
public static void main(String[] args) {
Car myCar = new Car("Hyundai"); // (1)
modifyCar(myCar); // (2)
System.out.println(myCar.getName()); // 결과: Kia
reassignCar(myCar); // (3)
System.out.println(myCar.getName()); // 결과: 여전히 Kia! (BMW로 바뀌지 않음)
}
public static void modifyCar(Car paramCar) {
paramCar.setName("Kia");
}
public static void reassignCar(Car paramCar) {
paramCar = new Car("BMW"); // 새로운 객체를 할당
}
}
Java
복사
위 코드가 메모리에서 동작하는 과정을 따라가면 자바가 왜 Call by Value인지 완벽히 증명됩니다.
•
(1) 객체 생성: 힙 영역에 "Hyundai"라는 이름의 Car 객체가 생성됩니다(주소: 0x100). main 메서드의 스택 프레임에 있는 지역 변수 myCar는 0x100이라는 값을 가집니다.
•
(2) modifyCar 호출: modifyCar 메서드가 호출되며 새로운 스택 프레임이 열립니다. 이때 인자 paramCar가 생성되는데, 자바는 myCar가 가지고 있던 값(`0x100`)을 복사하여 paramCar에 넣어줍니다. (이것이 완벽한 Call by Value입니다). 이제 myCar와 paramCar는 서로 다른 스택 프레임에 존재하는 별개의 변수지만, 우연히 같은 힙 주소(0x100)를 바라보고 있을 뿐입니다. 따라서 paramCar를 통해 힙 영역의 데이터를 "Kia"로 수정하면, 같은 곳을 바라보던 myCar로 조회할 때도 변경된 데이터가 보입니다.
•
(3) reassignCar 호출 (핵심): reassignCar의 paramCar 역시 0x100을 복사받고 시작합니다. 하지만 내부에서 new Car("BMW")를 호출하여 힙에 새로운 객체(0x200)를 만들고 이를 paramCar에 대입합니다. 만약 자바가 C++처럼 'Pass by Reference'를 지원했다면, 원본 변수인 myCar가 가리키는 방향 자체를 0x200으로 바꿀 수 있었을 것입니다. 하지만 자바는 값만 복사해서 넘겼기 때문에, paramCar라는 지역 변수의 값만 0x200으로 바뀔 뿐, main 메서드의 myCar는 영원히 0x100을 유지합니다.
이러한 작동 방식 때문에 자바 시스템 설계자들은 이를 C언어의 포인터 전달과 구분하여 명백한 Pass by Value로 명명한 것입니다.
5. 메모리 구조를 알아야 하는 이유: 동시성(Concurrency)과 객체 탈출(Escape)
단순히 면접 질문을 방어하는 것을 넘어, 객체가 Pass by Value로 전달될 때 스택과 힙의 상호작용을 이해하는 것은 자바 멀티스레드 프로그래밍(동시성)에서 데이터 무결성을 지키는 데 핵심적인 역할을 합니다.
멀티스레드 환경에서, 스택 프레임 내부에 존재하는 기본 타입(Primitive type) 변수들은 스레드 간에 절대로 공유될 수 없으므로 안전(Stack confinement)합니다[3]. 하지만 참조 변수가 참조하는 힙 영역의 객체는 다릅니다. 메서드의 반환값으로 객체의 참조값(Value)을 넘기거나 외부 메서드의 인자로 참조값을 전달하는 행위는 해당 객체를 외부 스레드에 무방비로 노출시키는 '객체 발행(Publication) 및 탈출(Escape)'을 유발합니다[4-6].
내가 만든 컬렉션(Collection) 객체의 참조값이 다른 클래스의 메서드로 '값 복사'를 통해 전달되면, 여러 스레드가 우연히 힙의 동일한 메모리 주소를 바라보고 동시에 쓰기 작업을 시도할 수 있습니다. 이는 심각한 데이터 경합(Data Race)을 초래합니다. 자바의 ReentrantLock이나 synchronized 블록, 혹은 AtomicReference와 같은 원자적 변수[7, 8]를 사용해 철저한 락(Lock)을 걸어주어야 하는 이유가 바로 자바의 인자 전달 체계가 힙 메모리상의 동일한 객체를 가리키는 다수의 참조 복사본을 쉽게 만들어낼 수 있기 때문입니다.
6. 결론: 요약 및 정리
자바의 인자 전달 방식을 헷갈리게 만들었던 원인은, 자바가 명시적인 포인터 기호를 숨기고 모든 객체 제어를 '참조(Reference) 변수'를 통해 수행하도록 추상화했기 때문입니다. 요약하자면 다음과 같습니다.
1.
자바는 오직 'Pass by Value'로만 인자를 전달합니다.
2.
객체를 파라미터로 넘길 때 복사되는 '값(Value)'은 객체 자체가 아니라, 객체가 힙 메모리 상에 존재하는 위치를 나타내는 '메모리 주소값'입니다.
복사된 주소값을 통해 힙의 상태를 변경할 수는 있지만, 호출한 쪽(Caller)의 원본 참조 변수가 전혀 다른 객체를 가리키도록 참조의 연결 고리 자체를 바꿀 수는 없습니다. (이것이 진정한 Pass by Reference와의 결정적 차이입니다).
언어의 추상화 뒤에 숨겨진 운영체제의 메모리 관리(Stack과 Heap) 구조를 꿰뚫어 볼 수 있다면, 앞으로 여러분이 짠 자바 코드가 시스템 내부에서 어떠한 방식으로 데이터를 처리하고 이동시키는지 명확하게 그려지실 것입니다.
참고문헌
[1] UNIX Network Programming, Volume 1, Third Edition, The Sockets Networking API — 112 3.3 Value-Result Arguments We mentioned that when a socket address structure is passed to any socket function, it is always passed by reference. That is, a pointer to the structure is passed. The length of the structure is also passed as an argument. But the way in which the length is passed dep…
[2] UNIX Network Programming, Volume 1, Third Edition, The Sockets Networking API — structure when filling it in) and a result when the function returns (it tells the process how much information the kernel actually stored in the structure). This type of argument is called a value-result argument. Figure 3.8 shows this scenario. Addison Wesley : UNIX Network Programming Volume 1, T…
[3] Java Concurrency in Practice — 9. The connection pool implementations provided by application servers are thread-safe; connection pools are necessarily accessed from multiple threads, so a non-thread-safe implementation would not make sense. 10. Another reason to make a subsystem single-threaded is deadlock avoidance; this is one…
[4] Java Concurrency in Practice — Publishing an object means making it available to code outside of its current scope, such as by storing a reference to it where other code can find it, returning it from a nonprivate method, or passing it to a method in another class. In many situations, we want to ensure that objects and their inte…
[5] Java Concurrency in Practice — 40 Chapter 3. Sharing Objects The most blatant form of publication is to store a reference in a public static field, where any class and thread could see it, as in Listing 3.5. The initialize method instantiates a new HashSet and publishes it by storing a reference to it into knownSecrets. public st…
[6] Java Concurrency in Practice — From the perspective of a class C, an alien method is one whose behavior is not fully specified by C. This includes methods in other classes as well as overrideable methods (neither private nor final) in C itself. Passing an object to an alien method must also be considered publishing that object. S…
[7] Java Concurrency in Practice — This page intentionally left blank Chapter 15 Atomic Variables and Nonblocking Synchronization Many of the classes in java.util.concurrent, such as Semaphore and Concur-rentLinkedQueue, provide better performance and scalability than alternatives using synchronized. In this chapter, we take a look a…
[8] Java Concurrency in Practice — The atomic variable classes provide a generalization of volatile variables to support atomic conditional read-modify-write operations. AtomicInteger repre-sents an int value, and provides get and set methods with the same memory 5. Actually, the biggest disadvantage of CAS is the difficulty of const…
