JIGGAG

null을 반환하지 말자

2021년 10월 12일

객체가 좀 더 안정적으로 처리되기 위해서


null을 반환하지 말자

지난번에 null을 인자로 허용하지 말자를 이야기했다. 그리고 드디어 이번에 null을 반환하지 말자를 이야기하려고 한다.

null을 다루는 것은 항상 이슈의 중심에 있는 것 같다.

const getTitle = (): string | null => {
	if (만약_타이틀이_없다면) {
		return null;
	}
	return "타이틀";
};

const titleLength: number = getTitle()?.length || 0;  // 항상 신뢰할 수 없기 때문에 default value 설정
console.log(titleLength);

null을 반환하기 때문에 getTitle 함수의 리턴타입은 string | null을 갖게 되었다. 반환되는 값을 신뢰할 수 없기 때문에 titleLength이 항상 number 타입을 갖게 하기 위해서 getTitle().length -> getTitle()?.length처럼 옵셔널 처리를 해주고 디폴트를 설정해주어야만 한다.

만약 옵셔널 처리를 하지 않은채 getTitle().length를 했다면 바로 NullPointerException이 발생하게 된다. (ts에서는 TypeError: Cannot read properties of null (reading 'length') 에러가 발생한다)

예외를 던지는 것이 문제가 아니라 반환되는 값이 신뢰할 수 있는 값인지 여부를 확인해야만 하는 작업이 필요하게 되었다.

const getTitle = (): string | null => {
	if (만약_타이틀이_없다면) {
		return null;
	}
	return "타이틀";
};

const title = getTitle();
if (title === null) {
	console.log("타이틀이 null이라서 반환할 수 없다");
}
const titleLength: number = getTitle().length;
console.log(titleLength);

객체 자체가 스스로 판단할 수 없으니깐 우리가 판단해주는 title === null이 조건이 추가되었다.

객체가 자신만의 생명주기(new로부터 시작되어 어디서도 사용하게 되지 않는 순간까지), 자신만의 상태, 행동을 갖는 능동적이 유기체이다. 그러나 객체가 반환하는 값이 신뢰할 수 없게 되어 스스로 자신의 행동에 대한 결과를 책임질 수 없게 된다면 위의 title === null 조건처럼 외부의 판단에 기대어 동작하게 되는 것이다. (객체가 능동적인 것이 아니라 수동적이 되어버렸다)

어떤 것이 신뢰할 수 있는 값인지 확인하기 전까지 마음 편히 사용할 수 없기 때문에 유지보수에 큰 악영향을 주게 된다. 인자를 null로 받아서 처리할때와 동일하게 반환되는 값이 null인지 확인하는 로직이 사용처마다 추가되어야하기 때문이다.


왜 null을 반환하게 되었을까?

인자로 null을 받게된 이유도 전달하려는 값이 없는데 전달하려고 해서였다.

null을 반환하게된 이유 또한 반환하려는 값이 없는데 반환하려고 했기 때문이다.

예를 들면, API 문서에 따르면 이번에 추가된 getCategoryList 엔드포인트의 포맷은 배열이였다.

interface CategoryList {
	success: boolean;
	message: string;
	data: Category[]; // 이런 느낌
}

하지만 실제로 CategoryList가 존재하지 않는 조건으로 호출하였을때 data: null이 응답이 와버렸다!! 🙈 그리고 앱은 죽어버렸다.

이런일이 왜 발생했을까?

반환하려는 값이 없는데 반환하려고 했기 때문에 null을 보내버린 것이다. (개인적으로는 찾지 못했다면 빈 배열이 오는건 어떨까 항상 소원한다...)

반환하려는 값이 없다면 그냥 실패하고 에러를 던지면 되는 것이다.


빠르게 실패하기 vs 안전하게 실패하기

개인적으로 사용하는 입장에서 안전하게 실패하기를 좋아한다. 왜냐하면 갑자기 에러가 나버리면 너무 당황스럽다...

하지만 그것이 null을 전달 받는 것을 좋아한다는 뜻이 아니다. null이 내려올때면 너무 당황스러웠다!

안전하게 실패하기에서는 항상 동작하도록 어떤 상황에서도 유지되기를 희망하기 때문이라고 생각한다. Exception을 던지기 보다는 null이라도 반환해서 이 상황을 넘어가기를 원하는 것이다. (null을 사용하는 곳이 없기를 기도하며... 행복회로 같은데!)

빠르게 실패하기는 정반대로 문제가 되는 상황이 발생하는 즉시 바로 에러를 던져버리는 것이다. 실패하는 원인을 빠르게 찾아서 수정할 수 있도록 도와주는 것이다. (잘못된 사용법이거나 미처 처리하지 못한 부분처럼 바로 찾을 수 있도록)

