파이썬 | 변수, 참조, 스코프 개념 정리

2026. 9. 5. 15:06크래프톤 정글/포스팅

전체 개념 지도 (요약)

[대주제 1] 변수와 객체의 관계
        │ 
        ▼
[대주제 2] 이름표의 저장 위치와 유효 범위 (namespace, scope)
   ├─ namespace: "이름→참조" 매핑이 저장되는 테이블
   ├─ LEGB: 읽기는 자동 탐색, 쓰기는 무조건 지역 생성
   └─ global/nonlocal: 쓰기 시 바깥 스코프 명시 지정
        │
        ▼
[대주제 3] 코드 한 줄의 실제 동작: mutate vs rebind
   ├─ mutation(내부 수정) vs rebinding(재할당)
   ├─ mutable/immutable: mutation이 가능한 타입인가
   ├─ in-place 메서드는 None 반환이 관례
   └─ 이름표를 잃은 객체는 reference counting으로 자동 소멸
        │
        ▼
[대주제 4] 진단 도구
   ├─ is(같은 객체) vs ==(같은 값)
   └─ 얕은 복사(바깥만) vs 깊은 복사(전부 새로)

 

1. 변수와 객체의 관계

파이썬에서 변수는 값을 담는 상자가 아니라, 객체를 가리키는 이름표(name)다.

객체 자체는 메모리(힙) 어딘가에 따로 저장되고, 변수는 그 객체를 가리키는 참조일 뿐이다.

이 하나의 사실에서 이후의 모든 개념이 파생된다.

a = [1, 2, 3]  # [1, 2, 3]이라는 리스트 객체는 힙에 저장, 변수 a는 그 객체를 가리킴 
b = a          # b는 같은 객체를 가리키는 또 다른 이름표. 리스트 객체를 가리키는 이름표가 총 2개가 된 것

 


2. 이름표의 저장 위치와 유효 범위 (namespace, scope)

2-1. 스코프와 네임스페이스의 관계

변수가 "객체를 가리키는 이름표"라면, 그 이름표 자체는 또 어딘가에 저장되어 있어야 한다. 

 

이 둘을 구분해서 봐야 한다.

  • 스코프(scope): "이 코드 위치에서 이 이름을 쓰면 어디서 찾아야 하는가"를 정하는 규칙 
  • 네임스페이스(namespace): "이름 → 객체 참조" 매핑이 실제로 저장된 장소 

즉, 스코프는 "몇 개의 네임스페이스를, 어떤 순서로 뒤질 것인가"를 정하는 규칙이고(컴파일러가 이미 다 계산해서, 바이트코드 명령어 종류(LOAD_FAST vs LOAD_GLOBAL 등) 자체에 박아넣은 결정), 네임스페이스는 그 규칙에 따라 실제로 뒤져지는, 이름표들이 진짜로 보관된 장소(실행 중에 계속 참조되고 바뀜)라고 할 수 있다. 코드가 실행되는 동안, 관련된 네임스페이스들이 동시에 메모리에 여러 개 떠 있고, 스코프 규칙은 그중 어디를 어떤 순서로 뒤질지를 정해준다.

x = "전역"

def outer():
    x = "outer"
    def inner():
        x = "inner"
        print(x)
    inner()

 

inner가 실행되는 순간, 다음 네임스페이스들이 동시에 존재한다

[전역 네임스페이스]         {'x': "전역", 'outer': <function>}
[outer의 지역 네임스페이스]   {'x': "outer", 'inner': <function>}
[inner의 지역 네임스페이스]   {'x': "inner"}

여기서 inner 안의 print(x)가 어떤 x를 쓸지는, 다음 절의 LEGB라는 탐색 순서 규칙이 정한다.

 

2-2. LEGB 스코프 규칙

이름을 찾을 때 파이썬이 여러 네임스페이스를 뒤지는 순서: Local → Enclosing → Global → Built-in

  • 읽기(read)는 지역에 없으면 자동으로 바깥 네임스페이스까지 찾아간다.
  • 쓰기(=)는 절대 자동으로 바깥을 건드리지 않고, 무조건 그 스코프의 네임스페이스에 새 이름을 만든다.
x = 10
def foo():
    print(x)   # 10, 읽기는 전역까지 자동 탐색 (LEGB)
foo()
x = 10
def bar():
    x = x + 1  # UnboundLocalError!
bar()
  • bar()가 에러 나는 이유: 파이썬은 함수를 실행하기 전에 미리 코드를 훑어보고, x = ...라는 대입문이 있으므로 함수 전체에서 x를 그 함수의 지역 네임스페이스(고정 배열의 한 칸)로 확정해버린다. 이게 바로 2-2에서 말한 "지역 변수는 실행 전에 미리 확정된다"는 사실이 실제로 드러나는 지점이다. 그래서 우변의 x를 읽으려는 순간, "이 칸은 지역 변수로 예약은 됐는데 아직 값이 채워지기 전"이라 에러가 난다.
  • 이는 파이썬의 설계 선택이며, 의도치 않은 전역 변수 오염을 막기 위함이다("명시적인 것이 암묵적인 것보다 낫다"). JavaScript 등 다른 언어는 지역 함수 안에서 =로 전역 변수를 자동으로 수정할 수 있어 오히려 버그의 원인이 되기도 한다.

