본문으로 건너뛰기

Adapter 연결 -- Pipeline과 DI

이 문서는 Adapter의 Pipeline 생성, DI 등록, Options 패턴을 다루는 가이드입니다. Port 정의는 12-ports.md, Adapter 구현은 13-adapters.md, 단위 테스트는 14b-adapter-testing.md를 참조하세요.

“Use Case, 검증, 트랜잭션 등 횡단 관심사를 어떻게 일관되게 조합할 것인가?” “각 Adapter마다 로깅·메트릭·트레이싱 코드를 반복 작성해야 하는가?” “Pipeline Observable 클래스를 DI에 어떻게 등록하고, Options 패턴으로 구성 값을 주입하는가?”

Pipeline은 횡단 관심사(cross-cutting concerns)를 처리하는 Functorium의 핵심 메커니즘입니다. Source Generator가 생성한 Observable wrapper를 통해 로깅, 메트릭, 트레이싱을 자동으로, 일관되게 적용합니다. 이 문서는 Pipeline 생성 확인부터 DI 등록, Options 패턴 활용까지 Adapter 연결의 전체 과정을 다룹니다.

이 문서를 통해 다음을 학습합니다:

  1. Mediator Pipeline 구성 — Source Generator가 생성하는 Observable 래퍼의 구조와 자동 제공 기능
  2. DI 등록 패턴RegisterScopedObservablePort로 Pipeline을 Port 인터페이스에 매핑하는 방법
  3. Options 패턴 활용OptionsConfigurator를 사용한 강타입 설정 바인딩과 시작 시 검증

이 문서를 이해하기 위해 다음 개념에 대한 기본적인 이해가 필요합니다:

Adapter에 관측성 코드를 직접 작성하지 마십시오. [GenerateObservablePort] + DI 등록 한 줄이면 로깅, 메트릭, 트레이싱이 자동으로 일관되게 적용됩니다.

