본문 바로가기

OOP

[Swift] SOLID(객체지향설계) 예시로 알아보기

- Reference

https://dongminyoon.tistory.com/49



# SOLID (객체지향설계)

- [정의]
SOLID란 로버트 마틴이 2000년대 초반에 명명한 객체지향 플그래밍 및 설계의 다섯가지 기본원칙을 마이클 패더스가 두문자어 기억술로 소개한것임

- [만들어진이유]
코드의 유지보수와 확장이 쉬운시스템을 만들고자 원칙을 만든것이다.

SOLID원칙들은 소프트웨어 작업에서 프로그래머가 소스코드가 읽기 쉽고 확장하기 쉽게될떄까지 소프트웨어 소스코드를 리펙터링하는것이다.

[원칙의 종류]
S. 단일책임원칙(Single responsibility principle)
한 클래스는 하나의 책임만 가져야한다.

O. 개방폐쇄원칙(Open/closed principle)**
소프트웨어 요소는 확장에는 열려있으나 변경에는닫혀있어야한다.

L 리스코프 치환원칙(liskov substitution principle)
프로그램의 객체는 프로그램의 정확성을 깨드리지 않으면서 하위 타입의 인스턴스로 바꿀수 있어야한다.

I 인터페이스 분리원칙(Interface segregation principle)**
특정 클라이언트를 위한 인터페이스 여러개가 범용 인터페이스 하나보다 낫다.

D 의존관계역전원칙(Dependency inversion principle)
프로그래머는 추상화에 의존해야지, 구체화에 의존하면안된다. 의존성 주입은 이 원칙을 따르는 방법중 하나이다.

- [왜 알아야할까?]
새롭게 어떤 기능이 추가되거나 유지 보수가 되어야할 때 더욱 생산성 있고 유연하게 대처가 가능하다.

# 단일책임원칙

클래스나 함수를 설계할때 , 각단위들은 단 하나의책임만을 가져야한다는 원칙이다. 강아지는 멍멍, 고양이는 야옹을 해야하는데 고양이가 멍멍하면 안되잖아여?

# [나쁜예]

class LoginServiceBadExample {
    func login(id: String, pw: String) {
        let userData = requestLogin()
        let user = decodeUserInform(data: userData)
    }
    
    private func requestLogin() -> Data {
        return Data()
    }
    
    private func decodeUserInform(data: Data) -> user {
        return user()//(name: "", age: 10)
    }
    
    private func saveUserOnDatabse(user: user) {
        
    }
}

# [좋은예]

protocol APIHandlerprotocol {
    func requestLogin() -> Data
}
protocol DecodingHandlerProtocol {
    func decode<T>(from data: Data) -> T
}
protocol DBhandlerProtocol {
    func saveOnDatabase<T>(inform: T)
}

class LoginService {
    let apiHandler: APIHandlerprotocol
    let decodingHandler: DecodingHandlerProtocol
    let dbHandler: DBhandlerProtocol
    
    init(apiHandler: APIHandlerprotocol,
         decodingHandler: DecodingHandlerProtocol,
         dbHandler: DBhandlerProtocol) {
        self.apiHandler = apiHandler
        self.decodingHandler = decodingHandler
        self.dbHandler = dbHandler
    }
    
    func login() {
        let loginData = apiHandler.requestLogin()
        let user: user = decodingHandler.decode(from: loginData)
        dbHandler.saveOnDatabase(inform: user)
    }
}

나쁜예와 비교해서 각각의 DB, Decoder, APIHandler역할을 하는 프로토콜을 만들어주고 각자의 역할만 하는 메소드만들 구현해보았다. 그리고 LoginService에서는 단지 이프로토콜을 활용해서 상효작용만을 하고있따.

이전과 비교해서 확실히 LoginService는 로그인에 관련된 로직만을 다루는데 각각의 모듈들을 활용해서 로그인에 관한 책임만을 가지고 있다는것을 알수있따.

 

# 개방폐쇄원칙

확장에는 열려있으나 변경에는 닫혀있어야한다는 원칙이다.
어떤 기능을 추가할때 기존의 코드는 만지지않고 새로동작하는 기능에 대해서만 코드가 작성아 되어야한다.
이러한 원칙을 지키기 위해서는 다양한 방법들이 존재할 수 있다.

우선 기본적인 예로 프로토콜을 활용하는것이다. 만약 동물의 소리를 내는 동물원이라는 변수가 있는데 여기에 새로운 동물이 추가된 다고 생각을 하면 어떻게 구현했냐에 따라 OCP를 지키느냐 안지키느냐로 나뉠 수 있다.

