Apply - 이펙트가 있는 함수들을 연이어 적용하고 싶어

Posted on June 28, 2025

일주일 사이로 두 개의 Apply 글을 올렸습니다.

Jeff Beck
출처 Wikimedia Commons - Jeff Beck in Amsterdam 1979

오래 전에 타브 악보를 보고, 더듬 더듬 곡을 연습하다가, 이렇게 하는 게 아닌데란 생각이 들었었습니다. 원곡자들은 오랫동안 베어 있는 자신의 멜로디 습관이나, 익숙한 손놀림등을 기반으로 나온 연주일텐데, 그 걸 기본기 없이 따라해서 내 것이 될까하는 의문이었습니다. 아마도, 시간 투자를 많이한다면, 언젠가는 내 것이 될지도 모르지만, 더 효율적인 방법은 스케일 연습같은 것들을 먼저하고, 조금씩 곡 해석을 하며 내 것으로 만드는 게 더 빠르지 않을까 생각했었습니다.

함수형 프로그래밍도 비슷하게 접근하고 있습니다. 절차형에 찌든 사람이 느끼는 함수형의 이 독특한 패턴들을 떠올리고 구현하기까지, 만든 사람들은 분명 하루 아침에 순간적으로 만든 것이 아닌, 그들이 지나온 생각의 길이 있었을텐데요. 물론, 천재들이 걸어 온 길을 나도 아무 정보 없이 찾아내겠다는 아니고, 적어도 도착지는 알고 있는 상태니 역으로 추적해보는 생각 놀이를 자주합니다.

이 글을 읽는다고, 함수형이 갑자기 편해지거나, 무슨 태초의 원리를 알게된다거나 하는 글은 당연히 아닙니다. 아직 검증되지 않은, 이런 생각 길도 있지 않았을까 정도의 글일 뿐입니다. 주의해서 보세요. 아직 Apply만 따로 떼어 이렇게 설명하는 문서는 본적이 없으니, 제가 틀린 눈을 가졌을 수 있습니다.

이미 함수형이 네이티브인 분들한테는 필요한 해석이 아닐 수 있습니다. 절차형에 쩔은 사람이 함수형으로 넘어가는 중에 정리한 해석입니다. 요즘은 Apply에 꽂혀 있습니다. 그동안 Apply의 강력함을 왜 눈치 못챘나 싶습니다. 언젠가 더 진도가 나가면 또 바뀔지 모르지만, 지금은 함수형에서 원톱 주인공이 Apply로 보입니다.

※ 아래 나오는 코드들은 실제 구현과 다르고, 실제 동작하지 않는 의사 코드입니다.

값을, 값이 아닌 함수로 표현

함수가 필요한 정보를 값으로 넘기지 않고, 함수로 넘기면 어떤 효과가 날까요?

예를 들어 다음과 같은 경우

func1 :: Int -> Int
func1 arg = arg + 1

인데, arg를 값으로 넘기지 않고, 일부 정보가 없어, 아직 값이 안된 함수인 채로 넘긴다면,

-- func2 :: (Int -> Int) -> Int
-- func2 f = f x + 1
--           ^^^
--  뭔지 모를 x에 '적용'하고 있다.

나중에 함수를 적용(실행)해야 값을 얻으니, 인자가 필요하고, 이를 x라 하면 이는 나중에 무언가와 바인딩 될 값이니, 최종 아래와 같은 모양이 됩니다.

func3 :: (Int -> Int) -> Int -> Int
func3 f = \x -> f x + 1

arg란 값을 받아 +1하는 작업이었는데, arg를 함수로 만들어 넘겨서, 나중에 외부에서 개입할 수 있는 여지 x를 남길 수 있게 됐습니다. 예를 들어 100원을 넘겨 101원이 되는 작업이었는데, 100원이 아니라 \x -> 100 * x같은 것들을 넘기고, x를 통해 미래1에 (환율 같은 것들을 적용해) 값을 바꿔줄 수 있습니다. 값을 넘길 때 미리 했어야 하는 작업을, 당장은 모르니 뒤로 미뤘다고 볼 수도 있습니다. 당장은 모르는 것들은 그대로 두고, 구조를 만들어 가는 것이 함수형의 특징중 하나입니다.