// DI 등록 (Pipeline -> Port 인터페이스)
services.RegisterScopedObservablePort<IProductRepository, ProductRepositoryInMemoryObservable>();
// HttpClient 등록 (External API)
services.AddHttpClient<ExternalPricingApiServiceObservable>(client =>
client.BaseAddress = new Uri(options.BaseUrl));
Terminal window
# Pipeline 생성 파일 확인
ls {Project}/obj/GeneratedFiles/Functorium.SourceGenerators/.../*.g.cs
  1. [GenerateObservablePort] 적용 후 빌드 -> obj/GeneratedFiles/에 Pipeline 클래스 생성 확인
  2. Registration 클래스 생성 (Adapter{Layer}Registration)
  3. RegisterScopedObservablePort<IPort, ObservableAdapter>() 호출로 DI 등록
  4. Program.cs에서 Registration 호출
개념설명
Pipeline 클래스Source Generator가 자동 생성하는 Observability 래퍼 ({ClassName}Observable)
RegisterScopedObservablePortPipeline을 Port 인터페이스로 DI 등록하는 확장 메서드
Registration 클래스Adapter 프로젝트별 DI 등록을 모아둔 정적 클래스
Options 패턴OptionsConfigurator<T>로 설정 값을 강타입 바인딩

참고: UsecaseCachingPipelineIMemoryCache에 의존합니다. UseCaching() 사용 시 services.AddMemoryCache()를 DI에 등록해야 합니다.

먼저 Pipeline이 어떻게 생성되는지 확인한 뒤, DI 등록과 Options 패턴까지 순서대로 진행합니다.


[GenerateObservablePort] 어트리뷰트가 적용된 Adapter를 빌드하면, Source Generator가 자동으로 Pipeline 클래스를 생성합니다.

[GenerateObservablePort] 속성을 클래스에 적용하면 Source Generator가 Pipeline 래퍼 클래스를 자동 생성합니다.

위치: Functorium.Adapters.SourceGenerators.GenerateObservablePortAttribute

[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class GenerateObservablePortAttribute : Attribute { }

변환 다이어그램:

원본 클래스 생성된 Pipeline 클래스
┌─────────────────────────────┐ ┌─────────────────────────────────────┐
│ [GenerateObservablePort] │ │ ProductRepositoryInMemoryObservable │
│ ProductRepositoryInMemory │ ──► │ : ProductRepositoryInMemory │
│ : IProductRepository │ │ │
├─────────────────────────────┤ ├─────────────────────────────────────┤
│ GetById(Guid id) │ │ override GetById(Guid id) │
│ GetAll() │ │ + Activity Span 생성 │
│ Create(Product) │ │ + 로깅 (Debug/Info/Error) │
└─────────────────────────────┘ │ + 메트릭 (Counter/Histogram) │
│ + 에러 분류 │
└─────────────────────────────────────┘

빌드 후 다음 경로에서 생성된 파일을 확인합니다.

{Project}/obj/GeneratedFiles/
└── Functorium.SourceGenerators/
└── Functorium.SourceGenerators.Generators.ObservablePortGenerator.ObservablePortGenerator/
└── {Namespace}.{ClassName}Observable.g.cs

예시:

LayeredArch.Adapters.Persistence/obj/GeneratedFiles/.../
└── Repositories.ProductRepositoryInMemoryObservable.g.cs
LayeredArch.Adapters.Infrastructure/obj/GeneratedFiles/.../
└── ExternalApis.ExternalPricingApiServiceObservable.g.cs
OrderService/obj/GeneratedFiles/.../
└── Messaging.RabbitMqInventoryMessagingObservable.g.cs

생성된 Pipeline 클래스는 다음과 같은 구조를 가집니다.

// 자동 생성 코드 (예시 구조)
public class ProductRepositoryInMemoryObservable : ProductRepositoryInMemory
{
private readonly ActivitySource _activitySource;
private readonly ILogger<ProductRepositoryInMemoryObservable> _logger;
private readonly Histogram<double> _durationHistogram;
// ... 기타 Observability 필드
public ProductRepositoryInMemoryObservable(
ActivitySource activitySource,
ILogger<ProductRepositoryInMemoryObservable> logger,
IMeterFactory meterFactory,
IOptions<OpenTelemetryOptions> openTelemetryOptions
/* + 원본 생성자의 매개변수들 */)
: base(/* 원본 생성자 매개변수 */)
{
// Observability 초기화
}
public override FinT<IO, Product> Create(Product product)
{
// Activity 시작 -> 원본 메서드 호출 -> 로깅/메트릭 기록
return /* 래핑된 호출 */;
}
}

핵심 구조:

  • 원본 Adapter 클래스를 상속 (ProductRepositoryInMemoryObservable : ProductRepositoryInMemory)
  • virtual 메서드를 override하여 Observability 로직 추가
  • 생성자에 ActivitySource, ILogger, IMeterFactory 등 Observability 의존성 주입
  • 원본 생성자 매개변수도 함께 전달

Pipeline은 다음 관찰성 기능을 자동으로 제공합니다. 모든 필드는 OpenTelemetry 시맨틱 규칙과의 일관성을 위해 snake_case + dot 표기법을 사용합니다.

다음 표는 Pipeline이 자동 제공하는 4가지 관찰성 기능을 정리한 것입니다.

기능설명주요 Tag/Field
분산 트레이싱Span 자동 생성 ({layer} {category} {handler}.{method})request.layer, request.category.name, request.handler.name, request.handler.method, response.status, response.elapsed
구조화된 로깅요청/응답/에러 자동 로깅 (EventId 2001-2004)Request(Info/Debug), Success(Info/Debug), Warning(Expected), Error(Exceptional)
메트릭 수집Counter + Histogram 자동 기록adapter.{category}.requests, adapter.{category}.responses, adapter.{category}.duration
에러 분류Expected/Exceptional/Aggregate 자동 분류error.type, error.code

로그 레벨 규칙:

이벤트EventId로그 레벨조건
Request2001Information / DebugDebug는 파라미터 값 포함
Response Success2002Information / DebugDebug는 반환값 포함
Response Warning2003Warningerror.IsExpected == true
Response Error2004Errorerror.IsExceptional == true

에러 분류 규칙:

Error Caseerror.typeerror.code로그 레벨
IHasErrorCode + IsExpected"expected"에러 코드Warning
IHasErrorCode + IsExceptional"exceptional"에러 코드Error
ManyErrors"aggregate"첫 번째 에러 코드Warning/Error
Expected (LanguageExt)"expected"타입 이름Warning
Exceptional (LanguageExt)"exceptional"타입 이름Error

상세 사양: 트레이싱 Tag 구조, 로그 Message Template, 메트릭 Instrument 정의 등 상세 내용은 08-observability.md를 참조하세요.

Pipeline 생성 과정에서 발생할 수 있는 빌드 에러와 해결 방법을 정리한 표입니다.

에러증상원인해결
CS0506cannot override because it is not virtual메서드에 virtual 키워드 누락모든 인터페이스 메서드에 virtual 추가
Pipeline 클래스 미생성obj/GeneratedFiles/에 파일 없음[GenerateObservablePort] 어트리뷰트 누락클래스에 어트리뷰트 추가
생성자 매개변수 충돌Source Generator 에러생성자 매개변수 타입이 Observability 타입과 충돌생성자 매개변수에 고유 타입 사용
네임스페이스 누락using 에러Functorium 패키지 참조 누락Functorium.SourceGenerators NuGet 패키지 추가

Pipeline이 정상적으로 생성되었다면, 이제 DI 컨테이너에 등록하여 런타임에서 사용할 수 있게 합니다.


생성된 Pipeline 클래스를 DI 컨테이너에 등록합니다.

위치 규칙: {Project}.Adapters.{Layer}/Abstractions/Registrations/

네이밍 규칙: Adapter{Layer}Registration

RegisterScopedObservablePort로 Pipeline Observable 클래스를 Port 인터페이스에 매핑하는 패턴을 주목하세요.

// 파일: {Adapters.Persistence}/Abstractions/Registrations/AdapterPersistenceRegistration.cs
using Functorium.Adapters.Abstractions.Registrations;
public static class AdapterPersistenceRegistration
{
public static IServiceCollection RegisterAdapterPersistence(
this IServiceCollection services)
{
// Pipeline 등록
services.RegisterScopedObservablePort<
IProductRepository,
ProductRepositoryInMemoryObservable>();
return services;
}
public static IApplicationBuilder UseAdapterPersistence(
this IApplicationBuilder app)
{
return app;
}
}

참조: Tests.Hosts/01-SingleHost/LayeredArch.Adapters.Persistence/Abstractions/Registrations/AdapterPersistenceRegistration.cs

참고: Adapter에 Options 패턴이 필요한 경우, Registration 메서드에 IConfiguration 파라미터를 추가합니다. 4.6 Options 패턴 참조.

// 단일 인터페이스 등록
services.RegisterScopedObservablePort<
IProductRepository, // Port 인터페이스
ProductRepositoryInMemoryObservable>(); // 생성된 Pipeline
// InMemory 환경
services.RegisterScopedObservablePort<IUnitOfWork, UnitOfWorkInMemoryObservable>();
// EF Core 환경
services.RegisterScopedObservablePort<IUnitOfWork, UnitOfWorkEfCoreObservable>();

External API Adapter는 HttpClient와 Pipeline 두 가지를 등록해야 합니다.

// 1단계: HttpClient 등록
services.AddHttpClient<ExternalPricingApiServiceObservable>(client =>
{
client.BaseAddress = new Uri(configuration["ExternalApi:BaseUrl"]
?? "https://api.example.com");
client.Timeout = TimeSpan.FromSeconds(30);
});
// 2단계: Pipeline 등록
services.RegisterScopedObservablePort<
IExternalPricingService,
ExternalPricingApiServiceObservable>();

참고: HttpClient는 Observable 클래스 타입으로 등록합니다. Observable이 원본 Adapter를 상속하므로 생성자의 HttpClient 매개변수를 그대로 받습니다.

HttpClient Lifetime 관리: AddHttpClient<T>()는 내부적으로 IHttpClientFactory를 사용하여 HttpClient의 수명을 관리합니다. HttpClient를 직접 new하면 소켓 고갈(socket exhaustion) 문제가 발생할 수 있으므로 반드시 IHttpClientFactory를 통해 생성해야 합니다. IHttpClientFactory는 내부 HttpMessageHandler의 풀링과 수명 관리(기본 2분 순환)를 자동으로 처리하여 DNS 변경 반영과 커넥션 풀링을 최적화합니다.

