Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

PEP 675 – 임의의 리터럴 문자열 타입

Author:
Pradeep Kumar Srinivasan <gohanpra at gmail.com>, Graham Bleaney <gbleaney at gmail.com>
Sponsor:
Jelle Zijlstra <jelle.zijlstra at gmail.com>
Discussions-To:
Typing-SIG thread
Status:
Final
Type:
Standards Track
Topic:
Typing
Created:
30-Nov-2021
Python-Version:
3.11
Post-History:
07-Feb-2022
Resolution:
Python-Dev message

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

Important

This PEP is a historical document: see LiteralString and typing.LiteralString for up-to-date specs and documentation. Canonical typing specs are maintained at the typing specs site; runtime typing behaviour is described in the CPython documentation.

×

See the typing specification update process for how to propose changes to the typing spec.

초록

현재는 타입 어노테이션으로 함수 매개변수가 임의의 리터럴 문자열 타입일 수 있다고 지정할 방법이 없습니다. Literal["foo"]와 같이 정확한 리터럴 문자열 타입을 지정해야 합니다. 이 PEP는 리터럴 문자열 타입들의 상위 타입인 LiteralString을 도입합니다. 이를 통해 함수는 Literal["foo"]나 Literal["bar"]와 같은 임의의 리터럴 문자열 타입을 받을 수 있습니다.

동기

SQL이나 셸 명령을 실행하는 강력한 API는 사용자가 제어하는 임의의 문자열이 아니라 리터럴 문자열로 호출할 것을 권장하는 경우가 많습니다. 그러나 이 권장 사항을 타입 시스템으로 표현할 방법이 없으므로, 개발자가 이를 따르지 않아 보안 취약점이 발생하기도 합니다. 예를 들어, 데이터베이스에서 사용자 레코드를 조회하는 단순한 방법은 사용자 id를 받아 미리 정의된 SQL 쿼리에 삽입하는 것입니다:

def query_user(conn: Connection, user_id: str) -> User:
    query = f"SELECT * FROM data WHERE user_id = {user_id}"
    conn.execute(query)
    ...  # Transform data to a User object and return it

query_user(conn, "user123")  # OK.

그러나 사용자가 제어하는 데이터인 user_id가 SQL 명령 문자열과 섞이므로, 악의적인 사용자가 임의의 SQL 명령을 실행할 수 있습니다:

 # Delete the table.
 query_user(conn, "user123; DROP TABLE data;")

 # Fetch all users (since 1 = 1 is always true).
 query_user(conn, "user123 OR 1 = 1")

이러한 SQL 인젝션 공격을 막기 위해 SQL API는 매개변수화된 쿼리를 제공합니다. 매개변수화된 쿼리는 실행되는 쿼리와 사용자가 제어하는 데이터를 분리하여 임의의 쿼리를 실행할 수 없게 합니다. 예를 들어, sqlite3에서는 원래 함수를 매개변수가 있는 쿼리로 안전하게 작성할 수 있습니다:

def query_user(conn: Connection, user_id: str) -> User:
    query = "SELECT * FROM data WHERE user_id = ?"
    conn.execute(query, (user_id,))
    ...

문제는 이 규율을 강제할 방법이 없다는 것입니다. sqlite3 자체의 문서조차 외부 입력으로 sql 인자를 동적으로 만들지 말라고 독자에게 당부할 수 있을 뿐이며, API 작성자는 이를 타입 시스템으로 표현할 수 없습니다. 사용자는 여전히 이전처럼 편리한 f-문자열을 사용할 수 있고(실제로 자주 그렇게 하며), 그 결과 코드가 SQL 인젝션에 취약한 상태로 남습니다.

널리 쓰이는 보안 린터인 Bandit 같은 기존 도구는 AST를 검사하거나 그 밖의 의미론적 패턴 매칭을 사용해 SQL API에 쓰이는 안전하지 않은 외부 데이터를 탐지하려고 합니다. 그러나 이러한 도구는 큰 여러 줄 쿼리를 실행하기 전에 변수에 저장하거나, 조건에 따라 쿼리에 리터럴 문자열 수식어를 추가하거나, 함수를 사용해 쿼리 문자열을 변환하는 등의 흔한 관용구를 허용하지 않습니다. (기존 도구는 Rejected Alternatives 절에서 살펴봅니다.) 예를 들어, 많은 도구가 다음과 같은 무해한 코드 조각에서 거짓 양성을 탐지합니다:

def query_data(conn: Connection, user_id: str, limit: bool) -> None:
    query = """
        SELECT
            user.name,
            user.age
        FROM data
        WHERE user_id = ?
    """
    if limit:
        query += " LIMIT 1"

    conn.execute(query, (user_id,))

우리는 사용자가 제어하는 데이터가 해롭게 실행되는 것은 금지하면서도, 위와 같은 무해한 관용구는 계속 허용하고 사용자에게 추가 작업을 요구하지 않기를 원합니다.

이 목표를 달성하기 위해, 리터럴로 이루어졌다고 알려진 문자열 값만 받는 LiteralString 타입을 도입합니다. 이는 PEP 586의 Literal["foo"] 타입을 일반화한 것입니다. LiteralString 타입의 문자열에는 사용자가 제어하는 데이터가 포함될 수 없습니다. 따라서 LiteralString만 받는 API는 인젝션 취약점으로부터 안전합니다(실용적인 한계는 있습니다).

sqlite3의 execute 메서드가 사용자 입력으로 만들어진 문자열을 허용하지 않기를 원하므로, 해당 메서드의 typeshed 스텁이 LiteralString 타입의 sql 쿼리를 받도록 합니다:

from typing import LiteralString

def execute(self, sql: LiteralString, parameters: Iterable[str] = ...) -> Cursor: ...

이렇게 하면 앞의 안전하지 않은 SQL 예제가 성공적으로 금지됩니다. 아래의 변수 query는 user_id를 사용하는 형식 문자열로부터 생성되었으므로 str 타입으로 추론되며, execute에 전달될 수 없습니다:

def query_user(conn: Connection, user_id: str) -> User:
    query = f"SELECT * FROM data WHERE user_id = {user_id}"
    conn.execute(query)  # Error: Expected LiteralString, got str.
    ...

이 메서드는 더 복잡한 예제도 허용할 만큼 충분히 유연합니다:

def query_data(conn: Connection, user_id: str, limit: bool) -> None:
    # This is a literal string.
    query = """
        SELECT
            user.name,
            user.age
        FROM data
        WHERE user_id = ?
    """

    if limit:
        # Still has type LiteralString because we added a literal string.
        query += " LIMIT 1"

    conn.execute(query, (user_id,))  # OK