실제로 작업이 일어나진 않고, 작업을 모아둔 상태입니다. 값들에 고정적으로 할 일(+1)을 하겠다는 걸 모아 두기만 했습니다.

func3 구현에 보면, f x가 보입니다. 이렇게, 함수를 적용Apply하는 걸, 또 다른 함수가 담당하게 만들어 두는 건 굉장한 유연성을 줍니다. 이 글은, Apply를 이리 저리 정의해서 어떤 효과가 있는가를 보는 글입니다.

여기서 주연과 조연을 바꿔 보겠습니다. (+1)하는데 추가로 값들에 고정적으로 할 일을 하는 게 아니라, 값들에 어떤 일을 하는데, 추가로 (+1)을 하는 걸로 바꿔서 바라 보겠습니다.

Apply

func3이 하는 일을 보면, 별로 특별할 것 없는 적용 Apply \x -> f x입니다. 잘 보면 이 때, Apply전이나 후에 추가 작업을 할 수 있습니다. 위의 예에서 + 1처럼 말입니다. 한 마디로, 특별한 Apply를 만들어 두고, 항상 해야 되는 작업을 심어둘 수 있습니다. 어떤 함수든 이 Apply를 거치면, 항상해야 되는 절차 (+1)이 진행됩니다.

추가 작업을 둘 수 있다라는 속성이, 지금 보기에는(저의 지금 지식 한계에선) 람다 산법의 근간이 되는, 즉 함수형의 근간이 되는 굉장히 중요한 속성이지 않을까 생각합니다. (함수형으로 여러 제약을 돌파하는 구현들을 보다 보면, 함수 몸체와 인자를 연결해주는 지점인, 적용이 바로 매직이 일어나는 순간입니다.) 함수들이 공통적으로 해야되는 작업인 컨텍스트가 있다면, 이를 통해 구현할 수 있습니다.

※ 잠깐, 컨텍스트 구현 전에, 눈에 확연히 보이는 장점을 먼저 보겠습니다. 하스켈에선 Apply 정의를 통해 표현(외관)이 간결해지는 효과도 있습니다. 다른 함수형 언어를 몰라, 다른 언어가 얼마나 이쁘게 나오는지는 잘 모릅니다.

적용을 다음과 같이 정의하면,

-- 하스켈에선 함수를 두 인자 사이에 두는 "연산자"로 정의할 수 있다. 중위 표현식이라한다.
f <: v :: (Int -> Int) -> Int -> Int 
f <: v = f v -- 특별히 하는 일 없이, 그냥 적용
infixr 오른쪽 우선 결합으로 지정

func1 <: v1 의 결과값은 Int입니다. 그럼, 이 결과값을 받아, 연이어 실행할 함수들을 아래처럼 써 줄 수 있습니다.

func3 <: func2 <: func1 <: v1

원래 func3 (func2 (func1 v1)) 이렇게 써 줘야 되는데, 괄호 없이 예쁘게 바뀌었습니다.

제일 먼저 실행할 함수가 가장 뒤에 나오는 게 불편하다면, 인자 순서를 바꿔서 좀 더 직관적인 모양으로 바꿀 수도 있습니다.

v :> f :: Int -> (Int -> Int) -> Int
infixl 왼쪽 우선 결합으로 지정

이제 다음 처럼 써 줄 수 있습니다.

v1 :> func1 :> func2 :> func3

예쁘지요?. (지금은 에디터의 도움을 받아, 괄호를 다루는 게 특별히 어려운 일은 아니지만, 예전에 단순 메모장으로 Lisp를 하려면 괄호가 하나의 장벽이기도 했습니다.)

컨텍스트(맥락)

이제 함수의 텍스트2컨텍스트를 분리해서 볼 준비가 끝났습니다.

예를 들어, func1, func2, func3이 실행할 때, 모두 공통으로 환경값 Env가 필요하다면, 공통되는 작업인, 환경값을 받아 참조하는 작업을 컨텍스트로 보고 Env를 유통하는 역할을 하는 추가 작업Apply, 즉 :>에 심어둘 수 있습니다.Env의 흐름은 :>가 담당하는 거지요. 나중에 더 얘기하겠지만, 하스켈에선 타입으로 살짝 가리면, :>가 컨텍스트를 담당하고 있다는 사실까지도 눈에 안띄게 할 수 있습니다. Apply를 잘 정의하면, 드라마틱하게 프로그래머 뇌의 수고를 덜어 줄 수 있게 됩니다.

