포스트

코드리뷰 한 방에 JSON Schema 구조 뒤집기

코드리뷰 한 방에 JSON Schema 구조 뒤집기

지난 포스팅

Armeria(2): DocService 코드 상세분석
Armeria(4): Java SPI로 Provider를 Armeria에 꽂기

지금까지 JacksonPolymorphismTypeInfoProvider로 다형성 정보를 추출하고, SPI로 등록하는 것까지 완성했다. 남은 일은 이 정보를 실제 JSON Schema로 변환하는 것이다. Armeria(2)에서 ServiceSpecification이라는 최종 산물을 만들어 JSON으로 반환한다고 했는데, 그 JSON 구조를 생성하는 JsonSchemaGenerator 이야기다.


처음 구현: 메서드마다 definitions를 중복 생성

처음 구현에서는 각 메서드의 JSON Schema 안에 필요한 model 정의를 definitions 섹션으로 포함시켰다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{
  "getAnimal": {
    "type": "object",
    "properties": {
      "id": { "type": "integer" }
    },
    "definitions": {
      "com.example.Animal": {
        "oneOf": [
          { "$ref": "#/definitions/com.example.Dog" },
          { "$ref": "#/definitions/com.example.Cat" }
        ]
      },
      "com.example.Dog": {
        "type": "object",
        "properties": { "breed": { "type": "string" } }
      },
      "com.example.Cat": {
        "type": "object",
        "properties": { "isIndoor": { "type": "boolean" } }
      }
    }
  },
  "listAnimals": {
    "type": "object",
    "definitions": {
      "com.example.Animal": { "...": "똑같은 정의가 또!" },
      "com.example.Dog": { "...": "중복!" },
      "com.example.Cat": { "...": "중복!" }
    }
  }
}

이 방식은 동작은 하지만 두 가지 문제가 있었다.

  1. 중복: Animal, Dog, Cat 정의가 메서드마다 반복된다. API가 많을수록 JSON 크기가 폭발적으로 늘어난다.
  2. 구식 형식: definitions는 JSON Schema draft-04 방식이다. 현행 표준(draft 2020-12)은 $defs를 사용한다.

코드리뷰: 구조를 뒤집어라

minwoox 리뷰어가 이 구조를 지적하며 개선을 요청했다.

minwoox 코드리뷰 댓글 - $defs 구조 제안 minwoox의 Sep 19 리뷰: 중복 definitions 문제와 species 누락을 지적하며 $defs/methods + $defs/models 구조를 제안했다.

YoungHoney 반영 결과 댓글 Sep 23 답변: JsonSchemaGenerator를 재작성해 새 구조로 출력되는 JSON을 확인했다.

제안된 구조는 명확했다. 모든 model 정의를 최상위 $defs.models로 올리고, 각 메서드는 $defs.methods에 모아서 참조로만 연결하자는 것이었다. definitions는 JSON Schema draft-04 방식이고, 현행 표준(draft 2020-12)에서는 deprecated되었다는 점도 함께 지적받았다.


After: $defs 구조로 전면 재설계

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "com.example.AnimalService",
  "title": "com.example.AnimalService",
  "$defs": {
    "models": {
      "com.example.Animal": {
        "type": "object",
        "title": "com.example.Animal",
        "oneOf": [
          { "$ref": "#/$defs/models/com.example.Dog" },
          { "$ref": "#/$defs/models/com.example.Cat" }
        ],
        "discriminator": {
          "propertyName": "species",
          "mapping": {
            "dog": "#/$defs/models/com.example.Dog",
            "cat": "#/$defs/models/com.example.Cat"
          }
        }
      },
      "com.example.Dog": {
        "type": "object",
        "title": "com.example.Dog",
        "properties": {
          "species": { "type": "string" },
          "breed": { "type": "string" }
        },
        "required": ["species"]
      },
      "com.example.Cat": {
        "type": "object",
        "title": "com.example.Cat",
        "properties": {
          "species": { "type": "string" },
          "isIndoor": { "type": "boolean" }
        },
        "required": ["species"]
      }
    },
    "methods": {
      "getAnimal": {
        "$id": "com.example.AnimalService/getAnimal",
        "title": "getAnimal",
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "id": { "type": "integer" }
        }
      },
      "listAnimals": {
        "$id": "com.example.AnimalService/listAnimals",
        "title": "listAnimals",
        "type": "object",
        "additionalProperties": false
      }
    }
  }
}

주목할 점이 세 가지 있다.

  1. Animal, Dog, Cat 정의가 $defs.models한 번만 등장한다.
  2. DogCatproperties"species": { "type": "string" }자동으로 추가되어 있다.
  3. "required": ["species"]자동으로 추가된다.

2번과 3번은 따로 명시하지 않았는데 어떻게 들어간 걸까? polymorphismToBase 미리계산 덕분이다.


