좋은 함수는 Null을 지양한다

camel·2023년 7월 4일
0

아침마다 개발 사이트를 서칭하는데 좋은 글이 있어서 포스팅하게 되었다.
내용이 무겁지만 안에 담은 내용들은 프론트엔드 개발자로서 중요하다 느꼈고,

몇번 읽어도 정리가 잘되지 않아서 포스팅을 하면서 정리하고자 하였다.

그리고 방향성이 다양해서 원본의 글과 다르게 조금 각색하였다.




Null의 중요성

정적 분석 서비스 rollbar 에서 1000개 이상의 JS 프로젝트에서의 소프트웨어 결함 통계를 공개했다.
(여기서는 null 과 undefined 를 구분하지 않고 null 로 통일해서 표현한다.)

상위 1~10위까지의 대부분이 null과 undefined 로 인한 문제였다.
이 외에 (과거 자료지만) 안드로이드 플레이 스토어의 Top 1,000 Popular Apps 들을 분석한 결과에서도 NullPointerException 가 전체 결함 중 2번째였다.

이를 대체하기 위해 자주 쓰는 방법은 Null Safe 문법이다.

Null Safe 문법

TypeScriptt나 Kotlin 등 요즘의 모던한 문법을 지원하는 언어들을 사용하다보면 Null 값들을 안전하게 다루는 여러가지 방법들을 알게 된다.
다만, 이런 것들은 대부분 지엽적인 경우가 많다.
(본질을 해결하지 못한다는 의미)

이미 Null값이 프로젝트 전방위적으로 퍼져있는 상태에서 어떻게 Null 에러를 피할 것인가같은 경우이다.

예를 들어 대표적인 null 을 다루는 방법으로 소개하는 것이 바로 Optional chaining (?.) 이다.

let user = {
  name: 'Alice',
  address: null
};

console.log(user?.address?.street); // 출력: undefined

이외에도 Nullish coalescing (??) 을 활용할 수도 있다.

TS가 아닌 다른 언어에서는 엘비스 연산자 (?:) 로 불리기도 한다.

let input = null;
let value = input ?? "default";

console.log(value); // 출력: "default"

이런 문법적 기능을 이용하면 인한 Null Exception(예외 처리) 을 피할 수 있다.
하지만 이런 방법은 Null 을 다루는 방법이 아니라 Null 을 피하는 방법이다.

Null Safe 문법만이 Null을 다루는 방법이 되면 다음과 같이 모든 영역을 Null Safe 문법으로 도배해야만 한다.

모든 객체의 하위 탐색이 있을때마다 ?. 을 붙일 것인가?와 같은 일이 생긴다는 것이다.

즉, Null Safe 문법은 어디까지나 보조적인 도구이지, 애초에 Null을 어디까지 허용할 것인가 등의 고민이 먼저 필요한 것이다.


null을 안전하게 다루는 패턴

이번 시간에 이야기해볼 것은 null 을 다루는 방법이다.
(피하는 것이 아니라 다루는 방법이다)

문법적 도움 없는 언어나 생태계에서도 통용되며,
근본적으로 저런 Null 관련 기능들의 사용을 최소화할 수 있는 패턴 혹은 구조를 이야기한다.

1. 사전 조건 검증

처음 봤던 이미지를 다시 보자.
Null 값이 프로그램 전체에 퍼져있으면 그만큼의 Null Safe 코드가 프로그램 전체에 퍼져있어야만한다.

이렇게 Null 객체가 프로그램 전체에 퍼지지 않도록 하려면 어떻게 해야할까?

가장 쉽고 흔한 방법은 입구에서 막는 것이다.

예를 들어 다음과 같이 user 객체의 상태에 대해 조건 검증이 필요하다면 그건 가능하면 가장 진입점에서 검증을 해야만 한다.

function myFun(user : User) {
  if(user.name !== null) {
    throw new Error('Name must be Non Null');
  }
  
  if(user.age < 19) {
    throw new Error('age must be lower than 19 years old');
  }
  ....
  businessLogic // 이 로직은 안전해진다.
}

이렇게 되면 주요 로직들은 Null에 대한 걱정 없이 수행할 수 있다.


다만, 이런 사전 조건 체크해야할 대상이 많다면 (객체의 필드가 많을 경우), 수많은 if문으로 주요 로직을 확인하기가 어렵다.

-> 라이브러리를 통해서 if문 간소화하는 것이 좋다
(라이브러리 관련 내용 생략)

사전 조건 검증은 결국 Null이 침범하지 않는 영역을 확실하게 확보하는 것을 의미한다.

함수 내부에서 인자로 받은 값에 대한 검증, API나 상태 도구에서 가져온 값등을 시작 단계에서 바로 검증하여, 주요 비즈니스 로직까지 Null이 침범하지 않도록 하는 것이다.


