본문으로 건너뛰기

ExtractApiChanges

릴리스 노트에 “ErrorCodeFactory 클래스가 추가되었습니다”라고 적었는데, 실제 코드에는 해당 클래스가 없다면 어떻게 될까요? API 정확성은 릴리스 노트의 신뢰를 좌우합니다. ExtractApiChanges.cs는 이 문제를 근본적으로 해결합니다. 소스 코드가 아니라 컴파일된 DLL에서 직접 Public API를 추출하여, 실제로 빌드된 결과물과 100% 일치하는 API 정보를 생성합니다. 모든 어셈블리의 API를 하나로 합친 Uber 파일은 이후 Claude가 릴리스 노트를 작성할 때 단일 진실 공급원(Single Source of Truth) 역할을 합니다.

.release-notes/scripts/ExtractApiChanges.cs
Terminal window
cd .release-notes/scripts
dotnet ExtractApiChanges.cs

인자가 필요 없습니다. 현재 디렉터리를 기준으로 Git 루트와 프로젝트를 자동 탐색합니다.

패키지 참조와 CLI 정의는 AnalyzeAllComponents.cs와 동일한 패턴을 따르지만, 이 스크립트는 인자 없이 동작합니다.

#!/usr/bin/env dotnet
#:package System.CommandLine@2.0.1
#:package Spectre.Console@0.54.0
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Spectre.Console;
var rootCommand = new RootCommand("Extract API changes by building current branch");
rootCommand.SetAction(async (parseResult, cancellationToken) =>
{
await ExtractApiChangesAsync();
return 0;
});
return await rootCommand.Parse(args).InvokeAsync();

스크립트가 실행되면 일어나는 일

섹션 제목: “스크립트가 실행되면 일어나는 일”

스크립트는 다섯 단계를 거쳐 API 데이터를 수집합니다. 각 단계의 결과가 다음 단계의 입력이 되는 파이프라인 구조입니다.

API 추출의 핵심 도구인 ApiGenerator.cs가 존재하는지 먼저 확인합니다. 이 파일이 없으면 이후 단계를 진행할 수 없으므로 즉시 종료합니다.

AnsiConsole.MarkupLine("[bold]Step 1[/] [dim]Locating ApiGenerator...[/]");
var apiGeneratorPath = Path.Combine(toolsDir, "ApiGenerator.cs");
if (!File.Exists(apiGeneratorPath))
{
AnsiConsole.MarkupLine("[red]Error:[/] ApiGenerator.cs not found");
Environment.Exit(1);
}
AnsiConsole.MarkupLine($" [green]Found[/] [dim]{apiGeneratorPath}[/]");

Src 디렉터리에서 Functorium으로 시작하는 .csproj 파일을 찾습니다. 테스트 프로젝트는 제외합니다.

AnsiConsole.MarkupLine("[bold]Step 2[/] [dim]Finding Functorium projects...[/]");
var srcDir = Path.Combine(gitRoot, "Src");
var projectFiles = Directory.GetFiles(srcDir, "*.csproj", SearchOption.AllDirectories)
.Where(p => !p.Contains(".Tests.") &&
Path.GetFileName(p).StartsWith("Functorium"))
.OrderByDescending(p => p)
.ToList();
AnsiConsole.MarkupLine($" [green]Found[/] [white]{projectFiles.Count}[/] projects");

Step 3: 프로젝트 빌드 및 API 생성

섹션 제목: “Step 3: 프로젝트 빌드 및 API 생성”

이 단계가 스크립트의 핵심입니다. 각 프로젝트를 dotnet publish로 빌드한 뒤, 생성된 DLL을 ApiGenerator.cs에 전달하여 Public API를 추출합니다.

