변수의 범위와 변경 가능한 데이터: 지역 변수·전역 변수·참조의 차이
변수의 범위와 변경 가능한 데이터: 지역 변수·전역 변수·참조의 차이#
함수를 배우고 나면 이런 일이 생깁니다.
def calculate_total():
total = 10_000
return total
print(calculate_total()) # 10000
print(total) # NameError함수 안에서는 total을 사용했는데 함수 밖에서는 왜 찾을 수 없을까요?
반대로 다음 코드는 함수 안에서 리스트를 변경했는데, 함수 밖의 목록까지 바뀝니다.
def add_item(items):
items.append("우유")
cart = ["사과"]
add_item(cart)
print(cart) # ['사과', '우유']두 예제를 이해하려면 서로 다른 질문에 답해야 합니다.
- 변수의 범위:
total,cart같은 이름을 어디에서 사용할 수 있는가? - 데이터의 변경 가능성: 이름이 가리키는 객체의 내용을 바꿀 수 있는가?
이 두 개념을 섞어 생각하면 함수의 결과를 예상하기 어려워집니다. 특히 장바구니, 주문 목록, 설정 정보처럼 리스트와 딕셔너리를 여러 함수에 전달하는 프로그램에서는 버그로 이어지기 쉽습니다.
이 글에서는 Python으로 설명하되, 이름의 범위, 객체의 참조, 원본 변경과 복사라는 핵심 개념은 다른 언어의 코드를 이해할 때도 도움이 됩니다.
변수의 범위란 무엇인가?#
변수의 범위 또는 스코프는 코드에서 어떤 이름을 사용할 수 있는 영역입니다.
def greet():
message = "안녕하세요"
print(message)
greet()message는 greet() 함수 안에서 만든 이름입니다. 함수 안에서는 사용할 수 있지만 함수 밖에서는 직접 사용할 수 없습니다.
# print(message)
# NameError: 함수 밖에는 message라는 이름이 없다.필요한 값을 함수 밖에서 사용하려면 반환합니다.
def create_greeting():
message = "안녕하세요"
return message
greeting = create_greeting()
print(greeting) # 안녕하세요함수 안의 message와 함수 밖의 greeting은 서로 다른 이름입니다. 함수가 반환한 값을 바깥에서 새 이름으로 받은 것입니다.
지역 변수: 함수 안에서 사용하는 이름#
함수 안에서 할당한 이름은 일반적으로 지역 변수입니다.
def calculate_price(price, quantity):
total = price * quantity
return total이 함수에는 price, quantity, total이라는 이름이 있습니다.
price와quantity는 호출할 때 전달받는 매개변수입니다.total은 함수 안에서 만든 지역 변수입니다.- 함수 밖에서는 반환값을 받아 사용할 수 있습니다.
order_total = calculate_price(3_000, 2)
print(order_total) # 6000이름의 범위를 제한하면 서로 다른 함수에서 같은 이름을 사용해도 충돌하지 않습니다.
def calculate_price():
result = 6_000
return result
def calculate_points():
result = 300
return result두 함수의 result는 각각의 함수 안에서 사용하는 이름입니다.
전역 변수: 함수 밖에 정의한 이름#
함수 밖에서 정의한 이름을 함수 안에서 읽을 수 있습니다.
tax_rate = 0.1
def calculate_tax(price):
return price * tax_rate
print(calculate_tax(10_000)) # 1000.0여기서 tax_rate는 함수 밖에서 정의되었습니다. calculate_tax()는 그 이름을 읽어 계산합니다.
하지만 함수가 바깥 값에 의존한다는 사실이 함수 호출만으로는 보이지 않습니다.
calculate_tax(10_000)이 호출을 읽는 사람은 가격만 입력한다고 생각할 수 있지만, 실제 결과는 바깥의 tax_rate에도 영향을 받습니다.
필요한 값을 인자로 전달하면 의존 관계가 더 분명해집니다.
def calculate_tax(price, tax_rate):
return price * tax_rate
print(calculate_tax(10_000, 0.1)) # 1000.0전역 변수를 항상 쓰면 안 된다는 뜻은 아닙니다. 다만 결과를 결정하는 중요한 값이 함수 밖에 숨어 있는지 확인해야 합니다.
함수 안에서 같은 이름에 할당하면 어떻게 될까?#
다음 코드를 보겠습니다.
count = 10
def show_count():
count = 20
print(f"함수 안: {count}")
show_count()
print(f"함수 밖: {count}")실행 결과:
함수 안: 20
함수 밖: 10함수 안의 count = 20은 바깥의 count를 변경하지 않습니다. 함수 안에 **새 지역 이름 count**를 만든 것입니다.
이 동작을 이해하지 못하면 다음 코드가 낯설게 보일 수 있습니다.
count = 10
def increase_count():
count = count + 1
return count
# increase_count()
# UnboundLocalError가 발생한다.함수 안에서 count에 할당하고 있으므로 Python은 그 이름을 지역 변수로 취급합니다. 그런데 오른쪽의 count + 1을 계산할 때 아직 그 지역 변수에 값이 없으므로 오류가 발생합니다.
가장 단순한 해결: 값을 받아 반환하기#
def increase_count(count):
return count + 1
count = 10
count = increase_count(count)
print(count) # 11함수는 현재 값을 입력받고 새 값을 반환합니다. 값이 어떻게 바뀌었는지 호출하는 코드에 드러납니다.
global로 바깥 이름에 할당할 수도 있다#
Python에는 함수 안에서 전역 이름에 할당하겠다고 명시하는 global이 있습니다.
count = 10
def increase_count():
global count
count += 1
increase_count()
print(count) # 11문법적으로 가능하지만 이 함수는 반환값 없이 프로그램 바깥의 상태를 변경합니다. 여러 함수가 count를 바꾼다면 원인을 추적하기 어려울 수 있습니다.
처음 함수를 설계할 때는 인자로 받고 결과를 반환하는 방식을 기본으로 익히는 편이 데이터 흐름을 이해하기 쉽습니다.
변수는 객체를 가리킨다#
이제 범위와 별개로 변수와 데이터의 관계를 살펴보겠습니다.
Python의 변수는 데이터를 담은 고정된 상자라기보다 객체를 가리키는 이름으로 이해하면 여러 동작이 분명해집니다.
first = ["사과", "우유"]
second = first이 코드가 실행된 뒤 first와 second는 같은 리스트 객체를 가리킵니다. 리스트 두 개를 만든 것이 아닙니다.
second.append("계란")
print(first) # ['사과', '우유', '계란']
print(second) # ['사과', '우유', '계란']second를 통해 그 리스트를 변경했으므로 first로 보아도 변경된 내용이 나타납니다.
같은 값과 같은 객체는 다르다#
first = ["사과", "우유"]
second = ["사과", "우유"]
print(first == second) # True
print(first is second) # False==는 두 리스트의 값이 같은지 비교합니다.is는 두 이름이 같은 객체를 가리키는지 비교합니다.
두 목록의 내용은 같지만 별도로 만든 객체입니다.
second = first
print(first == second) # True
print(first is second) # True이번에는 같은 리스트를 두 이름으로 참조합니다.
값을 비교하려는 일반적인 코드에서는 ==를 사용합니다. is는 객체 동일성을 확인해야 하는 경우에 쓰며, None인지 검사할 때는 is None이 일반적입니다.
변경 가능한 객체와 변경할 수 없는 객체#
Python의 객체는 생성 후 그 객체의 내용을 변경할 수 있는지에 따라 구분할 수 있습니다.
| 구분 | 대표적인 Python 자료형 | 예시 |
|---|---|---|
| 변경 가능한 객체 | list, dict, set |
리스트에 항목 추가, 딕셔너리 값 변경 |
| 변경할 수 없는 객체 | int, float, str, tuple, bool |
기존 객체를 바꾸는 대신 새 값에 이름을 연결 |
리스트는 내용물을 수정할 수 있습니다.
cart = ["사과"]
cart.append("우유")
print(cart) # ['사과', '우유']문자열은 특정 위치의 글자를 직접 교체할 수 없습니다.
name = "철수"
# name[0] = "영"
# TypeError가 발생한다.새 문자열을 만들고 이름을 다시 할당할 수는 있습니다.
name = "영수"
print(name) # 영수이때 기존 문자열 객체의 첫 글자를 수정한 것이 아닙니다. name이 새로운 문자열을 가리키게 된 것입니다.
정수를 증가시키면 기존 정수 객체가 수정될까?#
score = 100
score = score + 50
print(score) # 150score + 50을 평가해 150이라는 값을 얻고, score라는 이름을 그 결과에 다시 연결합니다. 정수 100 자체의 내부 값을 150으로 바꾼 것으로 생각하지 않는 편이 정확합니다.
이 차이는 함수를 호출할 때 중요해집니다.
함수에 정수와 리스트를 전달하면 왜 결과가 다를까?#
정수를 전달하고 매개변수에 다시 할당한 경우#
def add_bonus(score):
score = score + 50
print(f"함수 안: {score}")
original_score = 100
add_bonus(original_score)
print(f"함수 밖: {original_score}")실행 결과:
함수 안: 150
함수 밖: 100함수 안의 score가 새 결과인 150을 가리키게 되었지만 바깥의 original_score는 여전히 100을 가리킵니다.
바깥에서도 새 점수를 사용하려면 반환합니다.
def add_bonus(score):
return score + 50
original_score = 100
updated_score = add_bonus(original_score)
print(original_score) # 100
print(updated_score) # 150리스트를 전달하고 내용 자체를 바꾼 경우#
def add_product(cart):
cart.append("우유")
shopping_cart = ["사과"]
add_product(shopping_cart)
print(shopping_cart) # ['사과', '우유']함수의 cart와 바깥의 shopping_cart가 같은 리스트 객체를 가리킵니다. append()는 그 객체의 내용을 바꾸므로 바깥에서도 변경이 보입니다.
리스트를 전달했지만 매개변수에 새 목록을 할당한 경우#
def replace_cart(cart):
cart = ["노트", "펜"]
print(f"함수 안: {cart}")
shopping_cart = ["사과"]
replace_cart(shopping_cart)
print(f"함수 밖: {shopping_cart}")실행 결과:
함수 안: ['노트', '펜']
함수 밖: ['사과']함수 안에서 cart = [...]라고 하면 지역 이름 cart가 새 리스트를 가리키게 됩니다. 원래 shopping_cart가 가리키던 리스트의 내용은 바뀌지 않았습니다.
다음 두 코드를 구분하는 것이 핵심입니다.
| 함수 안의 코드 | 의미 | 바깥의 원래 리스트 |
|---|---|---|
cart.append("우유") |
기존 리스트 객체를 변경 | 변경이 보임 |
cart = ["노트", "펜"] |
지역 이름 cart에 새 리스트를 할당 |
그대로 유지 |
이름을 다시 할당하는 것과 이름이 가리키는 객체를 변경하는 것은 다릅니다.
원본을 바꾸지 않으려면 어떻게 할까?#
함수가 기존 목록을 수정하지 않고 새 목록을 만들도록 설계할 수 있습니다.
def with_added_product(cart, product):
new_cart = cart.copy()
new_cart.append(product)
return new_cart
original_cart = ["사과"]
updated_cart = with_added_product(original_cart, "우유")
print(original_cart) # ['사과']
print(updated_cart) # ['사과', '우유']cart.copy()로 새 리스트를 만든 뒤 그 새 리스트를 변경합니다. 호출한 쪽의 original_cart는 유지됩니다.
이 함수의 사용 방법도 명확합니다. 원본을 바꾸려면 반환된 새 결과를 받아야 합니다.
cart = ["사과"]
cart = with_added_product(cart, "우유")
print(cart) # ['사과', '우유']이때 마지막 줄의 cart = ...는 원래 리스트 객체를 직접 수정한 것이 아니라, 변수 cart가 새로 반환된 리스트를 가리키게 합니다.
얕은 복사와 중첩된 데이터#
.copy()는 바깥 리스트를 새로 만들지만, 안에 들어 있는 객체까지 모두 복제하지는 않습니다. 이를 얕은 복사라고 합니다.
original = [
{"name": "사과", "quantity": 1}
]
copied = original.copy()바깥의 리스트는 두 개지만, 안에 들어 있는 딕셔너리는 공유합니다.
print(original is copied) # False
print(original[0] is copied[0]) # True복사본 안의 딕셔너리를 수정하면 원본에서도 변경이 보입니다.
copied[0]["quantity"] = 3
print(original)
# [{'name': '사과', 'quantity': 3}]반면 복사본의 바깥 리스트에 새 항목을 추가하면 원본 바깥 리스트에는 추가되지 않습니다.
copied.append({"name": "우유", "quantity": 1})
print(len(original)) # 1
print(len(copied)) # 2중첩된 구조까지 독립적으로 복제해야 한다면 copy.deepcopy()를 사용할 수 있습니다.
import copy
original = [
{"name": "사과", "quantity": 1}
]
copied = copy.deepcopy(original)
copied[0]["quantity"] = 3
print(original[0]["quantity"]) # 1
print(copied[0]["quantity"]) # 3다만 모든 문제를 deepcopy()로 해결하려고 하기보다, 어느 부분을 독립적으로 변경해야 하는지 먼저 확인하는 것이 좋습니다. 데이터가 크거나 파일·연결 같은 자원을 포함한다면 단순 복제가 적절하지 않을 수 있습니다.
함수의 기본 인자에 리스트를 두면 생기는 일#
변경 가능한 객체와 함수가 결합할 때 특히 자주 발생하는 실수입니다.
def add_item(item, cart=[]):
cart.append(item)
return cart
print(add_item("사과")) # ['사과']
print(add_item("우유")) # ['사과', '우유']각 호출에서 새로운 빈 리스트가 만들어질 것 같지만, 기본값인 리스트는 함수를 정의할 때 준비되어 이후 호출에서 재사용됩니다. 첫 호출에서 바꾼 목록을 두 번째 호출에서도 사용한 것입니다.
호출할 때마다 새 목록이 필요하다면 다음처럼 작성합니다.
def add_item(item, cart=None):
if cart is None:
cart = []
cart.append(item)
return cart
print(add_item("사과")) # ['사과']
print(add_item("우유")) # ['우유']이 패턴에서 None은 “목록을 따로 전달하지 않았다”는 표시입니다. 실제 목록이 전달되면 그 목록을 사용합니다.
my_cart = ["노트"]
add_item("펜", my_cart)
print(my_cart) # ['노트', '펜']따라서 이 함수는 인자로 받은 기존 리스트를 수정한다는 점도 함께 알아야 합니다.
안쪽 함수가 바깥 함수의 이름을 사용할 때#
함수 안에 함수를 정의할 수도 있습니다.
def make_multiplier(factor):
def multiply(number):
return number * factor
return multiply
double = make_multiplier(2)
print(double(10)) # 20multiply() 안에서는 자신의 매개변수 number와 바깥 함수의 매개변수 factor를 함께 사용합니다. make_multiplier(2) 호출이 끝난 뒤에도 반환된 함수가 factor의 값을 사용할 수 있습니다. 이런 구조를 클로저라고 합니다.
처음에는 “안쪽 함수가 자신이 정의된 곳의 값을 사용할 수 있다” 정도로 이해하면 충분합니다.
바깥 함수의 이름에 다시 할당하려면?#
읽는 것과 다시 할당하는 것은 다릅니다.
def make_counter():
count = 0
def increase():
nonlocal count
count += 1
return count
return increase
counter = make_counter()
print(counter()) # 1
print(counter()) # 2nonlocal count는 increase() 안의 새 지역 변수 count가 아니라, 바깥 함수 make_counter()의 count에 할당하겠다는 뜻입니다.
global과 범위가 다릅니다.
global: 모듈 수준에 정의된 이름에 할당할 때 사용nonlocal: 가장 가까운 바깥 함수 범위의 이름에 할당할 때 사용
둘 다 바깥 상태를 바꾸는 방법이므로, 함수가 반환값만 만드는지 상태를 유지하고 변경하는지 의도를 분명히 해야 합니다.
실제 설계에서는 무엇을 먼저 정해야 할까?#
장바구니에 상품을 추가하는 기능을 만든다고 가정해 보겠습니다. 함수 이름을 짓기 전에 원본을 변경할지부터 결정해야 합니다.
방식 1: 전달받은 장바구니를 직접 변경한다#
def add_to_cart(cart, product):
cart.append(product)cart = ["사과"]
add_to_cart(cart, "우유")
print(cart) # ['사과', '우유']기존 장바구니의 상태를 바꾸는 작업이라는 뜻입니다.
방식 2: 원본은 유지하고 새 장바구니를 반환한다#
def cart_with_product(cart, product):
return [*cart, product]cart = ["사과"]
new_cart = cart_with_product(cart, "우유")
print(cart) # ['사과']
print(new_cart) # ['사과', '우유']이 방식은 원본과 결과를 나란히 비교하기 쉽습니다.
어느 한쪽이 언제나 정답은 아닙니다. 함수를 호출한 뒤 원래 객체가 달라질 것이라고 기대하는지, 새 결과를 받아 사용할 것인지 프로그램의 흐름에 맞춰 선택해야 합니다.
디버깅할 때 확인할 질문#
함수 호출 후 값이 예상과 달라졌다면 다음 순서로 확인해 보세요.
- 문제의 이름은 어느 범위에서 정의되었는가?
- 함수 안에서 그 이름에 새 값을 할당했는가?
- 아니면 이름이 가리키는 리스트나 딕셔너리 자체를 수정했는가?
- 함수의 인자와 바깥 변수가 같은 객체를 참조하는가?
.copy()로 바깥 구조만 복사하고 안쪽 객체는 공유하고 있지 않은가?- 변경 가능한 기본 인자가 여러 호출에서 재사용되고 있지 않은가?
예를 들어 cart.append(item)과 cart = cart + [item]은 모두 목록에 항목을 추가한 것처럼 보일 수 있지만, 객체에 미치는 영향은 다릅니다.
def append_item(cart, item):
cart.append(item)
def rebuild_cart(cart, item):
cart = cart + [item]첫 함수는 기존 리스트를 변경합니다. 둘째 함수는 새 리스트를 만들고 지역 이름 cart를 그 결과에 다시 할당합니다. 둘째 함수가 새 결과를 반환하지 않으면 호출한 쪽은 그 목록을 받을 수 없습니다.
직접 확인해 보기#
다음 코드의 결과를 실행하기 전에 예상해 보세요.
def update_order(order):
order["status"] = "paid"
order = {"status": "cancelled"}
return order
original_order = {"status": "pending"}
returned_order = update_order(original_order)
print(original_order["status"])
print(returned_order["status"])실행 결과:
paid
cancelled왜 결과가 다를까요?
- 함수에 들어온
order는 처음에original_order와 같은 딕셔너리를 가리킵니다. order["status"] = "paid"는 그 딕셔너리 객체를 수정합니다.order = {"status": "cancelled"}는 함수 안의 지역 이름order가 새 딕셔너리를 가리키게 합니다.- 함수는 새 딕셔너리를 반환합니다.
따라서 원래 주문은 "paid"가 되었고, 반환된 별도 딕셔너리는 "cancelled"입니다. 짧은 코드지만 변수의 범위, 참조, 원본 변경, 재할당이 모두 들어 있습니다.
정리#
변수의 범위는 이름을 어디에서 찾고 사용할 수 있는지 결정합니다. 변경 가능성은 그 이름이 가리키는 객체의 내용을 바꿀 수 있는지 결정합니다.
- 함수 안에서 만든 지역 변수 이름은 함수 밖에서 직접 사용할 수 없습니다.
- 함수 밖의 값을 활용할 때는 인자로 전달하고 결과를 반환하면 흐름이 분명해집니다.
- 리스트·딕셔너리 같은 객체는 같은 객체를 참조하는 여러 이름을 통해 변경 내용을 볼 수 있습니다.
- 매개변수에 새 객체를 재할당하는 것과 기존 객체를 수정하는 것은 다릅니다.
- 얕은 복사는 바깥 구조만 새로 만들며, 중첩된 객체는 공유할 수 있습니다.
- 변경 가능한 기본 인자는 함수 호출 사이에 뜻밖의 상태를 남길 수 있습니다.
함수의 동작이 헷갈릴 때는 “변수 안에 값이 들어 있다”는 그림만 떠올리지 말고 두 가지를 따로 확인해 보세요. 이 이름은 어느 범위의 이름인가? 그리고 지금 어떤 객체를 가리키는가? 이 질문이 값이 바뀐 이유를 찾는 출발점입니다.