2. Null 반환을 지양한다

null 의 범위를 메소드/함수 지역으로 제한한다.

Null도 지역적으로 제한할 경우 큰 문제가 안된다.
메서드/함수의 인자로 전달되는 경우, 메서드/함수 내부에서만 null을 사용하고 가능한 외부로 전달되지 않도록 한다.

  • 반환 값이 꼭 있어야 한다면 null을 반환하지 말고 예외를 던진다. (throw Error)
  • Null 대신 유효한 값이 반환 되어 이후 실행 되는 로직에서 Null로 인한 피해가 가지 않도록 한다.
    Null Object Pattern

Null 값을 만날 경우 그게 정상적인 흐름이 아닌 경우 예외를 던지는 것은 주로 사용하는 방법이다.

다만, 예외로 인해 Flow가 종료되는 것이 아니라 다음 흐름으로 정상적으로 넘어가길 원하는 경우엔 예외를 던질 수가 없다.

이럴 경우 Null Object를 사용하면 좋다.

Null Object Pattern은 Null 처리를 단순화하는 방법이다.

Null이 아닌 디폴트 객체를 반환하여 Null인 경우에도 프로그램이 계속 동작하도록 한다.
보통 ORM 등을 사용하다보면 조회결과가 없을 때 빈 배열을 반환하는 경우가 이에 속한다.

예를 들면

위 코드와 같이 Null을 반환하는 함수 (getClassNames) 에 의존하면, 항상 Null 체크가 필요하다.

반면, Null 이 아닌 Null Object ([]) 을 반환하면 의존하는 함수들은 Null체크를 하지 않아도 된다

boolean이 반환될때 역시 null은 배제하고 false 를 반환하는 것이 좋다.
boolean은 엄밀히 말해서 2가지 타입을 가진 열거형이다.
이걸 null을 넣어서 3가지 타입으로 늘리는 것은 좋지 않다.
원래 의도대로 2가지의 타입으로 표현해야하며, Null과 false가 구분이 필요하다면 이건 3개의 경우를 표현해야하는 열거형이 필요한 경우이다.

// bad
null, false, true

// good
READY, PASS, FAIL

문자열은 상황에 따라 다르다.
다음과 같이 단순히 문자들의 집합으로서만 문자열이 필요하다면 빈 문자열을 Null Object로 사용하면 된다.

export class UserComment {
  private _comment: string | null = null;
  
  get comment(): string {
    return this._comment ?? '';
  }
}
  • 코멘트는 노출용 문자열이기 때문에 빈 문자열이 Null을 대체해도 사이드 이펙트가 없으며, 호출자에서도 그대로 노출하면 된다.

단, 특정 의미를 지니는 경우엔 Null을 반환하는 것이 낫다.

export class Payment {
  private _cardNo: string | null = null;
  
  get cardNo(): string | null {
    return this._cardNo;
  }
}

위 코드에서는 문자열이 Null 인 것은 카드 거래가 없음을 나타낸다.
이럴 경우 Null은 그 의미를 지니고 있기 때문에 Null Object로 대체하기가 곤란할 수 있다.


Null Object (React)

객체의 경우에도 충분히 사용할 수 있다.
예를 들어 다음과 같이 User 객체의 Null 유무에 따라 노출값이 달라질 경우 Null Safe 문법 (Nullish coalescing) 을 통해 쉽게 해결할 순 있다.

// bad
const UserInfo = ({ user }) => {
  return (
          <div>
            <p>Name: {user?.name ?? 'Not Available'}</p>
            <p>Email: {user?.email ?? 'Not Available'}</p>
          </div>
  );
};

const ParentComponent = ({ user }) => {
  return <UserInfo user={user} />;
};

다만, 다음과 같은 경우엔 이는 좋은 해결책이 아니다.

  • User 객체가 Null 인것만 체크하면 되는데, 모든 필드마다 Null Safe 문법이 필요한가?
    • User 객체의 필드가 Null 인지에 대한 고려는 하단에서 개선책을 나눈다.
  • User 객체가 사용되는 곳이 UserInfo 외에도 많다면 어떻게 될까?
  • User 객체의 필드가 2개가 아니라 10개, 20개라면 어떻게 될까?

결국 한번 퍼진 Null 은 UserInfo 외에도 수많은 함수, 컴포넌트에서 Null Safe 문법을 사용해야만 한다.

이럴 경우 Null Object를 활용하기 좋다.

// good
const UserInfo = ({ user }) => {
  return (
    <div>
      <p>Name: {user.name}</p>
      <p>Email: {user.email}</p>
    </div>
  );
};

