Published on

Haskell Monads로 IO·에러·상태 안전하게 합치기

Authors

서버 프로그램을 짜다 보면 결국 같은 문제로 모입니다. 파일을 읽고(IO), 파싱하다가 실패할 수 있고(에러), 처리 중간중간 누적 상태(카운터, 캐시, 설정)를 들고 다닙니다(상태). 이걸 즉흥적으로 섞기 시작하면 if/else와 예외 처리, 전역 가변 상태가 서로 발목을 잡습니다.

Haskell은 이 문제를 효과(effect) 로 분리하고, 타입 으로 “무슨 일이 일어날 수 있는지”를 선언하게 만듭니다. 그리고 모나드와 모나드 트랜스포머를 이용해 IO·에러·상태를 한 흐름으로 합치되, 합쳐진 결과가 여전히 추론 가능하고 테스트 가능하도록 유지합니다.

이번 글에서는 다음을 목표로 합니다.

  • IO, 에러, 상태를 각각 어떤 타입/모나드로 표현하는지
  • StateT + ExceptT + IO 조합을 실전 형태로 쓰는 법
  • 레이어 순서가 의미에 미치는 영향(핵심)
  • 자원 정리와 로깅 같은 “현실적인” 포인트

1) 문제를 “효과”로 쪼개기

예를 들어 다음과 같은 작업을 생각해봅시다.

  • 파일에서 설정을 읽는다(IO)
  • 설정을 파싱한다(실패 가능)
  • 처리 과정에서 통계를 누적한다(상태)

명령형 언어에서는 한 함수 안에서 예외를 던지고 잡고, 전역 카운터를 올리고, 실패 시 부분적으로 변경된 상태를 되돌리는 로직을 추가하게 됩니다. 운영 환경에서는 이런 꼬임이 종종 장애로 이어지고, 결국 로그를 파고들며 원인을 추적합니다. (운영 로그 기반 문제 해결 흐름이 익숙하다면 journalctl 로그 폭주로 디스크 찰 때 10분 해결 같은 글이 떠오를 겁니다.)

Haskell에서는 “이 함수는 IO를 한다”, “이 함수는 실패할 수 있다”, “이 함수는 상태를 읽고 쓴다”가 타입에 드러나도록 구성합니다.


2) 기본 재료: IO, Either, State

IO

IO a는 외부 세계와 상호작용해서 a를 만들어낸다는 뜻입니다.

에러: Either e a

실패 가능한 계산은 Either e a로 표현합니다.

  • Left e는 실패
  • Right a는 성공

예외를 던지는 대신 “실패할 수 있음”을 반환 타입으로 강제합니다.

상태: State s a

상태를 들고 다니는 계산은 State s a로 표현합니다.

  • 입력 상태 s를 받아
  • 결과 a와 변경된 상태 s를 돌려줍니다

하지만 현실에서는 IO도 하고, 실패도 하고, 상태도 필요합니다. 그래서 등장하는 게 모나드 트랜스포머 입니다.


3) 모나드 트랜스포머로 효과 합치기

가장 흔한 조합 중 하나는 다음입니다.

  • StateT s : 상태
  • ExceptT e : 에러
  • IO : 실제 실행

즉, 스택 형태로 쌓습니다.

AppM a = StateT AppState (ExceptT AppError IO) a

이 타입은 “상태를 읽고/쓰면서, 에러로 중단될 수 있고, IO도 수행하는 계산”을 뜻합니다.

아래는 완전한 예제입니다.

