'세니타이즈'에 해당하는 글 1건

제목: Fun with String Interpolation

Update: 2017.02.02 API 설계 가이에따라 escape(unsafe:)가 escaping(unsafe:)로 바뀌었다.

스위프트 프로그래머가 제일 처음 배우는것 중 하나가 문자열 보간(string interpolation)이거나, 혹은 변수와 수식을 리터럴 문자열로 만드는 것이다.
let a = 6
let b = 12
let message = "\(a) × \(b) = \(a * b)"
// → "6 × 12 = 72"
보관된 문자열로 여러분의 커스텀 타입을 초기화할때, 문자열 보간이 하는 일을 커스텀할 수 있다는 사실은 잘 모를것이다. 이 글이 그것에 대한 내용이다.

이스케이핑 언세이프 문자열(Escaping unsafe strings)
나는 (아마) 언세이프한 사용자 입력값을 세니타이즈(sanitize: 문자열을 안전하게 만들기위해 처리하는 과정) 하기위해서 이스케이프된 문자열 작업을 하려 한다.

동기
사용자로부터 받은 문자처럼 외부로부터 받은 데이터를 다루는 프로그램이 있는데, 보장된 프로그램을 만들기 위해 외부 데이터가 공격경로(attack vector)로 사용될 수 있는 부분을 반드시 안전에 대비되어 있엉 한다. 예를들어 공격자가 계정을 등록할때 사용자 이름란에 <script>를 넣을 수 있다. 만약 웹앱이 그대로 텍스트를 렌더링하면 공격자는 제어불능의 스크립트를 실행시킬 수 있게 해주는 셈이 되는데, 공격자가 다른 사용자의 쿠키를 훔칠 수 있게 할 수도 있다. 이스케이프하는 HTML 태그처럼 외부로부터 받은 모든 인풋들을 세니타이즈 해야하는 이유이다.

나는 몇주전에 Joel Spolsky가 쓴 트윗를 발견했는데, 여기에는 2005년도에 그가 쓴 Making Wrong Code Look Wrong 글이 링크되있었다. Spolsky는 이 글에서 변수 네이밍의 특정 스타일에대해 이야기하며, 한 변수가 세이프 혹은 언세이프(이케이프 되지 않은) 문자를 가진다면, 프로그래머들이 따라오기 더 쉬워진다고 말한다. 좋은 읽을거리이고 Hyngrian Notation의 역사 이야기도 담고있다.

모든 문자열이 동등하지 않다.
그러나 네이밍 규약을 따르려고 하는것보다는 스위프트처럼 강타입 언어에서 이 문제를 해결하는게 (더 안전하고) 더 나은 것이며, 이것이 타입 시스템의 이점을 취할 수 있는 것이다.

"언세이프 문자열"과 "세이프 문자열"은 근본적으로 너무 달라서 이것들을 서로 다르게 다루어야한다. 그러나 이 둘은 같은 String 타입으로 사용하는 경향이 있는데, 문제의 근원지는 바로 이것이다. 이제 이 개념을 배우기위해 타입을 분리시켜보자. 나는 이것들을 UnsafeString 그리고 SantinizedHTML이라 부르고 있다. 각각은 내부 저장소로 String을 사용한다.
/// An unescaped string from a potentially unsafe
/// source (such as user input)
struct UnsafeString {
    var value: String
}

/// A string that either comes from a safe source
/// (e.g. a string literal in the source code)
/// or has been escaped.
struct SanitizedHTML {
    fileprivate(set) var value: String

    init(unsafe input: UnsafeString) {
        value = SanitizedHTML.escaping(unsafe: input.value)
    }
}
또한 생성자가 UnsafeString으로부터 SantinizedHTML을 만들도록 하고, 이 과정에서 인풋을 이스케이핑한다. 이 escape 메소드는 모든 꺾쇠를 해당하는 HTML 요소로 대치한다. 아주 간단해 보이는 예제이지만 실상은 조금 복잡할 수도 있다.
import Foundation // required for String.replacingOccurrences(of:with:)