사용자가 SQL 코드를 전혀 바꿀 필요가 없었다는 점에 주목하십시오. 타입 검사기는 리터럴 문자열 타입을 추론할 수 있었고, 위반이 있는 경우에만 오류를 보고했습니다.

LiteralString은 셸 명령을 만들거나 문자열을 이스케이프하지 않고 HTML 응답으로 렌더링할 때처럼 명령과 데이터를 엄격하게 분리해야 하는 다른 경우에도 유용합니다(Appendix A: Other Uses 참고). 전반적으로, 이렇게 엄격함과 유연함을 함께 갖추었기 때문에 사용자에게 부담을 주지 않으면서도 민감한 코드에서 더 안전한 API 사용을 쉽게 강제할 수 있습니다.

사용 통계

sqlite3를 사용하는 오픈 소스 프로젝트 표본을 조사한 결과, conn.execute는 약 67%의 경우 안전한 문자열 리터럴로 호출되었고, 약 33%의 경우 잠재적으로 안전하지 않은 지역 문자열 변수로 호출되었습니다. 이 PEP의 리터럴 문자열 타입을 타입 검사기와 함께 사용하면 그 33%의 경우 중 안전하지 않은 부분(즉, 사용자가 제어하는 데이터가 쿼리에 포함되는 경우)을 막으면서도 안전한 경우는 매끄럽게 그대로 허용할 수 있습니다.

근거

먼저, 보안 취약점을 막는 데 왜 타입을 사용합니까?

문서로 사용자에게 경고하는 것만으로는 충분하지 않습니다. 대부분의 사용자는 이러한 경고를 아예 보지 못하거나 무시합니다. 기존의 동적 또는 정적 분석 방식을 사용하는 것은 지나치게 제한적입니다. Motivation 절에서 보았듯이(그리고 Rejected Alternatives 절에서 더 자세히 논의하겠지만) 이러한 방식은 자연스러운 관용구를 막습니다. 이 PEP의 타입 기반 방식은 엄격함과 유연성 사이에서 사용자 친화적인 균형을 이룹니다.

런타임 방식은 통하지 않습니다. 런타임에 쿼리 문자열은 평범한 str이기 때문입니다. 명백히 악의적인 페이로드를 정규식으로 걸러 내는 것과 같은 휴리스틱을 사용하여 일부 공격을 막을 수는 있지만, 이를 우회하는 방법은 언제나 존재할 것입니다(좋은 쿼리와 나쁜 쿼리를 완벽하게 구별하는 문제는 정지 문제로 환원됩니다).

쿼리 문자열이 리터럴 문자열 표현식인지 확인하기 위해 AST를 검사하는 것과 같은 정적 방식은 문자열이 중간 변수에 대입되는 경우나 무해한 함수에 의해 변환되는 경우를 구별하지 못합니다. 이 때문에 이러한 방식은 지나치게 제한적입니다.

놀랍게도 타입 검사기는 런타임 방식이나 정적 분석 방식에서는 얻을 수 없는 정보에 접근할 수 있으므로 두 방식보다 더 잘 해냅니다. 구체적으로, 타입 검사기는 어떤 표현식이 Literal["foo"]와 같은 리터럴 문자열 타입을 가지는지 알려 줄 수 있습니다. 타입 검사기는 이미 변수 대입이나 함수 호출을 거쳐 타입을 전파합니다.

현재의 타입 시스템만으로도, SQL 또는 셸 명령 실행 함수가 가능한 입력 문자열 세 개만 받는다면 우리의 일은 끝났을 것입니다. 다음과 같이 작성하기만 하면 됩니다:

def execute(query: Literal["foo", "bar", "baz"]) -> None: ...

하지만 물론 execute는 가능한 모든 쿼리를 받을 수 있습니다. 쿼리에 사용자가 제어하는 임의의 문자열이 포함되지 않도록 어떻게 보장할 수 있습니까?

우리는 값이 Literal[<...>] 형태의 타입이어야 한다고 명시하고자 합니다. 여기서 <...>는 어떤 문자열입니다. LiteralString이 나타내는 것이 바로 이것입니다. LiteralString은 모든 리터럴 문자열 타입의 “슈퍼타입”입니다. 사실상 이 PEP는 타입 계층에서 Literal["foo"]와 str 사이에 타입 하나를 도입할 뿐입니다. Literal["foo"]나 Literal["bar"]와 같은 특정 리터럴 문자열은 모두 LiteralString과 호환되지만, 그 반대는 성립하지 않습니다. LiteralString 자체의 “슈퍼타입”은 str입니다. 따라서 LiteralString은 str과 호환되지만, 그 반대는 성립하지 않습니다.

리터럴 타입의 Union은 Union의 각 요소가 개별적으로 LiteralString과 호환되므로 자연스럽게 LiteralString과 호환된다는 점에 유의하십시오. 따라서 Literal["foo", "bar"]는 LiteralString과 호환됩니다.

그러나 우리가 정확한 리터럴 쿼리만 표현하려는 것이 아니라는 점을 떠올려 보십시오. 우리는 query + " LIMIT 1"과 같이 두 리터럴 문자열을 조합하는 것도 지원하고자 합니다. 이 역시 위의 개념으로 가능합니다. x와 y가 LiteralString 타입의 두 값이라면, x + y 역시 LiteralString과 호환되는 타입이 됩니다. Literal["foo"]와 Literal["bar"] 같은 구체적인 사례를 살펴보면 이를 추론할 수 있습니다. 더해진 문자열 x + y의 값은 "foobar"일 수밖에 없고, 이 값의 타입은 Literal["foobar"]이므로 LiteralString과 호환됩니다. x와 y가 리터럴 타입의 유니언인 경우에도 같은 추론이 적용됩니다. x와 y에서 각각 하나씩 고른 두 리터럴 타입을 쌍으로 더한 결과는 리터럴 타입이며, 이는 전체 결과가 리터럴 타입의 Union이고 따라서 LiteralString과 호환된다는 것을 의미합니다.

이런 방식으로 Python의 Literal 문자열 타입 개념을 활용하여, API가 리터럴로 구성되었다고 알려진 문자열만 받을 수 있도록 지정할 수 있습니다. 더 구체적인 세부 사항은 나머지 절에서 설명합니다.

명세

런타임 동작

typing.NoReturn과 비슷한 구현으로 typing.py에 LiteralString을 추가할 것을 제안합니다.

