Published on

Haskell에서 Monoid·Fold로 null 체크 제거

Authors

서버/배치 코드에서 null 체크(또는 그에 준하는 분기)는 유지보수 비용을 급격히 올립니다. 조건문이 늘어날수록 로직의 핵심이 흐려지고, 누락된 케이스가 런타임 버그로 바뀌기 쉽습니다. Haskell은 애초에 null을 값으로 두지 않지만, 현실의 데이터는 비어있거나([]), 값이 없거나(Maybe), 실패할 수 있고(Either), 여러 값을 합쳐야 합니다.

이 글에서는 Haskell의 MonoidFold(특히 foldMap)를 이용해 “없음/비어있음”을 자연스럽게 흡수하고, 분기와 임시 변수를 줄여 null 체크에 해당하는 코드를 제거하는 패턴을 정리합니다.

참고로 이런 접근은 프레임워크/런타임의 캐시나 상태 불일치처럼 “조건 분기 폭발”이 문제를 키우는 상황에서도 도움이 됩니다. 예를 들어 캐시 때문에 데이터가 안 갱신될 때도 결국 상태를 합성 가능한 형태로 모델링하는 게 중요합니다. 관련해서는 Next.js App Router 캐시로 데이터가 안 갱신될 때도 같이 읽어보면 맥락이 이어집니다.

null 체크가 늘어나는 전형적인 코드

Haskell에서 null 대신 흔히 다음과 같은 형태가 등장합니다.

  • 리스트가 비었는지 확인: null xs
  • MaybeNothing인지 확인: case m of ...
  • 여러 후보 중 첫 번째 성공/첫 번째 값 선택
  • 컬렉션의 값들을 합치되, 비어있으면 기본값

문제는 이 분기가 로직 전반에 퍼지면서 “핵심 계산”이 묻힌다는 점입니다.

핵심 아이디어: 비어있음을 Monoid의 항등원으로 흡수

Monoid는 아래 두 가지를 제공합니다.

  • 결합 연산: (<>)
  • 항등원: mempty

비어있음/없음은 많은 타입에서 자연스럽게 mempty로 모델링됩니다.

  • 리스트: mempty = []
  • Text/String: mempty = ""
  • 합계: Sum 0
  • 곱: Product 1

즉, “값이 없으면 기본값”을 ifcase로 처리하지 않고, 그냥 합치면 됩니다.

예시 1: 로그/메시지 누적에서 빈 값 처리

import Data.Monoid (Sum(..))

renderWarnings :: [String] -> String
renderWarnings ws = mconcat (map (\w -> "WARN: " <> w <> "\n") ws)

-- ws가 []여도 mconcat 결과는 "" (mempty)

ws가 비어있을 때 별도 분기가 필요 없습니다. mconcat가 항등원으로 흡수합니다.

Fold로 분기 제거: foldr보다 foldMap을 먼저 떠올리기

foldMap은 “각 원소를 Monoid로 바꾸고, 전부 합친다”는 패턴을 한 번에 표현합니다.

foldMap :: (Foldable t, Monoid m) => (a -> m) -> t a -> m

여기서 중요한 점은 Foldable이 리스트만이 아니라 Maybe, Either(부분), Map의 값 등에도 적용된다는 것입니다.

예시 2: 숫자 합계에서 빈 컬렉션 처리

import Data.Foldable (foldMap)
import Data.Monoid (Sum(..))

sumAges :: [Int] -> Int
sumAges xs = getSum (foldMap Sum xs)

-- []이면 getSum (Sum 0) == 0

[] 체크가 사라집니다.

Maybe의 null 체크 제거: foldMap과 fromMaybe의 역할 분담

Maybe aFoldable입니다. 즉, Just x는 원소 1개짜리 컨테이너, Nothing은 빈 컨테이너처럼 접을 수 있습니다.

예시 3: Maybe 값이 있으면 더하고 없으면 0

import Data.Foldable (foldMap)
import Data.Monoid (Sum(..))

addOptional :: Maybe Int -> Int
addOptional m = getSum (foldMap Sum m)

-- Nothing이면 0, Just 10이면 10

이 패턴은 “없으면 기본값”을 mempty로 처리하는 전형적인 예입니다.

예시 4: Maybe Text를 합치기 (없으면 빈 문자열)

import Data.Foldable (fold)

fullName :: Maybe String -> Maybe String -> String
fullName first last = fold first <> " " <> fold last

-- fold :: (Foldable t, Monoid m) => t m -> m
-- Maybe String은 Foldable이고 String은 Monoid

Nothing이면 mempty로 흡수되므로 별도 체크가 없습니다.

“첫 번째 값” 선택에서 null 체크 제거: First/Last

여러 후보 중 첫 번째로 존재하는 값을 선택하는 로직은 흔히 if x /= Nothing then ... 같은 형태로 번집니다. 이때 First/Last 모노이드를 쓰면 조합이 쉬워집니다.

Data.MonoidFirst a는 내부적으로 Maybe a를 감싸고, (<>)가 “왼쪽이 Just면 왼쪽 유지, 아니면 오른쪽” 규칙으로 동작합니다.

예시 5: 여러 소스에서 사용자 닉네임 선택

import Data.Monoid (First(..))