foreach (var projectFile in projectFiles)
{
var assemblyName = Path.GetFileNameWithoutExtension(projectFile);
var publishDir = Path.Combine(projectDir, "bin", "publish");
await AnsiConsole.Status()
.Spinner(Spinner.Known.Dots)
.StartAsync($"Publishing [cyan]{assemblyName}[/]...", async ctx =>
{
// 1. 프로젝트 빌드
var publishResult = await RunProcessAsync(
"dotnet",
$"publish \"{projectFile}\" -c Release -o \"{publishDir}\""
);
if (publishResult.ExitCode != 0)
{
AnsiConsole.MarkupLine($" [yellow]WARN[/] {assemblyName} - Publish failed");
return;
}
// 2. ApiGenerator로 API 추출
var dllPath = Path.Combine(publishDir, $"{assemblyName}.dll");
var apiResult = await RunProcessAsync(
"dotnet",
$"\"{apiGeneratorPath}\" \"{dllPath}\" -"
);
// 3. API 파일 생성
if (apiResult.ExitCode == 0 && !string.IsNullOrWhiteSpace(apiResult.Output))
{
var content = new StringBuilder();
content.AppendLine("// <auto-generated>");
content.AppendLine($"// Assembly: {assemblyName}");
content.AppendLine($"// Generated at: {DateTime.Now}");
content.AppendLine("// </auto-generated>");
content.Append(apiResult.Output);
await File.WriteAllTextAsync(outputFile, content.ToString());
generatedApiFiles.Add(outputFile);
}
});
}

모든 어셈블리의 API를 하나의 파일(all-api-changes.txt)로 합칩니다. 단일 파일로 합치는 이유는 검증을 단순화하기 위해서입니다. Phase 4에서 릴리스 노트를 작성할 때, 코드 예제의 API가 정확한지 이 파일 하나만 확인하면 됩니다.

AnsiConsole.MarkupLine("[bold]Step 4[/] [dim]Creating Uber API file...[/]");
var uberContent = new StringBuilder();
uberContent.AppendLine("// All API Changes - Uber File");
uberContent.AppendLine($"// Generated: {DateTime.Now}");
uberContent.AppendLine();
foreach (var apiFile in generatedApiFiles)
{
var assemblyName = Path.GetFileNameWithoutExtension(apiFile);
uberContent.AppendLine($"// ═══════════════════════════════════════════");
uberContent.AppendLine($"// Assembly: {assemblyName}");
uberContent.AppendLine($"// ═══════════════════════════════════════════");
uberContent.AppendLine();
var content = await File.ReadAllTextAsync(apiFile);
uberContent.AppendLine(content);
uberContent.AppendLine();
}
var uberFilePath = Path.Combine(apiChangesDir, "all-api-changes.txt");
await File.WriteAllTextAsync(uberFilePath, uberContent.ToString());

