요약
Go 1.27에서 새 JSON 패키지 encoding/json/v2와 jsontext가 정식 기능이 됐습니다.
Go 팀과 외부 기여자 7명이 Go 블로그에 올린 글입니다. Go 1.25에서 실험 기능으로 먼저 선보인 새 API가 2020년 말 설계 초안 이후 약 6년 만에 안정판이 됐다고 알렸습니다.
왜 중요한가
- 기존 encoding/json은 Go 1 호환성 약속 때문에 잘못된 기본 동작을 고칠 수 없었습니다. 새 패키지로 이 문제를 정리했습니다.
- 기존 v1이 v2 위에서 다시 구현됐습니다. 그래서 v1 사용자도 코드를 바꾸지 않고 새 메서드와 태그 같은 개선을 함께 받습니다.
핵심 내용
- 문법만 다루는 jsontext와 Go 값 변환을 맡는 json/v2로 역할을 나눴습니다.
- v2는 잘못된 UTF-8과 중복 키를 오류로 처리합니다. v1은 이를 그냥 받아들여 보안 문제로 이어질 수 있었습니다.
- nil 슬라이스와 맵은 null 대신 []와 {}로 쓰고, 필드 이름은 대소문자를 구분해 맞춥니다.
- 언마셜(JSON을 Go 값으로 바꾸는 작업)은 벤치마크에서 최대 10배 빨라졌고, 마셜은 v1과 비슷합니다.
- 달라진 동작은 모두 옵션이나 구조체 태그로 v1처럼 되돌릴 수 있어 단계적으로 옮길 수 있습니다.
HN 반응
MarshalIndent가 빠져 불편하다는 불만에, 다른 이용자들은 jsontext의WithIndent옵션으로 같은 일을 할 수 있다고 답했습니다.- 6년이나 걸린 점을 두고 개발이 느리다는 지적과, 대부분 서비스엔 v1로 충분했고 v2의 장점은 속도보다 API라는 반론이 맞섰습니다.
저는 Dart의 SIMD 지원을 개선하는 작업을 하고 있는데, 그 동기 중 하나가 해당 언어의 스키마 명세를 바탕으로 파서 명세를 특수화하는 것입니다.
좀 더 구체적으로 말하면, simdjson 방식의 효율적인 파서를 만들어 내는 파서 생성기와 JSON 파서 명세가 있다면, 스키마에 따라 JSON의 부분집합으로 제한한 입력을 simdjson이 범용 JSON을 파싱하는 것만큼은 효율적으로 파싱할 수 있어야 합니다.
그렇게 되면 (필요하면 지연 평가까지 되는) 정적 타입 안전 모델과 simdjson 수준의 성능이라는 두 마리 토끼를 다 잡을 수 있습니다!
저는 이게 충분히 해낼 수 있는 일이라고 여전히 생각합니다. 다만 그러려면 만들어야 할 인프라가 많을 뿐입니다.
Meta가 바이너리 포맷으로 비슷한 일을 하고 있다는 글을 읽은 기억이 있습니다. JSON에는 아직 그런 것이 없다는 게 이상합니다.
이런 접근법을 “단계적 파싱(staged parsing)”이라고 부릅니다.
https://dl.acm.org/doi/pdf/10.1145/2714064.2660241
https://dl.acm.org/doi/pdf/10.1145/3314221.3314625
저도 올해 초에 메타파싱을 다뤘습니다(타입 지원과 직렬화 포맷이 더 낫다는 이유로 특히 gRPC 위에서요).
JSON을 쓰는 사람 대부분은 강한 타입이나 스키마를 원하지 않는다고 생각합니다. 원한다면 다른 직렬화 포맷을 쓰면 되니까요. JSON은 (JS 객체로 역직렬화되니까) 사실상 어떤 데이터 구조든 표현할 수 있는 범용적이고 사람이 읽을 수 있는 방식으로 설계됐습니다. 그러니 JSON에 타입이나 스키마를 강제하려 들면 JSON의 모든 특성이 거꾸로 방해가 됩니다.
제 생각엔 (네트워크를 통한) 타입 레지스트리와 디스커버리가 더 규범적이고, (grpc-gateway처럼) HTTP와의 호환도 더 나은 gRPC 유사 프로토콜이 최선입니다. JSON 스키마는 절대 아닙니다.
저는 serde를 문제없이 써 왔고, 타입 없는 옵션인
serde::Value0는 지금까지 한 번도 쓴 적이 없습니다.저는 방해가 되는 걸 아직 겪어 본 적이 없습니다.
우리가 JSON을 쓰는 건 표준이고, 사람이 읽을 수 있고, 대체로 자기 기술적이고, 자바스크립트에서 기본으로 돌아가기 때문이에요.
스키마가 있더라도 몰라도 JSON 객체를 읽을 수 있잖아요.
Protobuf는 보조 도구가 잔뜩 없으면 쓸 수 없는 쓰레기예요.
꼭 그렇지는 않습니다. JSON의 가장 큰 장점은 아마 호환성이 아주 좋다는 점일 겁니다. 사실상 모든 언어에 바로 쓸 수 있는 JSON 라이브러리가 있고, 없더라도 파서나 생성기를 직접 짜는 게 그리 어렵지 않습니다. 사람이 읽을 수 있다는 점도 무시할 만한 장점이 아닙니다.
저는 대체로
encoding/json/v2가 마음에 들고, 만들어 줘서 기쁩니다.그래도
MarshalIndent를 남겨 뒀으면 좋았겠습니다. 같은 일을 하는 코드가 훨씬 복잡하고 직관적이지 않아서 마이그레이션이 번거롭고, 급하게 코드를 쓰다가 읽기 좋은 JSON을 찍어 보고 싶을 때도 귀찮습니다. 물론 요즘은 LLM이 그 복잡한 버전을 대신 써 주겠지만요.제가 뭘 놓친 건지 모르겠는데, 이 기능은 그냥 jsontext로 옮겨 간 것 아닌가요? https://pkg.go.dev/encoding/json/jsontext#Value.Indent
https://pkg.go.dev/encoding/json/jsontext#WithIndent
JSON을 처리할 때 순서를 보존하려고 임시방편이 도입된 적이 있습니다.
https://news.ycombinator.com/item?id=44893833
진짜 해결책은 여전히 찾는 중입니다.
https://github.com/golang/go/issues/27179#issuecomment-5363437785
이 동작 방식에 필요한 데이터 타입에 대한 제안이 방금 승인되었습니다.
https://github.com/golang/go/issues/69559
저희 회사에서 까다로운 고객사용 파서(실수 유형마다 구체적인 오류를 내야 했습니다)를 만들어야 했을 때 성능을 유지하면서 jsontext 패키지를 썼는데, 아주 잘 동작합니다.
인트로스펙션이 없고 속도가 매우 뛰어납니다. 단순하지 않은 용도라면 무엇이든 파서로 jsontext를 추천합니다.
저는… 이거 괜찮다고 생각해요?
저는 Go 쪽 뉴런 밴드를 다시 모으는 중이에요. 예전에 작은 시스템 작업에 쓴 적은 있는데, 이제 웹 쪽은 인터프리터 언어에서 완전히 갈아타려고 해요. 일단 Go로요. 직접 손으로 코딩하던 데서 적어도 LLM의 도움을 받는 코딩으로 넘어가는 중이기도 하고요.
그래서 불편한 점은 좀 모르는 상태예요(마지막으로 쓸 때는 불편함을 느껴 본 적이 없어요. 제가 하던 일이 아주 작고 Go에 잘 맞는 모양이었거든요).
이게 들어오기까지 6년이 걸렸다는 점이 눈에 띄었습니다.
아주 놀랍지는 않고, Go 팀이 출시 전에 인터페이스를 신중하게 따져 본다는 점은 다행이라고 생각합니다. 그래도 JSON API 하나 넣는 데 6년은 꽤 많은 작업 같습니다.
가끔 Go의 개발 속도가 궁금해져요. 제네릭도(튜토리얼 말고는 아직 써 본 적이 없어요) 한참 걸렸잖아요.
Go에서 JSON 처리는 느리기로 꽤 유명한데, 그래서 왜 이렇게 오래 걸렸는지 잘 이해가 안 돼요.
사용 사례의 99% 정도는 encoding/json으로 충분했습니다. 물론 개선은 환영입니다. 하지만 저는 개인적으로 이런 종류의 사이트로는 세계 최대급인 곳의 백엔드 한 축을 개발하는 일을 이끌었는데, 우리는 encoding/json과 net/http만 썼습니다. 우리 사용 사례 대부분은 그냥 API를 만드는 일이었고, 고객용도 있고 내부용도 있고 신뢰하는 서드파티용도 있었습니다. 이 시스템들은 처리량이 꽤 높았고, 대부분의 사람이 평생 다룰 규모보다 큽니다.
회사 안에서 다른 팀 사람들이 무슨 지옥 같은 이유에서인지 최적화된 JSON 라이브러리를 직접 짜는 것도 봤습니다. 저는 앱이 IO 요청을 단 한 번이라도 하는 순간 그 나노초 단위 절감은 보이지도 않는다고 늘 되풀이해서 말했습니다. 요청 처리에 드는 총 CPU 시간에는 영향이 있겠지만, 우리에게 컴퓨팅이 한계였던 적은 한 번도 없었고 대부분의 사람에게도 아마 그럴 겁니다. 그리고 커스텀 JSON 코드를 짜거나 서드파티 의존성을 끌어오는 대신 앱의 스케일을 조금 더 키우는 쪽이 대가라면, 저는 더 키우는 쪽을 받아들이겠습니다.
encoding/json/v2의 가장 큰 이점은 속도가 아니라 개선된 API입니다.
JSON의 경우는 “이 정도면 충분”했던 것 같습니다. 이게 문제라고 느낀 사람들은 수많은 서드파티 옵션 중 하나를 썼겠죠.
저는 표준 라이브러리가 빨리 변하지 않는 점이 좋습니다. 생태계를 머릿속에 담기가 쉬워지거든요(예를 들어 코드가 2년도 못 가서 컴파일이 깨지는 자바스크립트 생태계와는 다릅니다).
하지만 느리고 꾸준한 방식이 그 나름의 문제를 낳는다는 데도 동의합니다. 제네릭이 좋은 예이긴 한데, Go 모듈이 어쩌면 더 좋은 예일 겁니다. Go 팀이 정말로 속도를 냈어야 했던 일이 가끔 있다는 걸 보여 주니까요.
ㅋㅋ, 반박하려는 건 아닌데, 저도 의존성 관리를 파고든 적이 있어요(모듈 구현 때문이기도 하고, 초기의 시행착오 때문이기도 하고, 순전히 호기심 때문이기도 해요).
의존성 관리는 진짜 악몽이에요. cargo, npm, apt, yum, nix, homebrew 어느 것도 빈틈없이 만든 사람이 없어요.
핵심 문제는 정해진 수명주기나 버전 규칙을 지킬 수도 있고 안 지킬 수도 있는 서드파티 소프트웨어를 관리해야 한다는 점이에요.
리눅스 배포판들은 각자 전담 팀이 업스트림 소스를 지켜보다가, 옳다고 생각하는 것을 골라 지정된 타깃용으로 빌드하고 테스트한 다음 배포하는 방식으로 해결해요(게다가 이 단계들은 여러 변수에 따라 중요도와 엄격함이 제각각이에요).
프로그래밍 라이브러리(depmod, goose, vgo, 모듈이 관리하는 대상이 이거예요)는 똑같이 나쁘거나 더 어려워요. 다른 개발자들이 상상하는 무엇으로든 바꿀 수 있는 토대를 제공하는 게 라이브러리의 본질이니까요.
거기에 모듈은 (처음에는) 약간 다른 생태계를 다루던 사람들이 썼다는 점도 있어요. 구글은 모노레포로 유명해서, 의존성 관리가 좀 더 편했거든요.
그러니 이런 점들을 생각하면 의존성 관리는 언제나 의견이 갈리는 어려운 문제일 수밖에 없어요(예를 들어 저는 벤더링 말고 의존성을 “고정”할 더 쉬운 방법이 있어야 한다는 확고한 의견이 있어요. 모든 주니어 Go 개발자가 와서 go.sum이 그 일을 한다고 주장하라는 초대는 아니에요. go.sum은 그렇지 않아요. go.sum은 거기에 쓴 사람이 마지막으로 빌드할 때 어떤 의존성이 쓰였는지 남긴 기록일 뿐이에요).
저는 패키지 관리 도구를 만들어 본 적이 있어서 의존성 관리가 어렵다는 데 동의합니다. 다만 언어 개발에서 마주치는 다른 문제들에 비하면 그렇게까지 어렵지는 않다고 생각합니다.
그건 그렇고 제 요지는, Go가 이미 널리 쓰이는 서드파티 패키지 관리 도구가 여러 개 자리 잡을 때까지 기다렸다가 자체 실험적 구현을 내놨다는 것입니다.
그런 무대응이 생태계에 훨씬 큰 혼란을 줬습니다. 기다리다 지친 사람들이 각자 만들어서 워크플로에 끼워 넣었는데, 1~2년 뒤에 완전히 새로운 시스템으로 다시 갈아타야 했으니까요.
Go가 손을 놓았던 이유로는 구글의 필요가 자주 거론됩니다. 하지만 저는 그게 오히려 상황을 더 나쁘게 만든다고 봅니다. Go 팀이 특정 기업 하나를 벗어난 사용 사례에 공감하지 못한다면, 누구나 쓸 수 있는 범용 언어로 내놓아서는 안 되는 것 아닙니까.
저는 이 언어를 대체로 좋아하는 사람으로서 이 말을 하는 겁니다.
흥미롭네요. 저는 같은 이유에서 정반대 결론에 이르렀습니다. 저는 Go 팀이 자체 방안을 정하기 전에 다른 대안 제안과 구현이 어떤 게 나와 있는지 지켜본 점이 마음에 듭니다.
“Go 팀이 특정 기업 하나를 벗어난 사용 사례에 공감하지 못한다”는 점을 생각하면 더더욱 그렇습니다. 자신의 한계를 안다는 뜻이니까요. 대상 사용자들이 먼저 설계 공간을 어느 정도 직접 탐색하게 두는 건 현명하다고 생각합니다. 잘못된 설계가 굳어질 위험이 상당히 줄어드니까요.
당시에 이 문제로 소동이 있었던 건 어렴풋이 기억합니다. 하지만 제 인상으로는 여러 의존성 관리 프로젝트의 커뮤니티가 저마다 자존심이 너무 세서 더 크고 장기적인 그림을 보지 못하는 심술궂은 모습이었습니다.
go.mod에 이렇게 넣으세요.
그러면 go.sum이 (GOSUMDB와 함께) 본래 역할을 해서, 원하는 리비전의 코드만 정확히 받게 됩니다.
왜 그런지 설명하는 부분을 빼먹으셨네요.
> 제네릭도(튜토리얼 말고는 아직 써 본 적이 없어요) 한참 걸렸잖아요.
Haskell 쪽 사람들이 설계에 전문성을 보탠 뒤로 릴리스에 들어가기까지 겨우 1년쯤 걸렸습니다. 내부적으로 상당히 큰 변경이었고 릴리스도 연 2회로 제약된다는 점을 생각하면 사실 꽤 빠른 편이에요.
Go 팀은 내부에 필요한 역량이 없다고 처음부터 분명히 밝혔습니다. 그래도 많은 시도를 했지만 실패했고, 오픈 소스 커뮤니티가 도와주기를 기대하고 있었죠. 안타깝게도 오픈 소스라고 해서 기꺼이 기여하겠다는 뜻은 아니더라고요. HN에 전문가가 그렇게 많은데도, 돕는 것보다 “go에는 제네릭이 없다 ㅋㅋ”라고 올리는 쪽이 더 좋았던 모양이에요.
그 사람들은 자바 제네릭에도 도움을 준 같은 사람들이고, 2006년에 책도 냈습니다.
https://homepages.inf.ed.ac.uk/wadler/topics/gj.html
Featherweight Go 이전에 Featherweight Java가 있었습니다.
그러니 2012년(1.0 릴리스 연도)에 설계된 언어라면 역사를 쓸데없는 군더더기라며 손사래 칠 게 아니라 역사에서 배웠어야 합니다.
https://github.com/golang/proposal/blob/master/design/go2draft-generics-overview.md
그리고 Featherweight Go 이후에도 팀원 모두가 납득한 것은 아니었습니다. 여기서 롭 파이크가 말합니다.
https://youtu.be/yE5Tpp2BSGw?t=2134
> 역사를 쓸데없는 군더더기라며 손사래 칠 게 아니라.
처음 Go 발표를 다시 보면, Go에 제네릭이 필요하다고 믿지만 그것을 해낼 전문성이 없다는 점을 아주 분명히 밝혔어요. 맞아요, 그 주제의 책이 이미 쓰여 있었던 건 사실이에요. 그렇다고 Go 팀 누군가가 그걸 잘 알았다거나 익힐 시간이 있었다는 뜻은 아니죠. 프로그래밍 언어를 설계하는 데 신이 필요한 건 아니에요. 불완전한 인간도 할 수 있어요.
제네릭의 구석구석을 아는 HN 전문가들이 기여할 수도 있었겠지만, 뭐, “go에는 제네릭이 없다 ㅋㅋ” 하고 헛소리를 올리는 게 시간을 더 잘 쓰는 방법이었나 보죠. 하루에 시간은 참 많으니까요.
Gonuts에는 Go 팀이 제네릭에 별로 열의가 없었다는 내용이 가득합니다. 실제로 gccgo는 버려졌고, 결국 gcj처럼 GCC에서 빠지리라고 예상합니다.
Go 문화는 전문가의 기여를 환영하지 않습니다. Rust, D, C#, Swift 등 심지어 자바조차도 언어 설계자들에게 훨씬 열린 태도입니다.
언어 기능에 열의가 있는 사람은 없습니다. 기능이 하나 늘 때마다 복잡도가 늘어나니까요. 그렇다고 필요성을 인식하지 못했다는 뜻은 아닙니다. Go 팀이 필요성을 인식했다는 건 분명히 알 수 있어요. 처음부터 Haskell 쪽 인력이 나타날 때까지 계속 제네릭 작업을 해 왔으니까요. 증거는 남아 있습니다. 다만 결과가 좋지 않았는데, 필요한 전문성이 없다고 스스럼없이 말하던 팀이니 예상할 만한 일입니다.
HN 전문가 중 누군가가 그 전문성을 보태러 나타났을 법도 한데, “go에는 제네릭이 없다 ㅋㅋ”를 쏟아내야 할 일이 많았나 보네요.
일부는 시도했고, 돌팔매와 몽둥이와 쇠스랑으로 맞이를 받았습니다. 결국 지도부가 Go v1.0이 나오기 몇 년 전에 자바 제네릭을 설계한 바로 그 팀에 도움을 청해야 했죠.
Haskell 쪽 인력은 2006년 Featherweight Java로 이미 실력을 증명한 사람들이었습니다.
v1 이전에 Go 제네릭 작업을 하던 사람들이 제네릭이 마침내 들어올 때도 그 자리에서 도왔어요. 그러니 당신이 본 돌팔매, 몽둥이, 쇠스랑이 그 일을 해내는 데 겁을 먹을 만큼 강력하지는 않았다는 거죠. 누가 그걸 기대하지도 않았겠지만요. 해볼 가치가 있는 일을 할 때 의미 없는 방해물은 걸림돌이 되지 않아요.
하지만 HN의 “go에는 제네릭이 없다 ㅋㅋ” 댓글이 더 중요했던 건 분명하죠. 그 말을 몇 년 동안 하루에 몇 번씩 되풀이하지 않고 그 노력을 Go 제네릭을 훨씬 일찍 들이는 데 썼다면 어떻게 됐을지 생각해 보세요. 운 좋은 줄 아세요.
도움을 주려면, 받는 쪽이 받아들일 마음이 있어야 합니다.