Published on

Haskell Lens 실무 - 중첩 JSON 불변 업데이트

Authors

서버에서 내려온 JSON을 받아 특정 필드만 바꾸고 다시 내보내야 하는 작업은 흔합니다. 문제는 JSON이 중첩될수록 case/패턴 매칭이 길어지고, Maybe/Either 처리로 코드가 급격히 지저분해진다는 점입니다. Haskell은 불변(immutable) 데이터가 기본이기 때문에 “수정”은 곧 “복사 + 일부 변경”인데, 이 과정을 사람이 수동으로 하면 실수가 잦습니다.

Lens는 이 문제를 경로(path) 기반으로 읽고/수정하는 합성 가능한 도구로 바꿔줍니다. 특히 Aeson의 Value를 다루는 lens-aeson을 사용하면, 중첩 JSON에서 특정 키를 찾아 값을 바꾸는 작업을 꽤 선언적으로 쓸 수 있습니다.

이 글에서는 다음을 목표로 합니다.

  • Aeson Value 기반의 중첩 JSON을 Lens로 조회/수정하기
  • 키가 없거나 타입이 다를 때의 실패를 안전하게 다루기
  • 배열 내부의 여러 원소를 일괄 업데이트하기
  • 실무에서 자주 쓰는 “부분 업데이트(patch)” 스타일로 구조화하기

참고로, 실패 원인 진단을 체계화하는 접근은 인프라/분산 시스템에서도 중요합니다. 예를 들어 gRPC 마이크로서비스 503·데드라인 초과 디버깅처럼 “원인별로 분해해서 보는 습관”이 Lens에서도 그대로 도움이 됩니다.

준비: 패키지와 기본 타입

필요 패키지는 대략 아래 조합이 많습니다.

  • aeson: JSON 파싱/인코딩
  • lens: Lens 본체
  • lens-aeson: Value에 대한 Prism/Traversal 제공
  • (선택) aeson-pretty: 디버깅 출력

cabal 예시는 다음처럼 둘 수 있습니다.

build-depends:
  base,
  aeson,
  lens,
  lens-aeson,
  bytestring,
  text

이 글의 예제는 Data.Aeson.Value를 직접 다룹니다. 실무에서는 “최종적으로는 타입 안전한 레코드로 디코딩”하는 편이 좋지만, 외부 API의 스키마가 자주 바뀌거나 부분 수정만 필요한 파이프라인에서는 Value 기반 편집이 유용합니다.

예제 JSON: 중첩 구조

다음 같은 JSON을 가정해 봅시다.

  • user.profile.name을 바꾸고
  • user.flags.betatrue로 켜고
  • orders 배열에서 statuspending인 항목만 processing으로 바꾸기
{
  "user": {
    "id": 42,
    "profile": {
      "name": "alice",
      "email": "alice@example.com"
    },
    "flags": {
      "beta": false
    }
  },
  "orders": [
    {"id": 1, "status": "pending", "amount": 1200},
    {"id": 2, "status": "paid", "amount": 800}
  ]
}

Lens-Aeson 핵심 문법: key, _String, _Bool, values

lens-aesonValue에 대해 다음 같은 도구를 제공합니다.

  • key "user": 객체에서 특정 키로 이동하는 Traversal
  • nth 0: 배열 인덱스 접근
  • _String, _Bool, _Number, _Object, _Array: 타입 Prism
  • values: 배열의 모든 원소 Traversal

중요한 점은 keyTraversal이라는 것입니다. 즉 키가 없으면 “아무것도 안 하고 지나가며”, 수정도 조용히 무시됩니다. 이 특성은 장점이자 함정입니다.

  • 장점: 부분적으로만 있는 필드를 부드럽게 업데이트 가능
  • 함정: 오타/스키마 변경이 있어도 조용히 실패해서 버그가 늦게 발견됨

따라서 실무에서는 “조용히 실패해도 되는 업데이트”와 “반드시 성공해야 하는 업데이트”를 구분해야 합니다.

기본 업데이트: 중첩 필드 불변 수정

아래 코드는 user.profile.name"bob"으로 바꿉니다.