LiteralString은 오직 타입 검사에만 사용되는 특수 형식이라는 점에 유의하십시오. 런타임에 type(<expr>)이 LiteralString을 반환하는 표현식은 존재하지 않습니다. 따라서 구현에서 이것을 str의 서브클래스로 지정하지 않습니다.

LiteralString을 사용할 수 있는 위치

LiteralString은 다른 타입을 사용할 수 있는 곳이라면 어디에서나 사용할 수 있습니다:

variable_annotation: LiteralString

def my_function(literal_string: LiteralString) -> LiteralString: ...

class Foo:
    my_attribute: LiteralString

type_argument: List[LiteralString]

T = TypeVar("T", bound=LiteralString)

Literal 타입의 유니언 안에 중첩할 수는 없습니다:

bad_union: Literal["hello", LiteralString]  # Not OK
bad_nesting: Literal[LiteralString]  # Not OK

타입 추론

LiteralString 추론

모든 리터럴 문자열 타입은 LiteralString과 호환됩니다. 예를 들어 "foo"는 Literal["foo"] 타입으로 추론되므로 x: LiteralString = "foo"는 유효합니다.

Rationale에 따라, 다음과 같은 경우에도 LiteralString으로 추론합니다:

  • 덧셈: x와 y가 모두 LiteralString과 호환되면 x + y는 LiteralString 타입입니다.
  • 결합: sep의 타입이 LiteralString과 호환되고 xs의 타입이 Iterable[LiteralString]과 호환되면 sep.join(xs)는 LiteralString 타입입니다.
  • 제자리 덧셈: s가 LiteralString 타입이고 x가 LiteralString과 호환되는 타입이면, s += x는 s의 타입을 LiteralString으로 유지합니다.
  • 문자열 포매팅: f-문자열은 그 구성 표현식이 리터럴 문자열인 경우, 그리고 오직 그 경우에만 LiteralString 타입입니다. s.format(...)은 s와 인자들의 타입이 LiteralString과 호환되는 경우, 그리고 오직 그 경우에만 LiteralString 타입입니다.
  • 리터럴 보존 메서드: 부록 C에 LiteralString 타입을 보존하는 str 메서드의 전체 목록을 제공합니다.

그 밖의 모든 경우에는, 조합되는 값 중 하나 이상이 리터럴이 아닌 str 타입이면 조합 결과의 타입은 str입니다. 예를 들어 s가 str 타입이면 "hello" + s는 str 타입입니다. 이는 타입 검사기의 기존 동작과 일치합니다.

LiteralString은 str 타입과 호환됩니다. str의 모든 메서드를 상속합니다. 따라서 LiteralString 타입의 변수 s가 있다면 s.startswith("hello")라고 작성해도 안전합니다.

일부 타입 검사기는 동등성 검사를 할 때 문자열의 타입을 좁힙니다:

def foo(s: str) -> None:
    if s == "bar":
        reveal_type(s)  # => Literal["bar"]

if 블록 안에서 이렇게 좁혀진 타입은 Literal["bar"] 타입이므로 LiteralString과도 호환됩니다.

예시

위 규칙을 명확히 이해하는 데 도움이 되도록 아래 예시를 참고하십시오:

literal_string: LiteralString
s: str = literal_string  # OK

literal_string: LiteralString = s  # Error: Expected LiteralString, got str.
literal_string: LiteralString = "hello"  # OK

리터럴 문자열의 덧셈:

def expect_literal_string(s: LiteralString) -> None: ...

expect_literal_string("foo" + "bar")  # OK
expect_literal_string(literal_string + "bar")  # OK

literal_string2: LiteralString
expect_literal_string(literal_string + literal_string2)  # OK

plain_string: str
expect_literal_string(literal_string + plain_string)  # Not OK.

리터럴 문자열을 사용한 결합(join):

expect_literal_string(",".join(["foo", "bar"]))  # OK
expect_literal_string(literal_string.join(["foo", "bar"]))  # OK
expect_literal_string(literal_string.join([literal_string, literal_string2]))  # OK

xs: List[LiteralString]
expect_literal_string(literal_string.join(xs)) # OK
expect_literal_string(plain_string.join([literal_string, literal_string2]))
# Not OK because the separator has type 'str'.

리터럴 문자열을 사용한 제자리(in-place) 덧셈:

literal_string += "foo"  # OK
literal_string += literal_string2  # OK
literal_string += plain_string # Not OK

리터럴 문자열을 사용하는 형식 문자열:

literal_name: LiteralString
expect_literal_string(f"hello {literal_name}")
# OK because it is composed from literal strings.

expect_literal_string("hello {}".format(literal_name))  # OK

expect_literal_string(f"hello")  # OK

username: str
expect_literal_string(f"hello {username}")
# NOT OK. The format-string is constructed from 'username',
# which has type 'str'.

expect_literal_string("hello {}".format(username))  # Not OK

리터럴 정수와 같은 다른 리터럴 타입은 LiteralString과 호환되지 않습니다:

some_int: int
expect_literal_string(some_int)  # Error: Expected LiteralString, got int.

literal_one: Literal[1] = 1
expect_literal_string(literal_one)  # Error: Expected LiteralString, got Literal[1].

리터럴 문자열에 대해 함수를 호출할 수 있습니다:

def add_limit(query: LiteralString) -> LiteralString:
    return query + " LIMIT = 1"

def my_query(query: LiteralString, user_id: str) -> None:
    sql_connection().execute(add_limit(query), (user_id,))  # OK

조건문과 조건식은 예상대로 동작합니다:

def return_literal_string() -> LiteralString:
    return "foo" if condition1() else "bar"  # OK

def return_literal_str2(literal_string: LiteralString) -> LiteralString:
    return "foo" if condition1() else literal_string  # OK

def return_literal_str3() -> LiteralString:
    if condition1():
        result: Literal["foo"] = "foo"
    else:
        result: LiteralString = "bar"

    return result  # OK

TypeVar 및 제네릭과의 상호 작용

LiteralString을 TypeVar의 상한(bound)으로 지정할 수 있습니다:

from typing import Literal, LiteralString, TypeVar

TLiteral = TypeVar("TLiteral", bound=LiteralString)

def literal_identity(s: TLiteral) -> TLiteral:
    return s

hello: Literal["hello"] = "hello"
y = literal_identity(hello)
reveal_type(y)  # => Literal["hello"]

s: LiteralString
y2 = literal_identity(s)
reveal_type(y2)  # => LiteralString