extension SanitizedHTML {
    /// Escapes a string.
    fileprivate static func escaping(unsafe input: String) -> String {
        return input
            .replacingOccurrences(of: "<", with: "&lt;")
            .replacingOccurrences(of: ">", with: "&gt;")
    }
}
두 타입의 value 프로퍼티에대한 선언의 차이를 주목하자. UnsafeString의경우 종종 값타입으로 필요할 것이기 때문에 valuevar이다. 이렇게하여 세이프티(단순 소유 모델(ownership model)은 값이 대입될때 복사가 일어남) 값 타입들은 포기하지 않는다. 반면 SantinizedHTMLvalue 프로퍼티는 fileprivate(set)으로 수정되는데, 모든 제3자는 그 타입의 공식적인 API를 우회하여 수정할 수 없고, 언이스케이프된 문자열 값을 주입하기 위함이다. 이것은 타입의 그 구현으로부터 계속 변경을 허락받아야한다.

세니타이즈한 문자열에 새로운 것을 붙일 수 있는 방법을 만들어보자. append(_:) 메소드를위해 두가지 오버로드를 제공한다. 하나는 UnsafeString을 받고, 다른 하나는 SantinizedHTML을 받는다. 나중에는 이것이 이미 세니타이즈함을 보장할 것이라서 이것을 다시 이스케이프할 필요가 없다.
extension SanitizedHTML {
    mutating func append(_ other: SanitizedHTML) {
        // other is already safe
        value.append(other.value)
    }

    mutating func append(_ other: UnsafeString) {
        let sanitized = SanitizedHTML(unsafe: other)
        append(sanitized)
    }
}

안전한 자료로부터 이스케이프하지 않은 입력 받기
또한 우리는 이미 안전하다고 알고있는 것을 SantinizedHTML에 추가하는 방법도 필요하다. <h1>태그나 <p>태그(혹은 <script>태그까지도)를 HTML 템플릿에서 이스케이프되도록 원하지 않을 수도 있다. 문자열 리터럴(literals), 즉 상수 문자열을 통해 그렇게 할 수 있다. 코드에서의 문자열 리터럴은 항상 안전하다고 가정할 수 있다. 여러분의 소스코드가 보장되있다면 어떤 경우라도 장담할 수 있다(all bets are off in any case).

여러분의 타입을 문자열 리터럴과 함께 초기화시키는 기능을 넣기위해, ExpressibleByStringLiteral 프로토콜을 따르게 한다. 이 프로토콜은 3개의 생성자를 필요로한다. 그래도 제네럴하게 각각에게 전달할 수 있어서 생각보다 쉽게 만들 수 있다.
// Initialization with a string literal should not escape the input.

extension SanitizedHTML: ExpressibleByStringLiteral {

    init(stringLiteral value: String) {

        self.value = value

    }

    init(unicodeScalarLiteral value: String) {

        self.init(stringLiteral: value)

    }

    init(extendedGraphemeClusterLiteral value: String) {

        self.init(stringLiteral: value)

    }

}
우리가 여기에 있는 동안 UnsafeString의 능력과 같게 만드는 것이 좋아보인다. 이 구현은 SantinizedHTML의 것과 동일하다.
extension UnsafeString: ExpressibleByStringLiteral {

    // Same implementation, see above

}
이제 문자열 리터럴로 UnsafeString 값과 SantinizedHTML 값을 생성할 수 있다. 여기에는 타입을 지시해주어야하는데, String값을 받을 수도 있기 때문이다.
let userInput: UnsafeString = "<script>alert('P0wn3d');</script>"

var sanitized: SanitizedHTML = "<strong>Name:</strong> "

sanitized.append(userInput)

sanitized.value

// → "<strong>Name:</strong> &lt;script&gt;alert('P0wn3d');&lt;/script&gt;"
이제 됐다! 세이프 문자열 리터럴은 이스케이프하지 않았지만 언세이프한 사용자 입력이 있다.

문자열 보간법
이제 기본적인 것들이 해결되었다. 모든 렌더링 API가 입력받을때, SantinizedHTML만 받을 수 있어야 한다면, 그 새로운 타입을 언이스케이프된 문자열을 렌더링하지 못하게 만들어야한다.

그러나 아래처럼 문자열 보간법을 통해 초기화할 수 있다면 SantinizedHTML을 사용하여 편리하게 만들 수 있다.
let sanitized2: SanitizedHTML = "<strong>Name:</strong> \(userInput)"

