파서 콤비네이터: 하스켈 초보자를 위한 파싱

박준규 @curry@hackers.pub

이 글은 저자의 허락을 받아 ChatGPT를 통해 한글로 옮긴 것입니다.

파싱은 모든 프로그래머가 항상 수행하는 작업입니다. 운이 좋으면, 여러분이 받는 데이터가 JSON, XML 등과 같은 표준에 따라 구조화되어 있을 때가 있습니다. 그런 경우에는 해당 형식을 네이티브 데이터 타입으로 변환해주는 라이브러리를 다운받아 사용하면 끝입니다.
때로는 그렇게 운이 좋지 않을 때도 있습니다. 사람들의 전화번호, 차량 번호판, 주민등록번호를 기록하는 다양한 방식이나, 명령어 인터페이스의 출력, 파일 시스템의 체계적으로 이름 붙여진 파일처럼, 구조화되지 않았거나 문서화가 잘 되어 있지 않은 “미니 포맷(miniformat)”의 데이터를 받는 경우가 그렇습니다. 때로는 표준 형식의 데이터를 다루고 있지만, 그것이 널리 알려지지 않았거나 인기 없는 형식이라 파서가 없는 경우도 있습니다. 또는 사용자가 입력하는 데이터를 읽을 때, 상대적으로 사용자 친화적인 형식을 원할 수도 있습니다.
이럴 때는 어떤 형태로든 파싱 루틴을 작성해야 하며, 이를 수행하는 몇 가지 방법이 있습니다. 하스켈에서는 파서 콤비네이터(parser combinators)를 사용하는 것을 선호합니다. 잠시 시간을 내어 그 이유를 보여드리겠습니다. 이미 파서 콤비네이터를 배우는 것이 왜 중요한지 알고 있다면, 바로 ReadP라는 제목으로 넘어가셔도 됩니다.

파싱 – 어려움과 골칫거리

제가 처음 프로그래밍을 시작했을 때는, 문자열을 키워드로 나눈 뒤 알려진 값과 비교하는 방식으로 직접 파싱 루틴을 만들곤 했습니다. 예를 들어, METAR 보고서(공항의 기상, 구름층, 습도 등 상태를 보고하는 국제적인 준표준 형식)는 다음과 같이 보일 수 있습니다.

BIRK 281500Z 09014KT CAVOK M03/M06 Q0980 R13/910195

여기서 세 번째 “단어”, 즉 09014KT에는 바람에 대한 정보가 들어 있습니다. 바람 속도(일단 단위인 노트는 무시하고 14만)를 추출하고 싶다면, 다음과 같은 방식을 사용할 수 있습니다.

windSpeed :: String -> Maybe Int
windSpeed windInfo =
    let
        -- 풍향 제거
        speed = drop 3 windInfo
    in
        -- "knots" 단위를 제거하고 숫자로 변환
        readMaybe (take (length speed - 2) speed)

몇 달 후에 보면[1] 이 코드가 읽기 어렵다는 점을 무시하더라도, 이 방식은 안정적이지 않습니다. 일부 METAR 보고서는 바람 속도를 m/s 단위로 표기하는데, 이런 데이터를 함수에 넣으면 어떻게 되는지 보십시오.

λ> windSpeed "09007MPS"
Nothing

Nothing을 반환하는 걸까요? 코드 안의 마법 상수 중 하나, 즉 코드에 있는 length speed - 2가 m/s 단위로 적힌 바람 속도에는 적용되지 않기 때문입니다. 우리의 코드는 노트(knots)에 맞춰져 있었던 거죠. 이런 경우 바람 속도 문자열의 첫 글자를 확인해서 해결할 수도 있지만, 이쯤 되면 코드가 이미 꽤 복잡해집니다.
이 방법이 분명 좋은 접근법은 아님이 드러납니다.

정규 표현식

프로그래밍 실력이 조금 늘었을 때, 저는 정규 표현식에 대해 알게 되었습니다. 아, 그 작고도 멋진, 마법 같은 문자들의 간결한 조합들 말이죠. 분명 이것들을 이용해서 어느 정도 파싱을 할 수 있지 않을까요?
물론 가능합니다. 다음은 우리의 windSpeed 함수의 새로운 버전입니다.

windSpeed :: String -> Maybe Int
windSpeed report =
    case matchRegex "[0-9]{3}([0-9]{2,3})(KT|MPS)" report of
        Just (speed, unit) -> readMaybe speed
        Nothing -> Nothing

