[Contributor] 첫 오픈소스 컨트리뷰션: swagger-typescript-api 버그 수정기

개발자로서 라이브러리를 가져다 쓰기만 하던 단계에서 벗어나, 전 세계 개발자들이 사용하는 swagger-typescript-api 오픈소스의 allOf + nullable: true 조합에서 | null이 생성되지 않는 버그를 발견하고 수정한 과정으로 '컨트리뷰터'로 데뷔한 과정을 기록합니다.

1. 문제의 발견: nullable: true가 무시되는 순간

프로젝트에서 swagger-typescript-api 라이브러리를 사용해 백엔드 OpenAPI 스펙으로부터 TypeScript 타입을 자동 생성하고 있었습니다. 그런데 swagger schema 에서는 nullable이지만 특정 필드에서는 null union type이 붙지 않는 현상을 발견했습니다.

OpenAPI 3.0에서 $ref와 nullable을 같은 레벨에 쓰면 $ref가 다른 키워드를 무시하는 스펙 제약이 있습니다. 그래서 allOf로 감싸는 것이 공식 권장 패턴입니다. 즉 라이브러리가 이 표준 패턴을 제대로 처리하지 못하고 있던 것이었죠.

"priorStudy": {
  "nullable": true,
  "allOf": [
    {
      "properties": {
        "patient": { ... }
      },
      "type": "object"
    },
    {
      "properties": {
        "studyIdInPacs": { "type": "string", "nullable": true },
        "category": {
          "allOf": [{ "$ref": "#/components/schemas/Category" }],
          "nullable": true
        }
      },
      "type": "object"
    }
  ]
}

[예상 결과]

priorStudy:
  | ({
      patient: { ... };
    } & {
      studyIdInPacs: string | null;
      category: TCategory | null;
      ...
    })
  | null;  // ← should be here

[실제 결과 (버그)]

priorStudy: {
  patient: { ... };
} & {
  studyIdInPacs: string | null;
  category: TCategory | null;
  ...
};  // ← | null missing

임시방편으로 생성된 파일을 후처리하는 스크립트를 만들어 쓰고 있었는데, 라이브러리 자체를 고치는 게 맞다고 판단해서 PR을 보내기로 마음을 먹게되었습니다.

2. 버그 추적 (Root Cause Analysis)

라이브러리의 소스 코드를 내려받아 디버깅을 시작했고, 두 가지 핵심적인 원인을 찾아냈습니다.

🔍 원인 1: 단순 문자열 스캔

// tests/spec/nullable-allof-3.0/intersection.test.ts
test("allOf intersection + nullable:true should produce (A & B) | null", async () => {
  await generateApi({
    input: path.resolve(import.meta.dirname, "schema-intersection.json"),
    output: tmpdir,
    httpClientType: "axios",
    modular: true,
    generateRouteTypes: true,
    generateClient: true,
    extractResponses: true,
    extractRequestBody: true,
    extractResponseBody: true,
    typePrefix: "T",
  });

  // priorStudy: nullable=true → | null 있어야 함
  expect(content).toMatch(/priorStudy[\s\S]*?\| null/);
});

테스트 결과: FAIL — 버그 재현 성공을 했고, 파싱 흐름을 따라가다 보니 isNullMissingInType 함수에서 false 를 반환했기 때문에 null이 추가 되지 않는 현상을 발견했습니다.

object.ts
  → property마다 getInlineParseContent() 호출
    → SchemaParser.parseSchema()
      → ComplexSchemaParser.parse()  (allOf이므로)
        → AllOfSchemaParser.parse()
          → safeAddNullToType(schema, type) 호출
            → isNullMissingInType() 체크
              → ❌ false 반환 → | null 추가안됨
const type = `{
  patient: { age: number; };
} & {
  studyIdInPacs: string | null;  // ← 여기
  category: TCategory | null;    // ← 여기
  id: number;
}`;

// 기존 로직
type.includes(" null")  // → true ← 내부 필드 때문에!

// "이미 null 있음"으로 잘못 판단 → | null 추가 안 함