두가지 방법에 대해서는 너무나 선택하기 어려운 것 같다. 운영하는 입장에서 빠르게 실패하기 보다는 어떻게서든 동작하는 것이 좋을텐데, 어쨌든 숨어있는 버그를 빨리 찾아서 고치려면 빠르게 실패해야만 하고...🤔


그렇다면 null을 대체할 수 있는 방법은?

반환하려는 값이 없는데 반환하려고 했기 때문에 null을 반환하게 되었다.

위에서도 잠깐 나왔지만 getCategoryList에서 반환하려는 Category가 업는 경우 data=null 대신에 data=[]를 반환하는 것이다. 사용하는 입장에서는 data: Category[]라는 인터페이스대로 사용할 수 있기 때문에 null 체크를 하지 않아도 되고 동일한 콜렉션으로 이어지기 때문에 깔끔해보인다 👍

또 다른 방법으로 그동안 모든것을 객체로 만들어서 사용해왔던 것처럼 이번에도 null 대신 null 객체를 만들어서 반환하는 건 어떨까?

인터페이스는 실제 반환하려던 것과 동일하지만 데이터는 없는 것이다.

interface User {
	name: string;
	age?: number;
}

만약 name="없는_이름"으로 찾으려고 했으나 반환되는 값이 없는 경우 User(name="없는_이름")의 형태로 반환되는 것이다. 그러나 결국 age를 사용하려고 하면 문제가 발생하게 된다...

이 방법도 결국 제한적인 사용이나 조건이 필요하게 되므로 나홀로 희망하였던 비어있는 컬렉션을 반환하는 방법이 좋지 않을까!?

반환하려는 값이 없는 경우 null 대신 예외를 던지거나, 비어있는 컬렉션이나 널 객체를 반환하도록 하자


체크 예외를 던지자

체크 예외 (checked exception)언체크 예외 (unchecked exception)를 구분하고 있는데, 자바에 한정된 이야기인 것 같다.

RuntimeException을 상속 받는 경우 언체크 예외라고 하며 다른건 모두 체크 예외로 구분된다.

런타임 예외라고 하니깐 느낌이 오는데 이건 서비스가 실행 중에 에러가 발생한 것으로 실수이다. (발생해서는 안되는거였는데 어쩌다보니 발생해버렸네!?)

그렇다면 의도적으로 예외를 다뤄야하는 것은 체크 예외에 속한다. (무조건 try-catch로 감싸거나 상위로 던지거나)

fun exception() {
	throw IOException("런타임 에러")
}

fun sub1() {
	try {
		exception()
	} catch (ex: IOException) {
		// 1. 예외 처리를 해주거나
	}
}

fun sub2() {
		exception() // 예외를 그대로 전달해서
}
fun main() {
	try {
		sub2()
	} catch (ex: IOException) {
		// 2. 상위 레벨에서 예외 처리를 해주는 형태
	}
}

exception함수를 실행하면 IOexception라는 체크 예외가 전파되는데 이를 호출하는 sub*에서 에러처리를 해주거나 그대로 상위 레벨 main으로 전달할 수 있다. 문제가 일어날 수 있음을 알려주고 사용하는 쪽에서 문제에 대한 처리를 할 수 있도록 하는 것이다.

체크 예외는 문제가 일어날 수 있음에도 자기 자신이 문제를 해결하지 않고 그대로 넘겨버리는 형태로 사용처에서 반드시 처리를 해줘야만 하는 예외이다. 그렇기 때문에 항상 예외가 발생할 수 있음이 명백하게 나타나있어 안전하게 다룰 수 있는 것이다.

반면 언체크 예외는 언제 어디서 나타날지 모르기 때문에 무시할 수 있으며 누군가 예외 처리를 해주는 곳을 만나기 전까지 계속 버블버블 상위로 전파된다.


언체크 예외를 언제 처리해야할까?

만약 모든 함수마다 예외처리를 해두었다면 진짜 에러가 어디서 생긴건지 찾기 어려울 수 있다.

예를 들면, 메일 앱에서 로그인하고 메일 목록을 가져오는 list API를 호출했다. 액세스 토큰을 가지고 유효한 사용자인지 먼저 확인하였다. 그리고 반환된 아이디로 리스트를 조회해서 반환하는 순서이다. 그러나 액세스 토큰이 유효하지 않다고 Exception을 발생시켰다(극단적). 근데 이를 사용하고 있던 곳에서 에러 처리를 해서 토큰이 없으니 아이디는 디폴트값으로 설정해버리면서 리스트 조회는 실패할 것이다. API 응답을 받은 사용자는 리스트 조회가 왜 실패했을까 원인을 파악해야하는데 방금 받은 에러 메세지는 리스트 조회 실패인 것이다.