이 코드는 이전 코드보다 몇 가지 면에서 실제로 나아졌습니다. 우선, m/s 단위의 측정값에서도 오류가 발생하지 않습니다. 둘째, 단위를 추출할 수 있는데, (교육 목적상 사용하지는 않았지만) 이를 이용해 속도 값을 더 유용하고 표준화된 단위로 변환할 수도 있습니다.
하지만 여전히 이전 코드와 비슷한 문제를 안고 있습니다. 가장 눈에 띄는 것은 유지보수가 어렵다는 점입니다. 정규 표현식 자체가 문자들의 뒤죽박죽 조합(soup)이라, 각 부분이 어떤 역할을 하는지 구분하기가 쉽지 않습니다.

파서 콤비네이터

바로 이때 파서 콤비네이터가 등장합니다. 정규 표현식만큼 쉽게 작성할 수 있으면서도, 훨씬 유지보수가 용이합니다. 제대로 된 파서를 작성하는 것을 편리하게 만들어 주죠. 파서 콤비네이터를 사용하면 windSpeed 함수는 약간 다르게 보입니다.

windSpeed :: String -> Maybe Int
windSpeed windInfo =
    parseMaybe windSpeedParser windInfo

windSpeedParser :: ReadP Int
windSpeedParser = do
    direction <- numbers 3
    speed <- numbers 2 <|> numbers 3
    unit <- string "KT" <|> string "MPS"
    return speed

이 작은 예제에서는 확실히 “코드 양이 더 많아졌습니다”. 하지만 windSpeedParser가 얼마나 읽기 쉬운지 보세요. 파서 콤비네이터 경험이 전혀 없어도, 대충 내용을 이해할 수 있을 정도입니다. 먼저 방향(direction)이 나오는데, 세 자리 숫자입니다. 그다음 속도(speed)가 나오는데, 두 자리 또는 세 자리 숫자입니다. 마지막으로 단위(unit)가 나오는데, KT 또는 MPS입니다. 이를 통해 속도를 반환합니다. 파서 자체는 무엇을 파싱하는지에 대한 설명이라고 보면 됩니다.
이 작은 예제에서는 얼마나 멋진지 바로 느끼지 못할 수도 있지만, 더 큰 데이터를 파싱할 때는 파서를 바로 읽고 이해할 수 있다는 점이 매우 유용합니다. 파서 콤비네이터 기반 파서는 큰 작업에도 훌륭하게 확장됩니다.

ReadP

하스켈 표준 라이브러리에는 작지만 꽤 쓸만한 파서 생성 라이브러리가 포함되어 있습니다. Text.ParserCombinators.ReadP. 데이터를 처리하기 위해 직접 파서를 작성해야 할 때는, 가장 먼저 이 라이브러리를 사용하는 것이 좋습니다. 문자열을 나누는 방식은 잊으세요. 정규 표현식도 잊으세요. 이제부터는 ReadP가 정답입니다.[2]

import Text.ParserCombinators.ReadP

isVowel :: Char -> Bool
isVowel char =
    any (char ==) "aouei"

vowel :: ReadP Char
vowel =
    satisfy isVowel

먼저 isVowel이라는 도움 함수를 만듭니다. 이 함수는 전달된 문자가 모음이면 True를 반환합니다. 내부적으로는, 인자로 받은 문자가 문자열 "aouei" 안의 문자와 일치하는지 확인하는 방식으로 동작합니다.
그다음 isVowel을 파서 vowel에서 사용합니다. 여기서 satisfy 함수는 ReadP 라이브러리의 핵심 함수 중 하나입니다. 이 함수는 매우 중요하므로, 먼저 타입 시그니처를 살펴보겠습니다.

satisfy :: (Char -> Bool) -> ReadP Char

satisfyChar -> Bool 타입의 함수를 받아, 해당 함수의 조건을 통과하는 문자를 파싱하는 파서를 반환합니다. 여기서는 isVowel을 전달했으므로, 단일 모음을 파싱하는 파서를 얻는 것입니다. 마찬가지로, 단일 숫자를 파싱하는 satisfy isDigit 파서나, 단일 공백 문자만 파싱하고 다른 문자가 들어오면 실패하는 satisfy (== ' ') 파서도 생각해볼 수 있습니다.
아, 아직 명확하지 않을까 봐 덧붙이자면, 타입이 ReadP Char인 값은 문자를 파싱하고 Char 값을 반환하는 파서입니다. 타입이 ReadP Float인 파서도 문자들을 파싱하지만, 결과로 Float 값을 반환합니다. 즉, ReadP something이라는 타입을 볼 때마다 내부적으로는 “something을 파싱하는 파서”라고 이해하면 됩니다.