기존 로직은 생성된 타입 문자열 전체에 null이라는 단어가 포함되어 있는지만을 확인하고 있었습니다.

  • 문제 점: 교차 타입 내부에 string | null인 필드가 하나라도 있으면, 최상위 타입은 null 허용이 아님에도 불구하고 "이미 null이 포함되어 있네?"라고 착각하여 | null 추가를 건너뛰고 있었습니다.

🔍 원인 2: 파싱 캐시($parsed)의 부작용

schema-utils.ts를 수정했는데도 테스트가 여전히 실패했습니다. safeAddNullToType에 로그를 찍어보니 아예 호출 자체가 안 되고 있었습니다.

원인을 살펴보니 SchemaParser.parseSchema()의 memoization 구조였습니다.

// schema-parser.ts
parseSchema = () => {
  if (!this.schema.$parsed) {
    // ...
    schemaType = this.schemaUtils.getInternalSchemaType(this.schema);
    parsedSchema = this._baseSchemaParsers[schemaType](this.schema, this.typeName);

    this.schema.$parsed =
      this.config.hooks.onParseSchema(this.schema, parsedSchema) || parsedSchema;
  }

  // $parsed가 있으면 파싱 로직 전체를 건너뛰고 캐시 반환
  return this.schema.$parsed;
};

schema 객체 자체에 $parsed를 직접 붙이는 방식이었고, 동일한 스키마 객체를 여러 곳에서 참조할 때 중복 파싱을 방지하기 위한 의도적인 설계를 확인했습니다

문제는 AllOfSchemaParser.parse() → safeAddNullToType() 호출 흐름이 이 parseSchema() 내부에 있다는 점인데요, priorStudy 스키마가 다른 경로(예: schemaRoutes의 response 파싱)에서 먼저 처리되어 $parsed가 세팅된 상태라면, object.ts에서 getInlineParseContent()를 호출할 때 이미 캐시된 값을 그대로 반환합니다.

즉, 한 번 파싱된 스키마는 캐싱되는데, 이 과정에서 nullable 속성이 각기 다르게 적용되어야 하는 상황(어떤 곳에선 null 허용, 어떤 곳에선 필수)을 제대로 분기 처리하지 못하고 있었습니다.

[다른 경로에서 먼저 파싱]
parseSchema() 호출
  → $parsed 없음 → 파싱 실행
  → AllOfSchemaParser.parse()
    → safeAddNullToType() → isNullMissingInType() → false (버그)
  → $parsed = { content: "({...} & {...})" }  ← | null 없는 상태로 캐싱

[object.ts에서 fieldValue 계산 시]
getInlineParseContent() → parseSchema() 호출
  → $parsed 있음 → 즉시 returnsafeAddNullToType() 실행 안 됨 ❌

그래서 isNullMissingInType 수정만으로는 이 캐시를 뚫을 수 없었습니다. $parsed가 세팅되는 타이밍이 object.ts보다 앞서 있기 때문이었던 것이었죠.

따라서 캐시 이후 단계, 즉 getInlineParseContent()가 반환한 값을 fieldValue로 확정하는 시점에서 nullable여부를 판단하고 $parsed 를 적용시킬지 safeAddNullToType을 적용하는 방식으로 우회했습니다.

// allOf + nullable: true 조합에서 | null 누락을 보완
const fieldValue = nullable
  ? this.schemaUtils.safeAddNullToType(property, rawFieldValue)
  : rawFieldValue;

3. 해결 과정

✅ 해결 1: 검사 범위 좁히기 (schema-utils.ts)

src/schema-parser/schema-utils.ts multiline 타입은 마지막 줄만 검사하도록 변경했습니다. 수정 후에는 내부 필드의 null 여부와 관계없이 최상위 선언에 null이 붙었는지를 정확히 판별하게 되었습니다.

// Before
isNullMissingInType = (schema, type) => {
  const { nullable, type: schemaType } = schema || {};
  return (
    (nullable || !!get(schema, "x-nullable") || schemaType === this.config.Ts.Keyword.Null) &&
    typeof type === "string" &&
    !type.includes(` ${this.config.Ts.Keyword.Null}`) &&  // ← 내부 필드도 감지
    !type.includes(`${this.config.Ts.Keyword.Null} `)
  );
};