pickNickname :: Maybe String -> Maybe String -> Maybe String -> Maybe String
pickNickname fromProfile fromCache fromHeader =
  getFirst (First fromProfile <> First fromCache <> First fromHeader)
  • 조건문 없이 “우선순위”가 표현됩니다.
  • 값이 전부 없으면 결과는 Nothing입니다.

마지막 값을 원하면 Last를 쓰면 됩니다.

Either/검증 누적에서 분기 제거: 실패를 합치는 방식 선택

Either e a는 기본적으로 Monad로 쓰면 첫 실패에서 멈추는 경향이 있습니다(단락 평가). 반면 “여러 검증 에러를 모아서 보여주기”는 Monoid/Semigroup 기반 설계가 더 잘 맞습니다.

표준 Either만으로도 일부 패턴은 만들 수 있고, 실무에서는 Validation(예: validation 패키지, either 패키지 등)을 더 자주 씁니다. 핵심은 에러 타입을 리스트처럼 결합 가능하게 만드는 것입니다.

예시 6: 간단한 검증 에러 누적(리스트로 결합)

아래는 개념 설명용으로, 에러를 [String]으로 누적하는 형태입니다.

import Data.Monoid (Sum(..))

type Errors = [String]

data V a = Ok a | Err Errors
  deriving (Show)

instance Functor V where
  fmap f (Ok a)  = Ok (f a)
  fmap _ (Err e) = Err e

instance Applicative V where
  pure = Ok
  Ok f   <*> Ok a   = Ok (f a)
  Err e1 <*> Err e2 = Err (e1 <> e2)
  Err e  <*> _      = Err e
  _      <*> Err e  = Err e

nonEmpty :: String -> V String
nonEmpty s = if null s then Err ["empty"] else Ok s

minLen :: Int -> String -> V String
minLen n s = if length s < n then Err ["too short"] else Ok s

validateName :: String -> V String
validateName s = (\_ _ -> s) <$> nonEmpty s <*> minLen 3 s

여기서 중요한 포인트는 Err끼리는 (<>)로 합쳐진다는 점입니다. if는 개별 검증 함수 내부에만 존재하고, 조합부에서는 분기가 사라집니다.

이런 “에러 누적”은 운영 환경에서 디버깅 시간을 줄입니다. 장애 분석에서도 단일 원인만 보이는 것보다 여러 증상을 한 번에 수집하는 편이 유리하죠. 비슷한 맥락의 트러블슈팅 글로 Spring Boot 3 간헐적 500? Netty 메모리릭 추적도 참고할 만합니다.

foldMap으로 “조건부 포함”을 선언적으로 만들기

null 체크의 또 다른 형태는 “조건에 맞으면 리스트에 넣고, 아니면 안 넣기”입니다. 이때도 foldMap과 리스트 모노이드를 쓰면 깔끔합니다.

예시 7: 조건을 만족하는 항목만 로그로 수집

import Data.Foldable (foldMap)

collectErrors :: [(String, Bool)] -> [String]
collectErrors xs = foldMap step xs
  where
    step (msg, isErr) = if isErr then [msg] else []

-- []는 mempty라서 자연스럽게 누적

if는 남아있지만, “누적 로직”에서 분기가 사라지고, 결과는 항상 리스트로 합성됩니다.

실전 팁: null 체크를 없애려면 데이터 모델부터 바꿔야 한다

Monoid/Fold는 마법이 아니라 “값이 없을 수 있음”을 타입과 결합 규칙으로 옮기는 도구입니다. 다음 체크리스트를 추천합니다.

1) 기본값이 자연스러운 타입을 선택하기

  • 문자열 누적: Text/Stringmempty가 빈 문자열
  • 수치 누적: Sum Int, Product Double
  • 컬렉션 누적: 리스트/Map/Set

기본값이 자연스럽다면 fromMaybe보다 fold/foldMap이 더 선언적일 때가 많습니다.

2) “선택” 규칙을 모노이드로 고정하기

  • 첫 번째를 택한다: First
  • 마지막을 택한다: Last
  • 최댓값/최솟값: Max, Min(패키지/버전에 따라 Data.Semigroup 제공)

규칙이 고정되면, 조건문 대신 (<>)로 합치면 됩니다.

3) foldr로 모든 걸 해결하려 하지 말기

foldr는 강력하지만, 의도를 숨기기 쉽습니다.

  • “매핑 후 합치기”는 foldMap
  • “그냥 합치기”는 fold 또는 mconcat

이름 자체가 의도를 문서화합니다.

마무리: null 체크가 아니라 합성 규칙을 작성하라

Haskell에서 null 체크를 줄이는 가장 좋은 방법은 ifcase를 억지로 지우는 게 아니라, 비어있음/없음을 항등원으로 흡수하고 결합 규칙을 타입으로 고정하는 것입니다.

  • 기본값이 있는 누적은 Monoid
  • 컬렉션/옵셔널을 접는 로직은 FoldablefoldMap으로
  • 우선순위 선택은 First/Last

이렇게 바꾸면 코드가 짧아지는 것보다 더 큰 이점이 생깁니다. “예외 케이스 처리”가 로직 곳곳에 흩어지지 않고, 합성 규칙 한 곳으로 모이면서 테스트/리팩터링이 쉬워집니다.