SOLID 설계 리뷰
에이전트가 C# 클래스 또는 모듈을 각 SOLID 원칙에 따라 구체적인 .NET 관점으로 살펴보고, 스멜, 위반된 원칙 및 최소한의 리팩터링 방안을 제시하도록 안내합니다 — 교과서적인 지적이 아닌 실제 결합도나 변경 비용을 유발하는 위반을 우선시합니다.
사용 시점
- 클래스가 다루기 힘들어질 때 — 하나의 파일에 I/O, 비즈니스 로직, 매핑, 부수 효과가 모두 포함된 경우.
- PR에서 새로운 타입 변형이 도입되고 기존
switch또는if-else체인을 수정해야 할 때. - 생성자가 직접 의존성을
new로 생성하거나DateTime.Now/static헬퍼를 직접 참조할 때. - 인터페이스에 여러 관련 없는 호출자를 만족시키기 위해 멤버가 누적되어 no-op 구현이 강제될 때.
프로세스
- 검토할 클래스/모듈을 식별합니다. 그 책임을 명명합니다. 이름을 설명하는 데 "그리고" 또는 "또는"이 필요하다면 이미 신호입니다.
- 아래 C# 렌즈를 사용하여 각 SOLID 원칙을 순서대로 살펴봅니다. 각각을 별도의 패스로 취급하고, 첫 번째 발견 후 멈추지 않습니다.
- 스멜과 위반된 원칙을 명명합니다. "자체
HttpClient와SqlConnection을 조립하는 뚱뚱한 생성자" → DIP; "NotSupportedException을 던지는 기본 재정의" → LSP. 정확해야 합니다. - C# 메커니즘을 사용한 최소한의 리팩터링을 제안합니다. 타입, 패턴, 또는 DI 등록 변경 사항을 명명합니다 — 단순히 "원칙을 적용하라"가 아닙니다. 일반적인 "SOLID 따르기" 조언은 발견 사항이 아닙니다.
- 실제 영향에 따라 우선순위를 정합니다. EF 지속성과 이메일 발송을 혼합한 갓 서비스는 매 스프린트마다 변경을 유발합니다. 12개의 멤버를 가졌지만 어떤 호출자도 완전히 사용하지 않는
IOrderService는 진정한 ISP 위반입니다. 우연히 두 개의 느슨하게 관련된 사용 사례를 만족하는sealed클래스는 변경 이유가 하나라면 SRP를 위반하지 않습니다. 해를 끼치는 위반과 단순히 불순한 위반을 구분하십시오.
.NET / Azure 점검 사항
SRP — 단일 책임 원칙
- 갓 서비스 / 갓 클래스. EF Core(
_context.SaveChangesAsync), 이메일 전송(_emailSender.SendAsync), 비즈니스 규칙 검증, DTO 매핑을 모두 수행하는 단일 클래스는 여러 변경 이유를 가집니다.IOrderValidator,IOrderRepository(또는 전용 지속성 클래스에서 DbContext 직접 사용),IOrderNotifier를 별도 협력자로 추출하십시오. - 뚱뚱한 컨트롤러.
if/검증 로직을 포함하고, 데이터베이스를 직접 호출하며, 응답 페이로드를 형식화하는 ASP.NET Core 컨트롤러는 세 가지 작업을 수행합니다. 컨트롤러는 조정만 해야 합니다: 서비스 호출, 결과 매핑,IActionResult반환. 비즈니스 규칙은 애플리케이션/도메인 계층에 속합니다. - 타입 이름이나 책임에 "And"가 포함된 경우.
OrderValidationAndPersistenceService는 SRP 위반을 알리는 명명 스멜입니다. 클래스를 "그리고"로만 설명할 수 있다면 분할하십시오. - 여러 변경 이유. 질문: "이메일 공급자를 전환하면 이 클래스가 변경되어야 합니까? 데이터베이스 스키마를 변경하면? 비즈니스 규칙이 변경되면?" "예"가 두 개 이상이면 책임이 두 개 이상입니다.
OCP — 개방-폐쇄 원칙
- 타입 판별자에 대한
switch/if-else증가. 서비스 내부의switch(order.Type)블록은 새로운OrderType열거형 값마다 다시 열려야 하므로 OCP를 위반합니다. 전략으로 대체하십시오:IOrderHandler(또는IOrderProcessor)를 정의하고 타입당 하나의 구현을 등록합니다. 조정자에 주입된IEnumerable<IOrderHandler>를 통해 해결하고handler.Handles(order.Type)으로 디스패치합니다. - 하드 코딩된 동작 확장. 새 할인 규칙, 알림 채널, 또는 내보내기 형식을 추가하려면 새
IDiscountRule또는IExporter를 도입하는 대신 기존 클래스를 편집해야 한다면, 해당 클래스는 수정에 대해 닫혀 있지 않은 것입니다. 두 번째 사례가 발생하기 전에 추상화를 도입하십시오. - 타입 판별자 안티패턴. 처리 클래스 내부의
if (order is PremiumOrder)또는if (typeof(T) == typeof(FooOrder))는 위switch와 동등한 코드 스멜입니다. 다형성 또는 전략 레지스트리가 이를 제거합니다.
LSP — 리스코프 치환 원칙
throw new NotSupportedException()/NotImplementedException()을 던지는 재정의. 기본 계약을 준수할 수 없는 하위 타입(예:Add에서 예외를 던지는ReadOnlyCollection<T>)은 LSP를 위반합니다. 재정의를 의미 있게 구현할 수 없다면 계층 구조가 잘못된 것입니다 — 합성 또는 별도 인터페이스를 선호하십시오.- 사후 조건을 약화시키는 하위 타입. 기본 클래스가
GetOrders()가 절대null을 반환하지 않음을 보장한다면,null을 반환하는 재정의는 호출자가 의존하는 계약을 위반합니다. 불변 조건(XML 문서 또는 계약)을 문서화하고 재정의에서 이를 강제하십시오. - 사전 조건을 강화하는 하위 타입. 기본 클래스에 없는 null 검사를 추가하거나 기본 클래스가 허용한 유효한 매개변수 범위를 거부하는 재정의는 기본 계약 입력을 전달하는 코드를 손상시킵니다. 하위 타입은 기본 클래스가 허용한 모든 것을 수용해야 합니다.
- 호출 코드에서 만연한
is/as다운캐스트. 호출자의if (handler is SpecialHandler sh) sh.SpecialMethod()는 추상화가 깨졌음을 의미합니다 — 메서드가 기본 인터페이스에 속하거나 타입이 동일한 계층 구조를 공유하지 않아야 합니다. 빈번한 다운캐스트는 LSP/추상화 실패 신호입니다.
ISP — 인터페이스 분리 원칙
- 관련 없는 관심사를 가진 뚱뚱한 인터페이스(10개 이상의 멤버).
IOrderService가CreateOrder,CancelOrder,GetInvoice,SendConfirmation,ExportToCsv,CalculateTax,ValidateAddress,GetShippingRates를 노출한다면 모든 구현자와 테스트 더블이 사용하지 않는 메서드를 스터브하도록 강제합니다. 역할 인터페이스로 분할하십시오:IOrderWriter,IOrderReader,IOrderNotifier,IOrderExporter. - No-op 구현 /
NotImplementedException스터브. 목 또는 테스트 구현이 인터페이스 멤버의 절반에 대해throw new NotImplementedException()을 반환한다면 해당 멤버는 다른 역할 인터페이스에 속합니다. no-op의 존재는 ISP 위반의 직접적인 증거입니다. - 호출하지 않는 메서드를 임포트하는 클라이언트.
PaymentService가IOrderService에 의존하지만GetOrder만 호출한다면IOrderReader에 의존해야 합니다. 의존성을 좁히면 결합도가 명확해지고 테스트 비용이 낮아집니다.
DIP — 의존성 역전 원칙
- 클래스 내부에서 구체적인 의존성을
new로 생성. 서비스 내부에서new HttpClient()를 생성하면 숨겨진 의존성이 생기고, DI 컨테이너를 우회하며, 소켓 누수(연결 풀링 없음)가 발생하고, 리플렉션 트릭 없이는 클래스를 테스트할 수 없게 만듭니다.IHttpClientFactory(타입화된 클라이언트) 또는 명명된 클라이언트를 생성자를 통해 주입하십시오.new SqlConnection(connectionString)도 마찬가지입니다 —IDbConnectionFactory를 주입하거나 EF Core / Dapper가 등록된DbContext를 통해 연결을 관리하도록 하십시오. - 추상화 대신 구체적인 타입에 의존.
IOrderRepository대신SqlOrderRepository로 타입화된 생성자는 클래스를 스토리지 기술에 결합시킵니다. 재사용 가능하거나 격리되어 테스트 가능해야 하는 모든 클래스는 인터페이스 또는 추상 타입에만 의존해야 합니다. DateTime.Now/ 정적 상태에 대한 숨겨진 결합. 비즈니스 메서드 내부의var now = DateTime.Now는 클래스를 비결정적으로 만들고 시간 여행 해킹 없이는 테스트할 수 없게 만듭니다.ISystemClock(또는 .NET 8+의TimeProvider)을 도입하고 주입하십시오.Environment.MachineName,Random.Shared또는 기타 앰비언트 정적에도 동일하게 적용됩니다.- 정적 가변 싱글톤. 서비스 클래스의
static Dictionary<string, Order> _cache는 DI 수명 관리를 우회하고 동시 사용에 안전하지 않은 숨겨진 공유 상태입니다. 적절한 범위 지정 및 제거를 지원하는IMemoryCache/IDistributedCache로 대체하십시오.
판단 메모: 모든
new가 DIP 위반은 아닙니다.new OrderDto(order)(일반 데이터 백) 또는new List<T>()(표준 컬렉션)은 괜찮습니다. DIP는 구성된 객체가 동작, 외부 I/O 또는 구성을 가질 때 — 즉, 테스트 더블이나 대체 구현으로 교체하는 것이 가치 있을 때 중요합니다.
위험 신호
| 신호 | 중요한 이유 |
|---|---|
서비스 메서드 또는 생성자 내부에 new HttpClient() 생성 | IHttpClientFactory 연결 풀링을 우회(부하 시 소켓 고갈), 의존성을 숨기고, 프로덕션 I/O 없이는 클래스를 테스트할 수 없게 만듦. |
조정자 클래스의 switch(order.Type) 또는 if (x is ConcreteType) | 새로운 변형마다 다시 열려야 함. 호출자가 타입 계층 구조에 대해 너무 많이 알고 있음. DI에서 확인된 IEnumerable<IHandler>로 대체. |
추상이 아닌 재정의에서 throw new NotImplementedException() 또는 throw new NotSupportedException() | 하위 타입이 기본 계약을 준수할 수 없음 — LSP 위반. 메서드를 올바르게 구현하거나 계층 구조를 재구성. |
| 호출자가 2-3개만 사용하는 10개 이상의 멤버를 가진 인터페이스 | 큰 테스트 더블을 강제. 여러 역할 인터페이스가 하나로 축소되었음을 나타냄. |
new AppDbContext(options)를 통해 자체 DbContext를 생성하는 클래스 | DI 수명 관리(Scoped vs Transient vs Singleton)를 우회하고, 연결 풀링을 방지하며, 테스트성을 저하시킴. AppDbContext 또는 IDbContextFactory<AppDbContext>를 주입. |
비즈니스 메서드 내부에서 직접 호출된 DateTime.Now | 테스트에서 비결정적; 시스템 시계에 결합되어 논리적 의존성을 숨김. TimeProvider(.NET 8+) 또는 ISystemClock을 주입. |
if/switch 비즈니스 로직 또는 EF 쿼리를 포함하는 컨트롤러 액션 | 컨트롤러가 애플리케이션/도메인 계층에 속하는 책임을 맡음. 비즈니스 규칙의 단일 변경이 컨트롤러를 건드려야 함. |
인프로세스 캐시로 사용되는 static 가변 필드 | 명시적 잠금 없이 스레드 안전하지 않음. DI 범위 지정을 우회. 제거 또는 관찰 불가. IMemoryCache 사용. |
예시
examples/solid-review/를 참조하십시오.