TypeScript 기반 Xcode 프로젝트 파서 분석
2025. 11. 11.React Native, 그리고 Expo 같은 크로스 플랫폼 프레임워크의 마케팅 문구는 항상 비슷하다. "한 번의 코드 작성으로 웹과 네이티브 앱을 모두 개발할 수 있다"는 것이다. 이론적으로는 React의 인터페이스를 활용하면 iOS, Android, Web 세 플랫폼을 모두 구현할 수 있다. 그러나 현실에서는 어느새 네이티브 코드를 들여다보고 있는 자신을 발견하게 된다.
다행히 최근 Expo 프레임워크는 네이티브 코드 없이도 개발할 수 있는 환경을 잘 구축해가고 있고, 덕분에 많은 회사들이 Expo를 채택하는 추세다. 하지만 여전히 네이티브를 보아야 할 순간은 있다. 특히 위젯과 앱 클립, 라이브 액티비티 같은 확장 기능을 구현할 때가 그렇다.
앞서 언급한 확장 기능을 추가하려면 웹 개발자도 네이티브를 이해해야 하며, 특히 iOS의 경우 Xcode 프로젝트에 대한 이해가 필수적이다. 이 글에서 분석할 @bacons/xcode는 Xcode 프로젝트를 웹 개발자에게 익숙한 TypeScript로 관리할 수 있게 해주는 오픈소스 라이브러리다.
이전 글에서 언급한 expo-apple-targets는 Expo ConfigPlugins 파이프라인에서 @bacons/xcode를 활용한다. 하지만 @bacons/xcode는 단독으로도 사용 가능하므로, Xcode 프로젝트를 자체적으로 관리해야 하는 팀에서도 유용하게 쓸 수 있다.
본격적인 분석에 앞서 몇 가지 안내를 하자면, 이 글에서는 Xcode 프로젝트의 각 설정에 대해서는 상세히 다루지 않는다. 다만 React Native 또는 Expo로 크로스 플랫폼 개발을 하며 iOS 네이티브를 다뤄본 경험이 있다면 내용을 이해하는 데 도움이 될 것이다.
사용 방법
@bacons/xcode는 크게 두 가지 API를 제공한다. JSON 형식으로의 파싱과 XcodeProject 클래스를 이용한 객체 그래프 방식이 있다.
아키텍처를 분석하기 전에 이 라이브러리가 무엇을 할 수 있는지 사용 방법을 먼저 살펴보자. 먼저 JSON 파싱 방식이다.
import {
parse,
build,
} from "@bacons/xcode/json";
import fs from "fs";
const projectPath = "./project.pbxproj";
const pbxprojContent = fs.readFileSync(projectPath);
const pbxproj = parse(pbxprojContent);
console.log(pbxproj);
const pbxprojString = build(pbxproj);
console.log(pbxprojString);JSON 형식 API는 @bacons/xcode/json 에서 parse() 와 build() 함수를 가져와 사용한다. parse()는 .pbxproj 파일을 JSON 객체로 변환하고, build()는 JSON 객체를 다시 .pbxproj 형식 문자열로 변환한다.
실제 필자가 개발 중인 macOS 프로젝트의 파일로 테스트해보자. 다음은 파싱 전 원본 pbxproj 파일의 일부이다.
{
archiveVersion = 1;
classes = {};
objectVersion = 77;
objects = {
/* Begin PBXNativeTarget section */
88A218652E8B6BE0008DA8E9 /* Plainboard */ = {
isa = PBXNativeTarget;
buildConfigurationList = 88A218712E8B6BE2008DA8E9 /* Build configuration list for PBXNativeTarget "Plainboard" */;
buildPhases = (
88A218622E8B6BE0008DA8E9 /* Sources */,
88A218632E8B6BE0008DA8E9 /* Frameworks */,
88A218642E8B6BE0008DA8E9 /* Resources */,
);
buildRules = ();
dependencies = ();
fileSystemSynchronizedGroups = (
88A218682E8B6BE0008DA8E9 /* Plainboard */,
);
name = Plainboard;
packageProductDependencies = (
88287CC32EB851E1005209F0 /* SwiftSoup */,
);
productName = Plainboard;
productReference = 88A218662E8B6BE0008DA8E9 /* Plainboard.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
};
rootObject = 88A2185E2E8B6BE0008DA8E9 /* Project object */;
}이는 웹 개발자에게 매우 생소한 형식의 파일이다. 여담으로 pbxproj 는 Xcode 프로젝트에서 사용하는 파일 형식으로, 상당히 오래전부터 Apple이 사용해온 방식이다. Xcode의 전신인 Project Builder 시절부터 사용되었으며, 1990년대부터 닷컴버블 시기까지 Project Builder가 사용되었다고 한다.
parse() 함수를 이용해 이 원본 파일을 JSON 형식으로 파싱한 결과는 다음과 같다.
{
archiveVersion: 1,
classes: {},
objectVersion: 77,
objects: {
"88A218652E8B6BE0008DA8E9": {
isa: "PBXNativeTarget",
buildConfigurationList: "88A218712E8B6BE2008DA8E9",
buildPhases: [
"88A218622E8B6BE0008DA8E9",
"88A218632E8B6BE0008DA8E9",
"88A218642E8B6BE0008DA8E9"
],
buildRules: [],
dependencies: [],
fileSystemSynchronizedGroups: [ "88A218682E8B6BE0008DA8E9" ],
name: "Plainboard",
packageProductDependencies: [ "88287CC32EB851E1005209F0" ],
productName: "Plainboard",
productReference: "88A218662E8B6BE0008DA8E9",
productType: "com.apple.product-type.application"
}
},
rootObject: "88A2185E2E8B6BE0008DA8E9"
}파싱 후 각종 불필요한 주석이 제거되어 훨씬 더 구조적으로 보인다. 다음은 동일한 원본 파일을 XcodeProject 클래스를 이용한 객체 그래프 방식으로 표현한 결과이다.
import {
XcodeProject,
} from "@bacons/xcode";
const project = XcodeProject.open("./project.pbxproj");
const targets = project.rootObject.props.targets; // 타겟 설정 반환
console.log(targets); // 타겟 설정 출력// 타겟 설정 출력 결과
[
PBXNativeTarget {
uuid: "88A218652E8B6BE0008DA8E9",
props: {
isa: "PBXNativeTarget",
buildConfigurationList: "88A218712E8B6BE2008DA8E9",
buildPhases: [],
buildRules: [],
dependencies: [],
fileSystemSynchronizedGroups: [],
name: "Plainboard",
packageProductDependencies: [],
productName: "Plainboard",
productReference: "88A218662E8B6BE0008DA8E9",
productType: "com.apple.product-type.application"
}
}
]project.rootObject.props.targets는 Xcode 프로젝트의 타겟 설정들을 배열로 반환한다. 타겟 유형에는 대표적으로 PBXNativeTarget이 있는데, 이는 위젯이나 라이브 액티비티를 추가할 때 생성된다. 프로젝트 구성에 따라 PBXAggregateTarget이나 PBXLegacyTarget 등도 포함될 수 있다.
Xcode 프로젝트 파싱

앞서 JSON 형식과 객체 그래프 모델, 두 가지 API를 살펴보았다. 두 API는 공통적으로 JSON 형식으로의 파싱 과정을 거친다. 결국 JSON 파싱 로직이 @bacons/xcode의 핵심이라고 볼 수 있다. 이에 대해 알아보자.
@bacons/xcode는 Chevrotain이라는 파서 프레임워크(Parsing DSL)를 사용한다. 이 라이브러리는 LL 계열의 파서 구현을 지원하는데, LL 계열 파서는 왼쪽에서 오른쪽으로, 위에서 아래로 읽어가며 정해진 규칙에 따라 구문을 처리하는 방식이다.
파서 구현은 크게 토큰(Token) 정의 → 렉서(Lexer) → 파서(Parser) 순으로 진행된다.
토큰부터 렉서와 파서를 전부 포함하면 인터프리터로 부를 수도 있지만, 이 글에서는 파서로 표현하겠다.
토큰 정의
먼저 토큰 정의부터 살펴보자.
// src/json/parser/identifiers.ts
import { createToken, Lexer } from "./chevrotain";
// ...
export const ObjectStart = createToken({ name: "OpenBracket", pattern: /{/ });
export const ObjectEnd = createToken({ name: "CloseBracket", pattern: /}/ });
export const ArrayStart = createToken({ name: "ArrayStart", pattern: /\(/ });
export const ArrayEnd = createToken({ name: "ArrayEnd", pattern: /\)/ });
export const Terminator = createToken({ name: "Terminator", pattern: /;/ });
export const Separator = createToken({ name: "Separator", pattern: /,/ });
export const Colon = createToken({ name: "Colon", pattern: /=/ });
// ...https://github.com/EvanBacon/xcode/blob/main/src/json/parser/identifiers.ts
createToken() 함수로 토큰을 정의한다. 이 함수는 토큰의 이름을 나타내는 name과 매칭 패턴을 정의하는 pattern을 인자로 받는다. 예를 들어, 렉서가 .pbxproj 파일을 읽다가 {를 만나면 ObjectStart 토큰을 생성한다.
다음은 더 복잡한 검증이 필요한 토큰들이다.
// src/json/parser/identifiers.ts
import { createToken, Lexer } from "./chevrotain";
// ...
function matchQuotedString(text: string, startOffset: number) { ... }
function matchData(text: string, startOffset: number) { ... }
export const DataLiteral = createToken({
name: "DataLiteral",
pattern: { exec: matchData },
line_breaks: false,
start_chars_hint: [`<`],
});
export const QuotedString = createToken({
name: "QuotedString",
pattern: { exec: matchQuotedString },
line_breaks: false,
start_chars_hint: [`"`, `'`],
});
export const StringLiteral = createToken({
name: "StringLiteral",
pattern: /[\w_$/:.-]+/,
line_breaks: false,
});
export const WhiteSpace = createToken({
name: "WhiteSpace",
pattern: /[ \t\n\r]+/u,
group: Lexer.SKIPPED,
});
export const Comment = createToken({
name: "Comment",
pattern: /\/\/.*/,
group: Lexer.SKIPPED,
});
export const MultipleLineComment = createToken({
name: "MultipleLineComment",
pattern: /\/\*[^*]*\*+([^/*][^*]*\*+)*\//,
line_breaks: true,
group: Lexer.SKIPPED,
});createToken() 함수 호출에 새로운 인자들이 추가되었다. 우선 pattern의 exec는 정규식만으로 매칭하기 어려운 패턴을 별도 함수로 정의하여 전달할 수 있게 한다.
line_breaks는 토큰이 줄바꿈 문자를 포함할지 여부를 결정하는 속성이다. start_chars_hint는 렉서가 문자열을 순회할 때 특정 시작 문자를 만났을 때만 패턴 매칭을 수행하도록 최적화하는 속성이다. 예를 들어 DataLiteral은 <를 만났을 때만 패턴 검증을 진행하고, 그 외의 경우에는 건너뛴다.
group은 렉서가 특정 타입의 토큰을 별도로 분류하도록 지정하는 속성이다. 여기서는 Lexer.SKIPPED가 사용되었는데, 이는 해당 패턴을 만나면 토큰을 생성하지 않고 건너뛴다는 의미이다.
렉서
토큰 스트림(Token Stream)을 생성하는 렉서 구현은 매우 간단하다. 참고로 토큰 스트림이란 앞서 정의한 토큰 단위로 이루어진 연속된 데이터 흐름을 의미한다.
// src/json/parser/lexer.ts
import { Lexer } from "./chevrotain";
import identifiers from "./identifiers";
export const tokens = [...identifiers];
export const lexer = new Lexer(tokens);https://github.com/EvanBacon/xcode/blob/main/src/json/parser/lexer.ts
코드가 이처럼 간결한 이유는 Chevrotain의 추상화 덕분이다. 렉서를 직접 구현했다면 .pbxproj 파일을 읽고 텍스트를 순차적으로 순회하는 로직부터 구현해야 했을 것이다.
파서 및 규칙 정의
앞서 구현한 렉서로 토큰 스트림을 만들었다. 파서는 이를 전달받아 규칙에 따라 토큰을 소비하며 구체적 구문 트리(CST)를 생성한다. 파서 내부에 정의된 규칙들을 살펴보자.
웹에서 자주 접하는 추상 구문 트리(AST)와 달리, CST는 파싱 후에도 원본의 구체적인 문법 구조를 보존한다. 주로 원본을 다시 복원해야 할 때 활용된다.
// src/json/parser/parser.ts
export class PbxprojParser extends CstParser {
constructor() {
super(tokens, {
recoveryEnabled: false,
});
this.performSelfAnalysis();
}
head = this.RULE("head", () => {
this.OR([
{ ALT: () => this.SUBRULE(this.array) },
{ ALT: () => this.SUBRULE(this.object) },
]);
});
array = this.RULE("array", () => {
this.CONSUME(ArrayStart);
this.OPTION(() => {
this.MANY(() => {
this.SUBRULE(this.value);
this.OPTION2(() => this.CONSUME(Separator));
});
});
this.CONSUME(ArrayEnd);
});
object = this.RULE("object", () => {
this.CONSUME(ObjectStart);
this.OPTION(() => {
this.MANY(() => {
this.SUBRULE(this.objectItem);
});
});
this.CONSUME(ObjectEnd);
});
objectItem = this.RULE("objectItem", () => {
this.SUBRULE(this.identifier);
this.CONSUME(Colon);
this.SUBRULE(this.value);
this.CONSUME(Terminator);
});
identifier = this.RULE("identifier", () => {
this.OR([
{ ALT: () => this.CONSUME(QuotedString) },
{ ALT: () => this.CONSUME(StringLiteral) },
]);
});
value = this.RULE("value", () => {
this.OR([
{ ALT: () => this.SUBRULE(this.object) },
{ ALT: () => this.SUBRULE(this.array) },
{ ALT: () => this.CONSUME(DataLiteral) },
{ ALT: () => this.SUBRULE(this.identifier) },
]);
});
}https://github.com/EvanBacon/xcode/blob/main/src/json/parser/parser.ts
PbxprojParser에는 총 6가지 규칙이 정의되어 있다. 조금 복잡할 수 있으니 차분하게 각 규칙의 의미를 살펴보자.
head는 최상위 엔트리 포인트를 의미한다.SUBRULE()메서드를 통해서 다른 정의된RULE()로 전달하는 규칙이다.OR()메서드를 통해서array혹은object규칙으로 파싱한다.
array는 괄호로 감싸진 배열 구조를 파싱한다.CONSUME()메서드로ArrayStart토큰을 소비한 후,MANY()메서드로 0개 이상의 값들을 반복 처리하며, 각 값 사이에는Separator토큰이 선택적으로 올 수 있다.- 마지막으로
ArrayEnd토큰을 소비하여 배열 규칙을 종료한다.
object는 중괄호로 감싸진 객체 구조를 파싱한다.ObjectStart토큰을 소비한 후,MANY()메서드로 0개 이상의objectItem규칙을 반복 처리한다.- 마지막으로
ObjectEnd토큰을 소비하여 객체 규칙을 종료한다.
objectItem은 객체 내부의 키-값 쌍을 파싱한다.identifier규칙으로 키를 파싱하고,Colon토큰을 소비한 후,value규칙으로 값을 파싱한다.- 마지막으로
Terminator토큰을 소비하여 항목을 종료한다.
identifier는 키나 문자열 값으로 사용될 수 있는 식별자를 파싱한다.OR()메서드를 통해QuotedString또는StringLiteral토큰 중 하나를 소비한다.
value는 pbxproj 파일에서 값으로 사용될 수 있는 모든 타입을 파싱한다.OR()메서드를 통해object,array,DataLiteral, 또는identifier중 하나를 선택하여 처리한다.- 이를 통해 중첩된 객체나 배열 구조를 재귀적으로 파싱할 수 있다.
이렇게 정의한 파서를 사용하는 방법은 다음 코드와 같다.
// src/json/parser/parser.ts
const parser = new PbxprojParser();
export function parse(text: string): CstNode {
const lexingResult = lexer.tokenize(text);
if (lexingResult.errors.length) {
throw new Error(`Parsing errors: ${lexingResult.errors[0].message}`);
}
parser.input = lexingResult.tokens;
const parsingResult = parser.head();
if (parser.errors.length) {
throw new Error(`Parsing errors: ${parser.errors[0].message}`);
}
return parsingResult;
}parse() 함수에서 반환하는 parsingResult에는 파싱 결과물인 구체적 구문 트리의 첫 번째 노드가 담겨 있다.
파서 구현이 완료되면서 파싱의 핵심 로직이 완성되었다. 파서가 제공하는 방문자 패턴의 클래스를 확장하면 개발자가 CST를 원하는 형식으로 변환할 수 있다. @bacons/xcode에서는 JSON으로 변환하는 로직까지 핵심 아키텍처에 포함되어 있으므로, 이 부분까지 살펴보자.
JSON 형식으로 변환
파서는 getBaseCstVisitorConstructorWithDefaults() 메서드를 제공하는데, 이는 ICstVisitor 인터페이스를 구현하는 기본 Visitor 클래스의 생성자를 반환한다.
이 생성자를 상속받아 커스텀 Visitor 클래스를 만들 수 있다. 다음은 커스텀 Visitor 구현을 위한 베이스 클래스 BaseVisitor 를 선언한 코드이다.
// @chevrotain/types/api.d.ts
export interface ICstVisitor<IN, OUT> {
visit(cstNode: CstNode | CstNode[], param?: IN): OUT
validateVisitor(): void
}
// src/json/parser/parser.ts
/**
new (...args: any[]) => ICstVisitor<any, any>
*/
export const BaseVisitor = parser.getBaseCstVisitorConstructorWithDefaults();다음은 BaseVisitor 를 확장하여 JsonVisitor 클래스를 만든 코드다.
// src/json/visitor/JsonVisitor.ts
export class JsonVisitor extends BaseVisitor {
context: Partial<XcodeProject> = {};
constructor() {
super
this.validateVisitor();
}
head(ctx: any) {
if (ctx.array) {
this.context = this.visit(ctx.array);
} else if (ctx.object) {
this.context = this.visit(ctx.object);
}
}
object(ctx: any) {
return (
ctx.objectItem?.reduce(
(prev: any, item: any) => ({
...prev,
...this.visit(item),
}),
{}
) ?? {}
);
}
array(ctx: any) {
return ctx.value?.map((item: any) => this.visit(item)) ?? [];
}
objectItem(ctx: any) {
const key = this.visitIdentifierAsString(ctx.identifier);
return {
[key]: this.visit(ctx.value),
};
}
visitIdentifierAsString(identifierCtx: any) {
const ctx = identifierCtx[0]?.children || identifierCtx;
if (ctx.QuotedString) {
return ctx.QuotedString[0].payload ?? ctx.QuotedString[0].image;
} else if (ctx.StringLiteral) {
return ctx.StringLiteral[0].payload ?? ctx.StringLiteral[0].image;
}
throw new Error("unhandled identifier: " + JSON.stringify(identifierCtx));
}
identifier(ctx: any) {
if (ctx.QuotedString) {
return ctx.QuotedString[0].payload ?? ctx.QuotedString[0].image;
} else if (ctx.StringLiteral) {
const literal = ctx.StringLiteral[0].payload ?? ctx.StringLiteral[0].image;
return parseType(literal);
}
throw new Error("unhandled identifier: " + JSON.stringify(ctx));
}
value(ctx: any) {
if (ctx.identifier) {
return this.visit(ctx.identifier);
} else if (ctx.DataLiteral) {
return ctx.DataLiteral[0].payload ?? ctx.DataLiteral[0].image;
} else if (ctx.object) {
return this.visit(ctx.object);
} else if (ctx.array) {
return this.visit(ctx.array);
}
throw new Error("unhandled value: " + JSON.stringify(ctx));
}
}
https://github.com/EvanBacon/xcode/blob/main/src/json/visitor/JsonVisitor.ts
JsonVisitor 클래스의 생성자에서 호출되는 validateVisitor() 메서드는 Visitor 클래스가 파서의 모든 규칙을 올바르게 구현했는지 검증한다. JsonVisitor 에는 파서에서 선언한 각 규칙에 대응하는 메서드가 구현되어 있다.
JsonVisitor의 동작 방식은 다음과 같다. head() 메서드부터 시작하여 각 규칙에 맞춰 구현된 메서드들이 재귀적으로 호출되면서, 파싱한 컨텐츠를 JSON 형식(문자열, 숫자, 객체, 배열)으로 변환하여 context 멤버 변수에 저장한다.
저장된 context 멤버 변수를 다음과 같이 사용할 수 있다.
// src/json/types.ts
export interface XcodeProject {
archiveVersion: number;
objectVersion: number;
objects: Record<UUID, AbstractObject<any>>;
rootObject: UUID;
classes: Record<UUID, unknown>;
}
// src/json/index.ts
import * as parser from "./parser/parser";
import { XcodeProject } from "./types";
import { JsonVisitor } from "./visitor/JsonVisitor";
export function parse(text: string): Partial<XcodeProject> {
const cst = parser.parse(text);
const visitor = new JsonVisitor();
visitor.visit(cst);
return visitor.context;
}https://github.com/EvanBacon/xcode/blob/main/src/json/index.ts
parse() 메서드는 파라미터로 받은 문자열을 파싱하여 Partial<XcodeProject> 타입의 객체를 반환한다. parse() 메서드를 사용한 실제 파싱 결과를 살펴보자. 다음은 필자의 Swift 프로젝트에서 objects 필드의 일부 데이터이다.
88A2186F2E8B6BE2008DA8E9 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
};
name = Debug;
};이를 JSON 형식으로 파싱하면 다음과 같다.
{
"88A2186F2E8B6BE2008DA8E9": {
"isa": "XCBuildConfiguration",
"buildSettings": {
"ALWAYS_SEARCH_USER_PATHS": "NO",
"GCC_PREPROCESSOR_DEFINITIONS": [
"DEBUG=1",
"$(inherited)"
]
},
"name": "Debug"
}
}불필요한 주석이 제거되고 괄호 구조가 배열로 변환되는 등, 일반적인 JSON 형식으로 깔끔하게 변환되었다.
Xcode 프로젝트 작성

pbxproj 형식으로 변환
지금까지 Xcode 프로젝트 파일을 JSON으로 변환하는 과정을 살펴보았다. 이번에는 JSON을 원래 포맷인 pbxproj 형식으로 변환하는 Writer 클래스를 살펴보자.
Writer 클래스는 컴파일러 이론이나 복잡한 알고리즘이 아닌, JSON 객체를 pbxproj 형식으로 단순 직렬화하는 하드코딩 변환 로직이다. 기본 로직은 JSON 객체를 순회하면서 만나는 타입에 따라 분기 처리하며, 객체나 배열을 만나면 재귀적으로 메서드를 호출한다.
우선 Writer 클래스의 멤버 변수를 살펴보자.
// src/json/writer.ts
export class Writer {
private indent = 0;
private contents: string = "";
private comments: { [key: string]: string } = {};
// ...
}https://github.com/EvanBacon/xcode/blob/main/src/json/writer.ts
원본 .pbxproj 파일을 보면 각 데이터 구조마다 들여쓰기가 중요함을 알 수 있다. 그래서 들여쓰기 레벨을 추적하기 위한 변수로 indent를 사용한다. 순회 중 객체나 배열을 만나면 중첩 구조를 의미하므로 indent 값이 1 증가한다.
contents는 .pbxproj 파일에 작성될 텍스트가 담긴 변수이다. 클래스 내의 write()와 println() 메서드를 통해 처리된다.
comments는 이전에 파싱할 때 제거되었던 주석을 담는 변수이다. 파싱 시 별도로 저장해둔 것이 아니라 직접 새로 작성한다. comments를 위한 함수는 Writer 클래스와 별도로 존재한다.
Writer 클래스의 메서드 전부를 살펴보기에는 코드가 좀 많다. 그래서 일부 핵심 메서드만 살펴보자.
// src/json/writer.ts
export type JSONPrimitive =
| boolean
| number
| string
| null
| Buffer
| undefined;
export class Writer {
// ...
pad(x: number): string { ... } // 내부에서 재귀적으로 호출
private println(string?: JSONPrimitive) {
this.contents += this.pad(this.indent);
this.contents += string;
this.contents += EOL;
}
private write(string?: JSONPrimitive) {
this.contents += this.pad(this.indent);
this.contents += string;
}
private printAssignLn(key: string, value: string) { ... }
private flush(string?: JSONPrimitive) {
const current = this.indent;
this.indent = 0;
this.write(string);
this.indent = current;
}
// ...
}이들은 contents에 직접 문자열을 작성하는 메서드이다. write()는 JSONPrimitive 타입의 문자열을 받아 현재 레벨의 indent를 기준으로 pad() 메서드를 재귀적으로 호출하여 들여쓰기를 처리한 뒤 contents에 저장한다. println() 메서드는 write()에 더해 줄바꿈을 추가한다.
flush() 메서드는 임시로 indent를 초기화하여 작성한 뒤 원래 레벨의 indent로 복귀한다. .pbxproj 파일을 보면 'Begin'과 'End'로 시작하는 주석이 왼쪽 정렬로 작성되어 있는데, 이러한 경우 flush() 메서드를 활용한다.
// src/json/writer.ts
export class Writer {
// ...
private writeProject() {
this.println("{");
if (this.project) {
this.indent++;
this.writeObject(this.project as any, true);
this.indent--;
}
this.println("}");
}
// ...
}writeProject() 메서드는 최상위 프로젝트 객체를 작성하는 엔트리 포인트다. 이를 시작으로 writeObject() 메서드 내부에서 재귀적으로 함수 호출이 이루어진다. 다음은 writeObject() 메서드이다.
// src/json/writer.ts
// 일부 코드는 생략
export class Writer {
// ...
private writeObject(object: JSONObject, isBase?: boolean) {
Object.entries(object).forEach(([key, value]) => {
if (this.options.skipNullishValues && value == null) {
// ...
} else if (value instanceof Buffer) {
// ...
} else if (Array.isArray(value)) {
this.writeArray(key, value);
} else if (isObject(value)) {
if (!isBase && !Object.keys(value).length) {
this.println(ensureQuotes(key) + " = {};");
return;
}
this.println(ensureQuotes(key) + " = {");
this.indent++;
if (isBase && key === "objects") {
this.writePbxObjects(value);
} else {
this.writeObject(value, isBase);
}
this.indent--;
this.println("};");
} else if (typeof value === "number") {
// ...
} else {
// ...
}
});
}
}
// ...객체를 만나면 writeObject() 메서드가 각 value를 순회하며 타입별로 분기 처리한다. 배열은 바로 writeArray() 메서드를 호출하며, 객체는 세 가지 조건으로 처리된다. 빈 객체 여부, 'objects' 키를 가진 객체 여부, 일반 객체 이렇게 세 가지이다.
objects 키를 가진 객체는 pbxproj 형식에서 유일하다. 이 객체 내부에는 Xcode 프로젝트 설정의 거의 모든 것이 포함되어 있는 엔트리 객체나 다름없어서 writePbxObjects() 메서드로 별도 처리한다.
이외에도 다양한 메서드가 있는데, 주로 객체의 파생 케이스를 처리하기 위한 메서드들이다. JavaScript에서 객체 처리가 가장 까다로운 점을 생각하면 예상 가능한 범주이긴 하다.
Writer 클래스의 사용 방법은 다음과 같이 간단하다.
// src/json/index.ts
import { XcodeProject } from "./types";
import { Writer } from "./writer";
export function build(project: Partial<XcodeProject>): string {
return new Writer(project).getResults();
}https://github.com/EvanBacon/xcode/blob/main/src/json/index.ts
객체 그래프 모델

@bacons/xcode에는 앞서 글 초반에 언급한 것처럼 객체 그래프 방식의 클래스도 제공한다. 이는 JSON 형식보다 개발자 친화적이다. 가장 큰 장점은 TypeScript의 타입 안정성을 일관되게 유지할 수 있으며, IDE 자동완성을 지원하여 설정 변경을 더 명시적으로 할 수 있다는 점이다.
ISA
.pbxproj 파일에는 ISA라는 문자열이 포함되어 있다. 이는 Objective-C 런타임에서 메모리의 클래스 정의를 찾기 위해 사용했던 포인터를 의미한다. Apple은 Xcode 프로젝트 파일에도 이 개념을 차용했다.
객체 그래프에서도 마찬가지로 ISA 를 활용한다. 이 문자열로 Xcode 설정 객체의 종류를 식별하고, 팩토리 패턴을 기반으로 ISA에 매핑된 클래스 인스턴스를 생성한다. 각 하위 클래스는 세부 설정을 타입 추론 가능한 구조로 제공한다.
// src/json/types.ts
export enum ISA {
PBXBuildFile = "PBXBuildFile",
PBXAppleScriptBuildPhase = "PBXAppleScriptBuildPhase",
PBXCopyFilesBuildPhase = "PBXCopyFilesBuildPhase",
PBXFrameworksBuildPhase = "PBXFrameworksBuildPhase",
PBXHeadersBuildPhase = "PBXHeadersBuildPhase",
PBXResourcesBuildPhase = "PBXResourcesBuildPhase",
PBXShellScriptBuildPhase = "PBXShellScriptBuildPhase",
PBXSourcesBuildPhase = "PBXSourcesBuildPhase",
PBXRezBuildPhase = "PBXRezBuildPhase",
PBXContainerItemProxy = "PBXContainerItemProxy",
PBXFileReference = "PBXFileReference",
PBXGroup = "PBXGroup",
PBXVariantGroup = "PBXVariantGroup",
XCVersionGroup = "XCVersionGroup",
PBXFileSystemSynchronizedRootGroup = "PBXFileSystemSynchronizedRootGroup",
PBXFileSystemSynchronizedBuildFileExceptionSet = "PBXFileSystemSynchronizedBuildFileExceptionSet",
PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet = "PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet",
PBXNativeTarget = "PBXNativeTarget",
PBXAggregateTarget = "PBXAggregateTarget",
PBXLegacyTarget = "PBXLegacyTarget",
PBXProject = "PBXProject",
PBXTargetDependency = "PBXTargetDependency",
XCBuildConfiguration = "XCBuildConfiguration",
XCConfigurationList = "XCConfigurationList",
PBXBuildRule = "PBXBuildRule",
PBXReferenceProxy = "PBXReferenceProxy",
XCSwiftPackageProductDependency = "XCSwiftPackageProductDependency",
XCRemoteSwiftPackageReference = "XCRemoteSwiftPackageReference",
XCLocalSwiftPackageReference = "XCLocalSwiftPackageReference",
}https://github.com/EvanBacon/xcode/blob/main/src/json/types.ts
@bacons/xcode에서 ISA 는 열거형으로 선언되어있다. 열거형의 각 값은 다음의 코드처럼 .pbxproj 파일에서 확인할 수 있다.
/* Begin PBXResourcesBuildPhase section */
88A218642E8B6BE0008DA8E9 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */이 코드에서 ISA는 PBXResourcesBuildPhase로 선언되어 있다. 이러한 방식으로 Xcode는 다양한 ISA를 사용한다. 값이 많기 때문에 필요할 때마다 찾아보는 것을 권장한다. 참고로 ISA 값은 Xcode IDE의 프로젝트 설정 UI와 일대일로 매칭되지 않는 것으로 보인다.
ISA의 개수를 보면 알 수 있듯이, Xcode 프로젝트 객체 그래프에는 매우 많은 클래스가 있다. 모든 클래스를 살펴볼 수는 없으니 핵심만 추려서 살펴보자.
AbstractObject
AbstractObject는 객체 그래프 방식의 핵심 추상 클래스로, Xcode 프로젝트 파일의 모든 객체 타입의 기본 클래스이다. 이 클래스는 각 객체를 식별하기 위한 UUID를 포함하고 있으며, 객체 그래프 방식은 이를 기반으로 객체 간 참조를 관리한다.
/* Begin PBXResourcesBuildPhase section */
88A218642E8B6BE0008DA8E9 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */이 코드에서는 88A218642E8B6BE0008DA8E9 가 바로 UUID에 해당하며 이 객체의 고유한 식별자이다.
앞서 ISA 타입을 포인터라고 언급했기에 혼동할 수 있다. Xcode 프로젝트의 ISA는 어디까지나 Objective-C 런타임의 ISA 개념을 차용했을 뿐, 실제 고유한 포인터는 아니다. 즉, Xcode 프로젝트에서 ISA는 중복으로 존재할 수 있으며, 고유한 식별자 역할은 UUID가 담당한다.
다음은 AbstractObject 클래스 생성자에서 인자로 uuid를 전달받는 동시에 멤버 변수로 선언하는 코드이다.
// src/api/AbstractObject.ts
export abstract class AbstractObject<
TJSON extends json.AbstractObject<any> = json.AbstractObject<any>
> implements ReferenceCapableObject
{
// ...
constructor(
xcodeProject: XcodeProject,
public uuid: string, // 인자로 받는 동시에 공개 멤버 변수로 선언
public props: TJSON
) { ... }
// ...
}https://github.com/EvanBacon/xcode/blob/main/src/api/AbstractObject.ts
이번에는 AbstractObject 클래스에서 uuid 를 관리하는 메서드를 살펴보자.
export abstract class AbstractObject<
TJSON extends json.AbstractObject<any> = json.AbstractObject<any>
> implements ReferenceCapableObject
{
// ...
getReferrers(): AbstractObject[] {
return this.getXcodeProject().getReferrers(this.uuid);
}
isReferencing(uuid: string): boolean {
// AbstractObject를 상속하는 각 클래스마다 구현해야함
return false;
}
removeReference(uuid: string) {
// 구현은 비어있음
}
removeFromProject() {
this.getXcodeProject().delete(this.uuid);
const referrers = this.getReferrers();
referrers.forEach((referrer) => {
referrer.removeReference(this.uuid);
});
}
}getReferrers()는 Xcode 프로젝트 내에서 현재 객체를 참조하고 있는 모든 객체를 반환하는 메서드이다. AbstractObject 클래스를 상속받은 다른 클래스에서 별도로 구현할 필요가 없다.
isReferencing()은 현재 객체가 특정 객체를 참조하고 있는지 확인하기 위한 메서드이다. 상속받은 클래스에서 직접 구현해야 한다. 예를 들어 PBXBuildFile 클래스에서는 다음처럼 구현되어 있다.
// src/api/PBXBuildFile.ts
export class PBXBuildFile extends AbstractObject<PBXBuildFileModel> {
// ...
// AbstractObject 클래스를 상속받아 직접 isReferencing()을 구현했다.
isReferencing(uuid: string): boolean {
return [this.props.fileRef?.uuid, this.props.productRef?.uuid].includes(
uuid
);
}
}다시 AbstractObject 클래스로 돌아와서, removeReference() 메서드는 처음부터 구현 부분이 비어 있다. 마찬가지로 상속받은 클래스에서 직접 구현해야 한다. 예를 들어 XCConfigurationList 클래스에서는 다음처럼 구현되어 있다.
//
export class XCConfigurationList extends AbstractObject<XCConfigurationListModel> {
// ...
// AbstractObject 클래스를 상속받아 직접 removeReference()을 구현했다.
removeReference(uuid: string) {
const index = this.props.buildConfigurations.findIndex(
(child) => child.uuid === uuid
);
if (index !== -1) {
this.props.buildConfigurations.splice(index, 1);
}
}
}removeFromProject()는 Xcode 프로젝트 전체에서 현재 객체를 제거하는 메서드이다. 내부적으로는 먼저 Xcode 프로젝트에서 현재 객체를 제거하고, removeReference()를 반복적으로 호출하여 다른 객체에서도 현재 객체에 대한 참조를 제거한다. 이로써 Xcode 프로젝트 내부에서 해당 uuid에 대한 객체가 모두 제거된다.
마지막으로 AbstractObject 클래스에는 직렬화 메서드를 제공한다.
// src/api/AbstractObject.ts
// 코드가 길어서 일부는 생략하였다.
export abstract class AbstractObject<
TJSON extends json.AbstractObject<any> = json.AbstractObject<any>
> implements ReferenceCapableObject
{
// ...
protected inflate() {
for (const [key, type] of Object.entries(
this.getObjectProps()
) as EntriesAnyValue<TJSON>) {
if (!(key in this.props)) {
continue;
}
const jsonValue = this.props[key];
if (Array.isArray(jsonValue)) {
assert( ... );
this.props[key] = jsonValue
.map((uuid: string) => {
if (typeof uuid !== "string") {
return uuid;
}
try {
return this.getXcodeProject().getObject(uuid);
} catch (error: any) { ... }
})
.filter(Boolean);
} else if (jsonValue != null) {
if (jsonValue instanceof AbstractObject) {
this.props[key] = this.getXcodeProject().getObject(jsonValue.uuid);
continue;
}
assert( ... );
try {
this.props[key] = this.getXcodeProject().getObject(
jsonValue
);
} catch (error: any) { ... }
}
}
}
}inflate()는 UUID를 기반으로 새 객체를 생성하는 메서드이다. 객체 내부에서 사용 중인 자신을 제외한 UUID 문자열을 객체로 변환한다. UUID 문자열은 단독으로 존재하는 경우도 있고, 배열의 요소로 존재하는 경우도 있다. 다음은 배열의 요소로 존재하는 경우의 pbxproj 형식 Xcode 설정 예시이다.
88A218712E8B6BE2008DA8E9 = {
isa = XCConfigurationList;
buildConfigurations = (
88A218722E8B6BE2008DA8E9 /* Debug */,
88A218732E8B6BE2008DA8E9 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};이를 우선 JSON으로 변환하면 다음과 같다.
{
isa: "XCConfigurationList",
buildConfigurations: [
"88A218722E8B6BE2008DA8E9", // UUID 문자열
"88A218732E8B6BE2008DA8E9", // UUID 문자열
],
defaultConfigurationIsVisible: 0,
defaultConfigurationName: "Release",
}이때 inflate() 호출 없이 객체 그래프 방식으로 변환한다고 가정해보자. XCConfigurationList 객체만 생성되고 buildConfigurations 배열의 UUID 문자열은 그대로일 것이다.
XCConfigurationList {
uuid: "88A218612E8B6BE0008DA8E9",
props: {
buildConfigurations: [
"88A2186F2E8B6BE2008DA8E9", // 여전히 UUID 문자열
"88A218702E8B6BE2008DA8E9" // 여전히 UUID 문자열
]
}
}이제 실제 객체 그래프 방식의 동작인 inflate() 호출 후 결과를 보면 다음과 같이 변환된다.
XCConfigurationList {
uuid: "88A218612E8B6BE0008DA8E9",
props: {
buildConfigurations: [
XCBuildConfiguration {
uuid: "88A2186F2E8B6BE2008DA8E9",
props: {
isa: "XCBuildConfiguration",
name: "Debug",
buildSettings: { ... }
}
},
XCBuildConfiguration {
uuid: "88A218732E8B6BE2008DA8E9",
props: {
isa: "XCBuildConfiguration",
name: "Release",
buildSettings: { ... }
}
}
]
}
}
JSON 형식에서부터 남아있던 UUID 문자열이 XCBuildConfiguration 객체로 변환되었다. inflate() 메서드 이외에도 직렬화에는 toJSON() 메서드가 있다. 이는 inflate() 와 반대로 객체를 UUID 문자열로 변환한다.
지금까지 AbstractObject 추상 클래스의 코드를 일부 살펴보았다. 다른 Xcode 설정 관련 클래스들은 AbstractObject를 상속받아 각 설정 맥락에 맞게 커스터마이징되어 있다. 필자도 모든 Xcode 프로젝트 설정을 숙지하고 있지는 않으므로, 이 부분은 필요할 때 찾아보는 것을 권장한다.
XcodeProject
마지막으로 XcodeProject 클래스를 살펴보자. @bacons/xcode에서 거의 유일하게 AbstractObject의 상속을 받지 않으며, 객체 그래프 방식의 최상위 컨테이너 클래스로 이해하면 된다.
JSON 형식을 이야기할 때 코드 예제에서 동일한 이름의 인터페이스가 있었지만, 이 클래스와는 다르다. 다만 XcodeProject 클래스 생성자에서 json.XcodeProject라는 이름의 props를 받기 때문에 연관은 있다.
// src/api/XcodeProject.ts
export class XcodeProject extends Map<json.UUID, AnyModel> {
archiveVersion: number;
objectVersion: number;
rootObject: PBXProject;
classes: Record<json.UUID, unknown>;
private internalJsonObjects: Record<json.UUID, json.AbstractObject<any>>;
constructor(public filePath: string, props: Partial<json.XcodeProject>) {
super();
const json = JSON.parse(JSON.stringify(props));
assert(json.objects, "objects is required");
assert(json.rootObject, "rootObject is required");
this.internalJsonObjects = json.objects;
this.archiveVersion = json.archiveVersion ?? LAST_KNOWN_ARCHIVE_VERSION;
this.objectVersion = json.objectVersion ?? DEFAULT_OBJECT_VERSION;
this.classes = json.classes ?? {};
assertRootObject(json.rootObject, json.objects?.[json.rootObject]);
this.rootObject = this.getObject(json.rootObject);
this.ensureAllObjectsInflated();
}
// ...
}https://github.com/EvanBacon/xcode/blob/main/src/api/XcodeProject.ts
생성자의 인자로 받은 props는 JSON으로 변환된 후 각 멤버 변수 할당에 사용된다. 멤버 변수 중 internalJsonObjects는 pbxproj 형식의 objects에 해당하며, 이는 앞서 objects를 언급할 때 이야기한 Xcode 프로젝트 설정의 엔트리 포인트와 다름없다.
여기서 internalJsonObjects는 rootObject를 구성하는 데 사용되며 최종적으로는 제거된다. 이 과정은 getObject()와 ensureAllObjectsInflated()에서 이루어지는데, 이 메서드들을 순서대로 살펴보자.
// src/api/XcodeProject.ts
export class XcodeProject extends Map<json.UUID, AnyModel> {
// ...
getObject(uuid: string) {
const obj = this._getObjectOptional(uuid);
if (obj) {
return obj;
}
throw new Error(`object with uuid '${uuid}' not found.`);
}
private _getObjectOptional(uuid: string) {
if (this.has(uuid)) {
return this.get(uuid);
}
const obj = this.internalJsonObjects[uuid];
if (!obj) {
return null;
}
delete this.internalJsonObjects[uuid];
// model은 AbstractObject를 상속 받은 클래스이다.
const model = this.createObject(uuid, obj);
this.set(uuid, model);
model.inflate();
return model;
}
}getObject()는 프라이빗 메서드인 _getObjectOptional()를 호출한다. _getObjectOptional()에서는 has()와 get() 메서드로 캐시 확인을 먼저 수행한다. 이는 XcodeProject 클래스가 상속받는 Map 인스턴스의 자료 구조를 활용한다.
이후 internalJsonObjects에서 uuid를 키로 객체를 찾아 새 변수 obj에 저장하고, 기존 객체는 delete로 제거한다. createObject() 메서드는 AbstractObject 추상 클래스를 반환한다. 이 추상 클래스를 상속받은 객체 타입들이 모두 해당할 수 있다.
마지막으로 각 객체 타입을 캐시에 저장하고, 이전에 살펴본 inflate() 메서드를 호출하면 간접적인 재귀 호출을 통해 최종적으로 rootObject가 완성된다.
ensureAllObjectsInflated()는 이름 그대로 보험 같은 메서드이다. 처리되지 않고 남아 있는 internalJsonObjects 내 객체가 있을 수 있다는 가정하에, 완전히 변환 처리하기 위해 호출한다.
// src/api/XcodeProject.ts
export class XcodeProject extends Map<json.UUID, AnyModel> {
// ...
private ensureAllObjectsInflated() {
if (Object.keys(this.internalJsonObjects).length === 0) return;
while (Object.keys(this.internalJsonObjects).length > 0) {
const uuid = Object.keys(this.internalJsonObjects)[0];
this.getObject(uuid);
}
}
}internalJsonObjects의 잔여 객체를 확인하고 getObject() 메서드를 호출하는 것을 알 수 있다.
만약 개발자가 이 객체 그래프 방식을 통해 새 객체를 추가하고 싶다면 createModel()을 호출하면 된다. 방법은 다음과 같다.
// XCBuildConfiguration 생성 예시
const newBuildConfig = project.createModel({
isa: "XCBuildConfiguration",
name: "Custom",
buildSettings: { ... },
});객체를 새로 만들 때마다 당연히 새 UUID도 필요한데, 여기서는 기존 Xcode 프로젝트의 방식을 유사하게 모방하여 생성한다. 다음은 UUID를 만드는 함수이다.
// src/api/XcodeProject.ts
function uuidForPath(path: string): string {
return (
"XX" +
crypto
.createHash("md5")
.update(path)
.digest("hex")
.toUpperCase()
.slice(0, 20) +
"XX"
);
} 재미있게도 접두사와 접미사로 XX가 포함되어 있는데, 이는 라이브러리 제작자가 임의로 추가한 문자열이다. 만약 객체 그래프 방식으로 생성한 객체를 바로 확인하고 싶으면 XX가 붙은 UUID를 검색하면 빠르게 찾을 수 있다.
마치며
다음은 이전 글에서 사용한 라이브러리인 expo-apple-targets에서 @bacons/xcode를 활용하는 코드 예시이다.
import { XcodeProject } from "@bacons/xcode";
import * as xcodeParse from "@bacons/xcode/json";
import {
BaseMods,
ConfigPlugin,
IOSConfig,
} from "@expo/config-plugins";
const withXcodeProjectBetaBaseModInternal: ConfigPlugin = (config) => {
return BaseMods.withGeneratedBaseMods(config, {
// ...
providers: {
[customModName]: BaseMods.provider<XcodeProject>({
// ...
async getFilePath({ modRequest, _internal }) { ... },
async read(filePath) {
try {
return XcodeProject.open(filePath);
} catch (error: any) { ... }
},
async write(filePath, { modResults, modRequest: { introspect } }) {
if (introspect) { ... }
const contents = xcodeParse.build(modResults.toJSON());
if (contents.trim().length) { ... }
},
}),
},
});
};https://github.com/EvanBacon/expo-apple-targets/blob/main/packages/apple-targets/src/withXcparse.ts
이는 Expo의 ConfigPlugin 기능을 활용한 예시이다. 이 코드에서 read() 함수는 Expo의 플러그인 파이프라인에서 자동으로 호출된다. 즉, 다른 커스텀 플러그인을 작성할 때 이미 파싱된 XcodeProject 인스턴스를 전달 받아서 사용할 수 있다는 의미이다.
지금까지 @bacons/xcode 오픈소스의 코드를 살펴보면서 pbxproj 형식의 Xcode 프로젝트 파일을 TypeScript로 JSON과 객체 그래프 모델로 파싱하는 과정을 살펴보았다.
개인 프로젝트에 위젯 적용하기 위해 expo-apple-targets을 발견했고, pbxproj 형식과 Xcode IDE에서 이를 활용하는 방식 등 여러 흥미로운 점을 발견하면서 오픈소스 분석까지 진행하게 되었다.
Star가 많은 오픈소스도 아니라서 누가 이 글을 볼까 싶지만, React Native로 크로스 플랫폼을 개발하고 유지보수하는 개발자 입장에서, @bacons/xcode는 충분히 활용 가치가 있는 유용한 오픈소스라고 생각하여 이 글을 작성하게 되었다.
References