React 등의 SPA 프레임워크에 대한 대안이 없을까 찾던 중 Elm이라는 언어이자 프레임워크를 발견했다. 아직 정식 버전이 없긴 하지만, 함수형 프로그래밍이란 것이 무엇인지도 경험해볼 겸 시도해보기로 했다. 우선 함수형 프로그래밍이 무엇인지부터 살펴본 뒤, Elm의 특징과 사용법에 대해 살펴보고자 한다.

함수형 프로그래밍의 주요 개념

순수 함수

순수 함수는 자신에게 입력으로 주어진 파라미터만을 조작할 뿐, 외부의 어떤 것과도 상호작용(=부작용을 낳는)하지 않는다. 함수형 프로그래밍은 비순수 함수를 최소화하고, 이들을 순수 함수로부터 분리하는 것을 목표로 한다.

var dirty = 10;
function pure(x, y) {
  return x + y;
}

불변성

함수형 언어에서는 변수가 존재하지 않으며, 모든 값은 상수다. 어떤 값을 수정하려면, 값을 복사하는 것이 유일한 방법이다. 레코드의 복사본을 만들되 수정사항이 반영된 복사본을 만드는 방식을 사용한다.

addOne y =
  let
    x = 1
    x = 2 -- NAME CLASH : How can I know which one you want? Rename one of them!
  in
    x + y

고차 함수

함수를 파라미터로 받거나, 함수를 리턴할 수 있는… 고차원의 함수를 의미한다.

클로저

고차 함수가 함수를 리턴할 때, 리턴 후에도 함수 내의 변수를 수정할 수 있도록 허용하는 현상을 말한다. 함수형 언어에서는 불변성 덕분에 클로저로 인해 발생할 수 있는 위험 없이 고차함수를 사용할 수 있다.

합성 함수

기존의 함수들을 조합하여 반환한 새로운 함수를 합성 함수라고 하며, 입력 흐름은 화살표 방향을 따른다. 합성 함수는 파이프라인 연산자( >)보다 높은 우선 순위를 갖는데, 함수를 먼저 합성한 후에 값을 넣는다는 것을 의미한다.
a x = x + 10 -- <function> : number -> number
m x = x * 2 -- <function> : number -> number
ma x = (m << a) x -- <function> : number -> number **합성**

Point-Free 표기법

합성 함수를 만들 때 파라미터의 반복을 생략하는 함수 표기법이다.

a x = x + 10 -- <function> : number -> number
m x = x * 2 -- <function> : number -> number
ma x = (m << a) x -- <function> : number -> number **포인트프리 X**
mafree = m << a -- <function> : number -> number **포인트프리 O**

커링

Partial Application를 사용하여 파라미터가 하나 이상인 함수를 파라미터가 하나인 함수(커링 함수)들로 쪼개는 것. 파라미터 순서를 고려한 커링을 활용하여 합성 함수를 만들 수 있다. Elm에서 모든 함수는 커링 함수이다.

a x = x + 10 -- <function> : number -> number
m x = x * 2 -- <function> : number -> number
ma x = (m << a) x -- <function> : number -> number **합성**
mafree = m << a -- <function> : number -> number **포인트프리**
(ma 90, mafree 90) -- (200,200) : ( number, number1 )

aa x y = x + y -- <function> : number -> number -> number
aa 10 -- <function> : number -> number **커링**
c = aa 10 -- <function> : number -> number
(aa 10 10, (aa 10) 10, c 10) -- (20,20,20) : ( number, number1, number2 )

maa = m << aa 10 -- <function> : number -> number **포인트프리+커링+합성**
maa2 = m << (aa 10) -- <function> : number -> number
maa3 = m << c -- <function> : number -> number
(maa 90, maa2 90, maa3 90) -- (200,200,200) : ( number, number1, number2 )

참조 투명성

순수 함수가 외부의 어떤 것도 참조하지 않기 때문에 표현식으로 대체될 수 있는 성질을 의미한다.

dot s = s ++ "..."
scream s = (dot s) ++ "!"
transparent s = (s ++ "...") ++ "!"

병렬 처리

외부에 영향을 미치지 않는 순수 함수는 다른 순수 함수의 출력을 입력으로 받지 않는 한 병렬로 실행될 수 있다.

Elm 문법

값은 불변이다.

1 + 1 -- 2 : number
"string_" ++ "value" -- "string_value" : String

함수는 커링 함수이다. 꼭 필요한 경우가 아니면 파라미터 부분의 괄호는 생략할 수 있다.