하지만 파서 자체가 곧바로 입력을 받는 함수는 아닙니다. 파서를 실제 입력에 적용하려면 다른 함수로 실행해야 합니다. ReadP의 경우, 이 역할을 하는 함수가 readP_to_S입니다.(이름이 다소 혼란스러울 수 있습니다.) 이 함수는 파서와 입력을 받아, 파서를 해당 입력에 실행합니다. 이제 이 함수를 이용해 vowel 파서를 테스트할 수 있습니다. 타입 시그니처를 풀어서 보면 다음과 같습니다.

readP_to_S :: ReadP a -> String -> [(a, String)]

readP_to_S의 출력은 처음에는 조금 낯설게 보일 수 있지만, 여러 예제를 살펴보면 금세 의미를 파악할 수 있습니다. 본질적으로 readP_to_S는 성공한 파싱 결과들의 리스트를 반환합니다. 여기서 “파싱 결과”란 대략 (파싱된_값, 남은_문자열) 형태의 2-튜플을 의미합니다. 만약 파서가 실패한다면(즉, 입력의 시작 부분에서 아무것도 파싱하지 못하면) 빈 리스트를 반환합니다. 실행 예시는 다음과 같습니다.

λ> readP_to_S vowel "e"
[('e',"")]
λ> readP_to_S vowel "k"
[]
λ> readP_to_S vowel "another one bites the dust"
[('a',"nother one bites the dust")]
λ> readP_to_S vowel "did you see that"
[]

튜플의 첫 번째 요소는 성공적으로 파싱된 값이고, 두 번째 요소는 아직 파싱되지 않은 문자열의 나머지입니다.
문자열이 모음으로 시작하지 않으면, 파서는 완전히 실패합니다. 파서는 자동으로 관련 없는 문자를 건너뛰지 않으며, 이런 처리는 파서를 작성하는 사람에게 맡겨집니다. 이러한 높은 제어권은 때로는 불편할 수 있지만, 일반적으로는 유용합니다.
하지만 단일 모음을 읽는 것만큼 흥미로운 것은 여러 모음을 연속으로 읽는 것입니다. readP_to_S가 입력의 남은 문자열을 반환하므로, 우리는 여러 파서를 연쇄(chain)하여 사용하는 함수를 작성할 수 있다고 생각할 수 있습니다.

atLeastOne :: ReadP Char -> String -> [(String, String)]
atLeastOne parser input =
    case readP_to_S parser input of

        -- 빈 리스트는 파싱 실패를 의미하므로,
        -- 이 파서도 실패해야 합니다
        [] -> []

        -- 최소 한 글자 파싱에 성공했으므로,
        -- 재귀적으로 atLeastOne을 호출하여 더 많은 글자를 파싱 시도
        [(char, remainder)] ->
            case atLeastOne parser remainder of

                -- 성공적으로 파싱했지만 다음 시도에서 실패한 경우,
                -- 단일 성공 결과를 반환
                [] -> [(char:"", remainder)]

                -- 재귀 호출에 성공한 경우,
                -- 우리의 결과를 이어 붙이고 남은 입력과 함께 반환
                [(str, finalRemainder)] ->
                    [(char:str, finalRemainder)]

이 방식도 작동은 하지만, 아래에서 보듯이 매우 좋지 않은 방법입니다.

λ> atLeastOne vowel "aouibcdef"
[("aoui","bcdef")]
λ> atLeastOne vowel "gjshifu"
[]

atLeastOne이 좋지 않을까요? 우선, 이 함수는 취약하고(파서 콤비네이터가 기대하는 방식과 맞지 않으며), 작성하기도 매우 번거롭습니다. 나중에 코드를 읽으려 해도 명확하지 않습니다.
바로 이 지점에서 파서 콤비네이터의 콤비네이터 개념이 등장합니다. atLeastOne 함수는 파싱된 결과를 직접 다뤘지만, 우리가 사용하고자 하는 콤비네이터 함수들은 파서 자체를 다룹니다.
예를 들어, Text.ParserCombinators.ReadP에는 우리가 원하는 동작을 정확히 수행하는 many1 콤비네이터 함수가 있습니다. 이 함수의 타입 시그니처는 다음과 같습니다.

many1 :: ReadP a -> ReadP [a]

즉, 단일 a를 파싱하는 파서(우리의 경우 Char)를 받아서, 여러 개의 a를 파싱하는 파서를 반환합니다. 여기서 “여러 개”란 최소 한 개 이상이지만, 잠재적으로는 무한히 많을 수도 있습니다.
이 콤비네이터를 이용하면, 다음과 같이 만들 수 있습니다.

atLeastOneVowel :: ReadP [Char]
atLeastOneVowel =
    many1 vowel

