본문으로 건너뛰기

Ch 14.3 Optional 클래스

자바 개발자들이 가장 많이 겪는 에러 중 하나가 바로 NullPointerException(NPE) 입니다. 변수가 null인 상태에서 그 변수를 통해 객체 내부의 메서드나 필드에 접근하려 할 때 발생합니다.

자바 8에서는 이런 null 값을 안전하게 다룰 수 있도록 돕는 일종의 "객체 포장지(래퍼, Wrapper)" 클래스인 Optional<T>을 도입했습니다. 보통 스트림 파이프라인의 계산 결과가 값이 없을 수 있는 상황에 많이 사용되며, 단일 데이터의 null 처리에 강력합니다.

Optional이란?

Optional<T>은 값이 있을 수도, 없을 수도 있는 컨테이너 타입입니다. null 대신 "값 없음"을 명시적으로 표현하여 NPE를 방지합니다.

1. Optional 객체 생성하기

Optional 객체는 직접 new 키워드가 아니라 정적 팩토리 메서드로 생성합니다.

import java.util.Optional;

public class OptionalCreation {
public static void main(String[] args) {
// 1. 확실히 데이터가 null이 아닐 때
Optional<String> opt1 = Optional.of("Hello!");
System.out.println(opt1); // Optional[Hello!]

// 2. 데이터가 null일 가능성이 있을 때 (가장 많이 씀)
String nullableStr = null;
Optional<String> opt2 = Optional.ofNullable(nullableStr);
System.out.println(opt2); // Optional.empty

// 3. 비어있는 Optional 만들기
Optional<String> opt3 = Optional.empty();
System.out.println(opt3); // Optional.empty

// 주의: Optional.of(null) 은 NullPointerException 발생!
// Optional<String> bad = Optional.of(null); // NPE!
}
}
Optional.of() 주의

Optional.of(value)에 null을 넘기면 즉시 NullPointerException이 발생합니다. null 가능성이 있으면 반드시 Optional.ofNullable(value)을 사용하세요.

2. Optional 값 꺼내기 (null 안전하게 처리)

단순히 .get()을 쓰면 내부 값이 null일 때 에러가 발생하므로 Optional을 쓰는 의미가 퇴색됩니다. 안전하게 값을 가져오는 방법들이 제공됩니다.

2.1 기본값 제공: orElse(), orElseGet()

import java.util.Optional;

public class OptionalOrElse {
public static void main(String[] args) {
String name = null;
Optional<String> optName = Optional.ofNullable(name);

// orElse(기본값): 값이 있으면 그 값을 리턴, 없으면 지정한 기본값을 리턴
// 주의: 값이 있어도 기본값 표현식은 항상 평가됨
String result1 = optName.orElse("Guest");
System.out.println(result1); // "Guest"

// orElseGet(람다식): 비용이 큰 기본값을 설정할 때 지연 초기화 수행
// 값이 없을 때만 람다가 실행됨 → 성능상 유리
String result2 = optName.orElseGet(() -> "Guest user from DB");
System.out.println(result2); // "Guest user from DB"

// 값이 있는 경우
Optional<String> optPresent = Optional.of("Alice");
String result3 = optPresent.orElse("Guest");
System.out.println(result3); // "Alice"
}
}
orElse vs orElseGet 차이

orElse("default")는 값이 있어도 "default" 표현식이 평가됩니다. DB 조회나 객체 생성처럼 비용이 큰 작업이라면 orElseGet(() -> expensiveOperation())을 사용하세요.

2.2 예외 던지기: orElseThrow()

값이 존재하지 않을 때 커스텀 예외를 던지고 싶다면 사용합니다. API 서버 등에서 회원을 조회하지 못했을 때 유용합니다.

import java.util.Optional;

public class OptionalOrElseThrow {
// 사용자 조회 시뮬레이션
static Optional<String> findUserById(int id) {
if (id == 1) return Optional.of("Alice");
return Optional.empty(); // 없으면 빈 Optional
}

public static void main(String[] args) {
// 정상 케이스
String user = findUserById(1)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));
System.out.println("찾은 사용자: " + user); // Alice