// Pipeline 등록 (MessageBus는 별도 등록 필요)
services.RegisterScopedObservablePort<
IInventoryMessaging,
RabbitMqInventoryMessagingObservable>();

참조: Tutorials/Cqrs06Services/Src/OrderService/Program.cs (57행)

// InMemory Provider -- Query Adapter Pipeline 등록
services.RegisterScopedObservablePort<
IProductQuery,
ProductQueryInMemoryObservable>();
// Sqlite Provider -- Dapper Query Adapter Pipeline 등록
services.RegisterScopedObservablePort<
IProductQuery,
ProductQueryDapperObservable>();

참고: Query Adapter는 Repository와 동일한 RegisterScopedObservablePort API를 사용합니다. Provider 분기 패턴(4.6)에서 InMemory는 InMemory Query Adapter를, Sqlite는 Dapper Query Adapter를 등록합니다.

Ctx Enricher는 ICustomUsecasePipeline이 아니므로 별도로 DI에 등록합니다. UseObservability() 사용 시 CtxEnricher가 자동 활성화됩니다.

Usecase Ctx Enricher — Source Generator가 자동 생성한 Enricher:

services.AddScoped<
IUsecaseCtxEnricher<CreateOrderCommand.Request, FinResponse<CreateOrderCommand.Response>>,
CreateOrderCommandRequestCtxEnricher>();

Domain Event Ctx EnricherDomainEventCtxEnricherGeneratorIDomainEventHandler<T> 감지로 자동 생성한 Enricher:

services.AddScoped<
IDomainEventCtxEnricher<Order.CreatedEvent>,
OrderCreatedEventCtxEnricher>();
services.AddScoped<
IDomainEventCtxEnricher<Customer.CreatedEvent>,
CustomerCreatedEventCtxEnricher>();

RegisterDomainEventPublisher()IDomainEventPublisher, IDomainEventCollector, ObservableDomainEventNotificationPublisher 3개를 DI에 등록합니다. Handler 관점 관찰 가능성을 활성화하려면 NotificationPublisherType도 설정해야 합니다:

services.AddMediator(options =>
{
options.ServiceLifetime = ServiceLifetime.Scoped;
options.NotificationPublisherType = typeof(ObservableDomainEventNotificationPublisher);
});
services.RegisterDomainEventPublisher();

참조: 도메인 이벤트 §핸들러 등록, Logging 매뉴얼 §IDomainEventCtxEnricher

하나의 구현 클래스가 여러 인터페이스를 구현하는 경우:

// 2개 인터페이스 (Scoped 예시 -- Transient/Singleton도 동일한 For 패턴 지원)
services.RegisterScopedObservablePortFor<IReadRepository, IWriteRepository, ProductRepositoryObservable>();
// 3개 인터페이스
services.RegisterScopedObservablePortFor<IService1, IService2, IService3, MyServiceObservable>();
// 4개 이상 인터페이스 (params Type[] 오버로드)
services.RegisterScopedObservablePortFor<MyServiceObservable>(
typeof(IService1), typeof(IService2), typeof(IService3), typeof(IService4));

참고: For 접미사 메서드는 Scoped, Transient, Singleton 세 가지 Lifetime 모두 지원합니다 (예: RegisterTransientObservablePortFor, RegisterSingletonObservablePortFor).

아래 표는 Lifetime 별 사용 시점과 주의사항을 정리한 것입니다.

Lifetime사용 시점주의사항
Scoped (기본)Repository, External API, MessagingHTTP 요청 내 동일 인스턴스 공유
Transient상태 없는 가벼운 Adapter매번 새 인스턴스 생성 (메모리 주의)
Singleton스레드 안전한 읽기 전용 Adapter상태 변경 불가, 스레드 안전성 보장 필요

권장: 특별한 이유가 없으면 Scoped를 사용하세요.

등록 API 요약:

등록 APILifetime용도
RegisterScopedObservablePort<TService, TImpl>()ScopedHTTP 요청당 1개 (기본 권장)
RegisterTransientObservablePort<TService, TImpl>()Transient매 요청마다 새 인스턴스
RegisterSingletonObservablePort<TService, TImpl>()Singleton애플리케이션 전체 1개
Register{Lifetime}ObservablePortFor<T1, T2, TImpl>()Scoped/Transient/Singleton2개 인터페이스 -> 1개 구현체
Register{Lifetime}ObservablePortFor<T1, T2, T3, TImpl>()Scoped/Transient/Singleton3개 인터페이스 -> 1개 구현체
Register{Lifetime}ObservablePortFor<TImpl>(params Type[])Scoped/Transient/Singleton4개 이상 인터페이스 -> 1개 구현체

참조: Src/Functorium/Abstractions/Registrations/ObservablePortRegistration.cs

Program.cs에서 레이어별 Registration을 호출합니다.

// 파일: {Host}/Program.cs
var builder = WebApplication.CreateBuilder(args);
// 레이어별 서비스 등록
builder.Services
.RegisterAdapterPresentation()
.RegisterAdapterPersistence(builder.Configuration) // Options 패턴 사용 시 IConfiguration 전달
.RegisterAdapterInfrastructure(builder.Configuration);
var app = builder.Build();
app.UseAdapterInfrastructure()
.UseAdapterPersistence()
.UseAdapterPresentation();
app.Run();

참조: Tests.Hosts/01-SingleHost/LayeredArch/Program.cs

핵심 포인트:

  • RegisterAdapter{Layer}(): IServiceCollection 확장 메서드로 서비스 등록
  • UseAdapter{Layer}(): IApplicationBuilder 확장 메서드로 미들웨어 설정
  • 등록 순서는 의존성 방향에 따라 결정 (Presentation -> Persistence -> Infrastructure)
  • Options 패턴을 사용하는 Adapter는 IConfiguration 파라미터를 받음 (4.6 참조)

참고: 등록 순서는 DI 컨테이너의 의존성 해석과 무관하며, 가독성을 위해 도메인 -> 어댑터 -> 인프라 순서를 권장한다.

참고: 등록 순서의 근거와 환경별 구성 분기는 01-project-structure.md — Host 프로젝트를 참조하세요.

DI 등록의 기본 패턴을 이해했다면, 이제 Adapter에 구성 옵션을 주입하는 방법을 알아봅니다. OptionsConfigurator 패턴을 사용합니다. appsettings.json에서 설정을 읽고, 시작 시 FluentValidation으로 검증하며, StartupLogger에 자동 출력합니다.

// 파일: {Adapters.Persistence}/Abstractions/Options/PersistenceOptions.cs
using FluentValidation;
using Functorium.Adapters.Observabilities.Loggers;
using Microsoft.Extensions.Logging;
public sealed class PersistenceOptions : IStartupOptionsLogger
{
public const string SectionName = "Persistence"; // appsettings.json 섹션 이름
public string Provider { get; set; } = "InMemory";
public string ConnectionString { get; set; } = "Data Source=layeredarch.db";
public static readonly string[] SupportedProviders = ["InMemory", "Sqlite"];
// IStartupOptionsLogger -- 시작 시 자동 로깅
public void LogConfiguration(ILogger logger)
{
const int labelWidth = 20;
logger.LogInformation("Persistence Configuration");
logger.LogInformation(" {Label}: {Value}", "Provider".PadRight(labelWidth), Provider);
if (Provider == "Sqlite")
logger.LogInformation(" {Label}: {Value}", "ConnectionString".PadRight(labelWidth), ConnectionString);
}
// FluentValidation -- 시작 시 자동 검증
public sealed class Validator : AbstractValidator<PersistenceOptions>
{
public Validator()
{
RuleFor(x => x.Provider)
.NotEmpty()
.Must(p => SupportedProviders.Contains(p))
.WithMessage($"{nameof(Provider)} must be one of: {string.Join(", ", SupportedProviders)}");
RuleFor(x => x.ConnectionString)
.NotEmpty()
.When(x => x.Provider == "Sqlite")
.WithMessage($"{nameof(ConnectionString)} is required when Provider is 'Sqlite'.");
}
}
}