2-3. global / nonlocal

키워드 가리키는 네임스페이스 특이사항
global 모듈 최상위(전역) 네임스페이스 전역에 없어도 새로 만들면서 대입 가능
nonlocal 자신을 감싼 함수의 지역 네임스페이스 (전역 제외) 감싸는 함수에 그 이름이 미리 존재해야 함 (없으면 SyntaxError)
x = "전역"

def outer():
    x = "enclosing"

    def inner():
        nonlocal x
        x = "수정됨"

    inner()
    print(x)  # "수정됨" ← outer의 지역 x가 바뀜

outer()
print(x)  # "전역" ← 그대로

global은 Enclosing 단계를 건너뛰고 바로 전역 네임스페이스로 가고, nonlocal은 Enclosing 네임스페이스만 잡는다. 둘 다 "쓰기는 무조건 지역 생성"이라는 2-3의 기본 규칙에 대한 명시적 예외 선언이라는 점에서 같은 성격을 가진다.

2-4. Namespace는 항상 딕셔너리는 아니다

"이름 → 참조" 매핑이 저장되는 곳이 네임스페이스라고 했지만, 그 구현 방식은 스코프 종류에 따라 다르다.

  • 전역(모듈) 네임스페이스, 클래스 네임스페이스는 실제로 진짜 딕셔너리다. globals()가 반환하는 건 사본이 아니라 그 네임스페이스 원본 자체다.
x = 3
print(globals()['x'])     # 3
globals()['y'] = 10
print(y)                    # 10 ← 직접 수정한 게 실제로 반영됨
  • 함수의 지역 네임스페이스는 다르다. 컴파일러가 함수를 실행하기 전에 미리 코드를 훑어서, 그 함수 안에 어떤 지역 변수가 몇 개 쓰이는지 이미 다 알아낸다. 그래서 런타임에 매번 이름으로 딕셔너리를 뒤질 필요 없이, 그냥 고정된 배열의 "몇 번째 칸"으로 바로 접근하도록 최적화되어 있다 (LOAD_FAST / STORE_FAST 바이트코드).
def vartest(a):
    a = a + 1
    print(locals())    # {'a': 2}  ← 이렇게 딕셔너리처럼 "보여지긴" 함
    locals()['a'] = 99   # 하지만 직접 수정해도
    print(a)              # 2  ← 반영 안 됨! locals()는 스냅샷 사본일 뿐

 

즉 locals()가 반환하는 딕셔너리는 실제 저장소가 아니라, 그 순간의 지역 변수 상태를 딕셔너리 모양으로 복사해서 보여주는 스냅샷이다. 실제 원본은 함수 프레임 안의 배열에 있다.

스코프 네임스페이스 구현 globals()/locals()
전역(모듈) 진짜 딕셔너리 원본 그 자체, 직접 수정 반영됨
클래스 본문 진짜 딕셔너리 (클래스 생성 시 __dict__가 됨)
함수 지역 고정 크기 배열 (최적화) 스냅샷 사본, 직접 수정 반영 안 됨

3. 코드 한 줄의 실제 동작: Mutate vs Rebind

3-1. Mutation vs Rebinding — 가장 핵심적인 구분

어떤 코드 한 줄이 실제로 하는 일은 둘 중 하나다.

Mutation (변경) 객체 내부를 직접 수정 lst.append(4), lst[0] = 9
Rebinding (재바인딩) 이름표를 다른 객체로 다시 붙임 lst = [1, 2, 3]

함수에 인자로 리스트를 넘겼을 때, 함수 안에서 mutate하면 원본이 바뀌고, rebind하면 원본은 그대로다.

def try_mutate(lst):
    lst.append(100)      # mutation → 원본 바뀜

def try_reassign(lst):
    lst = [1, 2, 3]       # rebinding → 원본 안 바뀜

my_list = [1, 2]
try_mutate(my_list)
print(my_list)  # [1, 2, 100]

my_list = [4, 5]
try_reassign(my_list)
print(my_list)  # [4, 5] 그대로

판별 기준은 딱 하나

  • 그 줄이 =로 이름표를 다시 붙이는가(rebind)
  • 아니면 .append()/.sort()/lst[i]= 처럼 객체 안쪽을 건드리는가(mutate)

mutation은 대입문이 아니므로 LEGB의 "쓰기는 무조건 지역 생성" 규칙에 걸리지 않는다.

lst = [1, 2, 3]
def foo():
    lst.append(4)   # OK. lst에 대한 대입문이 없으므로 전역 lst를 그대로 사용
