C#에서 SOLID 파트 1: 단일 책임
Source: Dev.to
이것은 내 SOLID 원칙 시리즈 중 Part 1입니다. 각 기사에서는 실제 코드와 함께 원칙을 살펴보며, 실제 프로덕션 코드베이스에서 볼 수 있는 종류의 코드입니다.
단일 책임 원칙이란?
Simple version: 클래스는 변경 이유가 하나만 있어야 합니다.
메서드 하나가 아니라. 코드 한 줄이 아니라. 하나의 reason to change – 즉, 하나의 작업, 하나의 책임 영역을 의미합니다.
팀원 중 누군가가 “이메일 전송 방식을 업데이트해야 한다”고 말하면, 그 변경은 하나의 클래스에만 영향을 미쳐야 하며, 주문 계산 및 데이터베이스 쿼리를 처리하는 클래스까지 포함해서는 안 됩니다.
문제: 모든 것을 하는 클래스
거의 모든 코드베이스에서 이 패턴을 보았습니다. 어느 순간 누군가가 OrderService를 만들고 계속해서 기능을 추가합니다:
public class OrderService {
private readonly string connectionString;
public OrderService(string connectionString) {
this.connectionString = connectionString;
}
public void PlaceOrder(string customerEmail, string product, int quantity, decimal price) {
// 1. Calculate the total
decimal total = quantity * price;
if (quantity > 10)
total *= 0.9m; // 10% bulk discount
// 2. Save to database
using var connection = new SqlConnection(connectionString);
connection.Open();
using var command = new SqlCommand(
"INSERT INTO Orders (Email, Product, Qty, Total) VALUES (@e, @p, @q, @t)",
connection);
command.Parameters.Add("@e", SqlDbType.NVarChar).Value = customerEmail;
command.Parameters.Add("@p", SqlDbType.NVarChar).Value = product;
command.Parameters.Add("@q", SqlDbType.Int).Value = quantity;
command.Parameters.Add("@t", SqlDbType.Decimal).Value = total;
command.ExecuteNonQuery();
// 3. Send confirmation email
var message = new MimeMessage();
message.From.Add(new MailboxAddress("Orders", "orders@company.com"));
message.To.Add(new MailboxAddress("", customerEmail));
message.Subject = "Order Confirmed";
message.Body = new TextPart("plain") {
Text = $"Thanks for ordering {quantity}x {product}. Total: ${total}"
};
using var smtp = new SmtpClient();
smtp.Connect("smtp.company.com", 587);
smtp.Send(message);
smtp.Disconnect(true);
// 4. Log it
File.AppendAllText("orders.log",
$"{DateTime.Now}: Order placed - {product} x{quantity} = ${total}\n");
}
}
왜 이것이 문제인가
이 클래스는 변경 사유가 네 가지입니다:
| 사유 | 무엇이 변경되는가 |
|---|---|
| 할인 규칙 | 계산 로직 수정 |
| 데이터베이스 스키마 | SQL 명령 수정 |
| 이메일 형식 | 메시지 구성 수정 |
| 로그 방식 | 파일 쓰기 코드 수정 |
팀의 네 명이 동시에 이 파일을 수정해야 할 수도 있으며, 각각의 동작을 격리해서 테스트하기는 거의 불가능합니다. 이런 경우를 흔히 **“신 클래스(God Class)”**라고 부릅니다 – 너무 많은 것을 알고, 너무 많은 일을 수행하기 때문입니다.
The Fix: One Job Per Class
클래스를 나눕니다. 각 클래스는 하나의 존재 이유만 가지며, 인터페이스에 의존해 프로그래밍함으로써 이 구성 요소들을 테스트할 수 있게 합니다.
Contracts (Interfaces)
// Each interface represents one responsibility
public interface IOrderCalculator {
decimal CalculateTotal(int quantity, decimal unitPrice);
}
public interface IOrderRepository {
void Save(Order order);
}
public interface IOrderNotifier {
void SendConfirmation(string customerEmail, string product, int quantity, decimal total);
}
public interface IOrderLogger {
void Log(string product, int quantity, decimal total);
}
Implementations
Order Calculator – pricing rules only
public class OrderCalculator : IOrderCalculator {
public decimal CalculateTotal(int quantity, decimal unitPrice) {
decimal total = quantity * unitPrice;
if (quantity > 10)
total *= 0.9m; // bulk discount
return total;
}
}
Order Repository – persistence only
public class OrderRepository : IOrderRepository {
private readonly string connectionString;
public OrderRepository(string connectionString) {
this.connectionString = connectionString;
}
public void Save(Order order) {
using var connection = new SqlConnection(connectionString);
connection.Open();
using var command = new SqlCommand(
"INSERT INTO Orders (Email, Product, Qty, Total) VALUES (@e, @p, @q, @t)",
connection);
command.Parameters.Add("@e", SqlDbType.NVarChar).Value = order.CustomerEmail;
command.Parameters.Add("@p", SqlDbType.NVarChar).Value = order.Product;
command.Parameters.Add("@q", SqlDbType.Int).Value = order.Quantity;
command.Parameters.Add("@t", SqlDbType.Decimal).Value = order.Total;
command.ExecuteNonQuery();
}
}
Email Order Notifier – notification only
public class EmailOrderNotifier : IOrderNotifier {
public void SendConfirmation(string customerEmail, string product, int quantity, decimal total) {
// Using MailKit (the modern replacement for System.Net.Mail.SmtpClient,
// which has been deprecated since .NET 6)
var message = new MimeMessage();
message.From.Add(new MailboxAddress("Orders", "orders@company.com"));
message.To.Add(new MailboxAddress("", customerEmail));
message.Subject = "Order Confirmed";
message.Body = new TextPart("plain") {
Text = $"Thanks for ordering {quantity}x {product}. Total: ${total}"
};
using var smtp = new SmtpClient();
smtp.Connect("smtp.company.com", 587);
smtp.Send(message);
smtp.Disconnect(true);
}
}
Order Logger – logging only
public class FileOrderLogger : IOrderLogger {
public void Log(string product, int quantity, decimal total) {
File.AppendAllText("orders.log",
$"{DateTime.Now}: Order placed - {product} x{quantity} = ${total}\n");
}
}
Orchestrating the Process
public class OrderProcessor {
private readonly IOrderCalculator calculator;
private readonly IOrderRepository repository;
private readonly IOrderNotifier notifier;
private readonly IOrderLogger logger;
public OrderProcessor(IOrderCalculator calculator,
IOrderRepository repository,
IOrderNotifier notifier,
IOrderLogger logger) {
this.calculator = calculator;
this.repository = repository;
this.notifier = notifier;
this.logger = logger;
}
public void PlaceOrder(string customerEmail, string product, int quantity, decimal unitPrice) {
var total = calculator.CalculateTotal(quantity, unitPrice);
var order = new Order {
CustomerEmail = customerEmail,
Product = product,
Quantity = quantity,
```
```csharp
Total = total
};
repository.Save(order);
notifier.SendConfirmation(customerEmail, product, quantity, total);
logger.Log(product, quantity, total);
}
}
이제 각 클래스는 변경 이유가 하나씩만 가지게 되어, 코드베이스를 이해하고, 테스트하고, 유지보수하기가 더 쉬워집니다.
실제로 우리는 무엇을 얻었나요?
각 조각을 격리된 상태로 테스트할 수 있습니다. 대량 할인 적용 여부를 확인하고 싶나요? OrderCalculator를 직접 테스트하세요. 데이터베이스도, 이메일 서버도, 파일 시스템도 필요 없습니다:
[Test]
public void CalculateTotal_BulkOrder_AppliesTenPercentDiscount() {
var calculator = new OrderCalculator();
var total = calculator.CalculateTotal(quantity: 15, unitPrice: 10m);
Assert.That(total, Is.EqualTo(135m)); // 150 * 0.9
}
원래의 God Class에 대해 같은 테스트를 작성한다면, 산술 연산만 테스트하기 위해 데이터베이스 연결, SMTP 서버, 쓰기 가능한 파일 시스템까지 모두 준비해야 합니다.
변경 범위가 명확합니다. 상사가 “SMTP를 SendGrid로 바꾸자”고 하면, 새로운 IOrderNotifier 구현만 작성하면 됩니다. 계산기, 저장소, 로거는 전혀 건드리지 않으며, 실수로 깨질 위험도 없습니다.
새로운 개발자가 더 빨리 찾을 수 있습니다. “가격 로직이 어디 있지?” → OrderCalculator.cs. 200줄짜리 메서드의 47번째 줄에 파묻혀 있지 않습니다.
SRP 위반을 식별하는 방법
- 클래스 이름에 “And”가 포함되어 있다 (또는 포함되어야 함):
OrderAndEmailService,UserValidationAndStorage. - 설명할 때 “also”라는 단어를 사용한다: “It calculates the total and also sends the email and also saves to the database.”
- 완전히 다른 도메인의 의존성:
SmtpClient와SqlConnection을 모두 받는 클래스는 너무 많은 일을 하고 있다. - 메서드가 약 30줄 이상일 경우: 엄격한 규칙은 아니지만, 긴 메서드는 보통 여러 책임이 얽혀 있는 경우가 많다.
- 팀의 여러 사람이 무관한 이유로 같은 파일을 계속 수정한다.
가장 흔한 반론
“하지만 이제 파일이 너무 많아졌어요!”
네. 작고 집중된 파일 다섯 개가 거대한 하나의 파일보다 이해하고, 테스트하고, 수정하기가 쉽습니다. 여러분의 IDE에는 Ctrl+T가 있습니다. 사용하세요.
파일이 많아지는 비용은 거의 없습니다. God Class의 비용은 아무도 건드리고 싶어 하지 않는 코드와 관련 없는 기능들 사이에 퍼지는 버그입니다.
빠른 규칙
- 물어보세요: “and를 사용하지 않고 이 클래스의 역할을 한 문장으로 설명할 수 있나요?”
- 클래스가 3‑4개 이상의 의존성을 가지고 있다면, 너무 많은 일을 하고 있을 수 있습니다.
- 코디네이터는 괜찮습니다.
OrderService는 작업을 조정하지만 실제로 작업을 수행하지는 않습니다. - 과도하게 만들지 마세요. 모든 메서드마다 클래스를 만들 필요는 없습니다. 같은 이유로 변경되는 것들을 함께 묶으세요.
다음: Part 2 – 개방/폐쇄 원칙, “확장하고, 수정하지 마라.” 기존 클래스를 건드리지 않고 새로운 동작을 추가하는 방법을 살펴보겠습니다.
이 시리즈는 SOLID → 단위 테스트 → 클린 아키텍처 → 통합 테스트 순으로 진행됩니다. 다음 편을 놓치지 않으려면 Follow me 를 클릭하세요.