None을 둘러싼 헛소동: Python의 센티널 값

Much Ado About None: Sentinel Values in Python

florianwilhelm.info ▲ 71 댓글 46 sebg

요약

Python의 None은 뜻이 너무 많아서, 값이 없다는 사실을 정확히 나타내려면 센티널 값이 필요합니다.

플로리안 빌헬름이 쓴 글입니다. 그는 PEP 661 승인으로 Python 3.15에 내장 sentinel이 들어온 것을 계기로, 센티널 값(정상 값의 범위 밖에 두는 특별한 표시값)을 왜 써야 하는지 정리했습니다.

왜 중요한가

  • 직접 만든 센티널의 약점이던 타입 검사 문제를 언어 차원에서 해결해, object()나 Enum을 쓰던 편법을 걷어낼 수 있습니다.
  • 함수 인자에서 '값을 넘기지 않음'과 '값을 지움'을 구분해야 하는 수정용 API를 더 명확하게 설계할 수 있습니다.
  • 다만 타입 검사기 지원이 아직 따라오는 중이라, 도입 전에 쓰는 도구를 확인해야 합니다.

핵심 내용

  • None은 '없음', '설정 안 됨', '오류', 실제 값을 모두 뜻할 수 있어서 코드만 봐서는 의도를 구분하기 어렵습니다.
  • 예를 들어 timeout=None은 '시간 제한 없음'일 수도, '현재 값 유지'일 수도 있습니다.
  • 흔히 쓰는 MISSING = object()는 실행은 되지만, 타입 검사기가 타입을 좁혀 판단하지 못합니다.
  • 이런 센티널은 복사하거나 피클링(객체 직렬화)하면 is 비교가 깨지는 문제도 있습니다.
  • 3.15부터 sentinel은 내장 기능이 되어 타입 검사기가 None처럼 다룰 수 있고, 이전 버전은 typing_extensions로 쓸 수 있습니다.

HN 반응

  • 글의 캐시 예시를 두고, 없는 키라면 KeyError를 내야 하며 dict.get을 고른 탓이라는 지적이 이어졌습니다.
  • Python의 Optional은 중첩되지 않는다며 JSON과 SQL 사례가 나왔지만, API 설계부터 고치라는 반론도 있었습니다.

댓글