const getUser = ({ user }) => {
  // user가 null인 경우 대신 사용할 null 객체
  return user ?? {
    name: 'Not Available',
    email: 'Not Available',
  };
}

const ParentComponent = ({ user }) => {
  return <UserInfo user={ getUser(user) } />;
};

이렇게 될 경우 User 객체를 사용하는 곳에서는 별도의 Null체크가 필요하지 않게 된다.

만약 User의 필드들에 대해서도 Null 체크가 필요하다면, 기본값을 가진 객체를 반환하는 함수나 클래스를 활용하면 좋다.

class User {
  name = 'Not Available',
  email = 'Not Available',

  constructor(name: string, email: string) {
      this.name = name;
      this.email = email;
  }
}

초기값을 설정하면 필드값의 접근이 필요할때마다 Null 체크를 할 필요가 없어지며, 항상 유효한 상태를 보장하고, 가독성이 향상된다.

값을 다뤄야할때는 항상 초기값을 명시적으로 구분해서 실행할때마다 값의 체크가 필요하는 경우를 최대한 배제한다.

주의할 점
Null Object Pattern은 Fail-fast를 못하게 한다.

오류가 있는 상황이라 더이상 Flow를 진행하면 안되는 경우라면 바로 예외처리를 발생시키는 것이 옳으며, 괜히 null 객체로 인해 실제 오류가 발생한 지점에서 멀리 떨어진 함수에서 오류가 발생해선 안된다.


3. 함수 파라미터, 함수 전달인자에서 null 최대한 피하기

함수나 객체의 인자로 null 을 전달하는 것은 피한다.
함수 파라미터 (매개변수)는 nullable을 최대한 피한다.

호출하는 쪽에서 인자로 null을 넣어도 된다고 느끼게 하면 안된다.
null로 지나치게 유연한 메서드를 만들지 말고 최대한 명시적인 메서드/함수를 만들어야 한다.

다만, 이렇게 되면 외부의 API, 사용자의 입력, 데이터베이스의 조회 등으로 null 을 함수 인자로 전달할 수 밖에 없는 상황에서의 방법이 중요하다.

예를 들어 아래 코드에서 printLength 은 null에 대한 방어가 되어있다.
그래서 이를 호출하는 함수들은 무분별하게 null을 인자로 전달한다.

// bad
function mainFunction() {
  let value: string | null = getNullableValue();
  printLength(value); // null 체크 없이 바로 전달한다.
  ...이후 비즈니스 로직
}

function printLength(input: string | null) {
  if (input !== null) {
    console.log(input.length);
  }
}

이 방식은 여러 편의성을 제공한다.
함수 printLength 를 호출하는 누구나 null 을 신경쓰지 않게 되었기 때문이다.

다만, 문제는 함수 파라미터가 null을 허용할수록 null의 범위가 커진다는 것이다.
모든 공통 함수가 null을 허용한다는 것은, 그 함수를 호출하는 함수들은 null을 무분별하게 쓰라고 권장하는 것과 비슷하다.

  • null은 개발자가 주의를 기울여야하며 최대한 지양해야할 값의 유형이지, 무분별하게 사용해도 될 것이 아니다.

좀 더 실제에 가까운 예제를 보자.
다음은 문자열 <-> LocalDateTime 을 치환하는 유틸리티 함수의 모음이다.

export class DateTime {
  private static DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern(
    'yyyy-MM-dd HH:mm:ss',
  );

  static toString(dateTime: LocalDateTime | null): string {
    if (!dateTime) {
      return '';
    }

    return dateTime.format(this.DATE_TIME_FORMATTER);
  }
  ....
}

이 함수는 null을 허용한다.
null이 들어오면 안전하게 빈 문자열을 반환한다.
그리고 이제 이 함수를 호출하는 모든 함수들은 무분별하게 null을 사용하게 된다.

function mainFunction() {
  const value: LocalDateTime | null = getNullableTime();
  const dateStr = DateTime.toString(value);
  ...
}

위 mainFunction은 value 가 null 임에도 함수 곳곳에서 이를 활용하게 된다.
이 외에도 값이 null 일 경우 어떤 데이터/액션이 필요한지를 호출자가 결정할 수가 없게 된다.
문자열로 치환이 필요한 LocalDateTime 값이 null 일 경우

  • 빈 문자열
  • null
  • throw Exception

등등 어떤 결과가 필요할지를 유틸 함수인 DateTime이 결정 (빈 문자열 반환) 한다.
호출을 하는 주체 함수에서 결정할 수 없다.

오히려 다음과 같이 null을 허용하지 않고, 호출하는 쪽의 자유도에 맡기는 것이 더 열린 결말이다.

