
Python 참조와 메모리, 그리고 함수 인자까지 한 번에 정리하기
처음 Python 코드를 제대로 들여다보기 시작했을 때 가장 헷갈렸던 게 두 가지였습니다. 하나는 분명히 변수를 복사한 줄 알았는데 원본이 같이 바뀌는 현상이었고, 다른 하나는 함수 호출부에 붙은 정체불명의 별표(*)였습니다.
알고 보니 이 둘은 멀리 떨어진 이야기가 아니었습니다. 둘 다 "Python이 데이터를 어떻게 들고 다니는가"라는 같은 뿌리에서 나온 이야기였습니다. 변수가 참조라는 사실에서 출발해서, 그게 어떻게 메모리 누수로 이어지는지, 그리고 함수에 데이터를 넘길 때 알아두면 좋은 문법까지 차례대로 정리하겠습니다.
Python 변수는 값이 아니라 참조를 담습니다
C나 Java를 먼저 배운 사람이 가장 흔하게 빠지는 함정이 이겁니다. =을 보면 값을 복사한다고 생각하지만, Python에서 변수는 값을 담는 상자가 아니라 객체에 붙이는 이름표에 가깝습니다.
data = {"user_id": 12345, "action": "login"}
backup = data
여기서 backup은 data의 복사본이 아닙니다. 둘 다 메모리 어딘가에 있는 동일한 dict 객체를 가리키는 참조입니다. 이름표가 두 개 붙었을 뿐, 물건은 하나입니다.

그래서 이런 일이 벌어집니다.
backup["user_id"] = 99999
print(data["user_id"]) # 99999 출력 - 원본도 바뀜
backup을 건드렸는데 data도 같이 바뀝니다. 둘이 같은 객체를 가리키고 있으니 당연한 결과입니다. 이게 바로 얕은 복사(shallow copy)와 깊은 복사(deep copy)를 구분해야 하는 이유이기도 합니다.
조금 더 구체적으로 풀어보면 이렇습니다.
data = {"name": "홍길동"} # 메모리 주소 0x1234에 dict 객체가 생김
result = data # 값 복사가 아님! 주소(0x1234)만 복사
data와 result는 둘 다 0x1234라는 같은 주소를 가리킵니다. 즉 Python에서 변수 할당은 "주소를 복사하는 일"이지 "객체를 복사하는 일"이 아닙니다.
리스트 append도 똑같이 참조만 저장합니다
이 원리는 dict에만 해당하는 게 아닙니다. 리스트에 append할 때도 정확히 같은 일이 벌어집니다. 객체를 통째로 복사해서 넣는 게 아니라, 그 객체의 주소(참조)만 리스트에 넣습니다.
data = {"name": "홍길동"}
logs = []
logs.append(data) # data의 복사본이 아니라 주소만 들어감
data["name"] = "임꺽정"
print(logs[0]["name"]) # 임꺽정 - 리스트 안의 것도 같이 바뀜
logs[0]과 data는 같은 객체를 가리키고 있으니, 어느 쪽을 수정해도 양쪽 다 바뀝니다. 사소해 보이지만, 이 동작이 실제 서버 환경에서는 메모리 누수의 출발점이 됩니다.
그래서 서버 메모리가 샙니다
Flask나 FastAPI로 API 서버를 운영하다 보면 들어온 JSON을 dict로 받아 처리하는 일이 일상입니다. 그런데 다음 코드를 보겠습니다.
from flask import Flask, request
import json
app = Flask(__name__)
request_log = [] # 전역 변수
@app.route('/api/data', methods=['POST'])
def handle_post():
data = request.get_json()
request_log.append(data) # 문제의 시작점
result = process_data(data)
return json.dumps(result)
언뜻 문제없어 보입니다. 하지만 request_log라는 전역 리스트에 요청이 올 때마다 dict를 계속 쌓고 있습니다. 서버가 재시작되지 않는 한 이 리스트는 무한정 커집니다.
핵심은 앞에서 본 그대로입니다. request_log.append(data)는 data의 복사본이 아니라 참조를 저장합니다. 요청이 끝나도 전역 리스트가 그 dict를 붙잡고 있으니, 가비지 컬렉터가 수거하지 못합니다.