add x y = x + y -- <function> : number -> number -> number
hello to = "Hello " ++ to -- <function> : String -> String
hello "world" -- "Hello world" : String

If 역시 표현식이다.

isOdd n = if (modBy 2 n) /= 0 then True else False -- <function> : Int -> Bool

리스트는 같은 타입만 허용한다.

List.isEmpty [] -- True : Bool
List.map (\n -> isOdd n) [1,2,3,4,5] -- [True,False,True,False,True] : List Bool
List.length [1,2,3] -- 3 : Int
List.reverse [1,2,3] -- [3,2,1] : List number
List.sort [99,200,5,-1,0] -- [-1,0,5,99,200] : List number
[1, 2, "3"] -- TYPE MISMATCH (같은 타입만 허용)

튜플은 다른 타입을 허용하지만 최대 3개까지만 담을 수 있다.

(1,2,"3") -- (1,2,"3") : ( number, number1, String )
(1,2,3,4) -- BAD TUPLE (최대 3개까지만 허용)

레코드는 자동으로 필드 액세스 함수를 생성한다. 수정사항을 반영한 복사본 생성을 통해서만 업데이트할 수 있다.

e = { word = "Elm", len = 3 } -- ... : { len : number, word : String }
i = { word = "Is", len = 2 } -- ... : { len : number, word : String }
g = { word = "Good", len = 4 } -- ... : { len : number, word : String }
List.map .word [e,i,g] -- ["Elm","Is","Good"] ... (필드 액세스 함수)
{ e | word = "Haskell" } -- { len = 3, word = "Haskell" } ... (복사하여 업데이트)
{ e | len = 0 } -- { len = 0, word = "Elm" } ... (원본은 변하지 않음)
upper r = { r | word = String.toUpper r.word }
upper e -- { ver = 0.19, word = "ELM" } ... (수정복사본을 반환하는 함수)

셋은 리스트로부터 만들 수 있다.

import Set
Set.fromList [1,1,2,3] -- Set.fromList [1,2,3] : Set.Set number

딕트도 리스트로부터 만들 수 있다.

import Dict
langs = Dict.fromList [ ("E", "Elm"), ("P", "Python") ] -- ... : Dict String String
Dict.get "E" langs -- Just "Elm" : Maybe String

타입 어노테이션을 작성하여 함수의 흐름을 쉽게 알 수 있고, 에러메시지에도 도움을 준다.

add : number -> number -> number -- (타입 어노테이션)
add x y = x + y -- <function> : number -> number -> number

타입 변수를 사용하면 제네릭이 가능하다. 단, 타입 변수 이름으로 number, appendable, comparable, compappend를 사용할 경우 제한된 타입만 허용된다.

print : a -> a
print s = s -- <function> : a -> a (a는 타입 변수, 소문자로 시작)
print 1 -- 1 : number
print "1" -- "1" : String

타입 앨리어스를 사용하면 타입 어노테이션이 깔끔해진다. 엘름 아키텍처에서 모델을 정의할 때에도 자주 사용된다. 레코드에 대해 타입 별칭을 선언하면 레코드 생성자 함수가 자동 생성된다.

type alias Model = { count : Int } -- <function> : Int -> Model
Model 10 -- { count = 10 } : Model (레코드 생성자 함수)

커스텀 타입은 추가 정보를 덧붙인 Enum 같은 모습을 하며, 데이터를 직관적으로 표현할 수 있다. 값은 함수명과 파라미터 타입으로 이루어진다. 엘름 아키텍처에서 메시지, 모델을 정의할 때 자주 사용된다.

type State = Waiting | Bad Int | Good Int
Good -- <function> : Int -> State
Good 200 -- Good 200 : State

패턴 매칭은 커스텀 타입을 사용했을 때 일어날 수 있는 모든 상황에 대응하여 런타임 에러를 없애준다. 와일드 카드를 사용할 수 있다.

checkok state =
   case state of
     Ok _ ->
       "Ok"
     _ ->
       "Not Ok"
checkok Waiting -- "Not Ok" : String

Maybe는 런타임 에러를 없애기 위해 고안된 내장 커스텀 타입이다. 값 변환 등 실패할 가능성이 있는 상황에서 사용한다. Just a 와 Nothing 두 값을 갖는다.

Nothing -- Nothing : Maybe a
Just "Something" -- Just "Something" : Maybe String
String.toFloat "1" -- Just 1 : Maybe Float

type Maybe a
    = Just a
    | Nothing

