이 글은 2024년 3월 7일에 일본어로 먼저 발행된 기사를 번역한 글입니다.
안녕하세요. 커뮤니케이션 앱 LINE의 모바일 클라이언트를 개발하고 있는 Ishikawa입니다.
저희 회사는 높은 개발 생산성을 유지하기 위해 코드 품질 및 개발 문화 개선에 힘쓰고 있습니다. 이를 위해 다양한 노력을 하고 있는데요. 그중 하나가 Review Committee 활동입니다.
Review Committee에서는 머지된 코드를 다시 리뷰해 리뷰어와 작성자에게 피드백을 주고, 리뷰하면서 얻은 지식과 인사이트를 Weekly Report라는 이름으로 매주 공유하고 있습니다. 이 Weekly Report 중 일반적으로 널리 적용할 수 있는 주제를 골라 블로그에 코드 품질 개선 기법 시리즈를 연재하고 있습니다.
이번에 블로그로 공유할 Weekly Report의 제목은 '불이 'null'인 굴뚝에 연기가 'null'이 아닐 수 없다'입니다.
불이 'null'이면 연기도 'null'이다
널 객체(null object) 패턴은 null
, nil
, undefined
와 같은 값 대신 '비어 있음'이나 '유효하지 않음'과 같은 의미를 가진 객체를 사용하는 디자인 패턴 중 하나입니다. 이 패턴을 사용하는 이유로는 몇 가지가 있는데요. 대표적인 이유 중 하나는 ‘오류를 폴백(fallback)용 값으로 변환’하기 위해서 입니다.
TeamDao.selectMemberIds(TeamId): List<Int>?
라는 함수가 있다고 가정해 봅시다. 이 함수는 보통 팀 멤버의 ID 목록을 반환하지만 존 재하지 않는 팀 ID가 입력되면 null
을 반환합니다. 다음 TeamModelRepository.getMemberIds(TeamId)
에서는 TeamDao.selectMemberIds
가 null
을 반환하면 .orEmpty()
를 사용해 빈 목록으로 폴백합니다.
class TeamModelRepository(private val dao: TeamDao) {
fun getMemberIds(teamId: TeamId): List<Int> =
dao.selectMemberIds(teamId).orEmpty()
}
이 패턴을 적절히 사용하면 호출자의 코드를 단순하게 만들 수 있습니다. 대표적인 예로 List
와 같은 컬렉션을 순회하는 것을 들 수 있습니다. 다음 코드는 앞서 언급한 TeamModelRepository.getMemberIds
를 호출하는데요. null
을 처리할 필요가 없기 때문에 코드가 단순해졌습니다.
teamModelRepository.getMemberIds(teamId).asSequence()
.map(userModelRepository::getUserModel)
.map(UserModel::getName)
.forEach { userName -> sendMessage("Hello, $userName") }
널 객체 패턴은 List
와 같은 일반화된 타입뿐 아니라 애플리케이션 고유의 데이터 모델에도 사용할 수 있습니다. 다음 UserModel
은 INVALID
라는 인스턴스를 널 객체로 정의합니다.
class UserModel(
val id: Int,
val accountName: String,
val nickName: String,
val emailAddress: String
) {
val isInvalid: Boolean
get() = this == INVALID
companion object {
val INVALID = UserModel(0, "", "", "")
}
}
이 UserModel
은 다음과 같이 사용합니다.
class UserModelRepository(private val dao: UserDao) {
fun getUserModel(userId: Int): UserModel =
dao.selectUser(userId) ?: UserModel.INVALID
}
// On the caller side
fun caller(userId: Int) {
val userModel = userModelRepository.getUserModel(userId)
if (userModel.isInvalid) {
... // Unhappy-path logic such as showing a dialog
return
}
...
// Happy-path logic, such as updating profile UI
}
이 코드에 문제가 있을까요?
`null`에 연기가 나지 않게 하기
널 객체와 일반 객체를 구분해야 하는 경우 널 객체 패턴을 사용하지 않는 것이 코드를 더 견고하게 만들 수 있습니다.
이를 이해하려면 먼저 널 객체 패턴의 장점을 확인해야 하는데요. 널 객체 패턴의 장점 중 하나는 경계 조건이나 오류 조건의 로직을 일반적인 경우의 로직에 쉽게 통합할 수 있다는 것입니다. 다음 코드가 그 예시입니다.
class ProfileViewData(
val userName: String,
val profileImagePath: Path,
...
) {
companion object {
private val UNKNOWN = ProfileViewData("Unknown user", UNKNOWN_USER_IMAGE_PATH, ...)
fun fromProfileModel(model: ProfileModel?) {
if (model == null) {
return UNKNOWN // Converts null to a null object
}
// Happy-path case
...
return ProfileViewData(...)
}
}
}
// On the caller side
fun updateProfileView(val profileModel: ProfileModel?) {
val viewData = ProfileViewData.fromProfileModel(profileModel)
// Only happy-path case, no unhappy-path case
nameTextView.text = viewData.userName
profileImageView.image = loadImage(viewData.profileImagePath)
...
}
반대로 경계 조건이나 오류 조건을 일반적인 경우와 분리해야 할 때에는 널 객체 패턴을 사용하기에 적절하지 않은 경우가 많습니다. 예를 들어 위 UserModel
의 코드 스니펫에서는 userModel.isInvalid
를 확인해야 하는데요. 만약 isInvalid
를 확인하는 것을 잊어버려도 컴파일을 통과하기 때문에 실제로 작동시켜 보지 않으면 버그를 발견할 수 없습니다.
경계 조건 및 오류 조건과 일반 조건을 명확하게 구분해야 할 때에는 정적으로 검증되는 타입을 사용하는 것이 더 바람직합니다. 대표적으로 Kotlin의 null
, Swift의 nil
, 다른 언어의 Optional
또는 Maybe
등이 있습니다. 다른 타입을 사용해 오류를 구분하는 것은 동적 타이핑을 사용하는 언어에서도 효과적입니다. 실행돼서는 안 되는 코드가 그대로 실행되는 것보다 런타임 오류가 발생하는 것이 버그를 더 쉽게 발견할 수 있기 때문입니다.
널 객체 패턴은 다음 중 한 가지 상황일 때에만 사용하는 것이 좋습니다.
- 경계 조건 및 오류 조건의 경우와 일반적인 경우를 구분할 필요가 없다.
- '오류'를 나타내는 값의 후보가 여러 개이고 그중 어떤 값을 사용할지 정적으로 검증할 수 없다.
- 예:
List<T>?
의null
과 빈 목록의 경우,List<T>
로 변경하고 빈 목록만 오류 값으로 사용해야 하는 경우가 많다.
- 예:
참고: 동일성과 동등성
‘동일성(identity)’는 ‘동일한 객체’를 의미하고 ‘동등성(equivalence)’는 ‘(비록 다른 객체더라도) 동일한 값'을 의미합니다. 널 객체 패턴을 사용할 때는 이 동일성과 동등성의 차 이에 주의해야 합니다.
예를 들어 UserModel.isInvalid
는 ... == INVALID
와 같은 방식으로 구현돼 있고, UserModel
은 자체적인 equals
가 구현돼 있지 않은데요. 이 경우 UserModel(0, "", "", "").isInvalid
의 결과는 false
가 돼 해당 동작이 예상치 못한 버그를 발생시킬 수 있습니다.
한 줄 요약: 오류 값을 구분해야 하는 경우에는 널 객체 패턴을 사용하지 않는 것이 좋다.