Published on

Haskell do-notation 탈출 - ReaderT 실무 가이드

Authors

서버 사이드 Haskell을 하다 보면 do 블록이 빠르게 비대해집니다. 처음엔 읽기 쉬워 보이지만, 설정 읽기, 로깅, DB 핸들, 외부 API 클라이언트 같은 의존성이 한 함수에 계속 흘러들어오면서 로직과 인프라가 섞입니다. 그 결과는 대개 다음 중 하나입니다.

  • 함수 시그니처가 인자 지옥이 된다(핸들, 풀, 설정, 로거…)
  • 테스트에서 목 객체를 넣기 위해 호출 스택 전체를 바꾼다
  • 예외/에러 처리 방식이 함수마다 제각각이 된다

ReaderT는 이 문제를 “환경(의존성) 전달”이라는 관점에서 정면으로 해결합니다. 핵심은 단순합니다. 환경을 인자로 넘기지 말고, 컨텍스트로 묶어 한 번만 주입하고, 필요한 곳에서 ask/asks로 꺼내 쓰는 구조로 바꿉니다.

아래에서는 ReaderT를 실무에서 쓰기 위한 최소 셋업, 흔한 함정, 그리고 do를 줄이는 패턴(조합자, MonadReader 스타일, 레이어링)을 예제와 함께 정리합니다.

do-notation이 커지는 진짜 이유

do 자체가 문제라기보다, do 안에 들어가는 “부수효과와 의존성”이 제어되지 않기 때문에 커집니다. 예를 들어 이런 코드가 있다고 합시다.