가비지 컬렉션은 만능이 아닙니다
Python은 참조 카운팅(reference counting) 방식으로 메모리를 관리합니다. 어떤 객체를 가리키는 참조가 0이 되는 순간 메모리에서 해제됩니다.
def process_request():
data = {"temp": "value"} # 0x1234에 dict 생성, data가 참조
raw_json = json.dumps(data) # 새 문자열 객체 생성 (별개의 주소)
return raw_json
# 함수가 끝나면 data 이름표가 사라짐
# 0x1234를 가리키는 참조가 0 → GC가 즉시 수거
json.dumps(data)는 dict를 읽어서 새로운 문자열 객체를 만들 뿐, 원래 dict와는 별개입니다. 함수가 끝나 data라는 이름표가 사라지면 그 dict를 가리키는 참조가 0이 되고, 누구도 쓰지 않으니 GC가 메모리를 회수합니다. 정상적인 경우입니다.
문제는 누군가 계속 붙잡고 있을 때입니다.
cache = {}
def process_request(user_id):
data = {"user_id": user_id, "timestamp": time.time()}
cache[user_id] = data # 전역 cache가 data를 계속 참조
return json.dumps(data)
cache가 data를 참조하고 있으니, 함수가 끝나도 참조 카운트가 0이 되지 않습니다. 유저가 늘어날수록 cache는 계속 커지고 메모리도 함께 증가합니다. GC는 "아무도 안 쓰는 것"만 치울 수 있을 뿐, "전역 변수가 끈질기게 붙잡은 것"은 손대지 못합니다.
실제로 메모리를 새게 하는 세 가지 패턴
웹 서버에서 자주 보이는 누수 패턴을 정리하면 이렇습니다.
첫째, 만료 정책 없는 캐시입니다. dict나 리스트에 데이터를 쌓기만 하고 지우지 않으면 메모리는 무한정 늘어납니다. 해결책은 TTL을 적용하거나 크기 제한이 있는 자료구조를 쓰는 것입니다.
from functools import lru_cache
@lru_cache(maxsize=1000)
def get_user_data(user_id):
# 최대 1000개까지만 캐시, 오래된 것부터 자동 삭제
return fetch_from_db(user_id)
둘째, 클로저에서의 의도치 않은 참조입니다. 콜백이나 데코레이터가 외부 변수를 캡처하면서 큰 객체를 자기도 모르게 붙잡아두는 경우가 있습니다.
def create_handler(large_data):
def handler(request):
# large_data를 안 쓰더라도 클로저가 참조를 유지함
return "OK"
return handler
large_data가 수 MB짜리라면, handler가 살아있는 내내 그 메모리를 점유합니다.
셋째, 순환 참조입니다. 두 객체가 서로를 가리키면 참조 카운트가 0이 되지 않습니다. Python의 gc 모듈이 주기적으로 탐지해 수거하지만, 수거 주기 사이에는 메모리가 쌓일 수 있습니다.
class Node:
def __init__(self):
self.parent = None
self.children = []
parent = Node()
child = Node()
parent.children.append(child)
child.parent = parent # 순환 참조 발생
dict를 안전하게 다루는 습관
복사가 필요하면 명시적으로 합니다. copy.copy()는 얕은 복사를, copy.deepcopy()는 깊은 복사를 합니다. 중첩된 dict나 리스트가 있다면 deepcopy를 써야 완전히 독립된 복사본을 얻습니다.
import copy
original = {"user": {"name": "홍길동", "scores": [90, 85, 92]}}
shallow = copy.copy(original)
deep = copy.deepcopy(original)
deep["user"]["scores"].append(100)
print(original["user"]["scores"]) # [90, 85, 92] - 원본 유지
전역 상태 사용을 최소화합니다. 전역 변수에 데이터를 쌓는 패턴은 거의 항상 문제를 일으킵니다. 꼭 필요하다면 크기 제한과 만료 정책을 반드시 같이 둡니다.
약한 참조(weak reference)를 활용합니다. 있으면 좋지만 없어도 되는 캐시성 데이터는 weakref를 쓰면, 강한 참조가 사라질 때 GC가 알아서 수거합니다.
import weakref
class ExpensiveObject:
pass
cache = weakref.WeakValueDictionary()
obj = ExpensiveObject()
cache["key"] = obj
# obj에 대한 강한 참조가 사라지면 cache에서도 자동으로 빠짐
메모리를 모니터링하는 방법
문제를 조기에 잡으려면 모니터링이 필수입니다.
tracemalloc은 Python 3.4부터 기본 제공되는 메모리 프로파일러입니다. 어떤 코드가 메모리를 많이 쓰는지 추적할 수 있습니다.
import tracemalloc
tracemalloc.start()
process_many_requests() # 의심되는 코드 실행
snapshot = tracemalloc.take_snapshot()
for stat in snapshot.statistics('lineno')[:10]:
print(stat)
objgraph는 객체 간 참조 관계를 시각화해주는 서드파티 라이브러리입니다. 순환 참조나 의도치 않은 참조를 찾는 데 유용합니다.
import objgraph
objgraph.show_most_common_types(limit=10)
objgraph.show_backrefs(some_object, filename='refs.png')
잠깐, 용어부터 정리하겠습니다: 인자 vs 인수
함수 이야기로 넘어가기 전에 자주 헷갈리는 용어를 짚고 가겠습니다.
- 인자(parameter): 함수를 정의할 때 받기로 약속한 변수입니다.
-
def log_to_file(item, ts): # item, ts가 인자(parameter) ... - 인수(argument): 함수를 호출할 때 실제로 넘기는 값입니다.
-
log_to_file(item, item["ts"]) # 실제로 넘긴 값이 인수(argument)
한마디로 정의할 때 쓰는 자리표시자가 인자, 호출할 때 채워 넣는 실제 값이 인수입니다. 다음 별표 이야기에서 이 둘을 구분하면 훨씬 이해가 쉽습니다.
함수에 데이터 넘기기, 별표(*) 하나의 마법
이제 처음에 저를 당황하게 했던 별표 차례입니다. 곱셈도 아니고 포인터도 아닌(애초에 Python에 포인터가 어디 있겠습니까) 이 별표의 정체는 언패킹(unpacking)이었습니다.
쉽게 말하면 포장을 뜯는 일입니다. 택배 박스 안에 물건 세 개가 들어있다고 합시다. 박스째로 건네주는 것과, 박스를 뜯어서 물건 세 개를 각각 손에 쥐어주는 건 완전히 다른 상황입니다.

