흔히 “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

&qq 의 주소다. 그 주소가 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 결정)"]
항목JavaPythonGo
함수 인자 전달reference value 가 스택에 복사객체 주소가 새 이름에 바인딩값 / 구조체 / 포인터가 스택에 복사
객체 메모리 위치reference 타입은 항상 힙모든 객체가 힙escape analysis 로 스택 또는 힙
호출부 객체를 바꾸려면공유 객체의 mutator 호출mutable 객체의 in-place 메서드포인터로 받아 dereference 수정
매개변수 재할당 시호출부 불변호출부 불변호출부 불변
메모리 통제 모델명시적 reference + GCname binding + GCescape analysis + GC

공통점이 분명하다. 어떤 언어든 함수에 넘기는 것은 변수의 내용물이고, 그 내용물이 객체 주소면 함수가 호출부 객체에 도달할 수 있다. 진짜 참조 전달(C++ 의 & reference parameter, Pascal 의 var parameter)은 함수 안에서 매개변수를 재할당하면 호출부 변수도 같이 바뀌어야 하는데, Go/Java/Python 어느 쪽도 그렇게 동작하지 않는다.

함수 호출 시 변수의 내용물이 복사된다. 정수면 정수가, 객체 주소면 주소가, 구조체면 구조체 전체가. “Java 는 객체를 참조 전달한다” 거나 “Python 의 모든 것은 참조 전달” 이라는 설명이 직관에는 맞아 보여도 메모리에서는 모두 값 복사다. 무엇이 복사되느냐가 언어 사이의 차이를 만들 뿐, 전달 메커니즘 자체는 다르지 않다.