foo()
print(lst)  # [1, 2, 3, 4]
lst = [1, 2, 3]
def bar():
    lst += [4]       # UnboundLocalError! += 는 재할당으로 취급됨
bar()

3-2. Mutable vs Immutable — Mutation "선택지"가 있는지 여부

  • Mutable (가변): list, dict, set 등 → 내부를 건드리는 메서드가 존재 → mutation 가능
  • Immutable (불변): int, str, tuple, float 등 → 내부를 건드릴 방법 자체가 없음 → 뭘 해도 항상 rebinding
def modify(a):
    a += 1   # int는 mutation 불가 → 사실상 a = a + 1 (rebinding)

x = 10
modify(x)
print(x)  # 10, 안 바뀜

mutable/immutable은 "그 타입이 mutation을 지원하는가"를 나타내는 재료의 속성이고,
mutation/rebinding은 "실제로 이 코드가 어떤 동작을 했는가"를 나타내는 행위다.

3-3. In-place 메서드는 None을 반환한다

원본을 직접 수정(mutate)하는 메서드를 in-place 메서드라고 부른다. "제자리에서" 수정한다는 뜻이며, 새 객체를 만들지 않기 때문에 관례적으로 반환값이 없다(None).

numbers = [3, 1, 2]

result = sorted(numbers)   # out-of-place: 새 리스트 반환
print(result)   # [1, 2, 3]
print(numbers)  # [3, 1, 2]  원본 그대로

result = numbers.sort()    # in-place: 원본 직접 수정, 반환값 없음
print(result)   # None
print(numbers)  # [1, 2, 3]  원본이 바뀜

 

용어 의미 반환값
in-place method (sort, append, extend, insert, remove, pop, reverse, clear) 원본 객체를 직접 수정 보통 None
out-of-place / non-mutating (sorted, reversed, +, 슬라이싱) 새 객체를 만들어서 반환 새 객체

 

3-4. Reference Counting — 이름표 없는 객체는 사라진다

객체를 가리키는 이름(참조)이 하나도 없으면, 파이썬은 그 객체를 자동으로 메모리에서 제거한다(가비지 컬렉션).

lst = [1, 2, 3]
res = lst[:1].extend(lst[1:])

lst[:1]은 슬라이싱으로 새 리스트 객체를 만들지만, 그 객체를 담을 변수가 없다.

.extend()를 호출한 뒤 그 결과(None)만 res에 저장되고, 정작 수정된 리스트 객체는 아무도 가리키지 않아 접근 불가능한(unreachable) 상태가 되어 사라진다.


4. 진단 도구: 비교와 복사

4-1. is vs ==

연산자 의미 확인하는 것

연산자 의미 확인하는 것
== 값이 같은가 두 객체의 내용(value)이 동등한가
is 같은 객체인가 두 이름표가 같은 메모리 참조를 가리키는가
a = [1, 2, 3]
b = [1, 2, 3]
c = a

print(a == b)  # True  값은 같음
print(a is b)  # False  서로 다른 객체 (메모리 주소 다름)
print(a is c)  # True  같은 객체를 가리킴 (c는 a의 또다른 이름표)

print(id(a), id(b), id(c))  # a와 c는 같은 주소, b는 다른 주소

관례: None과 비교할 때는 == None이 아니라 is None을 쓴다.
None은 프로그램 전체에서 단 하나만 존재하는 싱글턴 객체이므로, 같은 객체인지(is) 확인하는 것이 의미상 더 정확하고 관례적이다.

4-2. 얕은 복사 vs 깊은 복사

리스트를 "복사"했다고 생각했는데, 안쪽 객체는 여전히 원본과 공유되고 있는 문제. 대주제 3의 mutation 개념이 왜 위험할 수 있는지 보여주는 대표 사례다.

import copy

original = [[1, 2], [3, 4]]

shallow = copy.copy(original)      # 또는 original[:], original.copy()
deep = copy.deepcopy(original)

shallow[0].append(99)   # 바깥 리스트는 새로 복사됐지만, 안쪽 리스트는 원본과 공유됨
print(original)   # [[1, 2, 99], [3, 4]]  원본도 바뀜!

deep[0].append(100)      # 완전히 독립된 복사본
print(original)   # [[1, 2, 99], [3, 4]]  안 바뀜
  • 얕은 복사(shallow copy): 바깥 컨테이너만 새로 만들고, 그 안의 요소들은 원본과 같은 객체를 그대로 참조
  • 깊은 복사(deep copy): 안쪽 요소까지 재귀적으로 전부 새 객체로 복사

리스트 안에 리스트/딕셔너리처럼 가변 객체가 중첩되어 있을 때만 이 차이가 실질적으로 드러난다. 안쪽 요소가 전부 int, str처럼 불변 객체라면 얕은 복사만으로도 충분히 안전하다.