Result는 Maybe와 유사하지만 Nothing 보다는 좀 더 상세한 에러 메시지가 필요할 때 사용한다. Ok value 와 Err error 두 값을 갖는다.

Ok 200 -- Ok 200 : Result error number
Err 404 -- Err 404 : Result number value

type Result error success
  = Err error
  | Ok success

JSON 디코더는 JSON 값을 Elm에서 사용하고자 할 때 사용한다.

gifDecoder =
  field "data" (field "image_url" string)

Flag는 Elm 아키텍처에서 모델을 초기화할 때 자바스크립트로부터 초기값을 얻어올 때 사용한다.

init : Int -> ( Model, Cmd Msg )
...

Port는 Elm 아키텍처에서 지속적으로 자바스크립트와 통신할 때 사용한다. Elm에서 자바스크립트로 보낼 때는 Cmd를, 받을 때는 Sub를 활용한다.

port sendMessage : String -> Cmd msg
port messageReceiver : (String -> msg) -> Sub msg
...

합성 함수와 파이프라인

Elm 코드에서 »나 |> 같은 기호들을 종종 볼 수 있다. 이들은 Elm에서 코드를 좀 더 유연하게 작성하고 가독성을 높이는 데 도움을 주는 연산자들이다.

합성 함수 연산자(», «)는 여러 개의 함수를 합성하여 보다 큰 기능의 함수를 생성하고자 할 때 사용한다. 파이프라인보다 연산자 우선 순위가 높다.

first_digit_string_composition : List Int -> String
first_digit_string_composition list =
   list 
   |> List.map ( String.fromInt >> String.left 1 >> String.toInt >> Maybe.withDefault 0 >> String.fromInt )
      >> String.join ","

main =
   -- text <| first_digit_string_pipe [155,2555,35555]
   -- text <| first_digit_string_composition [455,5555,65555]
   text << String.join "," << List.map ( String.fromInt << Maybe.withDefault 0 << String.toInt << String.left 1 << String.fromInt )
      <| [755,8555,95555]

파이프라인 연산자(|>, <|)는 어떤 값(혹은 함수의 결과값)을 함수로 전달할 때 사용한다. 파이프라인을 하나 통과할 때마다 함수에 값을 전달하여 호출하고 결과 값을 반환한다. 성능의 관점에서 파이프라인을 여러개 중첩하기보다 합성 함수를 최대한 중첩하고 파이프라인은 최소화하는 것이 낫다. 합성 함수보다 연산자 우선 순위가 낮다.

first_digit_string_pipe : List Int -> String
first_digit_string_pipe list =
   list
   |> List.map String.fromInt
   |> List.map (String.left 1)
   |> List.map (String.toInt)
   |> List.map (Maybe.withDefault 0)
   |> List.map String.fromInt
   |> String.join ","

요약하면,

  • 합성 함수 연산자(»,«)는 함수를 합성하여 새로운 함수를 만든다. 함수 호출은 일어나지 않는다.
  • 파이프라인 연산자(|>,<|)는 함수에 값을 전달한다. 함수 호출이 일어난다.
  • 합성 함수 연산자와 파이프라인이 섞여 있을 경우, 합성 함수가 먼저 만들어지고, 그 다음에 값이 입력된다.

타입 변수, 타입 연관 데이터(페이로드)

타입 변수와 타입 연관 데이터(페이로드)는 List, Maybe, Result, Html, Cmd, Sub, Decoder 등 Elm 코드 곳곳에서 발견할 수 있다. 코드를 보다 보면 타입 변수, 타입 연관 데이터가 혼동될 때가 있는데, 이에 대해 정리해보자.

[] -- [] : List a
[1,2,3] -- [1,2,3] : List number
["1","2","3"] -- ["1","2","3"] : List String

타입 변수는 소문자로 시작한다. 위 코드에서 빈 리스트는 List a, 정수 리스트는 List number, 문자열 리스트는 List String 타입이라고 출력된다. List a의 a는 확실히 타입 변수인 것을 알겠다. List를 생성할 때 정수 원소를 사용했더니 타입 변수 a 부분이 number로 바뀌었다. 타입 변수라는 표현처럼 a 부분은 그때 그때 달라진다는 것을 알 수 있다. 참고로 number는 Int와 Float만을 허용하는 특수한 타입 변수이다. 그러면 List String은 무엇일까? 이것은 타입 연관 데이터와 형식은 같지만 일단 제네릭이라고 부르는 것이 적합해보인다. 참고