그리고 보십시오! 아마 기대했던 것과는 조금 다를지도 모릅니다.

λ> readP_to_S atLeastOneVowel "aouibcdef"
[("a","ouibcdef")
,("ao","uibcdef")
,("aou","ibcdef")
,("aoui","bcdef")]

이제 readP_to_S가 왜 리스트를 반환하는지 이해할 수 있습니다. “최소 한 개의 모음”은 단지 한 글자일 수도 있고, 두 글자, 세 글자, 네 글자일 수도 있습니다. many1은 이러한 가능성들을 모두 반환하여, 원하는 결과를 선택할 수 있도록 해줍니다.
처음 보면 문제가 있어 보일 수 있지만, 대부분의 경우 파싱 가능한 결과가 하나뿐이어서 실제로는 크게 신경 쓰이지 않습니다.

METAR

이제 처음에 살펴본 METAR 보고서 예제로 돌아가서, 믿기지 않겠지만 이제 실제로 파싱을 시작할 수 있습니다! METAR 보고서의 첫 번째 단어는 보고서를 보낸 공항의 ICAO 코드명입니다. 이는 최소 한 개 이상의 대문자로 이루어져 있습니다.

airport :: ReadP String
airport =
    many1 (satisfy (\char -> char >= 'A' && char <= 'Z'))
λ> readP_to_S airport "BIRK 281500Z 09014KT CAVOK M03/M06 Q0980 R13/910195"
[("B","IRK 281500Z 09014KT CAVOK M03/M06 Q0980 R13/910195")
,("BI","RK 281500Z 09014KT CAVOK M03/M06 Q0980 R13/910195")
,("BIR","K 281500Z 09014KT CAVOK M03/M06 Q0980 R13/910195")
,("BIRK"," 281500Z 09014KT CAVOK M03/M06 Q0980 R13/910195")
]

이 대문자들 뒤에는 공백이 따라옵니다. 지금까지 우리는 “이것을 파싱하고, 그다음에 저것을 파싱하라”라는 식의 파서를 만들어본 적이 없습니다. 이를 가능하게 하려면, 파서가 일종의 모나드라는 점을 활용해 do 구문으로 작성할 수 있습니다.

airport :: ReadP String
airport = do
    code <- many1 (satisfy (\char -> char >= 'A' && char <= 'Z'))
    satisfy (== ' ')
    return code

many1 파서의 결과를 code 변수에 저장한 뒤, 한 개의 공백 문자를 파싱하고, 마지막으로 우리가 파싱한 ICAO 코드를 반환합니다. 여기서 주의할 점은, 이것이 하스켈의 return이라는 것입니다. 일반적인 프로그래밍 언어처럼 함수를 “즉시 종료하고 값을 반환”하는 의미가 아니라, 하스켈에서는 함수의 마지막 값이 자동으로 반환되기 때문에 return을 명시할 필요가 없습니다. 대신, 파서의 문맥에서 return은 이렇게 이해할 수 있습니다.

return :: a -> ReadP a

이 설명만으로는 와닿지 않을 수 있지만, return은 어떤 값을 받아서, 아무것도 파싱하지 않으면서도 그 값을 결과로 내놓는 파서를 만드는 함수입니다. 예를 들어, return 4는 어떤 입력을 주더라도 무조건 성공하며, 입력을 전혀 소비하지 않고 항상 숫자 4를 “파싱”한 것처럼 결과를 돌려줍니다.

검사하기

METAR 보고서의 다음 부분은 시간과 날짜 표기입니다. 형식은 다음과 같습니다.

<day of month><hours><minutes>Z

즉, 우리는 두 자리 숫자를 파싱해야 합니다! 먼저, 한 자리 숫자는 어떻게 파싱할까요? 아마 이미 눈치채셨을 겁니다.

digit :: ReadP Char
digit =
    satisfy (\char -> char >= '0' && char <= '9')

이제 두 자리 숫자는 어떻게 파싱할까요? 바로 콤비네이터의 힘을 씁니다! ReadP 모듈에는 count라는 함수가 있는데, 이 함수는 지정한 파서를 정확히 n번 연속으로 실행합니다. 따라서 두 자리 숫자를 파싱하려면 count 2 digit라는 파서를 만들면 됩니다.
이 시점에서 우리의 전체 타임스탬프 파서는 다음과 같습니다.

timestamp :: ReadP (Int, Int, Int)
timestamp = do
    day <- count 2 digit
    hour <- count 2 digit
    minute <- count 2 digit
    string "Z "
    return (read day, read hour, read minute)

여기서는 string 함수를 사용했는데, 이 함수는 지정한 정확한 문자열과 일치하는지 확인합니다. 이는 다음과 같이 직접 작성하는 대신 사용할 수 있는 편리한 기능입니다.

