

위와 같이 null을 반환하지 않도록 수정하라는 코드리뷰를 받았다.

얼마전에 본 클린코드의 목차에서 null과 관련된 내용이 있었던게 생각났고 이 책을 시작으로 다른 책들을 찾아보면서 Null Object Pattern이라는 키워드를 얻었다.
C나 JS함수들을 사용하다 보면 -1이나 null을 반환하는 함수들을 종종 사용하곤 한다.
석학들이 머리 맞대어 만든 함수들도 그런데 나는 그러면 안되는 건가..? 라는 생각을 한쪽에 품고 Null Object Pattern을 찾아 보던 중 책에서 이런 내용을 보았다.
[ 매직값을 반환하지 말아야 한다 ]
매직값은 함수의 정상적인 반환 유형에 적합하지만 특별한 의미를 가지고 있다.
매직값의 일반적인 예는 값이 없거나 오류가 발생했음을 나타내기 위해 -1을 반환하는 것이다.
[ 매직값은 버그를 유발할 수 있다 ]
값이 없음을 나타내기 위해 함수에서 -1을 반환하는 경우가 있다.
과거에는 더 명시적인 오류 전달 기법이나 널 또는 옵셔널 타입을 반환하는 것이 가능하지 않거나, 실용적이지 않기 때문에 매직값을 반환하는 것이 어느정도 합리적인 이유가 있었다.
레거시 코드로 작업 중이거나, 신중하게 최적화 해야하는 코드가 있다면 이렇게 할 이유가 있을 수 는 있다.
그러나 일반적으로 매직값을 반환하면 예측을 벗어날 위험이 있으므로 사용하지 않는 것이 가장 바람직하다.
-1과 같은 매직 넘버를 반환하는 것은 null을 반환하는 것 보다 더 안좋다
그래서 Null Object Pattern이 뭐지?
Null Object Pattern은 null 검사 코드 누락에 따른 문제를 없애 준다.
null을 반환하지 않고 null을 대신할 객체를 반환함으로써 null 검사 코드를 없앨 수 있도록 한다.
참고로 Null Object Pattern은 GoF 디자인 패턴에 속하는 패턴은 아니다.
검색을 하다보면 Null Object Pattern과 Special Case Pattern을 혼용하여 사용하곤 하는데 엄밀히 따지면 Null Object Pattern은 Special Case Pattern안에 포함되는 것으로 볼 수 있다.
신입 고객인 경우 특별 할인 내역을 명세서에 등록하는 예시 코드이다.
class Bill {
private val billContents = mutableListOf<Double>()
fun getTotalAmount(): Double {
return billContents.sum()
}
fun add(content: Double) {
billContents.add(content)
}
}
interface Discount {
fun addDetailToBill(bill: Bill)
}
class SpecialDiscount : Discount {
override fun addDetailToBill(bill: Bill) {
val totalAmount = bill.getTotalAmount()
bill.add(totalAmount * DISCOUNT_RATE)
}
companion object {
private const val DISCOUNT_RATE = -0.3
}
}
null 사용한 경우
object SpecialDiscountFactory2 {
fun create(customer: Customer): Discount? {
if (checkNewCustomer(customer)) {
return SpecialDiscount()
}
return null
}
}
fun createBill2(customer: Customer): Bill {
val bill = Bill()
// ... 사용 내역 추가
// .. 기타 할인 내역 추가
// 특별 할인 내역 추가
val specialDiscount = SpecialDiscountFactory.create(customer)
if (specialDiscount != null) {
specialDiscount.addDetailToBill(bill)
}
return bill
}
별도의 클래스 구현한 경우
class NullSpecialDiscount : Discount {
override fun addDetailToBill(bill: Bill) {
}
}
object SpecialDiscountFactory {
fun create(customer: Customer): Discount {
if (checkNewCustomer(customer)) {
return SpecialDiscount()
}
return NullSpecialDiscount()
}
}
fun createBill(customer: Customer): Bill {
val bill = Bill()
// ... 사용 내역 추가
// .. 기타 할인 내역 추가
// 특별 할인 내역 추가
val specialDiscount = SpecialDiscountFactory.create(customer)
specialDiscount.addDetailToBill(bill)
return bill
}
어떤 HTML 요소가 강조된 상태에 있는지 확인하는 코드이다. getClassNames() 함수를 호출해서 해당 요소에 적용된 클래스 중에 ‘highlighted’ 클래스가 있는지 확인한다. 이때 함수는 그 요소가 클래스 속성을 가지고 있지 않은 경우 널을 반환한다.
<p class="highlighted">
null 사용한 경우
fun getClassNames(element: HtmlElement): List<String>? {
val attribute = element.getAttribute("class")
if (attribute == null) { // class속성이 없으면 널값을 반환한다.
return null
}
return attribute.split(" ")
}
fun isElementHighlighted(element: HtmlElement): Boolean {
val classNames = getClassNames(element)
if (classNames == null) { // classNames 사용 전 널 확인을 먼저 해주어야 한다.
return false
}
return classNames.contains("highlighted")
}
널 객체 패턴 사용한 경우 (빈 리스트)
fun getClassNames(element: HtmlElement): List<String> {
val attribute = element.getAttribute("class")
if (attribute == null) { //attribute의 유무는 하위 단계에서 중요X
return emptyList()
}
return attribute.split(" ")
}
fun isElementHighlighted(element: HtmlElement): Boolean {
return getClassNames(element).contains("highlighted")
}
null대신 빈 리스트를 반환함으로써 코드는 간단해지고 예측을 벗어나는 작동을 할 가능성은 낮아진다.
널 객체 패턴 사용한 경우 (빈 문자열)
class UserFeedback(private val additionalComments: String?) {
fun getAdditionalComments(): String {
if (additionalComments == null) {
return ""
}
return additionalComments
}
}
사용자가 피드백을 제공할 때 입력한 자유 양식의 코멘트에 접근하는 함수이다.
사용자가 코멘트를 입력하지 않은 것인지 혹은 빈 문자열을 명시적으로 입력한 것인지 구분하는 것은 의미가 없기에 빈 문자열을 반환하도록 하였다.
클라이언트는 실제 협력자와 null 협력자를 같은 방식으로 다룰 수 있게 된다. 따라서 null 협력자를 특별히 처리하는 테스트 코드를 만들지 않아도 되므로 클라이언트 코드를 단순하게 만든다.
아무것도 하지 않는 협력자가 필요한 여러 클라이언트들도 똑같은 방식으로 아무것도 하지 않을 수 있다. 만약 아무 것도 하지 않는 행동에 변경이 필요하다면, 한 곳에서만 코드를 수정하면 된다.
class NullSpecialDiscount : Discount {
override fun addDetailToBill(bill: Bill) {
bill.add(-10.0) // 최초 고객이 아닐 경우 기본 할인 금액(10원)만 추가되도록 수정
}
}
좀 더 복잡한 상황에서는 예측을 벗어나는 작동을 할 위험이 커지는 반면 이점은 적어질 수 있다.
문자열이 코멘트가 아닌 ID라면 빈 문자열을 명시적으로 입력한 것인지 구분하는 것이 다음에 실행할 논리에 영향을 미칠 수 있기에 중요해 질 수도 있다.
이런 경우, 문자열이 없을 수 있음을 함수를 호출하는 쪽에서 명시적으로 인식하도록 하느 것이 중요해질 수 있다.
널 객체 패턴 사용한 경우 (빈 문자열)
class Payment(private val cardTransactionId: String?) {
fun getAdditionalComments(): String { // 함수 시그니처는 ID가 항상 존재할 것임을 보여준다.
if (cardTransactionId == null) {
return ""
}
return cardTransactionId
}
}
위와 같이 작성한 경우 함수를 사용하는 쪽에서 반환 값이 항상 null이 되지 않는 것으로 생각하고 모든 반환 값이 카드 트랜잭션이라 생각할 수 있다.
null 사용한 경우
class Payment(private val cardTransactionId: String?) {
fun getAdditionalComments(): String? { // 시그니처를 통해 ID가 존재하지 않을 수도 있음을 보여준다.
return cardTransactionId
}
}
커피 머그 잔을 나타내는 클래스와 커피 머그잔의 재고를 나타내는 클래스가 있다.
MugInventory 클래스는 재고에서 무작위로 머그잔을 가져오는 함수이다. 만약 재고가 없다면, 널 대신 크기가 0인 널 머그잔을 반환한다.
별도의 클래스 구현한 경우
class CoffeeMug(override val diameter: Double, override val height: Double) : Mug()
class NullMug(override val diameter: Double = 0.0, override val height: Double = 0.0) : Mug()
class MugInventory(private val mugs: List<Mug>) {
fun getRandomMug(): Mug {
if (mugs.isEmpty()) {
return NullMug()
}
return mugs.shuffled()[0]
}
}
위와 같이 작성 할 경우 호출하는 쪽에서는 재고가 없는 상황에서 크기가 0인 머그잔이 반환되더라도 일반 머그잔이라 인식하고 부정확한 정보들을 알아차리지 못할 수 있다.
(만약 사이즈가 0인 커피 머그잔을 반환 받는 것이 요구사항이라면 상관은 없을 것 같다)
null 사용한 경우
class MugInventory(private val mugs: List<Mug>) {
fun getRandomMug(): Mug? { // 시그니처를 통해 명시적으로 유효한 머그잔을
if (mugs.isEmpty()) { // 반환하지 않을 수 있음을 보여줄 수도 있다.
return null
}
return mugs.shuffled()[0]
}
}
여러 클라이언트가 null 객체가 아무 것도 하지 않는 것에 대해 동의하지 않는다면 구현하기에 곤란할 수 있다.
null 객체는 항상 아무 일도 하지 않는 객체로 존재해야 합니다. null 객체는 실제 객체로 변환되면 안된다.
객체의 유무가 중요할 경우 널객체를 사용하지 않는 것이 좋을 수 있다.
도입했을 때 클래스와 코드가 마구 늘어난다면 이 패턴이 적절하지 않은 상황이거나 잘못 구현한 것이다.
클린 코드
개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴
Good Code, Bad Code
https://johngrib.github.io/wiki/pattern/null-object/#fnref:fowler0