s_error: str
literal_identity(s_error)
# Error: Expected TLiteral (bound to LiteralString), got str.

LiteralString은 제네릭 클래스의 타입 인자로 사용할 수 있습니다:

class Container(Generic[T]):
    def __init__(self, value: T) -> None:
        self.value = value

literal_string: LiteralString = "hello"
x: Container[LiteralString] = Container(literal_string)  # OK

s: str
x_error: Container[LiteralString] = Container(s)  # Not OK

List 같은 표준 컨테이너는 예상대로 동작합니다:

xs: List[LiteralString] = ["foo", "bar", "baz"]

오버로드와의 상호 작용

리터럴 문자열과 오버로드가 특별한 방식으로 상호 작용할 필요는 없습니다. 기존 규칙으로 충분히 잘 동작합니다. 특정 Literal["foo"] 타입이 일치하지 않는 경우 LiteralString을 대체(fallback) 오버로드로 사용할 수 있습니다:

@overload
def foo(x: Literal["foo"]) -> int: ...
@overload
def foo(x: LiteralString) -> bool: ...
@overload
def foo(x: str) -> str: ...

x1: int = foo("foo")  # First overload.
x2: bool = foo("bar")  # Second overload.
s: str
x3: str = foo(s)  # Third overload.

하위 호환성

이전 Python 버전에서 사용할 수 있도록 typing_extensions.LiteralString을 추가할 것을 제안합니다.

PEP 586에서 언급하듯이, 타입 검사기는 “더 정교한 추론 기법을 자유롭게 실험해도 됩니다”. 따라서 리터럴 문자열로 초기화되었지만 어노테이션이 없는 변수에 대해 타입 검사기가 리터럴 문자열 타입을 추론한다면, 다음 예제는 문제없이 통과해야 합니다:

x = "hello"
expect_literal_string(x)
# OK, because x is inferred to have type 'Literal["hello"]'.

이를 통해 코드에 어노테이션을 전혀 달지 않고도 관용적인 SQL 쿼리 코드를 정밀하게 타입 검사할 수 있습니다(Motivation 섹션의 예제에서 본 것처럼).

그러나 PEP 586과 마찬가지로, 이 PEP는 위의 추론 전략을 의무화하지 않습니다. 타입 검사기가 x의 타입을 Literal["hello"]로 추론하지 않는 경우, 사용자는 x: LiteralString으로 명시적으로 어노테이션하여 타입 검사기를 도울 수 있습니다:

x: LiteralString = "hello"
expect_literal_string(x)

기각된 대안

왜 도구 X를 사용하지 않습니까?

SQL 인젝션과 같은 문제를 잡아내는 도구는 AST 기반, 함수 수준 분석, 오염 흐름(taint flow) 분석의 세 가지 유형으로 나뉘는 것으로 보입니다.

AST 기반 도구: Bandit에는 SQL 쿼리가 리터럴 문자열이 아닐 때 경고하는 플러그인이 있습니다. 문제는 Motivation 섹션에서 보았듯이, 완벽하게 안전한 SQL 쿼리 중 상당수가 문자열 리터럴로부터 동적으로 만들어진다는 점입니다. AST 수준에서는 결과로 만들어진 SQL 쿼리가 더 이상 문자열 리터럴로 나타나지 않으므로, 잠재적으로 악의적인 문자열과 구별할 수 없습니다. 이러한 도구를 사용하려면 개발자가 SQL 쿼리를 작성하는 방식을 크게 제한해야 합니다. LiteralString은 더 적은 제약으로 비슷한 수준의 안전성을 보장할 수 있습니다.

Semgrep과 pyanalyze: Semgrep은 함수 내 상수 전파를 포함한 더 정교한 함수 수준 분석을 지원합니다. 이를 통해 함수 내에서 일부 형태의 안전한 동적 SQL 쿼리를 허용하면서도 인젝션 공격을 방지할 수 있습니다. pyanalyze에도 비슷한 확장이 있습니다. 그러나 둘 다 안전한 SQL 쿼리를 구성하여 반환하는 함수 호출은 처리하지 못합니다. 예를 들어, 아래 코드 예제에서 build_insert_query는 여러 값을 해당 열에 삽입하는 쿼리를 만드는 헬퍼 함수입니다. Semgrep과 pyanalyze는 이러한 자연스러운 사용법을 금지하는 반면, LiteralString은 프로그래머에게 아무런 부담 없이 이를 처리합니다:

def build_insert_query(
    table: LiteralString
    insert_columns: Iterable[LiteralString],
) -> LiteralString:
    sql = "INSERT INTO " + table

    column_clause = ", ".join(insert_columns)
    value_clause = ", ".join(["?"] * len(insert_columns))

    sql += f" ({column_clause}) VALUES ({value_clause})"
    return sql

def insert_data(
    conn: Connection,
    kvs_to_insert: Dict[LiteralString, str]
) -> None:
    query = build_insert_query("data", kvs_to_insert.keys())
    conn.execute(query, kvs_to_insert.values())

# Example usage
data_to_insert = {
    "column_1": value_1,  # Note: values are not literals
    "column_2": value_2,
    "column_3": value_3,
}
insert_data(conn, data_to_insert)

테인트 흐름 분석: Pysa나 CodeQL 같은 도구는 사용자가 제어하는 입력에서 SQL 쿼리로 흘러 들어가는 데이터를 추적할 수 있습니다. 이러한 도구는 강력하지만, CI에 도구를 설정하고, “테인트” 싱크와 소스를 정의하고, 개발자에게 사용법을 교육하는 데 상당한 부담이 따릅니다. 또한 이러한 도구는 보통 타입 검사기보다 실행 시간이 더 오래 걸리므로(몇 초가 아니라 몇 분), 피드백을 즉시 받을 수 없습니다. 마지막으로, 이러한 도구는 라이브러리 스스로가 자신의 API를 어떻게 호출해야 하는지 정확하게 명시할 수 있게 하는 대신(LiteralString으로 가능한 것처럼), 취약점을 방지하는 부담을 라이브러리 사용자에게 떠넘깁니다.

전용 도구보다 새로운 타입을 사용하는 편을 선호하는 마지막 이유는 타입 검사기가 전용 보안 도구보다 더 널리 사용된다는 점입니다. 예를 들어, 2022년 1월에 MyPy는 7백만 회 이상 다운로드되었지만, Bandit은 2백만 회 미만 다운로드되었습니다. 타입 검사기에 보안 보호 기능이 바로 내장되어 있으면 더 많은 개발자가 그 혜택을 누리게 됩니다.