type State = Bad Int | Good Int
Good -- <function> : Int -> State
Good 200 -- Good 200 : State

타입 연관 데이터는 대문자 Int와 같이 해당 데이터의 타입을 설명해주고 있다. 타입 연관 데이터를 알려면 내부적으로 데이터 생성자가 동작하는 원리를 알아야 한다. 연관 데이터가 있는 커스텀 타입을 선언하면, 자동으로 데이터 생성자 함수가 만들어진다. 위 코드에서는 데이터 생성자 함수 Good과 Bad가 만들어진다. 데이터 생성자 함수는 다른 의미있는 동작은 하지 않고 단지 추가 데이터를 보관하기만 한다. 데이터 생성자 함수 Good은 Int 타입의 파라미터를 받아 파라미터로 받은 데이터를 보관하는 State 타입의 값을 생성한다. 위 코드에서 Good 200은 데이터 생성자 Good에 값 200을 넘겨 추가 데이터 200이 담긴 State 타입의 값 Good 200을 생성한다. 그런데 특이한 것은 Good 200이라는 데이터 생성자 호출로 생성한 값 역시 Good 200이라는 것이다. 데이터 생성자 함수는 일반 함수와는 조금 다르게 동작한다는 것을 기억하자. 아래 그림은 커스텀 타입의 연관 데이터에 대해 설명한다. Namaste와 NumericalHi에 추가 데이터가 필요함을 알 수 있다.

그림 출처

img

그러면 가이드에서 접한 코드들을 다시 확인해보자.

type Maybe a
    = Just a
    | Nothing
    
Just 1 -- Just 1 : Maybe number
Just "One" -- Just "One" : Maybe String    

커스텀 타입인 Maybe의 Just 값은 연관 데이터를 갖는데 연관 데이터의 타입은 타입 변수 a에 의해 결정된다.

type Result error success
  = Err error
  | Ok success
  
Ok 200 -- Ok 200 : Result error number
Err "Oops" -- Err "Oops" : Result String value

커스텀 타입인 Result 역시 연관 데이터를 가지며, 마찬가지로 연관 데이터의 타입은 타입 변수 error와 success에 의해 결정된다.

Http.get -- <function> : { expect : Http.Expect msg, url : String } -> Cmd msg

Http.get 함수는 레코드를 인자로 받고 Cmd msg를 리턴한다. Cmd msg의 msg 역시 타입 변수이다. 다만 Cmd는 연관 데이터를 가진다기보다는 List String의 경우처럼 제네릭의 개념으로 보는 것이 낫겠다. 참고로 팬텀 타입이라는 것도 있는데, 이것은 내부 구현과 관련한 내용으로 보이며 여기서 다룬 내용과 별 관계는 없어 보인다.

Html.text -- <function> : String -> Html msg

view 함수 혹은 Html.text가 반환하는 Html 타입 역시 타입 변수를 사용한다. Cmd msg와 마찬가지로 제네릭으로 보면 되겠다.

type Decoder a
Json.Decode.decodeString -- <function> : Decoder a -> String -> Result Error a

디코더 타입 역시 타입 변수를 사용한다. 역시 마찬가지로 제네릭으로 보면 되겠다.

요약하면,

  • 타입 변수는 소문자로 시작, 어떤 타입으로 대치하기 위한 용도, 제네릭과 관련
  • 타입 연관 데이터는 대문자로 시작, 어떤 추가 정보를 보관하기 위한 용도

Elm 아키텍처

Elm은 언어이기도 하지만, 웹 프론트엔드 프레임워크의 기능도 갖는데, 그 밑바탕에 Elm 아키텍처가 있다. Elm 아키텍처는 Model(상태), View(시각화), Update(갱신)로 구성되며, “M -> V -> U -> M -> V -> U -> …” 형태인 MVU 패턴으로 동작한다. Model이 View의 인자로 들어가고, View의 메시지가 Update의 인자로 들어가고, Update가 Model을 갱신하여 반환하는 과정을 반복하는 구조이다.

한편, Elm 코드는 아래 그림과 같이 브라우저의 런타임 영역과 Html(+Cmd/Sub)/Msg를 주고 받으며 동작한다. Html(+Cmd/Sub)는 Elm 코드에서 생성하며, Msg는 브라우저에서 생성한다.

그림 출처

Diagram of The Elm Architecture img

