본문 바로가기
인프런/김영한의 실전 자바 - 고급 3편, 람다, 스트림, 함수형 프로그래밍

[인프런] 김영한의 실전 자바 - 고급 3편, 람다, 스트림, 함수형 프로그래밍 / 10. Optional

by hxxyeoniii 2025. 6. 25.

Optional 소개

NullPointerException(NPE) 문제

> null을 잘못 사용하거나 null 참조에 대해 메서드 호출 시 NPE가 발생

> if(obj != null) {...} 코드 사용 시 코드가 복잡해지고 가독성이 떨어짐

 

Optional 등장

> 자바 8부터 도입한 클래스로 '값이 있을 수도 있고 없을 수도 있음'을 명시적으로 표현

> NPE을 사전에 예방하기 위해 도입됨

> 빈 값을 표현할 때 null 자체를 넘겨주지 않고 Optional.empty()처럼 의도를 드러내는 객체 사용


Optional의 생성과 값 획득

Optional 생성 메서드

1) Optional.of(T value)

: 내부 값이 확실히 null이 아닐 때 사용

: null 전달 시 NPE 발생

 

2) Optional.ofNullable(T value)

: 값이 null일 수도 있고 아닐 수도 있을 때 사용

: null이면 Option.empty() 반환

 

3) Optional.empty()

: 명시적으로 값이 없음을 표현할 때 사용

 

 

 

OptionalCreationMain.java

package optional;

import java.util.Optional;

public class OptionalCreationMain {
    public static void main(String[] args) {

        // 1. of() : 값이 null이 아님이 확실할 때 사용
        // null이면 NPE 발생
        String nonNullValue = "Hello Optional!";
        Optional<String> opt1 = Optional.of(nonNullValue);
        System.out.println("opt1 : " + opt1); // Optional[Hello Optional!]

        // 2. ofNullable() : 값이 null일 수도 아닐 수도 있을 때
        Optional<String> opt2 = Optional.ofNullable("Hello!");
        Optional<String> opt3 = Optional.ofNullable(null);
        System.out.println("opt2 : " + opt2); // Optional[Hello!]
        System.out.println("opt3 : " + opt3); // Optional.empty

        // 3. empty() : 비어있는 Optional을 명시적으로 생성
        Optional<Object> opt4 = Optional.empty();
        System.out.println("opt4 : " + opt4); // Optional.empty
    }
}

 

 

 

Optional의 값을 확인하거나 획득하는 메서드

1) isPresent(), isEmpty()

: 값이 있으면 true, 없으면 false

: isEmpty()는 반대 

 

2) get()

: 값이 있는 경우 그 값 반환

: 값이 없으면 NoSuchElementExcpeion 발생

: 직접 사용 시 주의해야 하며, 가급적이면 orElse, orElseXxx 계열 메서드를 사용하는 것이 안전

 

3) orElse(T other)

: 값이 있으면 그 값 반환

: 없으면 other를 반환

 

4) orElseGet(Supplier<? extends T> supplier)

: 값이 있으면 그 값을 반환

: 값이 없으면 supplier 호출해 생성된 값 반환

 

5) orElseThrow(...)

: 값이 있으면 그 값을 반환

: 없으면 지정한 예외를 던짐

 

6) or(Supplier<? extends Optional<? extends T>> supplier)

: 값이 있으면 해당 값의 Optional 그대로 반환

: 없으면 supplier가 제공하는 다른 Optional 반환

: 값 대신 Optional을 반환한다는 특징

 

 

 

OptionalRetrievalMain.jkava

package optional;

import java.util.Optional;

