ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 왜 Null을 보고 나쁘다고 하는걸까?
    웹 개발/웹&프로그래밍 2022. 3. 26. 23:55

    신입 때부터 관용어처럼 'null은 나쁘다'라는 말을 들어왔다.

     

     

    '왜 null은 나쁜가?'라는 궁금증을 가지고 있으면서도 막연히 객체, 값의 불안정함, null 처리를 위해 생기는 지저분한 코드들을 만들어내기에 나쁘다고 하는거겠지? 라며 근거없이 혼자 추측해서 짐작만 하고 있었다.

     

    근원적인 질문에 대해 이해하고 정리해두고 싶어서 null이 왜 나쁜지 몇가지 레퍼런스들을 읽어보기로 했다.


    1. Null이란

    1-1. 개념

    먼저 null이란 무엇인가?

     

    영국의 컴퓨터 과학자인 Tony Hoare(토니 호어?라고 읽어야하나)가 만든 개념으로 위키백과를 참고하자면,

     

    In computing, a null pointer or null reference is a value saved for indicating that the pointer or reference does not refer to a valid object.

     

    null 참조(null reference) 또는 null 포인터(null pointer)란 유효한 객체(Object)를 포인터(또는 참조) 하지 않고 있음을 가리키기 위한 저장된 값을 말한다. (null 참조, null 포인터는 같은 의미지만 맥락에 맞춰 혼용해서 사용하겠다.)

     

    덧붙이자면, null 포인터와 초기화되지 않은 포인터(uninitialized pointer)와 혼동하면 안된다. null 포인터는 다른 유효한 객체의 포인터와 비교하여 같지 않다고 보장한다. 하지만, 언어와 구현에 따라 초기화되지 않은 포인터는 어떤 것도 보장하지 않는다. 유효한 포인터와 같다 할 수도, null 포인터와 비교하여 같다 할 수도 있다.


    1-2. 누가 만들었나

    null을 만든 Tony Hoare는 2009년 한 소프트웨어 컨퍼런스에서 null 참조를 만든 것은 10억 달러의 실수라고 했다.

     

    I call it my billion-dollar mistake…At that time, I was designing the first comprehensive type system for references in an object-oriented language. My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn’t resist the temptation to put in a null reference, simply because it was so easy to implement. This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.
    – Tony Hoare, inventor of ALGOL W.

     

    그가 한 말을 대략 요약하자면 다음과 같다.

     

    그는 1965년 null 참조를 만들어냈다. 그 당시, ALGOL W라는 객체 지향 언어의 참조(reference)를 위한 최초의 종합적인 타입 시스템을 구축하고 있었다.

     

    당시, 그의 목표는 컴파일러가 자동으로 체크하면서 완벽하게 안전한 참조의 사용을 보장하는 것이었다. 하지만, null 참조를 넣는 유혹을 이기지 못했는데 왜냐면 구현하기가 무척 쉬웠기 때문이다.

     

    결국 이는 무수한 에러들, 불안정함, 시스템 충돌을 낳았다고 말했다.

     

    자. 그렇다면 대략 null이 무엇인지 알았고, 어떤 결과가 발생했는지 어~렴풋이 Tony Hoare의 말을 통해 알았으니 더 자세히 살펴보겠다.


    2. 왜 나쁜가.

    들어가기에 앞서, 이미 설명이 잘된 포스트가 있다.(미뮬님이 해석해두신 포스트는 여기를 참고)  이 포스트의 주된 내용과 추가적인 내용을 합쳐서 정리해보려 한다.

     

    정리와 내 나름의 생각이 들어있으므로 포스트 그대로 번역한게 아니기 때문에 원본을 보고 싶다면 앞에 언급한 포스트를 보면 된다.

     

    2-1. Ad-hoc(임시방편, 이것을 위해서만 만들어진) 에러 핸들링

    객체를 받을 경우, 항상 null인지 유효한 객체인지 체크해야한다.

     

    null 체크를 깜빡하면, 런타임 중에 NPE(Null Pointer Exception)을 보게 될 것이다.

     

    그래서 코드 로직이 if/then/else 식의 멀티 체크로 오염될 수 밖에 없다.

    // this is a terrible design, don't reuse
    Employee employee = dept.getByName("Jeffrey");
    if (employee == null) {
      System.out.println("can't find an employee");
      System.exit(-1);
    } else {
      employee.transferTo(dept2);
    }

    항상 이런 코드가 들어가야한다면 끔찍하다...

     

    위의 코드는 C처럼 절차지향적인 언어에서 예외적인 상황에 대응하는 것이다. OOP에서는 이런 상황에 깔끔하게 대처하기 위해 exception handling(예외 처리)을 도입했다. (우리가 흔히 아는 try, catch, throw exception 등등)

     

    그래서 코드는 아래처럼 깔끔해진다.

    dept.getByName("Jeffrey").transferTo(dept2); // 메서드 체이닝~

    null 참조는 절차지향적 언어의 상속이라 생각하고, null object 또는 exception을 사용하라.

     

    * 고찰

    서비스 개발을 위해 필요해서 체크하는 코드가 아니라 단순히 null 여부를 체크하기 위한 코드가 들어가니,

     

    처음부터 null이 아닌 객체가 확실하다면 위의 코드들은 안 만들어도 됐을 코드가 만들어진 것이다.

     

    불필요한 코드를 만들게 한다는 것.

     

    2-2. 모호한 의도(또는 의미)

    위의 getByName() 함수가 명확하게 의미가 전달되려면, getByNameOrNullIfNotFound()로 정의해야한다. 

     

    이런 문제는 null 또는 객체를 반환하는 모든 함수에 동일하게 발생한다. 그렇지 않으면 코드를 읽는 사람은 모호할 수 밖에 없어서 결국 긴 이름의 함수를 쓸 수 밖에 없다.

     

    모호성을 없애려면 항상 실제 객체를 반환하거나, null 객체 반환 또는 exception을 던져야한다.

     

    * 고찰

    평소 개발하는걸 생각하면 null을 반환해줘야 할 경우, getOrNull*이란 명칭으로 사용한다.

     

    null이어도 흐름상 상관없는 경우에 쓰긴하는데, 정석대로라면 null 객체를 반환해줘야겠지만, 그냥 null을 반환하곤 한다.

     

    이런 메서드를 만들기보다 보통 찾으려는 객체가 없으면 더이상 로직 진행이 어려운 경우가 많기 때문에

     

    주로 의미에 맞게 exception을 던지는 편인듯하다.

     

    2-3. 컴퓨터적 사고 vs 객체적 사고

    자바의 객체는 자료구조를 가리키는 포인터라는 것과 null은 아무것도 가리키지 않는 포인터라는 것을 아는 사람이라면 if (employment == null)이라는 문장을 이해할 수 있다.

     

    하지만, 객체 입장에서 이해한다면 이 문장은 의미없는 문장이다. 아래는 객체 입장에서 본 코드이다.

    - Hello, is it a software department? 
    - 안녕하세요. 소프트웨어 부서인가요?
    - Yes. 
    - 네.
    - Let me talk to your employee "Jeffrey" please.
    - Jeffrey라는 직원 좀 바꿔주세요.
    - Hold the line please... 
    - 잠시만 기다려주세요.
    - Hello.
    - (제프리 일수도 있고 null일 수도 있는 객체)안녕하세요.
    - Are you NULL?
    - 당신은 NULL 입니까?

     

    마지막 질문은 이상하게 들린다.  (제프리에게 통화건 걸 알면서 null이냐고 묻는게 이상하다는 말)

     

    대신, 만약 우리가 제프리에게 통화 요청 한 후 중간에 전화가 끊긴다면 뭔가 문제가 생겼다고 생각한다.(exception 발생)

     

    그러면 우린 전화를 다시 걸거나, 우리 상사에게 제프리가 연결되지 않는다고 알리고 다른 더 중요한 거래를 수행한다. 

     

    대안으로 제프리는 아니지만, 우리의 질문을 가장 잘 도와줄 수 있는 사람 또는 제프리만 수행할 수 있는 일이라 도와줄 수 없다고 말하는 사람(Null 객체)과 이야기할 수 있다.

     

    * 고찰

    객체 지향적 사고로 생각하긴 아직 멀었나보다. 상황으로 설명하니 이해가 되지만, if (object == null) 처리가 객체지향적 사고로 부자연스러운 처리라고 생각하지 못했다.

     

    객체를 찾아가는 입장에서 내가 찾으려는 객체와의 연결을 요청했음에도 연결된 대상에게 내가 찾는 객체인지 다른 대상(여기선 null)인지 확인하는 절차가 부자연스럽긴 하다.

     

    2-4. 느린 실패

    null을 사용한 코드는 빠른 실패가 아니라 천천히 실패하게 만든다. 뭔가 잘못되었을 때 모두에게 알리고 즉시 에러 핸들링을 하는 대신에, null을 사용한 코드는 클라이언트에게 이러한 실패들을 숨기게 된다.

     

    이러한 논의는 위에 이야기한 Ad-hoc 에러핸들링에 가깝다.

     

    코드는 가능한 깨지기 쉽고 필요할 땐 멈추도록 해야 한다.

     

    메소드들은 조작(다루는)하는 데이터에 대해 최대한 까다롭게 만들어야 한다.

     

    제공하는 데이터가 부족하거나, 메서드 시나리오에서 주된 흐름에 맞지 않으면 exception을 던지도록 해야한다.

     

    그렇지 않으면 공통적인 행위를 하고 모든 요청에 대해 exception을 던지는 null object를 반환해야한다.

     

    public Employee getByName(String name) {
      int id = database.find(name);
      Employee employee;
      if (id == 0) {
        employee = new Employee() {
          @Override
          public String name() {
            return "anonymous";
          }
          @Override
          public void transferTo(Department dept) {
            throw new AnonymousEmployeeException(
              "I can't be transferred, I'm anonymous"
            );
          }
        };
      } else {
        employee = Employee(id);
      }
      return employee;
    }

     

    * 고찰

    null을 받은 클라이언트 입장에서는 한참 뒤에 객체가 null이었다는 사실을 알게 되는 경우가 생긴다.

     

    객체를 반환받았을 때, 사실 null이었음에도 그 사실을 인지하지 못한 채 로직이 계속 진행되다 그 객체를 사용하려는 때가 되서야 Null Pointer Exception을 받아보게 된다.

     

    라이브러리 api나 프로그래밍 언어의 api를 사용하는 개발자 말고 일반 유저 입장에게도 발생할 수 있다.

     

    개발자가 해당 객체를 호출만 하고 어떠한 코드의 건드림 없이 그대로 일반 유저까지 데이터를 내려준다면 유저가 null을 보게 되는 것이다.

     

    그래서 화면이 무한로딩이거나 아무 화면이 안 뜨는 경우가 생길 수 있다.

     

    2-5. 가변적이고 불완전한 객체

    이 목차는 내가 느끼기에는 null이 나쁜 이유보다는 가변적인 객체가 나쁜 것에 대한 설명이고 그 가변 객체를 만들기 위해 null이 쓰여짐을 설명하는 듯하다.

     

    일반적으로, 객체는 불변하도록 디자인 해야한다.(highly recommended)

     

    이는 인스턴스화 될 때, 모든 필요한 정보를 얻어야하고 전체 라이프 사이클 동안 이러한 정보들이 변하지 않아야한다는 것을 의미한다.


    흔히, 불안전하고 가변적인 객체를 만드는 lazy loading(지연 로딩) 시, null이 사용된다. 

     

    예를 들어 아래 처럼 말이다.

    public class Department {
      private Employee found = null;
      
      public synchronized Employee manager() {
        if (this.found == null) {
          this.found = new Employee("Jeffrey");
        }
        return this.found;
      }
    }

    이러한 기술은 널리 사용되지만, OOP에서 안티패턴이다.

     

    왜냐면 대게 객체가 알 필요없는 컴퓨터 환경의 성능 문제에 대한 책임이 생기기 때문이다.

     

    객체가 비즈니스적인 로직(행동)이나 상태에 대해서 관리하는게 아니라 자신의 결과에 대한 캐싱을 신경쓰는 것이다.

     

    캐싱은 객체(여기서는 Employee)가 신경쓸 부분이 아니다.

     

    해결책은? lazy loading을 주된 방법으로 사용하지 않는 것이다.(직접적으로 쓰지말라고 이해하면 될 듯 싶다.)

     

    애플리케이션에서 다른 layer에 맡기면 된다.

     

    예를 들면, 자바에서 AOP(관점 지향적 프로그래밍)를 활용하여 처리할 수 있다.

     

    @Cacheable 애너테이션이 있는데, 이 애너테이션이 선언된 메서드가 결과 값을 캐싱하도록 한다. 이렇게 말이다.

    import com.jcabi.aspects.Cacheable;
    
    public class Department {
      @Cacheable(forever = true)
      public Employee manager() {
        return new Employee("Jacky Brown");
      }
    }

     

    * 고찰

    위의 예시하고 다르지만, null을 통해 객체나 변수를 가변적으로 만들었다.

     

    첫 회사를 다닐적에 경우에 따라 값을 다르게 넣어주기 위해 변수의 초기화를 위해 null을 자주 사용했다.

    (방법이 있었지만, 그저 아무 생각없이 사용했다.)

     

    간단한 예시를 들자면 아래처럼 유사하게 만들곤 했었다.

    public void sayHelloByCountry(Country country) {
        String sayHello = null;
        
        if (country.equals(Country.JAPAN)) {
        	sayHello = "こんにちは";
        } else if(country.equals(Country.ENGLAND)) {
        	sayHello = "Hello";
        } else {
        	sayHello = "안녕하세요";
        }
        
        return sayHello;
    }

     

    위의 로직이 있을 때, 지금 만든다면 null을 통한 lazy loading 필요없이 아래처럼 만들지 않을까 싶다. (코틀린에서는 if식을 리턴할 수 있지만..)

    public void sayHelloByCountry(Country country) {    
        if (country.equals(Country.JAPAN)) {
        	return "こんにちは";
        } 
        
        if(country.equals(Country.ENGLAND)) {
        	return "Hello";
        }
        
        return "안녕하세요";
    }

     


    여기서부터는 다른 포스팅 내용이다.

    2-6. Null은 타입을 파괴한다.

    정적 타입언어들은 프로그램에서 실제로 실행하지 않고 타입의 사용을 체크해서 프로그램의 행동을 어느정도 보장한다. 

     

    예를 들어, 자바에서 x.toUpperCase()가 있다면 x가 String이라면 타입 체크에 성공한다. 

     

    정적 타입 체크는 강력하다. 그러나 어떤 참조도 null이 될 수 있기 대문에 메서드를 호출하면 NPE가 발생할 수 있다.

     

    결국, toUpperCase()란 메서드는 String이 null이 아닐 때만 안전하게 호출할 수 있다.

     

    꼭 자바 뿐만 아니라 많은 언어에서도 생기는 결점이다. null이 타입 체크보다 위에 있는 것이다.

     

    2-6. Null은 엉성(지저분)하다.(sloppy)

    null을 갖는 것이 이해가 되지 않는 경우가 허다하다. 불행히도 어떤 프로그래밍 언어에서 무엇이든 null이 될 수 있다고 허용한다면, 무엇이든 null이 될 수 있다는 말이다.

     

    예를 들어, java에서는 String에 대해 늘 이런 처리를 해줘야한다.

    if (str == null || str.equals("")) {
    }

     

    2-7. Null은 빈약한 api를 만든다.

    Key-Value Store를 예로 들어보자.

     

    이런 api의 코드가 완성될 것이다.

    class Store
        ##
        # associate key with value
        # 
        def set(key, value)
            ...
        end
    
        ##
        # get value associated with key, or return nil if there is no such key
        #
        def get(key)
            ...
        end
    end

     

    사용자의 핸드폰 번호를 캐싱할 일이 생겼다. 그렇다면 코드는 이렇게 될 것이다.

    store = Store.new()
    store.set('Bob', '801-555-5555')
    store.get('Bob') # returns '801-555-5555', which is Bob’s number
    store.get('Alice') # returns nil, since it does not have Alice

     

    하지만, 핸드폰이 없는 유저가 있을 수도 있지 않겠는가. 이 또한 사용자의 핸드폰 번호가 없다는 것을 나타내기 위해 캐싱한다.

    store = Store.new()
    store.set('Ted', nil) # Ted has no phone number
    store.get('Ted') # returns nil, since Ted does not have a phone number

    자. 여기서 문제가 생긴다.

    • Alice란 사용자가 캐싱되어 있지 않다.
    • Ted는 캐싱되어있지만, 핸드폰 번호를 가지고 있지 않다.

     

    둘 다 null을 반환하지만 의미적으로 분간하기 어렵다.

     

    contains() 메서드를 활용하면 도움이 되겠지만, 중복 찾기(contains() 후에 get()을 할테니), 성능 저하, race condition을 일으킨다.

     

    몇 가지 목차가 더 있지만, 이정도만 정리해도 충분히 null 참조가 어떤 문제를 일으키는지 이해가 간다. 

    (글이 너무 길어지고 있어서 이쯤에서 마무리)

     

    더 궁금하다면 링크를 걸어둔 포스팅을 참고하면 되겠다.


    3. 정리

    실 개발 생활에서 null을 하나도 사용하지 않고 서비스를 개발하기 어렵다.

     

    다만, 여러 언어들이 null 처리를 깔끔하게 하기 위해 노력한다.

     

    nullable 타입 제공을 위해 ?.(엘비스 연산자)를 사용한다든지, Optional처럼 랩핑한 객체를 제공한다든지, 언어에서 null 처리를 한 함수를 랩핑하여 api(kotlin에서 isNullOrEmpty())로 제공한다든지 말이다.

     

    이러한 api, 타입을 사용해 null을 핸들링한다.

     

    회사에서 개발을 하면서 직접적으로 느꼈던 나의 불편함들은 이렇다.

     

    1. null 처리가 전파된다.
      • 구체적인 예로, 클래스의 필드가 nullable한 타입이라면 해당 클래스의 필드를 사용하는 곳에서 모두 null 처리를 해주어야한다.
    2. slow fail
      • 데이터가 null일 경우, 거의 대부분 즉시 로직의 흐름을 중단시켜야 하는게 다반사다. 하지만, null인 객체를 직접 참조해서 무언가를 하기 전까지 에러가 나지 않는다. (나(Null)를 써줄 때까지 언제까지고 흘러갈지 모름)
    3. 개발자의 불안함으로 만든 nullable한 타입을 통해 null이 들어올 수 있다는 가능성을 열어두는 것
      • non-nullable 해야할 필드나 메서드임에도 불구하고 혹시나 null이 들어 올 수 있지않을까? 하는 불안감에 nullable하게 만드는 것이다. 그 불안감이 서비스의 불안정함을 키운다. non-nullable해야하는 데이터를 위의 1번처럼 코드상으로 null 처리를 다 해주어야한다. (그런 불안감을 갖는다는 게 설계를 잘못한거겠지만..)

    첨언

    이걸 정리하면서 알게 된 게 우리가 흔히 아는 QuickSort(퀵소트)도 Tony Hoare가 만든 것이라 한다. 오히려 null 창시자보다 퀵소트 창시자로 더 유명하단다. 대체 그는...

     

     

    참고

    https://en.wikipedia.org/wiki/Null_pointer

    https://en.wikipedia.org/wiki/Tony_Hoare

    https://www.lucidchart.com/techblog/2015/08/31/the-worst-mistake-of-computer-science

    https://www.mimul.com/blog/why-null-is-bad

    https://www.yegor256.com/2014/05/13/why-null-is-bad.html

     

Designed by Tistory.