본문 바로가기

Optional 사용 원칙

Optional 필드·매개변수·컬렉션·get 남용을 제거하고 기본형 Optional과 null·빈 상태 컬렉션·예외의 선택 기준을 정리합니다.

Optional은 “값이 없을 수 있는 메서드 결과”를 표현할 때 가장 선명합니다.

필드, 매개변수, 컬렉션 래퍼까지 퍼뜨리면 도메인 모델이 중첩되고 호출자 선택지가 늘어납니다.

Optional 사용 여부보다 부재 원소 수와 객체 불변식을 가장 단순한 타입으로 나타내는 것이 우선입니다.


Optional List의 상태 중복

목록 결과를 Optional로 감싸면 빈 Optional과 값이 있는 빈 List가 모두 “원소 0개”를 뜻하면서도 서로 다른 상태가 됩니다.

아래 원문의 두 스트림은 모두 원소가 0개여서 wrong-zero-states=2가 됩니다.

lab/OptionalCollectionStateBug.java
import java.util.List;
import java.util.Optional;

public final class OptionalCollectionStateBug {
    public static void main(String[] args) {
        Optional<List<String>> absent = Optional.empty();
        Optional<List<String>> presentEmpty = Optional.of(List.of());
        int zeroStates =
                (absent.stream().flatMap(List::stream).count() == 0 ? 1 : 0)
                        + (presentEmpty.stream().flatMap(List::stream).count() == 0 ? 1 : 0);
        System.out.println("wrong-zero-states=" + zeroStates);
    }
}

검색 결과가 0개 이상이면 빈 List 하나로 충분합니다.

“질의를 수행하지 않음”과 “수행했지만 0개”가 실제로 다른 상태라면 Optional List보다 명명된 sealed 결과가 의미를 잘 전달합니다.

여기서 absent.stream()은 0개이고 presentEmpty.stream()은 빈 List 한 개를 가진 1개 Stream입니다. 뒤의 flatMap(List::stream)이 두 경우를 모두 0개로 바꿉니다. Optional 층의 원소 수와 펼친 태그 층의 원소 수를 같다고 세면 wrong-zero-states=2의 이유를 놓칩니다.


필드 타입의 도메인 불변식

Optional<Address> address 필드는 모든 접근자 호출자에게 Optional을 강제하고 직렬화·ORM·프레임워크 바인딩과 마찰을 만들 수 있습니다.

선택 관계라면 null을 허용하는 private 필드와 Optional 접근자를 쓸 수도 있고, 더 좋은 방법은 Address 미등록 상태를 도메인 타입이나 별도 집계로 만드는 것입니다.

src/OptionalReturnBoundary.java
import java.util.Optional;

public final class OptionalReturnBoundary {
    private static final class User {
        private String nickname;

        void changeNickname(String nickname) {
            if (nickname == null || nickname.isBlank()) {
                throw new IllegalArgumentException("nickname");
            }
            this.nickname = nickname;
        }

        void clearNickname() {
            nickname = null;
        }

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

    public static void main(String[] args) {
        User user = new User();
        System.out.println(user.nickname().orElse("anonymous"));
        user.changeNickname("min");
        System.out.println(user.nickname().orElseThrow());
    }
}

Optional은 게터 반환에서만 만들어집니다.

내부 null 허용 선택이 항상 최선은 아니며 모델 프레임워크와 불변식에 맞춰 결정합니다.

필수 필드는 생성자에서 null이 아님을 강제하고 Optional로 감싸지 않습니다.


매개변수 Optional의 부담

send(Optional<String> title)은 값이 있는 호출자도 Optional.of를 만들고 값이 없는 호출자도 빈 Optional을 만들어야 합니다.

오버로드, null 허용 어댑터, 요청 객체의 명시적 필드 상태가 더 나을 수 있습니다.

Optional 매개변수가 여러 개면 가능한 상태 조합이 급격히 늘어납니다.

src/ParameterWithoutOptional.java
public final class ParameterWithoutOptional {
    static String label(String title) {
        if (title == null || title.isBlank()) {
            throw new IllegalArgumentException("title");
        }
        return "TITLE: " + title;
    }

    static String defaultLabel() {
        return "TITLE: untitled";
    }