Elm 웹 프로그램의 진입점이 되는 main은 여러 가지 형태가 될 수 있다. 먼저 Elm 아키텍처가 아닌 단순 텍스트를 리턴하는 경우를 살펴보자. 아래와 같이 실행하여 개발자 도구를 열어보면 “ELM : Elm is good” 이라는 콘솔 메시지를 볼 수 있다. REPL로 작성하기 애매한 함수 등을 수정해가면서 테스트해볼 때 유용하다.

module Main exposing (..)
import Html exposing (text)

main =
    let
        _ = Debug.log "ELM " "Elm is good"
    in
        text "Hello world"

이제 Elm 아키텍처에서 사용하는 Browser 패키지의 함수들을 살펴보자. Elm은 여러 시나리오를 고려하여 Elm을 점진적으로 애플리케이션에 적용할 수 있도록 했다. 가장 간단한 함수인 Browser.sandbox는 모델의 초기상태를 선언하는 init, 모델을 갱신하는 update, 모델을 Html로 바꾸어주는 view 함수를 사용한다. Browser.element는 Cmd,Sub 등의 개념과 함께 flags와 subscriptions가 추가되었다. Browser.document는 view의 반환값이 단순 HTML이 아닌 title과 body로 이루어진 Document로 바뀌었다. Browser.application은 SPA를 위해 Url.Url, Navigation.Key, onUrlRequest, onUrlChange 등을 사용한다. 자세한 내용은 문서에서 확인할 수 있다.

Browser.sandbox
-- <function>
--     : { init : model, update : msg -> model -> model, view : model -> Html.Html msg }
--       -> Program () model msg
      
Browser.element
-- <function>
--     : { init : flags -> ( model, Cmd msg )
--       , subscriptions : model -> Sub msg
--       , update : msg -> model -> ( model, Cmd msg )
--       , view : model -> Html.Html msg
--       }
--       -> Program flags model msg

Browser.document
-- <function>
--     : { init : flags -> ( model, Cmd msg )
--       , subscriptions : model -> Sub msg
--       , update : msg -> model -> ( model, Cmd msg )
--       , view : model -> Browser.Document msg
--       }
--       -> Program flags model msg

Browser.application
-- <function>
--     : { init : flags -> Url.Url -> Browser.Navigation.Key -> ( model, Cmd msg )
--       , onUrlChange : Url.Url -> msg
--       , onUrlRequest : Browser.UrlRequest -> msg
--       , subscriptions : model -> Sub msg
--       , update : msg -> model -> ( model, Cmd msg )
--       , view : model -> Browser.Document msg
--       }
--       -> Program flags model msg

HTTP 요청

아래의 그림을 보며 이해해보자. View에서 임의의 메시지(SendHttpRequest)를 발생시키면, (런타임에서 이 메시지를 받아 update로 전달해주는 과정을 거친 뒤) Update에서 해당 메시지를 받는다. Update에서 해당 메시지를 핸들링하여 Http.get 커맨드를 반환((Model, Cmd Msg))하면, 런타임에서 해당 커맨드를 실행하여 Http 요청을 수행한다. 요청 결과를 담은 메시지가 Update로 돌아오면, 결과를 모델에 반영하거나 에러를 처리해준다. 요청 결과는 실패할 수도 있기 때문에 Result 타입을 쓴다. 요약하면,

  • init이나 view(+update)를 통해 런타임에 Http.get을 명령(커맨드)하고
  • 결과(메시지)를 update에서 처리

그림 출처

img

가이드의 예제를 통해 Http.get 함수를 조금 자세히 들여다 보자.

import Http

type Msg = GotText (Result Http.Error String)  
-- GotText -- ① <function> : Result Http.Error String -> Msg

Http.get { url = "...", expect = Http.expectString GotText }
-- ② { expect = <internals>, url = "..." } : { expect : Http.Expect Msg, url : String }

Http.get -- ③ <function> : { expect : Http.Expect msg, url : String } -> Cmd msg
Http.expectString -- ④ <function> : (Result Http.Error String -> msg) -> Http.Expect msg

위 예제에서는 Result 타입의 관련 데이터를 갖는 커스텀 타입 Msg을 선언한 뒤 Http.get 함수를 호출한다. 이 때Http.get의 파라미터 레코드의 expect 필드 부분에서 Http.expectString 함수를 호출한다.

  • ①,② Http.expectString의 파라미터는 연관데이터로 (Result Http.Error String)를 갖는 Msg 타입인 GotText
  • ③ Http.get의 파라미터 레코드의 expect 필드는 Http.Expect msg 타입
  • ③ Http.get의 반환값은 Cmd msg 타입
  • ④ Http.expectString의 반환값은 Http.Expect msg
  • ④ Http.expectString의 함수 정의 부분의 타입 변수 msg가 GotText의 반환 타입인 Msg로 대치되어 최종적으로 Http.Expect Msg를 반환
  • 따라서, Http.get의 함수 정의 부분의 타입 변수 msg가 Http.Expect Msg의 Msg로 대치되어 최종적으로 Cmd Msg를 반환

