오늘 한 일
- 챌린지반 과제 풀이 (오브젝트 풀링)
- 프로젝트 제출 및 발표
- 객체 지향 특강 2부
오늘은 객체 지향 특강에서 들었던 SOLID 원칙에 대해서 알아보고자 한다.
- 객체 지향 프로그래밍에서 소프트웨어 디자인의 다섯 가지 기본원칙
이 원칙은 소프트웨어의 유지보수성, 확장성, 재사용성 등을 향상시키는 데 중요한 역할을 한다.
- S - Single Responsibility Principle (단일 책임 원칙)
- 클래스는 하나의 책임만 가져야 하며, 해당 클래스의 변경 사유는 하나여야 합니다.
- O - Open/Closed Principle (개방/폐쇄 원칙)
- 소프트웨어 엔티티(클래스, 모듈, 함수 등)는 확장에 대해서는 열려 있어야 하지만 변경에 대해서는 닫혀 있어야 합니다.
- L - Liskov Substitution Principle (리스코프 치환 원칙)
- 하위 클래스는 상위 클래스에서 가능한 모든 동작을 수행할 수 있어야 합니다.
- I - Interface Segregation Principle (인터페이스 분리 원칙)
- 클라이언트는 사용하지 않는 메서드에 의존하도록 강요받아서는 안 됩니다. (단일 책임 원칙의 인터페이스 버전)
- D - Dependency Inversion Principle (의존성 역전 원칙)
- 상위 모듈은 하위 모듈에 의존해서는 안 되며, 둘 모두 추상화에 의존해야 합니다. (또한 상위 모듈을 상속받은 자기와 같은 단계의 모듈로부터 상속 받아서는 안된다.)
이 원칙을 준수하면 유지보수성이 높아지고 확장이 용이하며, 더욱 견고하고 효율적인 소프트웨어를 만들 수 있게 됩니다
단일 책임 원칙(Single Responsibility Principle, SRP)은 소프트웨어 개발에서 가장 기본적인 원칙 중 하나로, 각 클래스는 단 하나의 책임만을 가져야 한다는 원칙입니다. 이것은 클래스가 변경되어야 하는 이유가 단 하나여야 한다는 것을 의미합니다.
- 클래스는 단일 기능을 수행합니다: 각 클래스는 한 가지 명확한 목적이나 기능을 수행합니다. 이는 클래스가 변경되어야 하는 이유를 명확하게 파악할 수 있도록 합니다.
- 모듈성과 재사용성을 높입니다: 단일 책임을 가진 클래스는 다른 클래스와의 결합도가 낮아지므로 모듈화가 용이해집니다. 이로써 코드를 재사용하고 수정하기 쉬워집니다.
- 유지보수가 용이합니다: 각 클래스가 단일 책임을 가지므로 코드의 수정이 필요할 때 해당 클래스만 수정하면 됩니다. 이는 코드의 복잡성을 줄이고 유지보수 비용을 낮출 수 있습니다.
소프트웨어에서 데이터베이스 접속, 데이터 처리, 사용자 인터페이스 표시 등 각각의 작업은 서로 다른 책임을 가지고 있다. 따라서 각각의 작업을 담당하는 클래스를 따로 구성하는 것이 SRP를 준수하는 좋은 방법이다.
SRP를 지키지 않는 코드는 한 클래스가 너무 많은 책임을 가지거나, 여러 기능을 포함하고 있는 형태이다. 이러한 형태는 가독성이 떨어지고 유지 보수의 어려움을 증가시킨다.
using System; // 사용자 정보를 관리하는 클래스 public class UserManager { private Database database; public UserManager(Database database) { this.database = database; } // 사용자를 데이터베이스에 저장하는 메서드 public void SaveUser(User user) { // 데이터베이스에 저장하는 로직 } // 사용자를 데이터베이스에서 불러오는 메서드 public User LoadUser(int userId) { // 데이터베이스에서 사용자 정보를 불러오는 로직 return null; } } // 데이터베이스 관리 클래스 public class Database { // 데이터베이스 연결 및 관리를 위한 메서드 } // 사용자 정보 클래스 public class User { public int Id { get; set; } public string Name { get; set; } public string Email { get; set; } }
이 코드에서는
UserManager
클래스는 사용자 정보의 저장 및 로드와 같은 사용자 관리 기능만을 수행합니다. 따라서 클래스의 책임이 명확하게 구분되어 있습니다.
using System; // 사용자 정보를 관리하고 동시에 UI를 업데이트하는 클래스 public class UserManager { private Database database; private UIUpdater uiUpdater; public UserManager(Database database, UIUpdater uiUpdater) { this.database = database; this.uiUpdater = uiUpdater; } // 사용자를 데이터베이스에 저장하고 동시에 UI를 업데이트하는 메서드 public void SaveUserAndUpdateUI(User user) { // 데이터베이스에 저장하는 로직 // UI를 업데이트하는 로직 } // 사용자를 데이터베이스에서 불러오고 동시에 UI를 업데이트하는 메서드 public User LoadUserAndUpdateUI(int userId) { // 데이터베이스에서 사용자 정보를 불러오는 로직 // UI를 업데이트하는 로직 return null; } } // 데이터베이스 관리 클래스 public class Database { // 데이터베이스 연결 및 관리를 위한 메서드 } // UI를 업데이트하는 클래스 public class UIUpdater { // UI 업데이트를 위한 메서드 }
이 코드에서는
UserManager
클래스가 사용자 정보의 저장과 동시에 UI 업데이트도 수행합니다. 이는 클래스가 여러 가지 책임을 가지고 있어서 단일 책임 원칙을 위반하는 것입니다. 이러한 경우 클래스가 변경되어야 하는 이유가 둘 이상이 될 수 있으며, 코드의 유지보수가 어려워질 수 있습니다.
개방/폐쇄 원칙(Open/Closed Principle, OCP)는 소프트웨어 설계 원칙 중 하나로, "소프트웨어 개체(클래스, 모듈, 함수 등)는 확장에는 열려 있어야 하고 수정에는 닫혀 있어야 한다"는 원칙을 의미합니다. 즉, 새로운 기능을 추가할 때는 기존 코드를 수정하지 않고 확장을 통해 가능하도록 해야 합니다.
추상화와 다형성 활용: 추상화를 통해 인터페이스나 추상 클래스를 정의하고, 다형성을 활용하여 이를 구현하는 클래스들을 만듭니다. 새로운 기능이 추가되면 새로운 구현 클래스를 만들어 기존 인터페이스를 구현하면 됩니다.
확장 포인트 제공: 기존 코드에 새로운 기능을 추가하기 위한 확장 포인트를 제공합니다. 이를 통해 새로운 기능을 추가하는데 필요한 수정을 최소화하고, 기존 코드의 안정성을 유지할 수 있습니다.
디자인 패턴 활용: 디자인 패턴 중 전략 패턴, 팩토리 패턴 등을 활용하여 OCP를 지키는 방법이 있습니다. 이러한 패턴은 새로운 기능을 추가할 때 기존 코드 수정을 최소화하고 확장성을 높일 수 있도록 도와줍니다.
OCP를 준수하면 코드의 재사용성과 유연성을 높일 수 있으며, 새로운 기능 추가 시 코드 변경으로 인한 부작용을 최소화할 수 있습니다.
// 추상화를 통한 확장 포인트 제공 public interface IShape { void Draw(); } // 기존 코드 수정 없이 새로운 기능 추가 public class Circle : IShape { public void Draw() { Console.WriteLine("Drawing a circle"); } } public class Square : IShape { public void Draw() { Console.WriteLine("Drawing a square"); } } // 새로운 기능을 추가하기 위해 확장 public class Triangle : IShape { public void Draw() { Console.WriteLine("Drawing a triangle"); } }
위 코드에서는
IShape
인터페이스를 통해 도형을 추상화하고, 이를 구현하는 클래스들을 만듭니다. 새로운 기능을 추가할 때는 기존 코드를 수정하지 않고IShape
인터페이스를 구현하는 새로운 클래스를 추가함으로써 확장합니다.
public class DrawingTool { public void DrawCircle() { Console.WriteLine("Drawing a circle"); } public void DrawSquare() { Console.WriteLine("Drawing a square"); } } // 새로운 기능 추가 시 기존 코드를 수정해야 함 public class DrawingToolExtended : DrawingTool { public void DrawTriangle() { Console.WriteLine("Drawing a triangle"); } }
위 코드에서는 새로운 기능을 추가할 때 기존 클래스를 상속받아 새로운 메서드를 추가하는 방식을 사용합니다. 이 경우 새로운 기능을 추가할 때마다 기존 코드를 수정해야 하므로 OCP를 위반하게 됩니다. 또한 이러한 방식은 클래스 간의 결합도가 높아져 유지보수가 어려워질 수 있습니다.
리스코프 치환 원칙(Liskov Substitution Principle, LSP)은 객체 지향 프로그래밍에서 중요한 원칙 중 하나입니다. 이 원칙은 “하위 타입은 그것의 기반(상위) 타입으로 대체될 수 있어야 한다”는 원칙을 나타냅니다. 즉, 어떤 클래스를 사용하는 코드는 그 클래스의 하위 클래스를 사용하더라도 동일하게 작동해야합니다.
- 하위 클래스는 상위 클래스에서 가능한 모든 동작을 수행할 수 있어야 한다.
- 이 원칙은 상속 관계에서 하위 클래스가 상위 클래스와 동일하게 행동하도록 보장하여 프로그램의 논리적 일관성을 유지해야 합니다.
Animal이라는 상위 클래스가 있고, Dog과 Cat이라는 두 개의 하위 클래스가 있다고 가정해봅시다. 이 경우, Animal 클래스를 사용하는 코드는 Dog 또는 Cat 클래스를 사용하더라도 동일하게 작동해야 합니다.
public class Animal { public virtual void Speak() { Debug.Log("The animal speaks."); } } public class Dog : Animal { public override void Speak() { Debug.Log("The dog barks."); } } public class Cat : Animal { public override void Speak() { Debug.Log("The cat meows."); } } public class AnimalTest { public void LetAnimalSpeak(Animal animal) { animal.Speak(); } }
위 코드에서는 LetAnimalSpeak메서드는 Animal 클래스의 인스턴스를 매개변수로 받습니다. 그러나 이 메서드는 Dog 또는 Cat 클래스의 인스턴스를 받아도 동일하게 작동합니다. 이것이 리스코프 치환 원칙을 따르는 예입니다. 이 원칙을 따르면 코드의 유연성과 재사용성이 향상 되며, 클래스 계층 구조의 설계가 개선됩니다.
직사각형과 정사각형의 관계를 생각해봅시다. 정사각형은 직사각형의 특별한 경우이므로, 직사각형 클래스를 상속하여 정사각형 클래스를 만들 수 있을 것 같습니다. 그러나 이 경우 문제가 발생할 수 있습니다. 직사각형 클래스에서 너비와 높이는 독립적으로 설정할 수 있지만, 정사각형 클래스에서는 너비와 높이가 항상 같아야합니다. 따라서 정사각형 클래스는 직사각형 클래스의 모든 기능을 제대로 대체할 수 없으므로, 이 경우는 리스코프 치환 원칙을 어기는 경우이다.
대체 가능한 비유 - 고래는 물에 살지만 아기를 낳기 때문에 어류 클래스가 아니라 포유류 클래스에 속하는 거랑 비슷하다.
해당 경우에는 어류 클래스의 알을 낳는 기능을 상속받지 못했기 때문에 리스코프 치환 원칙을 어기게 되는 것이다.그래서 이와 같이 상속하기 애매한 부분에서는 클래스의 상속 대신에 인터페이스의 상속을 이용해줘야한다.
public interface Size { public void GetShape(); } public enum Habitat // 열거형으로도 속성을 나눠줄 수 있다. { Land, Marine }
'인터페이스 분리 원칙(Interface Segregation Principle, ISP)'을 나타냅니다.
이 원칙은 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다는 원칙을 의미합니다. 즉, 클라이언트에게 필요한 메서드만을 제공해야 하며, 불필요한 메서드는 제거하거나 다른 인터페이스로 분리해야 합니다.예를 들어, ISmartPhone이라는 인터페이스가 있고, 이 인터페이스에는 여러 가지 스마트폰 기능을 정의하는 여러 메서드가 있다고 가정해 봅시다. 그런데 이 중 일부 기능은 특정 스마트폰 모델에서만 지원되는 기능일 수 있습니다. 이런 경우, 모든 스마트폰 모델이 ISmartPhone 인터페이스의 모든 메서드를 구현해야 하는 것은 비효율적입니다.
해당 원칙을 지킨다면
1. 코드의 복잡성 감소
2. 유지 관리가 용이
3. 코드의 재사용성이 향상
4. 클래스와 인터페이스의 설계가 개선
public interface IBasicPhone { void Call(string number); void SendText(string number, string text); } public interface IAdvancedPhone : IBasicPhone { void UseWirelessCharging(); void UseBiometrics(); } public class BasicPhoneModel : IBasicPhone { public void Call(string number) { /* 구현 */ } public void SendText(string number, string text) { /* 구현 */ } } public class AdvancedPhoneModel : IAdvancedPhone { public void Call(string number) { /* 구현 */ } public void SendText(string number, string text) { /* 구현 */ } public void UseWirelessCharging() { /* 구현 */ } public void UseBiometrics() { /* 구현 */ } }
이 코드에서 BasicPhoneModel은 기본적인 전화와 문자 메시지 기능만을 지원하므로 IBasicPhone 인터페이스만을 구현하고, AdvancedPhoneModel은 추가적인 무선 충전과 생체 인식 기능을 지원하므로 IAdvancedPhone 인터페이스를 구현합니다. 이렇게 하면 각 스마트폰 모델은 자신이 지원하는 기능을 정의하는 인터페이스만을 구현하게 되므로, 인터페이스 분리 원칙을 준수하게 됩니다
의존 역전 원칙(Dependency Inversion Principle, DIP)
- 상위 모듈은 하위 모듈에 의존해서는 안됩니다. 둘 다 추상화에 의존해야합니다.
- 추상화는 세부 사항에 의해 의존해서는 안됩니다. 세부 사항이 추상화에 의존해야합니다.
이 원칙은 소프트웨어 구조의 결합도를 줄이는데 중요한 역할을 합니다. 이를 통해 소프트웨어의 유지 보수성과 확장성이 향상됩니다. 이 원칙을 따르면, 시스템의 각 부분을 독립적으로 개발하고 변경할 수 있으므로, 전체 시스템에 대한 영향을 최소화할 수 있습니다.
public interface IWeapon { void Use(); } public class Player { private IWeapon _weapon; public Player(IWeapon weapon) { _weapon = weapon; } public void Attack() { _weapon.Use(); } } public class Sword : IWeapon { public void Use() { // 공격 로직 } }
이 예시에서는 Player 클래스가 IWeapon이라는 인터페이스에 의존하고 있습니다. 이로 인해 Player는 Sword 뿐만 아니라 IWeapon 인터페이스를 구현하는 어떤 무기와도 작동할 수 있습니다. 이렇게 하면 Player 클래스를 변경하지 않고도 다른 무기를 사용할 수 있습니다. 이것이 의존 역전 원칙을 지키는 방법입니다. 이 원칙을 따르면 코드의 유연성과 재사용성이 향상됩니다.
public class Player { private Sword _sword; public Player() { _sword = new Sword(); } public void Attack() { _sword.Swing(); } } public class Sword { public void Swing() { // 공격 로직 } }
위의 코드에서 Player 클래스는 Sword 클래스에 직접적으로 의존하고 있습니다. 이는 Player가 다른 무기를 사용하려면 Player 클래스를 변경해야함을 의미합니다.
객체 지향 원칙을 얼핏 들어보기만 했지 제대로 이해했다는 생각은 많이 들지 않았다. 오늘 특강을 듣고 다시 이해해보는 과정을 거치니까. 전보다 좀 더 잘 이해하게 되었다.