($)

하스켈에선, 특별히 추가 작업이 없는 적용을 func $ arg = func arg (오른쪽 우선 결합) 로 정의해 두었습니다.

람다 산법의 프로그래밍 흐름은 오직 Apply에 의해서 만들어집니다. h (g (f x)) 식으로 써서 Apply를 눈치 못챘다면, Apply가 눈에 보이게 $를 쓰면, h $ g $ f $ x으로 쓸 수 있습니다. 다른 곳에선 함수의 합성으로 흐름이 만들어진다고 말하곤 하지만, 함수 합성 구현을 보면 안에서 적용이 이루어지고 있습니다. 여기선 콕 찝어서 적용으로 흐름이 만들어진다고 보겠습니다. 흐름을 만들어내는 적용은 다양하게 만들 수 있습니다.

Int -> Int란 함수들의 흐름을 아래와 같이 표현할 수 있습니다.

h $ g $ f $ x

이제 연이어 적용할 때 추가적인 작업이 필요한 함수들의 흐름을 만들어 보겠습니다.

Maybe

최대한, 필요한 사전 지식이 없도록 쓰려고 노력 중인데, Maybe 정도는 있어야 말이 풀리는 것 같아, 어쩔 수 없이 간단히 설명하고 넘어 가겠습니다.
Maybe Int: 정수를 계산하는 흐름을 만들다가 값이 없을 수 있는 경우를 가정해 보겠습니다. 아래 reciprocal(역수)은 0이 인자로 넘어 오면 계산할 수 없습니다. 값이 있을 수도 있고, 없을 수도 있는 상황을 하나의 타입으로 모델링 하려면 어떻게 할까요? 다음과 같이 정의합니다.

data Maybe a = Just a | Nothing

값이 있는 경우는 Just 값으로 표현하고, 값이 없을 때는 Nothing으로 표현합니다. 이제 값이 있든, 값이 없든 Maybe 타입을 반환한다고 말할 수 있습니다.

reciprocal :: Int -> Maybe Int
reciprocal = \x -> if x == 0 then Nothing else Just (1 / x)

0을 인자로 넘기면, 1/0이면 불능이 되니, Maybe로 결과를 표현했습니다.

이제 (+1), (+2)등과 어울려서 reciprocal을 쓰는 상황을 가정하겠습니다.

※ 다른 언어에선 Optional이라 표현하는 곳도 있습니다. 컨텍스트가 있는 타입 중에 만만한 타입이라 설명에 자주 등장합니다.

추가 작업이 필요한 흐름

(+1) $ (+2) $ reciprocal $ (+1) $ 1

이렇게 쓸 수 있으면 좋겠지만, 그냥은 위와 같이 쓸 수 없습니다. 최대한 위와 비슷하게 쓰는 게 목표입니다.

다른 연산들은 괜찮지만, 역수 때문에 언제든 Maybe 타입을 받는 걸 가정해야 되는 상황입니다. (+1)도, (+2)Int가 아닌 Maybe Int용으로 만들어야 합니다. 연산자가 한 두개면 다시 정의하는 것도 어렵지 않겠지만, 연산자가 많다면? 바로 특별한 적용을 정의하면 됩니다. Int -> IntMaybe Int에 적용하는, 새로운 적용을 만들어 보겠습니다.

1.

Maybe 타입 값이 Just 값이면 에 보통의 함수 적용을 하면 되고, Nothing이면, 함수 적용 없이 그냥 Nothing으로 두면 됩니다.

f <$> v = 만일 v가 Just val 이면 Just (f $ val)
          아니면 Nothing

※ 실제 하스켈 구현3의 결합 우선 순위는 왼쪽으로 되어 있는데, 괄호를 없애고 보기 위해 여기선 오른쪽 우선 결합으로 가정하겠습니다. 실제 구현은 주석을 참고하세요.

이제 다음처럼 써 줄 수 있을 것만 같은데, 안됩니다.

(+1) <$> (+2) <$> reciprocal <$> (+1) <$> 1