{-# LANGUAGE GeneralizedNewtypeDeriving #-}

module App where

import Control.Monad.State.Strict
import Control.Monad.Except
import Data.Char (isDigit)

-- 도메인 에러
data AppError
  = FileNotFound FilePath
  | InvalidConfig String
  deriving (Show, Eq)

-- 누적 상태
data AppState = AppState
  { filesRead :: Int
  , parseFails :: Int
  } deriving (Show, Eq)

initialState :: AppState
initialState = AppState { filesRead = 0, parseFails = 0 }

newtype AppM a = AppM
  { unAppM :: StateT AppState (ExceptT AppError IO) a
  } deriving ( Functor, Applicative, Monad
             , MonadIO
             , MonadState AppState
             , MonadError AppError
             )

runAppM :: AppM a -> IO (Either AppError (a, AppState))
runAppM app = runExceptT (runStateT (unAppM app) initialState)

-- IO: 파일을 읽는 척
readConfigFile :: FilePath -> AppM String
readConfigFile path = do
  modify' (\st -> st { filesRead = filesRead st + 1 })
  if path == "missing.conf"
    then throwError (FileNotFound path)
    else pure "port=8080"

-- 순수 파서: 실패 가능
parsePort :: String -> Either AppError Int
parsePort s =
  case dropWhile (/= '=') s of
    ('=':xs) | all isDigit xs -> Right (read xs)
    _                         -> Left (InvalidConfig s)

-- Either 결과를 AppM로 올리기
liftEither :: Either e a -> ExceptT e IO a
liftEither = except

loadPort :: FilePath -> AppM Int
loadPort path = do
  content <- readConfigFile path
  -- parsePort는 순수 함수이므로 테스트가 쉬움
  case parsePort content of
    Left e -> do
      modify' (\st -> st { parseFails = parseFails st + 1 })
      throwError e
    Right p -> pure p

mainLike :: IO ()
mainLike = do
  result <- runAppM (loadPort "app.conf")
  print result

핵심 포인트:

  • 파싱은 Either순수하게 분리했습니다.
  • IO는 readConfigFile에만 모였습니다.
  • 상태 업데이트는 modify'로 명시적으로 수행합니다.
  • 실패는 throwError로 “중단”하지만, 타입은 여전히 AppM으로 유지됩니다.

4) 스택 순서가 의미를 바꾼다

모나드 트랜스포머는 “그냥 합치면 된다”가 아니라, 쌓는 순서가 의미를 바꿉니다.

비교해봅시다.

  1. StateT s (ExceptT e IO) a
  • 에러가 나면 전체 계산이 Left로 끝납니다.
  • 하지만 상태가 어디까지 반영되는지는 “어디서 상태를 관찰하느냐”에 따라 달라집니다.
  • 보통 runExceptT (runStateT ...) 형태로 실행하면, 성공 시에만 (a, s)를 얻습니다.
  1. ExceptT e (StateT s IO) a
  • 실행 결과가 Either e a이고, 상태 s는 별도로 항상 남습니다.
  • 즉, 실패해도 “실패 시점까지 누적된 상태”를 회수할 수 있습니다.

운영 관점에서 보면 이 차이는 큽니다.

  • 실패하면 상태를 롤백하고 싶은가?
  • 실패해도 통계/메트릭은 남기고 싶은가?

예를 들어 “실패해도 카운터는 증가” 같은 요구는 흔합니다. 장애 분석 시에도 “몇 번 실패했는지”가 중요합니다. 이런 관측 가능성은 결국 상태/로그를 어떻게 남기느냐의 문제로 이어지고, 메모리 누수나 프로세스 강제 종료 같은 상황에서 더 절실해집니다. (운영 장애 트레이싱 흐름은 Linux OOM Killer 로그 추적과 메모리 누수 진단 같은 글과 결이 비슷합니다.)

정리하면:

  • 실패 시 상태를 버리고 싶으면 StateT를 바깥에 두는 구성이 직관적일 때가 많습니다.
  • 실패해도 상태를 회수(관측)하고 싶으면 ExceptT를 바깥에 두는 구성이 유리합니다.

5) 실전 패턴: 설정 로딩 + 검증 + 누적 상태

