본문으로 건너뛰기

Template 설계

앞 장에서 StringBuilder로 코드를 조립하는 기본 패턴을 익혔습니다. 그런데 ObservablePortGenerator가 생성하는 코드는 헤더, using 문, 필드, 생성자, 래퍼 메서드, 로깅 메서드 등 여러 구성 요소로 이루어져 있습니다. 이 모든 것을 하나의 메서드에 담으면 수백 줄의 생성 로직이 뒤엉키게 됩니다. 고정 부분(헤더, using 문)과 동적 부분(클래스 이름, 메서드 시그니처)을 명확히 분리하고, 각 구성 요소를 독립된 메서드로 추출하면 생성 코드 자체의 유지보수성이 크게 향상됩니다.

  1. 고정 부분과 동적 부분의 분리 원칙을 이해한다
    • 상수, 패턴, 완전 동적의 세 가지 범주
  2. 계층적 생성 메서드 구조를 파악한다
    • GenerateObservableClassSource → GenerateFields → GenerateMethod 호출 트리
  3. 로깅 메서드 템플릿의 파라미터 수에 따른 분기 전략을 학습한다

생성되는 코드 구조
=================
[고정] 헤더 (auto-generated 주석)
[고정] using 문
[동적] 네임스페이스
[동적] 클래스 선언
[고정] 필드 패턴 (타입만 동적)
[고정] 생성자 패턴 (파라미터만 동적)
[동적] 메서드들 (시그니처와 호출만 동적)
[고정] 로깅 메서드 패턴
GenerateObservableClassSource()
├── Header (상수)
├── Using 문 (상수)
├── Namespace (동적)
├── Class 선언 (동적)
│ ├── GenerateFields()
│ ├── GenerateConstructor()
│ ├── GenerateHelperMethods()
│ └── GenerateMethod() (각 메서드)
│ ├── 시그니처 생성
│ ├── 로깅 호출 생성
│ └── 실제 호출 생성
└── {ClassName}ObservableLoggers 클래스
└── GenerateLoggingMethods() (각 메서드)

namespace Functorium.SourceGenerators.Abstractions;
public static class Constants
{
/// <summary>
/// 생성된 코드의 공통 헤더
/// </summary>
public const string Header = """
// <auto-generated/>
// This code was generated by ObservablePortGenerator.
// Do not modify this file directly.
// Any changes will be overwritten when the code is regenerated.
#nullable enable
""";
}
헤더 구성 요소
=============
1. // <auto-generated/>
- IDE가 생성된 코드임을 인식
- 일부 분석기 경고 비활성화
2. 생성기 정보
- 어떤 도구로 생성되었는지 표시
- 문제 발생 시 추적 용이
3. 수정 금지 경고
- 개발자가 직접 수정하지 않도록 안내
4. #nullable enable
- Nullable 참조 타입 활성화
- 생성된 코드의 일관성 보장

private static string GenerateObservableClassSource(
ObservableClassInfo classInfo,
StringBuilder sb)
{
// 1. 헤더
sb.Append(Header)
.AppendLine();
// 2. Using 문
sb.AppendLine("using System.Diagnostics;")
.AppendLine("using System.Diagnostics.Metrics;")
.AppendLine("using Functorium.Adapters.Observabilities;")
.AppendLine("using Functorium.Adapters.Observabilities.Naming;")
.AppendLine("using Functorium.Abstractions.Observabilities;")
.AppendLine()
.AppendLine("using LanguageExt;")
.AppendLine("using Microsoft.Extensions.Logging;")
.AppendLine("using Microsoft.Extensions.Options;")
.AppendLine();
// 3. 네임스페이스 (동적)
sb.AppendLine($"namespace {classInfo.Namespace};")
.AppendLine();
// 4. 클래스 선언 (동적)
sb.AppendLine($"public class {classInfo.ClassName}Observable : {classInfo.ClassName}")
.AppendLine("{");
// 5. 필드
GenerateFields(sb, classInfo);
// 6. 생성자
GenerateConstructor(sb, classInfo);
// 7. 헬퍼 메서드
GenerateHelperMethods(sb, classInfo);
// 8. 각 메서드
foreach (var method in classInfo.Methods)
{
GenerateMethod(sb, classInfo, method);
}
// 9. 클래스 닫기
sb.AppendLine("}");
// 10. 로깅 확장 클래스
sb.AppendLine($"internal static class {classInfo.ClassName}ObservableLoggers")
.AppendLine("{");
foreach (var method in classInfo.Methods)
{
GenerateLoggingMethods(sb, classInfo, method);
}
sb.AppendLine("}");
return sb.ToString();
}

