Go에서 합계 유형에 대한 새로운 접근 방식 | Jeffrey Richter


#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 처리 흐름

언마샬링 과정

  1. JSON → unexported shape 구조체로 직접 언마샬링
  2. shape 포인터를 *Shape로 unsafe 변환
  3. 소비 코드는 해당 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 추가 시:

  1. 새로운 프로젝션 구조체 정의
  2. 모든 프로젝션 구조체에 새 필드 추가
  3. 새 kind 프로젝션에서만 필드 export
  4. 다른 프로젝션에서는 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 자동완성) 나쁨 나쁨 우수
메모리 레이아웃 명확성 낮음 낮음 높음
런타임 안전성 일반 일반 우수

주요 이점

  1. 성능: JSON을 한 번만 처리
  2. 개발자 경험: IDE의 코드 완성과 컴파일 타임 타입 체크
  3. 메모리 효율성: 중복 언마샬링 제거
  4. 우아함: 보일러플레이트가 많지만 명확한 구조
  5. 확장성: 새로운 kind 추가가 직관적

실제 구현 고려사항

메모리 안전성

Unsafe 포인터 변환이 안전한 이유

  • 모든 프로젝션 구조체는 메모리상 동일한 레이아웃
  • 필드 개수, 순서, 타입 모두 동일
  • Blank identifier는 메모리상 실제 필드와 동일하게 차지
  • 필드 이름의 visibility 변경은 메모리 구조에 영향 없음

검증 메커니즘

ValidateStructFields 호출:

  • 개발 중이나 테스트 환경에서 구조체 레이아웃 검증
  • True 플래그로 불일치 시 즉시 panic
  • 프로덕션 배포 전 모든 구조체의 정합성 보장

테스트

  • GitHub 저장소에 완전한 예제 포함
  • 광범위한 테스트 함수 제공
  • 실제 사용 패턴 보여줌

실용적 팁과 주의사항

  1. 코드 생성 활용: 프로젝션 구조체가 많으면 코드 생성 도구 사용
  2. 필드 순서 주의: 모든 프로젝션의 필드 순서와 타입이 정확히 일치해야 함
  3. 검증 호출: 애플리케이션 시작 시 ValidateStructFields 필수 호출
  4. Go 1.26 업그레이드: ptr 헬퍼 함수 불필요

주의사항

  1. Unsafe 포인터: 비록 안전하지만 Go의 권장사항 “Unsafe 회피” 원칙에 위배
  2. 반복되는 필드: 프로젝션마다 모든 필드를 명시해야 하므로 유지보수 부담 가능
  3. IDE 지원 한계: 일부 IDE는 Blank identifier 필드를 명확히 이해하지 못할 수 있음
  4. 성능 프로파일링: 실제 운영 환경에서 성능 측정 권장

결론 및 학습 포인트

Richter의 Sum Types 구현 기법은:

  1. 혁신적: 기존의 모든 방식을 벗어난 새로운 패턴
  2. 실무적: Azure 서비스와 같은 대규모 시스템에서 활용 가능
  3. 우아함: 복잡성이 있지만 최종 사용 코드는 간결하고 명확
  4. 진화하는 서비스 고려: 미지의 kind에 대한 graceful handling 제공

전체 구현은 GitHub 저장소에서 확인 가능하며:

  • 광범위한 주석 포함
  • 완전한 예제 포함
  • 테스트 함수 포함

이 패턴은 특히 REST API 클라이언트, 마이크로서비스 환경, JSON 기반 시스템에서 유용합니다.

1개의 좋아요