1. 핵심 주제 요약
Python 코드에서 None 체크가 도처에 퍼지는 문제를 해결하는 7가지 실전 패턴을 드론 배달 시스템 예제로 설명한다.
핵심 명제:
"문제는 None 자체가 아니라, 엣지(외부)의 지저분한 데이터가 도메인 코어 안으로 들어올 때 발생한다."
null 참조를 발명한 Tony Hoare는 이것을 **"10억 달러짜리 실수(Billion Dollar Mistake)"**라고 불렀다.
2. 주요 개념 설명
문제의 본질
DroneTelemetry같은 원시 외부 데이터는 GPS 실패, 배터리 미수신, 날씨 API 불완전 등으로None이 자연스럽다- 문제는 이런
None이 포함된 원시 데이터가 도메인 코어 함수(assign_delivery)에 그대로 전달될 때 발생 - 결과: 모든 함수·메서드가 None 체크를 반복해야 하는 "빅 메스" 형성
7가지 해결 패턴
패턴 1: 더 나은 기본값 (Better Defaults)
진짜 사용 가능한 기본값이 있을 때 None 대신 그것을 쓴다.
avoid_zones: list[Geofence] | None→avoid_zones: list[Geofence] = field(default_factory=list)- 빈 리스트로 이터레이션 가능, None 체크 불필요
None이 "비어 있음", "비활성화", "할 것 없음"을 의미할 때 효과적 (False, [], 0 등)
패턴 2: 더 일찍 검증 (Validate Sooner) — 엣지/도메인 분리
외부 엣지 데이터와 코어 도메인 데이터를 분리하는 별도 클래스를 만든다.
# 외부 원시 데이터 (None 허용)
@dataclass
class DroneTelemetry:
drone_id: str
location: Coordinates | None
battery_level: float | None
max_wind_speed: float | None
# 도메인 코어 객체 (None 금지, 엄격)
@dataclass(frozen=True)
class ReadyDrone:
drone_id: str
location: Coordinates # None 없음!
battery_level: float # None 없음!
max_wind_speed: float # None 없음!
# 변환 함수: 검증 로직 집중
def prepare_drone(telemetry: DroneTelemetry) -> ReadyDrone:
if telemetry.location is None:
raise ValueError("Drone has no GPS location")
if telemetry.battery_level is None:
raise ValueError("No battery reading")
if telemetry.max_wind_speed is None:
raise ValueError("No max wind speed data")
return ReadyDrone(
drone_id=telemetry.drone_id,
location=telemetry.location,
battery_level=telemetry.battery_level,
max_wind_speed=telemetry.max_wind_speed,
)
assign_delivery는 이제ReadyDrone을 받으므로 내부 None 체크 전부 제거- Fail Fast 원칙: GPS 없으면 즉시 중단, 코어 로직은 강한 가정 위에서 동작
패턴 3: 예외 발생 (Raise Exceptions Instead of Returning None)
return None 대신 의미있는 예외를 발생시킨다.
class RouteRejected(Exception):
pass
def assign_delivery(drone: ReadyDrone, weather: WeatherReport, ...) -> Route:
if weather.wind_speed > drone.max_wind_speed:
raise RouteRejected("Too windy")
if drone.battery_level < MINIMUM_BATTERY:
raise RouteRejected("Battery too low")
return Route(...) # 항상 Route 반환, None 없음
- 반환 타입이
Route | None→Route로 단순화 - 호출부에서
if route is None:체크 불필요 → 코드 단순화
패턴 4: 상태 명시적 모델링 (Model States Explicitly)
객체가 여러 상태를 거친다면, 상태별로 별도 클래스를 만든다.
@dataclass(frozen=True)
class OfflineDrone:
drone_id: str
# 위치, 배터리 없음
@dataclass(frozen=True)
class ConnectedDrone:
drone_id: str
battery_level: float
# 위치 없음
@dataclass(frozen=True)
class ReadyDrone:
drone_id: str
location: Coordinates
battery_level: float
max_wind_speed: float
assign_delivery는ReadyDrone만 받음 → "오프라인 드론을 라우팅에 넘길 수 없다"는 의도를 타입으로 표현reset(drone: OfflineDrone)같은 함수는 오프라인 드론만 받음- 클래스 하나에 Optional 필드가 많다면 경고 신호 — 상태 분리가 필요하다는 뜻
패턴 5: Null Object 패턴
"아무것도 안 하는 것"이 유효한 동작일 때 사용한다.
from typing import Protocol
class DiagnosticsProtocol(Protocol):
def record(self, message: str) -> None: ...
class RouteDiagnostics:
def record(self, message: str) -> None:
print(f"[DIAG] {message}") # 실제 로깅
class NullDiagnostics:
def record(self, message: str) -> None:
pass # 아무것도 안 함
def assign_delivery(drone: ReadyDrone, diagnostics: DiagnosticsProtocol) -> Route:
diagnostics.record("Assigning route...")
# if diagnostics is not None: 체크 전혀 없음!
...
diagnostics: RouteDiagnostics | None→diagnostics: DiagnosticsProtocol- 호출 시
NullDiagnostics()를 넘기면 로깅 없이 동작 - Protocol 클래스로 덕 타이핑 활용
패턴 6: 센티넬 객체 (Sentinel Objects)
None 자체가 이미 유효한 값이고, "제공되지 않음"을 별도로 표현해야 할 때.
MISSING = object() # 고유한 센티넬
def update_settings(value=MISSING):
if value is MISSING:
# 호출자가 인자를 생략한 경우
...
elif value is None:
# 호출자가 명시적으로 None을 전달한 경우
...
- API에서 "인자 생략" vs "None 전달"을 구분해야 할 때 사용
- Python 표준 라이브러리에서도 사용되는 패턴
패턴 7: Result 객체 (Result Objects)
예외 대신 성공/실패를 명시적 타입으로 반환한다.
@dataclass
class RouteFailed:
reason: str
RouteResult = Route | RouteFailed
def assign_delivery(drone: ReadyDrone, ...) -> RouteResult:
if too_windy:
return RouteFailed(reason="Too windy")
return Route(...)
- Haskell, Rust의
Result<T, E>타입에서 온 개념 - Python에서는
returns패키지로 구현 가능 - ArjanCodes의 견해: Python에서는 예외가 표준 관행이므로, Result 객체는 혼란을 줄 수 있어 예외 사용을 권장
3. 실전 코드/예제
Before (문제가 있는 코드)
def assign_delivery(telemetry: DroneTelemetry, weather: WeatherReport, ...) -> Route | None:
if telemetry.location is None:
return None
if telemetry.battery_level is None:
return None
if telemetry.max_wind_speed is None:
return None
if weather.wind_speed is None:
return None
if weather.wind_speed > telemetry.max_wind_speed:
return None
if telemetry.battery_level < MINIMUM:
return None
...
return route
After (개선된 코드)
def assign_delivery(drone: ReadyDrone, weather: WeatherReport,
diagnostics: DiagnosticsProtocol) -> Route:
if weather.wind_speed > drone.max_wind_speed:
raise RouteRejected("Too windy")
if drone.battery_level < MINIMUM:
raise RouteRejected("Battery too low")
diagnostics.record(f"Route assigned for drone {drone.drone_id}")
return Route(...)
패턴 적용 흐름
[외부 입력] DroneTelemetry (None 허용)
↓
[prepare_drone()] 검증 & 변환 (None → ValueError)
↓
[도메인 코어] ReadyDrone (None 없음)
↓
[assign_delivery()] 순수 비즈니스 로직 (None 체크 없음)
4. 시사점 및 적용 방법
핵심 설계 원칙
-
엣지와 코어를 분리하라
- 엣지(입력 레이어): None 허용, 방어적 프로그래밍
- 코어(도메인 레이어): None 금지, 강한 불변 조건
-
Invalid States를 표현하기 어렵게 만들어라
- 타입 시스템으로 잘못된 상태를 컴파일 타임(또는 초기화 시)에 차단
- "좋은 동작에 보상을 주는 설계"
-
Fail Fast
- 오류는 발생 즉시 터뜨려라
- 코어 로직에서는 모든 데이터가 유효하다고 가정
어떤 패턴을 언제?
| 상황 | 권장 패턴 |
|---|---|
| None이 "빈 값/비활성화" 의미 | Better Defaults |
| 외부 원시 데이터 → 도메인 진입 | Validate Sooner (분리 클래스) |
| 함수가 실패할 수 있음 | Raise Exceptions |
| 객체에 생명주기 상태가 있음 | Model States Explicitly |
| 선택적 동작(로깅 등) | Null Object Pattern |
| None도 유효한 값인데 "미제공" 구분 필요 | Sentinel Objects |
| 함수형 스타일 선호 | Result Objects |
Python 실무 적용 체크리스트
- [ ]
Optional[X]필드가 많은 클래스 → 상태 분리 고려 - [ ] 함수 내부에
if x is None:반복 → 검증 레이어 앞으로 이동 - [ ]
return None사용 중 → 예외 또는 기본값으로 대체 가능한지 검토 - [ ] 리스트/딕셔너리 타입의 None →
[],{}기본값으로 교체 - [ ] 전략 패턴의 선택적 객체 → Null Object Pattern 적용
ArjanCodes의 최종 결론
"None 체크와 검증은 소프트웨어의 입력 엣지에 집중하고, 내부 코어에서는 매우 엄격하게 유지하라. 이것이 None을 코어에서 거의 사용하지 않게 되는 비결이다."