private static void GenerateFields(StringBuilder sb, ObservableClassInfo classInfo)
{
// 관찰 가능성 필드
sb.AppendLine(" private readonly ActivitySource _activitySource;")
.AppendLine($" private readonly ILogger<{classInfo.ClassName}Observable> _logger;")
.AppendLine()
.AppendLine(" // Metrics")
.AppendLine(" private readonly Counter<long> _requestCounter;")
.AppendLine(" private readonly Counter<long> _responseCounter;")
.AppendLine(" private readonly Histogram<double> _durationHistogram;")
.AppendLine();
// 상수
sb.AppendLine($" private const string RequestHandler = nameof({classInfo.ClassName});")
.AppendLine()
.AppendLine(" private readonly string _requestCategoryLowerCase;")
.AppendLine();
// 로깅 레벨 캐시 (성능 최적화)
sb.AppendLine(" private readonly bool _isDebugEnabled;")
.AppendLine(" private readonly bool _isInformationEnabled;")
.AppendLine(" private readonly bool _isWarningEnabled;")
.AppendLine(" private readonly bool _isErrorEnabled;")
.AppendLine();
}

private static void GenerateConstructor(StringBuilder sb, ObservableClassInfo classInfo)
{
// 생성자 시작
sb.Append($" public {classInfo.ClassName}Observable(")
.AppendLine()
.AppendLine(" ActivitySource activitySource,")
.AppendLine($" ILogger<{classInfo.ClassName}Observable> logger,")
.AppendLine(" IMeterFactory meterFactory,")
.Append(" IOptions<OpenTelemetryOptions> openTelemetryOptions");
// 부모 클래스 파라미터 (동적)
string baseParams = GenerateBaseConstructorParameters(
classInfo.BaseConstructorParameters);
if (!string.IsNullOrEmpty(baseParams))
{
sb.Append(baseParams);
}
sb.Append(")");
// 부모 생성자 호출 (동적)
string baseCall = GenerateBaseConstructorCall(
classInfo.BaseConstructorParameters);
if (!string.IsNullOrEmpty(baseCall))
{
sb.AppendLine()
.Append(baseCall);
}
// 생성자 본문
sb.AppendLine()
.AppendLine(" {")
.AppendLine(" global::System.ArgumentNullException.ThrowIfNull(activitySource);")
.AppendLine(" global::System.ArgumentNullException.ThrowIfNull(meterFactory);")
.AppendLine(" global::System.ArgumentNullException.ThrowIfNull(openTelemetryOptions);")
.AppendLine()
.AppendLine(" _activitySource = activitySource;")
.AppendLine(" _logger = logger;")
.AppendLine()
.AppendLine(" // RequestCategory 캐싱")
.AppendLine(" _requestCategoryLowerCase = this.RequestCategory?.ToLowerInvariant()")
.AppendLine(" ?? ObservabilityNaming.Categories.Unknown;")
.AppendLine()
.AppendLine(" // Meter 및 Metrics 초기화")
.AppendLine(" string serviceNamespace = openTelemetryOptions.Value.ServiceNamespace;")
.AppendLine(" string meterName = $\"{serviceNamespace}.adapter.{_requestCategoryLowerCase}\";")
.AppendLine(" var meter = meterFactory.Create(meterName);")
.AppendLine(" _requestCounter = meter.CreateCounter<long>(...);")
.AppendLine(" _responseCounter = meter.CreateCounter<long>(...);")
.AppendLine(" _durationHistogram = meter.CreateHistogram<double>(...);")
.AppendLine()
.AppendLine(" _isDebugEnabled = logger.IsEnabled(LogLevel.Debug);")
.AppendLine(" _isInformationEnabled = logger.IsEnabled(LogLevel.Information);")
.AppendLine(" _isWarningEnabled = logger.IsEnabled(LogLevel.Warning);")
.AppendLine(" _isErrorEnabled = logger.IsEnabled(LogLevel.Error);")
.AppendLine(" }")
.AppendLine();
}