export class DateTime {
  static toString(dateTime: LocalDateTime): string {
    return dateTime.format(this.DATE_TIME_FORMATTER);
  }
}

function main() {
  const orderedAt = getOrderedAt();
  const dateStr = orderedAt ? DateTime.to(orderedAt) : ''; // 혹은 원하는 무엇이든
  subFunction(dateStr);
}

그래서 최대한 함수의 파라미터와 인자에는 null을 허용하지 않도록 구현한다.

그리고 null이 아닌 경우에만 인자로 전달하는 방법을 고려할때는 가능하면 1. 사전 조건 검증 에서 언급한 Early Exit을 사용하는 것을 추천한다.
(예시는 아래 3번째 코드를 말한다)

아래 예제코드를 기반으로 이야기하면,

function mainFunction() {
  let value: string | null = getNullableValue();
  if(value !== null) {
    nonnullFunction(value); 
  }
  ...이후 비즈니스 로직
}

이유는 다음과 같다.

nonnullFunction 에서 null 체크를 하더라도, 그 아래 코드들에서 value = null 로 인해 문제가 발생할 수 있다.
이를 위해 nonnullFunction 를 호출하는 코드 외에도 아래 로직들에서 null check 혹은 null safe 문법을 사용해야만 한다.

function mainFunction() {
  let value: string | null = getNullableValue();
  if(value !== null) { 
    nonnullFunction(value); 
  }
  ...이후 비즈니스 로직 // <-- 여기서도 똑같이 null safe 가 필요할 수 있다.
}

결국 null 값의 범위를 어디까지 줄일 수 있을 것인가의 문제가 된다.

function mainFunction() {
  let value: string | null = getNullableValue();
  
  // Early Exit
  if (value === null) {
    return ;
  }
  
  /** 아래부터는 Null Safe Zone **/
  nullableFunction(value); 
  ... 비즈니스 로직
}

가능하면 함수 파라미터 (매개변수) 에서는 null 허용을 최대한 피한다.
그리고 그렇게 null을 받지 않는 함수들의 모음을 최대한 늘려, null의 범위를 최소한으로 한다.

물론 이상적인 구조를 이야기하는 것이며, 팀 내에서 공통 유틸 함수 등에서 Null을 어디까지 허용할 것인가에 대해서는 논의해서 결정하는 것이 가장 좋다.

정리

Null Safe 문법들은 Null 문제를 회피할 수 있다.
다만, 이는 문제를 피하는 것이지, 예방 혹은 해결이 아니다.
이런 회피 방법만 고려하면 무분별하게 Null 사용을 부추기게 된다.

null로 지나치게 유연한 메서드를 만들지 말고 명시적인 메서드/함수를 만들자.

Null 의 범위를 좁히는 방법

  • 사전 조건 체크 (Pre Condition)
  • Null 반환 금지
  • Null 함수 인자 전달 금지
  • 초기값과 실행값 구분



끝으로

글을 3번 읽고, 정리를 2번해야지만 머리 속에 내용들이 온전하게 들어왔다.
글 안에 많은 내용을 내포하고 있어서 한번에 들어오지 못하였고,
여러번 읽어야만 이해가 되는 글이다.
그래서 내 방식대로 방향을 조금 바꾸었다.
중간 중간에 강조하는 부분과 방향성을 좀 더 명확히 하도록 수정하였다.

이 글에서 깨달은 점은 null을 가볍게 생각하였고
TS를 사용하면서 null safe를 많이 사용했던 기억이 있다.
그것의 근본이 무엇인지 왜 이렇게 되는지에 고려해본 적이 없었고, 문제가 있다고 생각하지 않았는데, 앞으로는 그것을 사전에 막을 방법이 무엇인지 어디가 문제인지 고려해보겠다.

출처
https://jojoldu.tistory.com/721?utm_source=oneoneone

profile
화이팅~ 가보자구

4개의 댓글

comment-user-thumbnail
2023년 7월 5일

블로깅 내용 좋네용 ~~ 잘 보고 갑니동

답글 달기
comment-user-thumbnail
2023년 7월 8일

Null safe 문법들 많이 이용하는데 그걸 애초에 방지해야겠다는 생각을 했어야했었는뎅.. 반성하게 되네요 ㅜㅜ ㅎㅎ 잘 보고 갑니당 !

답글 달기
comment-user-thumbnail
2023년 7월 9일

헐 저도 이 글 봤어요 ㅋㅋ 근데 null 처리는 어려운 것 같아요 ,,,

답글 달기
comment-user-thumbnail
2023년 7월 9일

null safe 문법을 사용한다고 정말로 safe한건 아니였군요.. 내용이 어렵지만 마지막 정리까지 읽으니 조금은 이해가 되네요!! 잘 읽었습니다 👍

답글 달기