마지막으로 .api/*.cs 파일의 Git diff를 생성합니다. 이 diff는 이전 릴리스 대비 API가 어떻게 변했는지를 보여주며, Breaking Change를 자동으로 감지하는 데 사용됩니다.

AnsiConsole.MarkupLine("[bold]Step 5[/] [dim]Generating API diff...[/]");
var diffResult = await RunProcessAsync(
"git",
$"diff HEAD -- \"Src/*/.api/*.cs\""
);
var diffFilePath = Path.Combine(apiChangesDir, "api-changes-diff.txt");
await File.WriteAllTextAsync(diffFilePath, diffResult.Output);
if (string.IsNullOrWhiteSpace(diffResult.Output))
{
AnsiConsole.MarkupLine(" [dim]No API changes detected[/]");
}
else
{
AnsiConsole.MarkupLine($" [green]✓[/] Diff saved to api-changes-diff.txt");
}

스크립트가 생성하는 파일들은 두 곳에 위치합니다.

.analysis-output/api-changes-build-current/
├── all-api-changes.txt # Uber 파일 (모든 API)
├── api-changes-summary.md # API 요약
└── api-changes-diff.txt # Git Diff
Src/
├── Functorium/.api/
│ └── Functorium.cs # Functorium Public API
└── Functorium.Testing/.api/
└── Functorium.Testing.cs # Testing Public API

all-api-changes.txt는 모든 어셈블리의 Public API를 한 파일에 담고 있습니다.

// All API Changes - Uber File
// Generated: 2025-12-19 10:30:00
// ═══════════════════════════════════════════
// Assembly: Functorium
// ═══════════════════════════════════════════
namespace Functorium.Abstractions.Errors
{
public static class ErrorCodeFactory
{
public static LanguageExt.Common.Error Create(string errorCode, string errorCurrentValue, string errorMessage);
public static LanguageExt.Common.Error Create<T>(string errorCode, T errorCurrentValue, string errorMessage) where T : notnull;
public static LanguageExt.Common.Error CreateFromException(string errorCode, System.Exception exception);
}
}
// ═══════════════════════════════════════════
// Assembly: Functorium.Testing
// ═══════════════════════════════════════════
namespace Functorium.Testing.Arrangements.Hosting
{
public class HostTestFixture<TProgram> : System.IAsyncDisposable where TProgram : class
{
public System.Net.Http.HttpClient Client { get; }
public System.IServiceProvider Services { get; }
}
}

api-changes-diff.txt에서는 추가된 API(+)와 삭제된 API(-)를 한눈에 볼 수 있습니다.

diff --git a/Src/Functorium/.api/Functorium.cs b/Src/Functorium/.api/Functorium.cs
index abc1234..def5678 100644
--- a/Src/Functorium/.api/Functorium.cs
+++ b/Src/Functorium/.api/Functorium.cs
@@ -10,6 +10,7 @@ namespace Functorium.Abstractions.Errors
public static class ErrorCodeFactory
{
public static Error Create(string errorCode, string errorCurrentValue, string errorMessage);
+ public static Error CreateFromException(string errorCode, Exception exception);
}
}

외부 프로세스를 실행하고 출력과 종료 코드를 반환합니다. Git 명령어와 dotnet publish, ApiGenerator 호출 모두 이 함수를 통합니다.

static async Task<ProcessResult> RunProcessAsync(string command, string arguments, bool quiet = false)
{
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = command,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
var output = new StringBuilder();
var error = new StringBuilder();
process.OutputDataReceived += (s, e) => { if (e.Data != null) output.AppendLine(e.Data); };
process.ErrorDataReceived += (s, e) => { if (e.Data != null) error.AppendLine(e.Data); };
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await process.WaitForExitAsync();
return new ProcessResult
{
ExitCode = process.ExitCode,
Output = output.ToString(),
Error = error.ToString()
};
}

현재 체크아웃된 브랜치 이름을 가져옵니다.

static async Task<string> GetCurrentBranchAsync()
{
var result = await RunProcessAsync("git", "rev-parse --abbrev-ref HEAD", quiet: true);
return result.Output.Trim();
}

실행하면 다음과 같은 출력을 볼 수 있습니다.

━━━━━━━━━━━━ Extracting API Changes ━━━━━━━━━━━━
╭──────────┬─────────────────────────────╮
│ Property │ Value │
├──────────┼─────────────────────────────┤
│ Branch │ main │
│ Output │ .analysis-output/... │
│ Timestamp│ 2025-12-19 10:30:00 │
╰──────────┴─────────────────────────────╯
Step 1 Locating ApiGenerator...
Found .release-notes/scripts/ApiGenerator.cs
Step 2 Finding Functorium projects...
Found 2 projects
Step 3 Publishing projects and generating API files...
✓ Functorium
✓ Functorium.Testing
Step 4 Creating Uber API file...
✓ all-api-changes.txt (2 assemblies)
Step 5 Generating API diff...
✓ Diff saved to api-changes-diff.txt
━━━━━━━━━━━━ API Extraction Complete ━━━━━━━━━━━━

ExtractApiChanges.cs는 내부적으로 ApiGenerator.cs를 서브프로세스로 호출합니다. dotnet publish로 DLL을 생성하고, 그 DLL 경로를 ApiGenerator.cs에 전달하면, PublicApiGenerator 라이브러리가 Public API 텍스트를 표준 출력으로 반환합니다.

ExtractApiChanges.cs
├─▶ dotnet publish (프로젝트 빌드)
│ │
│ └─▶ bin/publish/Assembly.dll
└─▶ dotnet ApiGenerator.cs <dll-path>
└─▶ Public API 텍스트 출력

빌드 실패나 DLL 미발견 같은 오류는 해당 컴포넌트만 건너뛰고 나머지를 계속 처리합니다. 하나의 프로젝트 실패가 전체 프로세스를 중단시키지 않도록 설계된 것입니다.

if (publishResult.ExitCode != 0)
{
AnsiConsole.MarkupLine($" [yellow]WARN[/] [dim]{assemblyName}[/] - Publish failed, skipping");
return;
}
var dllPath = Path.Combine(publishDir, $"{assemblyName}.dll");
if (!File.Exists(dllPath))
{
AnsiConsole.MarkupLine($" [yellow]WARN[/] [dim]{assemblyName}[/] - DLL not found");
return;
}

ExtractApiChanges.cs가 Uber 파일을 통해 “현재 코드에 실제로 존재하는 API”를 기록하지만, DLL에서 Public API를 추출하는 실제 작업은 ApiGenerator.cs가 담당합니다. 다음 절에서는 이 ApiGenerator.cs가 어셈블리를 어떻게 로드하고, PublicApiGenerator 라이브러리로 API를 추출하는지 살펴보겠습니다.

Q1: ExtractApiChanges.cs가 소스 코드가 아닌 DLL에서 API를 추출하는 이유는 무엇인가요?

섹션 제목: “Q1: ExtractApiChanges.cs가 소스 코드가 아닌 DLL에서 API를 추출하는 이유는 무엇인가요?”

A: 소스 코드를 파싱하면 #if 전처리기 지시문, partial class, Source Generator 등으로 인해 실제 빌드 결과와 다른 API를 추출할 수 있습니다. 컴파일된 DLL은 이 모든 과정이 반영된 최종 결과물이므로, 실제 사용자가 접하게 될 Public API와 100% 일치하는 정보를 보장합니다.

Q2: Uber 파일(all-api-changes.txt)을 어셈블리별로 분리하지 않고 하나로 합치는 이유는 무엇인가요?

섹션 제목: “Q2: Uber 파일(all-api-changes.txt)을 어셈블리별로 분리하지 않고 하나로 합치는 이유는 무엇인가요?”

A: Phase 4에서 릴리스 노트를 작성하고 Phase 5에서 검증할 때, 여러 파일을 교차 참조하면 누락이 발생하기 쉽습니다. 단일 파일로 통합하면 검색 한 번으로 모든 어셈블리의 API를 확인할 수 있어, 검증 과정이 단순하고 정확해집니다.

Q3: 하나의 프로젝트 빌드가 실패해도 나머지가 계속 진행되는 이유는 무엇인가요?

섹션 제목: “Q3: 하나의 프로젝트 빌드가 실패해도 나머지가 계속 진행되는 이유는 무엇인가요?”

A: 릴리스 노트에는 성공적으로 빌드된 컴포넌트의 정보만 포함하면 됩니다. 빌드 실패 시 해당 컴포넌트를 건너뛰고(WARN 로그 출력) 나머지를 계속 처리하는 부분 실패 허용(Partial Failure) 전략을 사용하여, 하나의 문제가 전체 워크플로우를 중단시키지 않도록 설계되었습니다.

Q4: api-changes-diff.txt는 언제 빈 파일이 되나요?

섹션 제목: “Q4: api-changes-diff.txt는 언제 빈 파일이 되나요?”

A: 첫 배포이거나, .api 폴더에 이전 버전의 API 파일이 커밋되어 있지 않으면 Git diff가 비어 있습니다. 이 경우 Breaking Change 감지는 커밋 메시지 패턴(feat!:, BREAKING CHANGE)에만 의존하게 됩니다. .api 폴더의 파일을 매 릴리스마다 커밋해두면 다음 릴리스에서 정확한 diff를 얻을 수 있습니다.