- Published on
Haskell 모나드 트랜스포머로 오류와 IO 합치기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 CLI 같은 현실 세계 프로그램은 대개 IO를 하면서(파일/네트워크/DB) 동시에 오류를 다룹니다. Haskell은 순수성과 타입으로 이 문제를 깔끔하게 풀지만, 초반에는 IO (Either e a) 같은 타입이 꼬이기 쉽습니다. 이 글에서는 모나드 트랜스포머로 IO와 오류(Either)를 자연스럽게 합치는 방법을, ExceptT를 중심으로 정리합니다.
또한 “그냥 Either를 쓰면 되지 않나?”부터 “ReaderT까지 얹으면 뭐가 좋아지나?”, “예외(Exception)와는 어떻게 나눠 쓰나?” 같은 실무 질문에 답할 수 있게 구성합니다.
운영 환경에서 오류 흐름을 명확히 하는 건 리소스 누수/고루틴 누수 방지와도 결이 비슷합니다. 취소/정리 경로가 명확해야 장애가 줄어듭니다. 관련 관점은 Go 고루틴 leak 막기 - context 취소·채널 close도 참고할 만합니다.
문제: IO와 Either가 중첩되면 읽기 어려워진다
예를 들어 사용자 입력을 읽고 파싱한 뒤 외부 요청을 하는 함수가 있다고 합시다.
- 파싱은 실패할 수 있음:
Either AppError UserId - 외부 요청은
IO가 필요함:IO User
이를 단순히 합치면 다음 같은 타입이 흔히 등장합니다.
IO (Either AppError User)Either AppError (IO User)(이건 더 다루기 불편)
IO (Either e a)는 그나마 쓸 수 있지만, 로직이 커질수록 case 중첩과 fmap/>>=가 뒤엉켜 가독성이 급격히 떨어집니다. 이때 모나드 트랜스포머를 쓰면 “오류가 섞인 IO”를 하나의 모나드처럼 다룰 수 있습니다.
핵심 도구: ExceptT로 오류를 IO 위에 얹기
ExceptT e m a는 직관적으로 “m 안에서 실행되는데 실패하면 e를 던질 수 있는 계산”입니다.
ExceptT AppError IO a= “IO를 하면서AppError로 실패할 수 있는 계산”
아래는 최소 예시입니다.
{-# LANGUAGE OverloadedStrings #-}
module Main where
import Control.Monad.Trans.Except (ExceptT, runExceptT, throwE)
import Control.Monad.IO.Class (liftIO)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
data AppError
= EmptyInput
| NotANumber T.Text
deriving (Show)
parseInt :: T.Text -> ExceptT AppError IO Int
parseInt t
| T.null (T.strip t) = throwE EmptyInput
| otherwise =
case reads (T.unpack (T.strip t)) of
[(n, "")] -> pure n
_ -> throwE (NotANumber t)
program :: ExceptT AppError IO ()
program = do
liftIO $ TIO.putStrLn "Enter a number:"
input <- liftIO TIO.getLine
n <- parseInt input
liftIO $ TIO.putStrLn ("You typed: " <> T.pack (show n))
main :: IO ()
main = do
result <- runExceptT program
case result of
Left err -> print err
Right () -> pure ()
포인트는 다음 3가지입니다.
- 실패는
throwE로 표현하고 IO는liftIO로 끌어올리고- 최종적으로
runExceptT로IO (Either e a)형태로 “내립니다”
이렇게 하면 중간 로직은 do 블록에서 자연스럽게 직렬화됩니다.
Either vs ExceptT: 무엇이 더 좋은가
Either e a 자체도 모나드이기 때문에 순수 계산에서는 Either만으로 충분합니다. 하지만 IO 같은 다른 효과와 결합되면 선택지가 생깁니다.
IO (Either e a)를 직접 다루기ExceptT e IO a로 추상화해서 다루기
대부분의 경우 ExceptT가 다음 장점이 있습니다.
- 가독성:
case중첩이 줄고, 실패 흐름이throwE로 통일됨 - 조합성:
ExceptT위에ReaderT/StateT같은 다른 효과를 얹기 쉬움 - 테스트 용이성:
ExceptT e Identity a처럼IO를 제거한 형태로도 실행 모델을 바꿀 수 있음(설계에 따라)
실무형 스택: ReaderT + ExceptT로 환경과 오류를 함께
실제 서비스 코드에서는 설정/로거/DB 커넥션 같은 “환경”을 매번 인자로 넘기기보다 ReaderT로 묶는 경우가 많습니다.
여기서 흔한 패턴이 이른바 ReaderT 패턴입니다.
ReaderT Env (ExceptT AppError IO) a
즉, “환경을 읽을 수 있고, 앱 오류로 실패 가능하며, IO도 하는 계산”이 됩니다.
{-# LANGUAGE OverloadedStrings #-}
module App where
import Control.Monad.IO.Class (MonadIO(liftIO))
import Control.Monad.Trans.Reader (ReaderT, ask, runReaderT)
import Control.Monad.Trans.Except (ExceptT, runExceptT, throwE)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
newtype Env = Env
{ envServiceName :: T.Text
}
data AppError
= BadUserId
| ExternalCallFailed
deriving (Show)
type AppM = ReaderT Env (ExceptT AppError IO)
validateUserId :: Int -> AppM Int
validateUserId uid
| uid > 0 = pure uid
| otherwise = throwE BadUserId
callExternal :: Int -> AppM T.Text
callExternal uid = do
env <- ask
-- 여기서는 예시로 출력만 수행
liftIO $ TIO.putStrLn ("[" <> envServiceName env <> "] calling external for uid=" <> T.pack (show uid))
-- 실패 가능 지점을 표현
if uid == 13
then throwE ExternalCallFailed
else pure "ok"
runApp :: Env -> AppM a -> IO (Either AppError a)
runApp env app = runExceptT (runReaderT app env)
이 구조의 이점은 다음과 같습니다.
- 의존성 전달의 일관성:
Env만 바꿔서 테스트/로컬/프로덕션 설정을 교체 - 오류 모델의 통일: 예상 가능한 실패는
AppError로 수렴 - IO 경계의 명확화:
liftIO가 있는 곳이 효과 경계가 됨
자주 하는 실수: ExceptT IO 순서와 의미
트랜스포머 스택은 “순서”가 의미를 바꿉니다.
ExceptT e IO a는 “IO를 하다가e로 실패”IO (Either e a)는 결과 표현만 보면 같아 보이지만, 코드 작성 경험이 다름ReaderT Env (ExceptT e IO) a와ExceptT e (ReaderT Env IO) a도 미묘하게 다릅니다
대부분의 앱 코드에서는 ReaderT Env (ExceptT e IO)가 많이 쓰입니다. 이유는 ReaderT가 바깥에 있으면 ask로 환경을 쉽게 꺼내고, 그 안에서 throwE/liftIO를 섞는 형태가 자연스럽기 때문입니다.
반대로 ExceptT e (ReaderT Env IO)도 가능하지만, 일부 조합/헬퍼가 예상과 다르게 동작하는 순간이 있어 팀 컨벤션을 정해두는 게 좋습니다.
IO 예외(Exception)와 ExceptT 오류는 어떻게 나눌까
Haskell에서는 다음 두 세계가 공존합니다.
- 예상 가능한 도메인 오류:
AppError같은 합 타입,ExceptT/Either로 표현 - 예상 불가능하거나 런타임 성격의 예외:
IOException등,Control.Exception계열
권장되는 실무 접근은 이렇습니다.
- 도메인 레벨에서 복구 가능한 실패는
AppError로 모델링 IO예외는 경계에서 잡아AppError로 변환하거나, 로깅 후 재던지기
예를 들어 파일 읽기에서 IOException을 앱 오류로 바꾸고 싶다면 다음처럼 합니다.
{-# LANGUAGE OverloadedStrings #-}
import Control.Exception (try)
import Control.Monad.Trans.Except (ExceptT, throwE)
import Control.Monad.IO.Class (liftIO)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
data AppError = FileReadFailed T.Text deriving (Show)
readFileText :: FilePath -> ExceptT AppError IO T.Text
readFileText path = do
e <- liftIO $ try (TIO.readFile path) -- e :: Either IOException Text
case e of
Left _ex -> throwE (FileReadFailed (T.pack path))
Right t -> pure t
여기서 중요한 건 try가 IO 예외를 Either로 바꿔준다는 점이고, 그 결과를 ExceptT의 throwE로 다시 “앱 오류 흐름”에 합치는 겁니다.
패턴: 작은 함수는 ExceptT로, 경계에서만 runExceptT
유지보수 관점에서 가장 흔한 실수는 너무 일찍 runExceptT로 내려버리는 것입니다. 그러면 다시 IO (Either e a)를 계속 들고 다니게 됩니다.
권장 패턴은:
- 내부 로직 함수들은
AppM a(예:ReaderT Env (ExceptT AppError IO) a)를 유지 - HTTP 핸들러/CLI
main같은 경계 레이어에서만runApp또는runExceptT로 실행
이렇게 하면 에러 매핑/로깅/메트릭 같은 관심사를 경계에 모을 수 있습니다.
예제: 여러 단계의 오류·IO를 한 흐름으로 합치기
아래는 “입력 읽기 → 검증 → 외부 호출 → 결과 출력”을 한 번에 묶은 예시입니다.
{-# LANGUAGE OverloadedStrings #-}
module Main where
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Reader (ReaderT, ask, runReaderT)
import Control.Monad.Trans.Except (ExceptT, runExceptT, throwE)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
newtype Env = Env { envServiceName :: T.Text }
data AppError
= Empty
| InvalidNumber T.Text
| ExternalFailed
deriving (Show)
type AppM = ReaderT Env (ExceptT AppError IO)
parsePositiveInt :: T.Text -> AppM Int
parsePositiveInt raw
| T.null (T.strip raw) = throwE Empty
| otherwise =
case reads (T.unpack (T.strip raw)) of
[(n, "")] | n > 0 -> pure n
_ -> throwE (InvalidNumber raw)
externalCall :: Int -> AppM T.Text
externalCall n = do
env <- ask
liftIO $ TIO.putStrLn ("[" <> envServiceName env <> "] external call with n=" <> T.pack (show n))
if n `mod` 5 == 0 then throwE ExternalFailed else pure "success"
app :: AppM ()
app = do
liftIO $ TIO.putStrLn "Type a positive integer:"
raw <- liftIO TIO.getLine
n <- parsePositiveInt raw
msg <- externalCall n
liftIO $ TIO.putStrLn ("Result: " <> msg)
runApp :: Env -> AppM a -> IO (Either AppError a)
runApp env m = runExceptT (runReaderT m env)
main :: IO ()
main = do
let env = Env "demo-service"
r <- runApp env app
case r of
Left e -> putStrLn ("ERROR: " <> show e)
Right _ -> pure ()
이 코드에서 중요한 관찰:
- 실패 지점은
throwE로 한 줄 IO는liftIO로 명시- 최종 출력/종료 코드는
main에서Either를 해석
즉, 애플리케이션의 “실패 가능한 작업”을 하나의 모나드(AppM)로 모델링하고, 경계에서만 해석합니다.
모나드 트랜스포머를 도입할 때의 운영적 이점
모나드 트랜스포머는 문법적 편의만이 아니라, 운영 안정성과도 연결됩니다.
- 오류 경로가 타입으로 강제되면 “실패했는데 계속 진행” 같은 버그가 줄어듭니다.
IO경계가 명확해져서 로깅/메트릭/트레이싱 삽입 위치가 선명해집니다.- 리소스 정리(
bracket류)와 결합할 때도, 성공/실패 흐름이 한 곳으로 모여 누수가 줄어듭니다.
장애 대응 관점에서는 “실패를 어디서 삼키는가”가 특히 중요합니다. 분산 시스템에서 중복 결제 같은 문제를 막기 위해 흐름을 명확히 설계하는 접근은 MSA 사가(Saga) 패턴 구현으로 중복결제 방지하기와도 통하는 면이 있습니다. Haskell에서는 그 흐름을 타입과 트랜스포머로 더 강하게 고정할 수 있습니다.
정리: ExceptT로 오류를, ReaderT로 환경을, 경계에서만 실행
IO (Either e a)를 직접 끌고 다니기 시작하면 코드가 빠르게 복잡해집니다.ExceptT e IO a는 “오류 가능한 IO”를 한 모나드로 만들어do표기를 자연스럽게 합니다.- 실무에서는
ReaderT Env (ExceptT AppError IO)같은 스택으로 환경과 오류를 함께 다루는 경우가 많습니다. runExceptT/runReaderT는 HTTP 핸들러, CLImain같은 경계에서만 호출하는 편이 유지보수에 유리합니다.
다음 단계로는 mtl 스타일의 타입클래스(MonadReader, MonadError, MonadIO)로 구체 스택을 숨기거나, 효과 시스템(예: polysemy, effectful)로 확장하는 방법을 검토해볼 수 있습니다. 다만 팀 규모/학습 비용을 감안하면, 먼저 ReaderT + ExceptT 조합을 안정적으로 운영하는 것만으로도 상당히 큰 효과를 얻습니다.