깨끗한 도메인 모델을 위한 JSON 직렬화와 애그리것 설계 패턴

궁금하면 500원·2025년 5월 30일

1. 합타입과 JSON 직렬화의 설계 사상

합타입은 "여러 타입 중 하나"를 의미하며, 외부에서는 내부의 복잡성을 모른 채 상위 타입으로 다룰 수 있어야 합니다.
직렬화 시 핵심은 "이 JSON이 어떤 서브타입으로 변환되어야 하는가?"를 판정하는 전략입니다.

판정 전략 3가지

  • 값 일치: 서브타입이 상태가 없는 값일 때 사용합니다.
  • JSON: {"fruit": "APPLE"}
  • Logic: Fruit.values().find { it.name == json.fruit }
  • Key/Structure Matching: JSON에 포함된 필드 구성이 특정 서브타입과 완전히 일치하는지 확인합니다.
sealed interface Shape {
    data class Circle(val radius: Double) : Shape
    data class Rect(val width: Int, val height: Int) : Shape
}
// {"radius": 10} -> Circle로 판정 (radius 키 존재 여부)
  • 타입 식별자 추가: 명시적인 힌트를 삽입합니다.
  • 내부 태그: {"type": "A", "a": 1}
  • 외부 태그: {"A": {"a": 1}}

2. JSON Schema와 Discriminator의 표준화

JSON Schema의 oneOf만으로는 어떤 스키마를 선택할지 모호함이 발생할 수 있습니다.
이를 해결하기 위해 표준 스키마와 라이브러리들은 Discriminator를 필수로 요구하는 추세입니다.

표준 대응 현황

  • OpenAPI 3.1 / JSON Schema Draft 7+: discriminator 속성을 통해 필드명과 매핑을 명시합니다.
  • Library 특성:
  • kotlinx.serialization: @SerialNameclassDiscriminator 설정을 통해 명시적 식별자 강제.
  • Jackson: Id.DEDUCTION 옵션을 통해 키 일치 방식 지원 가능하나, 성능과 명확성을 위해 식별자 권장.
  • Moshi: PolymorphicJsonAdapterFactory를 통한 식별자 기반 처리.

3. 타입 유실과 한계 극복

제네릭은 런타임에 타입 정보가 사라지는 Type Erasure 특성 때문에 직렬화 시 "타입 파라미터가 무엇인지" 알려주는 메커니즘이 필수적입니다.

합타입으로의 치환

제네릭의 가짓수가 무한하면 직렬화가 불가능합니다.
따라서 타입 상한을 설정하여 범위를 제한하고, 이를 합타입처럼 취급하여 해결합니다.

제네릭 상한과 매핑

sealed interface Animal
data class Dog(val barkVolume: Int) : Animal
data class Cat(val lifeLeft: Int) : Animal

// T를 Animal로 제한하여 직렬화 가능 범위를 확정
class Box<T : Animal>(val data: T)

2개 이상의 파라미터가 포함된 복합 제네릭 스키마

{
  "definitions": {
    "Animal": {
      "oneOf": [{ "$ref": "#/definitions/Dog" }, { "$ref": "#/definitions/Cat" }],
      "discriminator": { "propertyName": "type" }
    },
    "Fruit": {
      "oneOf": [{ "$ref": "#/definitions/Apple" }, { "$ref": "#/definitions/Banana" }],
      "discriminator": { "propertyName": "fruitType" }
    }
  },
  "properties": {
    "animal": { "$ref": "#/definitions/Animal" },
    "fruit": { "$ref": "#/definitions/Fruit" }
  }
}

4. Map으로의 추상화

모든 복잡한 객체는 결국 중복 키가 없는 Map<String, Any?>로 변환될 수 있습니다.
직렬화는 객체를 Map으로 바꾸는 과정(toMap), 역직렬화는 Map을 객체로 복구하는 과정(fromMap)입니다.

  • 메타데이터 포함: toMap 과정에서 역직렬화 힌트인 클래스명, 타입 코드 등를 추가하여 정보 손실을 막습니다.

  • 함수 시그니처의 정형화:

  • fun <T> T.toMap(serializer: Serializer<T>): Map<String, Any?>

  • fun <T> Map<String, Any?>.toObject(deserializer: Deserializer<T>): T


5. DDD 애그리것 모델링과 JSON 활용

DDD에서 애그리것은 데이터 변경의 단위입니다.
애그리것 루트를 제외한 내부 구성원들을 굳이 RDB 테이블로 파편화할 필요가 없다는 것이 현대적 모델링의 핵심입니다.

1) 불변식 유지와 JSON

애그리것 전체는 하나의 트랜잭션 내에서 정합성을 유지해야 합니다.

  • 전체 확인 정책: 애그리것 루트를 불러올 때 모든 하위 멤버를 함께 로드해야 불변식 체크가 가능합니다.
  • Value Object로서의 저장: 하위 구성원들을 JSON 컬럼으로 통째로 저장하면 조인 비용이 사라지고, 원자적 업데이트가 쉬워집니다.

2) 설계 시 주의사항

JSON 저장 방식을 선택했을 때 발생할 수 있는 문제와 해결책입니다.

문제점원인해결책
성능 저하한 애그리것 내 아이템(예: 주문 내역)이 수천 건인 경우애그리것 분리 Member와 Order를 분리
데이터 비대화주문 1건에 수만 개의 상품 상세 정보 포함상품 애그리것 분리 후 ID 참조만 유지
검색/필터링JSON 내부 필드로 검색이 필요한 경우CQRS 적용: 검색용 전용 테이블을 별도 생성

3) CQRS와 이벤트 기반 동기화

애그리것이 JSON으로 저장되어 검색이 어렵다면, 상태 변경 시 도메인 이벤트를 발행합니다. 이 이벤트를 구독하여 RDB의 플랫한 테이블을 업데이트하면, 쓰기 효율과 읽기 효율을 모두 잡을 수 있습니다.

profile
그냥 코딩할래요 재미있어요

0개의 댓글