{-# LANGUAGE OverloadedStrings #-}

import qualified Data.Text as T

-- 가짜 타입들
newtype UserId = UserId Int
newtype Email  = Email T.Text

data Config = Config { cfgAllowSignup :: Bool }
data Db     = Db

data Logger = Logger
logInfo :: Logger -> T.Text -> IO ()
logInfo _ _ = pure ()

findEmailByUserId :: Db -> UserId -> IO (Maybe Email)
findEmailByUserId _ _ = pure (Just (Email "a@b.com"))

sendWelcome :: Config -> Email -> IO ()
sendWelcome _ _ = pure ()

signup :: Config -> Logger -> Db -> UserId -> IO ()
signup cfg logger db uid = do
  logInfo logger "signup start"
  if not (cfgAllowSignup cfg)
    then logInfo logger "signup disabled"
    else do
      mEmail <- findEmailByUserId db uid
      case mEmail of
        Nothing -> logInfo logger "no email"
        Just email -> do
          sendWelcome cfg email
          logInfo logger "welcome sent"

여기서 do가 길어지는 이유는 로직이 복잡해서가 아니라, cfg/logger/db를 계속 들고 다니기 때문입니다. 함수가 늘어나면 이 인자들은 모든 레이어를 관통합니다.

ReaderT는 이 “관통 인자”를 환경으로 올려서 제거합니다.

ReaderT의 실무형 기본 셋업

실무에서는 대개 다음 형태를 씁니다.

  • Env에 의존성(설정, 핸들, 로거 등)을 모은다
  • AppM = ReaderT Env IO 같은 애플리케이션 모나드를 정의한다
  • 필요한 곳에서 asks로 꺼내 쓰고, 최상단에서 runReaderT로 한 번만 실행한다
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE OverloadedStrings #-}

import Control.Monad.Reader
import qualified Data.Text as T

newtype UserId = UserId Int
newtype Email  = Email T.Text

data Config = Config { cfgAllowSignup :: Bool }
data Db     = Db

data Logger = Logger
logInfo :: Logger -> T.Text -> IO ()
logInfo _ _ = pure ()

findEmailByUserId :: Db -> UserId -> IO (Maybe Email)
findEmailByUserId _ _ = pure (Just (Email "a@b.com"))

sendWelcome :: Config -> Email -> IO ()
sendWelcome _ _ = pure ()

-- 환경

data Env = Env
  { envConfig :: Config
  , envDb     :: Db
  , envLogger :: Logger
  }

-- 애플리케이션 모나드
newtype AppM a = AppM { unAppM :: ReaderT Env IO a }
  deriving (Functor, Applicative, Monad, MonadIO, MonadReader Env)

runAppM :: Env -> AppM a -> IO a
runAppM env action = runReaderT (unAppM action) env

-- 의존성 접근 헬퍼
getConfig :: AppM Config
getConfig = asks envConfig

getDb :: AppM Db
getDb = asks envDb

getLogger :: AppM Logger
getLogger = asks envLogger

-- 리팩터링된 로직
signup :: UserId -> AppM ()
signup uid = do
  logger <- getLogger
  liftIO $ logInfo logger "signup start"

  cfg <- getConfig
  if not (cfgAllowSignup cfg)
    then liftIO $ logInfo logger "signup disabled"
    else do
      db <- getDb
      mEmail <- liftIO $ findEmailByUserId db uid
      case mEmail of
        Nothing -> liftIO $ logInfo logger "no email"
        Just email -> do
          liftIO $ sendWelcome cfg email
          liftIO $ logInfo logger "welcome sent"

인자 지옥이 사라졌고, 최상단에서만 Env를 구성하면 됩니다.

여기서 “do-notation 탈출”이란

ReaderT를 쓴다고 do가 사라지진 않습니다. 대신 “의존성 전달 때문에 생긴 do”가 줄어듭니다. 그리고 다음 섹션의 패턴을 적용하면 do 자체도 더 얇아집니다.

asks와 조합자로 do를 얇게 만들기

ReaderT에서 자주 하는 실수는 “어차피 do니까” 하면서 매번 logger <- getLogger 같은 바인딩을 반복하는 것입니다. asks는 값을 꺼내는 순간에 바로 쓰도록 도와줍니다.

logInfoM :: T.Text -> AppM ()
logInfoM msg = do
  logger <- asks envLogger
  liftIO $ logInfo logger msg

findEmailM :: UserId -> AppM (Maybe Email)
findEmailM uid = do
  db <- asks envDb
  liftIO $ findEmailByUserId db uid

sendWelcomeM :: Email -> AppM ()
sendWelcomeM email = do
  cfg <- asks envConfig
  liftIO $ sendWelcome cfg email

이제 비즈니스 로직은 의존성 접근을 거의 보지 않습니다.

signup :: UserId -> AppM ()
signup uid = do
  logInfoM "signup start"
  allow <- asks (cfgAllowSignup . envConfig)
  if not allow
    then logInfoM "signup disabled"
    else do
      mEmail <- findEmailM uid
      case mEmail of
        Nothing    -> logInfoM "no email"
        Just email -> sendWelcomeM email >> logInfoM "welcome sent"

포인트는 asks (f . g) 같은 “필드 접근 조합”이 실무에서 매우 자주 쓰인다는 점입니다.

ReaderT에 에러 처리를 얹는 방식: ExceptT와의 조합

실무에서는 IO만으로 끝나지 않고, 도메인 에러를 명시적으로 다루는 경우가 많습니다. 이때 흔한 스택은 다음 중 하나입니다.

  • ReaderT Env (ExceptT AppError IO)
  • ExceptT AppError (ReaderT Env IO)

둘 다 가능하지만, “환경은 항상 있고, 그 위에서 실패할 수 있다”는 모델링에는 보통 ReaderT Env (ExceptT e IO)가 자연스럽습니다.

{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE OverloadedStrings #-}

import Control.Monad.Reader
import Control.Monad.Except
import qualified Data.Text as T

newtype UserId = UserId Int
newtype Email  = Email T.Text

data AppError
  = SignupDisabled
  | EmailNotFound
  deriving (Show, Eq)

-- Env/Config/Db/Logger는 앞 예제와 동일하다고 가정

data Config = Config { cfgAllowSignup :: Bool }
data Db = Db

data Logger = Logger
logInfo :: Logger -> T.Text -> IO ()
logInfo _ _ = pure ()

findEmailByUserId :: Db -> UserId -> IO (Maybe Email)
findEmailByUserId _ _ = pure Nothing

sendWelcome :: Config -> Email -> IO ()
sendWelcome _ _ = pure ()


data Env = Env { envConfig :: Config, envDb :: Db, envLogger :: Logger }

newtype AppM a = AppM { unAppM :: ReaderT Env (ExceptT AppError IO) a }
  deriving (Functor, Applicative, Monad, MonadReader Env, MonadError AppError, MonadIO)

runAppM :: Env -> AppM a -> IO (Either AppError a)
runAppM env action = runExceptT $ runReaderT (unAppM action) env

logInfoM :: T.Text -> AppM ()
logInfoM msg = do
  logger <- asks envLogger
  liftIO $ logInfo logger msg

signup :: UserId -> AppM ()
signup uid = do
  logInfoM "signup start"
  allow <- asks (cfgAllowSignup . envConfig)
  unless allow $ throwError SignupDisabled

  db <- asks envDb
  mEmail <- liftIO $ findEmailByUserId db uid
  email <- maybe (throwError EmailNotFound) pure mEmail

  cfg <- asks envConfig
  liftIO $ sendWelcome cfg email
  logInfoM "welcome sent"

이 구조의 장점은 다음과 같습니다.

  • 실패 경로가 throwError로 통일된다
  • 호출자는 Either AppError a로 결과를 받는다
  • “실패하면 이후 로직이 중단”되는 것이 코드 구조로 드러난다

ReaderT를 쓰면서도 테스트가 쉬워지는 이유

ReaderT는 “환경을 한 덩어리로 주입”하기 때문에 테스트에서 환경만 갈아끼우면 됩니다. 특히 로거/클라이언트/DB를 레코드로 추상화하면 효과가 큽니다.

예를 들어 Db를 실제 핸들이 아니라 “동작 레코드”로 바꿉니다.

{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE OverloadedStrings #-}

import Control.Monad.Reader
import qualified Data.Text as T

newtype UserId = UserId Int
newtype Email  = Email T.Text

-- 동작 레코드로 만든 포트

data Db = Db
  { dbFindEmail :: UserId -> IO (Maybe Email)
  }

data Config = Config { cfgAllowSignup :: Bool }

data Logger = Logger { loggerInfo :: T.Text -> IO () }


data Env = Env { envConfig :: Config, envDb :: Db, envLogger :: Logger }

newtype AppM a = AppM { unAppM :: ReaderT Env IO a }
  deriving (Functor, Applicative, Monad, MonadIO, MonadReader Env)

runAppM :: Env -> AppM a -> IO a
runAppM env action = runReaderT (unAppM action) env

findEmailM :: UserId -> AppM (Maybe Email)
findEmailM uid = do
  Db f <- asks envDb
  liftIO $ f uid

logInfoM :: T.Text -> AppM ()
logInfoM msg = do
  Logger f <- asks envLogger
  liftIO $ f msg

테스트에서는 Env만 구성하면 됩니다.

testEnv :: IO Env
testEnv = do
  let cfg = Config True
  let db  = Db { dbFindEmail = \_ -> pure (Just (Email "test@local")) }
  let lg  = Logger { loggerInfo = \_ -> pure () }
  pure (Env cfg db lg)

이 방식은 TypeScript에서 “의존성 객체를 주입하고 인터페이스로 교체”하는 패턴과 유사합니다. 타입 추론과 계약을 동시에 챙기는 관점은 [TS 5.x satisfies로 타입 안전과 추론을 함께](https://beautifulsoup.dev/blog/ts-5x-satisfies-type-safety-and-inference)에서 다룬 내용과도 결이 같습니다.

실무에서 자주 겪는 함정 4가지

1) liftIO가 너무 많아진다

ReaderT Env IO만 쓰면 IO 호출마다 liftIO가 필요합니다. 이를 줄이려면 “포트 함수”를 AppM로 감싼 헬퍼를 만들어 두는 게 좋습니다(앞의 findEmailM, logInfoM).

또는 아예 MonadIO 제약을 가진 타입클래스 기반으로 인터페이스를 만들 수도 있지만, 팀의 숙련도에 따라 복잡도가 급상승합니다.

2) Env가 비대해져서 ‘신의 객체’가 된다

처음엔 편하지만, 모든 의존성을 Env에 때려 넣으면 결합도가 올라갑니다. 실무 팁은 두 가지입니다.

  • 도메인별로 Env를 쪼갠 뒤 합성한다(예: UserEnv, BillingEnv)
  • 필요한 필드만 담은 “작은 컨텍스트”로 local을 사용해 범위를 좁힌다

local은 “현재 스코프에서만 Env를 변형”합니다.

withRequestId :: T.Text -> AppM a -> AppM a
withRequestId rid = local (\env -> env { envLogger = addRid rid (envLogger env) })
  where
    addRid :: T.Text -> Logger -> Logger
    addRid rid (Logger info) = Logger (\msg -> info ("[rid=" <> rid <> "] " <> msg))

3) ReaderT 스택 순서가 헷갈린다

ReaderT Env (ExceptT e IO)ExceptT e (ReaderT Env IO)run 순서가 달라지고, catchError/local의 상호작용도 달라질 수 있습니다. 팀 내에서 하나로 표준화하고, runAppM을 단일 진입점으로 숨기세요.

4) “모나드 트랜스포머가 어려워서” 도입이 멈춘다