30개 표시 · 전체 46개
  1. RantyDave HN

    "단순한 캐시는 키가 없을 때도, 키가 None에 매핑돼 있을 때도 None을 반환합니다."

    아뇨, KeyError를 반환하죠. 적어도 그래야 하고요.

  2. travisd HN

    별로 "파이썬답지" 않을 수는 있지만, 저희 회사에서는 정적 타입 검사를 받을 수 있는 방식을 쓰려고 예외에서 아주 적극적으로 벗어나는 중입니다. 타입 검사에서 잡혀야 할 버그가 런타임 버그가 되는 경우가 너무 많거든요.

    이제 match를 쓸 수 있게 돼서, 명시적인 에러 객체를 반환하기도 하는데 쓰기 편한 편입니다(러스트가 떠오르더군요).

  3. nomel HN

    이 둘이 무슨 상관이죠? 예외를 안 쓰면 에러를 스택 맨 위까지 손수 전달하는 코드를 직접 구현해야 할 뿐이잖아요. 그걸 위해 마련된 별도 통로를 두고요.

    제가 보기에 문제는 에러를 실제로 처리했는지, 위로 넘겼는지를 강제로 검사할 방법이 없다는 겁니다. 예외는 처리하지 않으면 그냥 죽어버리니, 결정론적인 동작이 목표라면 훨씬 낫죠. 타입 시스템으로 이걸 해결할 방법이 있을까요? 들어오는 값은 UnhandledResult, 나가는 값은 HandledResult 같은 걸로 하면 되려나요?

  4. aw1621107 HN

    반환값을 쓸 생각이라면 러스트의 Result나 하스켈의 Either 같은 타입에 해당하는 것을 반환하는 게 보통입니다. 반환 타입들이 우연히 같은 API를 공유하지만 않는다면, 반환값에 따라 분기하지 않았을 때 타입 검사기가 경고를 띄울 겁니다.

    다만 파이썬에는 C++의 [[nodiscard]]나 러스트의 #[must_use]에 해당하는 게 없는 걸로 알고 있어요. 그래서 반환값을 무시해도 되는 경우가 정당하게 존재한다면, 예외였다면 드러났을 에러를 반환값 방식에서는 놓칠 위험이 있습니다.

  5. lyorig HN

    글쓴이가 API를 그렇게까지 깊이 고민하지 않은 라이브러리들을 꽤 많이 겪어 봤나 보네요. 그래도 맞아요, 그래야 합니다.

  6. nomel HN

    정말 많은 사람이 구글이 예외를 안 쓴다는 이유로 예외는 나쁘니 피해야 한다고 배웠죠.

  7. deathanatos HN

    예외에 대한 반사적인 반감은 구글이 나쁘다고 말하기 훨씬 전부터 있었어요. 물론 구글이 거기에 도움이 된 건 아니지만요.

  8. IshKebab HN

    정말 많은 사람이 예외는 나쁘니 피해야 한다고 배운 건 예외가 실제로 나쁘고 피해야 하기 때문입니다. 그중에 구글 직원도 있는 거고요.

  9. 0cf8612b2e1e HN

    글쓴이의 예시는 하필 값이 없으면 None을 반환하는 문법을 골랐네요. 명확하게 하고 싶었다면 표준 API는 예외를 발생시킵니다.

      cache = {“key”: “value”, “some input”: None}
      cache.get(“missing_key”)
    

    반면에

      cache[“missing_key”]
    

    더 좋은 예시를 들 수 있었을 텐데요.

  10. pwdisswordfishq HN

    옵셔널 모나드를 도입하지 않으려고 참 많이도 고생하네요.

  11. woodruffw HN

    파이썬 타입 시스템에도 유니언 타입(Option 포함)이 있고, 쓰기도 꽤 편합니다. 기사에서 지적하듯 문제는 None이 제대로 된 센티널 타입이 더 적합한 온갖 곳에서 문맥에 따라 재사용된다는 점입니다.

  12. aw1621107 HN

    파이썬의 유니언 타입은 중첩이 안 되는 걸로 알고 있습니다. 옵셔널을 중첩할 수 있다면 별도의 센티널 타입이 필요한 경우가 줄어들 것 같아요. 예를 들어 "값이 없음"(None)과 "null 값이 있음"(Some(None))을 구분할 수 있으니까요.

  13. amluto HN

    맞아요. Option[Foo]는 사실상 Foo | None이라는 뜻일 뿐이라서, 런타임에는 Option[Foo]를 가질 수 없습니다. 근본적으로 파이썬의 정적 타입 시스템이 설명하는 언어는 파이썬과 어딘가 어설프게 맞아떨어지는 언어예요.

    List[Foo]도 재미있는 사례입니다. 런타임의 파이썬에는 리스트가 있지만 리스트에게 어떤 타입의 리스트인지 물어볼 수 없고, isinstance(a, List[Foo])라고 쓸 수도 없죠.

  14. ozyschmozy HN

    중첩된 옵셔널 타입이 뭔지 설명해 주시거나 예시를 들어 주실 수 있나요? 어떻게 말이 되는 건지 잘 모르겠어요.

  15. aw1621107 HN

    옵셔널 값은 어떤 값이거나 값이 없거나 둘 중 하나인데, 때로는 안쪽의 "어떤 값"이 그 자체로 옵셔널 값일 수 있다는 발상입니다(러스트로는 Option<Option<T>>, C++로는 std::optional<std::optional<T>>, 파이썬이라면 가상으로 Option[Option[T]] 같은 식입니다). 이런 상황에서는 가능한 상태가 세 가지입니다.

    • 안쪽 값이 있고, 그 값이 null이 아닌 무언가(정수, 문자열, 또 다른 옵셔널 등)인 경우

    • 안쪽 값이 있고, 그 값이 null인 경우

    • 안쪽 값이 없는 경우

    제가 이해하기로 문제는 파이썬에서 Option[T]가 유니언 타입 T | None과 같고, 유니언 타입은 평탄화되며(예: (T | U) | V는 T | U | V와 같음) 중복은 무시된다는(예: T | U | T는 T | U와 같음) 점입니다. 그래서 Option[Option[T]]는 (T | None) | None과 같고, 이는 T | None | None과 같으며, 결국 T | None과 같아집니다. "안쪽 값이 없음"과 "안쪽 값은 있지만 None임"의 구분이 사라지는 거죠.

    좀 억지스럽긴 하지만 더 구체적인 예를 들어 보겠습니다. 문서 ID로 그 문서의 소유자를 조회하는 API가 있고, 그 문서 ID가 실제 문서를 가리킬 수도 있고 아닐 수도 있다고 합시다(예를 들어 캐시에서 가져온 ID인데 그 뒤에 문서가 삭제됐을 수 있습니다). 이를 표현하는 한 가지 방법은 (러스트 문법으로)

        fn get_owner(id: DocumentId) -> Option<Option<UserId>> { ... }
    

    입니다. None이 오면 문서가 존재하지 않는 것이고, Some(None)이 오면 문서는 있지만 소유자가 없는 것이며, Some(<id>)가 오면 문서가 있고 소유자도 있는 겁니다.

    (새 enum을 만들면 더 잘 처리할 수 있겠지만, 예시일 뿐이니 좀 봐주세요.)

    이 시그니처를 파이썬으로 순진하게 옮기면 이렇게 됩니다.

        def get_owner(id: DocumentId) -> Option[Option[UserId]]: pass
    

    하지만 타입 검사기에게는 이게 다음과 같습니다.

        def get_owner(id: DocumentId) -> UserId | None: pass
    

    그래서 원문 글쓴이가 말한 난처한 상황에 빠집니다. None을 받을 수는 있는데, 그게 "문서 없음"인지 "소유자 없음"인지 더는 알 수 없게 되는 거죠. 파이썬식 해법은 바깥쪽 None(원문의 MISSING처럼)이든 안쪽 None(NO_OWNER 같은 것)이든 한쪽에 별도의 이름을 만들어 주는 겁니다.

  16. FreakLegion HN

    원하기만 하면 대부분의 언어에서 할 수 있습니다. 파이썬에서는 이렇게 됩니다.

        class Some[T]:
            value: T
    
    type Option[T] = Some[T] | None
    
    def get_owner(id: DocumentId) -> Option[Option[UserId]]: ...
    
    match get_owner(document_id):
            case None:
                ...
            case Some(None):
                ...
            case Some(Some(x)):
                ...
    
  17. aw1621107 HN

    네, 시그니처만이 아니라 개념 전체를 파이썬으로 옮기려 한다면 그쪽이 더 맞는 방식이죠. 그 대안도 같이 언급했어야 했네요(아니면 "파이썬식 해법 중 하나"라고 썼어야 했거나요). 흥미롭게도 이 방식이 실제로 쓰이는 건 본 적이 없긴 한데, 저는 여기 다른 댓글 작성자들만큼 현업 파이썬 코드를 잘 아는 편은 아니라서요.

    이름이 겹치는 건 아쉽지만, 다행히 이름 짓기는 컴퓨터 과학에서 가장 쉬운 문제로 유명하니 금방 해결되겠죠 :P

  18. ozyschmozy HN

    그렇군요, 감사합니다. 그래도 이런 건 Some(None)을 반환하는 것보다 항상 더 나은 대안이 있을 것 같아요. 새 enum을 만들거나, 파이썬이라면 예외를 쓰는 식으로요(예를 들어 문서가 없을 때 값을 반환하는 대신 DocumentMissingError 같은 걸 발생시키는 거죠).

  19. what HN

    이건 정말 어이없게 느껴지는데요. get_owner가 왜 "문서가 있고 소유자가 있을 수도 있다"와 "문서가 없어서 소유자도 없다"를 구분해야 하죠? 두 경우 모두 소유자 없음이라고 하면 충분하지 않나요? 왜 get_owner로 문서가 있는지를 알아내려 하는 건가요? 이 구분이 실제로 중요한 예가 있나요?

  20. setr HN

    SQL에서 LEFT JOIN을 할 때마다, 그리고 관심 있는 필드가 null을 허용할 때마다 이 문제를 겪습니다.

    오른쪽 값이 null인 이유가 행이 없어서일 수도 있고, 실제로 값이 null이어서일 수도 있죠. 조인 키를 확인해서 해결하는데, 그 키 자체가 null을 허용한다면 센티널을 도입할 수밖에 없습니다.

  21. aw1621107 HN

    두 경우 모두 소유자 없음이라고 하면 충분하지 않나요?

    그 정보로 정확히 무엇을 할 계획이냐에 달렸겠죠. "문서 없음"과 "소유자 없음"을 반드시 똑같이 처리해야 한다고는 생각하지 않습니다(예를 들어 갱신된 문서를 찾아 다시 시도하는 것과 작업 불허 에러를 반환하는 것은 다르니까요).

    왜 get_owner로 문서가 있는지를 알아내려 하는 건가요?

    아마 TOCTOU 문제 때문일 수도 있겠네요. 앞서 말했듯 문서가 어느새 사라졌을 수 있는데, 이런 상황을 감지하면 적절하게 대처할 수 있을 겁니다(공정하게 말하면 이 논리는 이후 작업에도 확장될 수 있겠지만요).

    버전 관리되는 파일 시스템이 좀 더 맞는 예였을지도 모르겠네요. 거기서는 None이 새 버전이 있다는 뜻이 될 테니까요. 키가 없을 수도 있는 JSON에서 키-값 쌍을 가져오는 경우도 다른 예로 언급된 걸 본 적이 있습니다.

    어쨌든 제가 분명히 좀 억지스러운 예라고 했으니 좀 봐주세요 :P 그냥 제일 먼저 떠오른 게 그거였어요.

    같은 층의 다른 댓글들이 든 예가 아마 더 실용적일 겁니다.

  22. what HN

    "문서 없음"과 "소유자 없음"을 반드시 똑같이 처리해야 한다고는 생각하지 않습니다(예를 들어 갱신된 문서를 찾아 다시 시도하는 것과 작업 불허 에러를 반환하는 것은 다르니까요).

    문서가 있는지가 중요하다면, 문서와 관련된 모든 API가 문서의 존재 여부를 알려 주리라 기대할 게 아니라 그것만 전담해서 알려 주는 API를 써야죠. 안 그러면 의미를 과적하는 겁니다. 존재하지 않는 문서에는 소유자가 없습니다. 문서의 소유자를 가져오는 API는 문서가 존재하는지 신경 쓰지 않아야 하고, 그건 중요하지 않아요.

  23. aw1621107 HN

    말씀드렸듯이 그건 TOCTOU 오류를 낳을 수 있는 방식으로 들립니다.

    그리고 다시 말하지만, 이건 즉흥적으로 든 예시고 처음부터 억지스럽다고 분명히 밝혔어요. 말이 되게 다듬을 수도 있겠지만, 사소한 걸로 끝없이 논쟁하느니 같은 층의 다른 댓글을 참고하는 편이 더 쉬울 것 같습니다.

  24. what HN

    그런가요? 문서가 삭제됐거나 그냥 존재하지 않으면 소유자 없음을 반환하는 건데, 그게 정확하잖아요. 왜 get_owner가 문서가 있는지 없는지까지 알려 줘야 하죠? 오히려 문서가 없으면 사전 조건을 어긴 것이니 에러가 되어야 합니다.

  25. aw1621107 HN

    문서가 삭제됐거나 그냥 존재하지 않으면 소유자 없음을 반환하는 건데, 그게 정확하잖아요.

    "모르겠음"은 "확실히 소유자 없음"과 같지 않습니다. 특정 상황에서는 두 답을 똑같이 처리할 수도 있지만, 그렇지 않을 수도 있어요.

    다시 get_owner가 일종의 캐시 위에서 동작하는 경우를 생각해 보세요(그렇다면 물론 함수 이름은 개선할 여지가 있겠죠). 문서가 캐시에서 사라졌다면 실제로 삭제돼서일 수도 있고, 다른 항목에 자리를 내주려고 그냥 축출돼서일 수도 있습니다. 일반적으로 캐시 항목이 없다는 걸 문서가 존재하지만 소유자가 없다는 것과 같다고 가정할 수는 없어요.

    비슷하게(그리고 또다시), get_owner가 버전 관리되는 파일 시스템 위에서 동작하는 경우를 생각해 보세요. 이때 바깥쪽 옵셔널은 (세대별) 문서 ID가 오래됐다는 뜻일 수 있습니다. 그런 경우에는 "모르겠음"과 "소유자 없음"을 같게 취급하면 틀린 처리입니다. 소유자가 "없음"에서 다른 값으로 바뀌었을 수 있으니까요(다만 파일 시스템 핸들이 낡아지는 방식이 그 외에도 무수히 많은 만큼, 이런 경우는 아마 Result<Option<UserId>, SomeErrorKind>를 반환하는 쪽이 현실적으로 더 낫겠죠).

    왜 get_owner가 문서가 있는지 없는지까지 알려 줘야 하죠?

    가끔은 좋은 대안이 없기 때문입니다. 캐시에 항목이 있는지 먼저 묻고 항목 값은 별도 작업으로 가져오는 건(특히 동시성 환경에서는) TOCTOU 오류의 전형적인 원인이에요. 버전 관리되는 파일 시스템 예시도 마찬가지고요.

    오히려 문서가 없으면 사전 조건을 어긴 것이니 에러가 되어야 합니다.

    그런 처리를 가능하게 하는 게 바로 바깥쪽 옵셔널의 존재 이유 아닌가요?

  26. Akronymus HN

    다른 예시가 당신의 사고 모델에 더 잘 맞을 수도 있겠네요.

    사용자가 커스텀 설정을 지정할 수 있고, 그 설정에는 값이 지정돼 있을 수도 있고 아닐 수도 있습니다. 예를 들어 어떤 설정은 최적화 수준이고, 다른 설정은 디버그 플래그입니다.

    어떤 중간 시스템을 거쳐 설정 정보를 얻는 통합 인터페이스를 만들고 싶다고 합시다(여기서는 레거시 제약 때문에 전체 목록을 가져오지 못하고 특정 설정만 조회할 수 있다고 칩시다). 어떤 설정은 존재하는지만 중요하고 다른 설정은 실제 값이 중요하기 때문에, 값이 들어 있는 설정, 비어 있는 설정, 존재하지 않는 설정을 구분해야 합니다.

  27. __s HN

    nullable 컬럼을 select해서 vec(Vec<Option<T>>)에 담는다고 해 봅시다. vec.get은 Option<Option<T>>를 반환하는데, 이게 null과 범위 초과를 구분해 줍니다.

    ClickHouse에서도 중첩 Nullable이 지원되면 좋겠어요. CoalescingMergeTree로 포스트그레스의 변경 없는 TOAST 값을 null과 헷갈리지 않고 쉽게 처리하고 싶거든요.

  28. david2ndaccount HN

    실용적인 코드에서는 필요 없다는 게 답입니다. 다만 제네릭 코드에서는 이미 옵셔널을 반환하는 것을 내 옵셔널로 감싸고 싶을 때 생겨요. optional[optional[whatever]]를 지원하는 언어에서도 그런 걸 실제로 직접 쓰고 있다면 API를 잘못 설계했다는 뜻이고요.

  29. masklinn HN

    실용적인 비제네릭 코드에서도 분명히 필요합니다. 저는 구조체에서 키가 없는 것과 키가 null로 설정된 것을 구분하는 JSON API를 정기적으로 만납니다.

  30. Waterluvian HN

    파이썬에서는 그게 실제로 어떻게 생겼을지 궁금하네요.

    이건 바이브 코딩을 잘 활용할 만한 일 같아요. 가정을 걸고 실험해 보는 거죠. 말로 설명하는 대신 "직접 보여 주는" 식으로, 댓글 속 의견의 잡음 대비 신호 비율을 줄일 수 있을 것 같습니다.

Hacker News에서 보기 ↗