코드 리뷰에서 배운 Python 실전 패턴들
여러 차례의 코드 리뷰 과정에서 다룬 Python 패턴과 설계 판단을 주제별로 정리했다. 단순 문법 설명이 아니라, "왜 이렇게 바꿨는가"라는 근거 중심으로 기록한다. 각 패턴마다 배경 지식부터 차근차근 설명하므로, Python 기초 문법을 아는 분이라면 누구나 따라올 수 있다.
1. 타입 안전성과 Pydantic 활용
Pydantic은 Python에서 데이터의 형태(타입)를 자동으로 검사해주는 라이브러리다. 예를 들어 "이름은 문자열, 나이는 숫자"라고 정해두면, 누군가 나이에 문자열을 넣으려 할 때 자동으로 에러를 내준다. 일종의 데이터 경비원 역할이다.
1.1 dict → dict[str, Any] + Field(default_factory=dict)
# 변경 전
details: dict = {}
# 변경 후
from typing import Any
from pydantic import Field
details: dict[str, Any] = Field(default_factory=dict)
무엇이 달라졌나?
두 가지가 바뀌었다.
첫 번째, dict → dict[str, Any]
dict만 쓰면 "이 안에 뭐가 들어가는지" 아무 정보가 없다. 열쇠(key)가 숫자인지 문자인지, 값(value)이 뭔지 전혀 모른다. dict[str, Any]로 바꾸면 "열쇠는 반드시 문자열(str)이고, 값은 뭐든 될 수 있다(Any)"라는 계약을 명시하는 것이다.
이렇게 하면 IDE(코드 편집기)가 자동완성을 더 잘 해주고, 타입 체커(코드의 타입 실수를 자동으로 찾아주는 도구)가 잘못된 사용을 미리 잡아낸다.
두 번째, = {} → Field(default_factory=dict)
이건 Python의 유명한 함정과 관련된다. 아래 예시를 보자:
# ⚠️ 위험한 패턴 (순수 Python 클래스에서)
class Student:
def __init__(self, scores = []):
self.scores = scores
a = Student()
b = Student()
a.scores.append(100)
print(b.scores) # [100] — b도 같이 바뀐다!
= []로 기본값을 쓰면, Python은 이 리스트를 딱 한 번만 만들고 모든 인스턴스가 같은 리스트를 공유한다. A 학생의 점수를 추가했는데 B 학생의 점수도 바뀌는 버그가 생긴다.
Field(default_factory=dict)는 "새 인스턴스를 만들 때마다 새로운 빈 dict를 만들어라"는 뜻이다. Pydantic은 내부적으로 이 문제를 처리해주지만, default_factory를 명시하면 의도가 더 명확해지고, 순수 Python에서의 나쁜 습관을 방지하는 방어적 코딩이 된다.
1.2 @field_serializer — 무거운 필드 JSON 직렬화 제외
프로그램 안의 데이터를 텍스트(JSON 같은 형식)로 변환하는 것이다. 네트워크로 데이터를 보내거나, 파일에 저장할 때 쓴다. 반대로 텍스트를 다시 데이터로 변환하는 건 **역직렬화(Deserialization)**라고 한다.
@field_serializer("raw_data", "cached_binary", "thumbnail_bytes")
def serialize_heavy_fields(self, v: bytes | None) -> None:
return None
왜 이렇게 했을까?
bytes는 이미지, 파일 내용 등 바이너리 데이터(0과 1의 나열)를 담는 타입이다. 이걸 JSON으로 변환하면 base64라는 형식으로 바뀌는데, 원래 크기보다 약 33% 커진다. 10MB 파일이 13MB짜리 텍스트가 되는 셈이다.
@field_serializer를 써서 return None을 하면, .model_dump_json()(모델을 JSON 텍스트로 바꾸는 메서드)을 호출할 때 해당 필드들이 null로 처리된다. 즉, 무거운 데이터는 JSON에 포함시키지 않겠다는 뜻이다.
"그러면 Field(exclude=True)를 쓰면 안 되나?"
쓸 수 있지만, 차이가 있다:
Field(exclude=True)→ 항상 제외된다. Python 코드에서item.raw_data로 접근하는 것도 안 된다.@field_serializer→ JSON으로 변환할 때만 개입한다. Python 코드에서는 정상적으로item.raw_data로 접근할 수 있다.
프로그램 내부에서는 데이터를 쓰되, 외부로 보낼 때만 제외하고 싶으므로 @field_serializer가 맞다.
1.3 내부 캐시 필드까지 serializer 확장
이후 리뷰에서 내부 캐시용 bool 필드들(is_processed, is_validated 같은)을 추가했다. bool은 true/false뿐이니 JSON으로 변환하는 데 문제가 없다. 하지만 여기서 중요한 깨달음이 있었다:
"기술적으로 직렬화가 되느냐"와 "JSON 출력에 포함돼야 하느냐"는 다른 질문이다.
이 필드들은 프로그램 내부에서만 쓰는 값이다. 외부에 보내는 JSON에 포함될 이유가 없다. 그래서 serializer를 확장했다:
@field_serializer(
"raw_data", "cached_binary", "thumbnail_bytes",
"is_processed", "is_validated", "is_cached",
)
def _exclude_internal_from_json(self, _v: object) -> None:
return None
메서드 이름도 serialize_heavy_fields( 무거운 필드 직렬화) → _exclude_internal_from_json(내부 필드를 JSON에서 제외)으로 바꿨다. 이름이 실제 역할을 정확히 설명해야 다른 개발자가 읽을 때 혼란이 없다.
인자 타입도 bytes | None → object로 바꿨다. 이제 bytes뿐 아니라 bool 필드도 처리하므로, 모든 타입을 받을 수 있는 object가 더 정확하다.
1.4 @field_validator — details 값 타입 제한
@field_validator("details")
@classmethod
def _validate_details(cls, v: dict) -> dict:
for key, value in v.items():
if not isinstance(value, (str, int, float, bool, type(None))):
raise ValueError(
f"details['{key}'] 값은 str/int/float/bool/None만 허용: "
f"{type(value).__name__}"
)
return v
배경: details는 dict[str, Any]로 선언했다. Any는 "아무거나 다 됨"이라는 뜻이므로, 리스트, 중첩 dict, 커스텀 객체 등 뭐든 들어갈 수 있다.
문제: 아무거나 다 넣을 수 있으면 JSON으로 변환할 때 에러가 날 수 있다. 예를 들어 커스텀 클래스의 인스턴스는 JSON으로 어떻게 바꿔야 하는지 알 수 없다. 또 데이터 구조가 중첩되면 읽는 쪽에서 파싱(해석)이 복잡해진다.
해결: @field_validator로 값의 타입을 원시 타입(문자열, 숫자, 불리언, None)으로 제한한다. 이렇게 하면:
- JSON 직렬화가 항상 안전하다 (원시 타입은 모든 JSON 라이브러리가 처리 가능)
- 데이터 구조가 flat(1단계 깊이)하게 유지되어, 모듈 간 데이터 교환이 단순해진다
1.5 model_copy — 불변 객체 업데이트 패턴
updated_config = self.config.model_copy(update={"is_valid": is_valid})
return self.model_copy(update={"config": updated_config})
불변(Immutable) 객체란? 한번 만들면 내용을 바꿀 수 없는 객체다. 바꾸고 싶으면 새로운 복사본을 만들어야 한다. 마치 종이에 적은 글을 수정할 때, 원본에 줄 긋고 고치는 게 아니라 새 종이에 다시 쓰는 것과 같다.
model_copy(update={...})는 원본을 그대로 두고, 특정 필드만 바뀐 새 인스턴스를 만든다. 여러 모듈이 같은 모델을 공유할 때 한쪽이 수정해도 다른 쪽에 영향이 없으니 안전하다.
그런데 이 패턴이 폐기됐다!
이후 리뷰에서 메모리 최적화가 필요해졌다. 큰 바이너리 데이터(raw_data)를 빨리 메모리에서 해제해야 하는 상황이었다. 불변 패턴은 "원본은 그대로 두고 새 복사본을 만든다"이므로, 원본의 raw_data가 메모리에 계속 남아있게 된다. 즉시 raw_data = None으로 설정해서 메모리를 확보해야 하는데, 불변 패턴이 오히려 방해가 된 것이다.
결국 frozen=True(수정 불가 설정) 없이 mutable하게 쓰고 있던 모델에서 불변성을 강제하는 것은, 복잡성과 메모리 낭비만 만든 사례가 됐다.
교훈: 설계 원칙(불변성)은 좋지만, 실제 제약 조건(메모리 한계)이 우선한다. "교과서적으로 좋은 방법"이 항상 "현실에서 최선"인 것은 아니다.
2. Python 데코레이터 심화
데코레이터는 함수나 메서드 위에 @이름을 붙여서, 그 함수의 동작을 꾸며주는(decorate) 기능이다. 함수 자체를 수정하지 않고도 새로운 기능을 추가할 수 있다. 선물 포장지처럼, 내용물(함수)은 그대로인데 겉모습(동작)을 바꿔주는 역할이다.
2.1 @property — 메서드를 속성처럼
class AnalysisAgent(BaseAgent):
@property
def name(self) -> str:
return "analysis"
agent = AnalysisAgent()
agent.name # "analysis" — 괄호 없이 접근 가능!
agent.name() # ❌ TypeError — 이미 property이므로 함수처럼 호출하면 에러
agent.name = "x" # ❌ AttributeError — setter가 없으므로 변경 불가
왜 쓸까?
보통 객체의 값을 읽을 때 agent.name처럼 괄호 없이 접근한다. 그런데 이 값이 단순 한 변수가 아니라 계산이 필요하거나 보호가 필요하다면?
@property를 쓰면 메서드(함수)인데 마치 일반 변수처럼 접근할 수 있다. 그리고 setter(값 변경 메서드)를 안 만들면 읽기 전용이 된다. 외부에서 agent.name = "해커"처럼 바꾸려 하면 에러가 나므로, 중요한 값을 보호할 수 있다.
2.2 @staticmethod — 클래스에 소속되지만 인스턴스 상태 무관
# 변경 전: 모듈 레벨 함수 (파일 어딘가에 떠돌아다님)
def _validate_format(data: bytes) -> bool: ...
# 변경 후: 클래스 안에 넣음
class DataValidator:
@staticmethod
def _validate_format(data: bytes) -> bool: ...
self가 없다는 게 핵심이다.
보통 클래스의 메서드는 첫 번째 인자로 self(자기 자신)를 받는다. self를 통해 인스턴스의 데이터(예: self.name, self.age)에 접근한다.
@staticmethod는 self를 받지 않는다. 즉, 인스턴스의 데이터를 전혀 쓰지 않는 함수다. 그렇다면 굳이 클래스 안에 넣는 이유가 뭘까?
소속을 명확히 하기 위해서다. 모듈 레벨(파일의 최상단)에 두면 "이 함수가 누구의 것인지, 어디서 쓰이는지" 파일을 뒤져야 한다. 클래스 안에 넣으면 DataValidator._validate_format(...)처럼 소속이 코드 자체에서 드러난다.
2.3 @classmethod — 클래스 자체를 인자로
class DataModel:
@classmethod
def from_path(cls, path: Path) -> "DataModel":
return cls(file_path=str(path), file_name=path.name)
# 사용할 때
model = DataModel.from_path(Path("/data/sample.csv"))
self vs cls의 차이:
self→ 인스턴스(이미 만들어진 객체)를 가리킴cls→ 클래스 자체를 가리킴
@classmethod는 인스턴스가 아직 없을 때, 클래스를 통해 직접 호출하는 메서드다. 위 예시에서 cls(...)는 DataModel(...)과 같다. 이런 패턴을 **팩토리 메서드(factory method)**라고 부른다 — "경로를 받아서 DataModel을 만들어주는 공장"인 셈이다.
Pydantic v2의 @field_validator에서 @classmethod가 필수인 이유도 이것이다. validator는 인스턴스가 생성되기 전에 실행되므로, self가 아직 존재하지 않는다. 그래서 cls를 받아야 한다.
2.4 @lru_cache(maxsize=1) → @cache
from functools import cache
@cache
def get_settings() -> Settings:
return Settings()
# 첫 호출: Settings()를 실제로 실행하고 결과를 저장
settings1 = get_settings()
# 두 번째 호출: 저장해둔 결과를 바로 반환 (Settings()를 다시 실행하지 않음)
settings2 = get_settings()
# settings1 is settings2 → True (완전히 같은 객체)
캐시(Cache)란? 한 번 계산한 결과를 저장해두고, 같은 요청이 오면 다시 계산하지 않고 저장된 결과를 돌려주는 것이다. 자주 검색하는 웹페이지를 브라우저가 저장해두는 것과 같은 원리다.
@lru_cache(maxsize=1)은 "결과 1개만 캐시한다"는 뜻이다. 그런데 get_settings()처럼 인자가 없는 함수는 결과가 항상 1개뿐이다. maxsize 제한이 무의미하므로, Python 3.9+에서 추가된 @cache(제한 없는 캐시)가 더 깔끔하다.
모듈 레벨에 settings = get_settings()를 둔 것은, 기존에 from config import settings로 사용하던 코드와의 호환성을 유지하면서도, FastAPI의 Depends(get_settings) 같은 함수형 패턴도 지원하기 위해서다.
3. 테스트 패턴: pytest & Mock
테스트는 코드가 의도대로 동작하는지 자동으로 확인하는 코드다. Mock은 "가짜 객체"다. 데이터베이스, 외부 API 같은 것을 진짜로 호출하면 느리고 불안정하니까, 가짜를 만들어서 "이 함수가 호출되면 이런 값을 돌려줘라"고 지정한다. 연극에서 실제 폭탄 대신 소품 폭탄을 쓰는 것과 비슷하다.
3.1 @pytest.fixture — 테스트 준비물 자동 주입
@pytest.fixture
def mock_parser() -> MagicMock:
"""가짜 DataParser를 만들어준다."""
return MagicMock(spec=DataParser)
@pytest.fixture
def agent(mock_parser, mock_validator) -> AnalysisAgent:
"""가짜 의존성을 주입한 AnalysisAgent를 만들어준다."""
return AnalysisAgent(parser=mock_parser, validator=mock_validator)
def test_analyze(agent, mock_parser):
# pytest가 fixture 체인을 자동으로 해결한다:
# 1. mock_parser 생성
# 2. mock_validator 생성
# 3. 둘을 이용해 agent 생성
# 4. test_analyze에 agent와 mock_parser 주입
...
어떻게 동작하나?
@pytest.fixture는 "이 함수의 결과물을 테스트에서 재사용할 준비물로 등록하라"는 뜻이다. 테스트 함수의 매개변수 이름과 fixture 함수 이름이 같으면 pytest가 자동으로 연결해준다.
위 예시에서 test_analyze(agent, mock_parser):
agent→agentfixture를 찾아서 실행agentfixture는mock_parser와mock_validator가 필요 → 이것들도 자동 실행- 이 **체인(연쇄)**을 pytest가 알아서 해결해준다
추가 기능:
scope="module"→ 모듈 전체에서 1번만 생성 (기본은 매 테스트마다 새로 생성)yield를 쓰면 테스트 후 정리(teardown) 코드를 작성할 수 있다:
@pytest.fixture
def temp_file():
f = open("test.tmp", "w")
yield f # 여기까지가 setup, 여기서 테스트 실행
f.close() # 테스트 끝나면 이 코드가 자동 실행 (teardown)
os.remove("test.tmp")
3.2 MagicMock(spec=...) — 인터페이스 동기화
# spec 없이 — 무법지대
mock = MagicMock()
mock.prse_data() # ✅ 에러 없음! (parse_data의 오타인데 통과됨)
mock.아무거나() # ✅ 이것도 통과됨 (존재하지 않는 메서드)
mock.xyz.abc.defg() # ✅ 이것마저 통과됨
# spec 있으면 — 실제 클래스의 메서드만 허용
mock = MagicMock(spec=DataParser)
mock.parse_data() # ✅ DataParser에 실제로 있는 메서드
mock.prse_data() # ❌ AttributeError! (오타 즉시 발견)
왜 spec이 중요한가?
MagicMock()은 기본적으로 어떤 메서드를 호출해도 에러 없이 통과시킨다. 편리하지만 위험하다. 메서드 이름에 오타가 있어도 테스트가 통과해버린다.
spec=DataParser를 지정하면 mock이 DataParser 클래스의 인터페이스(어떤 메서드를 갖고 있는지)를 복제한다. 실제로 존재하지 않는 메서드를 호출하면 에러가 난다. 나중에 DataParser의 메서드 이름이 바뀌면 mock 테스트도 함께 깨지므로, 테스트와 실제 코드가 항상 동기화된다.
비슷한 create_autospec(DataParser) 대신 MagicMock(spec=DataParser)를 쓴 이유:
create_autospec은 클래스 자체를 모킹한다 (인스턴스가 아니라). 우리 코드에서는 이미 만들어진 인스턴스를 주입받는 구조이므로, 인스턴스를 흉내내는 MagicMock(spec=...)이 맞다.
3.3 assert_called_once() — 호출 검증
# "validate 메서드가 정확히 1번 호출됐는가?"
mock_validator.validate.assert_called_once()
# "parse 메서드가 한 번도 호출되지 않았는가?"
mock_parser.parse.assert_not_called()
MagicMock은 모든 호출을 내부적으로 기록한다. 마치 CCTV처럼, 누가 언제 어떤 메서드를 호출했는지 다 알고 있다.
실전 활용 예시: 캐시에 결과가 이미 있으면 파서를 호출하지 않아야 한다. 이때 assert_not_called()로 "파서가 정말 호출되지 않았는지" 검증하면, 캐시 로직이 제대로 동작하는지 확인할 수 있다.
3.4 caplog — 로그 캡처 및 검증
with caplog.at_level(logging.WARNING, logger="myapp.core.processor"):
result = agent.analyze(data)
assert "파싱 실패" in caplog.text
caplog는 pytest가 기본 제공하는 fixture로, 프로그램이 남기는 로그를 캡처한다.
caplog.at_level(logging.WARNING, logger="myapp.core.processor")는 "myapp.core.processor 로거의 WARNING 레벨 이상 로그를 잡아라"는 뜻이다.
이게 왜 필요할까? 어떤 에러는 프로그램을 멈추지 않고, 로그만 남기고 계속 진행한다. 이때 "로그가 정말 남았는가?"를 테스트할 수 있어야 한다. 예를 들어 "손상된 데이터를 처리할 때 WARNING 로그가 남는가?"를 검증할 수 있다.
3.5 Pre-computed 결과 테스트 — 분기 경로 검증
성능 최적화를 위해 "미리 계산된 결과를 bool로 저장하고, 이후 단계에서 이 결과를 재활용하는" 구조를 만들었다. 이때 분기 로직이 네 갈래로 나뉘는데, 각각을 빠짐없이 테스트해야 한다:
| Pre-computed 값 | 동작 | 검증 방법 |
|---|---|---|
True | 재계산 스킵, 정상 판정 | assert_not_called() |
False | 재계산 스킵, 불확실 판정 | assert_not_called() |
None | fallback으로 원본 데이터 직접 처리 | assert_called_once() |
| 모두 pre-computed | 처리기 완전 미사용 | assert_not_called() |
None은 "아직 계산되지 않았다"는 뜻이므로, 이때만 실제 처리기를 호출해야 한다. assert_not_called()와 assert_called_once()로 각 경로에서 처리기가 호출됐는지/안 됐는지를 정확히 구분한다.
4. 에러 처리 전략
4.1 Python 예외 계층과 except Exception
Python의 모든 예외(에러)는 나무처럼 계층 구조를 이루고 있다:
BaseException ← 모든 예외의 최상위 부모
├── KeyboardInterrupt ← 사용자가 Ctrl+C를 누름
├── SystemExit ← 프로그램 종료 요청 (sys.exit())
└── Exception ← "일반적인" 예외들의 부모
├── ValueError ← 값이 잘못됨
├── TypeError ← 타입이 잘못됨
├── FileNotFoundError ← 파일을 찾을 수 없음
└── ...
except Exception이라고 쓰면 Exception과 그 아래 자식들만 잡는다. KeyboardInterrupt(Ctrl+C)와 SystemExit(프로그램 종료)는 잡지 않는다.
왜 이게 중요할까? 사용자가 Ctrl+C로 프로그램을 멈추려 하는데, except BaseException으로 모든 예외를 잡아버리면 프로그램이 멈추지 않는다. except Exception은 "프로그래밍 에러는 잡되, 사용자의 의도적 중단은 존중한다"는 뜻이다.
주석으로 "BaseException은 잡지 않음"을 명시하면, 미래 개발자가 "더 안전하게 하려고" BaseException으로 바꾸는 실수를 방지할 수 있다.
4.2 에러 타입 포맷팅
# 변경 전
error=str(e) # "No such file or directory"
# 변경 후
error=f"{type(e).__name__}: {e}" # "FileNotFoundError: No such file or directory"
"No such file or directory"만 보면 어떤 종류의 에러인지 바로 알기 어렵다. 네트워크 문제? 파일 시스템 문제? 권한 문제?
type(e).__name__은 에러 클래스의 이름(예: FileNotFoundError, PermissionError)을 반환한다. 이것을 에러 메시지 앞에 붙이면 에러의 종류와 상세 내용을 한 번에 볼 수 있다.
4.3 예외 타입별 분리 — 로그 기반 운영
이 패턴은 3단계에 걸쳐 진화했다:
1단계: 뭉뚱그려 처리
except (FileNotFoundError, json.JSONDecodeError) as e:
logger.error("DB 로드 실패: %s", e)
문제: "파일이 없는 건지, 파일은 있는데 내용이 깨진 건지" 구분이 안 된다.
2단계: logger.exception으로 변경
except (FileNotFoundError, json.JSONDecodeError) as e:
logger.exception("DB 로드 실패") # 스택 트레이스 포함
개선: 에러의 전체 경로(스택 트레이스)가 보이지만, 여전히 예외 종류를 구분하기 어렵다.
3단계: 타입별 분리 (최종)
except FileNotFoundError:
logger.error(
"DB 파일 없음 (경로: %s) — 해당 기능이 비활성화됩니다.",
self._db_path,
)
return {}
except json.JSONDecodeError:
logger.error(
"DB JSON 파싱 오류 (경로: %s) — 데이터 파일 손상 가능",
self._db_path,
exc_info=True,
)
return {}
왜 이게 좋은가?
- "파일 없음"인지 "파일 손상"인지 로그 한 줄로 즉시 구분
- 각 에러별로 영향 범위(비활성화됩니다, 손상 가능)를 명시
exc_info=True는FileNotFoundError에는 불필요(원인이 명확)하고,JSONDecodeError에는 필요(어디서 파싱이 깨졌는지 보려면 스택 트레이스가 필요)
4.4 FAIL_FAST 모드 — 환경별 동작 전환
_FAIL_FAST = os.getenv("APP_FAIL_FAST", "false").lower() == "true"
try:
result = process(data)
except Exception as e:
if _FAIL_FAST:
raise # 예외 재전파 — 프로그램 즉시 크래시
logger.error("처리 실패: %s", e)
result = default_value # 프로덕션: 에러 기록 후 계속 진행
딜레마가 있다:
- 프로덕션(실제 운영): 하나의 모듈이 실패해도 나머지는 계속 돌아야 한다. 에러를 기록하고 넘어간다.
- 개발 환경: 버그를 즉시 발견해야 한다. 에러가 나면 바로 크래시시켜서 개발자 눈에 띄게 해야 한다.
환경변수 APP_FAIL_FAST를 true로 설정하면 개발 모드, false(또는 미설정)면 프로덕션 모드다. 코드를 전혀 수정하지 않고 환경변수 하나로 동작을 전환할 수 있다.
4.5 에러 reason 구체화
# 변경 전
reason="처리 결과 없음 (워크플로우 오류)"
# 변경 후
reason="처리 결과 없음 (이전 단계 실패 후 잘못된 호출 또는 워크플로우 버그)"
변경 전 메시지를 보면 "오류가 났구나"만 알 수 있다. 변경 후 메시지를 보면 "이전 단계가 실패 한 뒤 이 함수가 호출됐거나, 워크플로우 자체에 버그가 있다"는 두 가지 가능한 원인을 즉시 파악할 수 있다.
운영 환경에서 새벽 3시에 알람이 울렸을 때, "오류입니다"와 "A가 잘못됐거나 B가 잘못됐습니다"의 차이는 디버깅 시간에서 크게 드러난다.
5. 메모리 최적화와 안전성
5.1 방어적 복사 — list(items)
# 변경 전
result = Result(items=items) # items 리스트를 그대로 전달
# 변경 후
result = Result(items=list(items)) # items의 복사본을 전달
핵심 개념: Python에서 리스트를 변수에 넣으면 복사가 아니라 "같은 물건을 가리키는 이름표"가 하나 더 생기는 것이다.
original = [1, 2, 3]
shared = original # 복사가 아님! 같은 리스트를 가리킴
original.append(4)
print(shared) # [1, 2, 3, 4] — shared도 바뀌었다!
이건 마치 같은 구글 문서를 두 사람이 공유하는 것과 같다. 한 사람이 수정하면 다른 사람에게도 바뀐 내용이 보인다.
list(items)를 하면 내용이 같은 새 리스트를 만든다. 원본과 복사본은 독립적이므로, 한쪽을 수정해도 다른 쪽에 영향이 없다. 이를 **방어적 복사(defensive copy)**라고 한다.
5.2 Lambda late binding 버그 (Critical)
코드 리뷰에서 가장 중요한 발견 중 하나였다. lambda의 동작을 정확히 이해하지 않으면 놓치기 쉽다.
# 변경 전
fallback=(lambda: processor.process(data.raw_content))
# 변경 후
raw_content = data.raw_content # 이 시점의 값을 로컬 변수에 저장
fallback=(lambda: processor.process(raw_content))
무슨 일이 벌어지는가?
Python의 lambda(그리고 일반 함수도)는 변수의 **값(value)**이 아니라 **이름(참조, reference)**을 캡처한다. 쉽게 말하면:
x = 10
f = lambda: print(x) # x의 "값 10"을 저장하는 게 아니라, "x라는 이름"을 기억함
x = 20 # x의 값을 바꿈
f() # 20 출력! (10이 아님)
lambda는 f가 만들어질 때의 x 값(10)을 기억하는 게 아니라, 실행될 때 x를 찾아가서 그때의 값(20)을 읽는다.
실전에서의 문제:
메모리 최적화를 위해 data.raw_content = None으로 큰 데이터를 해제한 뒤, 나중에 lambda가 실행되면 data.raw_content가 이미 None이 되어 있다. 프로세서에 None이 전달되면서 에러가 발생한다.
해결: lambda를 만들기 전에 raw_content = data.raw_content로 로컬 변수에 값을 미리 담아둔다. 이후 data.raw_content가 None이 되어도, 로컬 변수 raw_content는 원래 값을 그대로 갖고 있다.