    public static void main(String[] args) {
        System.out.println(label("Stream"));
        System.out.println(defaultLabel());
    }
}

두 메서드가 “제목 제공”과 “기본 제목” 의도를 이름으로 구분합니다.

매개변수 부재가 요청 스키마의 핵심이면 sealed 명령이나 빌더로 상태를 제한합니다.


isPresent·get 대체

isPresent() 분기와 get().name()을 map(User::name).orElse(defaultValue)로 묶을 때는 null 결과의 의미를 먼저 확인합니다.

직접 반환과 map 치환이 달라지는 null 경계

같은 Optional<User>의 null name이 직접 분기에서는 null로 반환되지만 map에서는 empty가 되어 anonymous를 선택합니다. 동일 입력의 중간 상태를 비교하면 단순 치환의 의미 변경이 드러납니다.

직접 반환과 map 치환이 달라지는 null 경계

직접 반환과 map 치환이 달라지는 null 경계

리팩터링 전후의 null 의미

직접 반환과 map 치환이 달라지는 null 경계

User는 있어도 name이 null일 수 있습니다. 직접 반환은 null을 남기지만 Optional.map은 empty로 바꿔 기본값을 선택합니다.

같은 null 이름도 직접 반환과 map의 결과가 다르다동일한non-nullOptional에name=null인User가있는반례를두독립식에적용한다. 직접name은null이고map의null결과는empty여서orElse기본값을반환한다. 5노드4값변환관계이고mobile동일,원main의Min입력을바꾼실제출력이나상호순차실행을뜻하지않는다. 이름빈String은empty변환대상이아니다.같은 Optional<User>User 있음 · name null직접 isPresent 분기get().name()null 그대로 반환User 부재가 아님map(User::name)null → emptyorElse 기본값 반환anonymous
같은 null 이름도 직접 반환과 map의 결과가 다르다동일한non-nullOptional에name=null인User가있는반례를두독립식에적용한다. 직접name은null이고map의null결과는empty여서orElse기본값을반환한다. 5노드4값변환관계이고mobile동일,원main의Min입력을바꾼실제출력이나상호순차실행을뜻하지않는다. 이름빈String은empty변환대상이아니다.같은 Optional<User>User 있음 · name null직접 isPresent 분기get().name()null 그대로 반환User 부재가 아님map(User::name)null → emptyorElse 기본값 반환anonymous
직접 반환과 map 치환이 달라지는 null 경계
조회 상태isPresent 분기에서 name 반환map 뒤 orElse 반환
User 있음·이름 있음이름이름
User 있음·이름 nullnulldefaultValue
User 없음defaultValuedefaultValue
User 있음·이름 있음
isPresent 분기에서 name 반환
이름
map 뒤 orElse 반환
이름
User 있음·이름 null
isPresent 분기에서 name 반환
null
map 뒤 orElse 반환
defaultValue
User 없음
isPresent 분기에서 name 반환
defaultValue
map 뒤 orElse 반환
defaultValue

같은 non-null Optional과 같은 defaultValue를 두고 비교한 API 의미입니다. 이름이 null인 행은 아래 main의 입력이 아니며, 기본값을 만드는 식에 부수 효과가 있다면 인수 평가 시점도 따로 확인해야 합니다.

이 예의 조건