private static void GenerateMethod(
StringBuilder sb,
ObservableClassInfo classInfo,
MethodInfo method)
{
// 1. 메서드 시그니처
string actualReturnType = ExtractActualReturnType(method.ReturnType);
sb.AppendLine($" public override {method.ReturnType} {method.Name}(");
for (int i = 0; i < method.Parameters.Count; i++)
{
var param = method.Parameters[i];
var comma = i < method.Parameters.Count - 1 ? "," : "";
sb.AppendLine($" {param.Type} {param.Name}{comma}");
}
// 2. FinT.lift + ExecuteWithSpan 패턴
string arguments = string.Join(", ", method.Parameters.Select(p => p.Name));
sb.AppendLine(" ) =>")
.AppendLine($" global::LanguageExt.FinT.lift<global::LanguageExt.IO, {actualReturnType}>(")
.AppendLine(" (from result in ExecuteWithSpan(")
.AppendLine($" requestHandler: RequestHandler,")
.AppendLine($" requestHandlerMethod: nameof({method.Name}),")
.AppendLine($" operation: FinTToIO(base.{method.Name}({arguments})),")
.AppendLine($" requestLog: () => AdapterRequestLog_..._{method.Name}(...),")
.AppendLine($" responseLogSuccess: AdapterResponseSuccessLog_..._{method.Name},")
.AppendLine($" responseLogFailure: AdapterResponseFailureLog_..._{method.Name},")
.AppendLine(" startTimestamp: ElapsedTimeCalculator.GetCurrentTimestamp())")
.AppendLine($" select result).Map(r => global::LanguageExt.Fin.Succ(r)));")
.AppendLine();
}
// 생성된 메서드
public override FinT<IO, User> GetUserAsync(
int userId
) =>
global::LanguageExt.FinT.lift<global::LanguageExt.IO, User>(
(from result in ExecuteWithSpan(
requestHandler: RequestHandler,
requestHandlerMethod: nameof(GetUserAsync),
operation: FinTToIO(base.GetUserAsync(userId)),
requestLog: () => AdapterRequestLog_UserRepository_GetUserAsync(RequestHandler, nameof(GetUserAsync), userId),
responseLogSuccess: AdapterResponseSuccessLog_UserRepository_GetUserAsync,
responseLogFailure: AdapterResponseFailureLog_UserRepository_GetUserAsync,
startTimestamp: ElapsedTimeCalculator.GetCurrentTimestamp())
select result).Map(r => global::LanguageExt.Fin.Succ(r)));

private static void GenerateLoggingMethods(
StringBuilder sb,
ObservableClassInfo classInfo,
MethodInfo method)
{
int paramCount = method.Parameters.Count;
// 기본 파라미터 4개 + 메서드 파라미터
// LoggerMessage.Define은 최대 6개 파라미터만 지원
if (paramCount <= 2)
{
// 고성능 로깅 (LoggerMessage.Define 사용)
GenerateHighPerformanceLogging(sb, classInfo, method);
}
else
{
// 폴백 로깅 (일반 로깅 사용)
GenerateFallbackLogging(sb, classInfo, method);
}
}