처음 에러가 발생한 액세스 토큰이 유효하지 않은 경우의 에러가 전달되었다면 유지보수에 수월하지 않았을까? 오히려 어떻게든 진행할 수 있도록 디폴트 아이디로 설정한 예외 처리가 오히려 문제를 깊숙하게 숨겨버렸다.

흐름 제어를 위한 예외 사용이라고 부르는 이러한 상황은 예외가 발생했을 뿐이지 단순 로직을 분기처리 한 것과 동일한 것이다.

언체크 예외는 아무곳에서도 처리하지 않으면 최상위로 전파될 것이기 때문에 최종적으로 딱 한 곳, 진입점에서만 예외 처리를 해두면 되는 형태이다.


체크 예외는 언제 처리할까?

언체크 예외와는 다르게 어디서 발생할 수 있는지 명확하게 나타내어져 있는 체크 예외예외를 잡아서 다시 던져주어야한다.

fun main() {
	try {
		...
	} catch (ex: Exception) {
		throw new Exception("새로운 예외를 던진다", ex)
	}
}

catch문에서 예외 처리를 한번 했지만 상위로 전달하기 위해 다시 기존 예외 정보와 함께 새로운 예외를 만들어서 던지는 처리가 필요하다. 예외 처리가 되어야만 하는 체크 예외에서는 새로운 예외를 만들어서 상위로 던지므로써 원래의 문제 원인을 상위로 전달하는데 성공하였다. (에러가 어디서 생긴건지 찾지 못하는 불상사를 해결할 수 있는 방법이다)

위의 액세스 토큰 문제에서 최종적으로 전달 받은 에러 내용은 리스트 조회 실패 - 액세스 토큰이 유효하지 않음 이런 형태가 될 수 있는 것이다.


예외가 최상위로 올라왔다면

결국 딱 한번 예외에 대한 마무리를 해주면 되는 것이다. (예외 처리를 해주지 않는다면 결국 예외는 예외니깐 앱이 죽게 되겠지...)

진입점을 최상위로 잡고 전달 받은 예외를 일괄적으로 처리하게 된다면 사용자의 호출이 실패했을 경우 초기화 후 다시 시도 해주면 된다. 이것은 예외를 상위로 전달하지 않고 모든 순간마다 처리를 해주는 작업을 하는 것보다 효율적으로 동작할 수 있다.


관점지향?

관점에 따라 분리해서 생각하는 것인데 예외 처리라는 관점으로 나눠서 생각할 수 있다.

API 응답이 안온다고 올때까지 계속 호출하고 있기에는 너무 무모하다. 재시도도 무한정 할 수 없는 일이다. (네트워크 오류 상태로 계속 호출 할 수 있으니.. ReactQuery에도 retryCount가 있다)

이런 예외 처리 코드를 모든 진입점에서 에러를 처리할 때 마다 작성하기에는 중복된 코드가 많아진다.

이를 관점 지향에서는 어댑터(라고 하는데 어노테이션 아닌가?)로 코드를 감싸서 실행하는 것이다.

마치 훅으로 분리해서 필요한 시점마다 가져와서 쓸 수 있도록 관점, 기능을 따로 분리해서 사용하는 형태인 것이다.


궁극의 예외 타입

결국 최상위로 모든 예외를 새로 만들어서 전달하는 형태라면 모든 예외 타입을 상속 받는 궁극의 예외 타입 하나만 있으면 되는 것 아닐까?


+ 리뷰

위에서 Unchecked Exception를 런타임 오류르 예로 들었고 이것이 발생하는 이유를 실수라고 표현하였다.

하지만 이 글을 리뷰하던 중 이게 과연 실수에서만 발생하는걸까?라는 결론에 도달하였다. 언체크 예외를 의도적으로 발생시킨 경우가 있을 수 있다.

예를 들면, 데이터를 조회하려는데 해당 조건에 만족하는 데이터가 없었다. (=null이 반환되는 순간이다) 이런 경우 빠르게 실패하기를 통해 null 대신 에러를 반환하였고 이것이 언체크 예외였던 것이다.

체크 예외가 아니기 때문에 사용처에서는 당연히 에러처리를 하지 않은 상태이다. 자연스럽게 흘러가다가 어느 순간 런타임 에러가 발생하는 상황이 오게 된다.

이것은 과연 실수일까?

분명 의도대로 에러를 반환한 것이고 안전하게 숨겨서 처리하지 않고 빠르게 실패했을 뿐인데...