satisfy (== 'H')
satisfy (== 'e')
satisfy (== 'l')
satisfy (== 'l')
satisfy (== 'o')

그냥 string "Hello"처럼 쓸 수 있습니다. 이제 string 함수는 ReadP 모듈에 존재하지만, 만약 존재하지 않는다면 어떻게 문자열을 받아 그 안의 모든 문자를 순서대로 파싱하는 함수를 만들 수 있을까요? 이것은 하스켈에서 파서 콤비네이터를 진짜로 이해하는 데 좋은 연습입니다. 지금 한 번 시도해보고, 못하겠다면 나중에 다시 돌아와서 도전해보세요!
잠깐만요! 이렇게 말하는 분이 있을지도 모르겠습니다. 여기서 read를 쓰고 있는데, 그거… 프로그램을 크래시 시킬 수도 있지 않나요? 보통은 맞습니다. 하지만 이번 경우에는 문자열이 숫자로만 이루어져 있다는 것을 알고 있기 때문에, Int로 안전하게 변환할 수 있습니다. 걱정하지 않으셔도 됩니다!
하지만 이 함수에는 몇 가지 다른 문제도 있습니다. 그중 하나에 집중해 보겠습니다.
첫 번째 숫자가 보고서가 작성된 월의 날짜라고 했는데, 정말로 다음과 같은 일이 일어나야 할까요?

λ> readP_to_S timestamp "888990Z "
[((88,89,90),"")]

한 달에 88일은 언제일까요? 그리고 시간 89:90은 하루 중 언제일까요? 분명히, 파싱한 값이 말이 되는지 확인할 필요가 있습니다. 이를 준비하기 위해, 타임스탬프 파서에 작은 수정을 가해보겠습니다.

timestamp :: ReadP (Int, Int, Int)
timestamp = do
    day <- fmap read (count 2 digit)
    hour <- fmap read (count 2 digit)
    minute <- fmap read (count 2 digit)
    string "Z "
    return (day, hour, minute)

여기서 우리가 한 것은 단지 read 호출 위치를 위로 옮긴 것뿐입니다. 기억하시겠지만, 펑터(functor)는 함수 하나를 “맵(map)”할 수 있는 대상이며, 파서도 펑터입니다! 즉, 파서에 fmap read를 적용하면, 파싱된 결과에 대해 read가 실행됩니다. 타입 시스템에 익숙한 분이라면, 다음 한 줄짜리 설명이 더 직관적일 수도 있습니다.

fmap read :: Parser String -> Parser Int

파싱된 결과에 바로 read를 적용하면, 이제 day, hour, minute 변수들이 정수형이 되므로, 값이 너무 크거나 작은지 쉽게 확인할 수 있습니다! 이 점은 반복되는 코드를 함수로 만들어보면 더욱 명확해집니다.

timestamp :: ReadP (Int, Int, Int)
timestamp = do
    day <- numbers 2
    hour <- numbers 2
    minute <- numbers 2
    string "Z "
    return (day, hour, minute)

numbers :: Int -> ReadP Int
numbers digits =
    fmap read (count digits digit)

펑터에 아직 익숙하지 않다면, 다음과 같이 동일한 동작을 하는 정의를 사용할 수도 있습니다.

numbers :: Int -> ReadP Int
numbers digits = do
    parse <- count digits digit
    return (read parse)

이제 파싱한 숫자들의 유효성을 확인할 수 있습니다.

timestamp :: ReadP (Int, Int, Int)
timestamp = do
    day <- numbers 2
    hour <- numbers 2
    minute <- numbers 2
    string "Z "
    if day < 1 || day > 31 || hour > 23 || minute > 59 then
        pfail
    else
        return (day, hour, minute)

이 부분은 거의 설명이 필요 없을 정도입니다. 날짜(day) 숫자는 1보다 작거나 31보다 크면 안 됩니다. 그렇다면 pfail 파서를 반환하는데, pfail은 어떤 입력에 대해서도 항상 실패하는 파서입니다. 모든 숫자가 규격 내에 있으면, 그 값을 반환합니다. 인터프리터에서 제대로 동작하는지 테스트해볼 수 있습니다.

λ> readP_to_S timestamp "888990Z "
[]
λ> readP_to_S timestamp "302359Z "
[((30,23,59),"")]