// error: cannot convert value of type 'String' to specified type 'SanitizedHTML'
현재, 이것은 컴파일타임 에러의 결과를 내뱉는다. 유용하게 만드려면 바로 잡아야하는데 보간법 문자열 부분은 안전하고 \()안의 부분은 안전하지 않게 하는 그런 작업을 수행해야한다.

이상한 ExpressibleByStringInterpolation 프로토콜
우리가 다음에 보려하는, 이것이 동작할 수 있다. 표준 라이브러리에는 우리가 따라야할 ExpressibleByStringInterpolation이라는 프로토콜을 제공하고 있다. 현재(Swift3에서) 이 프로토콜은 디프리케이트 되었다. 스위프트 팀이 이것을 "잘못된 설계한계가있다"고 인지했기 때문이다. 이 말은, 우리가 이것을 사용하면 나중에 경고를 보게될 것이고, 스위프트4나 그 후에 어떤 새로운 API(더 강력해 질 것으로 보인다)로 대체되어 우리 코드를 고칠 준비를 해야한다. 그전까지 현재 API가 직관적이진 않지만 놀랍도록 유용하게 쓰일것이다.

이 프로토콜은 두가지 생성자를 필요로한다. 문자열 보간법은 두가지 단계로 처리된다.
1. 먼저 첫번째 단계에서는, 컴파일러가 보간법 문자열을 문자열 리터럴의 세그먼트와 변수 표현식으로 분디한다. 그리고 세그먼트들은 init<T>(stringInterpolationSegment:) 생성자로 보내진다.

그 세그먼트들은 항상 문자열 리터럴 과변수 표현식을 번갈아간다. 그리고 첫번째 세그먼트는 항상 리터럴(보간법 문자열이 한 변수로 시작하면 아마 비어있을 것이다)이다. 두 변수 표현식이 보간법 문자열 안에서 직접 붙어있으면 다시 빈 리터럴 세그먼트가 그 사이에 들어갈 것이다.

보간법 문자를 위한 세그먼트들에대한 예시이다.
"\(name) says \(greeting1)\(greeting2)!"
이것이 아래처럼 된다.
""

name

" says "

greeting1

""

greeting2

"!"
이런 동작이 현재에 공식적으로 문서에 나와있진 않을거라 생각된다.

2. 그 두번째 보간법 단계는, 보간법 문자열에서 나타난 그 요구대로 첫번째 생성자의 결과물을 두번째 생성자인 init(stringInterpolation:)으로 보낸다.

이런 세그먼트들의 순서의 특징을 이용하여, 짝수번째(0을 포함한)의 세그먼트들은 항상 문자열 리터럴이니 세이프하고, 반면 홀수번째의 세그먼트들은 언세이프하니 반드시 이스케이프 해주어야한다.

ExpressibleByStringInterpolation을 따르기
이 API에서 이상한 점은 보간법 과정에서 첫 단계에서 타입을 따르는 생성자를 사용하는 것이다. 이 말은 각기 다른 보간법 세그먼트에서 유효한 SantinizedHTML 값을 만들어서 오직 두번째 단계에서 이 세그먼트들을 완성된 값으로 합쳐야만한다. 우리는 SantinizedHTML 안에 각 세그먼트를 담아두어야하니, 타입 정의에따라 새로운 프로퍼티를 추가해보자.
struct SanitizedHTML {

    fileprivate(set) var value: String

    // Required for string interpolation processing

    fileprivate var interpolationSegment: Any? = nil

    ...

}
 이 프로퍼티의 타입은 Optional<Any>이다. Any인 이유는 보간법 문자열로 들어온 어떤 값이라도 변환하지 않은채 가지고 있기 위함이고, Optional의 이유는 문자열 보간법 처리중에 이것이 필요하기 때문이다. 모든 예외는 nil이 될것이다.

아래 보간법의 모든 단계를 담은 전체 구현이 있다.
extension SanitizedHTML: ExpressibleByStringInterpolation {

    // Step 1

    public init<T>(stringInterpolationSegment expr: T) {

        // Store the segment

        interpolationSegment = expr

        // Dummy initialization, this is never used

        value = ""

    }


    // Step 2