  • name=null인 User를 담은 동일한 non-null Optional을 두 독립 식에 적용하는 반례입니다. 원 main의 Min 출력이 아닙니다.
  • 이름 빈 문자열은 null이 아니므로 map이 empty로 바꾸지 않습니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2동일한 non-null Optional에 name=null인 User가 있는 반례를 두 독립 식에 적용한다. 직접 반환한 name은 null이고 map의 null 결과는 empty여서 orElse 기본값을 반환한다. 5개 노드와 4개의 값 변환 관계이며 mobile도 동일하다. 원 main의 Min 입력을 바꾼 실제 출력이나 서로 순차적으로 실행하는 관계를 뜻하지 않는다. 이름이 빈 String이면 empty 변환 대상이 아니다.
원 도표의 설명과 주석
구분내용
설명동일한 non-null Optional에 name=null인 User가 있는 반례를 두 독립 식에 적용한다. 직접 반환한 name은 null이고 map의 null 결과는 empty여서 orElse 기본값을 반환한다. 5개 노드와 4개의 값 변환 관계이며 mobile도 동일하다. 원 main의 Min 입력을 바꾼 실제 출력이나 서로 순차적으로 실행하는 관계를 뜻하지 않는다. 이름이 빈 String이면 empty 변환 대상이 아니다.
전체 읽기 정보직접 반환과 map 치환이 달라지는 null 경계 직접 반환과 map 치환이 달라지는 null 경계 직접 반환과 map 치환이 달라지는 null 경계 리팩터링 전후의 null 의미 직접 반환과 map 치환이 달라지는 null 경계 User는 있어도 name이 null일 수 있습니다. 직접 반환은 null을 남기지만 Optional.map은 empty로 바꿔 기본값을 선택합니다. 직접 반환과 map 치환이 달라지는 null 경계 조회 상태 isPresent 분기에서 name 반환 map 뒤 orElse 반환 User 있음·이름 있음 이름 이름 User 있음·이름 null null defaultValue User 없음 defaultValue defaultValue User 있음·이름 있음 isPresent 분기에서 name 반환 이름 map 뒤 orElse 반환 이름 User 있음·이름 null isPresent 분기에서 name 반환 null map 뒤 orElse 반환 defaultValue User 없음 isPresent 분기에서 name 반환 defaultValue map 뒤 orElse 반환 defaultValue 같은 non-null Optional과 같은 defaultValue를 두고 비교한 API 의미입니다. 이름이 null인 행은 아래 main의 입력이 아니며, 기본값을 만드는 식에 부수 효과가 있다면 인수 평가 시점도 따로 확인해야 합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
같은 Optional 입력

직접 분기와 map 식 모두 User가 있고 그 name만 null인 입력에서 출발합니다.

  • 직접 식 입력: isPresent=true, name=null
  • map 식 입력: User 존재, name=null
  • User 부재: 두 입력 모두 아님
  • 객체의 존재와 내부 필드의 null을 별개 조건으로 고정합니다.
각 필드 추출과 변환

직접 식은 get().name()을 읽고 map은 User::name의 null 결과를 empty로 바꿉니다.

  • 직접 추출: null 값 그대로
  • map 변환: 매퍼 null → Optional.empty
  • 빈 문자열 조건: null이 아니므로 empty로 바꾸지 않음
  • map의 null 정규화가 직접 필드 반환과 다른 후속 선택을 만듭니다.
각 식의 반환

직접 식은 null을 반환하고 map 식은 empty의 orElse 기본값을 반환합니다.

  • 직접 반환: null
  • map·orElse 반환: anonymous
  • 차이 조건: User가 존재하고 name=null인 경우
  • 두 독립 식의 관측 차이이며 직접 반환 뒤 map이 실행되는 순서가 아닙니다.

직접 반환과 map 치환이 달라지는 null 경계

같은 Optional<User>의 null name이 직접 분기에서는 null로 반환되지만 map에서는 empty가 되어 anonymous를 선택합니다. 동일 입력의 중간 상태를 비교하면 단순 치환의 의미 변경이 드러납니다.

직접 반환과 map 치환이 달라지는 null 경계

직접 반환과 map 치환이 달라지는 null 경계

리팩터링 전후의 null 의미

직접 반환과 map 치환이 달라지는 null 경계

User는 있어도 name이 null일 수 있습니다. 직접 반환은 null을 남기지만 Optional.map은 empty로 바꿔 기본값을 선택합니다.

같은 null 이름도 직접 반환과 map의 결과가 다르다동일한non-nullOptional에name=null인User가있는반례를두독립식에적용한다. 직접name은null이고map의null결과는empty여서orElse기본값을반환한다. 5노드4값변환관계이고mobile동일,원main의Min입력을바꾼실제출력이나상호순차실행을뜻하지않는다. 이름빈String은empty변환대상이아니다.같은 Optional<User>User 있음 · name null직접 isPresent 분기get().name()null 그대로 반환User 부재가 아님map(User::name)null → emptyorElse 기본값 반환anonymous
같은 null 이름도 직접 반환과 map의 결과가 다르다동일한non-nullOptional에name=null인User가있는반례를두독립식에적용한다. 직접name은null이고map의null결과는empty여서orElse기본값을반환한다. 5노드4값변환관계이고mobile동일,원main의Min입력을바꾼실제출력이나상호순차실행을뜻하지않는다. 이름빈String은empty변환대상이아니다.같은 Optional<User>User 있음 · name null직접 isPresent 분기get().name()null 그대로 반환User 부재가 아님map(User::name)null → emptyorElse 기본값 반환anonymous
직접 반환과 map 치환이 달라지는 null 경계
조회 상태isPresent 분기에서 name 반환map 뒤 orElse 반환
User 있음·이름 있음이름이름
User 있음·이름 nullnulldefaultValue
User 없음defaultValuedefaultValue
User 있음·이름 있음
isPresent 분기에서 name 반환
이름
map 뒤 orElse 반환
이름
User 있음·이름 null
isPresent 분기에서 name 반환
null
map 뒤 orElse 반환
defaultValue
User 없음
isPresent 분기에서 name 반환
defaultValue
map 뒤 orElse 반환
defaultValue

같은 non-null Optional과 같은 defaultValue를 두고 비교한 API 의미입니다. 이름이 null인 행은 아래 main의 입력이 아니며, 기본값을 만드는 식에 부수 효과가 있다면 인수 평가 시점도 따로 확인해야 합니다.

이 예의 조건

