- Published on
Haskell Lens로 불변 데이터 업데이트 실전 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Haskell로 서비스를 만들다 보면 “불변 데이터 구조를 어떻게 깔끔하게 업데이트할 것인가”가 생산성을 좌우합니다. 단순한 레코드 한 단계 업데이트는 record { field = ... }로 충분하지만, 중첩이 깊어지거나 Maybe, 리스트, 맵이 섞이면 업데이트 코드가 금방 지저분해집니다.
Lens는 이 문제를 합성 가능한 접근자로 풀어줍니다. 특히 microlens 계열은 가볍고, lens 패키지는 강력한 조합기와 타입클래스를 제공합니다. 이 글에서는 “왜 Lens인가” 같은 철학보다, 바로 가져다 쓸 수 있는 불변 업데이트 패턴을 중심으로 정리합니다.
함수형 불변 상태를 다루는 관점은 Elixir에서도 동일하게 중요합니다. 상태 폭발을 불변+순수함수로 정리하는 접근은 아래 글도 참고할 만합니다.
준비: 패키지와 기본 연산자
아래 예시는 lens 패키지 기준입니다. microlens로도 거의 동일하게 작성할 수 있지만, 예제 폭을 위해 lens를 사용합니다.
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE OverloadedStrings #-}
module LensPatterns where
import Control.Lens
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Map.Strict as M
Lens에서 가장 자주 쓰는 연산자는 다음 네 가지입니다.
view또는(^.): 읽기set또는(.~): 설정over또는(%~): 함수로 변환(&): 파이프처럼 왼쪽 값을 오른쪽 함수에 전달
-- 읽기
name = user ^. userProfile . profileName
-- 설정
user' = user & userProfile . profileName .~ "new"
-- 변환
user'' = user & userProfile . profileName %~ T.toUpper
예제 도메인: 중첩 레코드 + Maybe + 컬렉션
현업에서 흔한 형태로 모델을 구성해봅니다.
data Profile = Profile
{ _profileName :: Text
, _profileEmail :: Maybe Text
} deriving (Show, Eq)
data Settings = Settings
{ _settingsDarkMode :: Bool
, _settingsLocale :: Text
} deriving (Show, Eq)
data User = User
{ _userId :: Int
, _userProfile :: Profile
, _userSettings :: Settings
, _userTags :: [Text]
, _userMeta :: M.Map Text Text
} deriving (Show, Eq)
makeLenses ''Profile
makeLenses ''Settings
makeLenses ''User
패턴 1: 깊은 중첩 레코드 업데이트를 “한 줄”로
기본 레코드 업데이트는 중첩이 깊어질수록 보일러플레이트가 늘어납니다. Lens는 합성으로 해결합니다.
setLocale :: Text -> User -> User
setLocale loc u = u & userSettings . settingsLocale .~ loc
enableDarkMode :: User -> User
enableDarkMode u = u & userSettings . settingsDarkMode .~ True
renameUser :: Text -> User -> User
renameUser nm u = u & userProfile . profileName .~ nm
핵심은 userSettings . settingsLocale 같은 합성 자체가 “접근 경로”라는 점입니다. 경로가 길어져도 코드 구조가 무너지지 않습니다.
패턴 2: 숫자 증가, 토글 등 “변환”은 (%~)로
set은 값 자체를 바꾸는 용도라면, over는 현재 값을 기반으로 변형합니다.
toggleDarkMode :: User -> User
toggleDarkMode u = u & userSettings . settingsDarkMode %~ not
appendTag :: Text -> User -> User
appendTag t u = u & userTags %~ (<> [t])
패턴 3: Maybe 필드 업데이트는 non 과 ?~ 조합
Maybe는 “없을 수도 있음”을 모델링하지만, 업데이트 로직을 장황하게 만들기도 합니다.
3-1. Maybe에 값 넣기: (?~)
setEmail :: Text -> User -> User
setEmail email u = u & userProfile . profileEmail ?~ email
3-2. Nothing일 때 기본값을 두고 변환: non
non은 Maybe a를 a처럼 다루되, Nothing을 기본값으로 간주합니다.
normalizeEmailDomain :: User -> User
normalizeEmailDomain u =
u & userProfile . profileEmail . non "" %~ normalize
where
normalize e =
let e' = T.toLower e
in if "@" `T.isInfixOf` e' then e' else e'
주의할 점은 non ""을 쓰면 Nothing이 Just ""로 바뀔 수 있다는 것입니다. “없음”을 유지해야 한다면 non 대신 traversed류로 조건부 변환을 고려하세요.
패턴 4: 리스트/트래버설 업데이트는 traversed와 필터 조합
리스트 전체를 변환하거나 일부만 바꿔야 할 때 Lens의 Traversal이 빛납니다.
4-1. 모든 태그를 정규화
normalizeTags :: User -> User
normalizeTags u =
u & userTags . traversed %~ (T.toLower . T.strip)
4-2. 특정 조건의 요소만 업데이트: filtered
filtered는 조건을 만족하는 대상만 통과시키는 Traversal입니다.
removeTempTags :: User -> User
removeTempTags u =
u & userTags %~ filter (not . ("tmp-" `T.isPrefixOf`))
-- 업데이트 관점에서 filtered 예시
upperCaseAdminTags :: User -> User
upperCaseAdminTags u =
u & userTags . traversed . filtered (== "admin") %~ T.toUpper
리스트에서 “삭제”는 보통 filter가 더 직관적이고, “조건부 수정”은 filtered가 깔끔합니다.
패턴 5: Map/딕셔너리 업데이트는 at, ix, non을 구분
Map은 실무에서 메타데이터, 플래그, 속성 저장에 자주 쓰입니다. Lens는 키 기반 업데이트를 표준화합니다.
at key:Maybe value로 접근 (없으면Nothing)ix key: 키가 있을 때만 접근하는Traversal(없으면 업데이트가 무시됨)
5-1. 키를 생성하거나 덮어쓰기: at + ?~
setMeta :: Text -> Text -> User -> User
setMeta k v u = u & userMeta . at k ?~ v
5-2. 키가 있을 때만 수정: ix
appendMetaIfExists :: Text -> Text -> User -> User
appendMetaIfExists k suffix u =
u & userMeta . ix k %~ (<> suffix)
5-3. 없으면 기본값 두고 수정: at + non
bumpCounter :: Text -> User -> User
bumpCounter k u =
u & userMeta . at k . non "0" %~ bump
where
bump t =
case reads (T.unpack t) of
[(n, "")] -> T.pack (show (n + (1 :: Int)))
_ -> "1"
패턴 6: “여러 필드 동시 업데이트”는 파이프라인으로 조립
Lens의 장점은 작은 업데이트 함수를 조립하기 쉽다는 점입니다.
onboardUser :: Text -> Text -> User -> User
onboardUser nm loc =
renameUser nm
. setLocale loc
. enableDarkMode
. appendTag "new"
호출은 이렇게 됩니다.
u2 = onboardUser "Kim" "ko-KR" u1
이 스타일은 “상태가 커질수록 핸들러가 비대해지는 문제”를 줄이는 데도 도움이 됩니다. 불변 업데이트를 작은 순수 함수로 쪼개고 합성하는 습관은 다른 생태계에도 그대로 적용됩니다.
패턴 7: 검증이 필요한 업데이트는 (%~) 대신 상태 모나드/검증 모나드로
Lens는 “어디를 바꿀지”를 잘 표현하지만, “바꿔도 되는지”는 별개 문제입니다. 예를 들어 이메일 변경은 형식 검증이 필요할 수 있습니다.
여기서는 단순화를 위해 Either Text로 검증을 붙여봅니다.
setEmailValidated :: Text -> User -> Either Text User
setEmailValidated email u = do
if T.isInfixOf "@" email
then Right (u & userProfile . profileEmail ?~ email)
else Left "invalid email"
여러 업데이트를 Either로 엮을 때는 do 표기법이 깔끔합니다.
updateUserValidated :: Text -> Text -> User -> Either Text User
updateUserValidated email loc u = do
u1 <- setEmailValidated email u
let u2 = setLocale loc u1
Right u2
Lens 자체로 “실패 가능한 업데이트”를 전부 해결하려고 하기보다, Lens는 위치 지정, 검증은 모나드로 분리하면 코드가 안정적입니다.
패턴 8: 부분 구조를 재사용하려면 “작은 Lens”를 먼저 만들기
중첩 경로가 반복되면, 합성 Lens를 이름 붙여 재사용하는 편이 유지보수에 좋습니다.
profileLens :: Lens' User Profile
profileLens = userProfile
emailLens :: Lens' User (Maybe Text)
emailLens = userProfile . profileEmail
localeLens :: Lens' User Text
localeLens = userSettings . settingsLocale
setKorean :: User -> User
setKorean u = u & localeLens .~ "ko-KR"
이 패턴은 팀 코드베이스에서 특히 유용합니다. “도메인에서 중요한 경로”를 별칭으로 만들면, 의도가 문서처럼 드러납니다.
패턴 9: 성능과 엄격성: Lens가 느리기보다 “자료구조 선택”이 더 크다
Lens를 도입할 때 흔히 나오는 걱정이 성능입니다. 실제로는 다음이 더 큰 영향을 줍니다.
- 리스트를 자주 수정하면
Seq나Vector가 더 나을 수 있음 Map을 과도하게 중첩하면 키 설계를 바꾸는 게 더 큼- 지연 평가로 인한 누수는
strict필드,deepseq같은 도구로 관리
Lens는 불변 업데이트를 “표현”하는 도구라, 병목이 생긴다면 대개 업데이트 횟수와 자료구조가 원인입니다. 성능 튜닝은 프로파일링 후에 하되, 코드의 명확성을 먼저 확보하는 편이 장기적으로 이득인 경우가 많습니다.
실전 체크리스트
- 중첩 레코드 업데이트가 2단계를 넘어가면 Lens 합성이 가독성에서 이김
Maybe는?~,non을 적절히 쓰되,Nothing이Just default로 바뀌는 의미를 항상 의식Map은at과ix의 차이를 명확히 구분- 여러 업데이트는 작은 순수 함수로 쪼개서 합성하고, 검증은
Either같은 컨텍스트로 분리 - 반복되는 경로는 합성 Lens에 이름을 붙여 도메인 용어로 승격
마무리
Haskell에서 Lens는 “문법 설탕”이 아니라, 불변 데이터 업데이트를 조립 가능한 부품으로 만드는 방식입니다. 특히 중첩 레코드, Maybe, 컬렉션이 섞인 모델에서 Lens를 쓰면 업데이트 코드가 짧아질 뿐 아니라, 변경 경로가 명확해져 리뷰와 리팩터링이 쉬워집니다.
다음 단계로는 Prism을 이용한 합 타입 업데이트, Traversal로 배치 수정, 그리고 optics 라이브러리로의 확장도 고려해볼 만합니다. 중요한 건 도구 자체보다, “업데이트를 작은 순수 함수로 쪼개어 합성한다”는 습관입니다.