str에 NewType을 사용하지 않는 이유는 무엇입니까?

LiteralString이 적합한 API라면 어떤 것이든, 대신 NewType("SafeSQL", str)과 같이 Python 타입 시스템 안에서 생성한 다른 타입을 받도록 수정할 수도 있습니다:

SafeSQL = NewType("SafeSQL", str)

def execute(self, sql: SafeSQL, parameters: Iterable[str] = ...) -> Cursor: ...

execute(SafeSQL("SELECT * FROM data WHERE user_id = ?"), user_id)  # OK

user_query: str
execute(user_query)  # Error: Expected SafeSQL, got str.

API를 호출하기 위해 새로운 타입을 만들어야 한다면 일부 개발자는 잠시 멈칫하고 더 주의를 기울일 수도 있지만, 개발자가 사용자가 제어하는 문자열을 그냥 새 타입으로 바꾼 뒤 수정된 API에 그대로 전달하지 않는다는 보장은 없습니다:

query = f"SELECT * FROM data WHERE user_id = f{user_id}"
execute(SafeSQL(query))  # No error!

결국 SafeSQL에 임의의 입력이 들어가는 것을 막아야 하는 문제로 원점으로 돌아가게 됩니다. 이는 이론적인 우려에 그치는 것도 아닙니다. Django는 SafeString과 mark_safe로 위의 방식을 사용합니다. CVE-2020-13596 같은 이슈는 이 기법이 어떻게 실패할 수 있는지 보여 줍니다.

또한 이 방식은 소스 코드를 침습적으로 변경해야 하지만(쿼리를 SafeSQL로 감싸는 것), LiteralString은 그러한 변경이 전혀 필요하지 않다는 점에도 유의하십시오. 사용자는 민감한 API에 리터럴 문자열을 전달하는 한, 이를 전혀 의식하지 않아도 됩니다.

Trusted Types를 모방하지 않는 이유는 무엇입니까?

Trusted Types는 DOM 기반 크로스 사이트 스크립팅(XSS)을 방지하기 위한 W3C 명세입니다. XSS는 위험한 브라우저 API가 사용자가 제어하는 가공되지 않은 문자열을 받을 때 발생합니다. 이 명세는 이러한 API가 지정된 새니타이징 함수가 반환하는 “Trusted Types”만 받도록 수정합니다. 이러한 새니타이징 함수는 잠재적으로 악의적인 문자열을 받아, 예를 들어 유효한 URL인지 확인하거나 HTML 인코딩하는 등의 방법으로 이를 검증하거나 무해하게 만들어야 합니다.

Trusted Types 개념을 Python으로 옮겨 오면 이 문제를 해결할 수 있으리라고 가정하고 싶어질 수 있습니다. 그러나 근본적인 차이점은 Trusted Types 새니타이저의 출력은 보통 실행 가능한 코드가 아니도록 의도된다는 점입니다. 따라서 입력을 HTML 인코딩하거나, 위험한 태그를 제거하거나, 그 밖의 방법으로 무력화하기가 쉽습니다. SQL 쿼리나 셸 명령의 경우, 최종 결과는 여전히 실행 가능한 코드여야 합니다. 입력 문자열에서 어느 부분이 무해하고 어느 부분이 잠재적으로 악의적인지를 신뢰성 있게 가려낼 수 있는 새니타이저를 작성할 방법은 없습니다.

런타임에 검사 가능한 LiteralString

LiteralString 개념을 정적 타입 검사를 넘어 str 객체의 런타임 검사 가능한 속성으로 확장할 수도 있습니다. 이렇게 하면 프레임워크가 동적 문자열에 대해 오류를 발생시킬 수 있게 되는 등 몇 가지 이점이 있습니다. 이러한 런타임 오류는 타입 오류보다 더 견고한 방어 메커니즘이 될 것입니다. 타입 오류는 억제되거나 무시될 수 있고, 작성자가 타입 검사기를 사용하지 않으면 아예 드러나지 않을 수도 있기 때문입니다.

LiteralString 개념을 이렇게 확장하면 Python에서 가장 근본적인 타입 중 하나를 변경해야 하므로 제안의 범위가 크게 늘어납니다. Perl의 taint와 유사한 문자열에 대한 런타임 테인트 검사는 과거에 검토되고 시도되었으며 앞으로도 누군가 고려할 수 있겠지만, 그러한 확장은 이 PEP의 범위를 벗어납니다.

기각된 이름

리터럴 문자열 타입의 이름으로 여러 후보를 검토했으며 typing-sig에서 의견을 모았습니다. 주목할 만한 대안은 다음과 같습니다:

  • Literal[str]: Literal["foo"] 타입 이름을 자연스럽게 확장한 형태입니다. 그러나 사용자가 이를 str 클래스의 리터럴 타입으로 오해할 수 있다는 이유로 typing-sig에서 반대했습니다.
  • LiteralStr: LiteralString보다 짧지만 PEP 작성자들이 보기에는 어색합니다.
  • LiteralDerivedString: 이 이름은 MadeFromLiteralString과 함께 이 타입의 기술적 의미를 가장 잘 나타냅니다. 이 타입은 "foo"와 같은 리터럴 표현식의 타입뿐 아니라 "foo" + "bar"와 같이 리터럴을 조합한 표현식의 타입도 나타냅니다. 그러나 두 이름 모두 장황해 보입니다.
  • StringLiteral: 사용자가 이를 기존의 “문자열 리터럴” 개념과 혼동할 수 있습니다. 문자열 리터럴은 소스 코드에 구문 토큰으로 존재하는 문자열을 뜻하지만, 이 PEP의 개념은 그보다 더 일반적입니다.
  • SafeString: 의도한 의미에 가깝기는 하지만, HTML 태그나 셸 관련 특수 문자를 이스케이프하는 등의 방법으로 문자열이 이미 정화(sanitize)되었다고 사용자가 오해할 수 있습니다.
  • ConstantStr: 리터럴 문자열을 조합한다는 개념을 나타내지 못합니다.
  • StaticStr: 문자열을 정적으로, 즉 프로그램을 실행하지 않고도 계산할 수 있다는 뜻으로 읽히지만 이는 사실이 아닙니다. Motivation 예제에서 보듯이 리터럴 문자열은 런타임 플래그에 따라 달라질 수 있습니다.
  • LiteralOnly[str]: bytes나 int 같은 다른 리터럴 타입으로 확장할 수 있다는 장점이 있습니다. 그러나 이러한 확장성이 가독성을 희생할 만큼 가치 있지는 않다고 판단했습니다.

