개발자로서 라이브러리를 가져다 쓰기만 하던 단계에서 벗어나, 전 세계 개발자들이 사용하는 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 있음 → 즉시 return
→ safeAddNullToType() 실행 안 됨 ❌그래서 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 branch5. 결과: 첫 PR 그리고 메인테이너의 응답
changeset은 버전 관리 자동화 도구입니다. PR 머지 시 patch 버전을 자동으로 올려주는 메타데이터 파일을 생성해달라고 요청이 들어왔고, 실행 후에 다시 push를 했습니다.
pnpm changeset
# → swagger-typescript-api 선택
# → patch 선택
# → 코멘트 입력
git add .changeset/
git commit -m "chore: add changeset"
git push6. 마치며: 오픈소스는 멀리 있지 않다
처음에는 "내가 이 유명한 라이브러리를 고칠 수 있을까?"라는 두려움이 있었습니다. 하지만 문제를 정의하고 코드를 파고들다 보니, 오픈소스 기여 역시 결국 '불편함을 해결하려는 의지'에서 시작된다는 것을 깨달았습니다.
오픈소스 컨트리뷰션이 어렵게 느껴졌는데, 실제로 해보니 내가 겪은 버그를 테스트로 재현하고 → 원인을 찾고 → 수정하는 과정 자체가 컨트리뷰션이었다.
이번 첫 컨트리뷰터 활동을 시작으로, 앞으로도 오픈소스 생태계에 긍정적인 영향을 끼치는 개발자로 성장해 나가고 싶습니다.