요약하면,

  • Http.expectString은 커스텀 타입 msg를 입력으로 받아 Http.Expect msg를 출력한다.
  • Http.get은 Http.Expect msg 등을 입력으로 받아 Cmd msg를 출력한다.

더 요약하면,

  • Http.get은 HTTP GET 요청의 결과 문자열 혹은 에러를 메시지에 담는 커맨드를 생성한다.

JSON

자바스크립트에서는 간단하게 JSON을 다룰 수 있지만, Elm에서는 JSON이 어렵고 번거롭게 느껴질 수 있다. 커맨드와 메시지를 사용한다는 기본 원리는 HTTP 요청과 같지만 런타임 에러를 없애려는 디자인에 따라 디코더와 인코더라는 개념이 추가되었다. JSON 값을 요청하여 사용하는 경우 다음과 같은 형태가 된다.

  • 런타임에 Http.get을 명령(커맨드)하고
  • 결과(메시지)를 생성하는 과정에서 디코더를 사용
  • 결과(메시지)를 update에서 처리

그러면 새롭게 등장한 디코더가 무엇인지 알아보자. Elm은 외부에서 가져온 데이터를 다룰 때, Elm 내부의 타입처럼 다루기 위해 아래 그림과 같이 두 번의 변환 과정을 거친다.

  • 첫 번째로, 외부에서 가져온 Raw 문자열을 JSON 값으로 변환(Parse)
  • 두 번째로, JSON 값을 다시 Elm 타입으로 변환(Decode)

그림 출처

img

여기서 두 번째 변환에 사용되는 것이 바로 디코더이다. 디코더는 비교적 단순한 형태의 원시 디코더와, 이들을 조합한 복잡한 형태의 디코더가 있다. 예를 들면 위 그림에서 빨간 색의 “Beanie”라는 JSON 값을 Elm의 String 타입으로 변환하기 위해 Json.Decode.string 원시 디코더를 사용하거나, [“A”,”B”,”C”]라는 JSON 값을 Elm의 List String 타입으로 변환하기 위해 list string 과 같이 디코더를 만들어 사용할 수 있다. 더 복잡한 형태를 위해 field, at, index, maybe, oneOf, map2 … 등이 준비되어 있다. Json.Decode.Pipeline 이라는 서드파티 패키지 역시 제공되고 있다. 예제를 통해 하나씩 살펴보자.

import Json.Decode exposing (..)
decodeString string "\"Beanie\"" -- Ok "Beanie" : Result Error String
decodeString int "9" -- Ok 9 : Result Error Int
decodeString float "2.5" -- Ok 2.5 : Result Error Float
decodeString bool "true" -- Ok True : Result Error Bool
decodeString bool "false" -- Ok False : Result Error Bool

decodeString -- <function> : Decoder a -> String -> Result Error a
string -- <internals> : Decoder String
int -- <internals> : Decoder Int
float -- <internals> : Decoder Float
bool -- <internals> : Decoder Bool

위 예제는 string, int, float, bool 등 원시 디코더에 대한 것이다. 디코더와 JSON 문자열을 받는 decodeString 이라는 함수를 사용했다. 이 함수는 Raw 문자열을 읽어 JSON 값으로 파싱한 후, 그 결과를 디코더에서 지정한 Elm 타입으로 변환을 시도한다. 변환이 실패할 수도 있으므로 반환 타입은 Result를 사용한다. 이 때 JSON 값을 의도한 Elm 타입으로 변환하기 위한 디코더로 string, int, float, bool 을 사용했다. 디코더가 무엇인지 이제 조금 감이 잡힐 것이다.

  • 코드를 작성하는 입장에서 봤을 때, 디코더는 외부의 값을 어떤 Elm 타입으로 변환할 것인지 Elm에게 알려주는 역할을 한다.
decodeString (list int) "[1,2,3,4,5]" -- Ok [1,2,3,4,5] : Result Error (List Int)
decodeString (dict int) "{\"id\":1}" -- Ok (Dict.fromList [("id",1)]) : Result Error (Dict.Dict String Int)

