ExtractApiChanges
릴리스 노트에 “ErrorCodeFactory 클래스가 추가되었습니다”라고 적었는데, 실제 코드에는 해당 클래스가 없다면 어떻게 될까요? API 정확성은 릴리스 노트의 신뢰를 좌우합니다. ExtractApiChanges.cs는 이 문제를 근본적으로 해결합니다. 소스 코드가 아니라 컴파일된 DLL에서 직접 Public API를 추출하여, 실제로 빌드된 결과물과 100% 일치하는 API 정보를 생성합니다. 모든 어셈블리의 API를 하나로 합친 Uber 파일은 이후 Claude가 릴리스 노트를 작성할 때 단일 진실 공급원(Single Source of Truth) 역할을 합니다.
파일 위치와 사용법
섹션 제목: “파일 위치와 사용법”.release-notes/scripts/ExtractApiChanges.cscd .release-notes/scriptsdotnet 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 데이터를 수집합니다. 각 단계의 결과가 다음 단계의 입력이 되는 파이프라인 구조입니다.
Step 1: ApiGenerator 확인
섹션 제목: “Step 1: ApiGenerator 확인”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}[/]");Step 2: 프로젝트 탐색
섹션 제목: “Step 2: 프로젝트 탐색”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); } });}Step 4: Uber 파일 생성
섹션 제목: “Step 4: Uber 파일 생성”모든 어셈블리의 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());Step 5: Git Diff 생성
섹션 제목: “Step 5: Git Diff 생성”마지막으로 .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 APIUber 파일 예시
섹션 제목: “Uber 파일 예시”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 Diff 예시
섹션 제목: “API Diff 예시”api-changes-diff.txt에서는 추가된 API(+)와 삭제된 API(-)를 한눈에 볼 수 있습니다.
diff --git a/Src/Functorium/.api/Functorium.cs b/Src/Functorium/.api/Functorium.csindex 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); } }주요 함수
섹션 제목: “주요 함수”RunProcessAsync
섹션 제목: “RunProcessAsync”외부 프로세스를 실행하고 출력과 종료 코드를 반환합니다. 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() };}GetCurrentBranchAsync
섹션 제목: “GetCurrentBranchAsync”현재 체크아웃된 브랜치 이름을 가져옵니다.
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 ━━━━━━━━━━━━ApiGenerator.cs 연동
섹션 제목: “ApiGenerator.cs 연동”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를 추출하는지 살펴보겠습니다.
FAQ
섹션 제목: “FAQ”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를 얻을 수 있습니다.