public class OptionalRetrievalMain {
    public static void main(String[] args) {

        // 문자열 "Hello"가 있는 Optional과 빈 Optional 준비
        Optional<String> optValue = Optional.of("Hello");
        Optional<String> optEmpty = Optional.empty();

        // isPresent() : 값이 있으면 true
        System.out.println("optValue.isPresent() : " + optValue.isPresent()); // true

        // isEmpty() : 값이 없으면 true
        System.out.println("optEmpty.isEmpty() : " + optEmpty.isEmpty()); // true

        // get() : 직접 내부 값을 꺼내고 없으면 예외(NoSuchElementException)
        String getValue = optValue.get();
        System.out.println("getValue : " + getValue); // Hello
        // String getValue2 = optEmpty.get();
        // System.out.println("getValue2 : " + getValue2); // NoSuchElementException 발생

        // orElse() : 값이 있으면 그 값, 없으면 지정 기본값 사용
        String val1 = optValue.orElse("기본값");
        System.out.println("val1 : " + val1); // Hello
        String val2 = optEmpty.orElse("기본값");
        System.out.println("val2 = : " + val2 ); // 기본값

        // orElseGet() : 값이 없을 때만 람다(Supplier)가 실행되어 기본값 생성
        String value1 = optValue.orElseGet(() -> {
            System.out.println("람다 호출");
            return "new Value";
        });
        System.out.println("value1 : " + value1); // Hello, 람다 호출

        String value2 = optEmpty.orElseGet(() -> {
            System.out.println("람다 호출");
            return "new Value";
        });
        System.out.println("value2 : " + value2); // new Value

        // orElseThrow() : 값이 있으면 반환, 없으면 예외 발생
        String value3 = optValue.orElseThrow(() -> new RuntimeException("값이 없음!"));
        System.out.println("value3 : " + value3); // Hello

        // String value4 = optEmpty.orElseThrow(() -> new RuntimeException("값이 없음!"));
        // System.out.println("value4 : " + value4); // RuntimeException 발생

        // or() : Optional 반환
        Optional<String> result1 = optValue.or(() -> Optional.of("Fallback"));
        System.out.println("result1 : " + result1); // Optional[Hello]

        Optional<String> result2 = optEmpty.or(() -> Optional.of("Fallback"));
        System.out.println("result2 : " + result2); // Optional[Fallback]
    }
}

Optional 값 처리

Optional 값 처리 메서드

1) ifPresent(Consumer<? super T> action)

: 값이 존재하면 action 실행

: 없으면 아무것도 안함

 

2) ifPresentOrElse(Consumer<? super T> action, Runnable emptyAction)

: 값이 존재하면 action 실행

: 없으면 emptyAction 실행

 

3) map(Function<? super T, ? extends U> mapper)

: 값이 있으면 mapper를 적용한 결과 반환

: 없으면 Optional.empty() 반환

 

4) flatMap(Function<? super T, ? extends Optional<? extends U>> mapper)

: map과 유사하지만, Optional 반환할 때 중첩되지 않고 평탄화해 반환

 

5) filter(Predicate<? super T> predicate)

: 값이 있고 조건 만족 시 그대로 반환

: 조건 불만족이거나 비어있으면 Optional.empty() 반환

 

5) stream()

: 값이 있으면 단일 요소를 담은 Stream<T> 반환

: 없으면 빈 스트림 반환

 

 

 

OptionalProcessingMain.java

package optional;

import java.util.Optional;
import java.util.stream.Stream;

public class OptionalProcessingMain {

    public static void main(String[] args) {
        Optional<String> optValue = Optional.of("Hello");
        Optional<String> optEmpty = Optional.empty();

        // ifPresent() : 값이 존재하면 Consumer 실행, 없으면 아무것도 안함
        optValue.ifPresent(v -> System.out.println(v)); // Hello
        optEmpty.ifPresent(v -> System.out.println(v)); // 아무것도 출력 X

        // ifPresentOfElse() : 값이 있으면 Consumer 실행, 없으면 Runnalbe 실행
        optValue.ifPresentOrElse(v -> System.out.println(v),
                () -> System.out.println("비어있음")); // Hello
        optEmpty.ifPresentOrElse(v -> System.out.println(v),
                () -> System.out.println("비어있음")); // 비어있음

        // map() : 값이 있으면 Function 적용 후 Optional로 반환, 없으면 Optional.empty()
        Optional<Integer> length1 = optValue.map(s -> s.length());
        System.out.println(length1); // Optional[5]
        Optional<Integer> length2 = optEmpty.map(String::length);
        System.out.println(length2); // Optional.empty

        // flatMap() : map()과 유사하나 이미 Optional을 변환하는 경우 중첩 제거
        Optional<Optional<Integer>> answer1 = optValue.map(s -> Optional.of(s.length()));
        System.out.println(answer1); // Optional[Optional[5]]

        Optional<Integer> flatAnswer1 = optValue.flatMap(s -> Optional.of(s.length()));
        System.out.println(flatAnswer1); // Optional[5] -> 평탄화!

        Optional<Integer>  answer2 = optEmpty.flatMap(s -> Optional.of(s.length()));
        System.out.println(answer2); // Optional.empty

        // filter() : 값이 있고 조건을 만족하면 그 값을 그대로, 불만족시 Optional.empty()
        Optional<String> filtered1 = optValue.filter(s -> s.startsWith("H"));
        Optional<String> filtered2 = optValue.filter(s -> s.startsWith("X"));
        System.out.println(filtered1); // Optional[Hello]
        System.out.println(filtered2); // Optional.empty

        // stream() : 값이 있으면 단일 요소 스트림, 없으면 빈 스트림
        optValue.stream().forEach(s -> System.out.println(s)); // Hello
        optEmpty.stream().forEach(s -> System.out.println(s)); // 출력 X
    }
}

