Haskell의 IO가 순수하거나 참조 투명하다는 주장에 반례와 의미론을 통해 반박하고, IO로 구현한 명령형 스타일과 순수한 대안을 대비시키며 참조 투명성과 순수성의 차이를 설명한다.
IO 표현식은 정말로 참조 투명한 프로그램일까?
때때로 제가 함수형 아키텍처나 IO 컨테이너에 대해 이야기하면, 어떤 독자는 Haskell의 IO가 정말 ‘순수하다’, ‘참조 투명하다’, ‘함수형이다’ 등과 같은 성질을 가진다고 주장하곤 합니다.
주장은 보통 이렇게 전개됩니다. IO 값은 어떤 동작의 합성 가능한 ‘서술’이며, 그 자체가 동작은 아니다. IO는 Monad 인스턴스이므로, 표준 모나딕 바인드 결합자 >>= 또는 그 파생자를 통해 합성된다.
또 다른 논점으로, 다음과 같은 장난감 예제가 보여 주듯, 순수 함수 내부에서 IO 값을 ‘호출’할 수 있다는 점이 제시되기도 합니다:
haskellgreet :: TimeOfDay -> String -> String greet timeOfDay name = let greeting = case () of _ | isMorning timeOfDay -> "Good morning" | isAfternoon timeOfDay -> "Good afternoon" | isEvening timeOfDay -> "Good evening" | otherwise -> "Hello" sideEffect = putStrLn "Side effect!" in if null name then greeting ++ "." else greeting ++ "," ++ name ++ "."
이는 Referential transparency of IO에 제시된 예제를 Haskell로 옮긴 것입니다. 여기서 sideEffect는 greet가 순수 함수임에도 불구하고 타입이 IO ()인 값입니다. 이런 예제는 때때로 putStrLn "Side effect!"라는 표현이 결정적이고 부작용이 없으므로 순수하다고 주장하는 근거로 쓰입니다.
사실 sideEffect는 어떤 동작을 서술하는 ‘프로그램’입니다. 그 프로그램(값) 자체는 참조 투명하지만, 실제로 실행하는 행위는 그렇지 않습니다.
Referential transparency of IO에서 설명했듯, 위 함수 적용이 합법인 이유는 greet가 IO 액션 ‘안의’ 값을 전혀 사용하지 않기 때문입니다. 실제로 컴파일러는 sideEffect 표현을 최적화로 제거할 수도 있으며, GHC가 실제로 그렇게 하는 것으로 알고 있습니다.
가장 흔한 주장들을 최대한 간결하게 요약해 보았습니다. 실제 온라인 토론을 인용할 수도 있겠지만, 특정인을 지적하고 싶지는 않습니다. 이 글이 누군가를 공격하는 것처럼 보이게 하고 싶지 않기 때문입니다. 다만 제 입장은 변함없이 ‘IO는 특별하다’이며, 그 이유를 이어서 설명하려 합니다.
일반론으로 시작해 논문을 인용하며 논증을 전개할 수도 있겠지만, 그러면 많은 독자를 잃게 될까 봐 우려됩니다. 그래서 논증을 뒤집어 반례로 시작하겠습니다. 만약 IO가 순수하거나 참조 투명하다는 주장을 받아들인다면 무슨 일이 일어날까요?
그렇다면 Haskell 코드 전부가 순수하다고 간주되어야 합니다. 그 속에는 putStrLn "Hello, world."나 launchMissiles도 포함됩니다. 그런 결론이 황당하다고 느끼는 건 제 주관적 의견일 수도 있지만, 동시에 IO를 사용해 the awkward squad를 다루려는 본래 목적에도 배치되어 보입니다.
더 나아가, 아마도 더 객관적으로, 이렇게 되면 모든 것을 IO로 작성하고도 그것을 ‘함수형’이라고 부를 수 있게 됩니다. 그게 무슨 뜻일까요?
만약 IO가 순수하다고 받아들인다면, 모든 것을 절차적 스타일로 작성하기로 마음먹을 수도 있습니다. 예를 들어, 알고리즘을 설명하는 명령형 유사코드를 그대로 반영해 막대 자르기(rod-cutting)를 구현할 수 있습니다.
haskell{-# LANGUAGE FlexibleContexts #-} module RodCutting where import Control.Monad (forM_, when) import Data.Array.IO import Data.IORef (newIORef, writeIORef, readIORef, modifyIORef) cutRod :: (Ix i, Num i, Enum i, Num e, Bounded e, Ord e) => IOArray i e -> i -> IO (IOArray i e, IOArray i i) cutRod p n = do r <- newArray_ (0, n) s <- newArray_ (1, n) writeArray r 0 0 --r[0]=0 forM_ [1..n] $ \j -> do q <- newIORef minBound --q=-∞ forM_ [1..j] $ \i -> do qValue <- readIORef q p_i <- readArray p i r_j_i <- readArray r (j - i) when (qValue < p_i + r_j_i) $ do writeIORef q (p_i + r_j_i) --q=p[i]+r[j-i] writeArray s j i --s[j]=i qValue' <- readIORef q writeArray r j qValue' --r[j]=q return (r, s)
아이러니하게도 cutRod 액션은 CLRS의 원래 유사코드와 마찬가지로 참조 투명성을 유지합니다. 알고리즘 자체가 결정적이며 (외부) 부작용이 없기 때문입니다. 그럼에도 Haskell 타입 시스템은 이를 ‘볼 수’ 없습니다. 이 구현은 본질적으로 IO 값을 반환합니다.
이 점이 도리어 IO가 순수하다는 증거라고 생각할 수도 있겠지만, 그렇지 않습니다. 우리는 언제든 return을 사용해 어떤 순수 값이든 IO로 끌어올릴 수 있다는 걸 알고 있습니다. 예컨대 return 42는 IO에 담겨 있어도 여전히 참조 투명합니다.
그 역은 항상 성립하지 않습니다. 코드가 IO에 담겨 있다고 해서 참조 투명하다고 결론지을 수 없습니다. 보통은, 그렇지 않습니다.
그렇다 해도, 왜 우리가 이 문제를 신경 써야 할까요?
문제는 캡슐화에 있습니다. 위의 cutRod 같은 액션이 IO 값을 반환할 때, 우리는 거의 아무 보장도 받지 못합니다. 그 액션의 사용자 입장에서는 타입만으로는 답할 수 없는 많은 질문이 생깁니다:
cutRod는 입력 배열 p를 변경하나요?cutRod는 결정적인가요?cutRod가 미사일을 발사하기도 하나요?cutRod의 반환값을 메모이제이션해도 되나요?cutRod가 반환하는 배열에 대한 참조를 어딘가 보관하나요? 백그라운드 스레드나 이후의 API 호출이 이 배열을 변경하지 않는다고 확신할 수 있나요? 다시 말해 별칭(aliasing) 문제가 있을까요?최선의 경우 이러한 보장 부재는 방어적 코딩으로 이어지지만, 보통은 버그로 이어집니다.
반대로, IO를 전혀 사용하지 않는 버전의 cutRod를 작성한다면 위의 모든 질문에 답할 수 있습니다. 그 장점은, 함수가 더 안전하고 사용하기 쉬워진다는 것입니다.
이 지점은 제가 수년간 이해하지 못했던 부분인데, Tyson Williams가 지적해 주기 전까지 몰랐습니다. 참조 투명성과 순수성은 동일하지 않지만, 겹치는 부분이 큽니다.

물론 이런 주장은 용어 정의를 요구하지만, 가볍게만 정리하겠습니다. 여기서 _참조 투명성_은 함수를 그것이 만들어 내는 값으로 치환할 수 있는 성질로 정의하겠습니다. 실무적으로는 메모이제이션이 가능함을 뜻합니다. 반면 _순수성_은 Haskell이 불순한 액션과 구분할 수 있는 함수로 정의하겠습니다. 실제로는 IO의 부재를 의미합니다.
보통 이 둘은 같은 것을 뜻하지만, 위에서 보았듯 IO에 내장되어 있으면서도 참조 투명한 코드를 작성하는 것도 가능합니다. 또한 겉보기에는 순수해 보이지만 참조 투명하지 않을 수 있는 함수의 예시도 있습니다. 다행히 제 경험상 이런 경우는 더 드뭅니다.
어쨌든, 이는 곁가지입니다. 제가 주장하려는 바는 IO가 특별하다는 점입니다. 맞습니다, IO는 Monad 인스턴스입니다. 맞습니다, 합성됩니다. 하지만, 참조 투명하지는 않습니다.
캡슐화 관점에서 저는 이전에 참조 투명성은 머릿속에 들어오기 쉬워서 매력적이다라고 주장했습니다. 참조 투명하지 않은 코드는 보통 머릿속에 잘 들어오지 않습니다.
그렇다면 왜 IO는 참조 투명하지 않을까요? 가끔 마주치는 주장에 따르면, IO 값은 프로그램을 서술합니다. Haskell 코드가 실행될 때마다, 같은 IO 값은 같은 프로그램을 서술합니다.
이 주장은 모든 C 코드가 참조 투명하다고 우기는 것만큼이나 실용성 없는 단언처럼 보입니다. 결국 C 프로그램 역시 여러 번 실행해도 같은 프로그램을 서술하니까요.
하지만 제 말을 믿을 필요는 없습니다. Tackling the Awkward Squad: monadic input/output, concurrency, exceptions, and foreign-language calls in Haskell에서 Simon Peyton Jones는 Haskell의 의미론을 제시합니다.
“우리의 의미론은 두 층으로 계층화되어 있다. _내부 표기(denotational) 의미론_은 순수한 항의 행위를 설명하고, _외부 모나딕 전이(transition) 의미론_은
IO계산의 행위를 설명한다.”
그 논문은 이어지는 약 20쪽에 걸쳐 IO가 왜 특별한지 상세히 설명합니다. 요점은 IO가 Haskell의 나머지 부분과는 다른 의미론을 가진다는 것입니다.
마무리하기 전에, 위의 cutRod 액션이 일부 독자에게 불편함을 줄 수 있다는 점을 압니다. 긴장을 풀 수 있도록 순수한 구현을 남깁니다.
haskell{-# LANGUAGE TupleSections #-} module RodCutting (cutRod, solve) where import Data.Foldable (foldl') import Data.Map.Strict ((!)) import qualified Data.Map.Strict as Map seekBetterCut :: (Ord a, Num a) => [a] -> Int -> (a, Map.Map Int a, Map.Map Int Int) -> Int -> (a, Map.Map Int a, Map.Map Int Int) seekBetterCut p j (q, r, s) i = let price = p !! i remainingRevenue = r ! (j - i) (q', s') = if q < price + remainingRevenue then (price + remainingRevenue, Map.insert j i s) else (q, s) r' = Map.insert j q' r in (q', r', s') findBestCut :: (Bounded a, Ord a, Num a) => [a] -> (Map.Map Int a, Map.Map Int Int) -> Int -> (Map.Map Int a, Map.Map Int Int) findBestCut p (r, s) j = let q = minBound --q=-∞ (_, r', s') = foldl' (seekBetterCut p j) (q, r, s) [1..j] in (r', s') cutRod :: (Bounded a, Ord a, Num a) => [a] -> Int -> (Map.Map Int a, Map.Map Int Int) cutRod p n = do let r = Map.fromAscList $ map (, 0) [0..n] --r[0:n]initialized to 0 let s = Map.fromAscList $ map (, 0) [1..n] --s[1:n]initialized to 0 foldl' (findBestCut p) (r, s) [1..n] solve :: (Bounded a, Ord a, Num a) => [a] -> Int -> [Int] solve p n = let (_, s) = cutRod p n loop l n' = if n' > 0 then let cut = s ! n' in loop (cut : l) (n' - cut) else l l' = loop [] n in reverse l'
이는 명령형 알고리즘을 비교적 직접적으로 옮긴 것입니다. 더 우아한 방법을 고안할 수도 있겠지요. 적어도, 저는 F#에서는 더 우아하게 했다고 생각합니다.
구현의 우아함이 어느 정도이든, 이 버전의 cutRod는 타입을 통해 자신의 성질을 드러냅니다. 이제 클라이언트 개발자는 타입만 보고도 위의 질문에 손쉽게 답할 수 있습니다. 아니요, 이 함수는 입력 리스트 p를 변경하지 않습니다. 예, 이 함수는 결정적입니다. 아니요, 미사일을 발사하지 않습니다. 예, 메모이제이션할 수 있습니다. 아니요, 별칭 문제는 없습니다.
가끔 Haskell의 IO가 모나딕하고 합성 가능하므로 참조 투명하며, 실행 중에만 그 성질을 잃는다는 주장과 마주칩니다.
저는 그런 주장들이 실용적으로 큰 의미가 없다고 봅니다. 실행 중에도 여전히 참조 투명한 Haskell의 다른 부분들이 있기 때문입니다. 따라서 IO는 여전히 특별합니다.
실용적인 관점에서, 제가 참조 투명성에 관심을 갖는 이유는 더 많이 가질수록 코드가 단순해지고, 머릿속에 더 잘 들어오기 때문입니다. 일부 사람들이 IO가 가진다고 주장하는 종류의 참조 투명성은 코드를 단순하게 만들지 못합니다. 현실에서 IO 코드는 C, Python, Java, Fortran 등으로 작성된 코드와 동일한 고유한 성질을 가집니다.