본문 바로가기
컴퓨터 과학/소프트웨어공학

[소프트웨어공학] SOLID: 객체지향 프로그래밍의 5가지 원칙

by webcodur 2024. 3. 29.
728x90

목차

     

    SOLID?

    SOLID 원칙은 객체 지향 프로그래밍과 소프트웨어 설계에서 코드의 유지보수성, 확장성, 그리고 유연성을 높이기 위한 다섯 가지 기본 원칙을 의미한다. 이 원칙들은 단일 책임 원칙(SRP), 개방-폐쇄 원칙(OCP), 리스코프 치환 원칙(LSP), 인터페이스 분리 원칙(ISP), 그리고 의존성 역전 원칙(DIP)을 포함한다. SOLID 원칙을 따름으로써 소프트웨어는 더욱 견고하고, 유지보수하기 쉬우며, 확장성 있는 구조를 갖출 수 있다.

     
     
    문자 약어 개념
    S SRP 단일 책임 원칙 (Single responsibility principle)
    한 클래스는 하나의 책임만 가져야 한다.
    O OCP 개방-폐쇄 원칙 (Open/closed principle)
    “소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.”
    L LSP 리스코프 치환 원칙 (Liskov substitution principle)
    “자식 클래스는 언제나 자신의 부모 클래스를 대체할 수 있다.” 
    I ISP 인터페이스 분리 원칙 (Interface segregation principle)
    “한 클래스는 자신이 사용하지 않는 인터페이스는 구현하지 말아야 한다”
    D DIP 의존관계 역전 원칙 (Dependency inversion principle)
    “추상화에 의존하되, 구체화에 의존하면 안된다.”
     
     
     
     
     
     

    1. 단일 책임 원칙 (Single Responsibility Principle, SRP)

    "하나의 클래스는 하나의 책임만 가져야 한다"

    • '책임'은 '변경해야 하는 이유'를 의미한다.
    • 즉, 한 클래스가 여러 가지 이유로 변경되어야 한다면, 그 클래스는 여러 책임을 지고 있는 것이므로, SRP를 위반하는 것이다.
    • 단일 책임 원칙은 클래스의 결합도를 낮추고 응집도를 높이는 데 도움을 준다. 클래스가 단 하나의 책임만을 가지면, 해당 클래스는 그 책임과 관련된 변경에만 반응하므로, 시스템의 다른 부분에 대한 의존성이 줄어들고 유지보수가 용이해진다.

    잘못된 사례

    잘못된 사례에서는 하나의 클래스가 여러 책임을 가지고 있다. 예를 들어, 사용자 정보의 업데이트, 로그인, 로그아웃을 모두 하나의 클래스에서 처리하는 경우가 이에 해당한다.

    public class User {
        public void UpdateUserInfo() { /* 사용자 정보 업데이트 로직 */ }
        public void Login() { /* 로그인 처리 로직 */ }
        public void Logout() { /* 로그아웃 처리 로직 */ }
    }
    

    정상적인 사례

    정상적인 사례에서는 각 클래스가 하나의 기능만을 담당한다. 예를 들어, 사용자 정보를 처리하는 클래스와 사용자의 로그인 처리를 담당하는 클래스가 분리되어 있는 경우를 생각해 볼 수 있다.

    public class User {
        public void UpdateUserInfo() { /* 사용자 정보 업데이트 로직 */ }
    }
    
    public class UserAuthentication {
        public void Login() { /* 로그인 처리 로직 */ }
        public void Logout() { /* 로그아웃 처리 로직 */ }
    }
    

     

     

    2. 개방-폐쇄 원칙 (Open/Closed Principle, OCP)

    "소프트웨어 구성요소(클래스, 모듈, 함수 등)는 확장에는 열려 있어야 하지만 변경에는 닫혀 있어야 한다"

    • 기존 코드를 변경하지 않으면서도 시스템의 기능을 확장할 수 있어야 한다는 것을 의미한다.

    '확장에는 열려 있어야 한다'

    • 이는 '클래스의 확장에 열려 있어야 한다' 에 가까운 의미이다
    • 아래 정상적인 사례에서 docType 추가에 대해 열려 있는 모습 참고

    '변경에는 닫혀 있어야 한다'

    • 이는  '메서드의 변경에 닫혀 있어야 한다' 에 가까운 의미이다
    • 아래 잘못된 사례에서 Display 함수가 수정되는 모습 참고

     

    잘못된 사례

    잘못된 사례에서는 새로운 기능을 추가하기 위해 기존 코드를 수정해야 한다. 이는 OCP 원칙을 위반하는 것이다.

    public class Document {
        public void Display(string docType) {
            if (docType == "PDF") {
                // PDF 문서를 표시하는 로직
            } else if (docType == "Word") {
                // Word 문서를 표시하는 로직
            }
            // 새로운 문서 타입을 처리하기 위해 이 메서드를 수정해야 함
        }
    }
    

    새로운 문서 타입을 추가하려면 Display 메서드 내부의 조건문을 변경해야 하므로, 이는 개방-폐쇄 원칙에 위배된다. 기존 코드의 수정 없이 새로운 기능을 추가할 수 있는 방식을 선택함으로써 시스템의 유연성과 확장성을 보장할 수 있다.

    정상적인 사례

    정상적인 사례에서는 새로운 기능을 추가할 때 기존 코드를 수정하지 않고도 확장할 수 있다. 예를 들어, 다양한 타입의 문서를 처리하는 시스템에서 새로운 문서 타입을 추가하고 싶은 경우를 생각해 볼 수 있다.

    public abstract class Document {
        public abstract void Display();
    }
    
    public class PdfDocument : Document {
        public override void Display() { /* PDF 문서를 표시하는 로직 */ }
    }
    
    public class WordDocument : Document {
        public override void Display() { /* Word 문서를 표시하는 로직 */ }
    }
    
    // 새로운 문서 타입을 추가할 때, 기존 코드를 변경하지 않고 새로운 클래스만 추가
    public class ExcelDocument : Document {
        public override void Display() { /* Excel 문서를 표시하는 로직 */ }
    }
    

     

     

     

     

    3. 리스코프 치환 원칙 (Liskov Substitution Principle, LSP)

    "부모 클래스의 인스턴스 대신 자식의 인스턴스를 써도 프로그램의 정확성이 변하지 않아야 한다"

    • 자식 클래스는 부모 클래스의 행위를 올바르게 수행할 수 있어야 하며, 부모 클래스를 대체할 수 있어야 한다는 의미
    • 핵심은 자식 클래스가 부모 클래스의 역할을 완벽하게 대체할 수 있어야 한다는 것.
    • 이는 자식 클래스가 부모 클래스의 인터페이스를 준수하면서도, 기대하는 행위를 정확하게 구현해야 함을 의미한다.
    • 이 원칙을 준수하면, 상속을 사용하는 코드의 유연성이 증가하고, 코드 재사용성이 개선된다.

    잘못된 사례

    잘못된 사례에서는 자식 클래스가 부모 클래스를 적절히 대체하지 못한다. 직사각형-정사각형 문제는 LSP를 위반하는 전형적인 예시다. 정사각형은 직사각형의 특별한 경우이지만, 정사각형 클래스가 직사각형 클래스를 상속받을 경우, 직사각형이 가지는 높이와 너비의 독립성이 깨진다.

    public class Rectangle {
        public virtual int Width { get; set; }
        public virtual int Height { get; set; }
        public int Area() {
            return Width * Height;
        }
    }
    
    public class Square : Rectangle {
        public override int Width {
            get { return base.Width; }
            set { base.Width = base.Height = value; }
        }
        public override int Height {
            get { return base.Height; }
            set { base.Width = base.Height = value; }
        }
    }
    

     

    예상하지 못한 동작은 주로 Square 객체의 너비와 높이를 독립적으로 설정할 수 없다는 점에서 비롯된다. Rectangle 클래스의 인스턴스가 너비와 높이를 각각 다르게 설정할 수 있다고 기대하지만, Square  는 너비와 높이가 항상 같아야 한다는 제약 때문에 이 기대를 충족시키지 못한다.

     

    예를 들어, 사용자가 Rectangle 타입의 객체에 너비 5, 높이 10을 설정하려 한다고 가정하자. Rectangle 의 인스턴스가 사실은 Square 였다면, Square 클래스의 구현에 따라 너비와 높이 중 하나의 값을 설정하면 다른 하나도 같은 값으로 강제되어 버린다. 따라서, Rectangle 로서의 기능을 기대하며 너비와 높이를 독립적으로 설정하려는 시도는 Square 객체에서 의도치 않은 결과를 초래한다.

     

    이는 Square Rectangle 의 대체로 적절하지 않다는 것을 의미하며, 리스코프 치환 원칙을 위반하는 것이다. 상속을 사용하는 설계에서 이러한 문제를 피하려면, 상속보다는 구성(합성)이나 인터페이스를 통한 설계를 고려해야 한다. 상속 구조를 재설계하거나, 두 객체 간의 관계를 다른 방식으로 표현하는 것이 필요하다.

     

    정상적인 사례

    정상적인 사례에서는 자식 클래스가 부모 클래스를 올바르게 대체할 수 있다. 예를 들어, 직사각형과 정사각형의 관계를 생각해 볼 때, 직사각형을 확장하여 정사각형을 구현하는 것이 일반적이지만, 이는 LSP를 위반할 수 있다. 대신, 두 클래스가 공통의 인터페이스를 구현하는 것이 LSP를 준수하는 방법이다.

    public interface IShape {
        int Area();
    }
    
    public class Rectangle : IShape {
        public int Width { get; set; }
        public int Height { get; set; }
        public int Area() {
            return Width * Height;
        }
    }
    
    public class Square : IShape {
        public int Side { get; set; }
        public int Area() {
            return Side * Side;
        }
    }
    

     

     

     

    4. 인터페이스 분리 원칙 (Interface Segregation Principle, ISP)

    “한 클래스는 자신이 사용하지 않는 인터페이스는 구현하지 말아야 한다”

    • 실제 클래스에 사용하지 않을 인터페이스의 기능은 없어야 한다.
    • 인터페이스가 지나치게 광범위하거나 지나치게 많은 기능을 구현해서는 안 된다
    • 인터페이스를 쓸 객체를 기준으로 잘게 분리해야 한다

    잘못된 사례

    // 인터페이스에 여러 기능을 정의
    public interface IMultiFunctionPrinter {
        void Print();
        void Scan();
        void Fax();
    }
    
    // 모든 기능을 구현해야 하는 클래스
    public class MultiFunctionMachine : IMultiFunctionPrinter {
        public void Print() { /* 프린트 기능 구현 */ }
        public void Scan() { /* 스캔 기능 구현 */ }
        public void Fax() { /* 팩스 기능 구현 */ }
    }
    
    // 팩스 기능이 필요 없는 클래스도 IMultiFunctionPrinter 인터페이스를 구현해야 함
    public class SimplePrinter : IMultiFunctionPrinter {
        public void Print() { /* 프린트 기능만 구현 */ }
        public void Scan() { throw new NotImplementedException(); }
        public void Fax() { throw new NotImplementedException(); }
    }
    
        +---------------------+                 
        | IMultiFunctionPrinter|                 
        +---------------------+                 
        | + Print()           |                 
        | + Scan()            |                 
        | + Fax()             |                 
        +----------+----------+                 
                   |                            
           +-------+-------+                    
           |               |                    
    +------+-------+ +------+------------------+            
    | MultiFunctionMachine | | SimplePrinter   |            
    +--------------+------+ +------+-----------+            
    | + Print()    |      | + Print()             |            
    | + Scan()     |      | + Scan() -> Exception |            
    | + Fax()      |      | + Fax() -> Exception  |            
    +--------------+      +-----------------------+            
    
    

     

    정상적인 사례

    // 각 기능을 별도의 인터페이스로 분리
    public interface IPrinter { void Print(); }
    public interface IScanner { void Scan(); }
    public interface IFax { void Fax(); }
    
    // 필요한 기능만 구현하는 클래스
    public class Printer : IPrinter {
        public void Print() { /* 프린트 기능 구현 */ }
    }
    
    public class MultiFunctionMachine : IPrinter, IScanner, IFax {
        public void Print() { /* 프린트 기능 구현 */ }
        public void Scan() { /* 스캔 기능 구현 */ }
        public void Fax() { /* 팩스 기능 구현 */ }
    }
    
    // 스캔 기능만 필요한 경우
    public class Scanner : IScanner {
        public void Scan() { /* 스캔 기능 구현 */ }
    }
    
    +----------+       +----------+       +--------+
    | IPrinter |       | IScanner |       | IFax   |
    +----------+       +----------+       +--------+
    | + Print()|       | + Scan() |       | + Fax()|
    +----+-----+       +----+-----+       +---+----+
         |                 |                |
         |                 |                |
    +----v----+       +----v----+      +----v-----------------+
    | Printer |       | Scanner |      | MultiFunctionMachine |
    +---------+       +---------+      +----------------------+
                                          | + Print()         |
                                          | + Scan()          |
                                          | + Fax()           |
                                          +-------------------+
    
    

     

     

    5. 의존관계 역전 원칙 (Dependency Inversion Principle, DIP)

    "추상화에 의존하되, 구체화에 의존하면 안된다."

    이 원칙은 두 가지 주요 내용을 담고 있다:

    1. 고수준 모듈은 저수준 모듈에 의존해서는 안 된다. 둘 모두 추상화에 의존해야 한다.
    2. 추상화는 세부 사항에 의존해서는 안 된다. 세부 사항이 추상화에 의존해야 한다.

    즉, 의존관계 역전 원칙은 세부 구현이 아닌 인터페이스(추상화)에 의존하도록 설계해야 함을 의미한다. 이 원칙은 유연성과 확장성을 높이며, 코드 간의 결합도를 낮추는 데 기여한다.

     

    DIP의 핵심은 "의존성의 방향을 역전시켜라"이다. 전통적인 프로그래밍에서는 상위 수준의 모듈이 하위 수준의 모듈에 의존한다. 하지만 DIP에서는 이러한 의존성이 추상화로 역전된다. 이를 통해 변경에 더 유연하게 대응할 수 있고, 모듈 간 결합도를 줄일 수 있다.

     

    잘못된 사례

    고수준 모듈(앱 핵심 기능 = 비즈니스 로직)이 저수준 모듈의 구체적인 구현에 직접 의존하는 경우를 생각해보자. 예를 들어, DataManager 클래스가 데이터를 파일 시스템에 저장하는 구체적인 방식(FileStorage)에 직접 의존하는 경우다.

    public class FileStorage {
        public void SaveData(string data) { // 파일 시스템에 데이터 저장}
    }
     
    public class DataManager {
        private FileStorage storage = new FileStorage();
        public void SaveData(string data) {
            storage.SaveData(data);
        }
    }
    

     

    이 경우, DataManagerFileStorage 에 직접적으로 의존하고 있으며, 저장 방식을 변경하고 싶다면 DataManager의 코드를 수정해야 한다. 이는 DIP를 위반하는 것이다.

     

    DataManager
         |
         v
    FileStorage
    

     

    정상 사례

    고수준 모듈과 저수준 모듈이 모두 추상화에 의존하는 경우를 보자. DataManager가 데이터 저장 방식의 추상화(IDataStorage)에 의존하고, 이 추상화를 구현하는 FileStorage 또는 DatabaseStorage를 사용하는 방식이다.

    public interface IDataStorage {
        void SaveData(string data);
    }
    
    public class FileStorage : IDataStorage {
        public void SaveData(string data) {// 파일 시스템에 데이터 저장}
    }
    
    public class DatabaseStorage : IDataStorage {
        public void SaveData(string data) {// 데이터베이스에 데이터 저장}
    }
    
    public class DataManager {
        private IDataStorage storage;
    
        public DataManager(IDataStorage storage) {
            this.storage = storage;
        }
    
        public void SaveData(string data) {
            storage.SaveData(data);
        }
    }
    

     

    이렇게 설계하면, DataManager는 세부 구현(FileStorage, DatabaseStorage)에 의존하지 않고, 추상화된 인터페이스(IDataStorage)에만 의존한다. 이로 인해 새로운 데이터 저장 방식을 추가하거나 변경할 때 DataManager를 수정할 필요가 없어진다. 이 예시는 DIP를 적절히 적용한 정상 사례다.

     

        DataManager
             |
             v
        IDataStorage
         /        \\
        v          v
    FileStorage  DatabaseStorage
    

     

    이런 방식으로, DIP는 고수준 모듈과 저수준 모듈 사이의 직접적인 의존성을 제거하고, 대신 추상화를 통해 둘을 연결한다. 이는 시스템의 유연성을 향상시키고, 변경에 대한 영향을 줄여준다.