잘못된 타임스탬프는 파싱되지 않고, 올바른 타임스탬프는 문제없이 파싱됩니다!
이 시점에서, UTC 타임스탬프를 세 숫자의 튜플로 반환하는 것이 좀 어리석게 느껴질 수도 있습니다. 맞습니다. 실제로는 UTCTime 타입으로 반환하는 것이 더 적절합니다. 하지만 METAR 보고서에는 월과 연도 정보가 없고, 오직 날짜(day)만 포함되어 있기 때문에, 연도와 월에 더미 값을 넣고 나중에 올바른 값으로 교체해야 하는 복잡성이 생깁니다. 이 부분은 이 튜토리얼의 범위를 벗어나지만, 연습 삼아 시도하고 싶다면 하스켈 시간(time) 라이브러리에 익숙해진 후 도전해보길 권장합니다.

선택 파서

METAR 보고서의 다음 부분은 바람 정보입니다. 바람의 속도와 방향입니다. 바람 정보는 세 부분으로 이루어져 있습니다. 먼저 세 자리 숫자가 바람 방향을 나타내며, 단위는 도입니다. 그다음 두 자리 또는 세 자리 숫자가 바람 속도입니다. 마지막으로 "kt" 또는 "mps"가 따라오는데, 각각 노트(knots) 단위와 미터/초(m/s) 단위를 나타냅니다.
여기서 “또는(or)”이 많이 등장합니다. 어떤 값이 이거나 저것일 수 있다는 뜻이죠. 다행히도, ReadP에서는 이를 간단히 처리할 수 있습니다. <|> 연산자를 사용하면, 여러 개의 하위 파서를 시도하고 가장 먼저 성공한 결과를 반환하도록 지정할 수 있습니다. <|>를 사용하려면 Control.Applicative 모듈을 임포트해야 합니다.
(참고로, 조금 더 고급 단계의 초보자에게 <|> 연산자는 Alternative 타입클래스의 일부로, 파서 외에도 여러 곳에서 사용할 수 있습니다. 하지만 여기서는 파서에 한정해서 생각하셔도 됩니다.)
먼저, 바람 방향을 나타내는 세 자리 숫자를 파싱해봅시다.

windInfo :: ReadP Int
windInfo = do
    direction <- numbers 3
    return direction

다음으로, 바람 속도를 나타내는 두 자리 또는 세 자리 숫자를 파싱해야 합니다. 앞서 배운 <|> 연산자를 사용하면, 파서는 다음과 같이 작성할 수 있습니다.

import Control.Applicative

windInfo :: ReadP (Int, Int)
windInfo = do
    direction <- numbers 3
    speed <- numbers 2 <|> numbers 3
    return (direction, speed)

마찬가지로, 바람 속도의 단위("kt" 또는 "mps")도 파싱해야 합니다. 마지막으로 공백 문자도 파싱해야 합니다.

windInfo :: ReadP (Int, Int, String)
windInfo = do
    direction <- numbers 3
    speed <- numbers 2 <|> numbers 3
    unit <- string "KT" <|> string "MPS"
    string " "
    return (direction, speed, unit)

이 단계에서, 바람 속도를 적절한 단위로 변환하는 것도 좋습니다. 특별한 이유는 없지만, 속도의 유일한 실제 단위로서 미터/초(m/s)를 선택해봅시다.

windInfo :: ReadP (Int, Int)
windInfo = do
    direction <- numbers 3
    speed <- numbers 2 <|> numbers 3
    unit <- string "KT" <|> string "MPS"
    string " "
    return (direction, toMPS unit speed)

toMPS :: String -> Int -> Int
toMPS unit speed =
    case unit of
         "KT" -> div speed 2
         "MPS" -> speed

중간 값을 이렇게 접근할 수 있다는 점이 파서를 모나드(monad)라고 부르는 이유입니다. 파서 내부에서 단위 값을 확인하고, 단위에 따라 속도 값을 다른 계산에 활용할 수 있는 것도 파서가 모나드이기 때문입니다. 만약 파서가 모나드가 아니었다면, 이미 파싱된 값을 기반으로 다른 값을 계산하는 방식은 불가능했을 것입니다.

Maybe

좋아요, 사실을 조금 숨겼습니다. 죄송합니다. 바람 정보가 처음 말한 것처럼 간단하지 않습니다. 예를 들어, 남쪽에서 27노트의 일정한 바람이 불고, 31노트의 돌풍(gust)이 함께 있을 경우, 이는 18027G31KT로 표기됩니다. 즉, 돌풍 속도는 G를 넣고 두 자리 또는 세 자리 숫자로 나타내며, 단위는 일반 바람 속도와 같습니다.
돌풍 속도는 METAR 보고서에 없을 수도 있기 때문에, 입력에 존재하지 않을 수도 있는 값을 파싱하는 방법이 필요합니다. 이를 위해, ReadP 모듈에는 option 함수가 있습니다.