// 예외 발생 케이스
try {
String notFound = findUserById(99)
.orElseThrow(() -> new IllegalArgumentException("ID 99 사용자를 찾을 수 없습니다."));
} catch (IllegalArgumentException e) {
System.out.println("예외 발생: " + e.getMessage());
}
}
}

2.3 값이 있을 때만 실행: ifPresent(), ifPresentOrElse()

import java.util.Optional;

public class OptionalIfPresent {
public static void main(String[] args) {
Optional<String> emailOpt = Optional.ofNullable("admin@example.com");

// ifPresent: 값이 있을 때만 람다 실행
emailOpt.ifPresent(e -> System.out.println("이메일 발송: " + e));

// ifPresentOrElse (Java 9+): 값이 있으면 첫 번째 람다, 없으면 두 번째 람다
Optional<String> emptyOpt = Optional.empty();
emptyOpt.ifPresentOrElse(
e -> System.out.println("이메일 발송: " + e),
() -> System.out.println("이메일이 없습니다. 기본 처리 수행.")
);
// 출력: 이메일이 없습니다. 기본 처리 수행.
}
}

3. Optional 값 변환하기

3.1 map(): 값을 변환하고 Optional로 감싸기

import java.util.Optional;

public class OptionalMap {
public static void main(String[] args) {
Optional<String> optStr = Optional.of("hello world");

// map: 값이 있으면 Function 적용 후 Optional로 감쌈
Optional<String> upperOpt = optStr.map(String::toUpperCase);
System.out.println(upperOpt.orElse("없음")); // HELLO WORLD

// 중첩 map 체이닝
Optional<Integer> lengthOpt = optStr
.map(String::trim)
.map(String::length);
System.out.println(lengthOpt.orElse(0)); // 11

// 빈 Optional에 map 적용 → 여전히 빈 Optional
Optional<String> emptyOpt = Optional.empty();
Optional<String> result = emptyOpt.map(String::toUpperCase);
System.out.println(result.isPresent()); // false
}
}

3.2 flatMap(): Optional을 반환하는 메서드 체이닝

import java.util.Optional;

public class OptionalFlatMap {
static Optional<String> getEmailDomain(String email) {
if (email != null && email.contains("@")) {
return Optional.of(email.split("@")[1]);
}
return Optional.empty();
}

public static void main(String[] args) {
Optional<String> emailOpt = Optional.of("user@example.com");

// map을 쓰면 Optional<Optional<String>> 이 됨 (이중 래핑)
Optional<Optional<String>> bad = emailOpt.map(OptionalFlatMap::getEmailDomain);

// flatMap은 중첩 Optional을 평탄화함 → Optional<String>
Optional<String> domain = emailOpt.flatMap(OptionalFlatMap::getEmailDomain);
System.out.println(domain.orElse("도메인 없음")); // example.com

// null인 경우
Optional<String> noEmail = Optional.ofNullable(null);
Optional<String> noDomain = noEmail.flatMap(OptionalFlatMap::getEmailDomain);
System.out.println(noDomain.orElse("도메인 없음")); // 도메인 없음
}
}

3.3 filter(): 조건에 맞지 않으면 빈 Optional 반환

import java.util.Optional;

public class OptionalFilter {
public static void main(String[] args) {
Optional<Integer> scoreOpt = Optional.of(85);

// filter: 조건을 만족하면 그대로, 아니면 빈 Optional
Optional<Integer> passOpt = scoreOpt.filter(score -> score >= 80);
System.out.println(passOpt.isPresent()); // true

Optional<Integer> highPassOpt = scoreOpt.filter(score -> score >= 90);
System.out.println(highPassOpt.isPresent()); // false

// 체이닝 예제
String result = Optional.of(" hello ")
.map(String::trim)
.filter(s -> !s.isEmpty())
.map(String::toUpperCase)
.orElse("비어있음");
System.out.println(result); // HELLO
}
}

4. 값 존재 여부 확인: isPresent(), isEmpty()

import java.util.Optional;