생성기는 Debug/Information 멀티 레벨 로깅을 생성합니다:

로그 레벨내용용도
Debug파라미터 값, 결과 값 포함상세 디버깅
Information핸들러/메서드명만운영 모니터링
Warning예상 에러 (Expected)비즈니스 에러 추적
Error시스템 에러 (Exceptional)장애 감지
internal static class UserRepositoryObservableLoggers
{
// ===== LoggerMessage.Define delegates for GetUserAsync =====
// 요청 로깅 (Information - 핸들러명만)
private static readonly Action<ILogger, string, string, string, string, Exception?> _logAdapterRequest_UserRepository_GetUserAsync =
LoggerMessage.Define<string, string, string, string>(
LogLevel.Information,
ObservabilityNaming.EventIds.Adapter.AdapterRequest,
"{request.layer} {request.category} {request.handler}.{request.handler.method} requesting");
// 요청 로깅 (Debug - 파라미터 포함, 고성능)
private static readonly Action<ILogger, string, string, string, string, int, Exception?> _logAdapterRequestDebug_UserRepository_GetUserAsync =
LoggerMessage.Define<string, string, string, string, int>(
LogLevel.Debug,
ObservabilityNaming.EventIds.Adapter.AdapterRequest,
"{request.layer} {request.category} {request.handler}.{request.handler.method} requesting with {request.params.userid}");
// 응답 로깅 (Information - 상태만)
private static readonly Action<ILogger, string, string, string, string, string, double, Exception?> _logAdapterResponseSuccess_UserRepository_GetUserAsync =
LoggerMessage.Define<string, string, string, string, string, double>(
LogLevel.Information,
ObservabilityNaming.EventIds.Adapter.AdapterResponseSuccess,
"{request.layer} {request.category} {request.handler}.{request.handler.method} responded {response.status} in {response.elapsed:0.0000} s");
// 응답 로깅 (Debug - 결과 포함, 고성능)
private static readonly Action<ILogger, string, string, string, string, string, double, User, Exception?> _logAdapterResponseSuccessDebug_UserRepository_GetUserAsync =
LoggerMessage.Define<string, string, string, string, string, double, User>(
LogLevel.Debug,
ObservabilityNaming.EventIds.Adapter.AdapterResponseSuccess,
"{request.layer} {request.category} {request.handler}.{request.handler.method} responded {response.status} in {response.elapsed:0.0000} s with {response.result}");
// 확장 메서드: LogAdapterRequestDebug_UserRepository_GetUserAsync
public static void LogAdapterRequestDebug_UserRepository_GetUserAsync(
this ILogger logger, string requestLayer, string requestCategory,
string requestHandler, string requestHandlerMethod, int userId) { ... }
// 확장 메서드: LogAdapterRequest_UserRepository_GetUserAsync
public static void LogAdapterRequest_UserRepository_GetUserAsync(
this ILogger logger, string requestLayer, string requestCategory,
string requestHandler, string requestHandlerMethod) { ... }
// 확장 메서드: LogAdapterResponseSuccessDebug_UserRepository_GetUserAsync
public static void LogAdapterResponseSuccessDebug_UserRepository_GetUserAsync(
this ILogger logger, string requestLayer, string requestCategory,
string requestHandler, string requestHandlerMethod,
string status, User result, double elapsed) { ... }
// 확장 메서드: LogAdapterResponseSuccess_UserRepository_GetUserAsync
public static void LogAdapterResponseSuccess_UserRepository_GetUserAsync(
this ILogger logger, string requestLayer, string requestCategory,
string requestHandler, string requestHandlerMethod,
string status, double elapsed) { ... }
// 확장 메서드: LogAdapterResponseWarning_UserRepository_GetUserAsync
// 확장 메서드: LogAdapterResponseError_UserRepository_GetUserAsync
}