(위 SRP의 예제도 역시 프로토콜을 이용해서 OCP의 원칙을 잘 지키고 있다. 만약 새롭게 DB, API, Call, Decoding의 로직을 수행하고 싶으면 단지 각각의 프로토콜을 구현하고 있는 객체를 외부에서 주입하면 되기떄문에 새로운 기능에도 변화없이 대응이 가능하게 된다.)

# 나쁜예

class Dog1 {
    func makeSound() {
        print("멍멍")
    }
}

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

class Zoo1 {
    var dogs: [Dog] = [Dog(), Dog(), Dog()]
    var cats: [Cat] = [Cat(), Cat(), Cat()]
    
    func makeAllSounds() {
        dogs.forEach { $0.makeSound() }
        cats.forEach { $0.makeSound() }
    }
}

만일 새로운 동물을 추가한다면 어떻게될까요 ?
여기서 새로운 동물을 정의하고 Zoo에 또 기존 코드를 만져야하고 수정을 해야합니다.
이렇게 되면 OCP원칙을 어기고 설계가 된다는 것입니다.

# 좋은예

protocol Animal {
    func makeSound()
}

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

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

class Zoo {
    var animals: [Animal] = []
    
    func makeAllSounds() {
        animals.forEach { $0.makeSound() }
    }
}
 

이렇게 프로토콜을 활용해서 설계를 진행하게되면 새롭게 동물이 추가되면 기존의 코드는 만지지않고 그저 class
새로운 동물: Animal으로 선언하여 구현만 하면됩니다.
그렇게 되면 Zoo클래스에서는 기존의 코드는 만지지않고 새로운 동물들을 추가하여 함수를 동작할 수 있게될것입니다.

확장에는 열려있지만 수정에는 닫혀있는 OCP를 지키는 코드가되는것이지요.


# 리스코프치환원칙

부모로 동작하는 곳에서 자식을 넣어주어도 대체가 가능해야한다는 원칙입니다.
자식클래스를 구현할떄, 기본적으로 부모 클래스의 기능이나 능력들을 물렵다는다.
여기서 자식클래스는 동작을 할때 부모클래스의 기능들을 제한하면안된다는 뜻입니다.

즉 부모클래스의 타입에 자식클래스의 인스턴스를 넣어도 똑같이 동작하여야합니다.
그렇다면 이번에도 잘못된 예와 올바르게 작성된 예로 알아보자

# 나쁜예

class Rectangle1 {
    var width: Float = 0
    var height: Float = 0
    
    var area: Float {
        return width * height
    }
}

class Square1: Rectangle1 {
    override var width: Float {
        didSet {
            height = width
        }
    }
}
func printArea(of rectangle: Rectangle1) {
    rectangle.height = 3
    rectangle.width = 6
    print(rectangle.area)
}

let rectangle = Rectangle1()
printArea(of: rectangle)
// 18

let square = Square1()
printArea(of: square)
// 36

실제로 정사각형은 직사각형이라고 할 수 있습니다. 이원리에 따라 프로그램으 다음과 같이 설계하면
문제가 생기게됩니다. 바로 정사각형의 넓이를 출력해야할떄 height = width 라는 구문으로 인해
printArea(_: Rectangle)에서 원하는 결과를 얻지 못하게됩니다.

즉 부모의 역할을 자식에서 대신하지못하고 있는 상황이 발생합니다.

# 좋은예

protocol Shape {
    var area: Float { get }
}

class Rectangle: Shape {
    let width: Float
    let height: Float
    
    var area: Float {
        return width * height
    }
    
    init(width: Float,
         height: Float) {
        self.width = width
        self.height = height
    }
}

class Square: Shape {
    let length: Float
    
    var area: Float {
        return length * length
    }
    
    init(lenght: Float) {
        self.length = lenght
    }
}

다음과 같은 방법으로 Rectangle, Square 모두 Shape이라는 protocol을 채택할 수 있게 설계하고 실제 구현부는 채택하는 하위 클래스로 넘기면 LSP의 원칙에 어긋나지 않는 프로그램을 설계할 수 있다.

즉, Shape의 역할을 Square, Rectangle모두가 기존의 룰을 위반하지않고 동작하는 프로그래밍 만들어지게 된다. 이러한 상황을 LSP를 지킨 설계하고 하게된다.


# 인터페이스 분리원칙

인터페이스를 일반화하여 구현하지 않는 인터페이스를 채택하는 것보다 구체적인 인터페이스를 채택하는 것이 더 좋다는 원칙입니다.

인터페이스를 설계할 떄 굳이, 사용하지않는 인터페이스는 채택하여 구현하지 말고 오히려 한가지의 기능만을
가지더라도 정말 사용하는 기능만을 가지는 인터페이스로 분리하라는 것입니다.

# 나쁜예

protocol Shape2 {
    var area: Float { get }
    var length: Float { get }
}

class Square2: Shape {
    var width: Float
    var height: Float
    
    var area: Float {
        return width * height
    }
    
