단일 책임 원칙: 훌륭한 코드를 위한 레시피
게시 됨: 2022-03-11우리가 훌륭한 코드라고 생각하는 것과 상관없이, 항상 한 가지 단순한 품질이 필요합니다. 바로 코드를 유지 관리할 수 있어야 한다는 것입니다. 적절한 들여쓰기, 깔끔한 변수 이름, 100% 테스트 범위 등은 여기까지만 가능합니다. 유지 관리할 수 없고 상대적으로 쉽게 변화하는 요구 사항에 적응할 수 없는 코드는 더 이상 사용되지 않기를 기다리는 코드입니다. 프로토타입, 개념 증명 또는 최소한의 실행 가능한 제품을 만들려고 할 때 훌륭한 코드를 작성할 필요가 없을 수도 있지만 다른 모든 경우에는 항상 유지 관리 가능한 코드를 작성해야 합니다. 이것은 소프트웨어 엔지니어링 및 디자인의 기본 품질로 간주되어야 하는 것입니다.
이 기사에서는 단일 책임 원칙과 이를 중심으로 하는 몇 가지 기술이 코드에 이러한 품질을 제공할 수 있는 방법에 대해 설명합니다. 훌륭한 코드를 작성하는 것은 예술이지만 몇 가지 원칙은 항상 여러분의 개발 작업에 강력하고 유지 관리 가능한 소프트웨어를 생성하는 데 필요한 방향을 제시하는 데 도움이 될 수 있습니다.
모델이 전부다
새로운 MVC(MVP, MVVM 또는 기타 M**) 프레임워크에 대한 거의 모든 책에는 잘못된 코드의 예가 흩어져 있습니다. 이러한 예는 프레임워크가 제공해야 하는 것을 보여주려고 합니다. 그러나 그들은 또한 초보자에게 나쁜 조언을 제공하기도 합니다. "우리 모델에 이 ORM X가 있고 뷰에 대해 템플릿 엔진 Y가 있고 이 모든 것을 관리할 컨트롤러가 있다고 가정해 봅시다."와 같은 예는 거대한 컨트롤러 외에는 아무 것도 달성하지 못합니다.
이러한 책을 옹호하기는 하지만 예제는 해당 프레임워크를 쉽게 시작할 수 있음을 보여주기 위한 것입니다. 소프트웨어 설계를 가르치기 위한 것이 아닙니다. 그러나 이 예제를 따르는 독자는 몇 년 후에야 프로젝트에 단일 코드 덩어리를 갖는 것이 얼마나 비생산적인지 깨닫게 됩니다.
모델은 앱의 핵심입니다. 애플리케이션 로직의 나머지 부분과 분리된 모델이 있는 경우 애플리케이션이 얼마나 복잡해졌는지에 관계없이 유지 관리가 훨씬 쉬워집니다. 복잡한 응용 프로그램의 경우에도 좋은 모델 구현은 매우 표현적인 코드를 생성할 수 있습니다. 그리고 이를 달성하려면 먼저 모델이 의도한 대로만 수행하도록 하고 이를 기반으로 구축된 앱이 수행하는 작업에는 관심을 두지 마십시오. 또한 기본 데이터 저장 계층이 무엇인지에 대해서는 관심을 두지 않습니다. 앱이 SQL 데이터베이스에 의존합니까, 아니면 모든 것을 텍스트 파일에 저장합니까?
이 기사를 계속하면서 관심 분리에 대한 훌륭한 코드가 얼마나 중요한지 깨닫게 될 것입니다.
단일 책임 원칙
단일 책임, 개방형, liskov 대체, 인터페이스 분리 및 종속성 반전과 같은 SOLID 원칙에 대해 들어본 적이 있을 것입니다. 첫 글자 S는 단일 책임 원칙(SRP)을 나타내며 그 중요성은 아무리 강조해도 지나치지 않습니다. 나는 그것이 좋은 코드를 위한 필요충분조건이라고 주장하기도 합니다. 사실, 잘못 작성된 코드에서는 항상 하나 이상의 책임이 있는 클래스를 찾을 수 있습니다. 수천 줄의 코드가 포함된 form1.cs 또는 index.php는 우리 모두에게 드문 일이 아닙니다. 아마 보았거나 했을 것입니다.
C#(ASP.NET MVC 및 Entity 프레임워크)의 예를 살펴보겠습니다. C# 개발자가 아니더라도 OOP 경험이 있으면 쉽게 따라할 수 있습니다.
public class OrderController { ... public ActionResult CreateForm() { /* * View data preparations */ return View(); } [HttpPost] public ActionResult Create(OrderCreateRequest request) { if (!ModelState.IsValid) { /* * View data preparations */ return View(); } using (var context = new DataContext()) { var order = new Order(); // Create order from request context.Orders.Add(order); // Reserve ordered goods …(Huge logic here)... context.SaveChanges(); //Send email with order details for customer } return RedirectToAction("Index"); } ... (many more methods like Create here) }
이것은 일반적인 OrderController 클래스이며 Create 메소드가 표시됩니다. 이와 같은 컨트롤러에서는 Order 클래스 자체가 요청 매개변수로 사용되는 경우를 종종 봅니다. 그러나 나는 특별 요청 클래스를 사용하는 것을 선호합니다. 다시, SRP!
위의 코드 스니펫에서 컨트롤러가 Order 객체 저장, 이메일 전송 등을 포함하되 이에 국한되지 않는 "주문하기"에 대해 너무 많이 알고 있음을 주목하십시오. 이는 단일 클래스에 대해 너무 많은 작업입니다. 약간의 변경이 있을 때마다 개발자는 전체 컨트롤러의 코드를 변경해야 합니다. 그리고 다른 컨트롤러도 주문을 생성해야 하는 경우를 대비하여 개발자는 코드를 복사하여 붙여넣는 경우가 많습니다. 컨트롤러는 전체 프로세스만 제어해야 하며 실제로 프로세스의 모든 논리를 수용해서는 안 됩니다.
하지만 오늘은 이 거대한 컨트롤러 작성을 중단하는 날입니다!
먼저 컨트롤러에서 모든 비즈니스 로직을 추출하고 이를 OrderService 클래스로 옮깁니다.
public class OrderService { public void Create(OrderCreateRequest request) { // all actions for order creating here } } public class OrderController { public OrderController() { this.service = new OrderService(); } [HttpPost] public ActionResult Create(OrderCreateRequest request) { if (!ModelState.IsValid) { /* * View data preparations */ return View(); } this.service.Create(request); return RedirectToAction("Index"); }
이 작업이 완료되면 컨트롤러는 이제 의도한 대로만 수행합니다. 즉, 프로세스를 제어합니다. 요청을 관리하고 응답을 보내는 작업을 수행하는 데 필요한 최소한의 정보인 보기, OrderService 및 OrderRequest 클래스에 대해서만 알고 있습니다.
이렇게 하면 컨트롤러 코드를 거의 변경하지 않을 것입니다. 보기, 요청 개체 및 서비스와 같은 기타 구성 요소는 비즈니스 요구 사항에 연결되어 있으므로 계속 변경될 수 있지만 컨트롤러에는 연결되지 않습니다.
이것이 SRP에 관한 것이며 이 원칙을 충족하는 코드를 작성하기 위한 많은 기술이 있습니다. 이것의 한 예는 의존성 주입(테스트 가능한 코드 작성에도 유용한 것)입니다.
의존성 주입
의존성 주입이 없는 단일 책임 원칙에 기반한 대규모 프로젝트는 상상하기 어렵습니다. OrderService 클래스를 다시 살펴보겠습니다.
public class OrderService { public void Create(...) { // Creating the order(and let's forget about reserving here, it's not important for following examples) // Sending an email to client with order details var smtp = new SMTP(); // Setting smtp.Host, UserName, Password and other parameters smtp.Send(); } }
이 코드는 작동하지만 그다지 이상적이지는 않습니다. Create 메소드 OrderService 클래스가 작동하는 방식을 이해하려면 SMTP의 복잡성을 이해해야 합니다. 그리고 다시, 복사-붙여넣기는 필요할 때마다 이러한 SMTP 사용을 복제할 수 있는 유일한 방법입니다. 그러나 약간의 리팩토링으로 다음과 같이 변경될 수 있습니다.
public class OrderService { private SmtpMailer mailer; public OrderService() { this.mailer = new SmtpMailer(); } public void Create(...) { // Creating the order // Sending an email to client with order details this.mailer.Send(...); } } public class SmtpMailer { public void Send(string to, string subject, string body) { // SMTP stuff will be only here } }
이미 훨씬 낫습니다! 그러나 OrderService 클래스는 여전히 이메일 전송에 대해 많이 알고 있습니다. 이메일을 보내려면 정확히 SmtpMailer 클래스가 필요합니다. 미래에 그것을 바꾸고 싶다면 어떻게 해야 할까요? 우리의 개발 환경에서 실제로 전송하는 대신에 특수 로그 파일로 전송되는 이메일의 내용을 인쇄하려면 어떻게 해야 할까요? OrderService 클래스를 단위 테스트하려면 어떻게 해야 합니까? 인터페이스 IMailer를 만들어 리팩토링을 계속해 보겠습니다.

public interface IMailer { void Send(string to, string subject, string body); }
SmtpMailer는 이 인터페이스를 구현합니다. 또한 우리 응용 프로그램은 IoC 컨테이너를 사용하고 IMailer가 SmtpMailer 클래스에 의해 구현되도록 구성할 수 있습니다. 그런 다음 OrderService는 다음과 같이 변경할 수 있습니다.
public sealed class OrderService: IOrderService { private IOrderRepository repository; private IMailer mailer; public OrderService(IOrderRepository repository, IMailer mailer) { this.repository = repository; this.mailer = mailer; } public void Create(...) { var order = new Order(); // fill the Order entity using the full power of our Business Logic(discounts, promotions, etc.) this.repository.Save(order); this.mailer.Send(<orders user email>, <subject>, <body with order details>); } }
이제 우리는 어딘가에 도착하고 있습니다! 이번 기회에 또 다른 변화를 만들어 봤습니다. OrderService는 이제 IOrderRepository 인터페이스에 의존하여 모든 주문을 저장하는 구성 요소와 상호 작용합니다. 더 이상 해당 인터페이스가 구현되는 방식과 이를 지원하는 스토리지 기술에 대해 신경 쓰지 않습니다. 이제 OrderService 클래스에는 주문 비즈니스 로직을 다루는 코드만 있습니다.
이렇게 하면 테스터가 이메일을 보낼 때 올바르지 않게 작동하는 무언가를 발견할 경우 개발자는 SmtpMailer 클래스를 정확히 알 수 있습니다. 할인에 문제가 있는 경우 개발자는 다시 OrderService(또는 SRP를 진심으로 수용한 경우 DiscountService) 클래스 코드를 볼 수 있는 위치를 알고 있습니다.
이벤트 기반 아키텍처
그러나 나는 여전히 OrderService.Create 메서드가 마음에 들지 않습니다.
public void Create(...) { var order = new Order(); ... this.repository.Save(order); this.mailer.Send(<orders user email>, <subject>, <body with order details>); }
이메일을 보내는 것은 주요 주문 생성 흐름의 일부가 아닙니다. 앱에서 이메일을 보내지 못하더라도 주문은 여전히 올바르게 생성됩니다. 또한 성공적으로 주문한 후 이메일 수신을 거부할 수 있는 새 옵션을 사용자 설정 영역에 추가해야 하는 상황을 상상해 보십시오. 이것을 OrderService 클래스에 통합하려면 종속성 IUserParametersService를 도입해야 합니다. 믹스에 현지화를 추가하면 ITranslator라는 또 다른 종속성이 있습니다(사용자가 선택한 언어로 올바른 이메일 메시지 생성). 특히 이러한 많은 종속성을 추가하고 화면에 맞지 않는 생성자로 끝나는 아이디어는 이러한 작업 중 일부가 불필요합니다. 32개의 종속성이 있는 클래스의 Magento 코드베이스(PHP로 작성된 인기 있는 전자 상거래 CMS)에서 이에 대한 훌륭한 예를 찾았습니다!
때때로 이 논리를 분리하는 방법을 알아내기가 어렵고 Magento의 클래스는 아마도 그러한 경우 중 하나의 희생자일 것입니다. 이것이 내가 이벤트 중심 방식을 좋아하는 이유입니다.
namespace <base namespace>.Events { [Serializable] public class OrderCreated { private readonly Order order; public OrderCreated(Order order) { this.order = order; } public Order GetOrder() { return this.order; } } }
주문이 생성될 때마다 OrderService 클래스에서 직접 이메일을 보내는 대신 특수 이벤트 클래스인 OrderCreated가 생성되고 이벤트가 생성됩니다. 응용 프로그램 이벤트 처리기의 어딘가에 구성됩니다. 그들 중 하나가 클라이언트에게 이메일을 보낼 것입니다.
namespace <base namespace>.EventHandlers { public class OrderCreatedEmailSender : IEventHandler<OrderCreated> { public OrderCreatedEmailSender(IMailer, IUserParametersService, ITranslator) { // this class depend on all stuff which it need to send an email. } public void Handle(OrderCreated event) { this.mailer.Send(...); } } }
OrderCreated 클래스는 의도적으로 Serializable로 표시됩니다. 이 이벤트를 즉시 처리하거나 대기열(Redis, ActiveMQ 또는 기타)에 직렬화하여 저장하고 웹 요청을 처리하는 프로세스/스레드와 별도의 프로세스/스레드에서 처리할 수 있습니다. 이 기사에서 작성자는 이벤트 기반 아키텍처가 무엇인지 자세히 설명합니다(OrderController 내의 비즈니스 로직에 주의하지 마십시오).
어떤 사람들은 이제 주문을 생성할 때 무슨 일이 일어나고 있는지 이해하기 어렵다고 주장할 수 있습니다. 그러나 그것은 진실에서 더 이상 멀어질 수 없습니다. 그렇게 생각한다면 IDE의 기능을 활용하십시오. IDE에서 OrderCreated 클래스의 모든 사용법을 찾아 이벤트와 관련된 모든 작업을 볼 수 있습니다.
그러나 언제 종속성 주입을 사용해야 하고 언제 이벤트 기반 접근 방식을 사용해야 합니까? 이 질문에 답하는 것이 항상 쉬운 것은 아니지만 애플리케이션 내의 모든 주요 활동에 종속성 주입을 사용하고 모든 보조 작업에 대해 이벤트 기반 접근 방식을 사용하는 것이 도움이 될 수 있는 간단한 규칙입니다. 예를 들어 IOrderRepository를 사용하여 OrderService 클래스 내에서 주문을 생성하는 것과 같은 일에 의존성 주입을 사용하고 주요 주문 생성 흐름의 중요한 부분이 아닌 이메일 전송을 일부 이벤트 핸들러에 위임합니다.
결론
우리는 매우 무거운 컨트롤러, 단 하나의 클래스로 시작하여 정교한 클래스 모음으로 끝났습니다. 이러한 변경의 이점은 예제에서 매우 분명합니다. 그러나 이러한 예를 개선할 수 있는 방법은 여전히 많이 있습니다. 예를 들어 OrderService.Create 메서드는 자체 클래스인 OrderCreator로 이동할 수 있습니다. 주문 생성은 단일 책임 원칙을 따르는 비즈니스 논리의 독립적인 단위이므로 고유한 종속성 집합이 있는 고유한 클래스가 있는 것은 당연합니다. 마찬가지로 주문 제거 및 주문 취소는 각각 자체 클래스에서 구현할 수 있습니다.
이 기사의 첫 번째 예와 유사한 고도로 결합된 코드를 작성할 때 요구 사항에 대한 작은 변경은 코드의 다른 부분에서 많은 변경으로 쉽게 이어질 수 있습니다. SRP는 개발자가 각 클래스에 고유한 작업이 있는 분리된 코드를 작성하는 데 도움이 됩니다. 이 작업의 사양이 변경되면 개발자는 해당 특정 클래스만 변경합니다. 물론 처음부터 손상되지 않는 한 다른 클래스가 여전히 이전과 같이 작업을 수행해야 하므로 변경으로 인해 전체 애플리케이션이 손상될 가능성이 적습니다.
이러한 기술을 사용하고 단일 책임 원칙을 따르는 코드를 미리 개발하는 것은 어려운 작업처럼 보일 수 있지만 프로젝트가 성장하고 개발이 계속됨에 따라 노력은 확실히 결실을 맺을 것입니다.