즉시 평가와 지연 평가

앞에서 살펴본 orElse()와 orElseGet()의 차이가 잘 느껴지지 않을 수도 있다.

이를 위해 즉시 평가와 지연평가를 이해해야 한다.

 

1. 즉시 평가(eager evaluation)

: 값(혹은 객체)을 바로 생성하거나 계산해 버리는 것

 

2. 지연 평가(lazy evaluation)

: 값이 실제로 필요할 때(사용될 때)까지 계산을 미루는 것

 

-> 이때 평가를 계산이라고 생각하면 된다.

 

 

 

Logger.java

package optional;

public class Logger {

    private boolean isDebug = false;

    public boolean isDebug() {
        return isDebug;
    }

    public void setDebug(boolean debug) {
        isDebug = debug;
    }

    // debug 설정한 경우만 출력
    public void debug(Object msg) {
        if(isDebug) {
            System.out.println("[DEBUG] " + msg);
        }
    }
}

 

LogMain1.java

package optional;

public class LogMain1 {

    public static void main(String[] args) {
        Logger logger = new Logger();
        logger.setDebug(true);
        logger.debug(10 + 20);

        System.out.println("== 디버거 모드 끄기 ==");
        logger.setDebug(false);
        logger.debug(100 + 200);
    }
}

 

실행 결과

-> logger.debug(100 + 200)은 디버거 코드가 꺼졌기에 출력되지 않는다.

-> 100 + 200 연산은 즉시 평가(바로 수행)되지만, 어디에도 사용되지 않으며 계산된 후에 버려진다.

 

 

 

LogMain2.java

package optional;

public class LogMain2 {

    public static void main(String[] args) {
        Logger logger = new Logger();
        logger.setDebug(true);
        logger.debug(value100() + value200());

        System.out.println("== 디버거 모드 끄기 ==");
        logger.setDebug(false);
        logger.debug(value100() + value200());

        // 코드 마지막에 추가
        System.out.println("== 디버그 모드 체크 ==");
        if(logger.isDebug()) {
            logger.debug(value100() + value200());
        }
    }

    static int value100() {
        System.out.println("value100 호출");
        return 100;
    }

    static int value200() {
        System.out.println("value200 호출");
        return 200;
    }
}

 

실행 결과

-> 여기서도 value100() + value200() 연산은 미래에 전혀 사용하지 않을 값을 계산해 아까운 CPU만 낭비했다.

-> logger.isDebug()로 디버그 모드 체크를 할 수 있지만 코드의 길이가 길어진다.

 

 

 

람다를 사용해 연산을 정의하는 시점과 실행하는 시점을 분리해 문제를 해결할 수 있다.

Logger.java : Logger에 람다(Supplier)를 받는 debug 메서드 추가

 

LogMain3.java : 람다 사용

package optional;

public class LogMain3 {

    public static void main(String[] args) {
        Logger logger = new Logger();
        logger.setDebug(true);
        logger.debug(() -> value100() + value200());

        System.out.println("== 디버거 모드 끄기 ==");
        logger.setDebug(false);
        logger.debug(() -> value100() + value200());
    }

    static int value100() {
        System.out.println("value100 호출");
        return 100;
    }

    static int value200() {
        System.out.println("value200 호출");
        return 200;
    }
}

 

실행 결과

 

실행 분석

1) 디버거 모드가 켜져있을 때

> 람다 생성

logger.debug(() -> value100() + value200())

> debug()를 호출하면서 인자로 람다 전달

> supplier에 람다가 전달되며 디버거 코드이므로 if문 수행

public void debug(Supplier<?> supplier) {
    if(isDebug) {
        System.out.println("[DEBUG] " + supplier.get());
    }
}

> supplier.get() 실행 시점에 value100() + value200()이 평가(계산)됨

> 평가 결과 반환 후 출력

 

2) 디버거 코드 껴져있을 때