// After
isNullMissingInType = (schema, type) => {
  const { nullable, type: schemaType } = schema || {};
  if (
    !(nullable || !!get(schema, "x-nullable") || schemaType === this.config.Ts.Keyword.Null) ||
    typeof type !== "string"
  ) {
    return false;
  }

  const nullKeyword = this.config.Ts.Keyword.Null;

  // multiline 타입은 마지막 줄만 검사 → 내부 필드의 "string | null"으로 null이 붙여져 있다고 오탐하는 것 방지
  const lastLine = type.trimEnd().split("\n").pop() ?? type;

  return (
    !lastLine.includes(` ${nullKeyword}`) &&
    !lastLine.includes(`${nullKeyword} `)
  );
};

해결 2: 캐시 오염 방지 (object.ts)

src/schema-parser/base-schema-parsers/object.ts 파일에서 $parsed 캐시를 우회해서 nullable이면 직접 safeAddNullToType을 적용.

// Before
const fieldValue = this.schemaParserFabric
  .createSchemaParser({ schema: property, schemaPath: [...this.schemaPath, name] })
  .getInlineParseContent();

// After
const rawFieldValue = this.schemaParserFabric
  .createSchemaParser({ schema: property, schemaPath: [...this.schemaPath, name] })
  .getInlineParseContent();

// allOf + nullable: true 조합에서 | null 누락을 보완
const fieldValue = nullable
  ? this.schemaUtils.safeAddNullToType(property, rawFieldValue)
  : rawFieldValue;

4. 테스트 케이스 추가

tests/spec/nullable-3.0/schema.json에 3가지 케이스를 추가했습니다.

"objectMaybeNullAllOf": {
  "nullable": true,
  "allOf": [{ "$ref": "#/components/schemas/OtherObject" }]
},
"inlineObjectMaybeNullAllOf": {
  "nullable": true,
  "allOf": [{ "type": "object", "properties": { "value": { "type": "string" } } }]
},
"intersectionMaybeNullAllOf": {
  "nullable": true,
  "allOf": [
    { "type": "object", "properties": { "a": { "type": "string", "nullable": true } } },
    { "type": "object", "properties": { "b": { "type": "string" } } }
  ]
}

스냅샷 업데이트

bun test:update 를 통해 스냅샷을 업데이트했고, 기존에 nullable이지만 null이 붙지 않았던 기존 스냅샷도 업데이트할 수 있었습니다.

# Before (잘못된 스냅샷)
status?: OrderStatusEnum;

# After (올바른 결과)
status?: OrderStatusEnum | null;

PR 과정

lint 확인을 통해, 내가 수정한 코드로 인해 새로운 린트 오류가 없다는 것을 확인도 했습니다.

# 수정 전 main 브랜치
bun lint
# Found 3 errors. Found 45 warnings.

# 수정 후
bun lint
# Found 3 errors. Found 45 warnings.  ← 동일

CI 통과

PR을 올리자 4개 체크가 모두 통과했습니다.

All checks have passed (4 successful checks)
✅ No conflicts with base branch

5. 결과: 첫 PR 그리고 메인테이너의 응답

스크린샷 2026-04-26 오후 5.51.03

changeset은 버전 관리 자동화 도구입니다. PR 머지 시 patch 버전을 자동으로 올려주는 메타데이터 파일을 생성해달라고 요청이 들어왔고, 실행 후에 다시 push를 했습니다.

pnpm changeset
# → swagger-typescript-api 선택
# → patch 선택
# → 코멘트 입력

git add .changeset/
git commit -m "chore: add changeset"
git push

6. 마치며: 오픈소스는 멀리 있지 않다

처음에는 "내가 이 유명한 라이브러리를 고칠 수 있을까?"라는 두려움이 있었습니다. 하지만 문제를 정의하고 코드를 파고들다 보니, 오픈소스 기여 역시 결국 '불편함을 해결하려는 의지'에서 시작된다는 것을 깨달았습니다.

오픈소스 컨트리뷰션이 어렵게 느껴졌는데, 실제로 해보니 내가 겪은 버그를 테스트로 재현하고 → 원인을 찾고 → 수정하는 과정 자체가 컨트리뷰션이었다.

이번 첫 컨트리뷰터 활동을 시작으로, 앞으로도 오픈소스 생태계에 긍정적인 영향을 끼치는 개발자로 성장해 나가고 싶습니다.