(+1) <$> reciprocal $ 1 은 되지만,
reciprocal <$> (+1) $ 1 은 안됩니다. (+1) $ 1 = 2Just 2가 아니니 <$>를 쓰지 못합니다. Int를 받으면 $를, Maybe Int를 받으면 <$>를 써야 되는데, 어떻게 해결하는 게 좋을까요? Int를 받는 경우가 없고, 항상 Maybe Int만 들어오게 만들면, 해결할 수 있을 것만 같습니다.

pure :: Int -> Maybe Int
pure n = Just n

Int를 단순히 Just 값으로 만들어 주는 함수입니다. 이를 이용하면,

reciprocal <$> (+1) <$> pure 1

이제 될 것 같지만, 여전히 다음이 문제 입니다.

2.

reciprocal <$> (reciprocal <$> ((+1) <$> pure 1))

<$>의 구현을 잘 보시면 Just (f val)입니다. 만일 f가 결과로 Maybe 타입 값을 만들어 내는 함수라면 Maybe (Maybe Int) 타입이 되어 버립니다. 예를 들어 reciprocal의 결과가 Just 1이 나왔는데, 여기에 다시 reciprocal<$>하면 Just ( Just 1 ) 되어 버립니다. 계산은 오류가 난 건 아니니 더 가볼까요? reciprocal을 한 번 더 적용 하려 하면, <$>구현 안에서 Just 1f<$>이 아니라 f $ (Just 1)을 하는 상황이 되어 계산을 할 수 없는 상태가 됩니다.

reciprocal :: Int -> Maybe IntJust (Just 1)에 적용할 방법이 필요해졌습니다.

Just (Just 1)Just 1로 만들면, <$>을 이용해 다시 적용할 수 있습니다.

Just (Just 1)의 의미를 살펴 보겠습니다. Just는 값이 있다는 의미를 표현하는 값 생성자로, 의미상 Just (Just 1)Just 1과 다른 의미를 가지고 있지 않습니다. 만일, Nothing이었으면, Just (Nothing)까지 가지도 않고 Nothing입니다.

Maybe (Maybe a) -> Maybe a로 봐도 의미가 달라지지 않는, 즉 Maybe (Mayb a)Maybe a는 다른 구조지만, 같게 봐도 되는 상황입니다. 필요에 따라서는 말이지요. 4이를 코드로 표현하면,

join :: Maybe (Maybe a) -> Maybe a
join (Just (Just v)) = Just v
join _ = Nothing

이제, 다음처럼 쓸 수 있습니다.

reciprocal (join . <$>) reciprocal <$> (+1) <$> pure 1

결과 타입이 Maybe a인 것들로만 흐름을 만들기 위해 (+1)은 빼고 보겠습니다. ((+1)이 필요하면 (+1) <$> pure 1 = pure (1+1) 상태로 흐름에 들어오면 됩니다.)

reciprocal (join . <$>) reciprocal (join . <$>) pure 1

실제 하스켈에 정의되어 있는 것과 맞추기 위해, 인자 순서를 바꾸고, 결합 우선 순위를 왼쪽으로 조정한 >>=를 정의하면

pure 1 >>= reciprocal >>= reciprocal

이라 쓸 수 있게 됐습니다. a -> m bm a에 적용하는 새로운, 특별한 적용을 정의했습니다.

마무리

Apply를 활용한 이런 구조는 특별한 테크닉이 아니라, 함수형 전반에서 쓰는 테크닉입니다. 테크닉이라 하기에도 뭐한 본질 같은 것입니다. 이렇게 따로 떼어내서 보는 것이, “왜, 이렇게 구현을 했지?”, 혹은 “어떻게 이런 구현을 떠올렸지?”가 궁금한, 저같이 함수형이 제 2 외국 코딩 언어인 분들에겐 도움이 될 수 있습니다.

하스켈에선 위와 같은 연산자 기호들을 무수히 만나게 되는데, 주요 연산자들은 Apply인 경우가 많습니다. 아직 함수형이 네이티브가 아닌, 제 2, 제 3 외국어쯤 되는 분들이, 기호가 잔뜩 들어간 문장을 독해할 때, 전 후에 특별한 작업이 들어간 Apply겠거니 하는 눈이 생긴다면, 이 글을 읽는데 들인 시간의 본전은 뽑으신 게 아닐까 합니다.