option :: a -> ReadP a -> ReadP a

option 함수는 두 개의 인자를 받습니다. 하나는 값(value), 다른 하나는 파서(parser)입니다. 이 함수는 파서를 실행해보지만, 파서가 실패하면 대신 지정한 값을 반환합니다. 먼저, 돌풍(gust) 파서를 만들어봅시다!(참고로, 하스켈 do 구문에서는 함수의 마지막 값이 자동으로 반환되므로 return이 필요하지 않습니다. return은 파싱 결과가 미리 정해진 값을 갖도록 하고 싶을 때만 사용합니다.)

gustParser :: ReadP Int
gustParser = do
    satisfy (== 'G')
    numbers 2 <|> numbers 3

이제 이 파서를 바람 정보 파서에 바로 포함시킬 수도 있습니다. 예를 들어 이렇게 작성할 수 있습니다.

windInfo :: ReadP (Int, Int, Int)
windInfo = do
    direction <- numbers 3
    speed <- numbers 2 <|> numbers 3
    gusts <- gustParser 
    unit <- string "KT" <|> string "MPS"
    string " "
    return (direction, toMPS unit speed, toMPS unit gusts)

하지만 문제는, 이제 이 파서는 돌풍 정보가 없는 바람 정보 섹션에서는 실패하게 된다는 점입니다. 그래서 option을 사용해보는 것입니다.

windInfo :: ReadP (Int, Int, Int)
windInfo = do
    direction <- numbers 3
    speed <- numbers 2 <|> numbers 3
    gusts <- option 0 gustParser 
    unit <- string "KT" <|> string "MPS"
    string " "
    return (direction, toMPS unit speed, toMPS unit gusts)

좋습니다… 단 한 가지 예외를 제외하면요. 다음을 살펴보세요.

λ> readP_to_S windInfo "09014KT "
[((90,7,0),"")]

읽기에는 그리 좋은 출력은 아니지만, 지금 결과는 바람 속도 7 m/s, 돌풍 0 m/s라는 의미입니다. 이럴 때는 아마도 Maybe 타입을 사용하는 것이 더 적절합니다.

windInfo :: ReadP (Int, Int, Maybe Int)
windInfo = do
    direction <- numbers 3
    speed <- numbers 2 <|> numbers 3
    gusts <- option Nothing (fmap Just gustParser)
    unit <- string "KT" <|> string "MPS"
    string " "
    return (direction, toMPS unit speed, fmap (toMPS unit) gusts)

여기서도 다시 한 번, 파서가 펑터라는 점을 활용합니다.

fmap Just gustParser :: ReadP (Maybe Int)

또한, gusts 변수에도 fmap을 적용하여, Maybe Int 값을 미터/초(m/s) 단위로 변환합니다.
마지막으로, 바람 정보 파서에서 꼭 터무니없는 튜플을 반환할 필요는 없다는 점을 보여드리겠습니다. 예를 들어, 다음과 같이 데이터 타입을 정의할 수 있습니다.

data WindInfo = WindInfo
    { dir :: Int
    , speed :: Int
    , gusts :: Maybe Int
    }
    deriving Show

그럼 이제 파서가 그 데이터 타입 값을 반환하도록 할 수 있습니다.

windInfo :: ReadP WindInfo
windInfo = do
    direction <- numbers 3
    speed <- numbers 2 <|> numbers 3
    gusts <- option Nothing (fmap Just gustParser)
    unit <- string "KT" <|> string "MPS"
    string " "
    return (WindInfo
        direction
        (toMPS unit speed)
        (fmap (toMPS unit) gusts))

이렇게 하면 출력 결과가 훨씬 읽기 편해지고, 더 중요한 것은 자신의 코드에서 다루기도 훨씬 편해집니다.

λ> readP_to_S windInfo "09014KT "
[(WindInfo {dir = 90, speed = 7, gusts = Nothing},"")]

통합하기

지금까지 작성한 파서들을 결합하면, METAR 보고서를 파싱하기 위한 탄탄한 출발점이 됩니다. 파서를 결합하는 방법은 지금까지 해왔던 것과 같은 방식으로 진행합니다. 이제 “파서 콤비네이터”에서 “콤비네이터”가 어디서 왔는지 이해되시나요? 우리는 파서를 가지고 많이 결합(combinate)합니다. 이것이 파서가 다루기 편한 이유 중 하나입니다. 큰 파서도 작은 파서만큼이나 간단하게 작성할 수 있습니다. 또한, METAR 보고서의 처음 몇 필드를 위한 데이터 타입도 정의합니다.

