[객체지향] 4.C#에서의 재사용 가능한 클래스 설계, SOLID 원칙을 고려한 유지보수 가능한 클래스 설계

1. 서론

소프트웨어 개발에서 재사용 가능한 클래스 설계는 개발 생산성을 높이고 유지보수 비용을 줄이는 중요한 요소입니다. C#은 객체지향 프로그래밍(OOP) 언어로서, 클래스와 객체의 개념을 기반으로 구조화된 코드 작성을 지원합니다. 그러나, 클래스가 재사용 가능하고 유지보수 가능한 상태로 설계되기 위해서는 SOLID 원칙을 준수해야 합니다. 이 글에서는 C#에서 재사용 가능한 클래스를 설계하는 방법과 SOLID 원칙에 대해 알아보겠습니다.

2. SOLID 원칙

SOLID는 객체지향 설계의 다섯 가지 기본 원칙을 나타내는 약어로, 소프트웨어의 유지보수성과 확장성을 향상시키는 데 도움을 줍니다. 각 원칙을 살펴보겠습니다.

2.1. Single Responsibility Principle (SRP)

단일 책임 원칙(SRP)은 클래스는 하나의 책임만 가져야 한다는 원칙입니다. 즉, 클래스가 변경되는 이유는 오직 하나여야 하며, 이렇게 함으로써 클래스를 더 쉽게 이해하고 수정할 수 있습니다.


public class UserService {
    public void CreateUser(User user) {
        // 사용자 생성 로직
    }
}

public class UserRepository {
    public void Save(User user) {
        // 데이터베이스에 사용자 저장 로직
    }
}

위의 예제에서 UserService 클래스는 사용자 생성 로직만 담당하고, UserRepository 클래스는 데이터 저장 로직만 담당합니다. 이는 SRP를 준수하여 각 클래스의 변경 가능성을 감소시킵니다.

2.2. Open/Closed Principle (OCP)

개방-폐쇄 원칙(OCP)은 클래스는 확장에는 열려 있어야 하지만, 수정에는 닫혀 있어야 한다는 원칙입니다. 이를 통해 기존 코드를 수정하지 않고 새로운 기능을 추가할 수 있습니다.


public abstract class Shape {
    public abstract double Area();
}

public class Circle : Shape {
    public double Radius { get; set; }

    public override double Area() {
        return Math.PI * Radius * Radius;
    }
}

public class Rectangle : Shape {
    public double Width { get; set; }
    public double Height { get; set; }

    public override double Area() {
        return Width * Height;
    }
}

위의 예제에서 Shape 클래스를 상속받은 CircleRectangle 클래스는 새로운 형태(도형)를 추가하면서도 기존의 Shape 클래스를 수정하지 않습니다. 이는 OCP를 준수한 설계입니다.

2.3. Liskov Substitution Principle (LSP)

리스코프 치환 원칙(LSP)은 서브타입은 언제나 자신의 기반 타입으로 교체할 수 있어야 한다는 원칙입니다. 즉, 자식 클래스는 부모 클래스의 기능을 완전히 지원해야 합니다.


public class Bird {
    public virtual void Fly() {
        // 날기 로직
    }
}

public class Sparrow : Bird {
    public override void Fly() {
        // 참새 날기 로직
    }
}

public class Ostrich : Bird {
    public override void Fly() {
        throw new NotSupportedException("타조는 날 수 없습니다.");
    }
}

타조는 날 수 없기 때문에 Ostrich 클래스는 Bird 클래스로 대체될 수 없습니다. 이는 LSP를 위반한 예시로, 이러한 설계를 피해야 합니다.

2.4. Interface Segregation Principle (ISP)

인터페이스 분리 원칙(IPS)은 클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다는 원칙입니다. 즉, 거대한 인터페이스를 여러 작은 인터페이스로 나누어야 합니다.


public interface IFlyable {
    void Fly();
}

public interface ISwimmable {
    void Swim();
}

public class Duck : IFlyable, ISwimmable {
    public void Fly() {
        // 오리 날기 로직
    }
    
    public void Swim() {
        // 오리 수영 로직
    }
}

public class Fish : ISwimmable {
    public void Swim() {
        // 물고기 수영 로직
    }
    
    // Fly() 메서드 없음, ISwimmable 인터페이스만 구현
}

위의 예제에서 Duck 클래스는 IFlyableISwimmable 인터페이스를 모두 구현하는 반면, Fish 클래스는 ISwimmable 인터페이스만 구현합니다. 이는 ISP를 준수하여 클라이언트의 의존성을 줄이는 방법입니다.

2.5. Dependency Inversion Principle (DIP)