템플릿 설계의 핵심은 “무엇이 고정이고 무엇이 동적인가”를 명확히 구분하는 것입니다. 고정 부분은 상수나 Raw String Literals로, 패턴 부분은 독립된 생성 메서드로, 완전 동적 부분은 ObservableClassInfoMethodInfo에서 추출한 데이터로 채웁니다. 이 분리 덕분에 새로운 관찰 가능성 필드나 로깅 패턴이 추가되더라도 해당 메서드만 수정하면 됩니다.

템플릿 부분고정/동적설명
헤더고정auto-generated 주석
Using 문고정필요한 네임스페이스
네임스페이스동적원본 클래스와 동일
클래스 선언동적원본 + Observable 접미사
필드패턴타입만 동적
생성자패턴파라미터만 동적
메서드패턴시그니처, 호출만 동적
로깅패턴파라미터 수에 따라 분기

Q1: 고정 부분과 동적 부분을 왜 분리해야 하나요?

섹션 제목: “Q1: 고정 부분과 동적 부분을 왜 분리해야 하나요?”

A: 분리하지 않으면 수백 줄의 StringBuilder 체이닝이 하나의 메서드에 뒤섞여 유지보수가 어려워집니다. 고정 부분(헤더, using 문)은 상수로, 동적 부분(클래스명, 메서드 시그니처)은 ObservableClassInfo에서 추출한 데이터로 채우면, 새로운 필드나 로깅 패턴이 추가되더라도 해당 생성 메서드만 수정하면 됩니다.

Q2: GenerateObservableClassSource에서 StringBuilder를 파라미터로 받는 이유는 무엇인가요?

섹션 제목: “Q2: GenerateObservableClassSource에서 StringBuilder를 파라미터로 받는 이유는 무엇인가요?”

A: 여러 클래스에 대해 순차적으로 코드를 생성할 때 StringBuilderClear()로 재사용하기 위해서입니다. 메서드 내부에서 매번 새로 생성하면 GC 압력이 증가하므로, 호출자가 하나의 인스턴스를 만들어 전달하고 Clear()로 초기화하는 방식이 메모리 효율적입니다.

Q3: 로깅 메서드 템플릿에서 파라미터 수에 따라 분기하는 기준은 무엇인가요?

섹션 제목: “Q3: 로깅 메서드 템플릿에서 파라미터 수에 따라 분기하는 기준은 무엇인가요?”

A: LoggerMessage.Define이 최대 6개의 제네릭 타입 파라미터만 지원하기 때문입니다. 기본 필드 4개(layer, category, handler, method)에 메서드 파라미터를 더한 총합이 6개 이하이면 고성능 LoggerMessage.Define 경로를, 초과하면 logger.LogDebug() 폴백 경로를 사용합니다.

Q4: 계층적 생성 메서드 구조에서 각 메서드의 역할을 어떻게 구분하나요?

섹션 제목: “Q4: 계층적 생성 메서드 구조에서 각 메서드의 역할을 어떻게 구분하나요?”

A: 생성할 코드의 구조적 단위를 기준으로 분리합니다. GenerateFields()는 필드 선언, GenerateConstructor()는 생성자, GenerateMethod()는 개별 메서드 오버라이드, GenerateLoggingMethods()는 로깅 delegate와 확장 메서드를 각각 담당합니다. 이렇게 하면 특정 부분의 생성 로직만 독립적으로 수정하거나 테스트할 수 있습니다.


템플릿의 전체 구조를 이해했습니다. 그런데 템플릿에서 동적으로 채워지는 요소 중 하나가 네임스페이스입니다. 생성된 코드가 원본 클래스와 같은 네임스페이스에 위치해야 상속이 올바르게 작동하고, 서로 다른 네임스페이스에 같은 이름의 클래스가 있을 때 파일명 충돌도 방지해야 합니다. 다음 장에서 이 처리 기법을 살펴봅니다.

11. 네임스페이스 처리