  • name=null인 User를 담은 동일한 non-null Optional을 두 독립 식에 적용하는 반례입니다. 원 main의 Min 출력이 아닙니다.
  • 이름 빈 문자열은 null이 아니므로 map이 empty로 바꾸지 않습니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2동일한 non-null Optional에 name=null인 User가 있는 반례를 두 독립 식에 적용한다. 직접 반환한 name은 null이고 map의 null 결과는 empty여서 orElse 기본값을 반환한다. 5개 노드와 4개의 값 변환 관계이며 mobile도 동일하다. 원 main의 Min 입력을 바꾼 실제 출력이나 서로 순차적으로 실행하는 관계를 뜻하지 않는다. 이름이 빈 String이면 empty 변환 대상이 아니다.
원 도표의 설명과 주석
구분내용
설명동일한 non-null Optional에 name=null인 User가 있는 반례를 두 독립 식에 적용한다. 직접 반환한 name은 null이고 map의 null 결과는 empty여서 orElse 기본값을 반환한다. 5개 노드와 4개의 값 변환 관계이며 mobile도 동일하다. 원 main의 Min 입력을 바꾼 실제 출력이나 서로 순차적으로 실행하는 관계를 뜻하지 않는다. 이름이 빈 String이면 empty 변환 대상이 아니다.
전체 읽기 정보직접 반환과 map 치환이 달라지는 null 경계 직접 반환과 map 치환이 달라지는 null 경계 직접 반환과 map 치환이 달라지는 null 경계 리팩터링 전후의 null 의미 직접 반환과 map 치환이 달라지는 null 경계 User는 있어도 name이 null일 수 있습니다. 직접 반환은 null을 남기지만 Optional.map은 empty로 바꿔 기본값을 선택합니다. 직접 반환과 map 치환이 달라지는 null 경계 조회 상태 isPresent 분기에서 name 반환 map 뒤 orElse 반환 User 있음·이름 있음 이름 이름 User 있음·이름 null null defaultValue User 없음 defaultValue defaultValue User 있음·이름 있음 isPresent 분기에서 name 반환 이름 map 뒤 orElse 반환 이름 User 있음·이름 null isPresent 분기에서 name 반환 null map 뒤 orElse 반환 defaultValue User 없음 isPresent 분기에서 name 반환 defaultValue map 뒤 orElse 반환 defaultValue 같은 non-null Optional과 같은 defaultValue를 두고 비교한 API 의미입니다. 이름이 null인 행은 아래 main의 입력이 아니며, 기본값을 만드는 식에 부수 효과가 있다면 인수 평가 시점도 따로 확인해야 합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
같은 Optional 입력

직접 분기와 map 식 모두 User가 있고 그 name만 null인 입력에서 출발합니다.

  • 직접 식 입력: isPresent=true, name=null
  • map 식 입력: User 존재, name=null
  • User 부재: 두 입력 모두 아님
  • 객체의 존재와 내부 필드의 null을 별개 조건으로 고정합니다.
각 필드 추출과 변환

직접 식은 get().name()을 읽고 map은 User::name의 null 결과를 empty로 바꿉니다.