의존 관계 역전 원칙(DIP)은 고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다는 원칙입니다. 구체 클래스를 사용하는 대신 인터페이스나 추상 클래스에 의존하여 결합도를 낮춥니다.


public interface IMessageService {
    void SendMessage(string message);
}

public class EmailService : IMessageService {
    public void SendMessage(string message) {
        // 이메일 전송 로직
    }
}

public class SmsService : IMessageService {
    public void SendMessage(string message) {
        // SMS 전송 로직
    }
}

public class Notification {
    private readonly IMessageService _messageService;

    public Notification(IMessageService messageService) {
        _messageService = messageService;
    }

    public void Notify(string message) {
        _messageService.SendMessage(message);
    }
}

위 예제에서 Notification 클래스는 구체적인 EmailServiceSmsService 클래스에 의존하지 않고 추상 인터페이스인 IMessageService에 의존합니다. 이는 DIP를 준수하여 유지보수성을 높입니다.

3. 유지보수 가능한 클래스 설계

SOLID 원칙을 준수한 클래스 설계는 유지보수성을 높이며, 코드의 가독성과 이해도를 향상시킵니다. 다음은 이를 적용한 유지보수 가능한 클래스 설계의 예입니다.

3.1. 실습: 주문 처리 시스템 설계

간단한 주문 처리 시스템을 만들면서 SOLID 원칙을 적용해 보겠습니다.

3.1.1. 클래스 설계

주문 처리 시스템을 위한 몇 가지 인터페이스와 클래스를 정의합니다.


public interface IOrder {
    void ProcessOrder();
}

public class OnlineOrder : IOrder {
    public void ProcessOrder() {
        // 온라인 주문 처리 로직
    }
}

public class InStoreOrder : IOrder {
    public void ProcessOrder() {
        // 매장 주문 처리 로직
    }
}

public class OrderProcessor {
    private IList<IOrder> _orders;

    public OrderProcessor(IList<IOrder> orders) {
        _orders = orders;
    }

    public void ProcessAllOrders() {
        foreach (var order in _orders) {
            order.ProcessOrder();
        }
    }
}

위 예제에서 IOrder 인터페이스는 모든 주문 유형이 구현해야 할 메서드를 정의하고, OnlineOrderInStoreOrder 클래스는 각자의 주문 처리 로직을 구현합니다. OrderProcessor 클래스는 이러한 주문들을 처리하는 책임을 가집니다.

3.1.2. 주문 유형 확장

주문 유형을 추가할 때 기존 코드를 수정하지 않고 새로운 주문 클래스를 추가하면 됩니다.


public class PhoneOrder : IOrder {
    public void ProcessOrder() {
        // 전화 주문 처리 로직
    }
}

이러한 방식으로 새로운 주문 유형을 추가하는 것은 OCP를 준수하며, 기존 클래스에 대한 영향을 최소화합니다.

3.1.3. 테스트 가능성

이 설계는 각 클래스가 독립적으로 작동하므로, 각각의 클래스와 인터페이스에 대한 단위 테스트도 용이합니다. 예를 들어, OrderProcessor 클래스의 단위 테스트는 다음과 같이 작성할 수 있습니다.


public class OrderProcessorTests {
    [Fact]
    public void ProcessAllOrders_ShouldCallProcessOrderForEachOrder() {
        // Arrange
        var mockOrder1 = new Mock<IOrder>();
        var mockOrder2 = new Mock<IOrder>();
        var orders = new List<IOrder> { mockOrder1.Object, mockOrder2.Object };

        var orderProcessor = new OrderProcessor(orders);

        // Act
        orderProcessor.ProcessAllOrders();

        // Assert
        mockOrder1.Verify(o => o.ProcessOrder(), Times.Once);
        mockOrder2.Verify(o => o.ProcessOrder(), Times.Once);
    }
}

위코드는 OrderProcessor 클래스의 ProcessAllOrders 메서드가 각 주문의 ProcessOrder 메서드를 호출하는지를 검증합니다. 이처럼 SOLID 원칙을 준수한 설계는 테스트 가능성을 높이고, 미래의 변경에 유연하게 대응할 수 있습니다.

4. 결론

이번 글에서는 C#에서 재사용 가능한 클래스를 설계하는 방법과 SOLID 원칙이 어떻게 유지보수성을 높이고 코드의 가독성을 향상시키는지에 대해 살펴보았습니다. SOLID 원칙을 따른 설계는 소프트웨어의 확장성을 높이고, 유지보수하는 데 드는 노력을 최소화합니다. 이러한 접근 방식은 고급 개발자에게 필수적인 기술이며, 실제 프로젝트에 적용할 때 큰 이점을 가져다 줄 것입니다.