thenullpage.com
메서드가 값을 못 찾으면 null을 돌려준다. 받는 쪽은 그게 null일 수 있다는 걸 모른 채 점을 찍는다. 그 순간 NullPointerException이 나는데, 스택 트레이스에는 어느 줄에서 터졌는지만 적혀 있지 누가 null을 흘려보냈는지는 안 적혀 있다. 값이 없을 수도 있다는 사실을 타입에 적어 두자고 나온 것이 Optional이다.
null은 아무 말도 해 주지 않는다. 반환 타입이 String이면 항상 문자열이 온다고 읽힌다.
static String findName(int id) {
if (id == 1) return "김개발";
return null;
}
String name = findName(2);
System.out.println(name.length());
// 출력:
// Exception in thread "main" java.lang.NullPointerException:
// Cannot invoke "String.length()" because "name" is null
시그니처만 봐서는 null이 나올 수 있는지 알 길이 없다. 반환 타입을 Optional<String>으로 바꾸면 없을 수도 있다는 사실이 시그니처에 적힌다.
Optional은 값 한 개짜리 상자다. 값이 든 상자와 빈 상자, 두 상태뿐이다.
Optional<String> a = Optional.of("김개발");
Optional<String> b = Optional.empty();
Optional<String> c = Optional.ofNullable(findName(2));
System.out.println(a); // Optional[김개발]
System.out.println(b); // Optional.empty
System.out.println(c); // Optional.empty
// Optional.of(findName(2));
// Exception in thread "main" java.lang.NullPointerException
of는 null을 넣는 순간 터진다. null일지 모르는 값을 감쌀 때는 ofNullable을 쓴다. 이제 findName을 이렇게 고쳐 놓고 이어 간다.
static Optional<String> findName(int id) {
if (id == 1) return Optional.of("김개발");
return Optional.empty();
}
꺼낼 때 없을 경우를 같이 적는다. orElse는 상자가 비어 있으면 대신 쓸 값을 내놓는다.
System.out.println(findName(1).orElse("이름없음")); // 김개발
System.out.println(findName(2).orElse("이름없음")); // 이름없음
System.out.println(findName(2).get());
// Exception in thread "main" java.util.NoSuchElementException:
// No value present
get은 비어 있으면 예외를 던진다. isPresent로 검사하고 get으로 꺼내는 조합도 되긴 하지만, 그건 if (name != null)을 이름만 바꿔 다시 쓴 것이라 얻는 게 없다.
orElse는 항상 계산되고 orElseGet은 필요할 때만 계산된다. 이 차이에서 사고가 가장 많이 난다.
static String backup() {
System.out.println("백업 조회 실행");
return "이름없음";
}
System.out.println(findName(1).orElse(backup()));
// 출력:
// 백업 조회 실행
// 김개발
System.out.println(findName(1).orElseGet(() -> backup()));
// 출력:
// 김개발
orElse의 괄호 안은 평범한 메서드 호출이라 상자에 값이 들었든 말든 먼저 실행된다. 값이 있으니 결과는 버려지는데 조회는 이미 나간 뒤다. orElseGet에 넘긴 () -> backup()은 필요해지면 실행할 코드를 넘긴다는 표기라 상자가 비었을 때만 불린다. 기본값이 상수면 orElse, DB 조회나 계산이 끼면 orElseGet이다.
없을 때 예외를 던지게 할 수도 있다. 값이 반드시 있어야 하는 자리에 쓴다.
String name = findName(2).orElseThrow(
() -> new IllegalArgumentException("2번 회원이 없다"));
// Exception in thread "main" java.lang.IllegalArgumentException:
// 2번 회원이 없다
findName(1).ifPresent(n -> System.out.println(n + " 찾음"));
// 출력: 김개발 찾음
findName(2).ifPresent(n -> System.out.println(n + " 찾음"));
// 아무것도 출력되지 않는다
ifPresent는 값이 있을 때만 안에 적은 일을 한다. 비어 있으면 조용히 넘어가니 null 검사 if가 통째로 사라진다.
값이 있을 때만 이어서 가공한다. map은 상자를 열지 않고 안의 값만 바꿔 다시 담는다.
System.out.println(findName(1).map(String::length)); // Optional[3]
System.out.println(findName(2).map(String::length)); // Optional.empty
System.out.println(findName(1).map(String::length).orElse(0)); // 3
System.out.println(findName(2).map(String::length).orElse(0)); // 0
비어 있으면 map은 아무 일도 하지 않고 빈 상자를 그대로 흘려보내니 중간에 null 검사를 끼워 넣을 자리가 없다. String::length는 length 메서드를 가리키는 표기다. 조건을 걸려면 filter를 붙인다.
System.out.println(findName(1).filter(n -> n.startsWith("김")).orElse("없음")); // 김개발
System.out.println(findName(1).filter(n -> n.startsWith("박")).orElse("없음")); // 없음
조건에 걸리지 않으면 값이 있어도 빈 상자가 된다.
쓸 자리는 하나뿐이다. 아래 셋은 흔한 오용이다.
class User {
private Optional<String> nickname; // 필드에 두지 않는다
}
void save(Optional<String> name) { } // 매개변수로 받지 않는다
Optional<List<String>> findAll() { } // 목록을 감싸지 않는다
Optional은 직렬화 대상이 아니라 필드에 두면 저장이나 전송에서 걸린다. 매개변수로 두면 부르는 쪽마다 Optional.of로 감싸는 수고를 떠안는다. 목록은 빈 목록이 이미 없다는 뜻이니 List.of()를 돌려주면 그만이다. Optional이 있을 자리는 값이 없을 수 있는 메서드의 반환 타입, 그 하나다.
정리하면 셋이다. 없을 수 있는 값은 반환 타입을 Optional로 적어 부르는 쪽이 못 본 척하지 못하게 한다는 것, 기본값에 계산이 끼면 orElse가 아니라 orElseGet을 써야 한다는 것, map과 filter로 이어 붙이면 null 검사 if가 사라진다는 것이다. backup 메서드를 직접 만들어 orElse와 orElseGet에 각각 넣어 보면 어느 쪽이 헛일을 하는지 출력에 그대로 찍힌다.