list -- <function> : Decoder a -> Decoder (List a)
list int -- <internals> : Decoder (List Int)
dict -- <function> : Decoder a -> Decoder (Dict.Dict String a)
dict int -- <internals> : Decoder (Dict.Dict String Int)

위 예제는 살짝 복잡한 디코더이다. 외부의 값을 Elm의 List Int 타입으로 변환하기 위해 list int 라는 디코더를 만들었다. Json.Decode.list 함수는 디코더를 인자로 받는데, 여기서는 Json.Decode.int 디코더를 인자로 받았다. 마찬가지로 dict int 디코더도 dict 함수와 int 디코더를 사용해 만들었다.

-- 정상 응답
json = """
{
  "data" : [
	  { "id": 1, "title": "Elm", "etc" : { "creator" : "Evan Czaplicki" } },
	  { "id": 2, "title": "Python", "etc" : { "creator" : "Guido van Rossum" } }
	]
}
"""

-- 비정상 응답
json_error = """
{
  "error" : "Server Fault"
}
"""
decodeString (field "data" (index 0 (at ["etc", "creator"] string))) json
-- Ok ("Evan Czaplicki") : Result Error String

decodeString (maybe (field "result" (index 0 (at ["etc", "creator"] string)))) json
-- Ok Nothing : Result Error (Maybe String)

type alias Language = { title : String, creator : String }
decodeString (
  map2 Language
	  (field "data" (index 0 (field "title" string)))
	  (field "data" (index 0 (at ["etc", "creator"] string)))
  ) json
-- Ok { creator = "Evan Czaplicki", title = "Elm" } : Result Error Language

d = field "data" << index 0 <| at ["etc", "creator"] string -- ... : Decoder String
decodeString d json -- Ok ("Evan Czaplicki") : Result Error String

위 예제는 실제 사례에서 나올만한 JSON과 함께 field, at, index, maybe, oneOf, map2(~map8) 함수를 살펴본다. field는 JSON 객체의 특정 프로퍼티를 디코딩할 때 사용한다. at은 객체 안에 네스팅된 객체를 디코딩할 때 사용한다. index는 배열에서 특정 인덱스에 접근할 때 사용한다. maybe는 프로퍼티 자체가 없을 수도 있을 때(nullable은 프로퍼티가 널 값을 가질 수 있을 때) 사용한다. oneOf는 응답이 정상일 때와 비정상일 때 복수의 디코더 중 적절한 한가지를 적용할 때 사용한다. map2(~map8) 함수는 여러 프로퍼티를 조합한 결과를 얻고자 할 때 사용한다.

import Json.Decode.Pipeline exposing (..)
d  = Json.Decode.succeed Language
   |> custom (field "data" (index 0 (field "title" string)))
   |> custom (field "data" (index 0 (at ["etc", "creator"] string)))
-- <internals> : Decoder Language

decodeString d json 
-- Ok { creator = "Evan Czaplicki", title = "Elm" } : Result Error Language

위 예제는 서드파티 패키지인 Json.Decode.Pipeline를 사용한 것이다. map2(~map8) 함수 대신 파이프라인을 사용할 수 있다. 예제는 나오지 않지만 값이 없을 때 기본값을 지정해줄 수 있는 optional 함수 등을 제공한다. 자세한 내용은 문서를 참고하자.

import Http
Http.expectString -- <function> : (Result Http.Error String -> msg) -> Http.Expect msg
Http.expectJson -- <function> : (Result Http.Error a -> msg) -> Decoder a -> Http.Expect msg

한편, 이제까지 사용했던 decodeString 함수는 디코딩에 앞서 파싱을 수행하는데, JSON 파트 맨 처음에 봤던 그림의 첫 번째 변환 과정(보라색 “Beanie” Raw 문자열을 빨간색 JSON 값으로 파싱)에 해당한다. HTTP 요청 파트에서 등장했던 Http.expectString 함수의 경우 Raw 문자열을 JSON 값으로 파싱하기 위해 decodeString 함수를 써야한다. 이러한 번거로움을 피하기 위해 Elm은 Http.expectJson 함수를 제공하는데, 이 함수는 decodeString 함수를 쓸 필요 없이, 인자로 받은 디코더를 사용해 바로 디코딩을 수행한다.

참고로 Json 패키지의 Decode 모듈은 문서에 따르면 아래와 같이 이루어져 있다.

-- Primitives
type Decoder a
string : Decoder String
bool : Decoder Bool
int : Decoder Int
float : Decoder Float