ReaderT는 트랜스포머 중에서도 가장 단순한 편입니다. 도입 전략은 다음이 현실적입니다.

  • 1단계: Env + ReaderT Env IO만 도입
  • 2단계: 에러가 필요해지면 ExceptT 추가
  • 3단계: 로깅/트레이싱 표준화(헬퍼 함수로 캡슐화)

운영 환경에서 장애를 추적할 때도 “컨텍스트를 구조적으로 전달”하는 것이 중요합니다. 예를 들어 네트워크 타임아웃이나 리트라이 같은 문제는 로그에 상관관계 ID가 없으면 원인 파악이 어려운데, 이런 부분을 local로 깔끔하게 해결할 수 있습니다. 비슷한 결의 트러블슈팅 관점은 [Node.js fetch ECONNRESET·ETIMEDOUT 해결법](https://beautifulsoup.dev/blog/nodejs-fetch-econnreset-etimedout-troubleshooting)도 참고가 됩니다.

ReaderT로 do-notation에서 ‘탈출’하는 실전 체크리스트

  • Env는 “의존성”만 담고, 도메인 데이터는 인자로 받는다
  • IO 호출은 xxxM 헬퍼로 감싸 liftIO 확산을 막는다
  • 에러는 ExceptT로 통일하고, 실패를 Either로 노출한다
  • local로 요청 단위 컨텍스트(요청 ID, 사용자 컨텍스트)를 주입한다
  • runAppM을 단일 진입점으로 만들어 스택 순서를 숨긴다

ReaderT는 “함수형 DI 컨테이너”처럼 보일 수 있지만, 더 중요한 가치는 의존성과 로직의 경계를 강제한다는 점입니다. do 블록은 계속 쓰더라도, 그 do가 더 이상 인프라 배선도로 오염되지 않게 만드는 것이 ReaderT 실무 적용의 핵심입니다.

마지막으로, CI/CD나 운영 환경에서도 “컨텍스트/환경이 어디에서 주입되었는지”가 불명확하면 디버깅 비용이 폭증합니다. 선언적 설정이 예상대로 동작하지 않을 때 원인을 체계적으로 좁혀가는 방식은 [Jenkins Declarative Pipeline에서 env가 안 먹는 6가지 이유](https://beautifulsoup.dev/blog/jenkins-declarative-pipeline-env-not-working-6-reasons) 같은 글의 접근과도 통합니다. ReaderT는 애플리케이션 내부에서 그 문제를 구조적으로 해결하는 도구라고 보면 좋습니다.