    var length: Float {
        return 0
    }
    
    init(width: Float,
         height: Float) {
        self.width = width
        self.height = height
    }
}

class Line: Shape2 {
    var pointA: Float
    var pointB: Float
    
    var area: Float {
        return 0
    }
    
    var length: Float {
        return pointA - pointB
    }
    
    init(pointA: Float,
         pointB: Float) {
        self.pointA = pointA
        self.pointB = pointB
    }
}

Line, Square 모두 Shape을 상속 받는 객체이지만 실제로 Square는 length라는 변수가 필요가 없고 Line은 are라는 변수가 필요없게 됩니다. 그럼에도 단지 Shape이라는 프로토콜을 채택한다는 이유만으로도 필요없는 기능을 구현하고 있습니다.

# 좋은예

protocol AreaCalculatableShape {
    var area: Float { get }
}

protocol LengthCalculatableShape {
    var length: Float { get }
}

class Square3: AreaCalculatableShape {
    var width: Float
    var height: Float
    
    var area: Float {
        return width * height
    }
    
    init(width: Float,
         height: Float) {
        self.width = width
        self.height = height
    }
}

class Line2: LengthCalculatableShape {
    var pointA: Float
    var pointB: Float
    
    var length: Float {
        return pointA - pointB
    }
    
    init(pointA: Float,
         pointB: Float) {
        self.pointA = pointA
        self.pointB = pointB
    }
}

기존에 필요없는 기능들을 구현하고 있떤 인터페이스들을 더욱 세분화하여 나누어주었습니다.

AreaCalculatableShape, LengthCalculatableShape으로 각각 인터페이스를 세분화시켜 넓이를 구해야하는 Shape에만 AreaCalculatableShape 채택하여 구현하고 길이를 구해야하는 Shape에만
LengthCalculatableShape채택하여 각각을 ISP의 원칙을 지키는 프로그램의 설계가 되었다.



# 의존관계역전 원칙

상위 모듈이 하위 모듈에 의존하면 안되고 두 모듈 모두 추상화에 의존하게 만들어야한다는 원칙이다.

어떤 상위 모듈에서 하위 모듈을 가지고 있을때, 상위 모듈의 기능이 하위모듈에 의존해서 기능을 수행하면 안된다는 뜻이다. 즉, 추상황를 진행하여 각각의들에 더 추상화된 것에 의존하게 만들어야한다느 뜻이다.
이렇게 코드를 설계해야 재사용에도 유용하고 하나를 수정했을때 더욱 수정사항이 많이 없는 훌륭한 프로그램을 설계할 수 있게됩니다.

DIP원칙은 나중에 UnitTest를 진행할때 더욱 중요하게도리 원칙인데 여기서 의존성 주입이라는 용어를 쓸수있을것같습니다. 상위 모듈에 어떤 하위모듈을 사용할때, 상위모듈에서 직접적으로 하위 모듈을 초기화하지않고 외부에서 하위모듈을 초기화할 수 있게 하라는 뜻입니다. 그리고 이 상위모듈, 하위 모듈은 모두 추상화된 객체에 의존할 수 있게해야합니다.

# 나쁜예

class APIHandler {
    func request() -> Data {
        return Data(base64Encoded: "This Data")!
    }
}

class LoginService2 {
    let apiHandler: APIHandler = APIHandler()
    
    func login() {
        let loginData = apiHandler.request()
        print(loginData)
    }
}

현재 상위 모듈인 LoginService가 하위모듈인 APIHandler에 의존하고 있는 관계로 만약 APIHandler의 구현방법이 변화하게되면 프로그램에 영향을 미치게되고 새롭게 LoginService라는 상위모듈을 수정해야하는 상황이 일어날 수 있습니다. 이러한 상황이 DIP의 원칙을 어긴 프로그램의 설계라고 할 수 있다.

이를 수정하기 위해 의존성 주입이라는것을 사용해서 수정해봅시다.

# 좋은예시

protocol APIHandlerProtocol {
    func requestAPI() -> Data
}

class LoginService3 {
    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 = LoginService3(apiHandler: loginAPI)
loginService.login()

상위 계층에 모듈을 의존성 주입하여 사용할때는 추상화시켜서 사용하는것이 유지보수에 유리한점이라는 것을 배울 수 있다.

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

이렇게 외부에서 내부의 변수를 초기화해서 의존관계를 가지는 경우를 의존성 주입이라고 하게되는데 의존성 주입을
추상화시켜서 진행하게되면 더욱 변화에는 안전한 프로그램을 설계할 수 있게된다.

# 결론

모든 원칙을 지키는 것은 불가능에 가깝다고한다. 그래서 한가지씩 지키려고 시도해보고 최대한 프로토콜로 추상화를 시키려고 시도하는것이 중요한것같다.