public class OptionalPresence {
public static void main(String[] args) {
Optional<String> present = Optional.of("value");
Optional<String> empty = Optional.empty();

// isPresent(): 값이 있으면 true
System.out.println(present.isPresent()); // true
System.out.println(empty.isPresent()); // false

// isEmpty() (Java 11+): 값이 없으면 true (isPresent의 반대)
System.out.println(present.isEmpty()); // false
System.out.println(empty.isEmpty()); // true

// get(): 값이 없으면 NoSuchElementException → 직접 사용 비권장
// isPresent() 체크 후 사용해야 안전
if (present.isPresent()) {
System.out.println(present.get()); // value
}
}
}

5. Optional.stream() (Java 9+)

Optional을 Stream과 함께 사용할 때 유용합니다.

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

public class OptionalStream {
public static void main(String[] args) {
// Optional을 Stream으로 변환 (값 있으면 1개짜리 스트림, 없으면 빈 스트림)
Optional<String> opt = Optional.of("hello");
opt.stream().forEach(System.out::println); // hello

Optional<String> empty = Optional.empty();
empty.stream().forEach(System.out::println); // 아무것도 출력 안 됨

// 실전: List<Optional<String>>에서 값이 있는 것만 추출
List<Optional<String>> optionals = List.of(
Optional.of("Alice"),
Optional.empty(),
Optional.of("Bob"),
Optional.empty(),
Optional.of("Charlie")
);

List<String> names = optionals.stream()
.flatMap(Optional::stream) // Optional.stream()으로 평탄화
.collect(Collectors.toList());

System.out.println(names); // [Alice, Bob, Charlie]
}
}

6. Optional을 잘못 사용하는 안티패턴

6.1 매개변수로 사용 금지

// 나쁜 예: 매개변수에 Optional 사용
public void sendEmail(Optional<String> email) {
email.ifPresent(e -> System.out.println("보내는 중: " + e));
}
// 호출자가 Optional.ofNullable(email)을 직접 래핑해야 해서 불편

// 좋은 예: null 가능성은 @Nullable 또는 오버로딩으로 처리
public void sendEmail(String email) {
if (email != null) {
System.out.println("보내는 중: " + email);
}
}

6.2 필드에 Optional 사용 금지

// 나쁜 예: 클래스 필드에 Optional 선언
public class User {
private Optional<String> nickname; // Serializable 불가, 메모리 낭비
}

// 좋은 예: 필드는 null 허용, getter에서 Optional 반환
public class User {
private String nickname;

public Optional<String> getNickname() {
return Optional.ofNullable(nickname);
}
}

6.3 컬렉션 원소로 사용 금지

// 나쁜 예: List에 Optional 저장
List<Optional<String>> list = new ArrayList<>();

// 좋은 예: null 원소 자체를 컬렉션에서 제거하거나 filter 처리
List<String> list2 = new ArrayList<>(); // null 원소를 넣지 않거나 필터링
Optional 사용 원칙

Optional메서드의 반환 타입(Return Type) 으로만 사용하세요. "이 메서드가 결과를 반환할 수도, 없을 수도 있다"는 의미를 API 사용자에게 명확히 전달하는 목적으로 사용합니다.

7. NullPointerException 방지 패턴 비교

전통적인 null 체크 방식

public class TraditionalNullCheck {
static String getCity(User user) {
if (user != null) {
Address address = user.getAddress();
if (address != null) {
City city = address.getCity();
if (city != null) {
return city.getName();
}
}
}
return "Unknown";
}
}

Optional 체이닝 방식

import java.util.Optional;

public class OptionalChaining {
static String getCity(User user) {
return Optional.ofNullable(user)
.map(User::getAddress)
.map(Address::getCity)
.map(City::getName)
.orElse("Unknown");
}
}

훨씬 읽기 쉽고 NPE가 발생할 여지가 없습니다.

8. 실전 예제: DB 조회 Optional 처리

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

// 도메인 모델
class Member {
private String id;
private String name;
private String email;

public Member(String id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}

public String getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
}

