코드 품질 개선 기법 16편: 불이 'null'인 굴뚝에 연기가 'null'이 아닐 수 없다 (새 탭에서 열림)
널 객체(null object) 패턴은 null 대신 '비어 있음'을 나타내는 객체를 사용하여 호출부의 코드를 단순화하고 예외 처리를 줄이는 유용한 디자인 패턴입니다. 그러나 일반적인 상태와 오류 상태를 명확히 구분해야 하는 상황에서 이 패턴을 무분별하게 사용하면, 컴파일러의 정적 검증을 우회하게 되어 오히려 버그를 발견하기 어렵게 만듭니다. 따라서 오류 처리가 필수적인 로직에서는 널 객체 대신 언어 차원의 null이나 Optional 타입을 사용하여 타입 안정성을 확보하는 것이 권장됩니다.
널 객체 패턴의 활용과 장점
널 객체 패턴은 유효하지 않은 값이나 비어 있는 상태를 특정 객체로 정의하여 프로그램의 흐름을 끊지 않도록 돕습니다.
- 코드 단순화: 컬렉션의 경우
null대신 빈 리스트(.orEmpty())를 반환하면 호출 측에서 별도의 널 체크 없이 즉시 순회(iteration) 로직을 수행할 수 있습니다. - 폴백 데이터 제공: UI 표시를 위한 데이터 모델에서 '알 수 없는 사용자'와 같은 기본 객체를 정의하면, 데이터가 없는 경우에도 화면 레이아웃을 깨뜨리지 않고 기본 정보를 안전하게 보여줄 수 있습니다.
- 로직 통합: 경계 조건이나 오류 상황을 일반적인 비즈니스 로직에 자연스럽게 통합시켜 코드의 가독성을 높입니다.
널 객체 패턴이 유발하는 타입 안정성 문제
오류 상태를 일반 객체처럼 취급하게 되면 개발자가 의도적으로 해당 상태를 확인해야 하는 로직을 누락했을 때 이를 잡아낼 방법이 부족해집니다.
- 컴파일 타임 검증 부재:
isInvalid와 같은 속성으로 오류를 확인해야 하는 널 객체를 사용하면, 확인 로직을 잊더라도 컴파일러는 이를 정상적인 코드로 인식합니다. - 런타임 버그 발생: 유효하지 않은 널 객체가 시스템 내부에서 계속 전달되다가 예상치 못한 지점에서 오작동을 일으킬 수 있으며, 이는 즉시 런타임 오류가 발생하는 것보다 원인 파악이 더 어렵습니다.
- 대안으로서의 정적 타입: Kotlin의 널 가능 타입(
?)이나 Swift의Optional을 사용하면 컴파일러가 강제로 널 처리를 요구하므로, 오류 조건과 일반 조건을 명확히 분리하여 처리할 수 있습니다.
널 객체 패턴 사용 시 주의할 점: 동일성과 동등성
널 객체를 정의하고 비교할 때는 객체의 비교 방식에 각별히 유의해야 합니다.
- 동일성(Identity) 문제:
UserModel.INVALID와 같은 정적 인스턴스를==연산자로 비교할 때, 해당 클래스에equals가 적절히 구현되어 있지 않으면 내용이 같더라도 다른 객체로 판별될 위험이 있습니다. - 값 기반 비교의 한계: 단순히 기본값(ID 0, 빈 문자열 등)을 채워 넣은 새 객체를 생성해 비교할 경우, 실제 '무효한 상태'를 나타내는 싱글톤 객체와 일치하지 않아 로직 오류가 발생할 수 있습니다.
상황에 맞는 도구 선택 제안
널 객체 패턴은 만능 해결책이 아니며, 상황에 따라 적절한 도구를 선택하는 것이 중요합니다.
- 널 객체를 권장하는 경우: 일반적인 경우와 오류/경계 상황을 굳이 구분할 필요가 없거나, '오류'를 나타내는 후보가 너무 많아 정적 검증이 오히려 복잡해질 때 사용합니다.
- 정적 타입을 권장하는 경우: 비즈니스 로직상 오류 상태를 반드시 인지하고 별도의 처리(예: 에러 다이얼로그 표시)를 수행해야 한다면 널 객체 대신 언어에서 제공하는
null이나Optional을 활용하여 타입 시스템의 보호를 받아야 합니다.