    public init(stringInterpolation segments: SanitizedHTML...) {

        let stringSegments = segments.enumerated()

            .map { index, segment -> String in

                guard let segment = segment.interpolationSegment else {

                    fatalError("Invalid interpolation sequence")

                }

                if index % 2 == 0 {

                    // Even indices are literal segments

                    // and thus already safe.

                    if let string = segment as? String {

                        return string

                    } else {

                        return String(describing: segment)

                    }

                } else {

                    // Odd indices are variable expressions

                    switch segment {

                    case let safe as SanitizedHTML:

                        // Already safe

                        return safe.value

                    case let unsafe as UnsafeString:

                        return SanitizedHTML.escaping(unsafe: unsafe.value)

                    default:

                        // All other types are treated as unsafe too.

                        let unsafe = UnsafeString(value: String(describing: segment))

                        return SanitizedHTML(unsafe: unsafe).value

                    }

                }

        }

        value = stringSegments.joined()

    }

}

1단계 생성자는 받은 값을 저장하고 실제 변환인 2단계로 넘어간다. 생성자는 반드시 모든 프로퍼티를 초기화해주어야 하므로, 2단계까지 계속 그 인스턴스가 살아있더라해도 value 프로퍼티를 위한 더미 값을 제공해주어야한다.

2단계에서는 SantinizedHTML값의 배열로 세그먼트를 받는다. 우리의 목적은 이 값들을 문자열로 변환하는 것이기 때문에, 이 과정에서 변수 표현식 세그먼트들을 이스케이프한다. 그리고 문자열들을 하나로 합쳐 우리 value 프로퍼티에 결과를 저장한다. 배열과 그것의 인덱스들을 연결하고 짝수 인덱스가 세이프하다는 정보를 이용한다. 또한 우리는 이미 짝수 인덱스들이 문자열이라는 것을 알지만, 안전을 위해 필요에따라 세그먼트를 확인하여 문자열로 변환하게 만든다.

홀수 인덱스의 경우, 3가지 상황으로 나뉜다. 첫번째, 세그먼트가 이미 SantinizedHTML 값이라면 다시 이스케이프하지 말고 바로 그 값을 반환한다. 두번째, 세그먼트가 UnsafeString이면 이스케이프하고 그 결과를 반환한다. 세번째, 세그먼트가 그 밖의 타입(String이나 Int)이면 먼저 UnsafeString으로 만들어서 이스케이프한다.

더 확장하여, 연속된 SantinizedHTML 값을 위한 커스텀 로직을 추가할 수도 있지만, 이것만으로도 충분히 강력해 보인다. 위의 것으로 예제를 시험해보자.
let sanitized2: SanitizedHTML = "<strong>Name:</strong> \(userInput)"

sanitized2.value

// → "<strong>Name:</strong> &lt;script&gt;alert('P0wn3d');&lt;/script&gt;"
우리가 원하는데로, 문자열 리터럴은 그대로 보내지고 변수 표현식은 이스케이프될 것이다. 멋지다!

이게다다. 새로운 타입을 더 편하게 사용하고 싶으면, 다음 단계로 CustomStringConvertible 혹은/그리고 CustomDebugStringConvertible를 따르게 할 수 있는데, 이것은 여러분에게 남겨두겠다.

모든 코드는 Gist에 올려놓았다. 플레이그라운드에 붙여넣어서 가지고 놀아보자.

결론
여러분의 타입을 어떻게 보간법 문자열로 번역하는지는 특히 DSLs에게 강력한 기능이다. 이번에 이 기술로 SQL 쿼리를 만들거나 다국어 문자열을 만드는데 적용해볼 수 있겠다(Brent Royal-Gordon이 다국어 문자열을 만든 구현 예시이다). 구성요소로 만든 문자열을 쓰는 모든 작업은 아마 이것이 도움이 되리라 믿는다.

이 API가 완벽하진 않아도 스위프트에서 이것이 가능하게 만들어 놓았다는 점이 매우 멋지다. 문자열 보간법 API가 나중 스위프트 버전에서 바뀌게되면 더 표현력 있고 사용하기 쉬워질 것이라 생각된다.

이 글에 아이디어를 제공해준 Bandes-Storch에게 특별히 감사하다.



이 블로그는 공부하고 공유하는 목적으로 운영되고 있습니다. 번역글에대한 피드백은 언제나 환영이며, 좋은글 추천도 함께 받고 있습니다. 피드백은 

으로 보내주시면 됩니다.



WRITTEN BY
tucan.dev
개인 iOS 개발, tucan9389

,