- Published on
Haskell에서 Monoid·Fold로 null 체크 제거
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/배치 코드에서 null 체크(또는 그에 준하는 분기)는 유지보수 비용을 급격히 올립니다. 조건문이 늘어날수록 로직의 핵심이 흐려지고, 누락된 케이스가 런타임 버그로 바뀌기 쉽습니다. Haskell은 애초에 null을 값으로 두지 않지만, 현실의 데이터는 비어있거나([]), 값이 없거나(Maybe), 실패할 수 있고(Either), 여러 값을 합쳐야 합니다.
이 글에서는 Haskell의 Monoid와 Fold(특히 foldMap)를 이용해 “없음/비어있음”을 자연스럽게 흡수하고, 분기와 임시 변수를 줄여 null 체크에 해당하는 코드를 제거하는 패턴을 정리합니다.
참고로 이런 접근은 프레임워크/런타임의 캐시나 상태 불일치처럼 “조건 분기 폭발”이 문제를 키우는 상황에서도 도움이 됩니다. 예를 들어 캐시 때문에 데이터가 안 갱신될 때도 결국 상태를 합성 가능한 형태로 모델링하는 게 중요합니다. 관련해서는 Next.js App Router 캐시로 데이터가 안 갱신될 때도 같이 읽어보면 맥락이 이어집니다.
null 체크가 늘어나는 전형적인 코드
Haskell에서 null 대신 흔히 다음과 같은 형태가 등장합니다.
- 리스트가 비었는지 확인:
null xs Maybe가Nothing인지 확인:case m of ...- 여러 후보 중 첫 번째 성공/첫 번째 값 선택
- 컬렉션의 값들을 합치되, 비어있으면 기본값
문제는 이 분기가 로직 전반에 퍼지면서 “핵심 계산”이 묻힌다는 점입니다.
핵심 아이디어: 비어있음을 Monoid의 항등원으로 흡수
Monoid는 아래 두 가지를 제공합니다.
- 결합 연산:
(<>) - 항등원:
mempty
비어있음/없음은 많은 타입에서 자연스럽게 mempty로 모델링됩니다.
- 리스트:
mempty = [] Text/String:mempty = ""- 합계:
Sum 0 - 곱:
Product 1
즉, “값이 없으면 기본값”을 if나 case로 처리하지 않고, 그냥 합치면 됩니다.
예시 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 a도 Foldable입니다. 즉, 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.Monoid의 First 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/String은mempty가 빈 문자열 - 수치 누적:
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 체크를 줄이는 가장 좋은 방법은 if와 case를 억지로 지우는 게 아니라, 비어있음/없음을 항등원으로 흡수하고 결합 규칙을 타입으로 고정하는 것입니다.
- 기본값이 있는 누적은
Monoid로 - 컬렉션/옵셔널을 접는 로직은
Foldable과foldMap으로 - 우선순위 선택은
First/Last로
이렇게 바꾸면 코드가 짧아지는 것보다 더 큰 이점이 생깁니다. “예외 케이스 처리”가 로직 곳곳에 흩어지지 않고, 합성 규칙 한 곳으로 모이면서 테스트/리팩터링이 쉬워집니다.