모나드의 표현력이 실제 필요 이상으로 강력하다는 문제의식 아래, 표현력과 정적 분석 가능성 사이의 스펙트럼을 살피고, 애플리커티브와 셀렉티브 등 대안적 효과 인터페이스가 제공하는 분석 이점을 설명한다.
좋아요, 우리 둘 다 모나드가 훌륭하다는 건 잘 알고 있죠. 모나드는 효과를 구조적으로 순서화하게 해 주고, 여러 면에서 함수형 프로그래밍 도구 상자의 초능력 같은 존재입니다. 아마 모나드가 없었다면 우리 대부분은 Haskell이라는 언어조차 들어보지 못했을 겁니다.
그런데 제 생각에는, 모나드는 사실 스스로에게는 너무 강력합니다. 좀 더 명확히 말하자면, 모나드는 실제 필요 이상으로 표현력이 크고, 우리가 거의 혹은 전혀 사용하지 않는 표현력을 얻기 위해 숨은 비용을 치르고 있다는 겁니다.
이 글에서는 서로 다른 효과 접근법이 표현력과 강한 정적 분석 사이의 스펙트럼 어디쯤에 위치하는지 살펴보고, 동적 타입 대 정적 타입 프로그래밍 언어의 논쟁처럼, 효과 시스템에 더 많은 구조와 제약을 더해 작성 가능한 프로그램의 수를 제한하는 것이 어떤 이점을 주는지 이야기해 보겠습니다.
모나드 인터페이스의 결정적 특징은, 이전 효과의 결과에 기반해 동적으로 다음에 수행할 효과를 선택할 수 있다는 점입니다.
이 능력은 엄청난 장점이며, Haskell이 순수성과 지연 평가의 목표를 훼손하지 않으면서도 ‘진짜’ 프로그램을 작성할 수 있게 해 주었습니다. 사용자 입력을 먼저 받은 다음 어떤 명령을 실행할지 결정한다거나, 데이터베이스에서 ID들을 가져와 후속 호출로 그 ID들을 해소한다 같은 일반적인 워크플로를 표현할 수 있게 해 주죠. 어느 정도 복잡한 프로그램을 작성하려면 이런 형태의 선택이 필수입니다.
하지만 이런 표현력은 공짜가 아닙니다! 이것도 스펙트럼 위에 놓여 있죠. 어느 정도 복잡한 JavaScript나 Python 코드베이스를 유지보수해 본 사람이라면 누구나 알듯이, 언제든 무엇이든 할 수 있는 능력은 가독성 비용을 치르게 하고, 여기서 더 중요하게는 정적 분석 능력의 비용을 치르게 만듭니다.
표현력 스펙트럼을 소개합니다:
Strong Static Analysis <+------------+------------+> Embarrassingly Expressive Code
보시다시피, 표현력을 더 얻을수록, 프로그램이 실행될 때 도대체 무슨 일을 할지 사전에 파악하기가 점점 더 어려워집니다.
이 점은 수많은 프로그래밍 언어 애호가들의 논쟁을 불러일으켰고, 효과 시스템의 세계 안에서도 아주 비슷한 논쟁이 성립합니다.
본질적으로 효과 시스템은 여러분이 선택한 프로그래밍 언어 안에서 ‘작은 프로그램’을 표현하는 방법입니다. 이 작은 프로그램들은 더 큰 프로그래밍 언어의 프레임워크 안에서 런타임에 구성되고, 분석되고, 실행될 수 있으며, 동일한 표현력 스펙트럼이 독립적으로 적용됩니다. 즉, 효과 시스템이 표현할 수 있는 프로그램이 많아질수록, 개별 프로그램에 대해 실행 전에 알 수 있는 정보는 줄어듭니다.
효과 시스템의 미시세계에도 유사한 ‘컴파일 타임’과 ‘런타임’ 단계가 있습니다. 예를 들어 다음은 DSL을 사용해 효과의 연쇄를 구성하는 간단한 Haskell 프로그램입니다:
-- Haskell에서 효과를 표현하는 일반적인 방법은
-- 모나딕 타입클래스 인터페이스를 사용하는 것입니다.
class Monad m => ReadWrite m where
readLine :: m String
writeLine :: String -> m ()
-- 런타임에만 알 수 있는 입력에 의존하는
-- 작은 프로그램 빌더를 쓸 수 있습니다.
greetUser :: ReadWrite m => String -> m ()
greetUser greeting = do
writeLine (greeting <> ", what is your name?")
name <- readLine
writeLine ("Hello, " <> name <> "!")
-- 런타임에, 세상에 아직 존재하지 않았던 새로운 미니 프로그램을 구성할 수 있습니다!
mkSimpleGreeting :: ReadWrite m => IO (m ())
mkSimpleGreeting = do
greeting <- readFile "greeting.txt"
pure (greetUser greeting)
이 단순화된 예시에서 보듯, 우리는 호스트 언어의 기능을 임의로 사용하여 ReadWrite DSL 안에서 더 작은 프로그램을 구성할 수 있습니다. 여기의 간단한 프로그램은 사용자로부터 한 줄 입력을 받아 이름으로 인사합니다.
이 정도라면 아주 멋집니다. 하지만 우리의 단순한 ReadWrite 효과에 다음과 같은 새 효과를 약간만 덧붙여 보죠:
class Monad m => ReadWriteDelete m where
readLine :: m String
writeLine :: String -> m ()
deleteMyHardDrive :: m ()
이제, 런타임에 ReadWriteDelete 효과 타입의 프로그램을 구성하거나 파싱한다면, 실제 실행하기 전에 그 프로그램이 deleteMyHardDrive를 호출하는지 여부를 알 수 있으면 좋겠습니다.
물론 호스트 언어에서 효과를 실제로 실행할 때 전체 삭제 요청을 그냥 중단하거나 무시할 수도 있죠. 그건 좋습니다. 하지만 여전히, 앱이 런타임에 임의의 ReadWriteDelete m => m () 프로그램을 건네받는다면, 그 프로그램이 deleteMyHardDrive를 호출할 가능성이 있는지를 실제로 실행해 보지 않고서는 절대 알 수 없습니다. 심지어 실행해 본다 해도, 우리가 놓친 어떤 다른 실행 경로에서 deleteMyHardDrive가 호출될 가능성을 배제할 수 없습니다.
정말로 바라는 건, 아무것도 실행하기 전에 프로그램과 그 모든 가능한 효과를 분석할 수 있는 능력입니다.
대부분의 프로그래머는 일상적인 프로그래밍 언어에 정적 분석을 적용했을 때의 장점을 잘 알고 있습니다. 타입 불일치나 잘못된 함수 호출 같은 기본적인 오류를 잡아주고, 경우에 따라 메모리 안전성이나 경쟁 상태 같은 문제도 잡아줍니다.
효과 시스템 안의 프로그램을 분석할 때는 보통 다른 종류의 이점을 기대하지만, 여전히 유용합니다!
예를 들어, 효과적(program with effects)인 프로그램에 대해 충분히 파악할 수 있다면, 중복 호출 제거, 독립적인 워크플로의 병렬화, 결과 캐싱, 더 효율적인 워크플로로의 최적화 같은 코드 변환을 수행할 수 있습니다.
또한 유용한 지식도 얻을 수 있습니다. 예를 들어 개발자가 무슨 일이 일어날지 더 잘 이해할 수 있도록 호출 그래프를 만들 수 있고, 파일 시스템이나 네트워크 같이 민감한 자원의 사용을 분석하여 실제 실행을 시작하기 전에 승인 요청을 할 수도 있습니다.
하지만 앞서 말했듯, 모나딕 효과 시스템에서는 이런 기법 대부분을 사용할 수 없습니다. 모나드 인터페이스 자체가 그 이유를 명확히 보여줍니다:
class Applicative m => Monad m where
(>>=) :: m a -> (a -> m b) -> m b
return :: a -> m a
Bind (>>=)에서 보듯, 다음에 어떤 효과(m b)가 실행될지 알려면 먼저 이전 효과(m a)를 실행해야 하고, 그 다음 호스트 언어(Haskell)가 임의의 Haskell 함수를 실행해야 합니다. 그 함수를 실행해 보기 전에는 그 결과가 무엇일지에 대해 통찰을 얻을 방법이 전혀 없습니다.
이제 스펙트럼에서 분석 쪽으로 한 걸음 옮겨, 애플리커티브에 대해 이야기해 봅시다…
애플리커티브(Applicative)는 효과적 연산을 표현하는 또 다른 인터페이스입니다.
제가 아는 한, 애플리커티브를 프로그래밍에 널리 소개한 최초의 문헌은 Conor McBride와 Ross Paterson의 2008년 논문 Applicative Programming with Effects입니다.
이 논문은 모나드가 이미 널리 사용된 이후에 쓰였고, 애플리커티브는 그 정의상 모나드보다 표현력이 약합니다. 더 정확히 말하면, 애플리커티브는 모나드보다 적은 효과적 프로그램을 표현할 수 있습니다. 모든 모나드는 애플리커티브 인터페이스를 구현하지만, 모든 애플리커티브가 모나드는 아니라는 사실이 이를 보여줍니다.
표현력이 약함에도 애플리커티브는 여전히 매우 유용합니다. 모나드가 아닌 효과에도 프로그램을 표현할 수 있게 해 줄 뿐 아니라, 실행에 앞서 효과적 프로그램에 어떤 효과들이 포함되는지 더 잘 분석할 수 있게 해 줍니다.
애플리커티브 인터페이스를 보죠:
class Functor f => Applicative f where
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b
모나드와 달리, 이전에 실행된 효과의 결과에 따라 미래의 효과가 달라지는 식의 순서를 제공하지 않습니다. 효과의 순서는 효과를 실행하기 전에 호스트 언어에 의해 전적으로 결정되고, 그 결과 실행 전에 효과의 순서를 신뢰할 수 있게 검사할 수 있습니다.
이 ‘제약’(그렇게 부를 수 있다면)은 프로그램 분석에서 엄청난 유용성을 줍니다. 어떤 애플리커티브 효과들의 시퀀스든, 실행 전에 이를 분석하여 예정된 모든 효과의 목록을 만들고, 잠재적으로 위험한 효과를 실행하기 전에 최종 사용자에게 허락을 구할 수 있습니다.
ReadWrite 효과로 어떤 모습인지 보겠습니다.
import Control.Applicative (liftA3)
import Control.Monad.Writer (Writer, runWriter, tell)
-- | 이제는 애플리커티브 인터페이스만 요구합니다
class (Applicative m) => ReadWrite m where
readLine :: m String
writeLine :: String -> m ()
data Command
= ReadLine
| WriteLine String
deriving (Show)
-- | 실제로는 아무 것도 실행하지 않고, 프로그램이 실행하려는 명령들을 기록만 하는
-- 더미 인터프리터 인스턴스를 구현할 수 있습니다.
instance ReadWrite (Writer [Command]) where
readLine = tell [ReadLine] *> pure "Simulated User Input"
writeLine msg = tell [WriteLine msg]
-- | 프로그램을 실행하여 실행할 명령들의 리스트를 얻는 헬퍼
recordCommands :: Writer [Command] String -> [Command]
recordCommands w = snd (runWriter w)
-- | 사용자에게 인사하는 간단한 프로그램
myProgram :: (ReadWrite m) => String -> m String
myProgram greeting =
liftA3
(\_ name _ -> name)
(writeLine (greeting <> ", what is your name?"))
readLine
(writeLine "Welcome!")
-- 이제 Writer 애플리커티브에서 프로그램을 실행해 무엇을 할지 볼 수 있습니다!
main :: IO ()
main = do
let commands = recordCommands (myProgram "Hello")
print commands
-- [WriteLine "Hello, what is your name?",ReadLine,WriteLine "Welcome!"]
이 인터페이스는 bind를 제공하지 않으므로, readLine의 결과를 이후의 writeLine 효과에서 사용할 수 없습니다. 아쉬운 점이죠. 애플리커티브가 이런 면에서 덜 ‘표현력’이 큰 것은 분명하지만, 애플리커티브 ReadWrite로 작성된 프로그램을 분석하여 실제로 아무 것도 실행하기 전에 어떤 효과들이 정확히 실행될지, 각 효과가 어떤 인자를 받는지 파악할 수 있습니다.
이 정도면 “표현력이 많을수록 항상 더 좋다”가 단순한 사실이 아님을 납득하셨길 바랍니다. 표현력은 프로그램 분석의 용이함과 표현력 사이의 연속선 상에 존재합니다.
표현력은 비용을 수반합니다. 특히 분석의 비용을요.
애플리커티브가 좋은 건 분명하지만, 제법 강한 제약이어서 유용한 프로그램 상당수를 못 쓰게 만듭니다. 그 둘 사이 어딘가에 있는 인터페이스가 있다면 어떨까요?
바로 ‘셀렉티브 애플리커티브(Selective Applicatives)’가 애플리커티브와 모나드 사이에 알맞게 위치합니다.
들어본 적 없다면, 이 글은 Selective 자체에 대한 튜토리얼이 아니니, 원하시면 여기에서 읽어 보세요.
셀렉티브 애플리커티브 인터페이스는 애플리커티브와 비슷하지만, 프로그램이 실행 시 선택할 수도 있는 분기 경로들의 알려진 집합을 지정할 수 있게 해 줍니다. 모나딕 인터페이스와 달리, 이러한 분기 경로는 사전에 알려져 열거되어 있어야 하며, 효과를 실행하는 중간에 즉석에서 만들어낼 수는 없습니다.
이 인터페이스는 일상적인 프로그래밍에 실제로 필요한 표현력 수준에 훨씬 가까워지면서도, 프로그램 분석의 주요 이점 대부분을 여전히 제공합니다.
다음은 셀렉티브 애플리커티브를 사용해 ReadWriteDelete 프로그램을 분석하는 예시입니다:
import Control.Monad.Writer
import Control.Selective as Selective
import Data.Either
import Data.Functor ((<&>))
-- 이제는 셀렉티브 인터페이스를 요구합니다
class (Selective m) => ReadWriteDelete m where
readLine :: m String
writeLine :: String -> m ()
deleteMyHardDrive :: m ()
data Command
= ReadLine
| WriteLine String
| DeleteMyHardDrive
deriving (Show)
-- | "Under"는 최소한의 셀렉티브 효과 수집을 도와주는 헬퍼입니다.
instance ReadWriteDelete (Under [Command]) where
readLine = Under [ReadLine]
writeLine msg = Under [WriteLine msg]
deleteMyHardDrive = Under [DeleteMyHardDrive]
-- | "Over"는 가능한 모든 셀렉티브 효과를 수집합니다.
instance ReadWriteDelete (Over [Command]) where
readLine = Over [ReadLine]
writeLine msg = Over [WriteLine msg]
deleteMyHardDrive = Over [DeleteMyHardDrive]
-- | 실제 IO 인스턴스
instance ReadWriteDelete IO where
readLine = getLine
writeLine msg = putStrLn msg
deleteMyHardDrive = putStrLn "Deleting hard drive... Just kidding!"
-- | 셀렉티브 효과를 사용하는 프로그램
myProgram :: (ReadWriteDelete m) => m String
myProgram =
let msgKind =
Selective.matchS
-- 정적 분석에서 고려해야 할 모든 유효 값들
(Selective.cases ["friendly", "mean"])
-- 입력을 얻기 위해 실행하는 액션
readLine
-- 각 입력에 대해 무엇을 할지
( \case
"friendly" -> writeLine ("Hello! what is your name?") *> readLine
"mean" -> writeLine ("Hey doofus, what do you want? Too late. I deleted your hard-drive. How do you feel about that?") *> deleteMyHardDrive *> readLine
-- 실제로는 일어나지 않습니다.
_ -> error "impossible"
)
prompt = writeLine "Select your mood: friendly or mean"
fallback =
(writeLine "That was unexpected. You're an odd one aren't you?")
<&> \() actualInput -> "Got unknown input: " <> actualInput
in prompt
*> Selective.branch
msgKind
fallback
(pure id)
allPossibleCommands :: Over [Command] x -> [Command]
allPossibleCommands (Over cmds) = cmds
minimumPossibleCommands :: Under [Command] x -> [Command]
minimumPossibleCommands (Under cmds) = cmds
runIO :: IO String
runIO = myProgram
-- | 이제 프로그램을 Writer 애플리커티브에서 실행해 무엇을 할지 볼 수 있습니다!
main :: IO ()
main = do
let allCommands = allPossibleCommands myProgram
let minimumCommands = minimumPossibleCommands myProgram
putStrLn "All possible commands:"
print allCommands
putStrLn "Minimum possible commands:"
print minimumCommands
-- All possible commands:
-- [ WriteLine "Select your mood: friendly or mean"
-- , ReadLine
-- , WriteLine "Hey doofus, what do you want? Too late. I deleted your hard-drive. How do you feel about that?"
-- , DeleteMyHardDrive
-- , ReadLine
-- , WriteLine "Hello! what is your name?"
-- , ReadLine
-- , WriteLine "That was unexpected. You're an odd one aren't you?"
-- ]
--
-- Minimum possible commands:
-- [ WriteLine "Select your mood: friendly or mean"
-- , ReadLine
-- ]
이제 이전 효과의 결과에 기반해 분기하는, 셀렉티브 애플리커티브의 모든 능력을 사용하는 프로그램을 읽어 보셨습니다.
사용자 입력에 따라 친절하거나 불친절한 인사 방식을 선택해 분기할 수 있으니, 애플리커티브 버전보다 표현력이 더 큽니다. 하지만 이 방식이 가장 투박한 선택지라는 것도 분명합니다. 쓰기도 까다롭고, 읽기도 꽤 어렵습니다.
이제 사용자 입력에 ‘분기’할 수는 있지만, 처리하고 싶은 모든 가능한 입력에 대해 명시적인 분기를 미리 구성해야 하므로, 사용자가 입력한 내용을 그대로 에코백한다거나, 이름으로 인사하는 간단한 프로그램조차 쓸 수 없습니다. 여전히 표현할 수 있는 프로그램에는 상당한 제약이 있죠.
하지만 밝은 면을 봅시다. 애플리커티브에서 했던 것처럼, 프로그램이 실행할 수 있는 명령들을 분석할 수 있습니다. 이번에는 프로그램에 분기 경로가 존재합니다.
셀렉티브 인터페이스는 프로그램을 분석하기 위한 두 가지 방법을 제공합니다:
Under newtype은 입력이 무엇이든 간에 프로그램이 반드시 실행할 최소한의 효과 시퀀스를 수집하게 해 줍니다.Over newtype은 프로그램이 모든 분기 경로를 통과한다고 가정했을 때 마주칠 수 있는 ‘모든’ 가능한 효과의 리스트를 수집합니다.이것이 가능한 실행 경로를 나타내는 그래프를 받는 것만큼 유용하진 않을 수 있지만, 프로그램이 무엇을 ‘할 수도 있는지’ 사용자에게 미리 경고하기에는 충분한 정보를 줍니다. 이를 통해 “정확히 무엇이 원인인지는 모르겠지만, 이 프로그램은 당신의 하드 드라이브를 삭제할 수 있는 능력이 있습니다”라고 알려줄 수 있죠.
물론 애플리커티브와 마찬가지로, 추가적인 셀렉티브 인터페이스를 작성하거나 Free Selective를 사용하여 셀렉티브 계산을 재작성함으로써 원하는 대로 최적화하거나 메모이제이션할 수 있습니다.
여기까지 보면 셀렉티브도 훌륭한 도구이지만, 제약은 여전히 너무 큽니다:
이 문제는 아직 해결되지 않았습니다. 하지만 걱정 마세요. 효과를 순서화하는 방법은 더 있습니다.
아마 또 5년쯤 걸릴지도 모르지만, 언젠가 이 여정을 이어가며, 카테고리 계층(Category classes)의 위계를 사용해 효과를 순서화하는 방법을 탐구해 보려 합니다. 이것이 표현력 스펙트럼에서 더 실용적인 중간 지점을 찾는 데 어떻게 도움이 되는지도요. 가능한 실행 경로를 분석하는 능력을 유지하면서, 우리가 실제로 필요한 프로그램을 작성하는 능력은 포기하지 않는 그런 지점 말이죠.
이 글이, 모나드가 함수형 프로그래밍에 엄청난 발견이었던 건 맞지만, 우리가 일상에서 흔히 직면하는 문제에 더 잘 맞는 추상을 계속 찾아야 한다는 점을 이해하는 데 도움이 되길 바랍니다.
뭔가 배운 점이 있었다면 좋겠네요 🤞! 그렇다면 제 책도 한번 봐 주세요: Haskell과 다른 함수형 언어에서 옵틱스를 사용하는 원리를 다루며, 초보자에서 시작해 모든 종류의 옵틱스에 정통한 마법사로 이끄는 내용입니다! 여기에서 보실 수 있습니다. 여러분의 구매는 이런 블로그 글을 더 쓸 시간을 정당화해 주고, 교육적인 함수형 프로그래밍 콘텐츠를 계속 만드는 데 큰 도움이 됩니다. 감사합니다!