data Report = Report
    { station :: String
    , time :: (Int, Int, Int)
    , wind :: WindInfo
    }
    deriving Show

metar :: ReadP Report
metar = do
    code <- airport
    time <- timestamp
    wind <- windInfo
    return (Report code time wind)

그리고 이 방법은 실제로 잘 작동합니다!

λ> readP_to_S metar "BIRK 281500Z 09014G17KT CAVOK M03/M06 Q0980 R13/910195"
[
    ( Report
        { station = "BIRK"
        , time = (28,15,0)
        , wind = WindInfo
            { dir = 90
            , speed = 7
            , gusts = Just 8
            }
        }
    , "CAVOK M03/M06 Q0980 R13/910195"
    )
]

음… 아직 METAR 보고서 전체를 파싱하는 것은 아닙니다. 보고서에는 시정(visibility), 구름층(cloud layers), 강수량(precipitation), 기압(atmospheric pressure), 기온(temperature), 활주로 상태(runway conditions) 등 많은 정보가 포함됩니다. 예를 들어, BIRK 보고서의 나머지 내용은 다음과 같습니다. 하늘은 맑음("cavoc"), 기온은 -3°C, 이슬점은 -6°C("M03/M06"), 기압은 0.98 kPa("Q0980") 활주로 13에는 약간의 서리가 있으나 브레이크에는 지장이 없음("R13/910195").
이 튜토리얼에서는 모든 정보를 다루는 전체 파서를 구현하지는 않겠습니다. 하지만 지금까지 배운 내용을 통해, 원한다면 전체 METAR 보고서를 파싱하는 데 필요한 모든 지식을 갖춘 셈입니다. 더 많은 METAR 보고서에 접근하고 싶다면, NWS Decoded METAR Reports에서 받을 수 있습니다. 관심 있는 공항의 ICAO 코드로 파일 이름의 네 글자를 바꾸면 됩니다. 만약 사람이 해석한 내용 없이 원본 METAR 보고서만 보고 싶다면, URL에서 "decoded"를 "stations"로 바꾸면 됩니다.
만약 전체 파서를 작성하게 된다면, 라이브러리로 만들어 Hackage에 제출해도 좋습니다. 현재까지 Haskell에는 METAR 파서 라이브러리가 없기 때문입니다.[3] 만약 제출하지 않더라도, 이번 튜토리얼을 통해 다음에 파서가 없는 데이터를 만나면 ReadP를 자신 있게 사용할 수 있는 능력을 갖추셨기를 바랍니다.

아, 그리고 처음 데모에서 사용한 parseMaybe 함수는요? 라이브러리에는 존재하지 않지만, 다음과 같이 쉽게 구현할 수 있습니다.

parseMaybe :: ReadP a -> String -> Maybe a
parseMaybe parser input =
    case readP_to_S parser input of
        [] -> Nothing
        ((result, _):_) -> Just result

또는 그와 유사한 다른 형태로도 구현할 수 있습니다.


  1. 왜 이 숫자 2가 튀어나오는 거지? 속도 값의 길이가 무슨 의미가 있는 걸까? ↩︎

  2. 하스켈에는 Attoparsec, Parsec, Megaparsec, Turtle.Pattern, Earley 등 여러 파서 콤비네이터 라이브러리가 있습니다. 각기 다른 용도에 적합한 라이브러리들이죠. 제가 ReadP를 추천하는 이유는, 물론 성능이 좋기 때문이기도 하지만, GHC를 설치했다면 이미 컴퓨터에 ReadP가 포함되어 있기 때문입니다! 하지만 Parsec 같은 좀 더 “배터리 포함(batteries included)”형 라이브러리를 배우고 싶으신가요? 문제없습니다. 이 튜토리얼에서 배우는 내용은 다른 파서 콤비네이터 라이브러리에서도 그대로 활용할 수 있습니다. 사실 저는 여러 라이브러리에서 이름이 동일하게 쓰이는 함수와 연산자만 가르치기로 했습니다. 따라서 이 튜토리얼을 보고 나면, 배운 내용을 Parsec, Attoparsec 등 다른 라이브러리에서도 그대로 사용할 수 있습니다.
    파서 콤비네이터에 익숙해지기 위해, 제가 생각할 수 있는 가장 간단한 파서부터 시작해봅시다. 바로 한 글자의 모음을 파싱하는 것입니다. ↩︎

  3. 역자주: 현재는 avwx라는 METAR 파서 라이브러리가 존재한다. ↩︎

11

1 comment

If you have a fediverse account, you can comment on this article from your own instance. Search https://hackers.pub/ap/articles/0198aaa2-0c08-73fe-b309-16aba57f325c on your instance and reply to it.

1