JsonSchemaGenerator 구현

최상위 구조 생성: doGenerate()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private ObjectNode doGenerate() {
    final ObjectNode root = mapper.createObjectNode();
    final ServiceInfo representativeService = serviceSpecification.services().iterator().next();
    final String serviceName = representativeService.name();

    root.put("$schema", "https://json-schema.org/draft/2020-12/schema");
    root.put("$id", serviceName);
    root.put("title", serviceName);

    final ObjectNode defs = root.putObject("$defs");
    defs.set("models", generateModels());  // 모든 타입 정의
    defs.set("methods", generateMethods()); // 모든 메서드 스키마

    return root;
}

$defs 아래에 modelsmethods 두 섹션을 만들고 각각 별도 메서드에서 생성한다.

models 섹션: generateModels()

1
2
3
4
5
6
7
8
9
10
private ObjectNode generateModels() {
    final ObjectNode modelsNode = mapper.createObjectNode();
    for (final StructInfo structInfo : serviceSpecification.structs()) {
        modelsNode.set(structInfo.name(), generateStructDefinition(structInfo));
    }
    for (final EnumInfo enumInfo : serviceSpecification.enums()) {
        modelsNode.set(enumInfo.name(), generateEnumDefinition(enumInfo));
    }
    return modelsNode;
}

ServiceSpecification에 수집된 모든 struct와 enum을 순회해 JSON 노드로 변환한다. 메서드가 아무리 많아도 각 모델은 여기서 한 번만 정의된다.

struct 정의 생성: generateStructDefinition()

핵심 로직이 여기 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
private ObjectNode generateStructDefinition(StructInfo structInfo) {
    final ObjectNode schemaNode = mapper.createObjectNode();
    schemaNode.put("type", "object");
    schemaNode.put("title", structInfo.name());

    // --- base 타입인 경우: oneOf + discriminator ---
    final List<TypeSignature> oneOf = structInfo.oneOf();
    if (!oneOf.isEmpty()) {
        final ArrayNode oneOfNode = schemaNode.putArray("oneOf");
        oneOf.forEach(sub -> {
            final ObjectNode ref = mapper.createObjectNode();
            ref.put("$ref", "#/$defs/models/" + sub.name());
            oneOfNode.add(ref);
        });

        final DiscriminatorInfo discriminator = structInfo.discriminator();
        if (discriminator != null) {
            final ObjectNode disc = schemaNode.putObject("discriminator");
            disc.put("propertyName", discriminator.propertyName());
            if (!discriminator.mapping().isEmpty()) {
                final ObjectNode mapping = disc.putObject("mapping");
                discriminator.mapping().forEach(mapping::put);
            }
        }
        return schemaNode; // base 타입은 여기서 끝
    }

    // --- 일반 struct 또는 서브타입인 경우 ---
    final ObjectNode props = mapper.createObjectNode();

    // 서브타입이면 discriminator 속성을 자동으로 주입
    final DiscriminatorInfo discriminatorInfo = polymorphismToBase.get(structInfo.name());
    if (discriminatorInfo != null) {
        final ObjectNode propertySchema = props.putObject(discriminatorInfo.propertyName());
        propertySchema.put("type", "string");
    }

    final List<String> requiredFields = new ArrayList<>();
    for (final FieldInfo field : structInfo.fields()) {
        props.set(field.name(), generateFieldSchema(field));
        if (field.requirement() == FieldRequirement.REQUIRED) {
            requiredFields.add(field.name());
        }
    }
    if (!props.isEmpty()) {
        schemaNode.set("properties", props);
    }

    // 서브타입이면 discriminator 속성을 required에도 추가
    if (discriminatorInfo != null) {
        requiredFields.add(discriminatorInfo.propertyName());
    }

    if (!requiredFields.isEmpty()) {
        final ArrayNode requiredNode = mapper.createArrayNode();
        requiredFields.forEach(requiredNode::add);
        schemaNode.set("required", requiredNode);
    }
    return schemaNode;
}

oneOf가 있는 타입(base 타입)과 없는 타입(일반 struct 또는 subtype)을 분기한다. 서브타입인 경우 polymorphismToBase에서 부모의 discriminator 정보를 찾아 해당 속성을 propertiesrequired 모두에 자동 삽입한다.

polymorphismToBase 미리계산