> 람다 생성

> debug()를 호출하면서 인자로 람다 전달

> supplier에 람다 전달

> 디버그 모드가 아니므로 if문 수행 X

 

 

 

=> 람다를 사용해 연산을 정의하는 시점과 실행하는 시점을 분리했다.


orElse() vs orElseGet()

orElse()는 보통 데이터를 받아 인자가 즉시 평가되고, orElseGet()은 람다를 받아 인자가 지연 평가된다.

 

OrElseGetMain.java

package optional;

import java.util.Optional;
import java.util.Random;

public class OrElseGetMain {

    public static void main(String[] args) {
        Optional<Integer> optValue = Optional.of(100);
        Optional<Integer> optEmpty = Optional.empty();

        System.out.println("단순 계산");
        Integer i1 = optValue.orElse(10 + 20); // 계산 후 버림
        Integer i2 = optEmpty.orElse(10 + 20); // 계산 후 사용
        System.out.println(i1); // 값이 있으므로 100 출력
        System.out.println(i2); // 값이 없으므로 30 출력

        // 값이 있으면 그 값, 없으면 지정된 기본값 사용
        System.out.println("=== orElse ===");
        Integer value1 = optValue.orElse(createData());
        Integer value2 = optEmpty.orElse(createData());
        System.out.println(value1); // 데이터 생성 ... 100
        System.out.println(value2); // 데이터 생성 ... 4

        System.out.println("=== orElseGet ===");
        Integer result1 = optValue.orElseGet(() -> createData());
        Integer result2 = optEmpty.orElseGet(() -> createData());
        System.out.println(result1); // 100
        System.out.println(result2); // 데이터 생성 ... 4


    }

    static int createData() {
        System.out.println("데이터 생성 ...");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        return new Random().nextInt(100);
    }
}

 

1) orElse(T other)

> 빈 값이면 other를 반환하는데 other를 항상 미리 계산

> 호출 즉시 평가하므로 즉시 평가 적용

> 값이 존재하지 않을 가능성이 높거나 orElse()에 넘기는 객체가 생성 비용이 크지 않은 경우 사용

> 연산이 없는 상수나 변수일 경우 사용

 

2) orElseGet(Supplier supplier)

> 값이 있을 때는 supplier가 호출되지 않음

> 생성 비용이 높은 객체를 다룰 때 더 효과적

> 필요할 때만 평가하므로 지연 평가 적용

 

=> 단순한 대체 값을 전달하거나 코드가 간단하다면 orElse() 사용, 객체 생성 비용이 큰 로직이고 Optional에 값이 이미 존재할 가능성이 높다면 orElseGet() 고려


Optional - 베스트 프랙티스

Optional이 좋아보여도 무분별하게 사용하면 오히려 코드 가독성과 유지보수에 도움이 되지 않을 수 있다.

 

1. 반환 타입으로만 사용하고 필드에는 가급적 쓰지 말기

> Optional은 메서드 반환값에 대해 값이 없을 수도 있음을 표현하기 위해 도입됨

> 클래스의 필드에 Optional을 직접 두는 것은 권장 X

 

(-) 

public class Product {
    // 안티 패턴: 필드를 Optional로 선언
    private Optional<String> name;
}

-> name이 null일수도, Optional.empty()일수도, Optional.of(value)일수도 있음

-> Optional 자체도 참조 타입이기 때문에 개발자가 부주의로 Optional 필드에 null을 할당하면 그 자체가 NPE 발생 여지를 남김

 

(+)

public class Product {
    // 필드는 원시 타입(혹은 일반 참조 타입) 그대로 둔다.
    private String name;
}

public Optional<String> getNameAsOptional() {
    return Optional.ofNullable(name);
}

-> 반환 시점에 Optional로 감싸주는 것이 더 나은 방법

 

 

 

2. 메서드 매개변수로 Optional을 사용하지 말기

> 자바 공식 문서에 Optional은 메서드 반환값으로 사용하길 권장하며, 매개변수로 사용하지 말라고 명시되어 있음

> 호출쪽에서 null 전달 대신 Optional.empty()를 전달해야 하는 부담이 생기며 결국 null을 사용하든 Optional.empty()를 사용하든 큰 차이가 없어 가독성만 떨어짐

 

(-) 