  • 직접 추출: null 값 그대로
  • map 변환: 매퍼 null → Optional.empty
  • 빈 문자열 조건: null이 아니므로 empty로 바꾸지 않음
  • map의 null 정규화가 직접 필드 반환과 다른 후속 선택을 만듭니다.
각 식의 반환

직접 식은 null을 반환하고 map 식은 empty의 orElse 기본값을 반환합니다.

  • 직접 반환: null
  • map·orElse 반환: anonymous
  • 차이 조건: User가 존재하고 name=null인 경우
  • 두 독립 식의 관측 차이이며 직접 반환 뒤 map이 실행되는 순서가 아닙니다.

예시 같은 non-null Optional에 이름이 null인 User가 들어 있다고 정합니다. isPresent 분기의 get().name()은 null을 그대로 반환하지만 map(User::name)은 매퍼의 null을 empty로 바꿔 orElse의 기본값을 선택합니다. User 부재를 다루는 두 식이 모든 필드값에서 같은 것은 아닙니다. 예제의 main의 이름 Min은 null이 아니므로 이 반례가 예제의 출력이라는 뜻은 아닙니다.

독립 확인: 이름 "",null,"Min" 세 User가 각각 있는 경우와 User가 없는 경우를 별도로 비교합니다. 직접 분기 반환과 map(...).orElse("anonymous")가 달라지는 입력은 무엇인가요? 이름이 빈 문자열인 상태를 map만으로 거절하나요?

풀이 확인

null 이름이 있는 User에서만 직접 반환 null과 map의 anonymous가 다릅니다. 빈 문자열은 두 식 모두 그대로 반환하며 Min도 같습니다. User가 없으면 둘 다 anonymous입니다. 빈 문자열까지 거절하려면 명시적 검증 또는 filter 조건이 추가로 필요하고, 그 정책 변경을 단순 문법 치환이라고 설명해서는 안 됩니다.

복잡한 else 분기에서 여러 명령을 수행한다면 ifPresentOrElse 또는 명시적 분기가 더 읽기 좋습니다.

Optional 변수에 null을 대입하면 이중 부재가 생기므로 Optional 참조 자체는 항상 null이 아니어야 합니다.

메서드가 Optional을 반환하기로 했으면 null 대신 빈 Optional, 즉 Optional.empty()를 반환합니다.

src/OptionalPipelineBestPractice.java
import java.util.Map;
import java.util.Optional;

public final class OptionalPipelineBestPractice {
    private record User(long id, String name, boolean active) {}

    private static Optional<User> find(Map<Long, User> users, long id) {
        return Optional.ofNullable(users.get(id));
    }

    public static void main(String[] args) {
        Map<Long, User> users = Map.of(1L, new User(1, "Min", true));
        String name =
                find(users, 1)
                        .filter(User::active)
                        .map(User::name)
                        .map(String::toLowerCase)
                        .orElse("inactive-or-missing");
        System.out.println(name);
    }
}

존재 여부 검사와 값 사용 사이에 별도의 가변 상태가 없습니다.

Map 미적중과 비활성 상태를 같은 레이블로 합치는 것이 실제 요구 사항인지 확인합니다.


기본형 Optional과 스트림

OptionalInt, OptionalLong, OptionalDouble은 기본형 값 하나의 부재를 표현합니다.

IntStream.average()는 OptionalDouble, min은 OptionalInt를 반환합니다.

이 타입들은 제네릭 Optional과 API가 완전히 같지 않으며 map·flatMap 메서드를 제공하지 않습니다.

app/PrimitiveOptionalScore.java
import java.util.List;
import java.util.OptionalInt;

public final class PrimitiveOptionalScore {
    static OptionalInt maximumPassingScore(List<Integer> scores) {
        return scores.stream().mapToInt(Integer::intValue).filter(score -> score >= 60).max();
    }