전반적으로 typing-sig에서 오랫동안 논의했지만 뚜렷하게 우세한 이름이 나오지 않았으므로, LiteralString 쪽으로 결정을 내렸습니다.

LiteralBytes

Literal[b"foo"]와 같은 리터럴 바이트 타입을 LiteralBytes로 일반화할 수도 있습니다. 그러나 리터럴 바이트 타입은 리터럴 문자열 타입보다 훨씬 드물게 사용되고 LiteralBytes에 대한 사용자 수요도 많지 않았으므로, 이 PEP에는 포함하지 않기로 했습니다. 다만 다른 사람들이 향후 PEP에서 이를 검토할 수는 있습니다.

참조 구현

이 기능은 Pyre v0.9.8에 구현되어 있으며 실제로 활발히 사용되고 있습니다.

구현은 단순히 타입 검사기를 확장하여 LiteralString을 리터럴 문자열 타입들의 상위 타입으로 추가합니다.

덧셈, join 등을 통한 조합을 지원하려면 Pyre에 포함된 typeshed 사본에서 str의 스텁을 오버로드하는 것으로 충분했습니다.

부록 A: 기타 용도

논의를 단순하게 하고 필요한 보안 지식을 최소화하기 위해 이 PEP 전체에서 SQL 인젝션에 초점을 맞추었습니다. 그러나 LiteralString은 다른 여러 종류의 인젝션 취약점을 방지하는 데에도 사용할 수 있습니다.

명령 인젝션

subprocess.run과 같은 API는 셸 명령으로 실행할 수 있는 문자열을 받습니다:

subprocess.run(f"echo 'Hello {name}'", shell=True)

사용자가 제어하는 데이터가 명령 문자열에 포함되면 코드가 “명령 인젝션”에 취약해집니다. 즉, 공격자가 악의적인 명령을 실행할 수 있습니다. 예를 들어 ' && rm -rf / #이라는 값을 넣으면 다음과 같은 파괴적인 명령이 실행됩니다:

echo 'Hello ' && rm -rf / #'

shell=True 모드에서 사용할 때는 LiteralString만 받도록 run을 수정하면 이 취약점을 막을 수 있습니다. 다음은 단순화한 스텁의 한 예입니다:

def run(command: LiteralString, *args: str, shell: bool=...): ...

크로스 사이트 스크립팅(XSS)

Django를 비롯해 널리 쓰이는 Python 웹 프레임워크 대부분은 템플릿 엔진을 사용하여 사용자 데이터로부터 HTML을 생성합니다. 이러한 템플릿 언어는 사용자 데이터를 HTML 템플릿에 삽입하기 전에 자동으로 이스케이프하므로 크로스 사이트 스크립팅(XSS) 취약점을 방지합니다.

하지만 자동 이스케이프를 우회하여 HTML을 있는 그대로 렌더링하는 흔한 방법은 Django의 mark_safe나 Jinja2의 do_mark_safe 같은 함수를 사용하는 것이며, 이는 XSS 취약점을 일으킵니다:

 dangerous_string = django.utils.safestring.mark_safe(f"<script>{user_input}</script>")
 return(dangerous_string)

mark_safe가 LiteralString만 받도록 수정하면 이 취약점을 방지할 수 있습니다:

 def mark_safe(s: LiteralString) -> str: ...

서버 사이드 템플릿 인젝션(SSTI)

Jinja 같은 템플릿 프레임워크는 Python 표현식을 허용하며, 이 표현식은 평가된 뒤 렌더링 결과에 치환됩니다:

template_str = "There are {{ len(values) }} values: {{ values }}"
template = jinja2.Template(template_str)
template.render(values=[1, 2])
# Result: "There are 2 values: [1, 2]"

공격자가 템플릿 문자열의 전체 또는 일부를 제어할 수 있다면, 임의의 코드를 실행하는 표현식을 삽입하여 애플리케이션을 장악할 수 있습니다:

 malicious_str = "{{''.__class__.__base__.__subclasses__()[408]('rm - rf /',shell=True)}}"
 template = jinja2.Template(malicious_str)
 template.render()
 # Result: The shell command 'rm - rf /' is run

Template API가 LiteralString만 받도록 수정하면 이와 같은 템플릿 인젝션 공격을 방지할 수 있습니다:

 class Template:
     def __init__(self, source: LiteralString): ...

로깅 형식 문자열 주입

로깅 프레임워크는 입력 문자열에 포매팅 지시자를 포함할 수 있도록 허용하는 경우가 많습니다. 최악의 경우, 사용자가 로그 문자열을 제어할 수 있도록 허용한 것이 CVE-2021-44228(통칭 log4shell)로 이어졌으며, 이 취약점은 “지난 10년간 가장 심각한 취약점”으로 묘사되었습니다. 현재 이와 유사한 공격에 취약한 것으로 알려진 Python 프레임워크는 없지만, 내장 로깅 프레임워크는 외부에서 제어되는 로깅 문자열을 통한 서비스 거부 공격에 취약한 포매팅 옵션을 제공합니다. 다음 예제는 간단한 서비스 거부 시나리오를 보여 줍니다:

 external_string = "%(foo)999999999s"
 ...
 # Tries to add > 1GB of whitespace to the logged string:
 logger.info(f'Received: {external_string}', some_dict)

이러한 종류의 공격은 로거에 전달되는 형식 문자열이 LiteralString이어야 하고 외부에서 제어되는 모든 데이터는 인자로 따로 전달되어야 한다고 요구함으로써 막을 수 있습니다(Issue 46200에서 제안된 것처럼):

 def info(msg: LiteralString, *args: object) -> None:
     ...

부록 B: 한계

LiteralString을 사용하더라도 사용자가 리터럴이 아닌 데이터로 만든 문자열을 API에 전달하는 것을 막지 못하는 경우가 여러 가지 있습니다:

1. 개발자가 타입 검사기를 사용하지 않거나 타입 어노테이션을 추가하지 않으면 위반 사항을 잡아낼 수 없습니다.

2. cast(LiteralString, non_literal_string)을 사용해 타입 검사기를 속이고 동적 문자열 값이 LiteralString인 것처럼 가장하게 할 수 있습니다. 타입이 Any인 변수도 마찬가지입니다.

3. # type: ignore와 같은 주석을 사용하여 리터럴 문자열이 아닌 문자열에 대한 경고를 무시할 수 있습니다.