-- Data Structures
nullable : Decoder a -> Decoder (Maybe a)
list : Decoder a -> Decoder (List a)
array : Decoder a -> Decoder (Array a)
dict : Decoder a -> Decoder (Dict String a)
keyValuePairs : Decoder a -> Decoder (List ( String, a ))
oneOrMore : (a -> List a -> value) -> Decoder a -> Decoder value

-- Object Primitives
field : String -> Decoder a -> Decoder a
at : List String -> Decoder a -> Decoder a
index : Int -> Decoder a -> Decoder a

-- Inconsistent Structure
maybe : Decoder a -> Decoder (Maybe a)
oneOf : List (Decoder a) -> Decoder a

-- Run Decoders
decodeString : Decoder a -> String -> Result Error a
decodeValue : Decoder a -> Value -> Result Error a
type alias Value = Value
type Error = Field String Error | Index Int Error | OneOf (List Error) | Failure String Value
errorToString : Error -> String

-- Mapping
map : (a -> value) -> Decoder a -> Decoder value
map2 : (a -> b -> value) -> Decoder a -> Decoder b -> Decoder value
map3 : (a -> b -> c -> value) -> Decoder a -> Decoder b -> Decoder c -> Decoder value
...
map8 : ...

-- Fancy Decoding
lazy : (() -> Decoder a) -> Decoder a
value : Decoder Value
null : a -> Decoder a
succeed : a -> Decoder a
fail : String -> Decoder a
andThen : (a -> Decoder b) -> Decoder a -> Decoder b

Time

시간 관련 용어가 있다.

  • 휴먼타임 : 우리가 흔히 시계를 보고 확인하는 시간. 한국, 미국 등 지역에 따라 제각각 다르다.
  • POSIX타임 : 특정 시점으로부터 경과된 시간이며, 전 세계 어디에서나 동일하다.
  • 타임존 : POSIX타임을 휴먼타임으로 변환하기 위해 필요한 것.

앞에서 살펴본 HTTP나 JSON에서는 런타임으로 커맨드를 보낸 다음 결과를 메시지로 받아왔다. Time 예제에서는 런타임에서 일정 간격으로 어떤 동작을 반복 수행하고 그 결과 역시 반복적으로 받게 되는데, 이 때 사용하는 것이 Browser.element 등에서 파라미터로 등장하는 subscriptions 이다.

type Msg
  = Tick Time.Posix
  | AdjustTimeZone Time.Zone
  
Tick -- <function> : Time.Posix -> Msg
AdjustTimeZone -- <function> : Time.Zone -> Msg

-----

init : () -> (Model, Cmd Msg)
init _ =
  ( Model Time.utc (Time.millisToPosix 0)
  , Task.perform AdjustTimeZone Time.here
  )  
  
Task.perform AdjustTimeZone Time.here -- <internals> : Cmd Msg
Time.here -- <internals> : Task.Task x Time.Zone

-----

subscriptions : Model -> Sub Msg
subscriptions model =
  Time.every 1000 Tick
  
Time.every -- <function> : Float -> (Time.Posix -> msg) -> Sub msg

가이드에서 부분 발췌한 위 코드를 살펴보자.

  • 우선 커스텀 타입으로 Tick과 AdjustTimeZone 메시지를 정의하고 있다. Tick은 Time.Posix 값을 갖고, AdjustTimeZone은 Time.Zone 값을 갖는다.
  • 그 다음으로 Task.perform 함수에 AdjustTimeZone Time.here를 인자로 주어 커맨드를 반환한다. 브라우저에서 커맨드가 수행되면 브라우저에서 인식한 현재 지역으로 타임존이 설정되는 것 같다.
  • subscription 함수는 Time.every 함수를 호출하여 Sub Msg 를 반환한다. Platform.Sub는 Elm이 런타임으로부터 데이터를 구독해야하는 상황에 쓰인다. 여기서는 매 1초마다 Time.Posix 정보를 담은 Tick 메시지를 구독하는 것 같다.

소감

시간이 좀 지나면 다 잊어버리겠지만, 함수형 프로그래밍이 무엇인지 아주 조금 알 것 같다. Elm은 낯설긴 하지만 잘 발전시키면 괜찮은 언어/프레임워크가 될 수 있을 것 같고, 언젠가 쓸모가 있을 것 같다. 다만 현재로서는 JSON 다루는 것이 너무나 번거롭게 느껴졌다. 가끔씩 새로운 시각으로 바라보는 것도 좋은 것 같다.

참고