변수의 범위와 변경 가능한 데이터: 지역 변수·전역 변수·참조의 차이

변수의 범위와 변경 가능한 데이터: 지역 변수·전역 변수·참조의 차이#

함수를 배우고 나면 이런 일이 생깁니다.

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)  # ['사과', '우유']

두 예제를 이해하려면 서로 다른 질문에 답해야 합니다.

  1. 변수의 범위: total, cart 같은 이름을 어디에서 사용할 수 있는가?
  2. 데이터의 변경 가능성: 이름이 가리키는 객체의 내용을 바꿀 수 있는가?

이 두 개념을 섞어 생각하면 함수의 결과를 예상하기 어려워집니다. 특히 장바구니, 주문 목록, 설정 정보처럼 리스트와 딕셔너리를 여러 함수에 전달하는 프로그램에서는 버그로 이어지기 쉽습니다.

이 글에서는 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)  # 150

score + 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))  # 20

multiply() 안에서는 자신의 매개변수 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())  # 2

nonlocal 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)  # ['사과', '우유']

이 방식은 원본과 결과를 나란히 비교하기 쉽습니다.

어느 한쪽이 언제나 정답은 아닙니다. 함수를 호출한 뒤 원래 객체가 달라질 것이라고 기대하는지, 새 결과를 받아 사용할 것인지 프로그램의 흐름에 맞춰 선택해야 합니다.

디버깅할 때 확인할 질문#

함수 호출 후 값이 예상과 달라졌다면 다음 순서로 확인해 보세요.

  1. 문제의 이름은 어느 범위에서 정의되었는가?
  2. 함수 안에서 그 이름에 새 값을 할당했는가?
  3. 아니면 이름이 가리키는 리스트나 딕셔너리 자체를 수정했는가?
  4. 함수의 인자와 바깥 변수가 같은 객체를 참조하는가?
  5. .copy()로 바깥 구조만 복사하고 안쪽 객체는 공유하고 있지 않은가?
  6. 변경 가능한 기본 인자가 여러 호출에서 재사용되고 있지 않은가?

예를 들어 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

왜 결과가 다를까요?

  1. 함수에 들어온 order는 처음에 original_order와 같은 딕셔너리를 가리킵니다.
  2. order["status"] = "paid"는 그 딕셔너리 객체를 수정합니다.
  3. order = {"status": "cancelled"}는 함수 안의 지역 이름 order가 새 딕셔너리를 가리키게 합니다.
  4. 함수는 새 딕셔너리를 반환합니다.

따라서 원래 주문은 "paid"가 되었고, 반환된 별도 딕셔너리는 "cancelled"입니다. 짧은 코드지만 변수의 범위, 참조, 원본 변경, 재할당이 모두 들어 있습니다.

정리#

변수의 범위는 이름을 어디에서 찾고 사용할 수 있는지 결정합니다. 변경 가능성은 그 이름이 가리키는 객체의 내용을 바꿀 수 있는지 결정합니다.

  • 함수 안에서 만든 지역 변수 이름은 함수 밖에서 직접 사용할 수 없습니다.
  • 함수 밖의 값을 활용할 때는 인자로 전달하고 결과를 반환하면 흐름이 분명해집니다.
  • 리스트·딕셔너리 같은 객체는 같은 객체를 참조하는 여러 이름을 통해 변경 내용을 볼 수 있습니다.
  • 매개변수에 새 객체를 재할당하는 것과 기존 객체를 수정하는 것은 다릅니다.
  • 얕은 복사는 바깥 구조만 새로 만들며, 중첩된 객체는 공유할 수 있습니다.
  • 변경 가능한 기본 인자는 함수 호출 사이에 뜻밖의 상태를 남길 수 있습니다.

함수의 동작이 헷갈릴 때는 “변수 안에 값이 들어 있다”는 그림만 떠올리지 말고 두 가지를 따로 확인해 보세요. 이 이름은 어느 범위의 이름인가? 그리고 지금 어떤 객체를 가리키는가? 이 질문이 값이 바뀐 이유를 찾는 출발점입니다.