4. str을 LiteralString으로 변환하는 간단한 함수를 만들 수 있습니다:

 def make_literal(s: str) -> LiteralString:
     letters: Dict[str, LiteralString] = {
         "A": "A",
         "B": "B",
         ...
     }
     output: List[LiteralString] = [letters[c] for c in s]
     return "".join(output)

린팅, 코드 리뷰 등을 통해 위의 문제를 완화할 수는 있지만, 결국 LiteralString이 제공하는 보호를 우회하려는 영리하고 악의적인 개발자는 언제나 성공할 것입니다. 기억해야 할 중요한 점은 LiteralString이 악의적인 개발자로부터 보호하기 위한 것이 아니라는 점입니다. 이는 선의의 개발자가 민감한 API를 실수로 위험한 방식으로 사용하는 것을 (그 외에는 개발자를 방해하지 않으면서) 막기 위한 것입니다.

LiteralString이 없다면 API 작성자가 가진 최선의 강제 수단은 문서화뿐인데, 문서는 쉽게 무시되고 아예 읽히지 않는 경우도 많습니다. LiteralString이 있으면 API를 오용하려면 의식적인 판단이 필요하고, 리뷰어와 향후 개발자가 알아챌 수 있는 흔적이 코드에 남습니다.

부록 C: LiteralString을 보존하는 str 메서드

str 클래스에는 LiteralString의 이점을 누릴 수 있는 메서드가 여러 개 있습니다. 예를 들어, 사용자는 Inferring LiteralString 절에서 살펴본 다른 예제들과 마찬가지로 "hello".capitalize()가 LiteralString 타입을 가질 것으로 기대할 수 있습니다. 이 문자열은 사용자가 제공한 임의의 문자열이 아니므로 LiteralString 타입으로 추론하는 것이 옳습니다. 이 문자열이 LiteralString과 호환되는 Literal["HELLO"] 타입을 가진다는 것을 우리는 알고 있습니다. 다시 말해, capitalize 메서드는 LiteralString 타입을 보존합니다. LiteralString을 보존하는 str 메서드는 이 밖에도 여러 개 있습니다.

typeshed의 str 스텁을 수정하여 해당 메서드들을 LiteralString을 보존하는 버전으로 오버로드할 것을 제안합니다. 이렇게 하면 타입 검사기가 메서드마다 LiteralString 동작을 하드코딩하지 않아도 됩니다. 또한 앞으로 typeshed 스텁을 수정하는 것만으로 새 메서드를 쉽게 지원할 수 있습니다.

예를 들어, capitalize 메서드에서 리터럴 타입을 보존하려면 스텁을 다음과 같이 변경합니다:

# before
def capitalize(self) -> str: ...

# after
@overload
def capitalize(self: LiteralString) -> LiteralString: ...
@overload
def capitalize(self) -> str: ...

str 스텁을 변경하면 스텁이 더 복잡해지고 오류 메시지를 이해하기 더 어려워질 수 있다는 단점이 있습니다. 타입 검사기는 사용자가 이해할 수 있는 오류 메시지를 만들기 위해 str을 특별하게 처리해야 할 수도 있습니다.

다음은 LiteralString 타입의 인자로 호출할 때 LiteralString을 반환하는 것으로 취급해야 하는 str 메서드의 전체 목록입니다. 이 PEP가 수락되면 typeshed에서 다음 메서드 시그니처들을 수정할 것입니다:

@overload
def capitalize(self: LiteralString) -> LiteralString: ...
@overload
def capitalize(self) -> str: ...

@overload
def casefold(self: LiteralString) -> LiteralString: ...
@overload
def casefold(self) -> str: ...

@overload
def center(self: LiteralString, __width: SupportsIndex, __fillchar: LiteralString = ...) -> LiteralString: ...
@overload
def center(self, __width: SupportsIndex, __fillchar: str = ...) -> str: ...

if sys.version_info >= (3, 8):
    @overload
    def expandtabs(self: LiteralString, tabsize: SupportsIndex = ...) -> LiteralString: ...
    @overload
    def expandtabs(self, tabsize: SupportsIndex = ...) -> str: ...

else:
    @overload
    def expandtabs(self: LiteralString, tabsize: int = ...) -> LiteralString: ...
    @overload
    def expandtabs(self, tabsize: int = ...) -> str: ...

@overload
def format(self: LiteralString, *args: LiteralString, **kwargs: LiteralString) -> LiteralString: ...
@overload
def format(self, *args: str, **kwargs: str) -> str: ...

@overload
def join(self: LiteralString, __iterable: Iterable[LiteralString]) -> LiteralString: ...
@overload
def join(self, __iterable: Iterable[str]) -> str: ...

@overload
def ljust(self: LiteralString, __width: SupportsIndex, __fillchar: LiteralString = ...) -> LiteralString: ...
@overload
def ljust(self, __width: SupportsIndex, __fillchar: str = ...) -> str: ...

@overload
def lower(self: LiteralString) -> LiteralString: ...
@overload
def lower(self) -> LiteralString: ...

@overload
def lstrip(self: LiteralString, __chars: LiteralString | None = ...) -> LiteralString: ...
@overload
def lstrip(self, __chars: str | None = ...) -> str: ...

@overload
def partition(self: LiteralString, __sep: LiteralString) -> tuple[LiteralString, LiteralString, LiteralString]: ...
@overload
def partition(self, __sep: str) -> tuple[str, str, str]: ...

@overload
def replace(self: LiteralString, __old: LiteralString, __new: LiteralString, __count: SupportsIndex = ...) -> LiteralString: ...
@overload
def replace(self, __old: str, __new: str, __count: SupportsIndex = ...) -> str: ...

if sys.version_info >= (3, 9):
    @overload
    def removeprefix(self: LiteralString, __prefix: LiteralString) -> LiteralString: ...
    @overload
    def removeprefix(self, __prefix: str) -> str: ...

    @overload
    def removesuffix(self: LiteralString, __suffix: LiteralString) -> LiteralString: ...
    @overload
    def removesuffix(self, __suffix: str) -> str: ...

@overload
def rjust(self: LiteralString, __width: SupportsIndex, __fillchar: LiteralString = ...) -> LiteralString: ...
@overload
def rjust(self, __width: SupportsIndex, __fillchar: str = ...) -> str: ...

@overload
def rpartition(self: LiteralString, __sep: LiteralString) -> tuple[LiteralString, LiteralString, LiteralString]: ...
@overload
def rpartition(self, __sep: str) -> tuple[str, str, str]: ...