참조: Tests.Hosts/01-SingleHost/LayeredArch.Adapters.Persistence/Abstractions/Options/PersistenceOptions.cs

  • sealed class로 선언
  • SectionName 상수 정의 (appsettings.json 섹션 이름)
  • IStartupOptionsLogger 구현 (LogConfiguration 메서드)
  • 중첩 Validator 클래스 (AbstractValidator<TOptions> 상속)
  • 위치: {Adapter}/Abstractions/Options/
// Options 등록 (1줄로 완료)
services.RegisterConfigureOptions<PersistenceOptions, PersistenceOptions.Validator>(
PersistenceOptions.SectionName);

RegisterConfigureOptions가 자동으로 처리하는 항목:

항목설명
Options 바인딩appsettings.jsonSectionName -> Options 프로퍼티 매핑 (BindConfiguration)
IValidator<TOptions> 등록TValidator를 Scoped로 DI 등록
FluentValidation 연결AddValidateFluentValidation()으로 IValidateOptions<TOptions> 연결
ValidateOnStart()프로그램 시작 시 검증 (실패 시 즉시 종료)
IStartupOptionsLogger 자동 등록typeof(IStartupOptionsLogger).IsAssignableFrom(typeof(TOptions)) 체크로, 구현 시 StartupLogger에 자동 출력

API 시그니처:

public static OptionsBuilder<TOptions> RegisterConfigureOptions<TOptions, TValidator>(
this IServiceCollection services,
string configurationSectionName)
where TOptions : class
where TValidator : class, IValidator<TOptions>
  • 반환 타입: OptionsBuilder<TOptions> (추가 체이닝 가능)
  • 제약 조건: TOptions : class, TValidator : class, IValidator<TOptions>

참조: Src/Functorium.Adapters/Options/OptionsConfigurator.cs

Options 클래스가 IStartupOptionsLogger를 구현하면, RegisterConfigureOptions가 자동으로 IStartupOptionsLogger로 DI에 등록합니다. StartupLoggerIEnumerable<IStartupOptionsLogger>를 주입받아 애플리케이션 시작 시 각 Options의 LogConfiguration()을 호출합니다.

public interface IStartupOptionsLogger
{
void LogConfiguration(ILogger logger);
}

로그 출력 형식:

대주제 Configuration
<= 빈 줄
세부주제 1
레이블1: 값1
레이블2: 값2

규칙:

  • PadRight(20)으로 레이블 정렬
  • 민감 정보(비밀번호, API 키)는 마스킹
  • 구조화된 로깅 템플릿 {Label}: {Value} 사용

Options 클래스 내부에 중첩 Validator 클래스를 정의합니다.

public sealed class Validator : AbstractValidator<PersistenceOptions>
{
public Validator()
{
RuleFor(x => x.Provider)
.NotEmpty()
.Must(p => SupportedProviders.Contains(p))
.WithMessage($"{nameof(Provider)} must be one of: {string.Join(", ", SupportedProviders)}");
RuleFor(x => x.ConnectionString)
.NotEmpty()
.When(x => x.Provider == "Sqlite")
.WithMessage($"{nameof(ConnectionString)} is required when Provider is 'Sqlite'.");
}
}

주요 검증 메서드:

메서드용도
NotEmpty()필수 값
InclusiveBetween()범위 검증
Must()커스텀 규칙 (SmartEnum 등)
When()조건부 검증
Matches()정규식 검증

Options 값에 따라 다른 Adapter 구현체를 DI에 등록하는 패턴입니다.

public static IServiceCollection RegisterAdapterPersistence(
this IServiceCollection services,
IConfiguration configuration)
{
// 1. Options 등록
services.RegisterConfigureOptions<PersistenceOptions, PersistenceOptions.Validator>(
PersistenceOptions.SectionName);
// 2. 시작 시점에 Provider 읽기
var options = configuration
.GetSection(PersistenceOptions.SectionName)
.Get<PersistenceOptions>() ?? new PersistenceOptions();
// 3. Provider에 따라 분기 등록
switch (options.Provider)
{
case "Sqlite":
services.AddDbContext<LayeredArchDbContext>(opt =>
opt.UseSqlite(options.ConnectionString));
RegisterSqliteRepositories(services); // Command 측: EF Core
RegisterDapperQueries(services, options.ConnectionString); // Query 측: Dapper
break;
case "InMemory":
default:
RegisterInMemoryRepositories(services); // Command + Query 모두 InMemory
break;
}
return services;
}