조금 더 “애플리케이션스럽게” 만들어보겠습니다.

  • 설정 파일을 읽는다(IO)
  • 여러 필드를 파싱한다(에러)
  • 검증 중 몇 개 경고를 누적한다(상태)
{-# LANGUAGE GeneralizedNewtypeDeriving #-}

module ConfigApp where

import Control.Monad.State.Strict
import Control.Monad.Except
import Control.Monad (when)
import Data.List (stripPrefix)

data AppError
  = MissingKey String
  | BadValue String
  deriving (Show, Eq)

data AppState = AppState
  { warnings :: [String]
  } deriving (Show, Eq)

initialState :: AppState
initialState = AppState { warnings = [] }

newtype AppM a = AppM
  { unAppM :: ExceptT AppError (StateT AppState IO) a
  } deriving ( Functor, Applicative, Monad
             , MonadIO
             , MonadState AppState
             , MonadError AppError
             )

runAppM :: AppM a -> IO (Either AppError a, AppState)
runAppM app = runStateT (runExceptT (unAppM app)) initialState

warn :: String -> AppM ()
warn msg = modify' (\st -> st { warnings = msg : warnings st })

readConfig :: IO [String]
readConfig = pure
  [ "port=8080"
  , "mode=dev"
  ]

lookupKey :: String -> [String] -> Either AppError String
lookupKey key xs =
  case [ v | line <- xs, Just v <- [stripPrefix (key <> "=") line] ] of
    (v:_) -> Right v
    []    -> Left (MissingKey key)

parseInt :: String -> Either AppError Int
parseInt s =
  if all (`elem` ['0'..'9']) s then Right (read s) else Left (BadValue s)

loadValidatedPort :: AppM Int
loadValidatedPort = do
  lines' <- liftIO readConfig
  portStr <- either throwError pure (lookupKey "port" lines')
  port <- either throwError pure (parseInt portStr)

  when (port < 1024) $ warn "port is privileged; consider using 1024+"
  when (port > 65535) $ throwError (BadValue "port out of range")

  pure port

여기서 일부러 ExceptT를 바깥에 뒀습니다.

  • 실패하더라도 runAppM 결과로 AppState가 남습니다.
  • 즉, 검증 중 쌓인 warnings를 실패 케이스에서도 확인할 수 있습니다.

이 패턴은 “실패해도 진단 정보는 남기자”에 잘 맞습니다.


6) 자원 정리: bracketMonadMask 감각

IO가 들어오면 파일 핸들, 소켓, DB 커넥션 같은 자원이 생깁니다. 에러가 발생해도 자원은 반드시 정리되어야 합니다.

Haskell에서는 보통 bracket 계열을 씁니다. 트랜스포머 스택에서는 liftIO로 감싸 쓰는 경우가 많습니다.

import Control.Exception (bracket)
import System.IO (IOMode(ReadMode), withFile, hGetContents)

readAll :: FilePath -> AppM String
readAll path = do
  -- withFile 자체가 bracket 패턴을 내장
  liftIO $ withFile path ReadMode $ \h -> do
    hGetContents h

더 복잡한 스택에서 예외(비동기 예외 포함)까지 고려해 “항상 정리”를 보장하려면 exceptions 패키지의 MonadMask/MonadCatch를 도입하는 방식도 흔합니다. 이 글에서는 깊게 다루지 않지만, 포인트는 하나입니다.

  • ExceptT의 “에러”와 IO 예외는 다른 축입니다.
  • IO 예외를 도메인 에러로 변환할지, 상위로 던질지 정책을 정해야 합니다.

7) 테스트 전략: 순수 로직을 아래로 내리기

모나드 스택을 잘 쓰는 팀의 공통점은 “순수 함수가 많다”입니다.

  • 파싱: String -> Either AppError a
  • 검증: a -> Either AppError a 또는 a -> [Warning]
  • IO: 경계에서만

위 예제에서도 parsePort, lookupKey, parseInt는 IO와 무관해서 단위 테스트가 매우 쉽습니다.

상태까지 포함한 로직도 State로 순수하게 만들 수 있습니다.

import Control.Monad.State.Strict

type PureCheck a = State [String] (Either AppError a)

그리고 실제 앱에서는 그 순수 로직을 AppM에서 호출만 하도록 구성하면, “운영에서 안전한 합성”과 “테스트 용이성”을 동시에 얻습니다.


8) 언제 ReaderT를 추가할까

현실 앱에서는 상태(StateT)보다 설정/의존성 주입(ReaderT)이 먼저 필요해지는 경우가 많습니다.

  • 설정값
  • 로거 핸들
  • DB 커넥션 풀

이때 흔한 형태가 이른바 ReaderT 패턴입니다.

ReaderT Env (ExceptT AppError (StateT AppState IO)) a

다만 스택이 길어질수록 “어떤 효과가 어디에서 오는지”가 헷갈릴 수 있으니, newtype AppM로 감싸고 필요한 타입클래스만 노출하는 방식이 유지보수에 유리합니다.


9) 정리: 합치는 게 아니라, 경계를 만든다

IO·에러·상태를 모나드로 합친다는 말은 “모든 걸 한 군데로 몰아넣는다”가 아닙니다. 오히려 반대입니다.

  • IO는 경계로 밀어내고
  • 실패 가능성은 Either/ExceptT로 드러내고
  • 상태 변경은 StateT로 추적 가능하게 만들고
  • 트랜스포머 스택 순서로 “실패 시 상태를 관측할지/롤백할지” 정책을 타입으로 고정합니다

이렇게 하면, 운영에서 문제가 생겼을 때도 “어디서 IO가 발생했고”, “어떤 실패가 가능한지”, “실패 시 어떤 정보가 남는지”가 코드 구조에 반영됩니다. 결국 디버깅과 안정성은 코드 스타일이 아니라 효과를 다루는 설계에서 나옵니다.

필요하다면 다음 단계로는 ReaderT 패턴과 로깅 추상화, 그리고 mtl 스타일의 타입클래스 분리(예: MonadLogger, MonadTime)까지 확장해보면 좋습니다.