// 리포지토리 계층
class MemberRepository {
private static final Map<String, Member> DB = new HashMap<>();

static {
DB.put("1", new Member("1", "Alice", "alice@example.com"));
DB.put("2", new Member("2", "Bob", null)); // 이메일 없는 회원
}

// Optional을 반환 타입으로 사용 → 없을 수 있음을 명시
public Optional<Member> findById(String id) {
return Optional.ofNullable(DB.get(id));
}
}

// 서비스 계층
class MemberService {
private final MemberRepository repository = new MemberRepository();

public String getMemberName(String id) {
return repository.findById(id)
.map(Member::getName)
.orElse("알 수 없는 사용자");
}

public void sendWelcomeEmail(String id) {
repository.findById(id)
.flatMap(member -> Optional.ofNullable(member.getEmail()))
.ifPresentOrElse(
email -> System.out.println("환영 이메일 전송: " + email),
() -> System.out.println("이메일 주소가 없어 발송 불가")
);
}

public Member getOrThrow(String id) {
return repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("ID " + id + " 회원 없음"));
}
}

public class OptionalRealWorld {
public static void main(String[] args) {
MemberService service = new MemberService();

// 1. 이름 조회
System.out.println(service.getMemberName("1")); // Alice
System.out.println(service.getMemberName("99")); // 알 수 없는 사용자

// 2. 이메일 발송
service.sendWelcomeEmail("1"); // 환영 이메일 전송: alice@example.com
service.sendWelcomeEmail("2"); // 이메일 주소가 없어 발송 불가

// 3. 없으면 예외
try {
service.getOrThrow("999");
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage()); // ID 999 회원 없음
}
}
}

9. 기본형 특화 Optional

박싱/언박싱 비용을 줄이기 위해 기본형 전용 Optional이 제공됩니다.

import java.util.OptionalInt;
import java.util.OptionalLong;
import java.util.OptionalDouble;
import java.util.stream.IntStream;

public class PrimitiveOptional {
public static void main(String[] args) {
// OptionalInt: 박싱 없이 int 값을 Optional로 처리
OptionalInt maxScore = IntStream.of(85, 90, 78, 92)
.max();

if (maxScore.isPresent()) {
System.out.println("최고 점수: " + maxScore.getAsInt()); // 92
}

// orElse도 동일하게 사용 가능
int result = IntStream.empty()
.max()
.orElse(0);
System.out.println("빈 스트림 max: " + result); // 0

// OptionalDouble
OptionalDouble avg = IntStream.of(1, 2, 3, 4, 5)
.average();
System.out.println(avg.orElse(0.0)); // 3.0
}
}

10. Optional 체이닝 패턴 총정리

import java.util.Optional;

public class OptionalChainingSummary {
public static void main(String[] args) {
// 시나리오: 사용자의 프로필 이미지 URL을 구하되,
// 없으면 기본 이미지 URL 반환

String userId = "user123";

String profileImageUrl = Optional.ofNullable(fetchUser(userId)) // null 가능
.map(user -> user.getProfile()) // Profile 추출
.filter(profile -> profile.isPublic()) // 공개 프로필만
.map(profile -> profile.getImageUrl()) // URL 추출
.filter(url -> url.startsWith("https://")) // 보안 URL만
.orElse("https://example.com/default-avatar.png"); // 기본값

System.out.println("프로필 이미지: " + profileImageUrl);
}

static User fetchUser(String id) { return null; } // DB 조회 시뮬레이션

// 내부 클래스 정의 (컴파일을 위해)
static class User {
Profile getProfile() { return null; }
}

static class Profile {
boolean isPublic() { return true; }
String getImageUrl() { return "https://cdn.example.com/img.png"; }
}
}
고수 팁: Optional vs 예외
  • 결과가 없을 수 있는 정상적인 상황 이면 Optional을 반환하세요.
  • 결과가 반드시 있어야 하는데 없는 오류 상황 이면 예외를 던지세요.
  • findByIdOptional<User> (없을 수도 있으니까 정상)
  • 필수 설정 파일 로드 실패 → IOException (있어야 하는데 없으면 오류)