네임스페이스(name space, 이름공간)이란 프로그래밍 언어에서 특정한 엔티티를 이름에 따라 구분할 수 있는 범위를 말하는 것이다. 소스코드를 작성할 때 가장 어려운 일 중 하나는 변수나 함수의 이름을 정하는 것인데, 충돌이 발생하지 않도록 변수/함수의 이름을 모두 유니크하게 만드는 것은 현실적으로 불가능하다. 모든 이름을 외우고 있기도 어렵거니와, 여러 사람이 협업하는 경우에 다른 사람이 쓸 이름까지 다 알 수는 없기 때문이다.
그래서 프로그래밍언어에서는 네임스페이스라는 개념을 도입하여, 특정한 하나의 이름이 통용될 수 있는 범위를 제한한다. 따라서 소속된 네임스페이스가 다르다면 같은 이름이 다른 개체를 가리키도록 하는 것이 가능해진다.
파이썬의 네임스페이스와 변수 스코프
파이선의 네임스페이스는 크게 세 가지로 분류된다.
- 전역 네임 스페이스 : 모듈별로 존재하며, 모듈 전체에 통용되는 이름을 사용한다.
- 지역 네임 스페이스 : 함수 및 메소드 별로 존재하며, 함수 내의 지역 변수들이 소속된다.
- 빌트인 네임 스페이스 : 기본 내장 함수 및 기본 예외들의 이름을 저장하는 곳
네임스페이스 구현
파이썬의 네임스페이스는 실제로 파이썬 사전으로 구현된다. 모든 이름 자체는 문자열로 되어 있고, 각 이름은 해당 범위에서의 실제 객체를 가리킨다. 이름과 실제 객체 사이의 맵핑은 가변적(mutable)이며 새로운 이름이 실행시간에 추가되거나 삭제될 수도 있다.1
변수의 스코프
변수의 스코프란 변수의 이름으로 그 변수가 가리키는 엔티티를 찾을 수 있는 영역의 범위를 말한다. 이는 현재 위치에서 액세스할 수 있는 네임스페이스들(복수가된다.)과 그 순서에 의해 결정된다. C나 자바스크립트 같은 언어는 블럭 단위의 스코프를 사용한다. 따라서 보통 중괄호({ … })내에서 선언된 변수는 해당 블럭 내에서 통용되며, 블럭을 빠져나가는 경우에는 폐기된다.
파이썬의 스코프
파이썬에서의 블럭은 다른 언어와 달리 조금 특별한데, 파이썬의 블럭은 “특별한 기능 단위의 경계”를 표시하기 보다는 “프로그램 수행 흐름의 분기점”에 그대로 일치하기 때문이다. (이 때문인지는 알 수 없지만) 파이썬에서는 블럭단위의 스코프는 존재하지 않는다. 파이썬에서 변수는 그저 지역 변수와 전역 변수만이 존재할 뿐이다.
전역이름
전역 이름은 모듈의 최상위에서 선언한 이름이다. 현재 모듈의 최상위에 어떤 이름들이 등록되어 있는지는 globals()
라는 함수를 호출하여 전역 이름 공간의 사본을 얻을 수 있다. 주의할 것은 이 때 얻어지는 것은 복사된 사본이므로 이 결과값에 새로운 키-값 쌍을 추가한다고 해서 해당 이름을 액세스하게 되는 것이 아님을 알아두자.
greet = 'hello'
globals()
# {'__name__': '__main__', .....
# '__builtins__' : <module 'builtins' (built-in)>, 'greet': 'hello'}
위에서 보듯이 greet = 'hello'
라고 새로운 객체를 생성하고 나면 그 이름이 globals()
의 결과에 포함되어 있는 것을 알 수 있다.
지역이름
지역 이름 공간은 함수나 메소드 단위로 생성되며, 함수에 진입하는 시점에 만들어진다. 로컬 이름 공간의 내용을 알고 싶다면 locals()
함수를 호출하면 된다. 다음 예에서는 간단한 함수를 하나 생성하고, 함수 내에서 지역 이름 공간의 내용을 출력한다.
def double(n):
m = n + n
print(locals())
return m
print(double(4))
# {'m' : 8, 'n' : 4 }
# 8
섀도우잉 (Shadowing)
섀도우잉(shadowing)은 특정한 스코프 내에서 선언된 이름이 그 외부 스코프와 중첩되는 것을 말한다. 다른 말로는 네임 마스킹이라고 하는데, 스코프 계층에서 중복된 이름이 발견되면, 로컬 이름 공간이 우선적으로 참조되며 전역 변수는 액세스가 되지 않는다. 파이썬에서는 로컬 이름공간과 전역 이름 공간 두 가지 밖에 없으므로, 함수 내에서 변수명을 참조하면 다음과 같은 순서대로 참조한다.
- 현재 함수의 이름 공간에서 로컬 변수 이름을 찾는다. 등록된 이름이 있다면 이 이름을 사용한다.
- 현재 이름공간에서 해당 이름이 발견되지 않았다면 상위 이름 공간을 찾아본다. 만약 중첩된 함수라면 상위 함수의 로컬 이름 공간 찾아본다. 이런 식으로 이름이 발견될 때까지 상위로 올라가서 전역 이름 공간을 찾는다.
- 모듈의 전역 이름 공간에서 발견되지 않는 이름은 내장 이름 공간에서 찾아본다.
- 이 과정에서 최초로 발견된 스코프의 이름을 사용하며, 이름이 발견되지 않았다면
NameError
예외가 발생한다.
이런 절차를 거쳐서 이름을 참조하기 때문에 “이름이 중첩되면 로컬 변수를 우선 사용할테니 문제 없군”하고 간단하게 생각해버릴 수 있는데, 뭐 그랬다면 이렇게 따로 포스팅을 할 이유가 없었을 것이다. 여기에서 파이썬 코드의 동작방식과 맞물려서 몇 가지 문제가 발생할 수 있다.
다음 예시를 보면서 무엇이 문제인지 살펴보자
섀도우잉이 발생했는지 알아차리기 어려운 경우
아래 코드를 보자.
myValue = 3
def increaseMyValue(step=1):
myValue = myValue + step # !
increseMyValue()
print(myValue)
함수 increase_my_value
는 myvalue
라는 함수를 주어진 step
의 크기만큼 크게 만들어준다. 그리고 이 함수 내부에서 myvalue
는 위에서 전역 레벨에 정의한 변수로 보이니까, 로컬이 아닌, 글로벌 스코프의 myvalue
를 변경하는 것처럼 보인다. 하지만 실제로 여기서 myvalue
는 글로벌 변수가 아니라 로컬 변수이다. 함수 내에서 myValue=...
하고 바인딩한다면, 이것은 같은 이름이 전역 범위에 있든 말든 신경쓰지 않고 로컬 변수로 만들어 버린다.
따라서 섀도우잉은 “읽기” 시점에만 적용된다. 그리고 함수 내에서 대입식의 좌변으로 표시되는 모든 변수는 로컬 변수로 간주된다. 그러면 처음에 의도했던 동작은 어떻게 구현할 수 있을까?
명시적인 전역/상위 스코프 변수 사용하기
중요한 것은 그냥 그러한 문법이 파이썬에 존재한다는 것 정도만 알고 있으면 된다. 예전에는 “이걸 가급적 쓰지 말자” 였는데, 이 개념을 이용해서 눈에 보이지 않게 몰래 전역 값을 건드리는 것이 얼마나 해로운 것인지에 대해서 몇 번 더 체감한 이후로는 정말 절대 사용하지 말라고 말하고 싶다.
함수 내에서 전역 혹은 상위 스코프의 값을 참조하여 읽기만 하는 경우가 아니라면 섀도우잉이 일어나지 않기 때문에 명시적으로 이 이름은 전역 변수에서 갖고 온다는 것을 지정해야 한다. 이 때 사용하는 키워드는 global
과 nonlocal
이다. global x
라고 하면 현재 스코프의 위치에 상관없이 x 라는 이름은 전역 변수이고, 이 이름은 지역 네임 스페이스에 등록하지 않는다는 뜻이다. nonlocal
은 전역변수를 포함한 상위 스코프의 이름을 사용한다는 뜻이다.
먼저 전역 변수를 업데이트 하는 함수는 다음과 같이 수정할 수 있다.
my_value = 4
def increase_my_value(step=1):
global my_value
my_value += step
print(my_value)
# 4
increase_my_value()
print(my_value) # 5
하지만 이런식으로 함수 내에서 전역 변수를 업데이트하는 것은 사실 권장되지 않는다. 왜냐하면 직접적으로 변수 값을 변경하지 않는 것은 나도 모르는 사이에 변수의 값을 어디선가 바꿔버리는 일종의 부작용이 되고, 투명한 동작이 아니다. 따라서 문제가 발생했을 때 추적이나 디버깅을 매우 어렵게 만들 수 있다.
어떤 제한적인 상황, 그러니까 명시적으로 여러 개의 전역 변수를 미리 정해진 규칙에 따라서 일괄적으로 업데이트 해야 할 필요가 있고, 이것을 처리해주는 함수를 만들어서 사용해야 할 필요도 있을 수 있다. (특별히 그 정해진 규칙에서 필요한 계산이 복잡하다면 더더욱) 그렇다고 하더라도 파이썬에서는 몇 가지 문법적인 잇점을 통해서 그러한 장벽을 충분히 피할 수 있다.
예를 들어 대여섯개의 전역 변수가 있고, 각 변수는 각각의 정해진 규칙대로 증가해야 한다고 하자. 이런 동작이 빈번하게 발생해야 한다면 전역 변수를 직접 업데이트하는 방법 대신에 다음과 같이 연속열을 분해하는 문법을 활용하도록 하자.
## 전역변수 a, b, c, d, e 가 각각 있음
## 매 업데이트 시마다 각 변수는 다음의 값으로 업데이트 됨
## a : 1씩 증가
## b : 2배로 증가
## c : 10배한 후 d를 더함
## d : d가 짝수이면 2로 나누고, 홀수이면 3을 곱한 후 1을 더한다.
## e : 제곱이 된다.
def update_global_values():
return (a + 1,
b * 2,
c * 10 + d,
d // 2 if d % 2 == 0 else d * 3 + 1,
e * e)
## 업데이트된 값을 구하는 함수는 규칙에 맞게 계산된 값을 튜플로 리턴하고,
## 실제 업데이트는 아래와 같이 루트 레벨에서 직접 대입한다.
update_global_values() # 이 호출에서는 실제로 업데이트 되는 값이 없다.
a, b, c, d, e = update_global_values()
다른 예를 하나 더 들어보자.
흔한 초보의 실수
다음 코드에서의 문제를 보자. 물론 앞에서 설명하면서 힌트를 좀 주긴 했지만, 어떤 문제가 있을까?
x = 3
def myFunction():
print(x)
x += 3
print(x)
myFunction()
나이브하게 생각해보면,
- (여러분이 듣는 수업이나 교재에서 알려준 바에 의하면) 파이썬은 인터프리터 언어니까 한 번에 한 줄씩 해석하고 처리한다. 따라서
- 첫번째
print(x)
문에서x
는 로컬에 정의되지 않았으므로 전역 변수이다. 3 을 출력한다. - 그리고
x
는 전역변수니까 4로 업데이트된다. - 두 번째
print(x)
문에서는 이제 4를 출력한다.
이렇게 돌아간다고 생각할 수 있다. 하지만 현실은 그리 만만치 않다.
- 파이썬이 “한 번에 한 줄씩” 처리하는 것은 모듈을 로드할 때 뿐이다. 따라서 파이썬 해석기가 여러분의 소스코드를 받아서 실행하기 전에 모든 해석이 이루어진다.
- 해석이 이루어진 후에 여러분의 소스코드는 컴파일된 바이트코드 (기계어는 아니고, 기계어에 가까운 중간 형태의 코드)로 번역된 상태로 메모리에 탑재된다.
- 따라서 첫번째
print(x)
문이 실행되기 이전에 x의 위치는 로컬 네임 스페이스로 정해진다. 그런데 아직 x가 정의가 안됐네? - 따라서 첫번째
print(x)
문에서NonLocalBoundError
예외가 발생하고 프로그램 실행이 중단된다.
Traceback (most recent call last):
File "<pyshell#28>",
line 1,
in <module> my_function() File "<pyshell#27>",
line 2, in my_function print(x)
UnboundLocalError: local variable 'x' referenced before assignment
안전한 변수 참조를 위한 규칙
이렇게 보면 파이썬의 네임 스페이스가 굉장히 괴상하게 돌아가는 것처럼 보일지도 모르겠다. 따라서 나름대로 몇 가지 규칙을 정해서 지키려고 하고 있다. 이 규칙을 지키는 선에서는 네임스페이스와 관련된 함정은 대부분 피할 수 있다.
- 함수 내에서 전역변수를 업데이트 하지 말 것.
global
,nonlocal
키워드는 아예 쓰지 않는다고 생각한다. - 함수 외부의 값을 변경해야 할 필요가 있다면, 변경된 값을 리턴에 포함시키고 실제 변경은 함수 외부의 코드에서 하도록 한다.
그리고 가급적이면 전역 변수를 내부에서 참조하지 않는 것을 권장한다. 전역 변수를 직접 읽기 보다는 인자값으로 받아서 처리하게 되면 함수의 내부는 상위 스코프와 암시적인 영향을 주고 받지 않는 순수한 함수가 된다. 순수 함수는 입력이 같을 때 같은 출력을 내는 것을 보장하므로, 디버깅이 편해지고 비동기 처리 및 병렬 처리등 문제가 발생했다면 매우 골치가 아파지2는 도메인에서도 그 확률을 크게 줄일 수 있다.
- 단 빌트인 네임 스페이스는 이름을 추가하거나 삭제할 수 없다. 네임스페이스 원본이 되는 사전 객체는 임의로 액세스할 수 없으며, 특정한 이름을 사용하기 시작하면 그것은 현재 범위에 따라서 지역 이름 공간이나 전역 이름공간 중 하나에 등록된다. ↩
- 특히 비동기처리나 병렬처리에서 전역변수를 참조하는 것은 매우 위험한데, 해당 함수 코드가 실행되는 와중에 다른 동시적 위치에서 전역 변수값이 변경될 소지가 있다. 따라서 병렬처리 코드에서는 전역 변수의 값이 바로 윗줄과 아랫줄에서 다르게 읽히는 문제도 흔히 발생한다. 그리고 이런 종류의 문제는 보통 디버깅시에는 나타나지 않으면서 프로그래머를 엿먹이는 상황도 많다. ↩
세상에 마상에… 정말 알기쉽게 쏙쏙 알려주시는군요, 하는일 다 잘되시길 바랍니다.
안녕하세요!
def my_function():
print(x)
x += 3
는 에러가 나는데
def my_function():
print(x)
는 에러가 나지 않는 이유가 궁금합니다.
함수 안에서 값을 바인딩하는 이름은 모두 그 함수의 지역변수입니다.
print(x)
x += 3
은 변수 x를 정의하기 전에 print문에서 사용했으니 에러가 되는 겁니다.
print() 만하면 지역변수에 없는 이름으로, 전역변구로 간주합니다.
댓글이 닫혔습니다.