흔히 “Java 나 Python 에서 객체를 함수에 넘기면 참조 전달(Pass by Reference), Go 에서 포인터를 넘기면 참조 전달” 이라 부른다. 메모리 동작을 들여다보면 세 언어 모두 값을 복사해서 넘긴다. 차이는 무엇을 복사하느냐다. 원시값을 복사하는 경우, 객체 주소를 복사하는 경우, 구조체 헤더 전체를 복사하는 경우. 참조 전달처럼 보였던 동작이 메모리에서는 모두 값 복사(Pass by Value)다.
포인터의 본질
포인터는 메모리 주소를 담는 변수다. 정수 변수가 정수를 담듯, 포인터 변수는 다른 변수가 위치한 메모리 주소를 담는다. C 와 Go 가 포인터를 명시적으로 노출하는 언어다.
func increment(p *int) {
*p += 1
}
x := 10
increment(&x)
// x == 11
increment 에 &x 를 넘기면 x 의 주소가 p 라는 새 변수에 복사된다. 함수 안의 p 와 호출부의 &x 는 같은 주소를 담은 별개의 변수다. *p += 1 은 그 주소가 가리키는 메모리(즉 x)를 직접 수정한다. 호출부의 x 가 바뀐 이유는 “참조가 넘어가서” 가 아니라 “주소값이 복사되어 같은 메모리를 가리키게 됐기 때문” 이다.
이게 글 전체의 핵심 사고다. 함수에 무엇을 넘기든 변수의 내용물이 복사된다. 그 내용물이 정수면 정수가 복사되고, 주소면 주소가 복사된다. 어느 언어든 같은 패턴이다.
Java references
Java 는 기본 타입(Primitive)과 참조 타입(Reference)을 명확히 분리한다.
- 기본 타입(
int,boolean,double등) — 값 자체가 스택에 저장된다. - 참조 타입(
String,List, 사용자 정의 클래스 등) — 객체는 힙에 만들어지고, 그 객체를 가리키는 주소가 스택의 변수 슬롯에 저장된다.
void mutate(StringBuilder sb) {
sb.append(" world");
}
StringBuilder s = new StringBuilder("hello");
mutate(s);
// s.toString() == "hello world"
s 라는 변수는 힙에 있는 StringBuilder 객체의 주소를 담은 reference 다. mutate(s) 호출 시 그 주소값이 sb 라는 새 변수에 복사된다. 같은 객체를 가리키는 두 reference 가 된 셈이라 sb.append 가 호출부의 s 가 보는 객체를 바꾼다.
이걸 “reference 가 전달됐다” 고 부를 수도 있지만, 명세 표현은 “reference value 가 값으로 전달됐다(passed by value)” 이다(JLS §8.4.1). 그 차이가 드러나는 지점은 함수 안에서 매개변수 자체를 재할당할 때다.
void replace(StringBuilder sb) {
sb = new StringBuilder("other");
}
StringBuilder s = new StringBuilder("hello");
replace(s);
// s.toString() == "hello" // 변하지 않음
sb 에 새 객체를 대입해도 그건 함수 안의 지역 변수만 바꿀 뿐이다. 호출부의 s 는 여전히 원래 객체를 가리킨다. 진짜 참조 전달이었다면 s 도 새 객체로 바뀌었어야 한다. 그래서 Java 는 pass by value 다.
Python name binding
Python 의 모든 데이터는 객체로 힙에 존재한다. 정수도 문자열도 리스트도 객체다. 변수는 객체가 아니라 객체에 붙은 “이름” 이다. CPython 구현에서는 PyObject * 포인터에 대응한다.
a = [1, 2, 3]
b = a
print(id(a) == id(b)) # True: 두 이름이 같은 객체를 가리킴
b = a 는 객체를 복사하는 게 아니라 같은 객체에 두 이름을 묶는다. 함수 호출도 같은 메커니즘이다.
def append_item(lst):
lst.append(4)
x = [1, 2, 3]
append_item(x)
# x == [1, 2, 3, 4]
호출 시 x 가 가리키는 객체의 주소가 lst 라는 새 이름에 바인딩된다. 같은 객체를 가리키는 두 이름이 생긴 셈이라 lst.append(4) 가 x 가 보는 객체를 바꾼다. 학술 용어로는 이 모델을 공유에 의한 호출(Call by Sharing, 또는 객체 참조 전달 Pass by Object Reference)이라 부른다. 엄밀히는 pass by value 가 아니지만, 메커니즘은 객체 주소의 복사라는 점에서 같은 결론에 도달한다.
가변(Mutable) 객체와 불변(Immutable) 객체의 차이는 객체 자체의 성질이지 전달 방식의 차이가 아니다.
def rebind(n):
n = n + 1 # 새 정수 객체를 만들고 n 이라는 지역 이름에 다시 바인딩
x = 10
rebind(x)
# x == 10 // 변하지 않음
n + 1 은 새 정수 객체를 만들고 n 이라는 지역 이름에 바인딩한다. 호출부의 x 는 여전히 원래 객체(10)에 묶여 있다. immutable 이라 호출부가 안 바뀐 게 아니라, 함수 안에서 정수 객체 자체를 in-place 수정할 수단이 없어서 새 객체를 만든 결과일 뿐이다. 리스트의 append 같은 in-place 메서드를 호출하면 객체 자체가 바뀌고 호출부도 그 변화를 본다.
Go 구조체와 escape analysis
Go 는 모든 함수 호출이 값 복사다. 정수든 구조체든 포인터든 함수에 넘기는 순간 새 변수에 값이 복사된다.
type Point struct {
X, Y int
}
func move(p Point) {
p.X += 1
}
q := Point{1, 2}
move(q)
// q.X == 1 // 구조체 전체가 복사돼서 넘어감, 호출부 변하지 않음
구조체 Point 가 통째로 복사된다. 함수 안의 p 는 호출부의 q 와 완전히 별개다. 호출부를 바꾸려면 포인터를 명시적으로 넘긴다.
func moveP(p *Point) {
p.X += 1
}
q := Point{1, 2}
moveP(&q)
// q.X == 2
&q 는 q 의 주소다. 그 주소가 p 라는 새 포인터 변수에 복사된다. p.X += 1 은 그 주소가 가리키는 메모리(즉 q)를 직접 수정한다. 메커니즘이 Java reference 와 같다.
slice 와 map 도 같은 패턴이다. slice 는 내부적으로 (pointer, len, cap) 세 필드로 이뤄진 헤더 구조체다. 함수에 넘기면 헤더 세 필드가 복사된다. 그런데 헤더의 pointer 가 같은 backing array 를 가리키므로 인덱스로 원소를 수정하면 호출부도 그 변화를 본다. 반대로 매개변수에 새 slice 를 대입하거나 append 로 capacity 가 늘어나 backing array 가 재할당되면 호출부와 함수 안의 slice 가 다른 array 를 가리키게 된다.
Go 가 두 언어와 갈리는 지점은 메모리 위치 결정 방식이다. Java 는 reference 객체가 항상 힙으로 가고, Python 은 모든 객체가 힙에 산다. Go 는 컴파일러가 탈출 분석(Escape Analysis)으로 각 변수가 스택에 머물러도 되는지 힙으로 탈출해야 하는지 결정한다.
func makeLocal() *Point {
p := Point{1, 2}
return &p // p 의 주소가 함수 밖으로 escape → 힙 할당
}
func makeTemp() Point {
p := Point{1, 2}
return p // p 가 함수 안에서만 살아 있음 → 스택 할당
}
-gcflags="-m" 플래그로 컴파일하면 컴파일러가 각 변수의 escape 결정을 출력한다. 개발자가 new 나 & 로 명시한다고 반드시 힙에 가는 것도 아니고, 일반 변수가 항상 스택에 머무는 것도 아니다. 사용처가 함수 밖으로 새어 나가는 패턴이 보이면 컴파일러가 알아서 힙으로 옮긴다.
세 언어 비교
세 언어가 메모리에서 다르게 행동하는 것처럼 보이지만 함수 호출 시 일어나는 일은 같은 모양이다.
block-beta
columns 3
j_h["Java"]
p_h["Python"]
g_h["Go"]
j["Stack:
s = 0xA1
Heap:
0xA1 → StringBuilder"]
p["Names:
a, b → 0xB2
Heap:
0xB2 → list"]
g["Stack 또는 Heap:
q Point{1,2}
p *Point = 0xC3
(escape analysis 결정)"]
| 항목 | Java | Python | Go |
|---|---|---|---|
| 함수 인자 전달 | reference value 가 스택에 복사 | 객체 주소가 새 이름에 바인딩 | 값 / 구조체 / 포인터가 스택에 복사 |
| 객체 메모리 위치 | reference 타입은 항상 힙 | 모든 객체가 힙 | escape analysis 로 스택 또는 힙 |
| 호출부 객체를 바꾸려면 | 공유 객체의 mutator 호출 | mutable 객체의 in-place 메서드 | 포인터로 받아 dereference 수정 |
| 매개변수 재할당 시 | 호출부 불변 | 호출부 불변 | 호출부 불변 |
| 메모리 통제 모델 | 명시적 reference + GC | name binding + GC | escape analysis + GC |
공통점이 분명하다. 어떤 언어든 함수에 넘기는 것은 변수의 내용물이고, 그 내용물이 객체 주소면 함수가 호출부 객체에 도달할 수 있다. 진짜 참조 전달(C++ 의 & reference parameter, Pascal 의 var parameter)은 함수 안에서 매개변수를 재할당하면 호출부 변수도 같이 바뀌어야 하는데, Go/Java/Python 어느 쪽도 그렇게 동작하지 않는다.
함수 호출 시 변수의 내용물이 복사된다. 정수면 정수가, 객체 주소면 주소가, 구조체면 구조체 전체가. “Java 는 객체를 참조 전달한다” 거나 “Python 의 모든 것은 참조 전달” 이라는 설명이 직관에는 맞아 보여도 메모리에서는 모두 값 복사다. 무엇이 복사되느냐가 언어 사이의 차이를 만들 뿐, 전달 메커니즘 자체는 다르지 않다.