※ 일부러 제목을 적지 않았습니다. 1번은 펑터고, 2번은 모나드입니다. 수학쪽에서 기가 막히게 똑같은 개념이 존재합니다. 위에서 정의한 걸로만, 그대로 되는 건 아니고, 둘 다 몇가지 법칙을 만족하게 설계하면 수학쪽 개념과 완전히 일치하는 펑터, 모나드가 됩니다. 이 법칙들은 코드로 구현되는 건 아니고, 프로그래머가 해당 법칙을 따르는지 잘 보면서 설계해야 하는데, 여기선 코드로 나오는 <$>, pure, >>=들의 필요성과, 그렇게 설계한 이유만 먼저 보는 글입니다.

너무 비수학적으로 풀어서 도움이 안되는 것 아닐까 걱정하는 분이 계실수도 있는데, 혼자 생각으론 수학쪽 개념의 시작도 여기서 본 것과 다르지 않다고 생각합니다. 서로 조금씩 다르지만, 닮은 것들을 얼마나 닮았는지 표현하며, 때로는 같게 보기 위한 개념들입니다. aMaybe aMaybe (Maybe a) 들을 다루면서 최대한 통일성 있는 방법을 찾습니다.


  1. 람다 함수는 아래처럼 읽을 수도 있습니다. 재미난 인문 표현이지만, 가끔 필요한 해석입니다.

    \미래 -> 미래와 작업
    ↩︎
  2. 원래 텍스트란 용어는, 글자로 된 것만 텍스트가 아니라, 무언가 해석이 필요한 것(그림이든, 건축이든, 뭐든…)을 텍스트라 하고, Text를 해석하는데 필요한 부가 정보를 Context라 합니다. Con은 함께together란 뜻입니다.↩︎

  3. 아직 (->)를 하나의 타입으로 바라보는 것에 익숙하지 않은 분은 나중에 봐도 됩니다. 아래는 여기에 꼭 필요한 내용은 아닙니다.

    실제 하스켈에서 구현은 $오른쪽 우선 결합, <$>왼쪽 우선 결합입니다. <$> :: (a -> b) -> f a -> f b로 되어 있는데, 왼쪽 결합이면, (+2) <$> (+1) <$> Just 1이 안돼야 될 것처럼 보입니다. (+2) <$> (+1) 자체가 타입이 맞지 않을 것 같으니까요. 하지만, 하스켈의 다른 기능이 쓰였습니다. 여기서 <$>Maybe에 적용하기 위한 적용이 아니라, 함수 타입에 쓰기 위한 적용입니다. 그리고 (+1) <$> Just 1에서 쓰인 <$>Maybe에 쓰기 위한 적용입니다. 하나의 구문에 들어 있지만, 컴파일러가 영리하게 적당한 구현을 찾아옵니다.

    하스켈은 타입 클래스를 이용해, 컴파일러가 인자에 따라서 적절한 구현(인스턴스)을 불러다 쓸 수 있어, 왼쪽 우선인데도 (+1) <$> (+1) <$> Just 1 이렇게 쓸 수 있습니다. 아래는 좀 생각이 복잡해지긴 하는데, (->)를 하나의 타입처럼 바라보는 경우를 자주 만나니, 읽는 훈련을 해두면 좋긴 합니다.

    함수 타입(r ->)<$>는 다음처럼 정의되어 있습니다.

    <$> :: (a -> b) -> f a -> f b
    -- f 에 (r ->)를 넣는다.
    <$> :: (a -> b) -> (r -> a) -> (r -> b)

    (a -> b) 함수를 (r -> a)에 있는 a에 적용하려면, (r -> a)의 결과 a(a -> b)를 적용하면 됩니다.

    f <$> f' = f . f' -- fmap = (.)

    함수 타입의 적용은 합성으로 정의되어 있습니다.↩︎

  4. 모나드 구조가 되기 위한 조건입니다. 두 번의 구조 변환 혹은 추가가 한 번의 구조 변환과 다를 게 없을 때 (다를 게 없다고 봐도 내가 필요한 만큼에선 문제가 없을 때) 모나드 구조를 만들 수 있습니다.↩︎

Github 계정이 없는 분은 메일로 보내주세요. lionhairdino at gmail.com