주의: 본문에서 부등호 문자가 노출되면 MDX 빌드에 문제가 될 수 있으니, 연산자나 타입 표기는 항상 코드 블록/인라인 코드로 감쌉니다.

{-# LANGUAGE OverloadedStrings #-}

module Main where

import Control.Lens
import Data.Aeson
import Data.Aeson.Lens
import qualified Data.ByteString.Lazy.Char8 as BL

updateName :: Value -> Value
updateName = set (key "user" . key "profile" . key "name" . _String) "bob"

main :: IO ()
main = do
  let input = decode (BL.pack sample) :: Maybe Value
  case input of
    Nothing -> putStrLn "invalid json"
    Just v  -> BL.putStrLn (encode (updateName v))

sample :: String
sample = "{\"user\":{\"id\":42,\"profile\":{\"name\":\"alice\",\"email\":\"alice@example.com\"},\"flags\":{\"beta\":false}},\"orders\":[{\"id\":1,\"status\":\"pending\",\"amount\":1200},{\"id\":2,\"status\":\"paid\",\"amount\":800}]}"
  • set optic newValue는 해당 경로가 존재하고 타입이 맞으면 값을 바꿉니다.
  • _String Prism을 붙였기 때문에, name이 문자열이 아니면 수정이 적용되지 않습니다.

여러 필드 동시 업데이트: 함수 합성으로 “패치” 만들기

Lens 업데이트는 함수이므로 합성이 쉽습니다.

patchUser :: Value -> Value
patchUser =
    set (key "user" . key "profile" . key "name" . _String) "bob"
  . set (key "user" . key "flags"   . key "beta" . _Bool) True

실무에서 이 패턴이 좋은 이유는 다음과 같습니다.

  • 각 수정이 독립적인 작은 함수가 된다
  • 테스트 시 “이 패치만 적용” 같은 조합이 쉽다
  • 파이프라인에서 조건부로 패치를 붙이기 쉽다

이런 “조합 가능한 작은 단위”는 재시도/백오프 로직을 합성해 안정성을 올리는 접근과도 닮았습니다. 예를 들어 OpenAI 429 Rate Limit 재시도·백오프 설계처럼, 작은 정책을 조합해 전체 안정성을 만드는 관점이 동일합니다.

배열 업데이트: 조건에 맞는 원소만 수정

orders 배열에서 status == "pending"인 원소만 수정해 봅시다.

핵심은 다음입니다.

  • key "orders" . values로 배열 원소를 순회
  • 각 원소(객체)에서 key "status" . _String을 가져와 조건 검사
  • 조건이 맞으면 같은 경로에 set
updatePendingOrders :: Value -> Value
updatePendingOrders =
  over (key "orders" . values) bump
  where
    bump :: Value -> Value
    bump o =
      case o ^? key "status" . _String of
        Just "pending" -> set (key "status" . _String) "processing" o
        _              -> o

여기서 (^?)는 “Traversal/Prism으로 값을 조회해서 Maybe로 받기”입니다. 조건 분기를 위해 조회는 Maybe로 받고, 실제 수정은 set으로 불변 갱신합니다.

조용한 실패를 통제하기: 반드시 성공해야 하는 경로 검증

앞서 말했듯, key 기반 Traversal은 키가 없으면 그냥 지나갑니다. 예를 들어 "profil"처럼 오타가 나도 컴파일은 되며 런타임에서도 조용히 아무것도 바뀌지 않습니다.

실무에서는 다음 중 하나를 선택하는 것이 좋습니다.

  1. 조용한 업데이트 허용: 부분 필드가 없으면 스킵 (best-effort)
  2. 업데이트 실패를 에러로 승격: 필드가 없거나 타입이 다르면 실패 처리

2번을 위해서는 “업데이트 전/후 비교” 또는 “필드 존재 여부를 먼저 체크”하는 패턴이 흔합니다.

패턴 A: 업데이트 전 preview로 필수 필드 확인

requireString :: Traversal' Value Text -> Value -> Either String Text
requireString optic v =
  case v ^? optic of
    Just t  -> Right t
    Nothing -> Left "required field missing or not a string"

updateNameStrict :: Value -> Either String Value
updateNameStrict v = do
  _ <- requireString (key "user" . key "profile" . key "name" . _String) v
  pure $ set (key "user" . key "profile" . key "name" . _String) "bob" v

이 방식은 “필수 필드 검증”과 “수정”을 명확히 분리합니다.

패턴 B: 변경 여부를 감지해 실패 처리

set은 변경이 없을 수도 있으니, 결과가 동일하면 실패로 간주하는 방법도 있습니다.

updateStrictByDiff :: Value -> Either String Value
updateStrictByDiff v =
  let v' = set (key "user" . key "profile" . key "name" . _String) "bob" v
  in if v' == v
       then Left "update did not apply (path missing or type mismatch)"
       else Right v'

단, 이 방법은 “원래 값이 이미 "bob"이라서 동일”인 경우도 실패로 처리할 수 있습니다. 그런 경우에는 업데이트 전 값 조회와 함께 조건을 더 정교하게 해야 합니다.

Value 편집 vs 타입 안전 레코드: 언제 무엇을 쓰나

Lens로 Value를 직접 편집하는 방식은 빠르고 유연하지만, 장기적으로는 타입 안정성이 약합니다. 실무에서의 기준을 정리하면 다음과 같습니다.

  • 외부 JSON 스키마가 자주 바뀌고, 그중 일부 필드만 건드리면 되는 경우: Value + lens-aeson이 생산적
  • 내부 도메인 로직에서 강한 불변식이 필요한 경우: 레코드 타입으로 디코딩 후 Lens(또는 record update) 사용
  • “부분 패치 API”처럼 입력 JSON을 그대로 전달하되 특정 필드만 강제해야 하는 경우: Value 편집이 특히 적합

추가로, CI에서 이런 류의 “조용한 실패”는 테스트로 잡는 것이 좋습니다. 캐시/권한/키 설정이 미묘하게 어긋나면 조용히 성능이 떨어지듯, JSON 업데이트도 조용히 스킵되면 늦게 터집니다. 이 관점에서 GitHub Actions 캐시 안 먹을 때 키·경로·권한 7단계 같은 체크리스트식 접근이 도움이 됩니다.

실무 팁: 업데이트 로직을 “옵틱 묶음”으로 관리하기

중첩 경로가 길어질수록 오타가 늘고, 여러 곳에서 같은 경로를 반복하게 됩니다. 이때는 경로 자체를 이름 붙여 재사용하는 편이 좋습니다.

userName :: Traversal' Value Text
userName = key "user" . key "profile" . key "name" . _String

betaFlag :: Traversal' Value Bool
betaFlag = key "user" . key "flags" . key "beta" . _Bool

setDefaults :: Value -> Value
setDefaults =
    set userName "bob"
  . set betaFlag True

이렇게 해두면 다음 이점이 생깁니다.

  • 경로 변경 시 한 곳만 고치면 됨
  • 테스트에서 v ^? userName처럼 의도가 드러남
  • “필수 경로”와 “선택 경로”를 타입/함수명으로 구분하기 쉬움

마무리

Haskell에서 중첩 JSON을 불변으로 업데이트하는 작업은, Lens를 도입하면 “중첩 구조를 따라 들어가서 일부만 바꾸는 반복 노동”을 크게 줄일 수 있습니다. 다만 key 기반 Traversal의 조용한 실패 특성을 이해하고, 필수 업데이트는 검증을 추가하는 것이 실무 품질을 좌우합니다.

정리하면 다음 3가지를 팀 규칙으로 두는 것이 좋습니다.

  • 경로는 재사용 가능한 옵틱으로 묶어라
  • best-effort 업데이트와 strict 업데이트를 분리해라
  • 배열 업데이트는 values 순회 + 조건부 set 패턴으로 표준화해라

이 패턴을 잡아두면, 외부 API JSON이 복잡해져도 Haskell 코드의 복잡도는 선형적으로만 늘어나고, 변경에 대한 회복력도 좋아집니다.