result = (base_ts, items_to_log, raw_json_str)
result는 값 세 개가 담긴 튜플입니다. 박스 하나에 물건 셋이 들어있는 상태입니다. 이걸 함수에 넘기는 방법은 두 가지입니다.
process_io_after_lock(result)
이렇게 하면 함수는 인수를 딱 하나 받은 셈입니다. 박스째로 받았으니 함수 안에서는 result[0], result[1] 식으로 일일이 꺼내 써야 합니다.
그런데 별표를 붙이면 이야기가 달라집니다.
process_io_after_lock(*result)
이 순간 튜플이 풀어헤쳐진 채로 함수에 전달됩니다. 함수 입장에서는 인수 세 개를 따로따로 받은 것과 똑같아집니다. 결국 위 코드는 아래와 완전히 동일합니다.
process_io_after_lock(base_ts, items_to_log, raw_json_str)
이게 왜 유용하냐면, 실무에서 데이터를 묶어 다루는 경우가 정말 많기 때문입니다. 데이터베이스에서 한 줄 읽어오면 튜플로 오고, API 응답을 파싱하면 리스트로 옵니다. 이런 걸 다른 함수에 넘길 때 인덱스로 하나하나 꺼내 나열하는 건 귀찮고 실수하기도 쉽습니다. 그냥 별표 하나 붙이면 알아서 풀립니다. 코드도 한결 깔끔해집니다.
처음엔 이상하게 보였던 문법인데, 익숙해지니 이거 없이 어떻게 코딩했나 싶을 정도입니다. 특히 가변 인자 함수와 조합하면 훨씬 강력해지는데, 그건 다음에 따로 정리해 보겠습니다.