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처럼 불변 객체라면 얕은 복사만으로도 충분히 안전하다.