DogCat 스키마를 생성할 때 “이 클래스는 누구의 서브타입이고, discriminator가 뭐지?”를 빠르게 알아야 한다. 생성자에서 역방향 맵을 미리 만들어 둔다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private JsonSchemaGenerator(ServiceSpecification serviceSpecification) {
    // ...
    // alias 맵 (gRPC 등에서 사용하는 이름 별칭)
    final Map<String, String> nameToAlias = new HashMap<>();
    for (final StructInfo struct : serviceSpecification.structs()) {
        if (struct.alias() != null) {
            nameToAlias.put(struct.name(), struct.alias());
        }
    }

    // 역방향 맵: "com.example.Dog" → Animal의 DiscriminatorInfo
    polymorphismToBase = new HashMap<>();
    for (final StructInfo struct : serviceSpecification.structs()) {
        final DiscriminatorInfo discriminator = struct.discriminator();
        if (discriminator != null) {
            struct.oneOf().forEach(sub -> {
                polymorphismToBase.putIfAbsent(sub.name(), discriminator);
                final String alias = nameToAlias.get(sub.name());
                if (alias != null) {
                    polymorphismToBase.putIfAbsent(alias, discriminator);
                }
            });
        }
    }
}

AnimaloneOf[Dog, Cat]이고 discriminator가 species라면, 이 코드는 다음 맵을 만든다.

1
2
3
4
{
  "com.example.Dog" → DiscriminatorInfo(propertyName="species", mapping={...}),
  "com.example.Cat" → DiscriminatorInfo(propertyName="species", mapping={...})
}

이후 Dog 스키마를 생성할 때 polymorphismToBase.get("com.example.Dog")DiscriminatorInfo를 즉시 조회해서 species 속성을 자동 주입한다.


전체 생성 흐름

flowchart TD
    A["ServiceSpecification"] --> B["JsonSchemaGenerator 생성자"]
    B --> C["polymorphismToBase 역방향 맵\n서브타입명 → 부모 DiscriminatorInfo"]
    C --> D["doGenerate()"]
    D --> E["generateModels()"]
    D --> F["generateMethods()"]

    E --> G["StructInfo 순회"]
    G --> H{"oneOf 있음?"}
    H -- "예 base 타입" --> I["oneOf 배열 + discriminator 객체 생성\n→ 여기서 return"]
    H -- "아니오" --> J{"polymorphismToBase\n에 있음?"}
    J -- "예 서브타입" --> K["discriminator 속성 properties에 추가\n+ required에 추가"]
    J -- "아니오 일반 struct" --> L["일반 필드 properties 생성"]
    K --> L

    F --> M["각 메서드 BODY 파라미터\n스키마 생성"]

구현 완료 후 DocService 화면

DocService after - Dog struct에 서브타입 필드 완전 노출 PR 이후: Dog struct 필드가 완전히 노출되고, 사이드바에 Cat, Dog, Toy, VetRecord 등 서브타입이 모두 표시된다.

DocService after - processAnimal 메서드 상세 PR 이후: processAnimal() 메서드 상세 화면


정리

PR #6370에서 구현한 내용을 정리하면 다음과 같다.

컴포넌트역할
JacksonPolymorphismTypeInfoProvider@JsonTypeInfo + @JsonSubTypes reflection으로 다형성 정보 추출
DiscriminatorInfodiscriminator propertyName + mapping 보관하는 불변 데이터 클래스
StructInfo (수정)oneOf, discriminator 필드 추가
JsonSchemaGenerator (재작성)$defs: {models, methods} 구조로 중복 없이 JSON Schema 생성
META-INF/servicesSPI로 Provider 자동 등록

Merge까지의 여정

PR Draft를 올린 것이 2025년 8월이었다. 이후 리뷰 과정이 길었는데, 서버 사이드 구현이 완료된 뒤에도 프론트엔드 연동 작업이 남아 있었다.

2026년 2월, minwoox가 먼저 approve하며 이런 댓글을 남겼다.

“@YoungHoney Sorry that I’m late. 😆
I resolved the conflict and did the UI work.
Thanks a lot!”

minwoox approve

말대로 conflict를 직접 resolve하고 RequestBody.tsx의 프론트엔드 작업까지 push해줬다. 그 덕분에 $defs 구조에 맞는 자동완성이 DocService UI에서도 동작하게 됐다.

jrhee17 approve

jrhee17도 같은 시기에 approve했고,

ikhoon approve

ikhoon이 2월 27일 최종 approve 후 4월에 merge했다.

Armeria 1.38.0 릴리즈 노트에는 다음 한 줄이 들어갔다.

DocService now correctly generates JSON Schema with oneOf and discriminator fields for types annotated with Jackson’s @JsonTypeInfo and @JsonSubTypes. #6370

그리고 릴리즈 페이지의 Thank you 섹션에 contributor로 이름이 올랐다.

Armeria 1.38.0 릴리즈 노트 - PR #6370 항목 1.38.0 릴리즈 노트에 포함된 PR #6370 한 줄

Armeria 1.38.0 Thank you 섹션 - contributor 등재 Armeria 1.38.0 릴리즈 페이지의 Thank you 섹션 — 맨 왼쪽 첫 번째 아이콘이 나다.

반년 이상 걸린 여정이었지만, 오픈소스 프로젝트에 실질적인 기능을 기여하고 릴리즈 노트에 이름을 올릴 수 있어서 보람찼다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.