참조: Tests.Hosts/01-SingleHost/LayeredArch.Adapters.Persistence/Abstractions/Registrations/AdapterPersistenceRegistration.cs

public static IApplicationBuilder UseAdapterPersistence(this IApplicationBuilder app)
{
var options = app.ApplicationServices
.GetRequiredService<IOptions<PersistenceOptions>>().Value;
if (options.Provider == "Sqlite")
{
using var scope = app.ApplicationServices.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LayeredArchDbContext>();
dbContext.Database.EnsureCreated();
}
return app;
}

SectionName <-> JSON 키 매핑:

Options 클래스SectionNameappsettings.json 키
PersistenceOptions"Persistence""Persistence": { ... }
OpenTelemetryOptions"OpenTelemetry""OpenTelemetry": { ... }

규칙: Options 클래스의 SectionName 상수값이 appsettings.json의 최상위 키와 정확히 일치해야 한다.

appsettings.json에서 Options 클래스의 SectionName 상수값과 일치하는 섹션을 정의합니다. 환경별 오버라이드는 ASP.NET Core Configuration 문서를 참조하세요. 통합 테스트 appsettings 설정은 16-testing-library.md를 참조하세요.

Provider 선택지:

ProviderCommand 측Query 측용도
"InMemory"ConcurrentDictionaryInMemory Query Adapter개발/테스트 (기본값)
"Sqlite"EF Core (SQLite)Dapper (SQLite)로컬 영속화

virtual 키워드 누락으로 CS0506 에러

섹션 제목: “virtual 키워드 누락으로 CS0506 에러”

원인: Pipeline 클래스가 원본 클래스를 상속받아 메서드를 override하므로, virtual이 없으면 컴파일 에러가 발생합니다.

해결: 모든 인터페이스 메서드에 virtual 키워드를 추가합니다.

// Good
public virtual FinT<IO, Product> GetById(Guid id) { ... }
// Bad - CS0506
public FinT<IO, Product> GetById(Guid id) { ... }

원인: [GenerateObservablePort] 어트리뷰트가 누락되었거나, dotnet build를 실행하지 않아 Source Generator가 트리거되지 않았습니다.

해결:

  1. 클래스에 [GenerateObservablePort] 어트리뷰트를 추가합니다.
  2. dotnet build를 실행하여 Source Generator를 트리거합니다.
  3. obj/GeneratedFiles/XxxObservable.g.cs가 생성되었는지 확인합니다.

Pipeline DI 미등록 (InvalidOperationException)

섹션 제목: “Pipeline DI 미등록 (InvalidOperationException)”

원인: No service for type 'IXxx' 예외가 발생하며, Pipeline Observable 클래스가 DI에 등록되지 않았습니다.

해결:

// Registration 클래스에서 등록
services.RegisterScopedObservablePort<IXxx, XxxObservable>();

전체 문제-증상-해결 목록은 부록 Quick Reference 체크리스트를 참조하세요.


Q1. 왜 virtual 메서드로 선언해야 하나요?

섹션 제목: “Q1. 왜 virtual 메서드로 선언해야 하나요?”

Pipeline 클래스가 원본 클래스를 상속받아 메서드를 override하기 때문입니다. virtual이 아니면 Pipeline이 메서드를 감쌀 수 없습니다.

// Good
public virtual FinT<IO, Product> GetById(Guid id) { ... }
// Bad - Pipeline이 override 불가
public FinT<IO, Product> GetById(Guid id) { ... }

Q2. FinT<IO, T> 대신 Task<T>를 사용할 수 없나요?

섹션 제목: “Q2. FinT<IO, T> 대신 Task<T>를 사용할 수 없나요?”

