#go sumtypes json #discriminatedunions #softwareengineering
Go 언어에서 Sum Types를 우아하게 구현하기
Go 언어에서 JSON 객체의 kind 필드를 기반으로 여러 구조체 타입을 처리해야 할 때, 기존 방식들은 복잡한 JSON 언마샬링이나 타입 스위칭이 필요했습니다. Jeffrey Richter가 제시하는 새로운 접근법은 unsafe 포인터 변환과 구조체 프로젝션(projection)을 활용하여 더 우아하고 효율적인 솔루션을 제공합니다. 이 기법은 타입 안전성과 개발자 경험을 모두 만족시키면서 별도의 커스텀 JSON 언마샬링 코드를 필요로 하지 않습니다.
기존 Sum Types 구현 방식의 문제점
일반적인 해결책들
1. 인터페이스 타입 반환 방식
- 모든 가능한 타입을 미리 알고 있어야 함
- Azure Go SDK에서 현재 사용 중
- 타입 스위칭 필요
2. Union Struct 패턴
- 각 서브타입마다 하나의 포인터 필드를 가지는 구조체 사용
- 스위치 문에서 null이 아닌 필드를 찾아야 함
- 복잡한 로직 필요
3. Enum 필드 방식
- 인터페이스 메서드 또는 union struct 필드로 enum 사용
- 여전히 커스텀 JSON 언마샬링 필요
- 성능 문제: 일반적으로 같은 JSON을 두 번 언마샬링함
핵심 문제점
모든 기존 방식이 커스텀 JSON 언마샬링 코드를 요구하며, 많은 경우 JSON을 두 번 처리하므로 속도와 메모리 효율성이 떨어집니다.
Richter의 새로운 접근법
핵심 개념: 구조체 프로젝션(Projection)
기본 원리
- 하나의 unexported 구조체에 모든 필드를 정의
- 이를 서로 다른 exported 구조체로 “프로젝션”
- Unsafe 포인터 변환을 사용하여 구조체 간 캐스팅
안전성 보장
- 프로젝션 구조체들은 메모리상 동일한 레이아웃을 가짐
- 필드 이름만 다름 (blank identifier _ 사용)
- Unsafe 변환은 메모리 손상을 유발하지 않음
구체적인 구현 구조
1단계: Unexported Shape 구조체 (JSON 언마샬링용)
type shape struct {
shapeCaster
Color *string `json:"color,omitempty"`
Kind *ShapeKind `json:"kind,omitempty"`
Radius *int `json:"radius,omitempty"`
Width *int `json:"width,omitempty"`
Height *int `json:"height,omitempty"`
}
2단계: Exported 프로젝션 구조체들
// 기본 프로젝션 - 공통 필드만 노출
type Shape struct {
shapeCaster
Color *string
Kind *ShapeKind
_ *int // Radius
_ *int // Width
_ *int // Height
}
// Circle 프로젝션 - Radius만 노출
type CircleShape struct {
shapeCaster
Color *string
Kind *ShapeKind
Radius *int
_ *int // Width
_ *int // Height
}
// Rectangle 프로젝션 - Width, Height 노출
type RectangleShape struct {
shapeCaster
Color *string
Kind *ShapeKind
_ *int // Radius
Width *int
Height *int
}
사용 예시
func main() {
incomingJson := jsonFromWebService()
var shapes []*Shape
json.Unmarshal(incomingJson, &shapes)
for _, s := range shapes {
switch *s.Kind {
case CircleShapeKind:
c := s.Circle()
c.Color, c.Radius = ptr("white"), ptr(2)
case RectangleShapeKind:
r := s.Rectangle()
r.Height = ptr(min(*r.Height, 10))
r.Width = ptr(min(*r.Width, 10))
}
}
outgoingJson, _ := json.Marshal(shapes)
}
ShapeCaster 필드의 역할
핵심 목적
모든 프로젝션 구조체에 embedded 필드로 포함되며, 반드시 첫 번째 필드여야 함:
- 구조체의 메모리 주소 = shapeCaster 필드의 주소
- 이는 unsafe 포인터 변환의 기반 제공
제공하는 메서드
캐스팅 메서드
Shape(): 현재 프로젝션을 Shape로 변환Circle(): 현재 프로젝션을 CircleShape로 변환Rectangle(): 현재 프로젝션을 RectangleShape로 변환- 부적절한 타입으로의 캐스팅 시도 시 panic 발생
변환 메서드
SetCircle(): 객체를 CircleShape로 변환- Kind 필드를 CircleShapeKind로 변경
- 모든 비circle 필드를 zero value로 설정
SetRectangle(): 객체를 RectangleShape로 변환- 동일한 원리로 작동
메서드 상속
Embedded shapeCaster 덕분에 모든 프로젝션 구조체는 이 메서드들을 자동으로 상속받음
JSON 처리 흐름
언마샬링 과정
- JSON → unexported
shape구조체로 직접 언마샬링 shape포인터를*Shape로 unsafe 변환- 소비 코드는 해당 kind에 따라 필요한 프로젝션으로 캐스팅
장점: JSON을 단 한 번만 처리 (기존 방식과의 주요 차이점)
마샬링 과정
- 프로젝션 구조체 배열을 JSON으로 마샬링
- 모든 프로젝션이 동일한 메모리 레이아웃을 가지므로 추가 변환 불필요
실제 예시
입력 JSON
[
{
"color": "red",
"kind": "circle",
"radius": 1
},
{
"color": "green",
"kind": "rectangle",
"width": 15,
"height": 15
},
{
"color": "blue",
"kind": "rectangle",
"width": 5,
"height": 5
}
]
처리 후 출력 JSON
[
{
"color": "white",
"kind": "circle",
"radius": 2
},
{
"color": "green",
"kind": "rectangle",
"width": 10,
"height": 10
},
{
"color": "blue",
"kind": "circle",
"radius": 5
}
]
구현의 실용적 측면
보조 함수
// ptr은 Go 1.26 이전에 필요
func ptr[T any](v T) *T { return &v }
Go 1.26 개선: 내장 new 연산자가 개선되어 ptr 함수가 더 이상 필요 없음
구조체 필드 검증
ValidateStructFields 메서드
var _ = sumtype.Caster[shape]{}.ValidateStructFields(true,
Shape{}, CircleShape{}, RectangleShape{})
- 목적: 모든 프로젝션 구조체가 동일한 필드 순서와 타입을 가지는지 검증
- 첫 번째 매개변수 true: 불일치 감지 시 panic 발생
- 타이밍: 애플리케이션 초기화 시 호출하여 조기에 문제 발견
- 중요성: 메모리 손상 방지
코드 생성
보일러플레이트 최소화
- 많은 kind가 있을 경우 코드 생성기 사용 권장
- 또는 AI 에이전트를 활용한 copy/paste 가능
- Richter 본인도 이 방식으로 개발함
확장성
새로운 kind 추가 시:
- 새로운 프로젝션 구조체 정의
- 모든 프로젝션 구조체에 새 필드 추가
- 새 kind 프로젝션에서만 필드 export
- 다른 프로젝션에서는 blank identifier 사용
설계의 철학적 배경
미지의 Kind 처리
default:
fmt.Printf("Unrecognized shape kind: %s\n", *s.Kind)
중요한 원칙
- 웹 서비스는 시간이 지남에 따라 진화 (버전 업그레이드)
- 새로운 kind가 추가될 수 있음
- 이전 클라이언트는 새 kind를 모를 수밖에 없음
- 따라서 default 케이스는 필수
Vet 도구의 警告에 대한 의견
- 일부 개발자는 모든 known kind에 대한 case가 있으면 default가 불필요하다고 주장
- Richter는 이에 동의하지 않음
- 미지의 kind에 대한 graceful handling이 진정한 견고성
방식 비교 및 장점
기존 방식 vs. Richter의 방법
| 측면 | 인터페이스 방식 | Union Struct | Richter 방식 |
|---|---|---|---|
| JSON 언마샬링 횟수 | 2회 | 2회 | 1회 |
| 커스텀 언마샬링 코드 | 필요 | 필요 | 불필요 |
| 타입 안전성 | 낮음 | 중간 | 높음 |
| 개발자 경험 (IDE 자동완성) | 나쁨 | 나쁨 | 우수 |
| 메모리 레이아웃 명확성 | 낮음 | 낮음 | 높음 |
| 런타임 안전성 | 일반 | 일반 | 우수 |
주요 이점
- 성능: JSON을 한 번만 처리
- 개발자 경험: IDE의 코드 완성과 컴파일 타임 타입 체크
- 메모리 효율성: 중복 언마샬링 제거
- 우아함: 보일러플레이트가 많지만 명확한 구조
- 확장성: 새로운 kind 추가가 직관적
실제 구현 고려사항
메모리 안전성
Unsafe 포인터 변환이 안전한 이유
- 모든 프로젝션 구조체는 메모리상 동일한 레이아웃
- 필드 개수, 순서, 타입 모두 동일
- Blank identifier는 메모리상 실제 필드와 동일하게 차지
- 필드 이름의 visibility 변경은 메모리 구조에 영향 없음
검증 메커니즘
ValidateStructFields 호출:
- 개발 중이나 테스트 환경에서 구조체 레이아웃 검증
- True 플래그로 불일치 시 즉시 panic
- 프로덕션 배포 전 모든 구조체의 정합성 보장
테스트
- GitHub 저장소에 완전한 예제 포함
- 광범위한 테스트 함수 제공
- 실제 사용 패턴 보여줌
실용적 팁과 주의사항
팁
- 코드 생성 활용: 프로젝션 구조체가 많으면 코드 생성 도구 사용
- 필드 순서 주의: 모든 프로젝션의 필드 순서와 타입이 정확히 일치해야 함
- 검증 호출: 애플리케이션 시작 시 ValidateStructFields 필수 호출
- Go 1.26 업그레이드: ptr 헬퍼 함수 불필요
주의사항
- Unsafe 포인터: 비록 안전하지만 Go의 권장사항 “Unsafe 회피” 원칙에 위배
- 반복되는 필드: 프로젝션마다 모든 필드를 명시해야 하므로 유지보수 부담 가능
- IDE 지원 한계: 일부 IDE는 Blank identifier 필드를 명확히 이해하지 못할 수 있음
- 성능 프로파일링: 실제 운영 환경에서 성능 측정 권장
결론 및 학습 포인트
Richter의 Sum Types 구현 기법은:
- 혁신적: 기존의 모든 방식을 벗어난 새로운 패턴
- 실무적: Azure 서비스와 같은 대규모 시스템에서 활용 가능
- 우아함: 복잡성이 있지만 최종 사용 코드는 간결하고 명확
- 진화하는 서비스 고려: 미지의 kind에 대한 graceful handling 제공
전체 구현은 GitHub 저장소에서 확인 가능하며:
- 광범위한 주석 포함
- 완전한 예제 포함
- 테스트 함수 포함
이 패턴은 특히 REST API 클라이언트, 마이크로서비스 환경, JSON 기반 시스템에서 유용합니다.