    public static void main(String[] args) {
        System.out.println(maximumPassingScore(List.of(30, 80, 70)).orElse(0));
        System.out.println(maximumPassingScore(List.of(20, 40)).orElse(0));
    }
}

출력은 80과 0입니다.

이 예제는 60점 이상만 선택하므로 반환된 0을 실제 합격 점수와 혼동하지 않습니다. 선택 조건이 달라져 0도 유효해진다면 OptionalInt를 유지하거나 별도 부재 정책을 정해야 합니다.

현재 30·80·70에서 필터를 지난 값 80·70의 최댓값은 80입니다. 20·40은 통과값이 없어 max가 empty이고 orElse가 0을 만듭니다. max 자체가 0을 계산한 것은 아닙니다. 빈 입력과 모두 60 미만인 입력은 같은 empty지만, 조건을 >=0으로 바꾼 [0]은 값이 있는 OptionalInt(0)입니다.


Optional을 쓰지 않을 선택 기준

컬렉션 결과에는 빈 컬렉션, 명령의 성공·실패 원인이 여러 개라면 Result 계층, 반드시 존재해야 하는 값에는 일반 타입과 입력 검사, 계산 실패에는 예외가 적합합니다.

성능에 민감한 기본형 반복문에서는 특수값이 도메인의 실제 값과 겹치지 않는지 확인한 뒤 별도 API를 사용할 수 있습니다.

엔터티 필드와 DTO에 Optional을 넣으면 라이브러리 지원을 확인합니다.

Java 직렬화 용도가 아니라도 JSON 매퍼가 원하는 표현으로 처리하는지 확인합니다.

프레임워크를 우회하려고 게터마다 null을 반환하면 타입 규칙이 다시 무너집니다.


Optional 반환 메서드 이름과 문서

find, 조회, maybe 계열 이름은 부재 가능성을 암시하지만 타입이 이미 설명합니다.

빈 상태가 “없음”, “권한 없음”, “필터 탈락” 중 무엇인지 Javadoc과 도메인 예외로 구분합니다.

정보 노출 때문에 의도적으로 같은 빈 상태를 반환할 수도 있습니다.


연습 문제

문서 slug를 받아 태그 목록을 반환하되 문서가 없거나 태그가 없으면 빈 불변 List를 반환하세요.

Optional<List<String>>와 null을 사용하지 마세요.

정답과 컬렉션 원소 수

Map.get 결과를 Optional로 내부 처리하되 public 반환은 0개 이상을 나타내는 List 하나입니다.

exercise/DocumentTagsSolution.java
import java.util.List;
import java.util.Map;
import java.util.Optional;

public final class DocumentTagsSolution {
    private record Document(String slug, List<String> tags) {
        private Document {
            tags = List.copyOf(tags);
        }
    }

    static List<String> tags(Map<String, Document> documents, String slug) {
        return Optional.ofNullable(documents.get(slug)).map(Document::tags).orElseGet(List::of);
    }

    public static void main(String[] args) {
        Map<String, Document> documents =
                Map.of("stream", new Document("stream", List.of("java", "api")));
        System.out.println(tags(documents, "stream"));
        System.out.println(tags(documents, "missing"));
    }
}

존재하는 문서는 두 태그, 미적중은 빈 List를 반환합니다.

Document 생성자가 태그 스냅샷을 만들어 외부 변경도 차단합니다.

독립 확인: 태그 연습에서 기존 stream 문서는 유지하고 empty라는 문서를 빈 태그 List로 추가합니다. missing과 empty의 반환값이 어떻게 같나요? 별도로 가변 원본 태그 [java]로 Document를 만든 뒤 원본에 api를 추가하면 Document의 태그가 늘어나나요? 반환 List에 직접 api를 추가하는 호출은 허용되나요?

풀이 확인

missing·empty는 모두 빈 불변 List이며 이 반환 계약으로 문서 존재 여부를 구분하지 않습니다. List.copyOf가 생성 시 원본 목록 구조를 복사하므로 원본의 뒤 add는 Document의 [java]에 반영되지 않습니다. 반환 List의 add는 UnsupportedOperationException입니다. String 태그를 복사한 이 예제에서의 목록 구조 보호를 일반 가변 원소의 깊은 복사로 확대하지 않습니다.

Optional을 올바르게 사용하는 핵심은 사용 개수를 무조건 줄이는 것이 아니라 적절한 경계에 두는 것입니다.

0개 또는 1개를 반환할 때는 Optional, 0개 이상에는 컬렉션, 필수값에는 일반 타입, 여러 결과 상태에는 이름을 붙인 결과 타입을 사용합니다.