- Published on
Haskell Lens로 불변 데이터 업데이트 실수 막기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/도메인 로직에서 Haskell의 불변 데이터는 강력한 안전장치지만, 레코드가 조금만 중첩되면 업데이트 코드가 급격히 장황해지고 실수 포인트가 늘어납니다. 특히 a { b = (b a) { c = ... } } 같은 패턴이 반복되면, 변경하려는 필드가 어디에 있었는지 헷갈리거나, 중간 레벨을 잘못 복사해 버그를 만들기 쉽습니다.
Lens는 이 문제를 “중첩 구조를 안전하게 가리키는 포인터”처럼 다루게 해줍니다. 단순히 코드를 짧게 만드는 도구가 아니라, 업데이트의 의도를 선언적으로 고정해 불변 데이터 갱신 실수를 줄이는 설계 기법입니다.
아래에서는 lens 패키지 중심으로, 실무에서 자주 겪는 실수 유형과 Lens로 막는 패턴을 예제로 정리합니다.
참고: 운영에서 터지는 장애를 줄이려면, 코드 레벨의 안전장치뿐 아니라 관측/디버깅 패턴도 중요합니다. 예를 들어 분산 환경에서 타임아웃이 복합적으로 얽히면 원인 파악이 어려운데, 이런 경우는 gRPC MSA에서 Deadline Exceeded 원인과 패턴 같은 글의 “원인 분해” 접근이 도움이 됩니다.
불변 레코드 업데이트에서 흔한 실수
1) 중첩 업데이트의 “복사-붙여넣기” 버그
레코드가 깊어지면 업데이트는 보통 이런 모양이 됩니다.
{-# LANGUAGE DeriveGeneric #-}
module Domain where
import GHC.Generics (Generic)
data Address = Address
{ city :: String
, zipCd :: String
} deriving (Show, Eq, Generic)
data Profile = Profile
{ name :: String
, address :: Address
} deriving (Show, Eq, Generic)
data User = User
{ userId :: Int
, profile :: Profile
} deriving (Show, Eq, Generic)
updateZipManual :: String -> User -> User
updateZipManual newZip u =
u { profile = (profile u) { address = (address (profile u)) { zipCd = newZip } } }
여기서 실수는 크게 두 가지입니다.
- 중간 레벨 접근을 반복하다가
profile u대신 다른 변수를 참조하는 실수 - 여러 필드를 동시에 바꿀 때 업데이트 순서/대상을 잘못 잡는 실수
2) “동시에 바꿔야 하는 값”의 누락
예를 들어 city가 바뀌면 zipCd를 검증하거나 재계산해야 하는데, 한쪽만 바꾸는 실수는 테스트가 약하면 그대로 프로덕션에 들어갑니다.
3) Maybe/리스트 내부 업데이트에서의 케이스 누락
중첩 구조 안에 Maybe Address 같은 값이 들어가면 수동 업데이트는 case가 늘어나고, 분기 누락이 생깁니다.
Lens는 이 세 가지를 각각 다른 방식으로 완화합니다.
Lens 기본: view, set, over로 의도를 고정하기
lens 패키지를 사용하면 “어디를 바꾸는지”를 Lens로 고정하고, set/over로 갱신합니다.
{-# LANGUAGE TemplateHaskell #-}
module LensDemo where
import Control.Lens
data Address = Address
{ _city :: String
, _zipCd :: String
} deriving (Show, Eq)
data Profile = Profile
{ _name :: String
, _address :: Address
} deriving (Show, Eq)
data User = User
{ _userId :: Int
, _profile :: Profile
} deriving (Show, Eq)
makeLenses ''Address
makeLenses ''Profile
makeLenses ''User
updateZip :: String -> User -> User
updateZip newZip = set (profile . address . zipCd) newZip
uppercaseCity :: User -> User
uppercaseCity = over (profile . address . city) (map toUpper)
where
toUpper ch = if 'a' <= ch && ch <= 'z' then toEnum (fromEnum ch - 32) else ch
핵심은 profile . address . zipCd 같은 “경로”가 코드에 명시된다는 점입니다.
- 수동 업데이트처럼
(profile u)를 여러 번 복사하지 않습니다. - “어디를 바꾸는지”가 타입과 조합으로 고정되어, 리뷰 시 실수를 찾기 쉬워집니다.
실수 방지 포인트 1: 여러 필드 갱신을 원자적으로 묶기
수동으로는 다음처럼 두 필드를 바꾸는 코드가 쉽게 길어집니다.
Lens에서는 “같은 경로 아래” 업데이트를 조합하기가 훨씬 쉽습니다.
moveCityAndZip :: String -> String -> User -> User
moveCityAndZip newCity newZip =
set (profile . address . city) newCity
. set (profile . address . zipCd) newZip
이 패턴은 “두 값이 함께 바뀌어야 한다”는 의도를 코드 구조로 드러냅니다.
더 나아가, 업데이트 로직이 복잡해지면 State 모나드(또는 Control.Lens의 연산자)로 갱신을 선언적으로 쌓을 수 있습니다.
import Control.Monad.State.Strict (execState)
moveCityAndZipS :: String -> String -> User -> User
moveCityAndZipS newCity newZip u = execState action u
where
action = do
profile . address . city .= newCity
profile . address . zipCd .= newZip
(.=)는 “해당 Lens 위치에 값을 대입”하는 연산입니다. 이렇게 쓰면 업데이트 누락이 훨씬 눈에 띄고, 여러 필드 변경이 한 블록에 모입니다.
실수 방지 포인트 2: Maybe 내부 업데이트에서 분기 누락 줄이기
예를 들어 Profile이 주소를 선택적으로 갖는 상황을 보겠습니다.
data Profile2 = Profile2
{ _name2 :: String
, _address2 :: Maybe Address
} deriving (Show, Eq)
makeLenses ''Profile2
수동 업데이트는 case가 필요합니다. Lens에서는 traversed나 _Just 같은 프리즘/트래버설을 조합합니다.
updateZipIfPresent :: String -> Profile2 -> Profile2
updateZipIfPresent newZip = set (address2 . _Just . zipCd) newZip
이 코드는 주소가 없으면 그대로 두고, 있으면 zipCd만 갱신합니다.
Nothing케이스를 빼먹을 여지가 줄어듭니다.- “없으면 스킵”이라는 정책이 경로(
address2 . _Just)에 명시됩니다.
실수 방지 포인트 3: 리스트 내부 특정 원소만 업데이트하기
[User]에서 특정 userId만 업데이트하는 코드는 수동으로 작성하면 필터/맵 조합이 길어지고 조건이 흩어집니다.
Lens에서는 traversed로 리스트를 순회하며 조건부로 갱신할 수 있습니다.
updateZipByUserId :: Int -> String -> [User] -> [User]
updateZipByUserId targetId newZip =
over (traversed . filtered (\u -> u ^. userId == targetId) . profile . address . zipCd) (const newZip)
여기서 중요한 건 filtered로 “업데이트 대상 조건”을 Lens 경로에 포함시킨다는 점입니다.
- 조건과 업데이트가 분리되지 않아, 리팩터링 중 조건만 바뀌거나 업데이트만 바뀌는 사고가 줄어듭니다.
Lens를 도입할 때 팀이 자주 겪는 함정
1) 연산자 남발로 가독성이 떨어지는 문제
(.~), (%~), (^.) 같은 연산자는 익숙해지면 빠르지만, 팀 합의 없이 도입하면 리뷰 난이도가 올라갑니다.
권장 규칙:
- 공개 API나 핵심 도메인 로직에서는
set,over,view같은 함수형을 우선 사용 - 내부 구현에서만 연산자를 제한적으로 사용
예:
-- 읽기 쉬운 형태
setZipReadable :: String -> User -> User
setZipReadable z = set (profile . address . zipCd) z
-- 연산자 형태(팀이 익숙할 때)
setZipOp :: String -> User -> User
setZipOp z = (profile . address . zipCd) .~ z
2) Template Haskell이 부담스러운 경우
makeLenses는 편하지만, 빌드/도구 체인에서 Template Haskell을 꺼리는 팀도 있습니다.
대안:
generic-lens로DeriveGeneric기반 자동 Lens 생성- 작은 프로젝트면 수동으로 Lens 정의
다만 “불변 업데이트 실수 방지” 목적이라면, Lens 생성 방식보다 Lens를 통한 업데이트 경로 고정이 핵심입니다.
3) “Lens면 다 안전하다”는 착각
Lens는 “어디를 바꾸는지”를 명확히 하고 분기 누락을 줄여주지만, 도메인 불변식까지 자동으로 지켜주지는 않습니다.
예를 들어 zipCd 형식 검증, city와 zipCd의 정합성 같은 규칙은 별도 타입/검증 계층으로 올려야 합니다.
이 관점은 다른 언어에서도 동일합니다. 예컨대 리소스/에러 처리를 타입으로 강제하는 접근은 C++의 std::expected 같은 도구로도 나타나는데, 관심 있다면 C++23 std - -expected로 에러·자원 누수 막기 글의 “실수 방지 설계” 관점이 참고가 됩니다.
실전 팁: Lens를 “업데이트 DSL”로 만들기
프로덕션 코드에서 Lens의 진짜 힘은 “작은 업데이트 함수”를 조합해 도메인 업데이트 DSL을 만드는 데 있습니다.
예를 들어 사용자 프로필 수정 명령을 여러 개 지원한다고 합시다.
data Patch
= PatchName String
| PatchCity String
| PatchZip String
deriving (Show, Eq)
applyPatch :: Patch -> User -> User
applyPatch p = case p of
PatchName n -> set (profile . name) n
PatchCity c -> set (profile . address . city) c
PatchZip z -> set (profile . address . zipCd) z
applyPatches :: [Patch] -> User -> User
applyPatches ps u = foldl (flip applyPatch) u ps
이렇게 하면 다음이 좋아집니다.
- 업데이트 로직이 “경로 + 값” 형태로 표준화됨
- 특정 필드 업데이트가 필요한 곳에서 수동 레코드 갱신을 재발명하지 않음
- 테스트도
Patch단위로 쪼개기 쉬움
운영에서 장애를 줄이는 관점에서는, 이런 표준화가 디버깅에도 이점이 있습니다. 업데이트 이벤트를 로깅할 때도 Patch 목록만 남기면 “무엇을 바꿨는지”가 명확해집니다. 네트워크 호출이 섞인 시스템이라면 타임아웃/재시도 정책과 함께 관측해야 하므로, gRPC Interceptor로 분산 트레이싱 전파 오류 잡기 같은 글의 “전파/관측을 구조화”하는 접근과도 결이 맞습니다.
정리: Lens는 ‘짧게 쓰는 문법’이 아니라 ‘실수 방지 장치’다
불변 데이터에서 업데이트 실수가 늘어나는 이유는, 사람이 “중첩 구조의 경로”와 “업데이트 정책”을 머릿속으로 추적해야 하기 때문입니다. Lens는 그 경로를 코드로 고정하고, Maybe/리스트 같은 컨테이너 내부 업데이트를 표준화해 분기 누락과 복사 실수를 줄입니다.
도입 우선순위를 제안하면 다음 순서가 현실적입니다.
- 가장 중첩이 깊고 수정이 잦은 레코드부터 Lens 적용
set/over중심으로 가독성 확보 후, 팀 합의가 되면 연산자 도입- 업데이트 조합을
Patch같은 DSL로 끌어올려 재사용/로깅/테스트 단위를 정리
이 과정을 거치면 “불변이라 안전하다”에서 한 단계 더 나아가, 불변 업데이트 자체를 실수하기 어렵게 만드는 코드베이스로 발전시킬 수 있습니다.