@overload
def rsplit(self: LiteralString, sep: LiteralString | None = ..., maxsplit: SupportsIndex = ...) -> list[LiteralString]: ...
@overload
def rsplit(self, sep: str | None = ..., maxsplit: SupportsIndex = ...) -> list[str]: ...

@overload
def rstrip(self: LiteralString, __chars: LiteralString | None = ...) -> LiteralString: ...
@overload
def rstrip(self, __chars: str | None = ...) -> str: ...

@overload
def split(self: LiteralString, sep: LiteralString | None = ..., maxsplit: SupportsIndex = ...) -> list[LiteralString]: ...
@overload
def split(self, sep: str | None = ..., maxsplit: SupportsIndex = ...) -> list[str]: ...

@overload
def splitlines(self: LiteralString, keepends: bool = ...) -> list[LiteralString]: ...
@overload
def splitlines(self, keepends: bool = ...) -> list[str]: ...

@overload
def strip(self: LiteralString, __chars: LiteralString | None = ...) -> LiteralString: ...
@overload
def strip(self, __chars: str | None = ...) -> str: ...

@overload
def swapcase(self: LiteralString) -> LiteralString: ...
@overload
def swapcase(self) -> str: ...

@overload
def title(self: LiteralString) -> LiteralString: ...
@overload
def title(self) -> str: ...

@overload
def upper(self: LiteralString) -> LiteralString: ...
@overload
def upper(self) -> str: ...

@overload
def zfill(self: LiteralString, __width: SupportsIndex) -> LiteralString: ...
@overload
def zfill(self, __width: SupportsIndex) -> str: ...

@overload
def __add__(self: LiteralString, __s: LiteralString) -> LiteralString: ...
@overload
def __add__(self, __s: str) -> str: ...

@overload
def __iter__(self: LiteralString) -> Iterator[str]: ...
@overload
def __iter__(self) -> Iterator[str]: ...

@overload
def __mod__(self: LiteralString, __x: Union[LiteralString, Tuple[LiteralString, ...]]) -> str: ...
@overload
def __mod__(self, __x: Union[str, Tuple[str, ...]]) -> str: ...

@overload
def __mul__(self: LiteralString, __n: SupportsIndex) -> LiteralString: ...
@overload
def __mul__(self, __n: SupportsIndex) -> str: ...

@overload
def __repr__(self: LiteralString) -> LiteralString: ...
@overload
def __repr__(self) -> str: ...

@overload
def __rmul__(self: LiteralString, n: SupportsIndex) -> LiteralString: ...
@overload
def __rmul__(self, n: SupportsIndex) -> str: ...

@overload
def __str__(self: LiteralString) -> LiteralString: ...
@overload
def __str__(self) -> str: ...

부록 D: 스텁에서 LiteralString을 사용하기 위한 지침

소스 코드에 타입 어노테이션이 없는 라이브러리는 Typeshed에 타입 스텁을 지정할 수 있습니다. 머신 러닝용 라이브러리처럼 다른 언어로 작성된 라이브러리도 Python 타입 스텁을 제공할 수 있습니다. 이 경우 타입 검사기는 타입 어노테이션이 소스 코드와 일치하는지 검증할 수 없으므로 타입 스텁을 신뢰할 수밖에 없습니다. 따라서 타입 스텁 작성자는 LiteralString을 사용할 때 주의해야 합니다. 함수가 실제로는 안전하지 않은데도 안전한 것처럼 보일 수 있기 때문입니다.

스텁에서 LiteralString을 사용할 때 다음 지침을 따를 것을 권장합니다:

  • 스텁이 순수 함수에 대한 것이라면, 대응하는 모든 매개변수가 리터럴 타입(즉, LiteralString 또는 Literal["a", "b"])을 가지는 경우에만 함수나 그 오버로드의 반환 타입에 LiteralString을 사용할 것을 권장합니다.
    # OK
    @overload
    def my_transform(x: LiteralString, y: Literal["a", "b"]) -> LiteralString: ...
    @overload
    def my_transform(x: str, y: str) -> str: ...
    
    # Not OK
    @overload
    def my_transform(x: LiteralString, y: str) -> LiteralString: ...
    @overload
    def my_transform(x: str, y: str) -> str: ...
    
  • 스텁이 staticmethod에 대한 것이라면 위와 같은 지침을 권장합니다.
  • 스텁이 그 밖의 다른 종류의 메서드에 대한 것이라면, 메서드나 그 오버로드 중 어느 것의 반환 타입에도 LiteralString을 사용하지 말 것을 권장합니다. 명시적인 매개변수가 모두 LiteralString 타입을 가지더라도 객체 자체가 사용자 데이터로 생성되었을 수 있고, 따라서 반환 타입이 사용자에 의해 제어될 수 있기 때문입니다.
  • 스텁이 클래스 속성이나 전역 변수에 대한 것이라면, 타입이 지정되지 않은 코드가 해당 속성에 임의의 값을 쓸 수 있으므로 역시 LiteralString을 사용하지 말 것을 권장합니다.

다만 최종 판단은 라이브러리 작성자에게 맡깁니다. 메서드나 함수가 반환하는 문자열 또는 속성에 저장된 문자열이 리터럴 타입을 가진다는 것, 즉 문자열 리터럴에 리터럴을 보존하는 str 연산만 적용하여 만들어진 문자열이라는 것이 보장된다고 확신한다면 LiteralString을 사용해도 됩니다.

이 지침은 인라인 타입 어노테이션에는 적용되지 않는다는 점에 유의하십시오. 예를 들어 LiteralString을 반환하는 메서드가 실제로 그 타입의 표현식을 반환하는지를 타입 검사기가 검증할 수 있기 때문입니다.

참고 자료

Scala의 리터럴 문자열 타입

Scala는 "foo" 같은 리터럴 문자열 타입을 포함한 싱글턴 타입의 상위 타입으로 Singleton을 사용합니다. Singleton은 이 PEP의 LiteralString을 일반화한 Scala의 대응물입니다.

Tamer Abdulradi는 Scala Days 발표 Literal types: What are they good for? (슬라이드 52~68)에서 Scala의 리터럴 문자열 타입을 “컴파일 시점에 SQL 주입 방지”에 활용하는 방법을 보여 주었습니다.

감사의 말

PEP에 대해 의견을 주신 다음 분들께 감사드립니다:

Edward Qiu, Jia Chen, Shannon Zhu, Gregory P. Smith, Никита Соболев, CAM Gerlach, Arie Bovenberg, David Foster, Shengye Wan