Pipeline은 FinT<IO, T> 반환 타입을 기대합니다. 함수형 에러 처리와 합성을 위해 이 타입을 사용합니다.

// 동기 작업
public virtual FinT<IO, Product> GetById(Guid id)
{
return IO.lift(() =>
{
if (_products.TryGetValue(id, out var product))
return Fin.Succ(product);
return Fin.Fail<Product>(Error.New("Not found"));
});
}
// 비동기 작업
public virtual FinT<IO, Product> GetByIdAsync(Guid id)
{
return IO.liftAsync(async () =>
{
var product = await _dbContext.Products.FindAsync(id);
return product is not null
? Fin.Succ(product)
: Fin.Fail<Product>(Error.New("Not found"));
});
}

Q4. 생성자 매개변수가 너무 많으면 어떻게 되나요?

섹션 제목: “Q4. 생성자 매개변수가 너무 많으면 어떻게 되나요?”

Pipeline은 원본 클래스의 생성자 매개변수를 자동으로 포함합니다. 동일한 타입의 매개변수가 여러 개 있으면 Source Generator가 에러를 발생시킵니다.

// Bad - 동일 타입 매개변수 충돌
public ProductRepositoryInMemory(
ILogger<ProductRepositoryInMemory> logger1,
ILogger<ProductRepositoryInMemory> logger2) // 타입 충돌!
// Good - 각 매개변수는 고유한 타입
public ProductRepositoryInMemory(
ILogger<ProductRepositoryInMemory> logger,
IOptions<RepositoryOptions> options)

Q5. 특정 메서드만 Pipeline에서 제외할 수 있나요?

섹션 제목: “Q5. 특정 메서드만 Pipeline에서 제외할 수 있나요?”

네, [ObservablePortIgnore] 어트리뷰트를 사용하면 특정 메서드를 Pipeline 래퍼 생성에서 제외할 수 있습니다.

[GenerateObservablePort]
public class MyAdapter : IMyPort
{
public virtual FinT<IO, Product> GetById(ProductId id) { ... } // Pipeline 래핑됨
[ObservablePortIgnore]
public virtual FinT<IO, Unit> InternalCleanup() { ... } // Pipeline에서 제외
}
  • [ObservablePortIgnore]가 적용된 메서드는 Source Generator가 override 래퍼를 생성하지 않습니다.
  • 해당 메서드 호출 시 로깅, 메트릭, 트레이싱이 기록되지 않습니다.
  • 내부 유틸리티 메서드나 Observability가 불필요한 메서드에 사용합니다.

Q7. RequestCategory 값은 어떻게 정하나요?

섹션 제목: “Q7. RequestCategory 값은 어떻게 정하나요?”

RequestCategory는 Observability Pipeline의 메트릭/트레이싱에서 사용하는 분류 태그입니다. 프레임워크가 정한 예약어는 없으며, 팀 내 일관된 네이밍이 중요합니다.

권장 값용도
"Repository"Aggregate CRUD 영속화
"UnitOfWork"트랜잭션 커밋
"QueryAdapter"읽기 전용 조회 (DTO 직접 반환)
"ExternalApi"외부 HTTP API 호출
"Messaging"메시지 큐 통신

문서설명
04-ddd-tactical-overview.md도메인 모델링 전체 개요
05a-value-objects.mdValue Object 구현 가이드
06b-entity-aggregate-core.mdEntity/Aggregate 핵심 패턴
11-usecases-and-cqrs.md유스케이스 구현 (CQRS Command/Query)
08a-error-system.md에러 시스템: 기초와 네이밍
08b-error-system-domain-app.md에러 시스템: Domain/Application 에러
08c-error-system-adapter-testing.md에러 시스템: Adapter 에러와 테스트
12-ports.mdPort 정의 가이드
13-adapters.mdAdapter 구현 가이드
14b-adapter-testing.mdAdapter 단위 테스트 가이드
15a-unit-testing.md단위 테스트 작성 가이드
08-observability.mdObservability 사양 (트레이싱, 로깅, 메트릭 상세)
01-project-structure.md서비스 프로젝트 구조 가이드

외부 참고: