본문 바로가기

TIL

SOLID

소프트웨어의 가치

  1. 가독성, 커뮤니케이션
    • 개발자는 코드를 통해 의사소통을 함. 읽고 이해할 수 없는 코드는 가치가 없음
  2. 단순성
    • 코드는 단순해야 함.
    • 커뮤니케이션에 도움이 되고, 버그 방지, 미래의 확장 용이
  3. 유연성
    • 기존의 코드를 수정하는 데 최대한 적은 시간을 해야 함.
    • 처음부터 유연성있는 코드를 작성하긴 어렵기 때문에, 이를 위해 많은 경험이 바탕이 되어야 함
    어떻게?
    • 처음엔 단순하게 → 기획 또는 정책의 변경이 발생하면 수정 
    • 고민해야할 부분 : 이 기획/정책의 변경의 원인은 무엇일지, 이러한 변경이 계속 발생 가능한 일 일지 고려해보기
    • 확장성을 고려한 리팩토링을 진행

왜 가독성, 단순성, 유연성이 중요한 가치인 이유는 무엇일까? 가치는 어떤 기준으로 매기는 걸까?

  • 비용과 이익을 기준으로!
  • 비용이란 시간과 돈 → 주된 비용은 개발자들의 시간 = 연봉이기 때문에 고려해야 함
  • 상황에 따라 SOLID를 지키지 않을 수도 있음
  • No Silver Bullet : 모든 문제를 해결하는 최고의 선택(답)은 존재하지 않기 때문에 엔지니어는 현재의 상황에서 우선순위를 계산하고, 그에 맞는 최선의 선택을 해야 함. 다만 이때 최선의 선택을 위해 기준을 세워야 함

SOLID

  • 객체 지향 프로그래밍을 위한 5가지 원칙
  • 객체 지향이란 :프로그램들의 동작을 객체끼리의 상호작용으로 풀어나가는 프로그램
  • 객체란 : 기능과 특성을 가지는 실체
  • 원칙 : 지켜지면 좋은 규칙 → 좋은 코드, 높은 품질의 코드를 쓰기 위해 사용하자
  • 좋은 코드의 기준을 생각하기 위해선 가치와 원칙을 이해야 함
  • → 가치는 원칙보다 높은 수준의 개념. 원칙은 가치를 지키기 위해서 존재해야 함
  Single Responsibility Principle
  단일 책임 원칙
  모든 클래스는 하나의 책임만 가지며, 클래스는 그 책임을 완전히 캡슐화해야 한다.
  Open/Closed Principle
  개방 폐쇄 원칙
  확장 가능하도록 열려있고, 변경에는 닫혀있어야한다.
  Liskov Substitution Principle
  리스코프 치환 원칙
  서브타입은(상속받은) 기본 타입으로 대체가능해야 한다.
  자식 클래스는 부모 클래스 동작(의미)를 바꾸지 않는다.
  Interface Segregation Principle
  인터페이스 분리 원칙
  클라이언트 객체는 사용하지 않는 메소드에 의존하면 안된다.
  각각의 필요한 인터페이스만 남도록 인터페이스를 분리해라.
  Dependency Inversion Principle
  의존관계 역전 원칙
  상위레벨 모듈은 하위레벨 모듈에 의존하면 안된다. 추상화된 인터페이스에 의존해야한다.

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

  • 소프트웨어의 요소(타입, 모듈, 함수 등)는 응집도 있는 하나의 책임을 갖는다. 타입를 변경해야하는 이유는 단지 응집도여야 한다.

높은 응집도, 낮은 결합도를 추구

결합도 : 서로 다른 타입이 얼마나 연관되어있는지 정도 → 결합도는 낮을 수록 좋다
응집도 : 같은 맥락을 공유하는 책임이 하나의 타입에 몰아져있는지 정도

 

  • 모든 클래스는 하나의 책임만 가지며, 클래스는 그 책임을 완전히 캡슐화해야 함
    • 클래스의 수정이유는 단 하나여야함
    • 하나의 클래스는 하나의 책임을 가져야함
    • 하나의 책임이 여러개의 클래스에 나뉘어 있어서도 안됨
    • ex) 디자이너에게 개발 시키지 마라..!!

역할 vs 책임

  • 역할: 자기가 마땅히 하여야 할 맡은 바 직책이나 임무
  • 책임: 맡아서 해야할 임무나 의무 → 변경의 이유! 책임을 잘 이수하냐에 따라 역할이 바뀔 수 있음
  • ex) 엘사가 여왕으로 왕국을 잘 다스리면 여왕 / 잘 다스리지 못하면 폭군

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

  • 소프트웨어 요소(클래스, 모듈, 함수 등) 확장 가능하도록 열려있고, 변경에는 닫혀있어야 함. 새 기능을 추가할 때 변경하지 말고 새 클래스나 함수를 만들어야 함
  • 확장에 열려있다
  •  프로토콜를 준수하는 클래스면 기존의 코드를 수정X → 클래스나 인스턴스가 늘어나도 확장가능
  • → 프로토콜이 프로토콜을 채택하면 수직확장도 가능!
  • 변경에 닫혀있다
  •  연쇄수정이 일어나는 상황을 방지 
  • → 기존 코드의 변경은 버그가 일어날 가능성이 높기 때문, 이에 연달아 테스트 코드의 변경도 필요한 상황이 올 수 도 있음
  • 객체가 변경될 때는 해당 객체만 바꿔도 동작이 잘되면 OCP를 잘 지킨 것
  • 이러한 특성들은 "추상화" 를 통해 이루어질 수 있음
  • ex) ErrorExplainable 프로토콜을 사용함으로 여러종류의 에러를 하나로 묶어서 사용하는 방식도 가능
// OCP 준수하지 않은 경우
class Dog {
    func makeSound() {
        print("멍멍")
    }
}

class Cat {
    func makeSound() {
        print("야옹")
    }
}

class Zoo {
    var dogs: [Dog] = [Dog(), Dog(), Dog()]
    var cats: [Cat] = [Cat(), Cat(), Cat()]
	// var birds: ~~ 수정해야함
    
    func makeAllSounds() {
        dogs.forEach {
            $0.makeSound()
        }
        
        cats.forEach {
            $0.makeSound()
        }
        // birds.forEach~~~~ 수정해야함
    }
}
// OCP 준수하는 경우
protocol Animal {
    func makeSound()
}

class Dog: Animal {
    func makeSound() {
        print("멍멍")
    }
}

class Cat: Animal {
    func makeSound() {
        print("야옹")
    }
}

//class Birds: Animal {~~ 
//Birdsclass를 추가해도 Zoo에서는 변화가 생기지 않음!

class Zoo {
    var animals: [Animal] = []
    
    func makeAllSounds() {
        animals.forEach {
            $0.makeSound()
        }
    }
}
디미터의 법칙(Law of Demeter) : 객체의 속성을 가져오지 말고, 객체가 일하도록 시켜라
기존의 프로토콜에 추가하는 식으로 수정할 경우가 필요하다는 생각이 들 때, 정말 이 프로토콜을 수정해야하는 것이 맞는 건지 다시 생각해보기! 꼭 이 프로토콜 안에 들어가야돼? 새로운 타입을 만들어야하는 거 아냐? 아니면 기능을 호출하는 것을 만들어서.. 책임을 갖고 있는 역할을 만들어서 그 역할이 일을 할 수 있도록 값을 주입하는 데 이때 SRP를 지키고자 각 메소드를 나눠서 파라미터에서 값을 받아서 실제 기능을 동작하는 메소드에 넘기는 역할만 하게끔 한다.. 의존성의 주입을 통해서 OCP를 지킨다

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

  • 서브타입은(상속받은) 기본 타입으로 대체가능해야 한다. 자식 클래스는 부모 클래스 동작(의미)를 바꾸지 않는다.
  • 직사각형-정사각형처럼 자식 클래스는 부모 클래스 동작(의미)를 바꾸지 않는다.
  • 프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야한다.
  • → 부모 클래스의 타입에 자식 클래스의 인스턴스를 넣어도 똑같이 동작(의미)하여야 합니다.
  • → ex) 숨쉬다라는 메소드에 밥을 먹는다는 의미를 넣으면 안된다
  • → 현실에서는 이미 대상이 존재하고 공통점을 찾아서 일반화해서 범주를 만듦. 코드에서 범주를 만드는 것은 범주를 만들고 세부 범주를 만들어가는 식으로 진행된다.

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

  • 클라이언트 객체는 사용하지 않는 메소드에 의존하면 안된다. 각각의 필요한 인터페이스만 남도록 인터페이스를 분리해라.
  • → 프로토콜은 기능구현의 명세이며 약속이다(구현을 강제한다)
  • → 프로토콜이 커지면 그만큼 한번에 요구되는 구현량이 많아진다. 이런 경우 일부 기능만 필요하다면, 나머진 퇴화(텅 빈 구현, 깡통)시키게 된다.
  • 클래스 내에서 사용하지 않는 인터페이스는 구현하지 말아야한다.
  • 클라이언트 객체는 사용하지 않는 메소드에 의존하면 안된다.
  • 인터페이스가 거대해지는 경우 SRP를 어기는 경우가 생길 수 있고, 해당 인터페이스를 채택해서 사용하는 경우 쓰지 않는 메소드가 있어도 넣어야 하는 경우가 발생할 수 있으니 최대한 인터페이스를 분리하는 것을 권장
  • → 인터페이스가 사용되지 않을 기능을 포함한 여러 기능을 가지면 안된다(단일책임을 충족했다는 가정 하)

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

  • 상위레벨 모듈은 하위레벨 모듈에 의존하면 안된다. (둘 다 추상화된 인터페이스에 의존해야한다. 추상화는 구체화에 의존하면 안되고 구체화는 추상화에 의존하면 안된다) → 구체적인 것은 쉽게 변하지만, 추상적인 것은 잘 변하지 않기 때문이다. → 규약, 명세(프로토콜/인터페이스)를 지키면 동작이 가능하게끔
  • UI : 어떤 사용자가 와도 사용할 수 있게끔 인터페이스를 만든 것
  • API : 앱을 어떠한 기기에서도, 어떠한 사용자도 사용가능하게하는 인터페이스
  • 상위 계층이 하위 계층에 의존하는 전통적인 의존관계를 역전시킴으로써 상위 계층이 하위 계층의 구현으로부터 독립되게 할 수 있다.
  • 인터페이스 : 프로토콜, 깡통 클래스, 추상 클래스
  • → 상위계층 : 인스턴스로 갖고 있거나(연관관계), 의존관계
  • 첫째, 상위 모듈은 하위 모듈에 의존해서는 안된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야한다.
  • 둘째, 추상화는 세부 사항에 의존해서는 안된다. 세부사항이 추상화에 의존해야 한다.
  • → 클래스에서 프로토콜을 채택하는 것
  • DIP는 ‘상위와 하위 객체 모두가 동일한 추상화에 의존해야 한다’는 객체 지향적 설계의 대원칙을 따른다.
  • 상위레벨 모듈은 하위레벨 모듈에 의존하면 안된다.
  • 도 모듈은 추상화된 인터페이스(프로토콜)에 의존해야한다.
  • 추상화 된 것은 구체적인 것에 의존하면 안되고, 구체적인 것이 추상화된 것에 의존해야한다.
  • 하위레벨 모듈이 상위레벨 모듈을 참조하는 것은 되지만 상위레벨 모듈이 하위레벨 모듈을 참조하는 것은 안한는게 좋다. 그런 경우는 제너릭이나 Associate를 사용
  • DIP를 만족하면 의존성 주입이라는 기술로 변화를 쉽게 수용할 수 있다.
class APIHandler {
    func request() -> Data {
        return Data(base64Encoded: "This Data")!
    }
}

// LoginService가 상위계층
class LoginService {
    let apiHandler: APIHandler = APIHandler()
    
    func login() {
        let loginData = apiHandler.request()
        print(loginData)
    }
}
// 추상화 -> 프로토콜, 내부구현을 하지 않은 것, 두루뭉실
protocol APIHandlerProtocol {
    func requestAPI() -> Data
}

// 세부상황, 구체화, 실체화 -> 선언, 정의, 구체적으로 설명가능한 것
class LoginService {
    let apiHandler: APIHandlerProtocol
    
    init(apiHandler: APIHandlerProtocol) {
        self.apiHandler = apiHandler
    }
    
    func login() {
        let loginData = apiHandler.requestAPI()
        print(loginData)
    }
}

class LoginAPI: APIHandlerProtocol {
    func requestAPI() -> Data {
        return Data(base64Encoded: "User")!
    }
}

let loginAPI = LoginAPI()
let loginService = LoginService(apiHandler: loginAPI)
loginService.login()

→ 이렇게 작성하게 되면 LoginService는 기존에 APIHandler에 의존하지 않고 추상화 시킨 객체인 APIHandlerProtocol에 의존하게 됩니다. 그렇기 때문에 APIHandlerProtocol의 구현부는 외부에서 변화에 따라 지정해서 지정해주면 되기 때문에 LoginService는 구현부에 상관없이 좀 더 변화에 민감하지 않은 DIP의 원칙을 지킨 프로그램을 설계할 수 있게 됩니다.


SOLID 원칙을 지키지 않았을 때 발생하는 문제점

  1. 경직성 : 하나를 바꿀 때 다른것들도 바꿔야해서 시스템을 변경하기 어려움
  2. 부서지기 쉬움 : 한 부분이 변경되었을 때 다른 한 부분이 영향을 받아서 새로운 오류가 발생 가능
  3. 부동성 : 다른 시스템이 재사용하기 어려움
  4. 점착성 : 제대로 작동하기 어려움
  5. 불필요한 반복 및 복잡성 : 과도한 설계
  6. 불투명성 : 의도를 파악하기 어려운 혼란스러운 표현
  7. 변경과 수정에 용이하게 대응할 수 없음
  8. 수정이 발생할 때마다 어디까지 영향을 미치는지 파악이 어려움

[iOS] SOLID 원칙 in Swift

728x90

'TIL' 카테고리의 다른 글

UICollectionView  (0) 2023.12.15
MarkDown 접기  (0) 2023.12.11
URL Session  (0) 2023.11.30
estimatedItemSize / itemSize  (0) 2023.11.30
UIApplicationDelegate / UISceneDelegate  (0) 2023.11.24