public void processOrder(Optional<Long> orderId) {
    if (orderId.isPresent()) {
        System.out.println("Order ID: " + orderId.get());
    } else {
        System.out.println("Order ID is empty!"); 
    }
}

 -> 호출하는 입장에서 processOrder(Optional.empty())처럼 호출해야 하는데, 이는 processOrder(null)과 큰 차이가 없고 오히려 Optional.empty()를 만드는 비용이 추가됨

 

(+)

// 1. 오버로드 예시
public void processOrder(long orderId) {
    // 이 메서드는 orderId가 항상 있어야 하는 경우
    System.out.println("Order ID: " + orderId);
}
public void processOrder() {
    // 이 메서드는 orderId가 없을 때 호출할 경우
    System.out.println("Order ID is empty!");
}


// 2. 방어적 코드
public void processOrder(Long orderId) {
    if (orderId == null) {
        System.out.println("Order ID is empty!");
        return;
    }
    System.out.println("Order ID: " + orderId);
}

-> 오버로드된 메서드를 만들거나, 명시적으로 null 허용 여부를 문서화하는 방식을 택하는 것이 나음

 

 

 

3. 컬렉션이나 배열 타입을 Optional로 감싸지 말기

> List, Set, Map 등 컬렉션 자체는 비어있는 상태를 표현할 수 있음

> Optional<List T>> 처럼 다시 감싸면 Optional.empty()와 빈 리스트가 이중 표현이 되고 혼란을 야기

 

(-)

public Optional<List<String>> getUserRoles(String userId) {
    List<String> userRolesList ...;
    if (foundUser) {
        return Optional.of(userRolesList);
    } else {
        return Optional.empty();
    }
}

// 반환받은 쪽
Optional<List<String>> optList = getUserRoles("someUser");
    if (optList.isPresent()) {
        // ...
}

-> 반환받은 내부 리스트가 empty일 수도 있어 한 번 더 체크해야 하는 모호함이 생김

 

(+)

public List<String> getUserRoles(String userId) {
    if (!foundUser) {
        // 권장: 빈 리스트 반환
        return Collections.emptyList();
    }
    return userRolesList;
}

-> 빈 컬렉션을 반환하게

 

 

 

4. isPresent()와 get() 조합을 직접 사용하지 않기

> Optional의 get()은 가급적 사용하지 않아야 함

> 사실상 null 체크 구문과 다름이 없으며 깜빡하면 NoSuchElementException 예외 발생

> 대신 orElse, orElseGet, orElseThrow, isPresentOrElse, map, filter 등의 메서드 활용

 

 

 

5. orElsetGet() vs orElse() 차이 분명히 이해하기

> 비용이 크지 않은 대체값이라면 orElse() 사용

> 복잡하고 비용이 큰 객체 생성이 필요한 경우, Optional 값이 이미 존재할 가능성이 높다면 orElseGet() 사용

 

 

 

=> 무조건 Optional이 좋은 것은 아니다.

 

 

 

* 클라이언트 메서드 vs 서버 메서드

사실 Optional을 고려할 때 가장 중요한 핵심은 Optional을 생성하고 반환하는 서버쪽 메서드가 아닌, Optional을 반환하는 코드를 호출하는 클라이언트 메서드에 있다.

결과적으로 Optional을 반환받는 클라이언트의 입장을 고려해서 하는 선택이 가장 Optional을 잘 사용하는 방법이다.

 

Q. 이 로직은 null을 반환할 수 있는가?

Q. null이 가능하다면 호출하는 입장에서 '값이 없을 수도 있다'는 사실을 명시적으로 인지할 필요가 있는가?

Q. null이 적절하지 않고, 예외를 던지는게 더 맞지 않은가?

 

 

 

* Optional 기본형 타입 지원

OptionalInt, OptionalLong 등과 같은 기본형 타입의 Optional도 있지만 다음의 이유로 잘 사용되지 않는다.

 

1)  Optional<T>와 달리 map(), flatMap()등 다양한 연산 메서드를 제공하지 않는다.

2) 기존 이미 Optional<T>를 많이 사용하고 있는 코드베이스에서 특정 상황만을 위해 OptionalInt 등을 섞어 쓰면 오히려 가독성을 떨어뜨린다.

 

기본형 Optional의 존재는 박싱/언박싱을 없애고 성능을 조금 더 높일 수 있다는 선택지를 제공하지만, 실제로는 별도로 쓰는게 좋을 정도로 성능 문제가 크게 나타나느냐?를 고민해야 함 -> 대부분의 경우 그렇지 않기에 잘 사용되지 않음